From 83f853d278d34da551348248a5d8f2416c983ed5 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 11 Nov 2026 19:21:08 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugins)!:=20=E6=8F=92=E4=BB=B6=20API?= =?UTF-8?q?=20=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95=E4=B8=80=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=EF=BC=8C=E6=89=80=E6=9C=89=E7=89=88=E6=9C=AC=E4=BB=8E?= =?UTF-8?q?=200=20=E8=B5=B7=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件契约 shared/contracts/plugins/v1 原地重写为只保留每个 API 最完整的形态: - pluginAPIVersion、清单 schemaVersion、全部能力版本、协议版本和文档版本都归 0; 版本字段和协商保留,以后从 0 往上迭代。requires 必填,宿主只提供 0。 - serverProcess 和 executableResolution 并入基础契约,声明它们会被拒绝为 plugin.api.unsupported。 - 删除宿主内置运行时(checkRuntimeVersion)和生成器 runtime 字段;工具一律经 requiresTools 声明,未声明时报 plugin.runtime.undeclared。tool 字段暴露 .version、.major、.executable。 - 语言服务器启动计划始终带 sessionPolicy;runtimeRequirements 统一为 kind tool。 - 构建文档(litheBuildModel、litheDependencies、litheTestResults、 litheTestDiscovery、litheBuildConfiguration、litheConfigurationEdit、 diagnostics、modelInputs)、索引、native-ui、bridge、VCS、editorActions、 后端计划版本都为 0。 - 删除旧格式插件清单测试和 fixture(tests/plugins.rs、official-v1、 language-support-v1、runtime-versions),fixture 去掉 -v2 后缀。 - invalid-manifests 的 expectedField 由测试校验。 --- rust/lithe-core/src/build/commands.rs | 24 +- rust/lithe-core/src/build/configuration.rs | 8 +- .../lithe-core/src/build/debug_preparation.rs | 7 +- rust/lithe-core/src/build/invocation.rs | 6 +- rust/lithe-core/src/build/manifest.rs | 145 +- rust/lithe-core/src/build/mod.rs | 1 - rust/lithe-core/src/build/model.rs | 2 +- rust/lithe-core/src/build/model_inputs.rs | 22 +- rust/lithe-core/src/build/outputs.rs | 2 +- rust/lithe-core/src/build/run_preparation.rs | 22 +- rust/lithe-core/src/build/test_discovery.rs | 5 +- rust/lithe-core/src/debug/adapters.rs | 44 +- rust/lithe-core/src/debug/types.rs | 2 +- .../src/lsp/interface/build_provider.rs | 246 +- rust/lithe-core/src/lsp/interface/client.rs | 46 +- rust/lithe-core/src/lsp/interface/engine.rs | 740 ++--- .../interface/engine_function_relay_tests.rs | 54 +- .../src/lsp/interface/keymap_provider.rs | 18 +- rust/lithe-core/src/lsp/interface/mod.rs | 13 + .../src/lsp/interface/plugin_function.rs | 298 +- .../src/lsp/interface/plugin_logging.rs | 31 +- .../src/lsp/interface/toolchain_provider.rs | 32 +- rust/lithe-core/src/lsp/interface/types.rs | 2 +- .../src/lsp/interface/vcs_provider.rs | 34 +- rust/lithe-core/src/plugins/backend.rs | 83 +- rust/lithe-core/src/plugins/capabilities.rs | 196 +- rust/lithe-core/src/plugins/declarative.rs | 926 ++----- rust/lithe-core/src/plugins/editor_actions.rs | 169 +- rust/lithe-core/src/plugins/executables.rs | 12 - rust/lithe-core/src/plugins/grammar.rs | 10 +- rust/lithe-core/src/plugins/host_api.rs | 8 +- rust/lithe-core/src/plugins/keymap_import.rs | 26 +- .../src/plugins/language_server_sessions.rs | 44 +- rust/lithe-core/src/plugins/marketplace.rs | 66 +- rust/lithe-core/src/plugins/mod.rs | 361 +-- rust/lithe-core/src/plugins/native_ui.rs | 4 +- rust/lithe-core/src/plugins/output_links.rs | 6 +- .../src/plugins/plugin_functions.rs | 39 +- rust/lithe-core/src/plugins/settings.rs | 82 +- .../src/plugins/settings_document_tests.rs | 8 +- rust/lithe-core/src/plugins/web_content.rs | 45 +- .../src/plugins/web_content_tests.rs | 4 +- .../lithe-core/src/project_generators/form.rs | 96 +- .../src/project_generators/manifest.rs | 107 +- rust/lithe-core/src/project_generators/mod.rs | 7 +- .../lithe-core/src/project_generators/plan.rs | 24 +- .../src/project_generators/template.rs | 46 +- rust/lithe-core/src/protocol/command.rs | 5 - rust/lithe-core/src/runtime/dispatcher.rs | 5 - .../src/tests/build_configuration.rs | 19 +- rust/lithe-core/src/tests/build_systems.rs | 216 +- .../src/tests/community_build_plugins.rs | 4 + .../src/tests/declarative_plugins.rs | 492 ++-- .../gradle/app-dependencies.json | 2 +- .../gradle/dependencies.json | 2 +- .../community_build_plugins/gradle/model.json | 2 +- .../gradle/plugin.json | 6 +- .../maven/app-dependencies.json | 2 +- .../maven/dependencies.json | 2 +- .../community_build_plugins/maven/model.json | 2 +- .../community_build_plugins/maven/plugin.json | 6 +- .../src/tests/language_server_sessions.rs | 45 +- .../src/tests/marketplace_plugins.rs | 103 +- rust/lithe-core/src/tests/mod.rs | 1 - .../src/tests/plugin_editor_resources.rs | 20 +- rust/lithe-core/src/tests/plugin_run_debug.rs | 4 +- rust/lithe-core/src/tests/plugins.rs | 63 - .../src/tests/project_generators.rs | 91 +- .../src/tests/toolchain_definitions.rs | 84 +- .../src/tests/toolchain_resolution.rs | 3 +- rust/lithe-core/src/tests/vcs.rs | 22 +- rust/lithe-core/src/vcs/manifest.rs | 12 +- rust/lithe-core/src/vcs/model.rs | 8 +- shared/contracts/plugins/v1/README.md | 2419 ++++++++--------- .../backend/backend-workspace.plugin.json | 7 +- .../v1/fixtures/backend/backend.plugin.json | 9 +- .../backend/command-backend.plugin.json | 9 +- .../fixtures/backend/function-protocol.json | 347 +-- .../plugin-functions-caller.plugin.json | 6 +- .../plugin-functions-exporter.plugin.json | 6 +- .../v1/fixtures/backend/plugin-logging.json | 6 +- .../backend/terminal-backend.plugin.json | 7 +- .../build-providers/build-configuration.json | 2 +- .../build-connection-debug-options.json | 2 +- .../build-execution-protocol.json | 2 +- .../build-provider-configuration.plugin.json | 12 +- ...ild-provider-debug-preparation.plugin.json | 14 +- .../build-provider-execution.plugin.json | 12 +- ...build-provider-language-server.plugin.json | 6 +- .../build-provider-preparation.plugin.json | 12 +- .../build-provider-reports.plugin.json | 12 +- .../build-provider-run-options.plugin.json | 12 +- ...build-provider-run-preparation.plugin.json | 12 +- .../build-provider-selectors.plugin.json | 12 +- .../build-provider-tests.plugin.json | 12 +- .../build-provider.plugin.json | 7 +- .../build-providers/build-run-options.json | 2 +- .../build-providers/build-test-discovery.json | 2 +- .../build-command-output-file.plugin.json | 6 +- .../build-command-stdout.plugin.json | 6 +- .../build-instance-options.plugin.json | 6 +- .../build-model-consumer.plugin.json | 14 +- .../build-model-producer.plugin.json | 14 +- .../fixtures/build-systems/build-plans.json | 8 +- .../build-static-file.plugin.json | 6 +- .../build-systems/build-tool-path.plugin.json | 9 +- .../build-toolchains.plugin.json | 7 +- .../debug-adapter-features.plugin.json | 6 +- .../debug-adapter-language-server.plugin.json | 6 +- .../debug-adapter-rejections.json | 64 +- .../debug-adapter-remote-attach.plugin.json | 6 +- .../debug-adapters/debug-adapter.plugin.json | 6 +- ...or-actions-v2.json => editor-actions.json} | 2 +- .../editor/editor-actions.plugin.json | 7 +- .../editor/editor-providers.plugin.json | 7 +- .../keymap-import-provider.plugin.json | 6 +- .../v1/fixtures/languages/command-plans.json | 424 +-- .../languages/language-grammar.plugin.json | 6 +- .../language-packaged-server.plugin.json | 10 +- ...language-server-backend-launch.plugin.json | 14 +- .../language-server-launch-resolution.json | 2 +- .../language-system-command.plugin.json | 10 +- .../languages/node-runtime.plugin.json | 10 +- .../fixtures/languages/runtime-versions.json | 16 - .../fixtures/manifest/hello-lithe.plugin.json | 7 +- .../fixtures/manifest/invalid-manifests.json | 163 +- .../v1/fixtures/marketplace/index-cases.json | 36 +- .../v1/fixtures/marketplace/index.json | 28 +- .../fixtures/marketplace/install-reviews.json | 63 +- .../marketplace/invalid-index-entries.json | 8 +- .../publisher-bundled.plugin.json | 7 +- .../project-generator-tool-field.plugin.json | 6 +- .../project-generator.plugin.json | 10 +- .../project-generators/project-plans.json | 111 +- .../run/tool-reference-launch.plugin.json | 18 +- ...gs-v2.plugin.json => settings.plugin.json} | 19 +- .../toolchains/tool-definition.plugin.json | 7 +- .../toolchains/toolchain-provider.plugin.json | 9 +- .../fixtures/ui/native-controls.plugin.json | 14 +- .../v1/fixtures/ui/native-form.plugin.json | 14 +- .../v1/fixtures/ui/native-panel.plugin.json | 14 +- .../v1/fixtures/ui/web-panel.plugin.json | 6 +- .../v1/fixtures/vcs/vcs-provider.plugin.json | 7 +- .../plugins/v1/native-ui.schema.json | 4 +- .../plugins/v1/plugin-index.schema.json | 22 +- .../plugins/v1/plugin-manifest.schema.json | 236 +- .../contracts/plugins/v1/toolchains/README.md | 52 +- shared/contracts/rust-core-api.md | 225 +- shared/contracts/vcs-provider-v1.md | 47 +- .../keymap/provider-negotiation-v1.json | 18 +- .../fixtures/plugins/language-support-v1.json | 44 - shared/fixtures/plugins/official-v1.json | 6 - shared/fixtures/plugins/settings-actions.json | 24 +- .../fixtures/plugins/settings-documents.json | 11 +- .../fixtures/plugins/settings-surfaces.json | 162 +- shared/fixtures/vcs/model-v1.json | 54 +- shared/logging/README.md | 2 +- 157 files changed, 3988 insertions(+), 6487 deletions(-) delete mode 100644 rust/lithe-core/src/tests/plugins.rs rename shared/contracts/plugins/v1/fixtures/editor/{editor-actions-v2.json => editor-actions.json} (89%) delete mode 100644 shared/contracts/plugins/v1/fixtures/languages/runtime-versions.json rename shared/contracts/plugins/v1/fixtures/settings/{settings-v2.plugin.json => settings.plugin.json} (92%) delete mode 100644 shared/fixtures/plugins/language-support-v1.json delete mode 100644 shared/fixtures/plugins/official-v1.json diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index a63bdb1d..afae0260 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -213,16 +213,8 @@ pub(crate) fn build_prepare_run_plan_command(payload: Value) -> Result Result crate::plugins::declarative::PluginRejection { /// Shared by snapshot decoding and format-independent edit planning. pub(crate) fn validate(snapshot: &ConfigurationSnapshot) -> Validation<()> { if snapshot.format != "litheBuildConfiguration" - || snapshot.version != 1 + || snapshot.version != CONFIGURATION_VERSION || snapshot.sources.len() > MAX_SOURCES { return Err(invalid("Unsupported or oversized native configuration.")); @@ -407,7 +411,7 @@ pub(crate) fn decode_edit( let receipt: EditReceipt = serde_json::from_value(request.result.clone()) .map_err(|_| invalid("Invalid configuration edit receipt."))?; if receipt.format != "litheConfigurationEdit" - || receipt.version != 1 + || receipt.version != CONFIGURATION_VERSION || receipt.source_id != source.id || !revision(&receipt.revision) || receipt.changed && (receipt.revision == "missing" || changed.is_empty()) diff --git a/rust/lithe-core/src/build/debug_preparation.rs b/rust/lithe-core/src/build/debug_preparation.rs index 4c3de32e..49e412eb 100644 --- a/rust/lithe-core/src/build/debug_preparation.rs +++ b/rust/lithe-core/src/build/debug_preparation.rs @@ -63,7 +63,12 @@ pub(crate) fn plan( build: &BuildSystemDeclaration, request: &PrepareRunRequest, ) -> Validation { - let mut value = run_preparation::plan(build, request, 5)?; + if request.launch_options.is_some() { + return Err(plans::invalid_selection( + "Launch options apply only to running a target.", + )); + } + let mut value = run_preparation::plan(build, request)?; if !build.run.iter().any(|run| { run.id == request.run && run diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 100df2ee..0a9e3c74 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -128,17 +128,13 @@ pub(crate) fn invocation_plan( }) .unwrap_or(request.platform); let invocation = &build_system.invocation; - let mut executables = candidates( + let executables = candidates( &invocation.executables, &root, platform, manifest, &request.settings, )?; - // Published v2 preview manifests adapt into the same candidate path. - if let Some(id) = &invocation.executable_setting { - executables.insert(0, json!({ "kind": "setting", "settingID": id, "path": manifest.executable_setting_value(id, &request.settings)? })); - } if executables.is_empty() { return Err(PluginRejection::new( "plugin.build.unavailable", diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 505eeaf1..53cace3d 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -1,4 +1,4 @@ -//! `buildSystems` declarations of API level 3 plugins and their validation. +//! `buildSystems` declarations and their validation. //! //! A declaration is data only. Everything a community plugin needs is //! expressible here: any command or packaged script as the executable, any @@ -23,8 +23,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; -/// First plugin API level with `buildSystems`. -pub(crate) const BUILD_SYSTEMS_API_VERSION: u32 = 3; /// Host integrations a build system may name. The Core implements none: /// every build tool is described by its plugin's data and backend, so any /// name is refused with `plugin.integration.unsupported`. @@ -200,10 +198,6 @@ pub(crate) enum Nesting { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Invocation { - /// Compatibility adapter for published buildSystems 2 previews. New - /// plugins declare a setting candidate in executables instead. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) executable_setting: Option, /// Candidates in preference order; the host takes the first that exists. pub(crate) executables: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -979,13 +973,6 @@ fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> V /// against. pub(crate) struct DeclarationContext<'a> { pub(crate) debug_adapters: &'a [crate::debug::adapters::DebugAdapterDeclaration], - pub(crate) plugin_api_version: u32, - pub(crate) build_systems_version: u32, - /// Version 2 binds candidates to the host's canonical project toolchains. - pub(crate) executable_resolution_version: u32, - /// Admission version for provider execution; independent of wire negotiation. - pub(crate) build_providers_version: u32, - pub(crate) model_inputs_version: u32, pub(crate) official: bool, /// Setting IDs a provider's launch templates may name. pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, @@ -1002,12 +989,6 @@ pub(crate) fn validate_declarations( if build_systems.is_empty() { return Ok(()); } - if context.plugin_api_version < BUILD_SYSTEMS_API_VERSION { - return Err(invalid( - "buildSystems", - "Build systems require pluginAPIVersion 3.", - )); - } if build_systems.len() > MAX_BUILD_SYSTEMS { return Err(invalid( "buildSystems", @@ -1264,15 +1245,6 @@ impl BuildSystemDeclaration { matches!(self.provider, Some(ProviderDeclaration::Process(_))) } - /// Whether a runtime dependency names a tool definition by its ID, which - /// plans turn into a toolchain candidate. - pub(crate) fn names_tool_definitions(&self) -> bool { - self.invocation - .runtime_dependencies - .iter() - .any(RuntimeDependency::names_tool_definition) - } - fn validate(&mut self, field: &str, context: &DeclarationContext<'_>) -> Validation<()> { if !is_entry_identifier(&self.id) { return Err(invalid( @@ -1291,15 +1263,10 @@ impl BuildSystemDeclaration { self.validate_integration(field, context.official)?; self.validate_provider(field, context)?; self.validate_detection(field)?; - let option_ids = self.validate_options(field, context.build_systems_version)?; - super::model_inputs::validate_declaration( - self, - context.model_inputs_version, - context.build_providers_version, - )?; + let option_ids = self.validate_options(field)?; + super::model_inputs::validate_declaration(self)?; self.validate_invocation(field, &option_ids, context)?; - let model_templates = - self.validate_tasks(field, &option_ids, context.build_systems_version)?; + let model_templates = self.validate_tasks(field, &option_ids)?; self.validate_model(field, &option_ids)?; self.validate_run(field, &option_ids, &model_templates)?; self.validate_test(field, &option_ids)?; @@ -1379,13 +1346,10 @@ impl BuildSystemDeclaration { } return Ok(()); }; - if self.configuration.is_some() - && (context.build_providers_version < 7 - || !matches!(provider, ProviderDeclaration::Process(_))) - { + if self.configuration.is_some() && !matches!(provider, ProviderDeclaration::Process(_)) { return Err(invalid( &field, - "Native configuration requires buildProviders 7 and an owned provider process.", + "Native configuration requires an owned provider process.", )); } if !uses { @@ -1397,60 +1361,17 @@ impl BuildSystemDeclaration { for run in &self.run { if let Some(debug) = &run.debug { match (&debug.adapter, &debug.configuration_type) { - (Some(_), None) if context.build_providers_version >= 5 => { + (Some(_), None) => { super::debug_preparation::adapter(run, context.debug_adapters)?; } - (None, Some(kind)) if context.build_providers_version >= 11 - && super::run_preparation::valid_configuration_type(kind) - && run.execution == Some(BuildExecution::Service) => {} + (None, Some(kind)) + if super::run_preparation::valid_configuration_type(kind) + && run.execution == Some(BuildExecution::Service) => {} _ => return Err(invalid(&field, - "Debug requires a same-package native adapter (buildProviders 5), or one registry configurationType for a service (buildProviders 11).")), + "Debug requires a same-package native adapter, or one registry configurationType for a service.")), } } } - if matches!( - self.test.as_ref().and_then(|test| test.results.as_ref()), - Some(TestResultsSource::Provider) - ) && context.build_providers_version < 8 - { - return Err(invalid( - &field, - "Provider-owned test reports require buildProviders 8.", - )); - } - if self - .test - .as_ref() - .is_some_and(|test| !test.selector_kinds.is_empty()) - && context.build_providers_version < 9 - { - return Err(invalid( - &field, - "Opaque test selectors require buildProviders 9.", - )); - } - if provider_tests && context.build_providers_version < 3 { - return Err(invalid(&field, "Provider tests require buildProviders 3.")); - } - if context.build_providers_version < 10 - && self.run.iter().any(|run| { - run.source == TaskSource::Provider - && run - .execution - .is_some_and(|execution| execution != BuildExecution::Application) - }) - { - return Err(invalid( - &field, - "Provider service/task preparation requires buildProviders 10.", - )); - } - if provider_runs && context.build_providers_version < 4 { - return Err(invalid( - &field, - "Provider run preparation requires buildProviders 4.", - )); - } if provider_runs || provider_tests || self @@ -1458,9 +1379,6 @@ impl BuildSystemDeclaration { .iter() .any(|task| task.source == TaskSource::Provider) { - if context.build_providers_version < 2 { - return Err(invalid(&field, "Provider tasks require buildProviders 2.")); - } if !matches!(provider, ProviderDeclaration::Process(_)) { return Err(invalid( &field, @@ -1480,7 +1398,6 @@ impl BuildSystemDeclaration { } ProviderDeclaration::Process(own) => own.process.validate( &field, - context.plugin_api_version, context.declared_settings, ServerRole::BuildProvider, context.runtime_tools, @@ -1514,7 +1431,7 @@ impl BuildSystemDeclaration { Ok(()) } - fn validate_options(&mut self, field: &str, version: u32) -> Validation> { + fn validate_options(&mut self, field: &str) -> Validation> { if self.options.len() > MAX_LIST { return Err(invalid(format!("{field}.options"), "Too many options.")); } @@ -1522,12 +1439,6 @@ impl BuildSystemDeclaration { for (index, option) in self.options.iter_mut().enumerate() { let field = format!("{field}.options[{index}]"); option.validate(&field)?; - if option.scope == OptionScope::Instance && version < 3 { - return Err(invalid( - format!("{field}.scope"), - "Instance options require buildSystems 3.", - )); - } if !ids.insert(option.id.clone()) { return Err(invalid( format!("{field}.id"), @@ -1546,21 +1457,6 @@ impl BuildSystemDeclaration { ) -> Validation<()> { let invocation = &mut self.invocation; let field = format!("{field}.invocation"); - if let Some(id) = &invocation.executable_setting { - if invocation - .executables - .iter() - .any(ExecutableCandidate::uses_resolution) - { - return Err(invalid(&field, "Do not combine the legacy executableSetting adapter with new candidate metadata or setting sources.")); - } - if context.build_systems_version < 2 || !context.declared_settings.is_executable(id) { - return Err(invalid( - format!("{field}.executableSetting"), - "An executable setting requires buildSystems 2 and a global file setting with effect reload or nextRun.", - )); - } - } if invocation.environment_inputs.len() > MAX_LIST { return Err(invalid( format!("{field}.environmentInputs"), @@ -1609,8 +1505,7 @@ impl BuildSystemDeclaration { && matches!( runtime.executables[0].source, ExecutableSource::Toolchain { .. } - ) - && context.executable_resolution_version >= 2; + ); if (!runtime.executables.is_empty() && !toolchain) || runtime.version.is_some() { return Err(invalid( field, @@ -1705,14 +1600,6 @@ impl BuildSystemDeclaration { { crate::plugins::executables::ordered_indices(list, &location)?; for candidate in list { - if matches!(candidate.source, ExecutableSource::Toolchain { .. }) - && context.executable_resolution_version < 2 - { - return Err(invalid( - &location, - "Toolchain candidates require executableResolution 2.", - )); - } if let ExecutableSource::Setting { setting_id } = &candidate.source { if !context.declared_settings.is_executable(setting_id) { return Err(invalid(&location, "A setting candidate needs a global file setting with effect reload or nextRun.")); @@ -1752,7 +1639,6 @@ impl BuildSystemDeclaration { &mut self, field: &str, options: &BTreeSet, - capability_version: u32, ) -> Validation> { if self.tasks.len() > MAX_TASKS { return Err(invalid(format!("{field}.tasks"), "Too many tasks.")); @@ -1763,8 +1649,7 @@ impl BuildSystemDeclaration { for (index, task) in self.tasks.iter_mut().enumerate() { let field = format!("{field}.tasks[{index}]"); if let Some(preparation) = &mut task.preparation { - if capability_version < 4 - || !is_entry_identifier(&preparation.purpose) + if !is_entry_identifier(&preparation.purpose) || preparation.source_roles.is_empty() || preparation.source_roles.len() > 6 || task.from_model @@ -1784,7 +1669,7 @@ impl BuildSystemDeclaration { }) { return Err(invalid(format!("{field}.preparation"), - "Preparation requires buildSystems 4, a project task without prompts, and unique purpose/source-role bindings.")); + "Preparation requires a project task without prompts and unique purpose/source-role bindings.")); } preparation.source_roles.sort(); } diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index 6a5ebb41..fc32e86b 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -38,7 +38,6 @@ mod test_outcome; mod test_outcome_merge; pub(crate) use commands::*; -pub(crate) use invocation::host_runtime_version_arguments; pub(crate) use manifest::{ activation_event, programs, validate_declarations, BuildSystemDeclaration, DeclarationContext, ProviderProcess, diff --git a/rust/lithe-core/src/build/model.rs b/rust/lithe-core/src/build/model.rs index 003cdd95..dff0fd39 100644 --- a/rust/lithe-core/src/build/model.rs +++ b/rust/lithe-core/src/build/model.rs @@ -18,7 +18,7 @@ use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; /// Version of the canonical `litheBuildModel` format. -pub(crate) const CANONICAL_MODEL_VERSION: u32 = 1; +pub(crate) const CANONICAL_MODEL_VERSION: u32 = 0; pub(crate) const MAX_PROJECTS: usize = 2_000; const MAX_ITEMS: usize = 10_000; const MAX_NAME: usize = 256; diff --git a/rust/lithe-core/src/build/model_inputs.rs b/rust/lithe-core/src/build/model_inputs.rs index 9df8292d..a47d6cbf 100644 --- a/rust/lithe-core/src/build/model_inputs.rs +++ b/rust/lithe-core/src/build/model_inputs.rs @@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; +/// Version of the `modelInputs` snapshot Core sends to a provider. +const SNAPSHOT_VERSION: u64 = 0; + const MAX_INPUTS: usize = 8; const MAX_INSTANCES: usize = 64; const MAX_BYTES: usize = 8 * 1024 * 1024; @@ -34,26 +37,21 @@ pub(crate) struct ModelInput { pub(crate) build_system_id: String, } -/// Enforces explicit publication/consumption and independent wire admission. -pub(crate) fn validate_declaration( - build: &mut BuildSystemDeclaration, - version: u32, - provider_version: u32, -) -> Validation<()> { +/// Enforces explicit publication and consumption of shared build models. +pub(crate) fn validate_declaration(build: &mut BuildSystemDeclaration) -> Validation<()> { let invalid = || { - plans::invalid_selection("Model sharing requires buildModelInputs 1; consumers need a provider and buildProviders 6, exact unique producers, and declared shared option IDs; producers cannot also consume inputs.") + plans::invalid_selection("Model sharing needs a model; consumers need a provider, exact unique producers, and declared shared option IDs; producers cannot also consume inputs.") }; if build.model_sharing.is_none() && build.model_inputs.is_empty() { return Ok(()); } - if version != 1 - || (build.model_sharing.is_some() && !build.model_inputs.is_empty()) + if (build.model_sharing.is_some() && !build.model_inputs.is_empty()) || build.model.is_none() || build.model_inputs.len() > MAX_INPUTS { return Err(invalid()); } - if !build.model_inputs.is_empty() && (build.provider.is_none() || provider_version < 6) { + if !build.model_inputs.is_empty() && build.provider.is_none() { return Err(invalid()); } let mut seen = BTreeSet::new(); @@ -208,7 +206,7 @@ pub(crate) fn resolve( } inputs.push(value); } - let value = json!({"version":1,"revision":request.revision,"inputs":inputs}); + let value = json!({"version":SNAPSHOT_VERSION,"revision":request.revision,"inputs":inputs}); if serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len()) > MAX_BYTES { return Err(invalid()); } @@ -322,7 +320,7 @@ pub(crate) fn check_snapshot(consumer: &BuildSystemDeclaration, value: &Value) - .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))) } if !keys(value, &["version", "revision", "inputs"]) - || value["version"] != 1 + || value["version"] != SNAPSHOT_VERSION || value["revision"] .as_u64() .is_none_or(|revision| revision > 9_007_199_254_740_991) diff --git a/rust/lithe-core/src/build/outputs.rs b/rust/lithe-core/src/build/outputs.rs index 5455dbe3..e2bcbf25 100644 --- a/rust/lithe-core/src/build/outputs.rs +++ b/rust/lithe-core/src/build/outputs.rs @@ -36,7 +36,7 @@ const MAX_MESSAGE: usize = 2_000; const MAX_SYNC_FILES: usize = 10_000; /// Version of the canonical `litheTestResults` and `litheDependencies` /// documents. -const CANONICAL_VERSION: u32 = 1; +const CANONICAL_VERSION: u32 = 0; impl From for Format { fn from(decoder: DocumentDecoder) -> Self { diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs index 829bffc3..1c956aaf 100644 --- a/rust/lithe-core/src/build/run_preparation.rs +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -172,7 +172,6 @@ pub(crate) struct PrepareRunRequest { pub(crate) fn plan( build_system: &BuildSystemDeclaration, request: &PrepareRunRequest, - protocol_version: u32, ) -> Validation { let declaration = build_system .run @@ -182,14 +181,9 @@ pub(crate) fn plan( let execution = declaration.execution.unwrap_or(BuildExecution::Application); match &request.launch_options { Some(options) => { - if options.debug.is_some() && protocol_version < 11 { + if options.execution != execution { return Err(plans::invalid_selection( - "Runtime debug options require buildProviders 11.", - )); - } - if protocol_version < 10 || options.execution != execution { - return Err(plans::invalid_selection( - "Launch options require buildProviders 10 and the declared execution kind.", + "Launch options must match the declared execution kind.", )); } options.validate(request.workspace_root.as_deref())?; @@ -281,7 +275,6 @@ pub(crate) struct DecodeRunPreparationRequest { pub(crate) fn decode( build_system: &BuildSystemDeclaration, request: &DecodeRunPreparationRequest, - protocol_version: u32, ) -> Validation { if !build_system .run @@ -291,17 +284,6 @@ pub(crate) fn decode( return Err(plans::unavailable(build_system, "run preparation")); } let invalid = || decode::invalid_output("Invalid provider run preparation result."); - if request.launch_options.is_some() && protocol_version < 10 { - return Err(invalid()); - } - if request - .launch_options - .as_ref() - .is_some_and(|options| options.debug.is_some()) - && protocol_version < 11 - { - return Err(invalid()); - } let value = &request.result; if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES { diff --git a/rust/lithe-core/src/build/test_discovery.rs b/rust/lithe-core/src/build/test_discovery.rs index 385e7e69..5f2a2cc3 100644 --- a/rust/lithe-core/src/build/test_discovery.rs +++ b/rust/lithe-core/src/build/test_discovery.rs @@ -11,6 +11,9 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; +/// Version of the `litheTestDiscovery` document a provider returns. +const DISCOVERY_VERSION: u64 = 0; + const MAX_TESTS: usize = 10_000; /// The plan's configuration and a provider's canonical discovery result. @@ -49,7 +52,7 @@ pub(crate) fn decode( let result = &request.result; if serde_json::to_vec(result).map_or(usize::MAX, |bytes| bytes.len()) > MAX_OUTPUT_BYTES || result["format"] != "litheTestDiscovery" - || result["version"] != 1 + || result["version"] != DISCOVERY_VERSION { return Err(invalid_output("Invalid test discovery document.")); } diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 920918ef..25fe507c 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -81,10 +81,6 @@ const EXECUTABLE_VARIABLES: &[&str] = &[ /// What a candidate may start with, so a lookup stays beside the user's work. const EXECUTABLE_ROOTS: &[&str] = &["${workspaceFolder}/", "${fileDirectory}/"]; -/// Host-known runtimes a `runSelectedRuntime` launch may name below -/// `serverProcess` 4; any other short ID refers to a plugin tool definition. -const SELECTED_RUNTIME_TOOLS: &[&str] = &["node", "python"]; - /// Target kinds a selector may match. const TARGET_KINDS: &[&str] = &["connection", "executable", "file", "process", "runTarget"]; @@ -566,8 +562,8 @@ pub(crate) enum DebugProcess { SelectedRuntime(SelectedRuntimeProcess), } -/// `serverProcess` 2: start the adapter on the runtime Run selected for the -/// target, not on a runtime the plugin names a version of. +/// Start the adapter on the runtime Run selected for the target, not on a +/// runtime the plugin names a version of. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct SelectedRuntimeProcess { @@ -629,19 +625,15 @@ impl DebugProcess { match self { Self::Server(process) => process.validate( field, - context.plugin_api_version, context.declared_settings, ServerRole::DebugAdapter, context.runtime_tools, ), Self::SelectedRuntime(process) => { let tool = process.launch.tool.as_str(); - context.runtime_tools.check( - tool, - SELECTED_RUNTIME_TOOLS, - format!("{field}.launch.tool"), - format!("The host cannot start a debug adapter on {tool}."), - )?; + context + .runtime_tools + .check(tool, format!("{field}.launch.tool"))?; validate_process_arguments( &process.arguments, &process.validation_arguments, @@ -663,7 +655,6 @@ impl DebugProcess { /// What validation needs from the manifest around the declarations. pub(crate) struct DeclarationContext<'a> { - pub(crate) plugin_api_version: u32, pub(crate) declared_settings: &'a DeclaredSettings, /// Language IDs of the plugin's `languageServers[]`. pub(crate) language_servers: &'a BTreeSet, @@ -700,8 +691,7 @@ pub(crate) fn validate_declarations( Ok(()) } -/// Whether any adapter starts on the selected runtime, which is the only use -/// of `serverProcess` 2. +/// Whether any adapter starts on the runtime Run selected. pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> bool { adapters.iter().any(|adapter| { adapter @@ -713,14 +703,6 @@ pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> boo }) } -/// Whether any adapter declares `features` or `defaults`, which need -/// `debugAdapters` 3. -pub(crate) fn uses_declared_features(adapters: &[DebugAdapterDeclaration]) -> bool { - adapters - .iter() - .any(|adapter| adapter.features.is_some() || adapter.defaults.is_some()) -} - /// Whether any adapter serves `attach`, which needs its own permission. pub(crate) fn serves_attach(adapters: &[DebugAdapterDeclaration]) -> bool { adapters @@ -2174,17 +2156,3 @@ fn sort_unique(values: &mut Vec, field: &str) -> Validation<()> { Err(invalid(field, "An entry is listed more than once.")) } } - -/// New bindings have their own manifest admission version. -pub(crate) fn uses_target_arguments(adapters: &[DebugAdapterDeclaration]) -> bool { - adapters - .iter() - .filter_map(|adapter| adapter.configurations.as_ref()) - .any(|configs| { - [configs.launch.as_ref(), configs.attach.as_ref()] - .into_iter() - .flatten() - .filter_map(|config| config.target_bindings.as_ref()) - .any(|bindings| bindings.args.is_some() || bindings.env.is_some()) - }) -} diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index b49a6964..85271b6e 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -43,7 +43,7 @@ pub struct CreateSessionRequest { pub declared: DeclaredAdapterBehavior, } -/// The `features` and `defaults` of a `debugAdapters` 3 declaration. The +/// The `features` and `defaults` of a `debugAdapters` declaration. The /// engine follows them instead of recognizing an adapter by its ID. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index ece0cf4e..19dccbbb 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -2,123 +2,57 @@ //! next to, or instead of, a language server. //! //! The manifest only says how to start the process; the protocol version is -//! negotiated in `initialize` under `capabilities.experimental.litheBuild`. -//! The host offers every version it speaks and the provider answers the one -//! it chose. Build requests are written only to a session that answered a -//! version the host offered, so a shared provider that does not speak the -//! protocol keeps serving its language. The rules live in the plugin contract +//! the `buildProviders` capability version, negotiated in `initialize` under +//! `capabilities.experimental.litheBuild`. The host offers the versions of +//! that capability it implements and the provider answers the version it +//! chose. Build requests are written only to a session that answered a version +//! the host offered, so a shared provider that does not speak the protocol +//! keeps serving its language. The protocol covers models and dependencies, +//! task and test execution with progress and cancellation, opaque test +//! selectors and inline test reports, run and debug preparation, typed run +//! options and runtime debug connections, host-resolved model inputs, and +//! source-backed native configuration. The rules live in the plugin contract //! (`shared/contracts/plugins/v1/README.md`, "Build providers"). +use crate::plugins::capabilities::{versions as capability_versions, BUILD_PROVIDERS}; use serde_json::{json, Value}; /// Key under `capabilities.experimental` on both sides of the exchange. const EXPERIMENTAL_KEY: &str = "litheBuild"; -/// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 11] = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; +/// Build-provider protocol versions this client speaks: the versions of the +/// `buildProviders` capability. +fn supported_versions() -> Vec { + super::offered_versions(capability_versions(BUILD_PROVIDERS)) +} /// Feature name recorded in the session's server capabilities once a -/// provider answered a supported version. +/// provider answered a supported version. Every build method is available to +/// a session that carries it. pub(crate) const BUILD_PROVIDER_FEATURE: &str = "litheBuild"; -/// Version 2 uses the same workspace session and operation lifecycle. +/// Task execution shares the workspace session and operation lifecycle. pub(crate) const EXECUTE_METHOD: &str = "lithe/build/execute"; -/// Version 4 prepares a bounded launch, while the host owns the application. +/// Prepares a bounded launch, while the host owns the application. pub(crate) const PREPARE_RUN_METHOD: &str = "lithe/build/prepareRun"; -/// The negotiated ability to accept host-resolved public model inputs. -pub(crate) const MODEL_INPUTS_FEATURE: &str = "litheBuildModelInputs"; -/// Old backends cannot silently ignore cross-provider context. -pub(crate) fn model_inputs_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 6 | 7 | 8 | 9 | 10 | 11)) -} - -/// Version 7 returns non-sensitive native settings and source/write metadata. +/// Returns non-sensitive native settings and source/write metadata. pub(crate) const CONFIGURATION_METHOD: &str = "lithe/build/configuration"; /// Provider-owned native editing; only a receipt crosses the wire. pub(crate) const EDIT_CONFIGURATION_METHOD: &str = "lithe/build/editConfiguration"; -/// Admission feature keeps native source metadata away from older protocols. -pub(crate) const CONFIGURATION_FEATURE: &str = "litheBuildConfiguration"; -/// Only version 7 can accept source-backed configuration reads. -pub(crate) fn configuration_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 7 | 8 | 9 | 10 | 11)) -} - -/// Version 8 returns canonical reports with the completing test operation. -pub(crate) const TEST_REPORTS_FEATURE: &str = "litheBuildTestReports"; -/// Older providers cannot silently fall back to host-owned report discovery. -pub(crate) fn test_reports_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 8 | 9 | 10 | 11)) -} - -/// Version 9 passes public semantic selectors unchanged to the owning backend. -pub(crate) const TEST_SELECTORS_FEATURE: &str = "litheBuildTestSelectors"; -/// An old backend must never widen a filtered request to all tests. -pub(crate) fn test_selectors_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 9 | 10 | 11)) -} - -/// Admission feature for typed user options and host-owned service/task launches. -pub(crate) const RUN_OPTIONS_FEATURE: &str = "litheBuildRunOptions"; -/// Older sessions cannot silently discard user arguments or launch ownership. -pub(crate) fn run_options_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 10 | 11)) -} - -/// Version 11 accepts a registry-selected adapter and host-owned connection target. -pub(crate) const RUNTIME_DEBUG_FEATURE: &str = "litheBuildRuntimeDebug"; -/// The process agreement, rather than just the manifest, gates debug choices. -pub(crate) fn runtime_debug_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - == Some(11) -} - -/// Version 5 prepares a native debug target, never an adapter process. +/// Prepares a native debug target, never an adapter process. pub(crate) const PREPARE_DEBUG_METHOD: &str = "lithe/build/prepareDebug"; -/// Admission feature for native debug preparation. -pub(crate) const DEBUG_PREPARATION_FEATURE: &str = "litheDebugPreparation"; -/// Debug preparation requires the explicit version 5 agreement. -pub(crate) fn debug_preparation_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 5 | 6 | 7 | 8 | 9 | 10 | 11)) -} - -/// Admission feature for the version 4 method. -pub(crate) const RUN_PREPARATION_FEATURE: &str = "litheRunPreparation"; -/// Version 3 adds tests while retaining the bounded execution lifecycle. +/// Runs tests while retaining the bounded execution lifecycle. pub(crate) const TEST_METHOD: &str = "lithe/build/test"; -/// Version 3 returns exact selectors; decoding remains a separate Core command. +/// Returns exact selectors; decoding remains a separate Core command. pub(crate) const DISCOVER_TESTS_METHOD: &str = "lithe/build/discoverTests"; -/// Feature required before writing a version 3 test request. -pub(crate) const TEST_EXECUTION_FEATURE: &str = "litheBuildTestExecution"; /// Progress never uses caller-chosen host operation IDs. pub(crate) const PROGRESS_METHOD: &str = "lithe/build/progress"; -/// Separate feature so version 1 cannot accidentally execute version 2 tasks. -pub(crate) const EXECUTION_FEATURE: &str = "litheBuildExecution"; -const VERSION_1_METHODS: [&str; 2] = ["lithe/build/model", "lithe/build/dependencies"]; +const MODEL_METHODS: [&str; 2] = ["lithe/build/model", "lithe/build/dependencies"]; /// The client half of the negotiation, placed under `capabilities.experimental`. pub(crate) fn client_experimental_capabilities() -> Value { - json!({ EXPERIMENTAL_KEY: { "versions": SUPPORTED_VERSIONS } }) + json!({ EXPERIMENTAL_KEY: { "versions": supported_versions() } }) } /// Whether the server's `initialize` capabilities chose a version this client @@ -129,12 +63,12 @@ pub(crate) fn negotiated(capabilities: &Value) -> bool { .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY)) .and_then(|answer| answer.get("version")) .and_then(Value::as_u64) - .is_some_and(|version| SUPPORTED_VERSIONS.contains(&version)) + .is_some_and(|version| supported_versions().contains(&version)) } /// Whether `method` is a request of the negotiated protocol. pub(crate) fn is_build_method(method: &str) -> bool { - VERSION_1_METHODS.contains(&method) + MODEL_METHODS.contains(&method) || is_execution_method(method) || method == DISCOVER_TESTS_METHOD || method == PREPARE_RUN_METHOD @@ -148,35 +82,11 @@ pub(crate) fn is_execution_method(method: &str) -> bool { matches!(method, EXECUTE_METHOD | TEST_METHOD) } -/// True only for the execution protocol, not merely any negotiated provider. -pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) -} - -/// Tests require an explicit version 3 agreement, independent of task support. -pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) -} - /// A preparation has progress and cancellation, but its reply is a launch plan. pub(crate) fn is_progress_method(method: &str) -> bool { is_execution_method(method) || method == PREPARE_RUN_METHOD || method == PREPARE_DEBUG_METHOD } -/// Run preparation requires an explicit version 4 agreement. -pub(crate) fn run_preparation_negotiated(capabilities: &Value) -> bool { - capabilities - .pointer("/experimental/litheBuild/version") - .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) -} - /// Normalizes bounded progress; the engine checks the pending request identity. pub(crate) fn progress(params: Option<&Value>) -> Option { let params = params?.as_object()?; @@ -199,10 +109,7 @@ pub(crate) fn progress(params: Option<&Value>) -> Option { /// Stable outcome for a provider-owned task. Tool failure is an exit code, /// while malformed replies and JSON-RPC errors are protocol failures. -pub(crate) fn execution_outcome( - reply: &Value, - reports_negotiated: bool, -) -> Result { +pub(crate) fn execution_outcome(reply: &Value) -> Result { if reply.get("error").is_some() { return Err(( "plugin.build.failed", @@ -232,8 +139,7 @@ pub(crate) fn execution_outcome( .ok_or_else(|| invalid.clone())?; let mut result = json!({ "exitCode": exit, "output": output }); if let Some(reports) = value.get("testResults") { - if !reports_negotiated - || !reports.is_object() + if !reports.is_object() || serde_json::to_vec(reports).map_or(usize::MAX, |value| value.len()) > 8 * 1024 * 1024 { return Err(invalid); @@ -250,7 +156,7 @@ mod tests { #[test] fn only_an_offered_version_counts_as_negotiated() { assert!(negotiated( - &json!({ "experimental": { "litheBuild": { "version": 1, "future": true } } }) + &json!({ "experimental": { "litheBuild": { "version": 0, "future": true } } }) )); // No answer, a version the host never offered, or a malformed answer // must keep build requests off the session. @@ -258,55 +164,58 @@ mod tests { assert!(!negotiated( &json!({ "experimental": { "litheBuild": {} } }) )); + assert!(!negotiated( + &json!({ "experimental": { "litheBuild": { "version": 1 } } }) + )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": 99 } } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": "1" } } }) + &json!({ "experimental": { "litheBuild": { "version": "0" } } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "versions": [1] } } }) + &json!({ "experimental": { "litheBuild": { "versions": [0] } } }) )); } #[test] - fn only_typed_provider_requests_are_accepted() { - assert!(is_build_method("lithe/build/model")); - assert!(is_build_method("lithe/build/dependencies")); - assert!(is_build_method("lithe/build/execute")); - assert!(is_build_method("lithe/build/test")); - assert!(!is_build_method("workspace/executeCommand")); + fn the_host_offers_only_version_zero() { + assert_eq!( + client_experimental_capabilities(), + json!({ "litheBuild": { "versions": [0] } }) + ); } + #[test] - fn test_protocol_does_not_remove_task_support() { - let capabilities = json!({"experimental":{"litheBuild":{"version":3}}}); - assert!(negotiated(&capabilities)); - assert!(execution_negotiated(&capabilities)); - assert!(tests_negotiated(&capabilities)); - assert!(!tests_negotiated( - &json!({"experimental":{"litheBuild":{"version":2}}}) - )); + fn only_typed_provider_requests_are_accepted() { + for method in [ + "lithe/build/model", + "lithe/build/dependencies", + "lithe/build/execute", + "lithe/build/test", + "lithe/build/discoverTests", + "lithe/build/prepareRun", + "lithe/build/prepareDebug", + "lithe/build/configuration", + "lithe/build/editConfiguration", + ] { + assert!(is_build_method(method), "{method}"); + } + assert!(!is_build_method("workspace/executeCommand")); } #[test] - fn inline_reports_require_their_protocol_and_are_retained_without_host_parsing() { - let result = json!({"result":{"exitCode":1,"output":"failed","testResults":{"format":"litheTestResults","version":1,"tests":[]}}}); - assert!(execution_outcome(&result, false).is_err()); + fn inline_reports_are_retained_without_host_parsing() { + let result = json!({"result":{"exitCode":1,"output":"failed","testResults":{"format":"litheTestResults","version":0,"tests":[]}}}); assert_eq!( - execution_outcome(&result, true).unwrap()["value"], + execution_outcome(&result).unwrap()["value"], result["result"] ); for invalid in [json!(null), json!([]), json!("x")] { let mut reply = result.clone(); reply["result"]["testResults"] = invalid; - assert!(execution_outcome(&reply, true).is_err()); + assert!(execution_outcome(&reply).is_err()); } - assert!(!test_reports_negotiated( - &json!({"experimental":{"litheBuild":{"version":7}}}) - )); - assert!(test_reports_negotiated( - &json!({"experimental":{"litheBuild":{"version":8}}}) - )); } #[test] @@ -316,19 +225,17 @@ mod tests { )) .unwrap(); assert_eq!( - execution_outcome(&json!({"result": fixture["result"]}), false).unwrap(), + execution_outcome(&json!({"result": fixture["result"]})).unwrap(), json!({"value": fixture["result"]}) ); for result in fixture["invalidResults"].as_array().unwrap() { assert_eq!( - execution_outcome(&json!({"result": result}), false) - .unwrap_err() - .0, + execution_outcome(&json!({"result": result})).unwrap_err().0, "plugin.build.invalidResult" ); } assert_eq!( - execution_outcome(&json!({"error": {"message": "private"}}), false) + execution_outcome(&json!({"error": {"message": "private"}})) .unwrap_err() .0, "plugin.build.failed" @@ -341,28 +248,9 @@ mod tests { assert!(progress(Some(invalid)).is_none()); } assert!(progress(Some(&json!({"operationID":"1","output":"x".repeat(65537)}))).is_none()); - assert!(execution_outcome( - &json!({"result": {"exitCode": 0,"output":"x".repeat(32*1024*1024+1)}}), - false - ) + assert!(execution_outcome(&json!({ + "result": {"exitCode": 0,"output":"x".repeat(32*1024*1024+1)} + })) .is_err()); } } - -#[cfg(test)] -mod configuration_tests { - use super::*; - #[test] - fn native_configuration_requires_its_own_wire_version_without_losing_old_methods() { - let old = json!({"experimental":{"litheBuild":{"version":6}}}); - let current = json!({"experimental":{"litheBuild":{"version":7}}}); - assert!(!configuration_negotiated(&old)); - assert!(configuration_negotiated(¤t)); - assert!(is_build_method(CONFIGURATION_METHOD)); - assert!(model_inputs_negotiated(¤t)); - assert!(execution_negotiated(¤t)); - assert!(tests_negotiated(¤t)); - assert!(run_preparation_negotiated(¤t)); - assert!(debug_preparation_negotiated(¤t)); - } -} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index b326720f..bf745a6b 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -454,10 +454,9 @@ pub(super) fn client_apply_server_message_for_plugin( } super::build_provider::PROGRESS_METHOD if !state.shutdown_requested - && state - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::EXECUTION_FEATURE) => + && state.server_capabilities.iter().any(|feature| { + feature == super::build_provider::BUILD_PROVIDER_FEATURE + }) => { if let Some(result) = super::build_provider::progress(message.get("params")) { events.push(LspClientEvent { @@ -540,7 +539,7 @@ pub(super) fn client_apply_server_message_for_plugin( match plugin_function::host_request( method, message.get("params"), - state.plugin_function_version.unwrap_or(0), + state.plugin_function_version, &state.host_services, ) { // The engine answers once the host has; the request @@ -900,7 +899,9 @@ fn experimental_capabilities( ) -> Value { let mut experimental = super::build_provider::client_experimental_capabilities(); if plugin_owned { - experimental[super::plugin_logging::FEATURE] = json!({"versions": [1]}); + let (logging_key, logging_offer) = + super::plugin_logging::client_experimental_capabilities(); + experimental[logging_key] = logging_offer; let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); @@ -2106,39 +2107,6 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::negotiated(capabilities) { insert_unique(&mut values, super::build_provider::BUILD_PROVIDER_FEATURE); } - if super::build_provider::execution_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::EXECUTION_FEATURE); - } - if super::build_provider::tests_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); - } - if super::build_provider::test_selectors_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::TEST_SELECTORS_FEATURE); - } - if super::build_provider::test_reports_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::TEST_REPORTS_FEATURE); - } - if super::build_provider::configuration_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::CONFIGURATION_FEATURE); - } - if super::build_provider::model_inputs_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::MODEL_INPUTS_FEATURE); - } - if super::build_provider::debug_preparation_negotiated(capabilities) { - insert_unique( - &mut values, - super::build_provider::DEBUG_PREPARATION_FEATURE, - ); - } - if super::build_provider::runtime_debug_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::RUNTIME_DEBUG_FEATURE); - } - if super::build_provider::run_options_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::RUN_OPTIONS_FEATURE); - } - if super::build_provider::run_preparation_negotiated(capabilities) { - insert_unique(&mut values, super::build_provider::RUN_PREPARATION_FEATURE); - } if plugin_function::negotiate(capabilities).is_some() { insert_unique(&mut values, plugin_function::FUNCTIONS_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 3a6f0158..ee6138a2 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -422,8 +422,8 @@ pub struct PluginHostResponse { pub result: Option, /// The host could not perform the service. Its message reaches the /// backend, bounded. Its code passes only when it is one a host may - /// report (`plugin_function::host_failure`); otherwise, and before - /// protocol version 3, it is `plugin.host.failed`. + /// report (`plugin_function::host_failure`); otherwise it is + /// `plugin.host.failed`. #[serde(default)] pub error: Option, } @@ -433,14 +433,14 @@ pub struct PluginHostResponse { /// Why the host could not perform a forwarded host-service request. pub struct PluginHostFailure { pub message: String, - /// Stable `plugin.host.*` code of the failure, from protocol version 3. + /// Stable `plugin.host.*` code of the failure. #[serde(default)] pub code: Option, /// Structured facts a backend may act on, such as the current version - /// of a `plugin.host.versionConflict`. From protocol version 3 it - /// reaches the backend as `error.data.details` when it is an object - /// within `plugin_function::MAX_HOST_DETAILS_BYTES`; otherwise it is - /// dropped and the code and message still arrive. + /// of a `plugin.host.versionConflict`. It reaches the backend as + /// `error.data.details` when it is an object within + /// `plugin_function::MAX_HOST_DETAILS_BYTES`; otherwise it is dropped and + /// the code and message still arrive. #[serde(default)] pub details: Option, } @@ -459,7 +459,7 @@ pub struct PluginHostNotification { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Whether a host notification was written to the backend. `false` means -/// the backend negotiated a protocol version without the notification. +/// the backend negotiated no plugin-function protocol version. pub struct PluginHostNotificationResponse { pub delivered: bool, } @@ -482,8 +482,7 @@ pub struct PluginFunctionRequest { #[serde(default)] pub timeout_milliseconds: Option, /// Workspace context `{ contextID, generation }` the call belongs to. - /// It reaches a backend that negotiated protocol version 3 or later as - /// `params.context`, and is dropped for an older backend. + /// It reaches the backend as `params.context`. #[serde(default)] pub context: Option, } @@ -1933,165 +1932,31 @@ impl RuntimeSession { ) .with_details(super::build_provider::BUILD_PROVIDER_FEATURE)); } - if super::build_provider::is_execution_method(&request.method) - && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::EXECUTION_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate build execution.", - ) - .with_details("plugin.build.unavailable")); - } - if matches!( - request.method.as_str(), - super::build_provider::TEST_METHOD | super::build_provider::DISCOVER_TESTS_METHOD - ) && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::TEST_EXECUTION_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate test execution.", - ) - .with_details("plugin.build.unavailable")); - } - if request.params.pointer("/launchOptions/debug").is_some() - && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::RUNTIME_DEBUG_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate runtime debug options.", - ) - .with_details("plugin.build.unavailable")); - } - if request.params.get("launchOptions").is_some() { - if request.method != super::build_provider::PREPARE_RUN_METHOD { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Launch options apply only to run preparation.", - )); - } - if !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::RUN_OPTIONS_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate typed run options.", - ) - .with_details("plugin.build.unavailable")); - } - } - if request.params.get("selectorKind").is_some() - || request.params.get("selectors").is_some() - { - if request.method != super::build_provider::TEST_METHOD { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Opaque selectors require a provider test request.", - )); - } - if !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::TEST_SELECTORS_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate opaque test selectors.", - ) - .with_details("plugin.build.unavailable")); - } - } - if request.params.get("testResults").is_some() { - if request.method != super::build_provider::TEST_METHOD - || request.params["testResults"] != json!(true) - { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Inline test reports require a provider test request.", - )); - } - if !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::TEST_REPORTS_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate inline test reports.", - ) - .with_details("plugin.build.unavailable")); - } - } - if matches!( - request.method.as_str(), - super::build_provider::CONFIGURATION_METHOD - | super::build_provider::EDIT_CONFIGURATION_METHOD - ) && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::CONFIGURATION_FEATURE) + if request.params.get("launchOptions").is_some() + && request.method != super::build_provider::PREPARE_RUN_METHOD { return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate native configuration.", - ) - .with_details("plugin.build.unavailable")); + ErrorCode::InvalidRequest, + "Launch options apply only to run preparation.", + )); } - if request.method == super::build_provider::PREPARE_DEBUG_METHOD - && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::DEBUG_PREPARATION_FEATURE) + if (request.params.get("selectorKind").is_some() + || request.params.get("selectors").is_some()) + && request.method != super::build_provider::TEST_METHOD { return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate debug preparation.", - ) - .with_details("plugin.build.unavailable")); + ErrorCode::InvalidRequest, + "Opaque selectors require a provider test request.", + )); } - if request.method == super::build_provider::PREPARE_RUN_METHOD - && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::RUN_PREPARATION_FEATURE) + if request.params.get("testResults").is_some() + && (request.method != super::build_provider::TEST_METHOD + || request.params["testResults"] != json!(true)) { return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate run preparation.", - ) - .with_details("plugin.build.unavailable")); - } - if request.params.get("modelInputs").is_some() - && !state - .client - .server_capabilities - .iter() - .any(|feature| feature == super::build_provider::MODEL_INPUTS_FEATURE) - { - return Err(CoreError::new( - ErrorCode::NotSupported, - "The provider did not negotiate model inputs.", - ) - .with_details("plugin.build.unavailable")); + ErrorCode::InvalidRequest, + "Inline test reports require a provider test request.", + )); } let mut params = request.params; if super::build_provider::is_progress_method(&request.method) { @@ -2414,9 +2279,8 @@ impl RuntimeSession { &request.params, )?; let mut params = params; - let version = state.client.plugin_function_version.unwrap_or(0); - super::plugin_function::attach_context(&mut params, version, request.context.as_ref())?; - super::plugin_function::attach_caller(&mut params, version, caller); + super::plugin_function::attach_context(&mut params, request.context.as_ref())?; + super::plugin_function::attach_caller(&mut params, caller); let timeout = request .timeout_milliseconds .map(|milliseconds| { @@ -2479,19 +2343,13 @@ impl RuntimeSession { })?; match (response.result, response.error) { (_, Some(failure)) => { - let version = state.client.plugin_function_version.unwrap_or(0); - let (code, rpc_code) = if version >= super::plugin_function::CONTEXT_VERSION { - super::plugin_function::host_failure(failure.code.as_deref()) - } else { - (super::plugin_function::codes::HOST_FAILED, -32603) - }; + let (code, rpc_code) = + super::plugin_function::host_failure(failure.code.as_deref()); let mut data = json!({ "code": code }); - if version >= super::plugin_function::CONTEXT_VERSION { - if let Some(details) = - super::plugin_function::bounded_host_details(failure.details.as_ref()) - { - data["details"] = details; - } + if let Some(details) = + super::plugin_function::bounded_host_details(failure.details.as_ref()) + { + data["details"] = details; } json_rpc_error_with_data( &backend_id, @@ -2502,10 +2360,7 @@ impl RuntimeSession { } (result, None) => { let result = result.unwrap_or(Value::Null); - if state.client.plugin_function_version.unwrap_or(0) - >= super::plugin_function::CONTEXT_VERSION - && result.to_string().len() > super::plugin_function::MAX_RESULT_BYTES - { + if result.to_string().len() > super::plugin_function::MAX_RESULT_BYTES { json_rpc_error_with_code( &backend_id, -32014, @@ -2522,8 +2377,8 @@ impl RuntimeSession { } /// Writes one host notification to a ready plugin-owned backend. - /// Returns `false`, writing nothing, when the backend negotiated a - /// protocol version without the notification. + /// Returns `false`, writing nothing, when the backend negotiated no + /// plugin-function protocol version. fn host_notify(&self, notification: PluginHostNotification) -> Result { let outbound_order = self.lock_outbound_order()?; let message = { @@ -2543,7 +2398,7 @@ impl RuntimeSession { let delivered = super::plugin_function::host_notification( ¬ification.method, ¬ification.params, - state.client.plugin_function_version.unwrap_or(0), + state.client.plugin_function_version, &state.client.host_services, )?; if !delivered { @@ -3301,27 +3156,23 @@ impl RuntimeSession { { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { - let (result, error) = match super::build_provider::execution_outcome( - &value, - state.client.server_capabilities.iter().any(|feature| { - feature == super::build_provider::TEST_REPORTS_FEATURE - }), - ) { - Ok(result) => (Some(result), None), - Err((code, message)) => ( - None, - Some(runtime_error( - self, - code, - "request", - Some(&pending.method), - None, - &message, - None, + let (result, error) = + match super::build_provider::execution_outcome(&value) { + Ok(result) => (Some(result), None), + Err((code, message)) => ( None, - )), - ), - }; + Some(runtime_error( + self, + code, + "request", + Some(&pending.method), + None, + &message, + None, + None, + )), + ), + }; push_request_event( self, &mut state, @@ -5030,7 +4881,7 @@ fn cancel_host_request_locked( params: Option<&Value>, outbound: &mut Vec, ) -> Result { - if state.client.plugin_function_version.unwrap_or(0) < super::plugin_function::CONTEXT_VERSION { + if state.client.plugin_function_version.is_none() { return Ok(false); } let Some(backend_id) = params.and_then(|params| params.get("id")) else { @@ -5102,12 +4953,7 @@ fn forward_host_request_locked( ) -> Result, CoreError> { let backend_id = request.get("id").cloned().unwrap_or(Value::Null); if state.host_requests.len() >= super::plugin_function::MAX_PENDING_HOST_REQUESTS { - let version = state.client.plugin_function_version.unwrap_or(0); - let code = if version >= super::plugin_function::CONTEXT_VERSION { - super::plugin_function::codes::HOST_LIMIT_EXCEEDED - } else { - super::plugin_function::codes::HOST_FAILED - }; + let code = super::plugin_function::codes::HOST_LIMIT_EXCEEDED; return Ok(vec![json_rpc_error_with_code( &backend_id, super::plugin_function::host_rpc_code(code), @@ -8649,49 +8495,9 @@ mod tests { assert_eq!(error.details.as_deref(), Some("sessionStopped")); } - /// Execution must not reach a model-only provider. - #[test] - fn provider_execution_requires_version_two() { - let harness = negotiated_build_provider(); - let error = build_request(&harness, "lithe/build/execute", json!({}), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/execute")); - } - - #[test] - fn provider_test_execution_requires_version_three() { - for version in [1, 2] { - for method in ["lithe/build/test", "lithe/build/discoverTests"] { - let harness = plugin_backend(json!({"litheBuild":{"version":version}})); - let error = build_request(&harness, method, json!({}), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != method)); - } - } - } - #[test] - fn provider_debug_preparation_requires_version_five_and_retains_progress() { - for version in [1, 2, 3, 4] { - let harness = plugin_backend(json!({"litheBuild":{"version":version}})); - let error = - build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap_err(); - assert_eq!(error.details.as_deref(), Some("plugin.build.unavailable")); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/prepareDebug")); - } - let mut harness = plugin_backend(json!({"litheBuild":{"version":5}})); + fn provider_debug_preparation_retains_progress() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); let operation = build_request(&harness, "lithe/build/prepareDebug", json!({}), None).unwrap(); let request = harness @@ -8721,59 +8527,37 @@ mod tests { } #[test] - fn inline_test_reports_cannot_reach_a_provider_without_version_eight() { - for version in [7, 8] { - let mut harness = plugin_backend(json!({"litheBuild":{"version":version}})); - let result = build_request( - &harness, - "lithe/build/test", - json!({"testResults":true}), - None, - ); - if version == 7 { - assert!(matches!(result.unwrap_err().code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/test")); - } else { - let operation = result.unwrap(); - let id = harness.server.await_request("lithe/build/test").unwrap(); - let value = json!({"exitCode":1,"output":"failure","testResults":{"format":"litheTestResults","version":1,"tests":[]}}); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":value})); - let event = harness.await_event(|event| { - event.kind == "requestCompleted" - && event.operation_id.as_deref() == Some(operation.as_str()) - }); - assert_eq!(event.result.as_ref().unwrap()["value"], value); - } - } + fn inline_test_reports_reach_the_provider() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); + let operation = build_request( + &harness, + "lithe/build/test", + json!({"testResults":true}), + None, + ) + .unwrap(); + let id = harness.server.await_request("lithe/build/test").unwrap(); + let value = json!({"exitCode":1,"output":"failure","testResults":{"format":"litheTestResults","version":0,"tests":[]}}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":value})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], value); } #[test] - fn provider_native_configuration_requires_version_seven_and_returns_only_the_projection() { - for version in 1..7 { - let harness = plugin_backend(json!({"litheBuild":{"version":version}})); - let error = - build_request(&harness, "lithe/build/configuration", json!({}), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/configuration")); - } - let mut harness = plugin_backend(json!({"litheBuild":{"version":7}})); + fn provider_native_configuration_returns_only_the_projection() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); let operation = build_request(&harness, "lithe/build/configuration", json!({}), None).unwrap(); let id = harness .server .await_request("lithe/build/configuration") .unwrap(); - let result = json!({"format":"litheBuildConfiguration","version":1,"sources":[]}); + let result = json!({"format":"litheBuildConfiguration","version":0,"sources":[]}); harness .server .send(json!({"jsonrpc":"2.0","id":id,"result":result})); @@ -8784,24 +8568,15 @@ mod tests { assert_eq!(event.result.as_ref().unwrap()["value"], result); } - /// User options must never be silently downgraded before a provider request. + /// Launch options reach the provider unchanged, and only run preparation accepts them. #[test] - fn provider_run_options_require_version_ten_before_wire_send() { + fn provider_run_options_reach_the_wire_for_run_preparation_only() { let fixture: Value = serde_json::from_str(include_str!( "../../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json" )) .unwrap(); let params = json!({"launchOptions":fixture["launchOptions"]}); - let old = plugin_backend(json!({"litheBuild":{"version":9}})); - let error = - build_request(&old, "lithe/build/prepareRun", params.clone(), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(old - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/prepareRun")); - let mut current = plugin_backend(json!({"litheBuild":{"version":10}})); + let mut current = plugin_backend(json!({"litheBuild":{"version":0}})); let error = build_request(¤t, "lithe/build/model", params.clone(), None).unwrap_err(); assert!(matches!(error.code, ErrorCode::InvalidRequest)); let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); @@ -8826,24 +8601,15 @@ mod tests { assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); } - /// Port ownership cannot be silently discarded by a version-10 process. + /// Port ownership reaches the provider and comes back in the raw plan. #[test] - fn provider_runtime_debug_requires_version_eleven_before_wire_send() { + fn provider_connection_debug_options_reach_the_wire() { let fixture: Value = serde_json::from_str(include_str!( "../../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json" )) .unwrap(); let params = json!({"launchOptions":fixture["launchOptions"]}); - let old = plugin_backend(json!({"litheBuild":{"version":10}})); - let error = - build_request(&old, "lithe/build/prepareRun", params.clone(), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(old - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/prepareRun")); - let mut current = plugin_backend(json!({"litheBuild":{"version":11}})); + let mut current = plugin_backend(json!({"litheBuild":{"version":0}})); let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); let id = current .server @@ -8860,19 +8626,8 @@ mod tests { } #[test] - fn provider_run_preparation_requires_version_four_and_returns_raw_plan() { - for version in [1, 2, 3] { - let harness = plugin_backend(json!({"litheBuild":{"version":version}})); - let error = - build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap_err(); - assert!(matches!(error.code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/prepareRun")); - } - let mut harness = plugin_backend(json!({"litheBuild":{"version":4}})); + fn provider_run_preparation_returns_the_raw_plan() { + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); let operation = build_request(&harness, "lithe/build/prepareRun", json!({}), None).unwrap(); let id = harness .server @@ -8894,7 +8649,7 @@ mod tests { #[test] fn provider_test_discovery_uses_the_existing_request_lifecycle() { - let mut harness = plugin_backend(json!({"litheBuild":{"version":3}})); + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); let operation = build_request( &harness, "lithe/build/discoverTests", @@ -8906,7 +8661,7 @@ mod tests { .server .await_request("lithe/build/discoverTests") .unwrap(); - let result = json!({"format":"litheTestDiscovery","version":1,"tests":[]}); + let result = json!({"format":"litheTestDiscovery","version":0,"tests":[]}); harness .server .send(json!({"jsonrpc":"2.0","id":id,"result":result})); @@ -8922,15 +8677,15 @@ mod tests { /// and replies are drained before checking that cancellation stayed final. #[test] fn provider_execution_progress_cancel_and_late_results_are_isolated() { - for (version, method) in [ - (2, "lithe/build/execute"), - (3, "lithe/build/test"), - (4, "lithe/build/prepareRun"), + for method in [ + "lithe/build/execute", + "lithe/build/test", + "lithe/build/prepareRun", ] { let mut harness = Harness::start(|_| {}); harness .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + .complete_initialize(json!({"experimental":{"litheBuild":{"version":0}}})); harness.await_state(LspLifecycleState::Ready); let operation = build_request(&harness, method, json!({"operationID":"spoof"}), None).unwrap(); @@ -8993,12 +8748,12 @@ mod tests { /// may contribute progress to that new call. #[test] fn provider_execution_reused_host_ids_reject_old_wire_progress() { - for (version, method) in [ - (2, "lithe/build/execute"), - (3, "lithe/build/test"), - (4, "lithe/build/prepareRun"), + for method in [ + "lithe/build/execute", + "lithe/build/test", + "lithe/build/prepareRun", ] { - let mut harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let mut harness = plugin_backend(json!({"litheBuild":{"version":0}})); let operation = "reused-build".to_string(); let send = |harness: &Harness| { harness @@ -9049,15 +8804,15 @@ mod tests { #[test] fn provider_execution_validates_results_and_expires_without_sleeping() { - for (version, method) in [ - (2, "lithe/build/execute"), - (3, "lithe/build/test"), - (4, "lithe/build/prepareRun"), + for method in [ + "lithe/build/execute", + "lithe/build/test", + "lithe/build/prepareRun", ] { let mut harness = Harness::start(|_| {}); harness .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); + .complete_initialize(json!({"experimental":{"litheBuild":{"version":0}}})); harness.await_state(LspLifecycleState::Ready); for (index, (reply, code)) in [ (json!({"exitCode":7,"output":"failed build"}), None), @@ -9081,7 +8836,11 @@ mod tests { }) .clone(); // Preparation returns raw launch data; the build decoder validates it. - let code = if version == 4 { None } else { code }; + let code = if method == "lithe/build/prepareRun" { + None + } else { + code + }; assert_eq!(ended.error.as_ref().map(|error| error.code.as_str()), code); if code.is_none() { assert_eq!(ended.result.unwrap()["value"], reply); @@ -9106,11 +8865,11 @@ mod tests { } } - /// A process that answered build-provider protocol version 1. + /// A process that answered the build-provider protocol with version 0. fn negotiated_build_provider() -> Harness { let mut harness = Harness::start(|_| {}); harness.server.complete_initialize(json!({ - "experimental": { "litheBuild": { "version": 1 } } + "experimental": { "litheBuild": { "version": 0 } } })); harness.await_state(LspLifecycleState::Ready); harness @@ -9152,7 +8911,7 @@ mod tests { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }) + json!({ "versions": [0] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -9165,40 +8924,42 @@ mod tests { .all(|message| message["method"] != "lithe/build/model")); } - /// A legacy backend must never silently execute without a declared shared input. + /// A reply naming a version the host did not offer negotiates nothing, + /// for the build protocol and the plugin-function protocol alike. #[test] - fn model_inputs_need_version_six_even_for_an_old_model_method() { - for version in [5, 6] { - let mut harness = Harness::start(|_| {}); - harness - .server - .complete_initialize(json!({"experimental":{"litheBuild":{"version":version}}})); - harness.await_state(LspLifecycleState::Ready); - let params = json!({"modelInputs":{"version":1,"revision":7,"inputs":[]}}); - let result = build_request(&harness, "lithe/build/model", params.clone(), None); - if version == 5 { - assert!(matches!(result.unwrap_err().code, ErrorCode::NotSupported)); - assert!(harness - .server - .messages() - .iter() - .all(|message| message["method"] != "lithe/build/model")); - } else { - let operation = result.unwrap(); - let id = harness - .server - .await_request("lithe/build/model") - .expect("version six receives its snapshot"); - assert_eq!(harness.written_request(&id)["params"], params); - harness - .server - .send(json!({"jsonrpc":"2.0","id":id,"result":{"projects":[]}})); - harness.await_event(|event| { - event.operation_id.as_deref() == Some(&operation) - && event.kind == "requestCompleted" - }); - } - } + fn a_reply_naming_an_unoffered_version_negotiates_nothing() { + let harness = plugin_backend(json!({ + "litheBuild": { "version": 1 }, + "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } + })); + let error = build_request(&harness, "lithe/build/model", json!({}), None) + .expect_err("version 1 was not offered"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) + .expect_err("version 1 was not offered"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness.server.messages().iter().all(|message| { + message["method"] != "lithe/build/model" && message["method"] != "lithe/function/invoke" + })); + } + + /// A model request carries the declared shared-input snapshot to the provider verbatim. + #[test] + fn model_inputs_reach_the_provider_verbatim() { + let mut harness = negotiated_build_provider(); + let params = json!({"modelInputs":{"version":0,"revision":7,"inputs":[]}}); + let operation = build_request(&harness, "lithe/build/model", params.clone(), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/model") + .expect("the provider receives its snapshot"); + assert_eq!(harness.written_request(&id)["params"], params); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":{"projects":[]}})); + harness.await_event(|event| { + event.operation_id.as_deref() == Some(&operation) && event.kind == "requestCompleted" + }); } #[test] @@ -9221,7 +8982,7 @@ mod tests { // A reply with a `locations` field proves no navigation normalizer // rewrites the document Core decodes. - let reply = json!({ "format": "litheBuildModel", "version": 1, "locations": [] }); + let reply = json!({ "format": "litheBuildModel", "version": 0, "locations": [] }); harness .server .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); @@ -9284,7 +9045,7 @@ mod tests { } }); harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} }})); harness.await_state(LspLifecycleState::Ready); let initialize = harness @@ -9299,7 +9060,7 @@ mod tests { if plugin_owned { assert_eq!( offer, - Some(&json!({"versions": [1], "providers": ["fixture"]})) + Some(&json!({"versions": [0], "providers": ["fixture"]})) ); } let event = harness @@ -9320,7 +9081,7 @@ mod tests { request.keymap_import_providers = vec!["fixture".to_string()]; }); harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 1, "providers": ["fixture", "other"]} + "litheKeymapImport": {"version": 0, "providers": ["fixture", "other"]} }})); harness.await_state(LspLifecycleState::Ready); assert!(harness @@ -9336,7 +9097,7 @@ mod tests { request.keymap_import_providers = vec!["fixture".to_string()]; }); harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} }})); harness.await_state(LspLifecycleState::Ready); let request = |provider: &str| KeymapImportProviderRequest { @@ -9384,7 +9145,7 @@ mod tests { request.keymap_import_providers = vec!["fixture".to_string()]; }); harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} }})); harness.await_state(LspLifecycleState::Ready); let mut request = KeymapImportProviderRequest { @@ -9428,7 +9189,7 @@ mod tests { request.keymap_import_providers = vec!["fixture".to_string()]; }); harness.server.complete_initialize(json!({"experimental": { - "litheKeymapImport": {"version": 1, "providers": ["fixture"]} + "litheKeymapImport": {"version": 0, "providers": ["fixture"]} }})); harness.await_state(LspLifecycleState::Ready); let request = KeymapImportProviderRequest { @@ -9516,7 +9277,7 @@ mod tests { #[test] fn a_query_reaches_only_a_selected_provider_and_returns_the_raw_response() { - let mut harness = toolchain_backend(json!({ "version": 1, "providers": ["mise"] })); + let mut harness = toolchain_backend(json!({ "version": 0, "providers": ["mise"] })); let initialize = harness .server .messages() @@ -9525,7 +9286,7 @@ mod tests { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheToolchains"], - json!({ "versions": [1], "providers": ["mise"] }) + json!({ "versions": [0], "providers": ["mise"] }) ); let event = harness .await_event(|event| event.kind == "toolchainProviders") @@ -9566,7 +9327,7 @@ mod tests { #[test] fn an_undeclared_toolchain_provider_enables_nothing() { - let mut harness = toolchain_backend(json!({ "version": 1, "providers": ["mise", "asdf"] })); + let mut harness = toolchain_backend(json!({ "version": 0, "providers": ["mise", "asdf"] })); harness.poll(); assert!(harness .events @@ -9585,7 +9346,7 @@ mod tests { const VCS_PLUGIN: &str = "dev.lithe.fixture.vcs-provider"; fn vcs_capabilities() -> Value { - json!({ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 }) + json!({ "status": 0, "baseContent": 0, "checkin": 0, "rollback": 0, "update": 0 }) } fn vcs_backend(answer: Value) -> Harness { @@ -9600,7 +9361,7 @@ mod tests { } fn negotiated_vcs_provider() -> Harness { - vcs_backend(json!({ "version": 1, "vcs": "svn", "capabilities": vcs_capabilities() })) + vcs_backend(json!({ "version": 0, "vcs": "svn", "capabilities": vcs_capabilities() })) } /// A native absolute path on every platform the tests run on. @@ -9687,7 +9448,7 @@ mod tests { .any(|feature| feature == "litheVcs")); } - /// Version 1 has no host side for credentials or console routing yet: an + /// The host has no side for credentials or console routing: an /// authentication request is refused so the provider fails with /// `vcs.authentication.failed`, and an execution report is passed on as a /// session log entry. @@ -9722,7 +9483,7 @@ mod tests { #[test] fn an_incomplete_vcs_answer_is_not_a_provider() { let mut harness = vcs_backend(json!({ - "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1 } + "version": 0, "vcs": "svn", "capabilities": { "status": 0, "baseContent": 0 } })); harness.poll(); assert!(harness @@ -9853,8 +9614,8 @@ mod tests { fn function_backend() -> Harness { plugin_backend(json!({ - "litheBuild": { "version": 1 }, - "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } + "litheBuild": { "version": 0 }, + "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } })) } @@ -10000,10 +9761,10 @@ mod tests { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }, - "litheFunctions": { "versions": [1, 2, 3, 4] }, - "litheLogging": { "versions": [1] }, - "litheVcs": { "versions": [1] } + "litheBuild": { "versions": [0] }, + "litheFunctions": { "versions": [0] }, + "litheLogging": { "versions": [0] }, + "litheVcs": { "versions": [0] } }) ); @@ -10259,12 +10020,12 @@ mod tests { assert!(harness.snapshot().pending_operation_ids.is_empty()); } - /// A backend written before plugin functions keeps its build requests; + /// A backend that speaks only the build protocol keeps its build requests; /// it receives no function calls and its function messages are not /// mistaken for registrations. #[test] fn a_build_only_backend_keeps_working_without_plugin_functions() { - let mut harness = plugin_backend(json!({ "litheBuild": { "version": 1 } })); + let mut harness = plugin_backend(json!({ "litheBuild": { "version": 0 } })); let error = invoke(&harness, FUNCTION_PLUGIN, "snapshot.refresh", None) .expect_err("functions need the negotiated protocol"); assert!(matches!(error.code, ErrorCode::NotSupported)); @@ -10287,7 +10048,7 @@ mod tests { .server .await_request("lithe/build/model") .expect("the build request should reach the backend"); - let reply = json!({ "format": "litheBuildModel", "version": 1 }); + let reply = json!({ "format": "litheBuildModel", "version": 0 }); harness .server .send(json!({ "jsonrpc": "2.0", "id": request_id, "result": reply })); @@ -10302,7 +10063,7 @@ mod tests { fn a_host_owned_session_serves_no_plugin_functions() { let mut harness = Harness::start(|_| {}); harness.server.complete_initialize(json!({ - "experimental": { "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } } + "experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } } })); harness.await_state(LspLifecycleState::Ready); let initialize = harness @@ -10405,7 +10166,7 @@ mod tests { json!([]), ] { let mut harness = plugin_backend( - json!({ "litheFunctions": { "version": 1, "functions": functions } }), + json!({ "litheFunctions": { "version": 0, "functions": functions } }), ); let initialized = harness .await_event(|event| event.kind == "functionsChanged") @@ -10723,7 +10484,8 @@ mod tests { } /// Domain dispatch could route back into the backend that asked, whose - /// handler would wait on itself; version 1 also offers no host capability. + /// handler would wait on itself; a host method the host does not offer is + /// unavailable. #[test] fn a_backend_cannot_ask_the_host_to_dispatch_back_into_itself() { let harness = function_backend(); @@ -10755,14 +10517,14 @@ mod tests { } } - fn host_service_backend(version: u64, host_services: &[&str]) -> Harness { + fn host_service_backend(host_services: &[&str]) -> Harness { let host_services: Vec = host_services.iter().map(ToString::to_string).collect(); let mut harness = Harness::start(move |request| { request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); request.host_services = host_services.clone(); }); harness.server.complete_initialize(json!({ - "experimental": { "litheFunctions": { "version": version } } + "experimental": { "litheFunctions": { "version": 0 } } })); harness.await_state(LspLifecycleState::Ready); harness @@ -10800,7 +10562,7 @@ mod tests { /// later request. #[test] fn a_granted_host_service_request_round_trips_through_the_host() { - let mut harness = host_service_backend(2, &["credentials"]); + let mut harness = host_service_backend(&["credentials"]); harness.server.send(json!({ "jsonrpc": "2.0", "id": "read", "method": "lithe/host/credentials/get", @@ -10850,35 +10612,70 @@ mod tests { assert_eq!(reply["error"]["message"], "The keychain is locked."); } - /// Host services start with protocol version 2 and only for the services - /// the plugin's permissions grant; refusals never reach the host. + /// Host services are offered only for the services the plugin's + /// permissions grant; a refusal never reaches the host. #[test] - fn host_services_need_version_2_and_a_grant() { - for (version, services, code) in [ - (1, &["credentials"][..], "plugin.host.unavailable"), - (2, &[][..], "plugin.host.permissionDenied"), - ] { - let mut harness = host_service_backend(version, services); - harness.server.send(json!({ - "jsonrpc": "2.0", "id": "read", - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" } - })); - let reply = await_reply(&harness, "read"); - assert_eq!(reply["error"]["data"]["code"], code, "version {version}"); - harness.poll(); - assert!(harness - .events - .iter() - .all(|event| event.kind != "hostRequest")); - } + fn host_services_need_a_grant() { + let mut harness = host_service_backend(&[]); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let reply = await_reply(&harness, "read"); + assert_eq!( + reply["error"]["data"]["code"], + "plugin.host.permissionDenied" + ); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + } + + /// A backend that negotiated no plugin-function protocol is offered no + /// host service and receives no host notification, whatever the plugin's + /// permissions grant. + #[test] + fn a_backend_without_the_function_protocol_gets_no_host_services() { + let mut harness = Harness::start(|request| { + request.plugin_id = Some(FUNCTION_PLUGIN.to_string()); + request.host_services = vec!["credentials".to_string(), "workspace".to_string()]; + }); + harness.server.complete_initialize(json!({ + "experimental": { "litheBuild": { "version": 0 } } + })); + harness.await_state(LspLifecycleState::Ready); + harness.server.send(json!({ + "jsonrpc": "2.0", "id": "read", + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" } + })); + let reply = await_reply(&harness, "read"); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.unavailable"); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "hostRequest")); + let request: PluginHostNotification = serde_json::from_value(json!({ + "sessionId": harness.session_id, + "method": "lithe/host/workspace/didChangeContexts", + "params": { "revision": 2 }, + })) + .expect("documented wire shape"); + assert!(!harness + .session() + .host_notify(request) + .expect("a known notification")); } /// A host that stops answering cannot make a backend grow the session's /// state: requests beyond the bound fail at once. #[test] fn unanswered_host_requests_are_bounded() { - let harness = host_service_backend(2, &["credentials"]); + let harness = host_service_backend(&["credentials"]); let limit = super::super::plugin_function::MAX_PENDING_HOST_REQUESTS; for index in 0..=limit { harness.server.send(json!({ @@ -10888,24 +10685,25 @@ mod tests { })); } let reply = await_reply(&harness, &format!("read-{limit}")); - assert_eq!(reply["error"]["data"]["code"], "plugin.host.failed"); + assert_eq!(reply["error"]["code"], -32014); + assert_eq!(reply["error"]["data"]["code"], "plugin.host.limitExceeded"); assert_eq!( harness.session().lock_state().unwrap().host_requests.len(), limit ); } - /// From version 3 Core answers capability discovery itself, listing - /// only the session's grants; the host never sees the request. + /// Core answers capability discovery itself, listing only the session's + /// grants; the host never sees the request. #[test] fn capability_discovery_is_answered_by_core() { - let mut harness = host_service_backend(3, &["workspace"]); + let mut harness = host_service_backend(&["workspace"]); harness.server.send(json!({ "jsonrpc": "2.0", "id": "caps", "method": "lithe/host/getCapabilities" })); let reply = await_reply(&harness, "caps"); - assert_eq!(reply["result"]["protocolVersion"], 3); + assert_eq!(reply["result"]["protocolVersion"], 0); assert_eq!(reply["result"]["backendAuthority"], "local"); assert!(reply["result"]["services"]["workspace"].is_object()); assert!(reply["result"]["services"]["credentials"].is_null()); @@ -10931,7 +10729,7 @@ mod tests { }); }); harness.server.complete_initialize(json!({ - "experimental": { "litheFunctions": { "version": 3 } } + "experimental": { "litheFunctions": { "version": 0 } } })); harness.await_state(LspLifecycleState::Ready); harness.server.send(json!({ @@ -10989,12 +10787,12 @@ mod tests { } } - /// From version 3 a field a method does not define is answered as an - /// unsupported option naming it, so a backend can retry without it; the - /// host never sees the request. + /// A field a method does not define is answered as an unsupported option + /// naming it, so a backend can retry without it; the host never sees the + /// request. #[test] - fn an_unknown_option_is_named_to_a_version_3_backend() { - let mut harness = host_service_backend(3, &["workspace"]); + fn an_unknown_option_is_named_to_the_backend() { + let mut harness = host_service_backend(&["workspace"]); harness.server.send(json!({ "jsonrpc": "2.0", "id": "folders", "method": "lithe/host/workspace/getFolders", @@ -11020,11 +10818,11 @@ mod tests { .all(|event| event.kind != "hostRequest")); } - /// From version 3 a host failure's code reaches the backend when it is - /// one a host may report; an unknown code is `plugin.host.failed`. + /// A host failure's code reaches the backend when it is one a host may + /// report; an unknown code is `plugin.host.failed`. #[test] - fn typed_host_failures_reach_a_version_3_backend() { - let mut harness = host_service_backend(3, &["workspace"]); + fn typed_host_failures_reach_the_backend() { + let mut harness = host_service_backend(&["workspace"]); for (id, code, expected, rpc_code) in [ ( "a", @@ -11060,11 +10858,11 @@ mod tests { } } - /// A host failure's details reach a version 3 backend under + /// A host failure's details reach the backend under /// `error.data.details`, next to the stable code. #[test] - fn host_failure_details_reach_a_version_3_backend() { - let mut harness = host_service_backend(3, &["workspace"]); + fn host_failure_details_reach_the_backend() { + let mut harness = host_service_backend(&["workspace"]); harness.server.send(json!({ "jsonrpc": "2.0", "id": "folders", "method": "lithe/host/workspace/getFolders", @@ -11103,8 +10901,8 @@ mod tests { /// `lsp.hostNotify` writes an admitted notification to the backend and /// refuses one the session was not granted. #[test] - fn host_notifications_reach_a_version_3_backend() { - let harness = host_service_backend(3, &["workspace"]); + fn host_notifications_reach_the_backend() { + let harness = host_service_backend(&["workspace"]); let notify = |method: &str, params: Value| { let request: PluginHostNotification = serde_json::from_value(json!({ "sessionId": harness.session_id, @@ -11125,27 +10923,11 @@ mod tests { assert!(notify("lithe/build/run", json!({})).is_err()); } - /// A version 2 backend never receives a notification it cannot read. - #[test] - fn host_notifications_skip_a_version_2_backend() { - let harness = host_service_backend(2, &["workspace"]); - let request: PluginHostNotification = serde_json::from_value(json!({ - "sessionId": harness.session_id, - "method": "lithe/host/workspace/didChangeContexts", - "params": { "revision": 2 }, - })) - .expect("documented wire shape"); - assert!(!harness - .session() - .host_notify(request) - .expect("a known notification")); - } - /// A backend that cancels its own host request is answered at once, the /// host learns which request to stop, and its late answer is refused. #[test] fn a_backend_can_cancel_its_host_request() { - let mut harness = host_service_backend(3, &["workspace"]); + let mut harness = host_service_backend(&["workspace"]); harness.server.send(json!({ "jsonrpc": "2.0", "id": "list", "method": "lithe/host/workspace/listContexts" @@ -11173,12 +10955,12 @@ mod tests { assert!(host_response(&harness, &host_request_id, Some(json!({})), None).is_err()); } - /// A call's workspace context reaches a version 3 backend as - /// `params.context`, and a malformed reference never leaves Core. + /// A call's workspace context reaches the backend as `params.context`, + /// and a malformed reference never leaves Core. #[test] - fn invocations_carry_the_workspace_context_from_version_3() { + fn invocations_carry_the_workspace_context() { let harness = plugin_backend(json!({ - "litheFunctions": { "version": 3, "functions": [{ "id": "snapshot.refresh" }] } + "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } })); let call = |context: Value| { let operation_id = harness.engine.next_operation_id(); diff --git a/rust/lithe-core/src/lsp/interface/engine_function_relay_tests.rs b/rust/lithe-core/src/lsp/interface/engine_function_relay_tests.rs index 7998d22b..18863713 100644 --- a/rust/lithe-core/src/lsp/interface/engine_function_relay_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_function_relay_tests.rs @@ -133,7 +133,6 @@ impl Fixture { struct Launch { plugin_id: &'static str, root_uri: &'static str, - protocol_version: u64, functions: Vec<&'static str>, host_services: Vec, } @@ -145,7 +144,6 @@ impl Launch { Self { plugin_id, root_uri: "file:///workspace", - protocol_version: 4, functions: Vec::new(), host_services: vec!["functions".to_string()], } @@ -161,11 +159,6 @@ impl Launch { self } - fn speaking(mut self, protocol_version: u64) -> Self { - self.protocol_version = protocol_version; - self - } - fn without_grant(mut self) -> Self { self.host_services.clear(); self @@ -179,7 +172,7 @@ impl Launch { .collect(); json!({ "experimental": { - "litheFunctions": { "version": self.protocol_version, "functions": functions } + "litheFunctions": { "version": 0, "functions": functions } } }) } @@ -520,23 +513,6 @@ fn a_call_the_host_starts_carries_no_caller() { assert!(received.get("caller").is_none(), "{received}"); } -#[test] -fn a_callee_on_an_older_protocol_version_gets_no_caller() { - let fixture = Fixture::new(); - let python = fixture.start(Launch::new(PYTHON)); - let go = fixture.start(Launch::new(GO).speaking(3).serving(&[FORMAT])); - fixture.configure(standard_snapshot()); - - python.call("call-1", GO, FORMAT); - - let (wire_id, received) = go.await_invoke(); - assert!(received.get("caller").is_none(), "{received}"); - // The rest of the call is unchanged for that callee. - assert_eq!(received["context"], workspace_context()); - go.answer(&wire_id, json!({})); - assert_eq!(python.reply("call-1")["result"], json!({ "value": {} })); -} - #[test] fn a_failing_callee_keeps_its_own_error_code() { let fixture = Fixture::new(); @@ -744,22 +720,8 @@ fn a_refused_call_never_reaches_the_callee() { } #[test] -fn relay_methods_need_protocol_version_4_and_the_functions_grant() { +fn relay_methods_need_the_functions_grant() { let fixture = Fixture::new(); - let old_protocol = fixture.start(Launch::new(PYTHON).speaking(3)); - old_protocol.call("call-1", GO, FORMAT); - assert_error( - &old_protocol.reply("call-1"), - "plugin.host.unavailable", - -32601, - ); - old_protocol.ask("providers-1", PROVIDERS, json!({ "functionID": FORMAT })); - assert_error( - &old_protocol.reply("providers-1"), - "plugin.host.unavailable", - -32601, - ); - let ungranted = fixture.start(Launch::new(ALPHA).without_grant()); ungranted.call("call-2", GO, FORMAT); assert_error( @@ -767,15 +729,21 @@ fn relay_methods_need_protocol_version_4_and_the_functions_grant() { "plugin.host.permissionDenied", -32010, ); + ungranted.ask("providers-2", PROVIDERS, json!({ "functionID": FORMAT })); + assert_error( + &ungranted.reply("providers-2"), + "plugin.host.permissionDenied", + -32010, + ); } #[test] -fn the_old_refusals_still_hold_with_the_relay() { +fn domain_dispatch_and_cross_plugin_host_calls_are_refused() { let fixture = Fixture::new(); let python = fixture.start(Launch::new(PYTHON).serving(&[LINT])); fixture.configure(standard_snapshot()); - // A backend still cannot ask the host to run a function or a domain + // A backend cannot ask the host to run a function or a domain // operation, which could route back into the backend itself. for (id, method) in [ ("invoke", "lithe/function/invoke"), @@ -789,7 +757,7 @@ fn the_old_refusals_still_hold_with_the_relay() { ); } - // The host still cannot call another plugin's function through a session. + // The host cannot call another plugin's function through a session. let error = python .host_call("host-call-1", GO, FORMAT) .expect_err("another plugin's function stays private"); diff --git a/rust/lithe-core/src/lsp/interface/keymap_provider.rs b/rust/lithe-core/src/lsp/interface/keymap_provider.rs index b1b85c1e..efcc11c0 100644 --- a/rust/lithe-core/src/lsp/interface/keymap_provider.rs +++ b/rust/lithe-core/src/lsp/interface/keymap_provider.rs @@ -1,5 +1,7 @@ -//! Negotiation for the independent shortcut import provider protocol. +//! Negotiation for the independent shortcut import provider protocol. The +//! protocol version is the `keymapImportProviders` capability version. +use crate::plugins::capabilities::{versions as capability_versions, KEYMAP_IMPORT_PROVIDERS}; use serde_json::{json, Value}; use std::collections::BTreeSet; @@ -11,6 +13,12 @@ pub(crate) const IMPORT_METHOD: &str = "lithe/keymap/import"; /// Maximum encoded provider reply forwarded to the host for Core validation. pub(crate) const MAX_REPLY_BYTES: usize = 1024 * 1024; +/// Protocol versions this client speaks: the versions of the +/// `keymapImportProviders` capability. +fn supported_versions() -> Vec { + super::offered_versions(capability_versions(KEYMAP_IMPORT_PROVIDERS)) +} + fn valid_offer(providers: &BTreeSet) -> bool { !providers.is_empty() && providers.len() <= crate::plugins::keymap_import::MAX_PROVIDERS @@ -26,7 +34,7 @@ pub(crate) fn client_experimental_capabilities( valid_offer(providers).then(|| { ( EXPERIMENTAL_KEY, - json!({"versions": [1], "providers": providers}), + json!({"versions": supported_versions(), "providers": providers}), ) }) } @@ -37,7 +45,7 @@ pub(crate) fn negotiate(capabilities: &Value, offered: &BTreeSet) -> Opt return None; } let answer = capabilities.pointer("/experimental/litheKeymapImport")?; - if answer.get("version")?.as_u64()? != 1 { + if !supported_versions().contains(&answer.get("version")?.as_u64()?) { return None; } let mut selected = BTreeSet::new(); @@ -79,7 +87,7 @@ mod tests { let offered = BTreeSet::from(["second".to_string(), "first".to_string()]); assert_eq!( client_experimental_capabilities(&offered).unwrap().1, - json!({"versions": [1], "providers": ["first", "second"]}) + json!({"versions": [0], "providers": ["first", "second"]}) ); } @@ -90,7 +98,7 @@ mod tests { for offered in [bad_id, many] { assert!(client_experimental_capabilities(&offered).is_none()); let answer = json!({"experimental": {"litheKeymapImport": { - "version": 1, "providers": [offered.iter().next().unwrap()] + "version": 0, "providers": [offered.iter().next().unwrap()] }}}); assert!(negotiate(&answer, &offered).is_none()); } diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index cb3a7c5b..f26f4ce1 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -30,3 +30,16 @@ pub(crate) use engine_logging::{resolve_engine_log_subject, ResolveEngineLogSubj pub(crate) use function_relay::RelaySnapshot; pub(crate) use transport::*; pub(crate) use types::*; + +/// The versions a Lithe runtime protocol offers in `initialize`. A runtime +/// protocol has no version of its own: it speaks the version of the contract +/// that owns it, the capability version of the manifest section that declares +/// the provider (or the plugin API version for plugin logging). The host +/// therefore offers exactly the versions of that contract it implements, and a +/// reply counts as negotiated only when it names one of them. +fn offered_versions(contract_versions: &[u32]) -> Vec { + contract_versions + .iter() + .map(|version| u64::from(*version)) + .collect() +} diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 86ef64a6..8b225f1a 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -1,9 +1,15 @@ //! Plugin functions: plugin-private actions a backend registers and the host //! invokes on behalf of the same plugin, next to the typed domain protocols. //! -//! The protocol is negotiated in `initialize` under -//! `capabilities.experimental.litheFunctions`, independently of `litheBuild`, -//! so a backend can speak either one. Functions never replace a domain +//! The protocol version is the `backend` capability version, negotiated in +//! `initialize` under `capabilities.experimental.litheFunctions`, +//! independently of `litheBuild`, so a backend can speak either one. The +//! protocol covers function registration and invocation, the host services of +//! `host_request` (credentials, `lithe/host/getCapabilities`, subscriptions, +//! workspace contexts with a workspace context on invocations, typed host +//! failure codes and details, host notifications), and the cross-plugin +//! function relay (`lithe/host/functions/*`) with the Core-written `caller` +//! of an invocation. Functions never replace a domain //! operation: a build model, dependency tree, or task still travels over its //! typed `lithe/build/*` method and Core's plan and decode commands. This //! module holds the deterministic rules (identifiers, registration, invocation @@ -12,6 +18,7 @@ //! The rules live in the plugin contract //! (`shared/contracts/plugins/v1/README.md`, "Plugin functions"). +use crate::plugins::capabilities::{versions as capability_versions, BACKEND}; use crate::plugins::language_server_sessions::{self, is_protocol_name}; use crate::protocol::{CoreError, ErrorCode}; use serde::Deserialize; @@ -21,22 +28,11 @@ use std::collections::BTreeSet; /// Key under `capabilities.experimental` on both sides of the exchange. const EXPERIMENTAL_KEY: &str = "litheFunctions"; -/// Plugin-function protocol versions this client speaks. Version 2 keeps -/// every rule of version 1 and adds the host services of `host_request`. -/// Version 3 keeps version 2 and adds `lithe/host/getCapabilities`, -/// subscription disposal, workspace contexts, a workspace context on -/// invocations, and typed host failure codes. -/// Version 4 keeps version 3 and adds the cross-plugin function relay -/// (`lithe/host/functions/*`) and the Core-written `caller` of an invocation. -const SUPPORTED_VERSIONS: [u64; 4] = [1, 2, 3, 4]; -/// First protocol version that offers host services. -const HOST_SERVICES_VERSION: u64 = 2; -/// First protocol version whose invocations may carry a workspace context -/// and whose host offers capability discovery and workspace contexts. -pub(crate) const CONTEXT_VERSION: u64 = 3; -/// First protocol version whose backend may call the functions other plugins -/// export, and whose invocations carry the Core-written `caller`. -pub(crate) const RELAY_VERSION: u64 = 4; +/// Plugin-function protocol versions this client speaks: the versions of the +/// `backend` capability. +fn supported_versions() -> Vec { + super::offered_versions(capability_versions(BACKEND)) +} /// Feature name recorded in the session's server capabilities once a backend /// answered a supported version with a valid function list. @@ -67,29 +63,31 @@ pub(crate) const MAX_FUNCTION_TIMEOUT_MS: u64 = 60 * 60_000; /// Host service that keeps secrets for the plugin, private to its ID. pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; /// Host service listing open workspace contexts and their folders, granted -/// by `workspace.metadata` to a `backend` 2 process. +/// by `workspace.metadata` to a `backend` process. pub(crate) const HOST_SERVICE_WORKSPACE: &str = "workspace"; -/// Capability discovery. Every plugin-owned session of version 3 has it; +/// Capability discovery. Every negotiated plugin-owned session has it; /// Core answers it without involving the host. const HOST_SERVICE_HOST: &str = "host"; -/// Ending subscriptions other services created. Every plugin-owned session -/// of version 3 has it, because only the session that holds a subscription -/// can name it. +/// Ending subscriptions other services created. Every negotiated +/// plugin-owned session has it, because only the session that holds a +/// subscription can name it. const HOST_SERVICE_SUBSCRIPTIONS: &str = "subscriptions"; /// Calls into the functions other plugins export. The grant comes from the /// manifest's `requires.pluginFunctions.calls`, not from a permission: the /// caller's declaration plus the provider's `exports` is the consent, and /// Core's relay checks both on every call. pub(crate) const HOST_SERVICE_FUNCTIONS: &str = "functions"; -/// Legacy aggregate grants. New methods are granted individually so adding a -/// method to `workspace` or `editor` never widens an existing permission. +/// Services granted as a whole by their aggregate grant. Every other host +/// method is granted individually, so adding a method to `workspace` or +/// `editor` never widens an existing permission. pub(crate) const GRANTABLE_HOST_SERVICES: &[&str] = &[ HOST_SERVICE_CREDENTIALS, HOST_SERVICE_WORKSPACE, HOST_SERVICE_FUNCTIONS, ]; -/// Whether a launch grant names an implemented legacy service or host method. +/// Whether a launch grant names a service granted as a whole or an implemented +/// host method. pub(crate) fn is_host_grant(grant: &str) -> bool { GRANTABLE_HOST_SERVICES.contains(&grant) || HOST_METHODS @@ -107,7 +105,7 @@ pub(crate) fn methods_for_permission(permission: &str) -> Vec<&'static str> { .collect() } -/// Permissions for the additive host services of backend 2. +/// Permissions for the additive host services. pub(crate) const HOST_PERMISSIONS: &[&str] = &[ "workspace.files.read", "workspace.files.watch", @@ -194,8 +192,7 @@ pub(crate) mod codes { pub const HOST_PERMISSION_DENIED: &str = "plugin.host.permissionDenied"; /// A host-service request has malformed or unknown params. pub const HOST_INVALID_PARAMS: &str = "plugin.host.invalidParams"; - /// The host could not perform the service, or too many requests of - /// the session were already waiting for it. + /// The host could not perform the service. pub const HOST_FAILED: &str = "plugin.host.failed"; /// The request names a workspace context that was closed or replaced /// by a newer generation. @@ -251,7 +248,7 @@ pub(crate) mod codes { /// exited. Reported by hosts to pages, never sent to a backend. pub const BACKEND_EXITED: &str = "plugin.backend.exited"; - // Cross-plugin relay (`lithe/host/functions/*`, protocol version 4). Each + // Cross-plugin relay (`lithe/host/functions/*`). Each // refusal has its own code and number, so a caller can branch on either. /// The caller's manifest does not declare a call to this function. @@ -323,7 +320,10 @@ pub(crate) struct FunctionProgress { /// The client half of the negotiation, placed under `capabilities.experimental`. pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { - (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) + ( + EXPERIMENTAL_KEY, + json!({ "versions": supported_versions() }), + ) } /// The version and functions of a backend that answered a version this @@ -335,7 +335,7 @@ pub(crate) fn negotiate(capabilities: &Value) -> Option<(u64, BTreeSet)> .get("experimental") .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; let version = answer.get("version").and_then(Value::as_u64)?; - if !SUPPORTED_VERSIONS.contains(&version) { + if !supported_versions().contains(&version) { return None; } let functions = match answer.get("functions") { @@ -503,7 +503,8 @@ pub(crate) fn parse_progress(params: Option<&Value>) -> Option /// The stable code for a backend request the host refuses on principle, or /// `None` for any other method. Domain dispatch can route back into the /// requesting backend, whose handler would then wait on itself; host -/// capabilities are offered per protocol version, and version 1 offers none. +/// capabilities are offered only to a plugin-owned session that negotiated +/// the protocol. pub(crate) fn refused_backend_request(method: &str) -> Option<&'static str> { if method == INVOKE_METHOD || method.starts_with("lithe/build/") @@ -576,8 +577,8 @@ pub(crate) enum HostParams { /// `plugin.host.invalidParams` or, for a field it does not know, /// `plugin.host.unsupportedOption` itself; for methods whose params /// Core cannot type without owning their domain. - // No row of version 3 uses it yet; the file, editor, and window services - // that follow add their rows with it. + // No row uses it yet; the file, editor, and window services that follow + // add their rows with it. #[allow(dead_code)] Object, } @@ -587,10 +588,8 @@ pub(crate) struct HostMethod { pub method: &'static str, /// Service whose grant admits the method. pub service: &'static str, - /// First protocol version that defines the method. - pub since: u64, pub params: HostParams, - /// A separate grant per method for new services; legacy services use their aggregate grant. + /// The method's own grant, or `None` when the service's aggregate grant admits it. pub permission: Option<&'static str>, } @@ -603,301 +602,258 @@ pub(crate) const HOST_METHODS: &[HostMethod] = &[ method: RUN_START, service: HOST_SERVICE_RUN, permission: Some("run.start"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/run/stop", service: HOST_SERVICE_RUN, permission: Some("run.start"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/debug/start", service: "debug", permission: Some("debug.start"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: FUNCTIONS_PROVIDERS, service: HOST_SERVICE_FUNCTIONS, permission: None, - since: RELAY_VERSION, params: HostParams::FunctionProviders, }, HostMethod { method: FUNCTIONS_INVOKE, service: HOST_SERVICE_FUNCTIONS, permission: None, - since: RELAY_VERSION, params: HostParams::FunctionInvoke, }, HostMethod { method: "lithe/host/keymapImport/listInputs", service: "keymapImport", permission: Some("keymaps.readSelectedSources"), - since: CONTEXT_VERSION, params: HostParams::KeymapSnapshot { chunk: false }, }, HostMethod { method: "lithe/host/keymapImport/readChunk", service: "keymapImport", permission: Some("keymaps.readSelectedSources"), - since: CONTEXT_VERSION, params: HostParams::KeymapSnapshot { chunk: true }, }, HostMethod { method: "lithe/host/terminal/reopen", service: "terminal", permission: Some("terminal.create"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/terminal/dispose", service: "terminal", permission: Some("terminal.create"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/terminal/create", service: "terminal", permission: Some("terminal.create"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/terminal/show", service: "terminal", permission: Some("terminal.create"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/terminal/close", service: "terminal", permission: Some("terminal.create"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/terminal/onExit", service: "terminal", permission: Some("terminal.create"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: CREDENTIALS_GET, service: HOST_SERVICE_CREDENTIALS, permission: None, - since: 2, params: HostParams::Credential { with_secret: false }, }, HostMethod { method: CREDENTIALS_SET, service: HOST_SERVICE_CREDENTIALS, permission: None, - since: 2, params: HostParams::Credential { with_secret: true }, }, HostMethod { method: CREDENTIALS_DELETE, service: HOST_SERVICE_CREDENTIALS, permission: None, - since: 2, params: HostParams::Credential { with_secret: false }, }, HostMethod { method: GET_CAPABILITIES, service: HOST_SERVICE_HOST, permission: None, - since: CONTEXT_VERSION, params: HostParams::Empty, }, HostMethod { method: SUBSCRIPTIONS_DISPOSE, service: HOST_SERVICE_SUBSCRIPTIONS, permission: None, - since: CONTEXT_VERSION, params: HostParams::Subscription, }, HostMethod { method: WORKSPACE_LIST_CONTEXTS, service: HOST_SERVICE_WORKSPACE, permission: None, - since: CONTEXT_VERSION, params: HostParams::Empty, }, HostMethod { method: WORKSPACE_GET_CONTEXT, service: HOST_SERVICE_WORKSPACE, permission: None, - since: CONTEXT_VERSION, params: HostParams::Context, }, HostMethod { method: WORKSPACE_GET_FOLDERS, service: HOST_SERVICE_WORKSPACE, permission: None, - since: CONTEXT_VERSION, params: HostParams::Context, }, HostMethod { method: "lithe/host/workspace/readDirectory", service: "workspace", permission: Some("workspace.files.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/workspace/stat", service: "workspace", permission: Some("workspace.files.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/workspace/readFile", service: "workspace", permission: Some("workspace.files.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/workspace/watch", service: "workspace", permission: Some("workspace.files.watch"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/workspace/findFiles", service: "workspace", permission: Some("workspace.search"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/workspace/findText", service: "workspace", permission: Some("workspace.search"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/documents/list", service: "documents", permission: Some("documents.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/documents/getSnapshot", service: "documents", permission: Some("documents.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/registerActions", service: "editor", permission: Some("editor.actions"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/registerProviders", service: "editor", permission: Some("editor.providers"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/getActive", service: "editor", permission: Some("documents.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/getSelections", service: "editor", permission: Some("documents.read"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/applyEdits", service: "editor", permission: Some("editor.edit"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/replaceSelections", service: "editor", permission: Some("editor.edit"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/insertText", service: "editor", permission: Some("editor.edit"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/open", service: "editor", permission: Some("editor.open"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/editor/reveal", service: "editor", permission: Some("editor.open"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/preview/openHTML", service: "preview", permission: Some("preview.open"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/browser/openExternal", service: "browser", permission: Some("browser.open"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/window/showInformation", service: "window", permission: Some("window.messages"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/window/showWarning", service: "window", permission: Some("window.messages"), - since: CONTEXT_VERSION, params: HostParams::Object, }, HostMethod { method: "lithe/host/window/showError", service: "window", permission: Some("window.messages"), - since: CONTEXT_VERSION, params: HostParams::Object, }, // Plan API-25: the plugin's own language servers. The host resolves @@ -907,21 +863,18 @@ pub(crate) const HOST_METHODS: &[HostMethod] = &[ method: LANGUAGE_SERVER_REQUEST, service: HOST_SERVICE_LANGUAGE_SERVER, permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), - since: CONTEXT_VERSION, params: HostParams::LanguageServerRequest, }, HostMethod { method: LANGUAGE_SERVER_NOTIFY, service: HOST_SERVICE_LANGUAGE_SERVER, permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), - since: CONTEXT_VERSION, params: HostParams::LanguageServerNotify, }, HostMethod { method: LANGUAGE_SERVER_SET_STATUS, service: HOST_SERVICE_LANGUAGE_SERVER, permission: Some(PERMISSION_LANGUAGE_SERVER_REQUEST), - since: CONTEXT_VERSION, params: HostParams::LanguageServerStatus, }, // Plan API-12: read-only text of a URI scheme the backend serves. @@ -929,7 +882,6 @@ pub(crate) const HOST_METHODS: &[HostMethod] = &[ method: DOCUMENTS_REGISTER_CONTENT_PROVIDER, service: "documents", permission: Some(PERMISSION_DOCUMENTS_CONTENT_PROVIDER), - since: CONTEXT_VERSION, params: HostParams::ContentProvider, }, ]; @@ -951,8 +903,8 @@ const LANGUAGE_SERVER_DID_CHANGE_STATE: &str = "lithe/host/languageServer/didCha const LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION: &str = "lithe/host/languageServer/didReceiveNotification"; -/// Services every plugin-owned session of their version has without a -/// permission: they reveal nothing beyond the session's own grants. +/// Services every negotiated plugin-owned session has without a permission: +/// they reveal nothing beyond the session's own grants. fn is_implicit_service(service: &str) -> bool { service == HOST_SERVICE_HOST || service == HOST_SERVICE_SUBSCRIPTIONS @@ -960,23 +912,21 @@ fn is_implicit_service(service: &str) -> bool { } /// Where a backend request for a host service goes, or why it is refused. -/// `version` is the negotiated protocol version (0 when not negotiated) and -/// `granted` the services the plugin's permissions grant the session. The -/// params are checked here, so the host only ever sees a well-formed -/// request. From version 3 a field the method does not define is +/// `version` is the negotiated protocol version, `None` when the backend did +/// not negotiate one, and `granted` the services the plugin's permissions +/// grant the session. The params are checked here, so the host only ever sees +/// a well-formed request. A field the method does not define is /// `plugin.host.unsupportedOption` naming it, so a later host can add it as /// an option without a new version and a backend can tell an option this /// host lacks from a malformed request. pub(crate) fn host_request( method: &str, params: Option<&Value>, - version: u64, + version: Option, granted: &BTreeSet, ) -> Result { - let Some(entry) = HOST_METHODS - .iter() - .find(|entry| entry.method == method && version >= entry.since.max(HOST_SERVICES_VERSION)) - else { + let entry = version.and_then(|_| HOST_METHODS.iter().find(|entry| entry.method == method)); + let (Some(version), Some(entry)) = (version, entry) else { return Err(HostRequestRefusal { rpc_code: -32601, code: codes::HOST_UNAVAILABLE, @@ -992,20 +942,13 @@ pub(crate) fn host_request( }) { return Err(HostRequestRefusal { - // Version 2 answered a missing grant like a missing method. - rpc_code: if version >= CONTEXT_VERSION { - host_rpc_code(codes::HOST_PERMISSION_DENIED) - } else { - -32601 - }, + rpc_code: host_rpc_code(codes::HOST_PERMISSION_DENIED), code: codes::HOST_PERMISSION_DENIED, message: "The plugin does not declare the permission for this host service.", details: None, }); } - if version >= CONTEXT_VERSION - && params.is_some_and(|value| value.to_string().len() > MAX_RESULT_BYTES) - { + if params.is_some_and(|value| value.to_string().len() > MAX_RESULT_BYTES) { return Err(HostRequestRefusal { rpc_code: host_rpc_code(codes::HOST_LIMIT_EXCEEDED), code: codes::HOST_LIMIT_EXCEEDED, @@ -1013,7 +956,7 @@ pub(crate) fn host_request( details: None, }); } - check_host_params(entry.params, params, version)?; + check_host_params(entry.params, params)?; if entry.method == GET_CAPABILITIES { return Ok(HostRoute::Answer(capabilities_answer(version, granted))); } @@ -1057,18 +1000,17 @@ pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> V } }; for entry in HOST_METHODS.iter().filter(|entry| { - version >= entry.since - && if entry.permission.is_some() { - granted.contains(entry.method) - } else { - admitted(entry.service) - } + if entry.permission.is_some() { + granted.contains(entry.method) + } else { + admitted(entry.service) + } }) { add(entry.service, "methods", entry.method); } for entry in HOST_NOTIFICATIONS .iter() - .filter(|entry| version >= entry.since && entry.admitted(granted)) + .filter(|entry| entry.admitted(granted)) { add(entry.service, "notifications", entry.method); } @@ -1121,7 +1063,6 @@ pub(crate) struct HostNotification { /// Host method whose individual grant admits it instead of `service`, /// for a notification that reports on a method granted per permission. pub grant: Option<&'static str>, - pub since: u64, pub params: NotificationParams, } @@ -1145,8 +1086,8 @@ const RUN_DID_EXIT: &str = "lithe/host/run/didExit"; const HOST_SERVICE_RUN: &str = "run"; /// Longest opaque run ID a host may hand out. const MAX_RUN_ID_BYTES: usize = 128; -/// The plugin's own UI commands reaching its backend. Every plugin-owned -/// session of version 3 has it; a command names only the plugin's own IDs. +/// The plugin's own UI commands reaching its backend. Every negotiated +/// plugin-owned session has it; a command names only the plugin's own IDs. const HOST_SERVICE_COMMANDS: &str = "commands"; /// Every host-to-backend notification the host may send through @@ -1158,42 +1099,36 @@ pub(crate) const HOST_NOTIFICATIONS: &[HostNotification] = &[ method: WORKSPACE_DID_CHANGE_CONTEXTS, service: HOST_SERVICE_WORKSPACE, grant: None, - since: CONTEXT_VERSION, params: NotificationParams::Revision, }, HostNotification { method: SUBSCRIPTIONS_NOTIFY, service: HOST_SERVICE_SUBSCRIPTIONS, grant: None, - since: CONTEXT_VERSION, params: NotificationParams::SubscriptionEvent, }, HostNotification { method: COMMANDS_DID_RUN, service: HOST_SERVICE_COMMANDS, grant: None, - since: CONTEXT_VERSION, params: NotificationParams::Command, }, HostNotification { method: RUN_DID_EXIT, service: HOST_SERVICE_RUN, grant: Some(RUN_START), - since: CONTEXT_VERSION, params: NotificationParams::RunExit, }, HostNotification { method: LANGUAGE_SERVER_DID_CHANGE_STATE, service: HOST_SERVICE_LANGUAGE_SERVER, grant: None, - since: CONTEXT_VERSION, params: NotificationParams::LanguageServerState, }, HostNotification { method: LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION, service: HOST_SERVICE_LANGUAGE_SERVER, grant: None, - since: CONTEXT_VERSION, params: NotificationParams::LanguageServerNotification, }, ]; @@ -1213,13 +1148,13 @@ fn is_language_server_state(value: &str) -> bool { } /// Whether a host notification reaches this session. `Ok(false)` means the -/// backend negotiated a version without it, so the host may skip it; a -/// method outside the table, one whose service the session was not -/// granted, or malformed params are the host's mistake and refused. +/// backend negotiated no protocol version (`version` is `None`), so the host +/// may skip it; a method outside the table, one whose service the session +/// was not granted, or malformed params are the host's mistake and refused. pub(crate) fn host_notification( method: &str, params: &Value, - version: u64, + version: Option, granted: &BTreeSet, ) -> Result { let invalid = |message: &str| { @@ -1341,7 +1276,7 @@ pub(crate) fn host_notification( "Notification params do not match the notification.", )); } - Ok(version >= entry.since) + Ok(version.is_some()) } /// A UI command ID as the manifest schema spells it: `[a-z0-9.-]+`. @@ -1355,11 +1290,7 @@ fn is_command_id(value: &str) -> bool { }) } -fn check_host_params( - rule: HostParams, - params: Option<&Value>, - version: u64, -) -> Result<(), HostRequestRefusal> { +fn check_host_params(rule: HostParams, params: Option<&Value>) -> Result<(), HostRequestRefusal> { let invalid = |message| HostRequestRefusal { rpc_code: -32602, code: codes::HOST_INVALID_PARAMS, @@ -1417,10 +1348,6 @@ fn check_host_params( } }; if let Some(option) = params.keys().find(|key| !allowed.contains(&key.as_str())) { - // Version 2 answered every unknown field as malformed params. - if version < CONTEXT_VERSION { - return Err(invalid("Params name a field this service does not define.")); - } return Err(HostRequestRefusal { rpc_code: host_rpc_code(codes::HOST_UNSUPPORTED_OPTION), code: codes::HOST_UNSUPPORTED_OPTION, @@ -1724,12 +1651,10 @@ pub(crate) fn is_context_reference(value: &Value) -> bool { } /// Adds the workspace context the host named to the params of one -/// `lithe/function/invoke`. A malformed reference is refused. A backend -/// that negotiated a version without contexts receives the call unchanged, -/// so a page works with an older backend exactly as before. +/// `lithe/function/invoke`. A malformed reference is refused, and a call +/// without a context is written unchanged. pub(crate) fn attach_context( invoke_params: &mut Value, - version: u64, context: Option<&Value>, ) -> Result<(), CoreError> { let Some(context) = context else { @@ -1742,10 +1667,8 @@ pub(crate) fn attach_context( codes::INVALID_PARAMS, )); } - if version >= CONTEXT_VERSION { - if let Some(params) = invoke_params.as_object_mut() { - params.insert("context".to_string(), context.clone()); - } + if let Some(params) = invoke_params.as_object_mut() { + params.insert("context".to_string(), context.clone()); } Ok(()) } @@ -1754,28 +1677,23 @@ pub(crate) fn attach_context( /// `lithe/function/invoke` as `caller: { "pluginID": ... }`, a sibling of /// `functionID`, `operationID`, `params` and `context`. Only Core writes it, /// from the identity of the session that made the request, so a callee can -/// key state by caller without trusting anything in the params. A callee that -/// negotiated a version before the relay never receives it, and a call the +/// key state by caller without trusting anything in the params. A call the /// host itself starts (`caller` is `None`) never carries one: its absence /// means the host. -pub(crate) fn attach_caller(invoke_params: &mut Value, version: u64, caller: Option<&str>) { +pub(crate) fn attach_caller(invoke_params: &mut Value, caller: Option<&str>) { let Some(caller) = caller else { return; }; - if version < RELAY_VERSION { - return; - } if let Some(params) = invoke_params.as_object_mut() { params.insert("caller".to_string(), json!({ "pluginID": caller })); } } -/// The JSON-RPC error code of a host-service code from version 3 on. Each -/// code has its own number, so a backend can branch on the number alone: -/// codes with a standard meaning keep the JSON-RPC or LSP number, and the -/// rest take numbers from the server-defined range, after -32000, which -/// backends use for their own failures. Versions 1 and 2 keep their -/// earlier numbers, see `host_request`. +/// The JSON-RPC error code of a host-service code. Each code has its own +/// number, so a backend can branch on the number alone: codes with a +/// standard meaning keep the JSON-RPC or LSP number, and the rest take +/// numbers from the server-defined range, after -32000, which backends use +/// for their own failures. pub(crate) fn host_rpc_code(code: &str) -> i64 { match code { codes::HOST_UNAVAILABLE => -32601, @@ -1815,7 +1733,7 @@ pub(crate) fn host_rpc_code(code: &str) -> i64 { } } -/// The `details` of a host failure a version 3 backend receives in +/// The `details` of a host failure a backend receives in /// `error.data.details`: an object of at most `MAX_HOST_DETAILS_BYTES` /// serialized, such as the current version of a `versionConflict` or the /// index of the edit a range error names. Anything else is dropped, so a @@ -1829,7 +1747,7 @@ pub(crate) fn bounded_host_details(details: Option<&Value>) -> Option { (size <= MAX_HOST_DETAILS_BYTES).then(|| details.clone()) } -/// The code and JSON-RPC error code a version 3 backend receives when the +/// The code and JSON-RPC error code a backend receives when the /// host failed a forwarded request. Only the codes a host may report pass /// through; anything else, including none, is `plugin.host.failed`, so a /// host cannot impersonate Core's own refusals of other kinds. @@ -2020,7 +1938,7 @@ mod tests { Some(_) => assert_eq!( negotiated, Some(( - expected["version"].as_u64().unwrap_or(1), + expected["version"].as_u64().unwrap_or(0), id_set(&expected["functions"]) )), "{}", @@ -2155,7 +2073,7 @@ mod tests { let outcome = host_request( case["method"].as_str().unwrap(), case.get("params"), - case["version"].as_u64().unwrap(), + Some(0), &granted, ); let expected = &case["expected"]; @@ -2206,11 +2124,7 @@ mod tests { fn invocation_contexts_follow_the_fixture() { for case in cases("invocationContexts") { let mut params = case["params"].clone(); - let outcome = attach_context( - &mut params, - case["version"].as_u64().unwrap(), - case.get("context"), - ); + let outcome = attach_context(&mut params, case.get("context")); let expected = &case["expected"]; match expected["code"].as_str() { Some(code) => assert_eq!( @@ -2231,11 +2145,7 @@ mod tests { fn invocation_callers_follow_the_fixture() { for case in cases("invocationCallers") { let mut params = case["params"].clone(); - attach_caller( - &mut params, - case["version"].as_u64().unwrap(), - case["caller"].as_str(), - ); + attach_caller(&mut params, case["caller"].as_str()); assert_eq!(params, case["expected"]["params"], "{}", name(&case)); } } @@ -2270,8 +2180,8 @@ mod tests { assert!(!numbers.contains(&-32603)); } - /// The relay's discovery and invocation requests are not the old - /// recursive call: the backend still cannot send `lithe/function/invoke`. + /// The relay's discovery and invocation requests are not the recursive + /// call: a backend cannot send `lithe/function/invoke`. #[test] fn relay_methods_do_not_weaken_the_recursive_call_refusal() { assert_eq!( @@ -2305,7 +2215,7 @@ mod tests { let outcome = host_notification( case["method"].as_str().unwrap(), &case["params"], - case["version"].as_u64().unwrap(), + Some(0), &granted, ); let expected = &case["expected"]; @@ -2346,16 +2256,37 @@ mod tests { "sequence": 1, "event": { "text": "x".repeat(MAX_HOST_NOTIFICATION_BYTES) } }); - let refusal = host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, 3, &BTreeSet::new()) + let refusal = host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, Some(0), &BTreeSet::new()) .expect_err("the notification is too large"); assert_eq!(refusal.details.as_deref(), Some(codes::HOST_INVALID_PARAMS)); } + /// A backend that negotiated no protocol version is offered no host + /// service and receives no host notification, whatever it was granted. + #[test] + fn an_un_negotiated_backend_gets_no_host_services() { + let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); + let refusal = host_request(GET_CAPABILITIES, None, None, &granted) + .expect_err("no negotiated version"); + assert_eq!(refusal.code, codes::HOST_UNAVAILABLE); + assert_eq!(refusal.rpc_code, -32601); + assert!(host_request(GET_CAPABILITIES, None, Some(0), &granted).is_ok()); + let params = json!({ "subscriptionID": "s1", "sequence": 1, "event": {} }); + assert!( + !host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, None, &granted) + .expect("a well-formed notification is skipped, not refused") + ); + assert!( + host_notification(SUBSCRIPTIONS_NOTIFY, ¶ms, Some(0), &granted) + .expect("a negotiated backend receives it") + ); + } + /// Capability discovery lists only what the session may call. #[test] fn capabilities_list_only_granted_services() { let granted = BTreeSet::from([HOST_SERVICE_WORKSPACE.to_string()]); - let answer = capabilities_answer(3, &granted); + let answer = capabilities_answer(0, &granted); let services = answer["services"].as_object().expect("services"); assert!(services.contains_key(HOST_SERVICE_WORKSPACE)); assert!(services.contains_key(HOST_SERVICE_HOST)); @@ -2383,7 +2314,7 @@ mod tests { host_request( CREDENTIALS_SET, Some(&json!({ "key": "k", "secret": secret })), - 2, + Some(0), &granted, ) }; @@ -2403,32 +2334,31 @@ mod tests { let granted = BTreeSet::from([method.to_string()]); let valid = json!({ "snapshotID": "abc-123", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131072 }); assert!(matches!( - host_request(method, Some(&valid), 3, &granted), + host_request(method, Some(&valid), Some(0), &granted), Ok(HostRoute::Forward("keymapImport")) )); - assert!(host_request(method, Some(&valid), 2, &granted).is_err()); - assert!(host_request(method, Some(&valid), 3, &BTreeSet::new()).is_err()); + assert!(host_request(method, Some(&valid), Some(0), &BTreeSet::new()).is_err()); for invalid in [ json!({ "snapshotID": "../private", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1 }), json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 131073 }), json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": -1, "maximumBytes": 1 }), json!({ "snapshotID": "abc", "providerID": "editor", "fileID": "file-1", "offset": 0, "maximumBytes": 1, "path": "/private" }), ] { - assert!(host_request(method, Some(&invalid), 3, &granted).is_err()); + assert!(host_request(method, Some(&invalid), Some(0), &granted).is_err()); } let list = "lithe/host/keymapImport/listInputs"; let list_grant = BTreeSet::from([list.to_string()]); assert!(host_request( list, Some(&json!({ "snapshotID": "abc", "providerID": "editor" })), - 3, + Some(0), &list_grant ) .is_ok()); assert!(host_request( list, Some(&json!({ "snapshotID": "abc", "providerID": "editor", "path": "secret" })), - 3, + Some(0), &list_grant ) .is_err()); @@ -2493,7 +2423,7 @@ mod tests { fn capabilities_list_language_server_methods_only_when_granted() { let services = |granted: &[&str]| { let granted = granted.iter().map(|method| method.to_string()).collect(); - capabilities_answer(3, &granted)["services"].clone() + capabilities_answer(0, &granted)["services"].clone() }; assert!(services(&[]).get(HOST_SERVICE_LANGUAGE_SERVER).is_none()); let granted = services(&[LANGUAGE_SERVER_REQUEST, LANGUAGE_SERVER_SET_STATUS]); diff --git a/rust/lithe-core/src/lsp/interface/plugin_logging.rs b/rust/lithe-core/src/lsp/interface/plugin_logging.rs index bf20d4fd..5fdbb76b 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_logging.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_logging.rs @@ -1,5 +1,9 @@ //! Validates plugin diagnostic facts before handing them to the host's single writer. +//! +//! The protocol version is the plugin API version (the base contract), +//! negotiated in `initialize` under `capabilities.experimental.litheLogging`. +use crate::plugins::declarative::SUPPORTED_PLUGIN_API_VERSIONS; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -30,8 +34,24 @@ struct Input { operation_id: Option, } +/// Plugin-logging protocol versions this client speaks: the plugin API +/// versions, since the protocol belongs to the base contract. It carries +/// closed diagnostic facts sent as `lithe/log` notifications. +fn supported_versions() -> Vec { + super::offered_versions(SUPPORTED_PLUGIN_API_VERSIONS) +} + +/// The client half of the negotiation, placed under `capabilities.experimental`. +pub(super) fn client_experimental_capabilities() -> (&'static str, Value) { + (FEATURE, json!({ "versions": supported_versions() })) +} + +/// Whether the server's `initialize` capabilities chose a version this client offered. pub(super) fn negotiated(capabilities: &Value) -> bool { - capabilities.pointer("/experimental/litheLogging/version") == Some(&json!(1)) + capabilities + .pointer("/experimental/litheLogging/version") + .and_then(Value::as_u64) + .is_some_and(|version| supported_versions().contains(&version)) } fn token(value: &str, limit: usize) -> bool { @@ -206,11 +226,16 @@ mod tests { let fact = decode("io.example.database", &BTreeSet::new(), &input).unwrap(); assert_eq!(fact.fields["operation"], operation); } + assert_eq!( + client_experimental_capabilities(), + ("litheLogging", json!({"versions":[0]})) + ); assert!(negotiated( - &json!({"experimental":{"litheLogging":{"version":1}}}) + &json!({"experimental":{"litheLogging":{"version":0}}}) )); assert!(!negotiated( - &json!({"experimental":{"litheLogging":{"version":2}}}) + &json!({"experimental":{"litheLogging":{"version":1}}}) )); + assert!(!negotiated(&json!({"experimental":{"litheLogging":{}}}))); } } diff --git a/rust/lithe-core/src/lsp/interface/toolchain_provider.rs b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs index 5d8ce8e7..b6966696 100644 --- a/rust/lithe-core/src/lsp/interface/toolchain_provider.rs +++ b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs @@ -2,19 +2,23 @@ //! //! The manifest lists the providers; `initialize` offers them under //! `capabilities.experimental.litheToolchains`, and the backend selects a version -//! and a subset of the offer. `toolchains/query` is written only to a session whose +//! (the `toolchainProviders` capability version) and a subset of the offer. `toolchains/query` is written only to a session whose //! answer passed [`negotiate`]. Replies stay untrusted here: Core transports them //! unchanged, and `toolchains.decodeReply` validates them against native facts. //! The rules live in `shared/contracts/plugins/v1/toolchains/README.md`. +use crate::plugins::capabilities::{versions as capability_versions, TOOLCHAIN_PROVIDERS}; use serde_json::{json, Value}; use std::collections::BTreeSet; /// Key under `capabilities.experimental` on both sides of the exchange. const EXPERIMENTAL_KEY: &str = "litheToolchains"; -/// Toolchain-provider protocol versions this client speaks. -const SUPPORTED_VERSIONS: [u64; 1] = [1]; +/// Toolchain-provider protocol versions this client speaks: the versions of the +/// `toolchainProviders` capability. +fn supported_versions() -> Vec { + super::offered_versions(capability_versions(TOOLCHAIN_PROVIDERS)) +} /// The only method this protocol writes. pub(crate) const QUERY_METHOD: &str = "toolchains/query"; @@ -29,7 +33,7 @@ pub(crate) fn client_experimental_capabilities( (!providers.is_empty()).then(|| { ( EXPERIMENTAL_KEY, - json!({ "versions": SUPPORTED_VERSIONS, "providers": providers }), + json!({ "versions": supported_versions(), "providers": providers }), ) }) } @@ -40,7 +44,7 @@ pub(crate) fn client_experimental_capabilities( pub(crate) fn negotiate(capabilities: &Value, offered: &BTreeSet) -> Option> { let answer = capabilities.pointer(&format!("/experimental/{EXPERIMENTAL_KEY}"))?; let version = answer.get("version").and_then(Value::as_u64)?; - if !SUPPORTED_VERSIONS.contains(&version) { + if !supported_versions().contains(&version) { return None; } let mut selected = BTreeSet::new(); @@ -78,7 +82,7 @@ mod tests { assert_eq!(key, "litheToolchains"); assert_eq!( offer, - json!({ "versions": [1], "providers": ["asdf", "mise"] }) + json!({ "versions": [0], "providers": ["asdf", "mise"] }) ); } @@ -86,19 +90,19 @@ mod tests { fn negotiation_accepts_only_a_complete_subset_of_the_offer() { assert_eq!( negotiate( - &answer(json!({ "version": 1, "providers": ["mise"], "future": true })), + &answer(json!({ "version": 0, "providers": ["mise"], "future": true })), &offered() ), Some(vec!["mise".to_string()]) ); for rejected in [ - json!({ "version": 2, "providers": ["mise"] }), - json!({ "version": 1 }), - json!({ "version": 1, "providers": [] }), - json!({ "version": 1, "providers": "mise" }), - json!({ "version": 1, "providers": ["mise", "mise"] }), - json!({ "version": 1, "providers": ["mise", "undeclared"] }), - json!({ "version": 1, "providers": ["mise", 7] }), + json!({ "version": 1, "providers": ["mise"] }), + json!({ "version": 0 }), + json!({ "version": 0, "providers": [] }), + json!({ "version": 0, "providers": "mise" }), + json!({ "version": 0, "providers": ["mise", "mise"] }), + json!({ "version": 0, "providers": ["mise", "undeclared"] }), + json!({ "version": 0, "providers": ["mise", 7] }), ] { assert_eq!( negotiate(&answer(rejected.clone()), &offered()), diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index e429a336..895a347c 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -189,7 +189,7 @@ pub struct LspClientState { #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub plugin_functions: BTreeSet, /// The `litheFunctions` version the backend chose; `None` when the - /// protocol was not negotiated. Version 2 adds host services. + /// protocol was not negotiated. #[serde(default, skip_serializing_if = "Option::is_none")] pub plugin_function_version: Option, /// Host services the plugin's permissions grant this session, from diff --git a/rust/lithe-core/src/lsp/interface/vcs_provider.rs b/rust/lithe-core/src/lsp/interface/vcs_provider.rs index 23a6f626..71b7919b 100644 --- a/rust/lithe-core/src/lsp/interface/vcs_provider.rs +++ b/rust/lithe-core/src/lsp/interface/vcs_provider.rs @@ -1,13 +1,15 @@ //! The VCS-provider protocol a plugin's LSP base-protocol process speaks. //! //! The manifest says how to start the process and which VCS it provides; -//! the protocol version, the VCS ID, and the provider's capabilities are -//! negotiated in `initialize` under `capabilities.experimental.litheVcs`. +//! the protocol version (the `vcsProviders` capability version), the VCS ID, +//! and the provider's capabilities are negotiated in `initialize` under +//! `capabilities.experimental.litheVcs`. //! Only plugin-owned sessions are offered the protocol, and requests are //! written only to a session whose answer named a version this client //! offered, a valid VCS ID, and every required capability. The rules live in //! `shared/contracts/vcs-provider-v1.md`. +use crate::plugins::capabilities::{versions as capability_versions, VCS_PROVIDERS}; use crate::vcs::{negotiated_capabilities, Capabilities}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -15,8 +17,11 @@ use serde_json::{json, Value}; /// Key under `capabilities.experimental` on both sides of the exchange. const EXPERIMENTAL_KEY: &str = "litheVcs"; -/// VCS-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 1] = [1]; +/// VCS-provider protocol versions this client speaks: the versions of the +/// `vcsProviders` capability. +fn supported_versions() -> Vec { + super::offered_versions(capability_versions(VCS_PROVIDERS)) +} /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. @@ -54,13 +59,16 @@ pub struct VcsNegotiation { /// The VCS the process provides; the host checks it against the /// manifest entry it launched. pub vcs: String, - /// Capabilities of protocol version 1 the provider supports. + /// Capabilities the provider supports. pub capabilities: Capabilities, } /// The client half of the negotiation, placed under `capabilities.experimental`. pub(crate) fn client_experimental_capabilities() -> (&'static str, Value) { - (EXPERIMENTAL_KEY, json!({ "versions": SUPPORTED_VERSIONS })) + ( + EXPERIMENTAL_KEY, + json!({ "versions": supported_versions() }), + ) } /// The provider's answer, or `None` when it chose no offered version, named @@ -71,7 +79,7 @@ pub(crate) fn negotiate(capabilities: &Value) -> Option { .get("experimental") .and_then(|experimental| experimental.get(EXPERIMENTAL_KEY))?; let version = answer.get("version").and_then(Value::as_u64)?; - if !SUPPORTED_VERSIONS.contains(&version) { + if !supported_versions().contains(&version) { return None; } let vcs = answer @@ -136,13 +144,13 @@ mod tests { json!({ "experimental": { "litheVcs": value } }) } - const REQUIRED: &str = r#"{ "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1 }"#; + const REQUIRED: &str = r#"{ "status": 0, "baseContent": 0, "checkin": 0, "rollback": 0 }"#; #[test] fn only_a_complete_answer_to_an_offered_version_counts() { let capabilities: Value = serde_json::from_str(REQUIRED).expect("JSON"); let negotiation = negotiate(&answer(json!({ - "version": 1, "vcs": "svn", "capabilities": capabilities, "future": true + "version": 0, "vcs": "svn", "capabilities": capabilities, "future": true }))) .expect("negotiated"); assert_eq!(negotiation.vcs, "svn"); @@ -151,10 +159,10 @@ mod tests { // capability map, or a missing required capability must keep VCS // requests off the session. for rejected in [ - json!({ "version": 2, "vcs": "svn", "capabilities": capabilities }), - json!({ "version": 1, "vcs": "SVN", "capabilities": capabilities }), - json!({ "version": 1, "vcs": "svn" }), - json!({ "version": 1, "vcs": "svn", "capabilities": { "status": 1 } }), + json!({ "version": 1, "vcs": "svn", "capabilities": capabilities }), + json!({ "version": 0, "vcs": "SVN", "capabilities": capabilities }), + json!({ "version": 0, "vcs": "svn" }), + json!({ "version": 0, "vcs": "svn", "capabilities": { "status": 0 } }), ] { assert_eq!(negotiate(&answer(rejected.clone())), None, "{rejected}"); } diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index 144b6b89..e8f8f178 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -1,23 +1,21 @@ -//! `backend` of an API level 3 plugin manifest: one long-lived process a -//! plugin starts for itself, independent of any build system, language, or -//! workspace. +//! `backend` of a plugin manifest: one long-lived process a plugin starts for +//! itself, independent of any build system, language, or workspace. //! //! A build provider or VCS provider exists for one domain contract and one //! workspace. A backend serves only the plugin itself: its web pages call the //! backend's [plugin functions](crate::lsp::interface::plugin_function), and //! the backend may ask the host for the primitive services its permissions -//! grant, such as private credential storage. The host keeps one process per -//! plugin on the machine that runs Lithe, and ends it when the plugin is -//! disabled, updated, or uninstalled. +//! grant, such as private credential storage or workspace contexts. The host +//! keeps one process per plugin on the machine that runs Lithe, and ends it +//! when the plugin is disabled, updated, or uninstalled. //! -//! `backend` 1 starts on the first call from one of the plugin's pages, so -//! it needs a web tool window. `backend` 2 may also start when a workspace -//! opens (`onWorkspaceOpen`) or when one of the plugin's commands runs, so it -//! needs only some way to start. Version 2 is also the only one that can be -//! granted the `workspace` host service. Every workspace reaches the one -//! process as an explicit context in each call, never as process state, so -//! `${workspaceStateDir}` stays refused in both versions; the per-workspace -//! state directory travels in the context instead. +//! The process starts on the first call from one of the plugin's pages, when +//! a workspace opens (`onWorkspaceOpen`), when one of the plugin's commands +//! runs, or when a provider query or exported function needs it, so the +//! manifest declares at least one of those triggers. Every workspace reaches +//! the one process as an explicit context in each call, never as process +//! state, so `${workspaceStateDir}` is refused; the per-workspace state +//! directory travels in the context instead. //! //! The process shares the launch fields, templates, and install review of //! every plugin server process. @@ -41,56 +39,35 @@ pub(crate) struct BackendDeclaration { /// Primitive host services a backend may request, each granted by one /// manifest permission. pub(crate) const HOST_SERVICE_CREDENTIALS: &str = "credentials"; -/// Workspace contexts and their folders (`workspace.metadata`, `backend` 2). +/// Workspace contexts and their folders (`workspace.metadata`). pub(crate) const HOST_SERVICE_WORKSPACE: &str = "workspace"; -/// First `backend` version that may start without a page and may use -/// workspace contexts. -pub(crate) const WORKSPACE_CONTEXT_VERSION: u32 = 2; - -/// Activation event of a `backend` 2 process that starts once a workspace -/// is open. It names no ID: the backend is one process for every workspace. +/// Activation event of a backend that starts once a workspace is open. It +/// names no ID: the backend is one process for every workspace. pub(crate) const ON_WORKSPACE_OPEN: &str = "onWorkspaceOpen"; /// What backend validation needs from the rest of the manifest. pub(crate) struct DeclarationContext<'a> { - pub(crate) plugin_api_version: u32, - /// `requires.backend`; 1 when absent, which only happens below level 3. - pub(crate) version: u32, pub(crate) declared_settings: &'a DeclaredSettings, - /// Whether a tool window of the plugin shows web content, the only - /// caller of a backend in `backend` 1. + /// Whether a tool window of the plugin shows web content, whose pages + /// call the backend. pub(crate) has_web_window: bool, /// Whether the plugin declares a UI command, whose `onCommand` event - /// starts a `backend` 2 process. + /// starts the backend. pub(crate) has_command: bool, /// Whether the manifest lists `onWorkspaceOpen`. pub(crate) activates_on_workspace_open: bool, /// A provider capability may start this backend for a host-owned query. pub(crate) provider_query_trigger: bool, - /// Whether tool names are host runtimes or tool-definition IDs. + /// The tool-definition IDs from `requiresTools`. pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } impl BackendDeclaration { pub(crate) fn validate(&self, context: &DeclarationContext<'_>) -> Validation<()> { - if context.plugin_api_version < 3 { - return Err(invalid( - "backend", - "A plugin backend requires plugin API level 3.", - )); - } - // Only the plugin's own pages call a backend in version 1, so one - // without a web window could never start. Version 2 may also start - // with a workspace, a command or an admitted provider query trigger. - if context.version < WORKSPACE_CONTEXT_VERSION { - if !context.has_web_window { - return Err(invalid( - "backend", - "A plugin backend serves the plugin's own web pages; declare a web tool window.", - )); - } - } else if !context.has_web_window + // A backend nothing can start would never run: it needs a page, a + // command, a workspace event, or a provider query trigger. + if !context.has_web_window && !context.has_command && !context.activates_on_workspace_open && !context.provider_query_trigger @@ -102,13 +79,12 @@ impl BackendDeclaration { } self.process.validate( "backend", - context.plugin_api_version, context.declared_settings, ServerRole::Backend, context.runtime_tools, )?; // One process serves every workspace, so a launch argument cannot - // name one of them; version 2 receives the directory per context. + // name one of them; the directory arrives with each context. if self.process.uses_workspace_state_directory() { return Err(invalid( "backend", @@ -126,14 +102,9 @@ pub(crate) fn programs(backend: &BackendDeclaration) -> Vec { programs } -/// The `backend` member of a launch plan: the host services the session may -/// request, which the host passes to `lsp.startServer` unchanged. Version 2 -/// also names its version, so the host knows it may start the process -/// without a page; a version 1 plan keeps its original shape. +/// The `backend` member of a launch plan: the `backend` capability version +/// the manifest requires and the host services the session may request, +/// which the host passes to `lsp.startServer` unchanged. pub(crate) fn plan_descriptor(version: u32, host_services: &[&str]) -> Value { - if version >= WORKSPACE_CONTEXT_VERSION { - json!({ "hostServices": host_services, "version": version }) - } else { - json!({ "hostServices": host_services }) - } + json!({ "hostServices": host_services, "version": version }) } diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 9a5ae7cf..50bc05ef 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -1,54 +1,58 @@ -//! Capability versions of declarative plugin API level 3. +//! Capability versions of the declarative plugin API. //! -//! Levels 1 and 2 version a manifest as one unit: a change to any section -//! raised the level of every plugin and hid plugins that never use that -//! section from older hosts. Level 3 versions each section on its own. A -//! manifest lists in `requires` exactly the capabilities it uses, each with -//! the version of that capability's contract it was written against, so a -//! plugin without build systems never depends on the build-system contract -//! and a new build-system version only affects plugins that opt into it. +//! A capability owns one manifest section (or one family of fields) and the +//! runtime protocol that serves it. A manifest lists in `requires` exactly the +//! capabilities it uses, each with the version of that capability's contract +//! it was written against, so a plugin without build systems never depends on +//! the build-system contract and a later version of one capability only +//! affects plugins that opt into it. Every capability currently supports only +//! version 0. Everything outside the capability sections, including launches, +//! tool references, and executable candidates, belongs to the base contract +//! (`pluginAPIVersion`) and is never listed in `requires`. +//! +//! Closure rule: a host declares a capability version when it accepts every +//! manifest form of that version and speaks its protocol. A feature a plugin +//! offers may go unpresented by a host without breaking that declaration, and +//! a host service a plugin needs is discovered at runtime (`getCapabilities` +//! and permissions), not through `requires`. +//! +//! A runtime protocol carries the version of the capability that owns it, so +//! the two can never disagree: `litheBuild` is `buildProviders`, +//! `litheFunctions` is `backend`, `litheVcs` is `vcsProviders`, the toolchain +//! protocol is `toolchainProviders`, the keymap protocol is +//! `keymapImportProviders`, and `litheLogging` is `pluginAPIVersion`. Use +//! [`versions`] and `SUPPORTED_PLUGIN_API_VERSIONS` for the versions this +//! Core build speaks. //! //! `requires` is checked before the sections are decoded, so a Core build //! that does not know a capability reports which one is missing instead of //! an unknown field. Hosts state the capability versions they implement in //! `hostCapabilities`; validating a section is not the same as running it. //! -//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-11-11-plugin-contract-v2-baseline.md use super::declarative::{PluginRejection, Validation}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; -/// First plugin API level whose manifests declare capability versions. -pub(crate) const CAPABILITIES_API_VERSION: u32 = 3; - -/// `languages[]`: file types and project markers. Version 3 adds a TextMate -/// `grammar` and an editing `configuration`; see `super::grammar`. Version 2 -/// added a Monaco Monarch grammar and is withdrawn: the desktop editor runs -/// TextMate grammars only, so no host implements it. +/// `languages[]`: file types and project markers, a TextMate `grammar`, and an +/// editing `configuration`; see `super::grammar`. pub(crate) const LANGUAGES: &str = "languages"; -/// `languageServers[]` apart from their launch fields. Version 2 adds -/// `workspaceConfiguration`. Version 3 adds the session fields a plugin -/// backend uses to talk to its own server: `requestMethods`, `commands`, -/// `serializedCommands`, `forwardNotifications`, `state`, and `remote`. +/// `languageServers[]`: the launch, `workspaceConfiguration`, and the session +/// fields a plugin backend uses to talk to its own server (`requestMethods`, +/// `commands`, `serializedCommands`, `forwardNotifications`, `state`, and +/// `remote`). pub(crate) const LANGUAGE_SERVERS: &str = "languageServers"; -/// The launch fields language servers and own build providers share. -/// Version 2 adds `runSelectedRuntime` for debug adapters; version 3 adds -/// `launch.kind: "backend"`, a language server whose command line the -/// plugin backend resolves. -/// Version 4 names runtimes by tool-definition ID (`requiresTools`) instead -/// of the host's closed runtime list. -pub(crate) const SERVER_PROCESS: &str = "serverProcess"; /// `vendor.bundledSignatureRequirement`: origin-scoped host-seal trust for a /// package whose independently installed versions require a publisher signature. pub(crate) const PACKAGE_TRUST: &str = "packageTrust"; /// `executionProviders[]`: run and test templates. pub(crate) const EXECUTION_PROVIDERS: &str = "executionProviders"; -/// `settings[]`. Version 2 adds scopes, effects, settings pages, and the -/// multiSelect, text, file, and directory types; see `super::settings`. +/// `settings[]`: scopes, effects, settings pages, and the multiSelect, text, +/// file, and directory types; see `super::settings`. pub(crate) const SETTINGS: &str = "settings"; -/// `ui`: tool windows and commands. Version 2 adds web content in a tool -/// window. +/// `ui`: tool windows with text, web, or host-rendered native content, and +/// commands. pub(crate) const UI: &str = "ui"; /// Declarative build detection, models, and command plans. pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; @@ -60,33 +64,28 @@ pub(crate) const BUILD_MODEL_INPUTS: &str = "buildModelInputs"; /// process; see `crate::vcs`. pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; -/// `backend`: the plugin's own long-lived process; see `super::backend`. -/// Version 1 serves only the plugin's web pages. Version 2 may start without -/// a page (`onWorkspaceOpen`, a plugin command) and may be granted the -/// `workspace` host service; it needs plugin-function protocol 3 for that. +/// `backend`: the plugin's own long-lived process; see `super::backend`. It +/// may start without a page (`onWorkspaceOpen`, a plugin command) and may be +/// granted the `workspace` host service; it speaks the plugin-function +/// protocol (`litheFunctions`). pub(crate) const BACKEND: &str = "backend"; -/// `projectGenerators[]`: entries of the New Project window. Version 1 lets a -/// field select a host-managed runtime; version 2 selects installations of -/// any plugin-defined tool with a `tool` field instead. +/// `projectGenerators[]`: entries of the New Project window. A field selects +/// installations of any plugin-defined tool with its `tool` field. pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; -/// `debugAdapters[]`: debug adapters implemented by a plugin process; see -/// `crate::debug::adapters`. Plugin API level 4. Version 3 adds declared -/// adapter `features` and `defaults`. +/// `debugAdapters[]`: debug adapters implemented by a plugin process, with +/// declared adapter `features`, `defaults`, and argument and environment +/// bindings; see `crate::debug::adapters`. pub(crate) const DEBUG_ADAPTERS: &str = "debugAdapters"; -/// First plugin API level that may require `debugAdapters` or `serverProcess` 2. -pub(crate) const DEBUG_ADAPTERS_API_VERSION: u32 = 4; - -/// Shared executable candidate order and strict explicit selection. -pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; /// Independent providers that translate external shortcut settings. pub(crate) const KEYMAP_IMPORT_PROVIDERS: &str = "keymapImportProviders"; /// `pluginFunctions`: functions exported to, and called from, other plugins; /// see `super::plugin_functions`. pub(crate) const PLUGIN_FUNCTIONS: &str = "pluginFunctions"; -/// Plugin-owned tool identities, discovery policy and executable candidates. +/// `toolDefinitions[]`: plugin-owned tool identities, discovery policy, and +/// executable candidates. pub(crate) const TOOL_DEFINITIONS: &str = "toolDefinitions"; /// Backend facts that can influence the executable selected for a defined tool. pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; @@ -94,29 +93,27 @@ pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; /// `crate::plugins::output_links`. pub(crate) const OUTPUT_LINK_MATCHERS: &str = "outputLinkMatchers"; -/// Capability versions this Core build validates and plans. Version 1 of a -/// capability keeps the meaning its section had at API level 2. +/// Capability versions this Core build validates and plans. A later version +/// of a capability is appended to its list. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ - (BACKEND, &[1, 2]), - (BUILD_MODEL_INPUTS, &[1]), - (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), - (BUILD_SYSTEMS, &[1, 2, 3, 4]), - (DEBUG_ADAPTERS, &[1, 2, 3]), - (EXECUTION_PROVIDERS, &[1]), - (EXECUTABLE_RESOLUTION, &[1, 2]), - (KEYMAP_IMPORT_PROVIDERS, &[1]), - (LANGUAGE_SERVERS, &[1, 2, 3]), - (LANGUAGES, &[1, 3]), - (OUTPUT_LINK_MATCHERS, &[1]), - (PACKAGE_TRUST, &[1]), - (PLUGIN_FUNCTIONS, &[1]), - (PROJECT_GENERATORS, &[1, 2]), - (SERVER_PROCESS, &[1, 2, 3, 4]), - (SETTINGS, &[1, 2]), - (TOOL_DEFINITIONS, &[1]), - (TOOLCHAIN_PROVIDERS, &[1]), - (UI, &[1, 2, 3, 4]), - (VCS_PROVIDERS, &[1]), + (BACKEND, &[0]), + (BUILD_MODEL_INPUTS, &[0]), + (BUILD_PROVIDERS, &[0]), + (BUILD_SYSTEMS, &[0]), + (DEBUG_ADAPTERS, &[0]), + (EXECUTION_PROVIDERS, &[0]), + (KEYMAP_IMPORT_PROVIDERS, &[0]), + (LANGUAGE_SERVERS, &[0]), + (LANGUAGES, &[0]), + (OUTPUT_LINK_MATCHERS, &[0]), + (PACKAGE_TRUST, &[0]), + (PLUGIN_FUNCTIONS, &[0]), + (PROJECT_GENERATORS, &[0]), + (SETTINGS, &[0]), + (TOOL_DEFINITIONS, &[0]), + (TOOLCHAIN_PROVIDERS, &[0]), + (UI, &[0]), + (VCS_PROVIDERS, &[0]), ]; /// Longest capability ID, so a hostile index cannot grow host state. @@ -128,42 +125,17 @@ pub(crate) type Requires = BTreeMap; /// Capability ID to the versions a host implements, as the host sends them. pub(crate) type HostCapabilities = BTreeMap>; -/// Checks a raw manifest's `requires` before its sections are decoded. Only -/// level 3 manifests may, and must, declare it. -pub(crate) fn check_manifest_requires(manifest: &Value, api_version: u64) -> Validation<()> { - let requires = manifest.get("requires"); - if api_version < u64::from(CAPABILITIES_API_VERSION) { - return match requires { - Some(_) => Err(PluginRejection::at( - "plugin.manifest.invalid", - "requires", - "Capability versions require pluginAPIVersion 3.", - )), - None => Ok(()), - }; - } - let Some(requires) = requires else { +/// Checks a raw manifest's `requires` before its sections are decoded. Every +/// manifest must declare it. +pub(crate) fn check_manifest_requires(manifest: &Value) -> Validation<()> { + let Some(requires) = manifest.get("requires") else { return Err(PluginRejection::at( "plugin.manifest.invalid", "requires", - "A pluginAPIVersion 3 manifest lists the capabilities it uses in requires.", + "A manifest lists the capabilities it uses in requires.", )); }; let requires = parse_requires(requires, "requires", "plugin.manifest.invalid")?; - // Capabilities introduced at level 4 must not leak into level 3 manifests, - // whose hosts were told that level 3 means exactly what it did before. - if api_version < u64::from(DEBUG_ADAPTERS_API_VERSION) { - if let Some((capability, version)) = requires - .iter() - .find(|(capability, version)| requires_api_4(capability, **version)) - { - return Err(PluginRejection::at( - "plugin.api.unsupported", - format!("requires.{capability}"), - format!("Plugin capability {capability} {version} requires pluginAPIVersion 4."), - )); - } - } match requires .iter() .find(|(capability, version)| !core_supports(capability, **version)) @@ -210,10 +182,13 @@ pub(crate) fn parse_requires( } let version = version .as_u64() - .filter(|version| *version >= 1) .and_then(|version| u32::try_from(version).ok()) .ok_or_else(|| { - PluginRejection::at(code, entry, "A capability version is a positive integer.") + PluginRejection::at( + code, + entry, + "A capability version is a non-negative integer.", + ) })?; requires.insert(capability.clone(), version); } @@ -248,8 +223,8 @@ pub(crate) fn check_used(requires: &Requires, used: &BTreeSet<&'static str>) -> } /// Whether a host can run every required capability. A host that sends no -/// capabilities runs no level 3 plugin, and a capability this Core build -/// cannot validate is never hosted, whatever the host claims. +/// capabilities runs no plugin, and a capability this Core build cannot +/// validate is never hosted, whatever the host claims. pub(crate) fn hosts(requires: &Requires, host: Option<&HostCapabilities>) -> bool { first_unhosted(requires, host).is_none() } @@ -281,15 +256,18 @@ fn first_unhosted<'a>( .map(|(capability, version)| (capability.as_str(), *version)) } -/// Capability versions first available at plugin API level 4. -fn requires_api_4(capability: &str, version: u32) -> bool { - capability == DEBUG_ADAPTERS || (capability == SERVER_PROCESS && version >= 2) +/// Versions of `capability` this Core build validates and plans; empty when +/// the capability is unknown. +pub(crate) fn versions(capability: &str) -> &'static [u32] { + SUPPORTED_CAPABILITIES + .iter() + .find(|(id, _)| *id == capability) + .map(|(_, versions)| *versions) + .unwrap_or(&[]) } fn core_supports(capability: &str, version: u32) -> bool { - SUPPORTED_CAPABILITIES - .iter() - .any(|(id, versions)| *id == capability && versions.contains(&version)) + versions(capability).contains(&version) } fn is_capability_id(value: &str) -> bool { diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index ab3a04b0..7c66e8bf 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -1,4 +1,4 @@ -//! Declarative plugin contract v1: manifest validation and deterministic +//! Declarative plugin contract: manifest validation and deterministic //! expansion of run/test command templates and language-server launch plans. //! //! A declarative plugin carries no host-loaded code. The host reads its @@ -11,7 +11,7 @@ //! can show them next to the affected plugin. Only a malformed command //! payload is a Core `invalid_request` failure. -use super::capabilities::{self, HostCapabilities, Requires, CAPABILITIES_API_VERSION}; +use super::capabilities::{self, HostCapabilities, Requires}; use super::language_server_sessions; pub(crate) use super::settings::is_setting_identifier; use super::settings::{ @@ -24,14 +24,11 @@ use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; /// `plugin.json` schema version that identifies a declarative manifest. -/// Version 1 was the retired macOS native-bundle manifest. -pub const DECLARATIVE_MANIFEST_SCHEMA_VERSION: u32 = 2; -/// Declarative plugin API levels this Core build can host. The level is -/// independent of the Lithe application version. -pub const SUPPORTED_PLUGIN_API_VERSIONS: &[u32] = &[1, 2, 3, 4]; -/// First API level with runtime launches, launch template variables, -/// `initializationOptions`, and runtime version requirements. -const LAUNCH_TEMPLATES_API_VERSION: u32 = 2; +pub const DECLARATIVE_MANIFEST_SCHEMA_VERSION: u32 = 0; +/// Base-contract versions (`pluginAPIVersion`) this Core build can host. The +/// version is independent of the Lithe application version, and a later one +/// is appended to this list. +pub const SUPPORTED_PLUGIN_API_VERSIONS: &[u32] = &[0]; /// ID prefix reserved to plugins published and signed by Lithe. pub const OFFICIAL_PLUGIN_NAMESPACE: &str = "dev.lithe"; @@ -40,18 +37,17 @@ const PERMISSION_LANGUAGE_SERVER: &str = "languageServer.launch"; const PERMISSION_RUN: &str = "process.run"; const PERMISSION_TEST: &str = "process.test"; const PERMISSION_UI: &str = "ui.contribute"; -/// Running the plugin's own scripts in a host-managed web view. `ui` 2. +/// Running the plugin's own scripts in a host-managed web view. const PERMISSION_UI_WEB: &str = "ui.web"; /// Running a declared build system's executable: model, task, test, and -/// dependency commands. API level 3. +/// dependency commands. const PERMISSION_BUILD: &str = "process.build"; /// Letting a VCS provider change the working copy and contact its server. -/// API level 3. const PERMISSION_VCS: &str = "process.vcs"; /// Creating projects: writing template files, running scaffolding tools, -/// and downloading project archives. API level 3. +/// and downloading project archives. const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; -/// Starting the plugin's own backend process. `backend` 1. +/// Starting the plugin's own backend process. const PERMISSION_BACKEND: &str = "backend.launch"; /// Supplying facts that can change the executable selected for a defined tool. const PERMISSION_TOOLCHAINS: &str = "toolchains.provide"; @@ -60,17 +56,17 @@ const PERMISSION_KEYMAPS: &str = "keymaps.provide"; /// Reading declared files from the explicitly selected import source. const PERMISSION_KEYMAP_SOURCES: &str = "keymaps.readSelectedSources"; /// Letting the backend keep secrets in the host's credential store, private -/// to the plugin. `backend` 1. +/// to the plugin. const PERMISSION_CREDENTIALS: &str = "credentials.store"; /// Letting the backend list the open workspaces and read their folders and -/// its per-workspace state directory. `backend` 2. +/// its per-workspace state directory. const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; -/// Starting a debug session the plugin's adapter serves. `debugAdapters` 1. +/// Starting a debug session the plugin's adapter serves. const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; -/// Attaching the plugin's adapter to a running process. `debugAdapters` 1. +/// Attaching the plugin's adapter to a running process. const PERMISSION_DEBUG_ATTACH: &str = "debug.attach"; /// Letting the backend start a debug session on one of its own adapters -/// (`lithe/host/debug/start`). A host-method permission of `backend` 2. +/// (`lithe/host/debug/start`), a host-method permission of the backend. const PERMISSION_DEBUG_START: &str = "debug.start"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BACKEND, @@ -90,20 +86,11 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_VCS, PERMISSION_WORKSPACE_METADATA, ]; -/// Runtime tools the host knows how to discover and pass to a language -/// server. The host exports each as `LITHE__PATH` and puts its -/// directory on the server's `PATH`; gopls, for example, runs `go` itself. -/// Build-system runtime dependencies with these IDs are host-managed too. +/// Runtime tools the host discovers and manages itself for build-system +/// runtime dependencies: it exports each as `LITHE__PATH` and puts its +/// directory on the process's `PATH`. Server processes never use this list; +/// they name tool definitions from `requiresTools`. pub(crate) const KNOWN_RUNTIME_TOOLS: &[&str] = &["go", "node"]; -/// Runtimes the host can start a packaged entry file with itself, as -/// ` `. `go` is only ever a -/// dependency of a server. -const LAUNCH_RUNTIME_TOOLS: &[&str] = &["node"]; -/// `serverProcess` version from which runtime tools are tool-definition IDs -/// listed in `requiresTools`, and the closed lists above no longer apply. -/// Below it, a valid short tool-definition ID outside the closed lists names -/// the tool definition with that ID; see `implicit_tool_reference`. -const TOOL_REFERENCES_SERVER_PROCESS: u32 = 4; /// Most tool definitions one plugin may require. const MAX_REQUIRED_TOOLS: usize = 32; /// Native host integrations a language server may ask for. The Core @@ -180,9 +167,8 @@ pub struct DeclarativePluginManifest { version: String, #[serde(rename = "pluginAPIVersion")] plugin_api_version: u32, - /// Capability versions the manifest was written against. API level 3; - /// see `super::capabilities`. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + /// Capability versions the manifest was written against; see + /// `super::capabilities`. requires: Requires, vendor: PluginVendor, #[serde(default)] @@ -197,32 +183,29 @@ pub struct DeclarativePluginManifest { language_servers: Vec, #[serde(default)] execution_providers: Vec, - /// User-adjustable options of the plugin. API level 2; see - /// `super::settings` for version 2 of the capability. + /// User-adjustable options of the plugin; see `super::settings`. #[serde(default, skip_serializing_if = "Vec::is_empty")] settings: Vec, /// Buttons on settings pages that invoke the plugin's functions. - /// `settings` 2. #[serde(default, skip_serializing_if = "Vec::is_empty")] setting_actions: Vec, - /// Project-level build tools the plugin describes. API level 3; the - /// shape and its validation live in `crate::build`. + /// Project-level build tools the plugin describes. The shape and its + /// validation live in `crate::build`. #[serde(default, skip_serializing_if = "Vec::is_empty")] build_systems: Vec, - /// Version control systems the plugin implements. API level 3; the - /// shape and its validation live in `crate::vcs`. + /// Version control systems the plugin implements. The shape and its + /// validation live in `crate::vcs`. #[serde(default, skip_serializing_if = "Vec::is_empty")] vcs_providers: Vec, - /// Debug adapters the plugin implements. API level 4; the shape and its - /// validation live in `crate::debug::adapters`. + /// Debug adapters the plugin implements. The shape and its validation + /// live in `crate::debug::adapters`. #[serde(default, skip_serializing_if = "Vec::is_empty")] debug_adapters: Vec, - /// Entries of the New Project window. API level 3; the shape and its - /// validation live in `crate::project_generators`. + /// Entries of the New Project window. The shape and its validation live + /// in `crate::project_generators`. #[serde(default, skip_serializing_if = "Vec::is_empty")] project_generators: Vec, - /// The plugin's own process serving its pages. `backend` 1; see - /// `super::backend`. + /// The plugin's own process serving its pages; see `super::backend`. #[serde(default, skip_serializing_if = "Option::is_none")] backend: Option, /// Tool policies owned by plugins, never a host-maintained definition table. @@ -234,24 +217,24 @@ pub struct DeclarativePluginManifest { /// Independent shortcut importers implemented by this plugin's backend. #[serde(default, skip_serializing_if = "Vec::is_empty")] keymap_import_providers: Vec, - /// Tool-definition IDs the plugin's processes and Run requests name. - /// `serverProcess` 4; hosts resolve them through plugin-owned toolchains. + /// Tool-definition IDs the plugin's processes and Run requests name; + /// hosts resolve them through plugin-owned toolchains. #[serde(default, skip_serializing_if = "Vec::is_empty")] requires_tools: Vec, - /// Patterns that turn console output into links. `outputLinkMatchers` - /// 1; see `super::output_links`. + /// Patterns that turn console output into links; see + /// `super::output_links`. #[serde(default, skip_serializing_if = "Vec::is_empty")] output_link_matchers: Vec, /// Functions this plugin exports to other plugins and the exported - /// functions it calls. `pluginFunctions` 1; see `super::plugin_functions`. + /// functions it calls; see `super::plugin_functions`. #[serde(default, skip_serializing_if = "Option::is_none")] plugin_functions: Option, } /// UI supplied by plugins. The host owns every window shell: docking, title, -/// activity bar, order, visibility, layout and lifecycle. Version 1 of the -/// `ui` capability is data only; version 2 adds web content that the host -/// loads from the package into a sandboxed web view inside that shell. +/// activity bar, order, visibility, layout and lifecycle. A window body is +/// text, web content that the host loads from the package into a sandboxed +/// web view inside that shell, or host-rendered native content. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct UIContributions { @@ -276,10 +259,10 @@ struct UIToolWindow { /// Exactly one of `text` and `web`. #[serde(default, skip_serializing_if = "Option::is_none")] text: Option, - /// The plugin's own page shown in the window body. `ui` 2. + /// The plugin's own page shown in the window body. #[serde(default, skip_serializing_if = "Option::is_none")] web: Option, - /// Versioned host-rendered tree/list content. `ui` 3. + /// Host-rendered tree/list content. #[serde(default, skip_serializing_if = "Option::is_none")] native: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -314,7 +297,7 @@ enum UIDock { struct UICommand { id: String, title: String, - /// Absent for a backend command from `ui` 4 on. + /// Absent for a command the plugin backend handles. #[serde( default, rename = "toolWindowID", @@ -460,11 +443,10 @@ struct LanguageDeclaration { project_file_names: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] dependencies: Option, - /// Syntax highlighting from a package file. `languages` 3; see - /// `super::grammar`. + /// Syntax highlighting from a package file; see `super::grammar`. #[serde(default, skip_serializing_if = "Option::is_none")] grammar: Option, - /// Comment and bracket behavior of the editor. `languages` 3. + /// Comment and bracket behavior of the editor. #[serde(default, skip_serializing_if = "Option::is_none")] configuration: Option, } @@ -491,18 +473,18 @@ struct LanguageServerDeclaration { #[serde(flatten)] process: ServerProcess, /// Native host integration this server needs, with the package files it - /// consumes. API level 2. + /// consumes. #[serde(default, skip_serializing_if = "Option::is_none")] host_integration: Option, /// Settings the server reads through `workspace/configuration` and /// receives again in `workspace/didChangeConfiguration` whenever one of /// the settings it names changes. String values are templates like /// `initializationOptions`, and may also name settings whose `effect` - /// is `immediate`. `languageServers` 2. + /// is `immediate`. #[serde(default, skip_serializing_if = "Option::is_none")] workspace_configuration: Option, - /// Fields of `languageServers` 3 that govern what the plugin backend may - /// send to its own server and how the host keeps the server's state. + /// Fields that govern what the plugin backend may send to its own server + /// and how the host keeps the server's state. #[serde(flatten)] session: language_server_sessions::SessionDeclaration, } @@ -525,7 +507,7 @@ pub(crate) struct ServerProcess { #[serde(default)] runtime_dependencies: Vec, /// LSP `initializationOptions` sent verbatim after template expansion of - /// its string values. API level 2. + /// its string values. #[serde(default, skip_serializing_if = "Option::is_none")] initialization_options: Option, } @@ -540,59 +522,31 @@ pub(crate) enum ServerRole { DebugAdapter, } -/// Runtime tools a server process may name for its launch and dependencies. +/// Tools a server process may name for its launch and dependencies: the +/// tool-definition IDs the manifest lists in `requiresTools`. #[derive(Debug, Clone, Copy)] -pub(crate) enum RuntimeTools<'a> { - /// The host's closed lists, below `serverProcess` 4, plus implicit - /// references to tool definitions by short ID. - Host, - /// Tool-definition IDs the manifest lists in `requiresTools`. - Declared(&'a BTreeSet), -} - -impl RuntimeTools<'_> { - /// Checks one tool a process names. `host` is the closed list that - /// applies below `serverProcess` 4; there `unsupported` explains a name - /// that is neither in it nor a valid short tool-definition ID. - pub(crate) fn check( - self, - tool: &str, - host: &[&str], - field: String, - unsupported: String, - ) -> Validation<()> { - match self { - Self::Host if host.contains(&tool) || implicit_tool_reference(tool, host) => Ok(()), - Self::Host => Err(PluginRejection::at( - "plugin.runtime.unsupported", - field, - unsupported, - )), - Self::Declared(tools) if tools.contains(tool) => Ok(()), - Self::Declared(_) => Err(PluginRejection::at( +pub(crate) struct RuntimeTools<'a>(&'a BTreeSet); + +impl<'a> RuntimeTools<'a> { + /// The tools a manifest lists in `requiresTools`. + pub(crate) fn new(tools: &'a BTreeSet) -> Self { + Self(tools) + } + + /// Checks that one tool a process names is listed in `requiresTools`. + pub(crate) fn check(self, tool: &str, field: String) -> Validation<()> { + if self.0.contains(tool) { + Ok(()) + } else { + Err(PluginRejection::at( "plugin.runtime.undeclared", field, format!("The tool {tool} is not listed in requiresTools."), - )), + )) } } } -/// Whether a tool named below `serverProcess` 4 refers to the tool -/// definition with the same ID instead of a runtime from the closed list -/// `host`. The reference resolves like one listed in `requiresTools`, so a -/// manifest published before tool definitions keeps working once a plugin -/// provides that definition. Only short IDs qualify: a plugin-qualified ID -/// already needs `serverProcess` 4. Names the host reserves for its own -/// runtimes (`KNOWN_RUNTIME_TOOLS`) never become references, so a runtime -/// the host knows but cannot use in this role stays rejected. -pub(crate) fn implicit_tool_reference(tool: &str, host: &[&str]) -> bool { - !host.contains(&tool) - && !KNOWN_RUNTIME_TOOLS.contains(&tool) - && !tool.contains(':') - && crate::toolchain::definitions::tool_id(tool) -} - impl ServerRole { fn noun(self) -> &'static str { match self { @@ -648,10 +602,10 @@ enum LanguageServerLaunch { /// Bare command names the host resolves through its tool discovery. SystemCommand { commands: Vec }, /// A packaged entry file started by a host-discovered runtime, so the - /// package needs no launcher script. API level 2. + /// package needs no launcher script. Runtime { - /// Runtime that executes `entry`: one of `LAUNCH_RUNTIME_TOOLS`, or a - /// tool-definition ID whose launch template starts the entry. + /// Tool-definition ID, listed in `requiresTools`, whose launch + /// template starts the entry. tool: String, /// Lowest acceptable runtime version, for example `21` or `18.17`. #[serde( @@ -677,8 +631,7 @@ enum LanguageServerLaunch { }, /// The plugin backend resolves the command line each time the server /// starts: the host calls `function` and passes its answer to - /// `plugins.languageServers.decodeLaunch`. Language servers only, - /// `serverProcess` 3. + /// `plugins.languageServers.decodeLaunch`. Language servers only. Backend { /// ID of a function the plugin backend registers. function: String, @@ -797,8 +750,8 @@ pub(crate) enum TemplatePart { Variable(Variable), } -/// Variables of language-server launch templates (API level 2). They name -/// host-owned locations, so a package never embeds a machine path. +/// Variables of language-server launch templates. They name host-owned +/// locations, so a package never embeds a machine path. #[derive(Debug, Clone, PartialEq, Eq)] enum LaunchVariable { /// Root of the installed, read-only plugin package. @@ -914,7 +867,7 @@ pub fn validate_manifest_value(value: Value) -> Validation= CAPABILITIES_API_VERSION { - capabilities::check_used(&self.requires, &self.used_capabilities())?; - } + capabilities::check_used(&self.requires, &self.used_capabilities())?; if self.languages.is_empty() && self.ui.is_none() && self.build_systems.is_empty() @@ -994,20 +944,15 @@ impl DeclarativePluginManifest { && self.toolchain_providers.is_empty() && self.keymap_import_providers.is_empty() && self.output_link_matchers.is_empty() - // A backend 2 may be the whole plugin: it starts on a workspace. + // A backend may be the whole plugin: it starts on a workspace. && self.backend.is_none() { return Err(invalid("languages", "The plugin declares no languages.")); } - let languages_version = self - .requires - .get(capabilities::LANGUAGES) - .copied() - .unwrap_or(1); let mut language_ids = BTreeSet::new(); for (index, language) in self.languages.iter_mut().enumerate() { - language.normalize(index, languages_version)?; + language.normalize(index)?; if !language_ids.insert(language.id.clone()) { return Err(invalid( format!("languages[{index}].id"), @@ -1016,36 +961,15 @@ impl DeclarativePluginManifest { } } self.languages.sort_by(|left, right| left.id.cmp(&right.id)); - if languages_version >= super::grammar::GRAMMAR_LANGUAGES_VERSION - && !self - .languages - .iter() - .any(LanguageDeclaration::uses_grammar_fields) - { - return Err(invalid( - "requires.languages", - "The declared languages version must be used: languages 3 adds grammar and configuration.", - )); - } let declared_settings = self.validate_settings()?; let required_tools = self.validate_required_tools()?; - let runtime_tools = if self.names_tools_by_definition() { - RuntimeTools::Declared(&required_tools) - } else { - RuntimeTools::Host - }; - let has_backend_functions = self.backend.is_some() && self.backend_version() >= 2; + let runtime_tools = RuntimeTools::new(&required_tools); super::output_links::validate_declarations( &mut self.output_link_matchers, - has_backend_functions, + self.backend.is_some(), )?; - let server_version = self - .requires - .get(capabilities::LANGUAGE_SERVERS) - .copied() - .unwrap_or(1); let mut server_languages = BTreeSet::new(); let mut referenced_immediate_settings = BTreeSet::new(); for (index, server) in self.language_servers.iter_mut().enumerate() { @@ -1069,13 +993,7 @@ impl DeclarativePluginManifest { if let Some(configuration) = &server.workspace_configuration { collect_setting_references(configuration, &mut referenced_immediate_settings); } - server.validate( - &field, - self.plugin_api_version, - server_version, - &declared_settings, - runtime_tools, - )?; + server.validate(&field, &declared_settings, runtime_tools)?; } if let Some(id) = declared_settings.first_unreferenced_immediate(&referenced_immediate_settings) @@ -1159,7 +1077,10 @@ impl DeclarativePluginManifest { && self.output_link_matchers.is_empty() && self.backend.is_none() // A grammar alone is a complete highlighting plugin. - && !self.languages.iter().any(LanguageDeclaration::uses_grammar_fields) + && !self + .languages + .iter() + .any(|language| language.grammar.is_some() || language.configuration.is_some()) { return Err(invalid( "languages", @@ -1167,33 +1088,11 @@ impl DeclarativePluginManifest { )); } self.validate_ui()?; - if !self.project_generators.is_empty() && self.plugin_api_version < CAPABILITIES_API_VERSION - { - return Err(invalid( - "projectGenerators", - "Project generators require plugin API level 3.", - )); - } - crate::project_generators::validate_declarations( - &self.project_generators, - self.requires - .get(capabilities::PROJECT_GENERATORS) - .copied() - .unwrap_or(1), - )?; + crate::project_generators::validate_declarations(&self.project_generators)?; crate::build::validate_declarations( &mut self.build_systems, &crate::build::DeclarationContext { debug_adapters: &self.debug_adapters, - plugin_api_version: self.plugin_api_version, - build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), - executable_resolution_version: self - .requires - .get("executableResolution") - .copied() - .unwrap_or(0), - build_providers_version: self.requires.get("buildProviders").copied().unwrap_or(1), - model_inputs_version: self.requires.get("buildModelInputs").copied().unwrap_or(0), official: is_official_namespace(&self.id), declared_settings: &declared_settings, language_servers: &server_languages, @@ -1203,12 +1102,12 @@ impl DeclarativePluginManifest { crate::vcs::validate_declarations( &mut self.vcs_providers, &crate::vcs::DeclarationContext { - plugin_api_version: self.plugin_api_version, declared_settings: &declared_settings, runtime_tools, }, )?; self.validate_debug_adapters(&declared_settings, runtime_tools)?; + self.validate_backend_launches()?; self.validate_toolchains()?; if !self.keymap_import_providers.is_empty() { super::keymap_import::validate_declarations(&mut self.keymap_import_providers)?; @@ -1217,8 +1116,6 @@ impl DeclarativePluginManifest { self.validate_plugin_functions()?; if let Some(backend) = &self.backend { backend.validate(&super::backend::DeclarationContext { - plugin_api_version: self.plugin_api_version, - version: self.backend_version(), declared_settings: &declared_settings, has_web_window: self .ui @@ -1250,49 +1147,20 @@ impl DeclarativePluginManifest { Ok(self) } - /// Capabilities whose sections the manifest declares. Every language - /// server, own build provider, and VCS provider is a `serverProcess`. + /// Capabilities whose sections the manifest declares. Launches, tool + /// references, and executable candidates belong to the base contract and + /// are never listed. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ ( capabilities::PACKAGE_TRUST, self.vendor.bundled_signature_requirement.is_some(), ), - ( - capabilities::EXECUTABLE_RESOLUTION, - !self.tool_definitions.is_empty() - || self.build_systems.iter().any(|system| { - system - .invocation - .executables - .iter() - .chain( - system - .invocation - .runtime_dependencies - .iter() - .flat_map(|runtime| &runtime.executables), - ) - .any(|candidate| candidate.uses_resolution()) - }), - ), (capabilities::LANGUAGES, !self.languages.is_empty()), ( capabilities::LANGUAGE_SERVERS, !self.language_servers.is_empty(), ), - ( - capabilities::SERVER_PROCESS, - !self.language_servers.is_empty() - || !self.requires_tools.is_empty() - || !self.vcs_providers.is_empty() - || !self.debug_adapters.is_empty() - || self.backend.is_some() - || self - .build_systems - .iter() - .any(crate::build::BuildSystemDeclaration::starts_own_provider), - ), ( capabilities::EXECUTION_PROVIDERS, !self.execution_providers.is_empty(), @@ -1352,24 +1220,13 @@ impl DeclarativePluginManifest { .collect() } - /// Checks the adapters, the `debugAdapters` version their declarations - /// need, and that `serverProcess` 2 is required exactly when one starts - /// on the selected runtime. + /// Checks the debug adapters against the plugin's settings, language + /// servers, and `requiresTools`. fn validate_debug_adapters( &mut self, declared_settings: &DeclaredSettings, runtime_tools: RuntimeTools<'_>, ) -> Validation<()> { - // Levels 1 and 2 carry no `requires`, so the capability gate never - // sees an adapter declared there. - if !self.debug_adapters.is_empty() - && self.plugin_api_version < capabilities::DEBUG_ADAPTERS_API_VERSION - { - return Err(invalid( - "debugAdapters", - "Debug adapters require plugin API level 4.", - )); - } let language_servers: BTreeSet = self .language_servers .iter() @@ -1378,148 +1235,34 @@ impl DeclarativePluginManifest { crate::debug::adapters::validate_declarations( &mut self.debug_adapters, &crate::debug::adapters::DeclarationContext { - plugin_api_version: self.plugin_api_version, declared_settings, language_servers: &language_servers, runtime_tools, }, - )?; - if crate::debug::adapters::uses_declared_features(&self.debug_adapters) - && self - .requires - .get(capabilities::DEBUG_ADAPTERS) - .copied() - .unwrap_or(1) - < 3 - { - return Err(invalid( - "requires.debugAdapters", - "Adapter features and defaults require debugAdapters 3.", - )); - } - if crate::debug::adapters::uses_target_arguments(&self.debug_adapters) + ) + } + + /// A language server the plugin backend launches asks the backend for its + /// command line, so the manifest must declare that backend. + fn validate_backend_launches(&self) -> Validation<()> { + if self.backend.is_none() && self - .requires - .get(capabilities::DEBUG_ADAPTERS) - .copied() - .unwrap_or(1) - < 2 + .language_servers + .iter() + .any(|server| server.process.is_backend_launch()) { return Err(invalid( - "requires.debugAdapters", - "Argument and environment bindings require debugAdapters 2.", - )); - } - if self.plugin_api_version >= CAPABILITIES_API_VERSION { - self.validate_server_process_version()?; - } - Ok(()) - } - - /// Checks that `requires.serverProcess` is the lowest version covering - /// the launches the manifest uses. Versions are cumulative: 2 adds the - /// selected-runtime launch of an adapter, 3 the launch a plugin backend - /// resolves, and 4 runtimes named by tool-definition ID, so a host - /// without a version never accepts a process it cannot start. - /// `validate_required_tools` pairs version 4 with `requiresTools`, so 4 - /// is accepted here for every launch below it. - fn validate_server_process_version(&self) -> Validation<()> { - let backend_launch = self - .language_servers - .iter() - .any(|server| server.process.is_backend_launch()); - let selected = crate::debug::adapters::uses_selected_runtime(&self.debug_adapters); - let declared = self - .requires - .get(capabilities::SERVER_PROCESS) - .copied() - .unwrap_or(1); - let tool_references = declared >= TOOL_REFERENCES_SERVER_PROCESS; - if backend_launch { - if declared != 3 && !tool_references { - return Err(PluginRejection::at( - "plugin.manifest.invalid", - "requires.serverProcess", - "serverProcess 3 is required exactly when a language server is launched by the plugin backend.", - )); - } - // Only a backend 2 process receives the workspace context the - // launch function needs. - if self.backend.is_none() || self.backend_version() < 2 { - return Err(invalid( - "backend", - "A language server launched by the plugin backend requires backend 2.", - )); - } - return Ok(()); - } - if tool_references { - return Ok(()); - } - if declared == 3 { - return Err(PluginRejection::at( - "plugin.manifest.invalid", - "requires.serverProcess", - "serverProcess 3 is required exactly when a language server is launched by the plugin backend.", - )); - } - if selected != (declared == 2) { - return Err(PluginRejection::at( - "plugin.manifest.invalid", - "requires.serverProcess", - "serverProcess 2 is required exactly when an adapter runs on the selected runtime.", + "backend", + "A language server launched by the plugin backend requires the backend section.", )); } Ok(()) } fn validate_toolchains(&self) -> Validation<()> { - let build_toolchains = self.build_systems.iter().any(|system| { - system - .invocation - .executables - .iter() - .chain( - system - .invocation - .runtime_dependencies - .iter() - .flat_map(|runtime| &runtime.executables), - ) - .any(|candidate| { - matches!( - candidate.source, - super::executables::ExecutableSource::Toolchain { .. } - ) - }) - || system.names_tool_definitions() - }); - if self.tool_definitions.is_empty() - && !build_toolchains - && self.requires.get(capabilities::EXECUTABLE_RESOLUTION) == Some(&2) - { - return Err(invalid( - "requires.executableResolution", - "Executable resolution 2 requires tool definitions or build toolchain candidates.", - )); - } if self.tool_definitions.is_empty() && self.toolchain_providers.is_empty() { return Ok(()); } - if self.plugin_api_version < CAPABILITIES_API_VERSION { - return Err(invalid( - "toolDefinitions", - "Toolchain declarations require plugin API level 3.", - )); - } - if !self.tool_definitions.is_empty() - && self.requires.get(capabilities::EXECUTABLE_RESOLUTION) != Some(&2) - { - return Err(invalid( - "requires.executableResolution", - "Tool definitions require executable resolution 2.", - )); - } crate::toolchain::definitions::validate_declarations( &self.id, &self.tool_definitions, @@ -1531,36 +1274,23 @@ impl DeclarativePluginManifest { Ok(()) } - /// Checks `pluginFunctions`: only a backend 2 of a level 3 or newer - /// manifest registers functions for the protocol that relays calls. + /// Checks `pluginFunctions`: only the plugin backend registers functions + /// for the protocol that relays calls. fn validate_plugin_functions(&mut self) -> Validation<()> { - let version = self.backend_version(); let Some(section) = self.plugin_functions.as_mut() else { return Ok(()); }; - if self.plugin_api_version < CAPABILITIES_API_VERSION { - return Err(invalid( - "pluginFunctions", - "Plugin functions require plugin API level 3.", - )); - } if self.backend.is_none() { return Err(invalid( "pluginFunctions", "Exported and called plugin functions need the plugin backend.", )); } - super::plugin_functions::validate(section, version) + super::plugin_functions::validate(section) } /// Host-queried providers must start without relying on the discovery they provide. fn validate_packaged_provider_backend(&self) -> Validation<()> { - if self.plugin_api_version < CAPABILITIES_API_VERSION || self.backend_version() != 2 { - return Err(invalid( - "requires.backend", - "Providers require plugin API 3 and backend 2.", - )); - } let backend = self .backend .as_ref() @@ -1584,23 +1314,15 @@ impl DeclarativePluginManifest { let Some(ui) = &mut self.ui else { return Ok(()); }; - if self.plugin_api_version < 2 { - return Err(invalid( - "ui", - "UI contributions require plugin API level 2.", - )); - } - let version = self.requires.get(capabilities::UI).copied().unwrap_or(1); - if (ui.tool_windows.is_empty() && (version < 4 || ui.commands.is_empty())) + if (ui.tool_windows.is_empty() && ui.commands.is_empty()) || ui.tool_windows.len() > 64 || ui.commands.len() > 128 { return Err(invalid( "ui", - "UI requires 1-64 tool windows and at most 128 commands.", + "UI declares a tool window or a command, with at most 64 tool windows and 128 commands.", )); } - // Below level 3 there is no `requires`, and the section means `ui` 1. let prefix = format!("{}.", self.id); let mut ids = BTreeSet::new(); let mut windows = BTreeSet::new(); @@ -1609,7 +1331,7 @@ impl DeclarativePluginManifest { if let Some(icon) = &window.icon { icon.validate()?; } - validate_window_body(window, index, version)?; + validate_window_body(window, index)?; if let Some(source) = window .native .as_ref() @@ -1652,16 +1374,8 @@ impl DeclarativePluginManifest { || !action_ids.insert(command.id.clone()) || match &command.tool_window_id { Some(id) => !windows.contains(id), - None => { - version < 4 - || self.backend.is_none() - || self - .requires - .get(capabilities::BACKEND) - .copied() - .unwrap_or(1) - < 2 - } + // A command without a window runs on the plugin backend. + None => self.backend.is_none(), } || command.placements.is_empty() { @@ -1690,21 +1404,6 @@ impl DeclarativePluginManifest { } } } - // A version only adds; declaring one without using it only hides - // the plugin from hosts that could run it. - if (version == 2 && ui.tool_windows.iter().all(|window| window.web.is_none())) - || (version == 3 && ui.tool_windows.iter().all(|window| window.native.is_none())) - || (version == 4 - && ui - .commands - .iter() - .all(|command| command.tool_window_id.is_some())) - { - return Err(invalid( - "requires.ui", - "The declared ui version must be used: ui 2 adds web, ui 3 adds native content.", - )); - } ui.tool_windows .sort_by(|a, b| (a.order, &a.id).cmp(&(b.order, &b.id))); ui.commands @@ -1809,29 +1508,28 @@ impl DeclarativePluginManifest { "credentials.store is used by a plugin backend; declare one or remove the permission.", )); } - // Only a backend 2 process receives workspace contexts; an older - // backend could never exercise the grant the user reviewed. + // Only the plugin backend receives workspace contexts, so the grant + // the user reviewed would otherwise never be exercised. if self .permissions .iter() .any(|p| p == PERMISSION_WORKSPACE_METADATA) - && (self.backend.is_none() - || self.backend_version() < super::backend::WORKSPACE_CONTEXT_VERSION) + && self.backend.is_none() { return Err(invalid( "permissions", - "workspace.metadata is used by a plugin backend 2; require backend 2 or remove the permission.", + "workspace.metadata is used by the plugin backend; declare one or remove the permission.", )); } if self .permissions .iter() .any(|p| crate::lsp::interface::plugin_function::HOST_PERMISSIONS.contains(&p.as_str())) - && (self.backend.is_none() || self.backend_version() < 2) + && self.backend.is_none() { return Err(invalid( "permissions", - "Host API permissions require backend 2.", + "Host API permissions require the plugin backend.", )); } // Actions and providers both read the live document they annotate. @@ -1867,11 +1565,10 @@ impl DeclarativePluginManifest { // nothing exercises. let language_server_request = crate::lsp::interface::plugin_function::PERMISSION_LANGUAGE_SERVER_REQUEST; - if self.language_server_version() >= 3 - && self - .language_servers - .iter() - .any(|server| server.session.exposes_server()) + if self + .language_servers + .iter() + .any(|server| server.session.exposes_server()) { required.insert(language_server_request); } else if self @@ -1881,7 +1578,7 @@ impl DeclarativePluginManifest { { return Err(invalid( "permissions", - "languageServer.request is used by a languageServers 3 server that lists requestMethods or forwardNotifications; declare one or remove the permission.", + "languageServer.request is used by a server that lists requestMethods or forwardNotifications; declare one or remove the permission.", )); } for permission in required { @@ -1932,12 +1629,10 @@ impl DeclarativePluginManifest { } self.activation_events = sorted_unique(std::mem::take(&mut self.activation_events)); let mut declared: BTreeSet = self.activation_events.iter().cloned().collect(); - // A backend 2 may start once a workspace opens. The event is optional + // The backend may start once a workspace opens. The event is optional // because pages and commands can start it too, so it is checked here // and kept out of the exact provider match below. - if self.backend.is_some() - && self.backend_version() >= super::backend::WORKSPACE_CONTEXT_VERSION - { + if self.backend.is_some() { declared.remove(super::backend::ON_WORKSPACE_OPEN); } // Every provider needs an event, and every event needs a provider: @@ -1990,6 +1685,12 @@ impl DeclarativePluginManifest { &self.version } + /// The version of `capability` the manifest requires. Validation has + /// already checked that every used capability is listed. + pub(crate) fn capability_version(&self, capability: &str) -> u32 { + self.requires.get(capability).copied().unwrap_or_default() + } + /// Name shown to the user. pub(crate) fn display_name(&self) -> &str { &self.display_name @@ -2028,7 +1729,7 @@ impl DeclarativePluginManifest { &self.permissions } - /// Capability versions of a level 3 manifest; empty below level 3. + /// Capability versions the manifest requires. pub(crate) fn requires(&self) -> &Requires { &self.requires } @@ -2058,11 +1759,10 @@ impl DeclarativePluginManifest { if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { services.push(super::backend::HOST_SERVICE_CREDENTIALS); } - if self.backend_version() >= super::backend::WORKSPACE_CONTEXT_VERSION - && self - .permissions - .iter() - .any(|p| p == PERMISSION_WORKSPACE_METADATA) + if self + .permissions + .iter() + .any(|p| p == PERMISSION_WORKSPACE_METADATA) { services.push(super::backend::HOST_SERVICE_WORKSPACE); } @@ -2085,38 +1785,17 @@ impl DeclarativePluginManifest { services } - /// Whether runtime tools are tool-definition IDs from `requiresTools` - /// rather than the host's closed lists: `serverProcess` 4 and later. - pub(crate) fn names_tools_by_definition(&self) -> bool { - self.requires - .get(capabilities::SERVER_PROCESS) - .is_some_and(|version| *version >= TOOL_REFERENCES_SERVER_PROCESS) - } - /// Tool-definition IDs the plugin may start processes with, sorted. pub(crate) fn required_tools(&self) -> &[String] { &self.requires_tools } - /// Checks `requiresTools` and returns its IDs. The list exists only from - /// `serverProcess` 4 on, and version 4 exists only for it. + /// Checks `requiresTools` and returns its IDs. Every tool a process names + /// is one of them. fn validate_required_tools(&mut self) -> Validation> { - let by_definition = self.names_tools_by_definition(); if self.requires_tools.is_empty() { - if by_definition { - return Err(invalid( - "requires.serverProcess", - "serverProcess 4 names tools by definition ID; list them in requiresTools.", - )); - } return Ok(BTreeSet::new()); } - if !by_definition { - return Err(invalid( - "requiresTools", - "requiresTools requires serverProcess 4.", - )); - } if self.requires_tools.len() > MAX_REQUIRED_TOOLS { return Err(invalid( "requiresTools", @@ -2142,22 +1821,6 @@ impl DeclarativePluginManifest { Ok(tools) } - /// `requires.backend`, or 1 for a manifest that does not list it. - pub(crate) fn backend_version(&self) -> u32 { - self.requires - .get(capabilities::BACKEND) - .copied() - .unwrap_or(1) - } - - /// `requires.languageServers`, or 1 for a manifest that does not list it. - pub(crate) fn language_server_version(&self) -> u32 { - self.requires - .get(capabilities::LANGUAGE_SERVERS) - .copied() - .unwrap_or(1) - } - /// Checks the declared settings, sorts them by ID, and returns the IDs /// launch templates may refer to. fn validate_settings(&mut self) -> Validation { @@ -2165,25 +1828,16 @@ impl DeclarativePluginManifest { if self.settings.is_empty() { return Ok(DeclaredSettings::default()); } - if self.plugin_api_version < LAUNCH_TEMPLATES_API_VERSION { - return Err(requires_api_2("settings".to_string())); - } if self.settings.len() > MAX_SETTINGS { return Err(invalid( "settings", "The plugin declares too many settings.", )); } - // Below level 3 there is no `requires`, and the section means `settings` 1. - let version = self - .requires - .get(capabilities::SETTINGS) - .copied() - .unwrap_or(1); let mut ids = BTreeSet::new(); for (index, setting) in self.settings.iter().enumerate() { let field = format!("settings[{index}]"); - setting.validate(&field, version)?; + setting.validate(&field)?; if !ids.insert(setting.id.clone()) { return Err(invalid( format!("{field}.id"), @@ -2195,23 +1849,11 @@ impl DeclarativePluginManifest { Ok(super::settings::declared(&self.settings)) } - /// Settings-page actions need `settings` 2; they are sorted by ID. + /// Checks the settings-page actions and sorts them by ID. fn validate_setting_actions(&mut self) -> Validation<()> { if self.setting_actions.is_empty() { return Ok(()); } - if self - .requires - .get(capabilities::SETTINGS) - .copied() - .unwrap_or(1) - < 2 - { - return Err(invalid( - "settingActions", - "Settings actions require version 2 of the settings capability.", - )); - } if self.setting_actions.len() > MAX_SETTING_ACTIONS { return Err(invalid( "settingActions", @@ -2337,25 +1979,8 @@ impl DeclarativePluginManifest { } impl LanguageDeclaration { - /// Whether the language uses a field `languages` 3 adds. - fn uses_grammar_fields(&self) -> bool { - self.grammar.is_some() || self.configuration.is_some() - } - - fn normalize(&mut self, index: usize, capability_version: u32) -> Validation<()> { + fn normalize(&mut self, index: usize) -> Validation<()> { let field = format!("languages[{index}]"); - if self.uses_grammar_fields() - && capability_version < super::grammar::GRAMMAR_LANGUAGES_VERSION - { - return Err(invalid( - if self.grammar.is_some() { - format!("{field}.grammar") - } else { - format!("{field}.configuration") - }, - "grammar and configuration require version 3 of the languages capability.", - )); - } if let Some(grammar) = &self.grammar { grammar.validate(&format!("{field}.grammar"))?; } @@ -2434,19 +2059,16 @@ impl LanguageServerDeclaration { fn validate( &self, field: &str, - api_version: u32, - capability_version: u32, declared_settings: &DeclaredSettings, runtime_tools: RuntimeTools<'_>, ) -> Validation<()> { self.process.validate( field, - api_version, declared_settings, ServerRole::LanguageServer, runtime_tools, )?; - self.session.validate(field, capability_version)?; + self.session.validate(field)?; if self.process.is_backend_launch() && self.host_integration.is_some() { return Err(invalid( format!("{field}.hostIntegration"), @@ -2455,12 +2077,6 @@ impl LanguageServerDeclaration { } if let Some(configuration) = &self.workspace_configuration { let field = format!("{field}.workspaceConfiguration"); - if capability_version < 2 { - return Err(invalid( - field, - "workspaceConfiguration requires version 2 of the languageServers capability.", - )); - } if !configuration.is_object() { return Err(invalid(field, "workspaceConfiguration is a JSON object.")); } @@ -2487,8 +2103,7 @@ impl LanguageServerDeclaration { } } if let Some(integration) = &self.host_integration { - let templated = api_version >= LAUNCH_TEMPLATES_API_VERSION; - self.validate_host_integration(integration, field, templated)?; + self.validate_host_integration(integration, field)?; } Ok(()) } @@ -2497,12 +2112,8 @@ impl LanguageServerDeclaration { &self, integration: &HostIntegrationDeclaration, field: &str, - templated: bool, ) -> Validation<()> { let field = format!("{field}.hostIntegration"); - if !templated { - return Err(requires_api_2(field)); - } if !KNOWN_HOST_INTEGRATIONS.contains(&integration.id.as_str()) { return Err(PluginRejection::at( "plugin.integration.unsupported", @@ -2606,17 +2217,15 @@ impl ServerProcess { } /// Checks the launch, its arguments and probe, the initialization - /// options, and the runtime dependencies. Level 1 arguments are literal - /// text; templates are parsed from level 2 on. + /// options, and the runtime dependencies. Arguments and options are + /// launch templates. pub(crate) fn validate( &self, field: &str, - api_version: u32, declared_settings: &DeclaredSettings, role: ServerRole, runtime_tools: RuntimeTools<'_>, ) -> Validation<()> { - let templated = api_version >= LAUNCH_TEMPLATES_API_VERSION; let noun = role.noun(); match &self.launch { LanguageServerLaunch::Packaged { path } => { @@ -2642,17 +2251,7 @@ impl ServerProcess { runtime_arguments, entry, } => { - if !templated { - return Err(requires_api_2(format!("{field}.launch.kind"))); - } - runtime_tools.check( - tool, - LAUNCH_RUNTIME_TOOLS, - format!("{field}.launch.tool"), - format!( - "The host cannot start a {noun} with {tool}: it is neither a host runtime nor a tool-definition ID." - ), - )?; + runtime_tools.check(tool, format!("{field}.launch.tool"))?; if let Some(version) = minimum_version { validate_minimum_version(version, &format!("{field}.launch.minimumVersion"))?; } @@ -2698,13 +2297,7 @@ impl ServerProcess { function, timeout_milliseconds, } => { - return self.validate_backend_launch( - field, - api_version, - function, - *timeout_milliseconds, - role, - ); + return self.validate_backend_launch(field, function, *timeout_milliseconds, role); } } if self @@ -2718,27 +2311,20 @@ impl ServerProcess { format!("{} arguments must not contain NUL.", capitalized(noun)), )); } - // Level 1 arguments are literal text. Parsing them as templates would - // change the command line of packages that already ship. - if templated { - validate_launch_templates( - &self.arguments, - LaunchTemplateContext::Start, - declared_settings, - &format!("{field}.arguments"), - )?; - validate_launch_templates( - &self.validation_arguments, - LaunchTemplateContext::ValidationProbe, - declared_settings, - &format!("{field}.validationArguments"), - )?; - } + validate_launch_templates( + &self.arguments, + LaunchTemplateContext::Start, + declared_settings, + &format!("{field}.arguments"), + )?; + validate_launch_templates( + &self.validation_arguments, + LaunchTemplateContext::ValidationProbe, + declared_settings, + &format!("{field}.validationArguments"), + )?; if let Some(options) = &self.initialization_options { let options_field = format!("{field}.initializationOptions"); - if !templated { - return Err(requires_api_2(options_field)); - } if !options.is_object() { return Err(invalid( options_field, @@ -2749,15 +2335,7 @@ impl ServerProcess { } for dependency in &self.runtime_dependencies { let dependencies_field = format!("{field}.runtimeDependencies"); - runtime_tools.check( - &dependency.tool, - KNOWN_RUNTIME_TOOLS, - dependencies_field.clone(), - format!( - "The host cannot provide the runtime {}: it is neither a host runtime nor a tool-definition ID.", - dependency.tool - ), - )?; + runtime_tools.check(&dependency.tool, dependencies_field.clone())?; // The launch runtime is already discovered for the process, and // only the launch declaration can state its version requirement. if matches!(&self.launch, LanguageServerLaunch::Runtime { tool, .. } if *tool == dependency.tool) @@ -2771,14 +2349,12 @@ impl ServerProcess { Ok(()) } - /// Checks a launch the plugin backend resolves (`serverProcess` 3). Only - /// a language server may use it, and its command line, probe, options, - /// and runtimes all come from the backend's answer, so the manifest - /// declares none of them. + /// Checks a launch the plugin backend resolves. Only a language server + /// may use it, and its command line, probe, options, and runtimes all + /// come from the backend's answer, so the manifest declares none of them. fn validate_backend_launch( &self, field: &str, - api_version: u32, function: &str, timeout_milliseconds: Option, role: ServerRole, @@ -2792,12 +2368,6 @@ impl ServerProcess { ), )); } - if api_version < capabilities::DEBUG_ADAPTERS_API_VERSION { - return Err(invalid( - format!("{field}.launch.kind"), - "A backend launch requires pluginAPIVersion 4 and serverProcess 3.", - )); - } if !crate::lsp::interface::plugin_function::is_function_id(function) { return Err(invalid( format!("{field}.launch.function"), @@ -2926,7 +2496,7 @@ impl ServerProcess { /// Install-review entries of the runtimes the host discovers for it. pub(crate) fn runtime_programs(&self) -> Vec { - self.runtime_requirements(false) + self.runtime_requirements() .into_iter() .map(|mut runtime| { runtime["kind"] = json!("runtime"); @@ -2937,21 +2507,13 @@ impl ServerProcess { /// Runtimes the host must discover for this process, sorted by tool. The /// launch runtime comes first in declaration terms but is merged here so - /// hosts resolve every tool the same way. - /// - /// An entry the host resolves through a tool definition carries - /// `kind: "tool"`: every entry when `by_definition` (`serverProcess` 4), - /// otherwise only implicit references. - fn runtime_requirements(&self, by_definition: bool) -> Vec { - let reference = - |tool: &str, host: &[&str]| by_definition || implicit_tool_reference(tool, host); - let mut requirements: Vec<(&str, Option<&str>, bool)> = self + /// hosts resolve every tool the same way. Every entry names a tool + /// definition, so every entry carries `kind: "tool"`. + fn runtime_requirements(&self) -> Vec { + let mut requirements: Vec<(&str, Option<&str>)> = self .runtime_dependencies .iter() - .map(|dependency| { - let tool = dependency.tool.as_str(); - (tool, None, reference(tool, KNOWN_RUNTIME_TOOLS)) - }) + .map(|dependency| (dependency.tool.as_str(), None)) .collect(); // A packaged runtime is part of the plugin, not a host requirement. if let LanguageServerLaunch::Runtime { @@ -2961,24 +2523,17 @@ impl ServerProcess { .. } = &self.launch { - requirements.push(( - tool.as_str(), - minimum_version.as_deref(), - reference(tool, LAUNCH_RUNTIME_TOOLS), - )); + requirements.push((tool.as_str(), minimum_version.as_deref())); } requirements.sort(); requirements.dedup(); requirements .into_iter() - .map(|(tool, minimum_version, tool_definition)| { - let mut requirement = json!({ "tool": tool }); + .map(|(tool, minimum_version)| { + let mut requirement = json!({ "tool": tool, "kind": "tool" }); if let Some(version) = minimum_version { requirement["minimumVersion"] = json!(version); } - if tool_definition { - requirement["kind"] = json!("tool"); - } requirement }) .collect() @@ -3007,14 +2562,15 @@ fn is_packaged_runtime_path(value: &str) -> bool { }) && is_safe_relative_path(&value.replace("${arch}", "arch")) } -/// A window body is literal text or, from `ui` 2 on, one package HTML page. -fn validate_window_body(window: &UIToolWindow, index: usize, version: u32) -> Validation<()> { +/// A window body is literal text, one package HTML page, or host-rendered +/// native content. +fn validate_window_body(window: &UIToolWindow, index: usize) -> Validation<()> { let field = format!("ui.toolWindows[{index}]"); if let Some(native) = &window.native { - if version < 3 || window.text.is_some() || window.web.is_some() { + if window.text.is_some() || window.web.is_some() { return Err(invalid( &field, - "Native content requires ui 3 and cannot coexist with text or web.", + "Native content cannot coexist with text or web.", )); } return native @@ -3027,10 +2583,6 @@ fn validate_window_body(window: &UIToolWindow, index: usize, version: u32) -> Va format!("{field}.text"), "The window text is too long.", )), - (None, Some(_)) if version < 2 => Err(invalid( - format!("{field}.web"), - "Web content requires pluginAPIVersion 3 and the ui capability version 2.", - )), (None, Some(web)) => { let entry = &web.entry; let valid = is_safe_relative_path(entry) @@ -3055,10 +2607,6 @@ fn validate_window_body(window: &UIToolWindow, index: usize, version: u32) -> Va } } -fn requires_api_2(field: String) -> PluginRejection { - invalid(field, "This declaration requires pluginAPIVersion 2.") -} - /// A minimum version is one to three dot-separated numbers; hosts compare it /// numerically against the leading components of the runtime's own version. pub(crate) fn validate_minimum_version(value: &str, field: &str) -> Validation<()> { @@ -3663,7 +3211,7 @@ pub struct LanguageServerLaunchPlanRequest { #[serde(default)] log_directory: Option, /// Absolute writable scratch directory for one start of the server. Only - /// a launch the backend resolves (`serverProcess` 3) needs it. + /// a launch the backend resolves needs it. #[serde(default)] temporary_directory: Option, /// Host CPU architecture, one of `KNOWN_ARCHITECTURES`. @@ -3673,7 +3221,6 @@ pub struct LanguageServerLaunchPlanRequest { #[serde(default)] settings: BTreeMap, /// The current workspace's overrides of project-scoped settings. - /// `settings` 2. #[serde(default)] project_settings: BTreeMap, } @@ -3772,16 +3319,13 @@ fn language_server_launch_plan( &manifest.effective_settings(request.layers()), ); } - // The engine enforces the session fields; a server below version 3 - // has none, so its plan stays as it was. - if manifest.language_server_version() >= 3 { - plan["sessionPolicy"] = server.session.policy(&server.language_id); - } + // The engine enforces the session fields. + plan["sessionPolicy"] = server.session.policy(&server.language_id); Ok(plan) } -/// The first step of a language server whose backend resolves its launch -/// (`serverProcess` 3): the function to call and its params. The host calls +/// The first step of a language server whose backend resolves its launch: +/// the function to call and its params. The host calls /// the function and passes the answer to `plugins.languageServers.decodeLaunch`, /// whose plan it then starts like any other. fn backend_resolved_launch_plan( @@ -4027,7 +3571,7 @@ fn backend_launch_plan( })?; let mut plan = server_launch_plan(manifest, &backend.process, None, request)?; plan["backend"] = super::backend::plan_descriptor( - manifest.backend_version(), + manifest.capability_version(capabilities::BACKEND), &manifest.backend_host_services(), ); Ok(plan) @@ -4080,7 +3624,7 @@ fn debug_adapter_launch_plan( /// The plan of the language server a `tcpLanguageServer` adapter runs in. /// The server's own session starts it; this plan only names the server the -/// adapter belongs to. A server the backend launches (`serverProcess` 3) is +/// adapter belongs to. A server the backend launches is /// asked by that session with the scratch directories of its own start, so /// here its plan names the function and demands no directory. fn hosting_language_server_plan( @@ -4297,35 +3841,24 @@ fn server_launch_plan( )); } - // Level 1 arguments are literal, so only level 2 values are expanded. - let templated = manifest.plugin_api_version >= LAUNCH_TEMPLATES_API_VERSION; let mut requirements = LaunchRequirements::default(); let expand_all = |templates: &[String], requirements: &mut LaunchRequirements| { templates .iter() - .map(|value| { - if templated { - expand_launch_template(value, &values, requirements) - } else { - value.clone() - } - }) + .map(|value| expand_launch_template(value, &values, requirements)) .collect::>() }; - let by_definition = manifest.names_tools_by_definition(); - let mut arguments = Vec::new(); let launch = match &process.launch { // A tool definition owns how its executable starts an entry, so the // plan hands the host the parts for `toolchains.instantiateLaunch`. - // Below `serverProcess` 4 the same holds for an implicit reference. LanguageServerLaunch::Runtime { tool, minimum_version, packaged_runtime, runtime_arguments, entry, - } if by_definition || implicit_tool_reference(tool, LAUNCH_RUNTIME_TOOLS) => { + } => { requirements.package_paths.insert(entry.clone()); let mut launch = json!({ "kind": "tool", @@ -4350,31 +3883,6 @@ fn server_launch_plan( LanguageServerLaunch::SystemCommand { commands } => { json!({ "kind": "systemCommand", "commands": commands }) } - LanguageServerLaunch::Runtime { - tool, - minimum_version, - packaged_runtime, - runtime_arguments, - entry, - } => { - // A host runtime takes the entry as its first positional argument. - arguments.extend(expand_all(runtime_arguments, &mut requirements)); - arguments.push(format!("{}/{entry}", values.plugin_directory)); - requirements.package_paths.insert(entry.clone()); - let mut launch = json!({ "kind": "runtime", "tool": tool, "entry": entry }); - if let Some(version) = minimum_version { - launch["minimumVersion"] = json!(version); - // The same probe as a host-managed build runtime. - launch["versionArguments"] = - json!(crate::build::host_runtime_version_arguments(tool)); - } - if let Some(path) = packaged_runtime { - let path = path.replace("${arch}", values.architecture); - requirements.package_paths.insert(path.clone()); - launch["packagedRuntime"] = json!(path); - } - launch - } // Validation admits a backend launch only for a language server, // whose plan `backend_resolved_launch_plan` builds instead. LanguageServerLaunch::Backend { .. } => { @@ -4384,7 +3892,7 @@ fn server_launch_plan( )); } }; - arguments.extend(expand_all(&process.arguments, &mut requirements)); + let arguments = expand_all(&process.arguments, &mut requirements); let validation_arguments = expand_all(&process.validation_arguments, &mut requirements); let mut plan = json!({ @@ -4392,7 +3900,7 @@ fn server_launch_plan( "launch": launch, "arguments": arguments, "validationArguments": validation_arguments, - "runtimeRequirements": process.runtime_requirements(by_definition), + "runtimeRequirements": process.runtime_requirements(), }); if let Some(options) = &process.initialization_options { plan["initializationOptions"] = expand_launch_value(options, &values, &mut requirements); @@ -4531,46 +4039,6 @@ fn expand_launch_template( expanded } -/// Output of a runtime's version probe and the requirement it must meet. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeVersionCheckRequest { - /// Launch runtime that printed `output`. - tool: String, - /// Requirement from the launch plan, one to three dot-separated numbers. - minimum_version: String, - /// Text the runtime printed for the plan's `versionArguments`. - output: String, -} - -/// Checks a host runtime's probe output. A tool definition checks the -/// version of the executable it resolves, so only `LAUNCH_RUNTIME_TOOLS` -/// are probed here. -fn check_runtime_version(request: &RuntimeVersionCheckRequest) -> Validation { - if !LAUNCH_RUNTIME_TOOLS.contains(&request.tool.as_str()) { - return Err(PluginRejection::new( - "plugin.runtime.unsupported", - format!( - "The host does not probe {}; its tool definition checks the version.", - request.tool - ), - )); - } - validate_minimum_version(&request.minimum_version, "minimumVersion")?; - let minimum = version_components(&request.minimum_version); - let Some(found) = reported_version(&request.output) else { - return Ok(json!({ "satisfied": false })); - }; - // Only the components the requirement names are compared, so `21` - // accepts every 21.x and `18.17` rejects 18.16. - let mut padded = found.clone(); - padded.resize(minimum.len().max(found.len()), 0); - Ok(json!({ - "satisfied": padded[..minimum.len()] >= minimum[..], - "version": found.iter().map(u64::to_string).collect::>().join("."), - })) -} - pub(crate) fn version_components(value: &str) -> Vec { value .split('.') @@ -4832,8 +4300,8 @@ pub(crate) fn sorted_unique(values: Vec) -> Vec { #[serde(rename_all = "camelCase")] struct ValidatePayload { manifest: Value, - /// Capability versions the host runs. When sent, a level 3 manifest - /// that requires anything else is refused. + /// Capability versions the host runs. When sent, a manifest that + /// requires anything else is refused. #[serde(default)] host_capabilities: Option, } @@ -4859,7 +4327,7 @@ struct LaunchPlanPayload { build_system_id: Option, #[serde(default, rename = "vcsProviderID")] vcs_provider_id: Option, - /// Selects the plugin's backend; `backend` 1. + /// Selects the plugin's backend. #[serde(default)] backend: bool, #[serde(default, rename = "debugAdapterID")] @@ -5268,13 +4736,3 @@ pub fn decode_grammar_command(payload: Value) -> Result { }, ))) } - -/// `plugins.declarative.checkRuntimeVersion`: decides whether a launch -/// runtime's version output meets the plan's `minimumVersion`. -pub fn check_runtime_version_command(payload: Value) -> Result { - let request: RuntimeVersionCheckRequest = - decode_payload(payload, "plugins.declarative.checkRuntimeVersion")?; - Ok(outcome( - check_runtime_version(&request).map(|check| ("check", check)), - )) -} diff --git a/rust/lithe-core/src/plugins/editor_actions.rs b/rust/lithe-core/src/plugins/editor_actions.rs index f689989e..b2feec0e 100644 --- a/rust/lithe-core/src/plugins/editor_actions.rs +++ b/rust/lithe-core/src/plugins/editor_actions.rs @@ -2,10 +2,11 @@ //! //! Core validates presentation and opaque function bindings. Plugin parsers own //! their business facts; hosts own document versions, session leases, icon -//! resources and clicks. Version 1 keeps the original two icons and 128 KiB -//! source bound; version 2 takes the icons and bound the host declared in -//! [`EditorHostOptions`](crate::plugins::editor_actions::EditorHostOptions) and adds -//! alternatives and reveal results. +//! resources and clicks. Collected actions are checked against the icons and +//! source bound the host declared in +//! [`EditorHostOptions`](crate::plugins::editor_actions::EditorHostOptions); an +//! action may carry alternatives, and a clicked action may return a reveal +//! result. use crate::lsp::interface::plugin_function::is_function_id; use crate::protocol::{CoreError, ErrorCode}; @@ -17,8 +18,6 @@ const MAX_ACTIONS: usize = 1000; const MAX_BYTES: usize = 1024 * 1024; /// Largest document a host may collect actions for, in UTF-8 bytes. pub const MAX_SOURCE_BYTES: usize = 1024 * 1024; -const LEGACY_SOURCE_BYTES: usize = 128 * 1024; -const LEGACY_ICONS: [&str; 2] = ["run", "run-all"]; const MAX_ICONS: usize = 32; const MAX_ALTERNATIVES: usize = 4; const MAX_REVEAL_LOCATIONS: usize = 100; @@ -219,58 +218,47 @@ struct Reveal { locations: Vec, } +/// Request versions of `plugins.editorActions.decode` this Core accepts. A +/// later version is appended. +const DECODE_VERSIONS: &[u64] = &[0]; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Payload { kind: String, + /// The decode version the host validates under; one of + /// [`DECODE_VERSIONS`]. + version: u64, value: Value, #[serde(default)] text: String, - /// 1 when absent: the original icons and source bound. - #[serde(default = "legacy_version")] - version: u64, - /// Version 2: the icons of the host's [`EditorHostOptions`]. + /// The icons of the host's [`EditorHostOptions`]; required by + /// `kind: actions`. #[serde(default)] icons: Vec, - /// Version 2: the host's `actionSourceBytes`. + /// The host's `actionSourceBytes`; required by `kind: actions`. #[serde(default)] source_bytes: Option, } -fn legacy_version() -> u64 { - 1 -} - -/// What one decode version admits. +/// What the host admits in a collected action list. struct Rules { icons: Vec, source_bytes: usize, - alternatives: bool, } impl Rules { fn of(payload: &Payload) -> Result { - match payload.version { - 1 if payload.icons.is_empty() && payload.source_bytes.is_none() => Ok(Self { - icons: LEGACY_ICONS.iter().map(ToString::to_string).collect(), - source_bytes: LEGACY_SOURCE_BYTES, - alternatives: false, - }), - 2 => { - let options = EditorHostOptions { - action_icons: payload.icons.clone(), - action_source_bytes: payload.source_bytes.unwrap_or(0), - provider_types: Vec::new(), - }; - options.validate()?; - Ok(Self { - icons: options.action_icons, - source_bytes: options.action_source_bytes, - alternatives: true, - }) - } - _ => Err(invalid("Unsupported editor action validation version.")), - } + let options = EditorHostOptions { + action_icons: payload.icons.clone(), + action_source_bytes: payload.source_bytes.unwrap_or(0), + provider_types: Vec::new(), + }; + options.validate()?; + Ok(Self { + icons: options.action_icons, + source_bytes: options.action_source_bytes, + }) } fn icon(&self, icon: &str) -> bool { @@ -404,7 +392,6 @@ fn actions(value: Value, text: &str, rules: &Rules) -> Result || item.range.start > item.range.end || !valid_position(&item.range.start) || !valid_position(&item.range.end) - || (!rules.alternatives && !item.alternatives.is_empty()) || item.alternatives.len() > MAX_ALTERNATIVES || !item.alternatives.iter().all(|alternative| { identifier(&alternative.id) @@ -426,7 +413,7 @@ fn actions(value: Value, text: &str, rules: &Rules) -> Result } /// Reads the `reveal` member of an action's function result. Other members -/// stay the plugin's own: a version 1 result had no meaning to the host. +/// stay the plugin's own and have no meaning to the host. fn invoke_result(value: Value) -> Result { let Some(reveal) = value.get("reveal").cloned() else { return Ok(json!({})); @@ -445,20 +432,25 @@ fn invoke_result(value: Value) -> Result { } /// Validates an independent registration (`kind: registration`), a parser -/// result (`kind: actions`, with its exact text) or, from version 2, the -/// result of a clicked action (`kind: invokeResult`). No I/O or tool parsing -/// occurs. +/// result (`kind: actions`, with its exact text and the host's icons and +/// source bound) or the result of a clicked action (`kind: invokeResult`). No +/// I/O or tool parsing occurs. pub fn decode_command(payload: Value) -> Result { let payload: Payload = serde_json::from_value(payload) .map_err(|_| invalid("Invalid editor action validation payload."))?; + if !DECODE_VERSIONS.contains(&payload.version) { + return Err(invalid("Unsupported editor action validation version.")); + } if payload.value.to_string().len() > MAX_BYTES { return Err(invalid("Editor action data exceeds 1 MiB.")); } - let rules = Rules::of(&payload)?; match payload.kind.as_str() { "registration" => Ok(json!({"registration": registration(payload.value)?})), - "actions" => Ok(json!({"actions": actions(payload.value, &payload.text, &rules)?})), - "invokeResult" if rules.alternatives => invoke_result(payload.value), + "actions" => { + let rules = Rules::of(&payload)?; + Ok(json!({"actions": actions(payload.value, &payload.text, &rules)?})) + } + "invokeResult" => invoke_result(payload.value), _ => Err(invalid("Unknown editor action validation kind.")), } } @@ -469,25 +461,26 @@ mod tests { const HOST_ICONS: [&str; 4] = ["run", "debug", "test", "navigate-super"]; - fn v2(kind: &str, text: &str, value: Value) -> Result { - decode_command(json!({"kind": kind, "version": 2, "icons": HOST_ICONS, + /// Decodes with the icons and source bound of the test host. + fn decode(kind: &str, text: &str, value: Value) -> Result { + decode_command(json!({"version": 0, "kind": kind, "icons": HOST_ICONS, "sourceBytes": MAX_SOURCE_BYTES, "text": text, "value": value})) } #[test] fn different_tools_share_registration_and_opaque_invocations() { for (extension, function) in [("http", "requests.send"), ("sql", "queries.execute")] { - let registration = decode_command(json!({"kind":"registration", "value":{ + let registration = decode_command(json!({"version": 0, "kind":"registration", "value":{ "id":"actions", "function":"actions.collect", "selector":{"fileExtensions":[extension]} }})).unwrap(); assert_eq!( registration["registration"]["selector"]["fileExtensions"][0], extension ); - let result = decode_command(json!({"kind":"actions", "text":"hello\nworld", "value":{"actions":[{ + let result = decode("actions", "hello\nworld", json!({"actions":[{ "id":"a", "range":{"start":{"line":1,"column":1},"end":{"line":2,"column":6}}, "title":"Execute", "icon":"run", "invoke":{"functionID":function,"params":{"opaque":[1,2]}} - }]}})).unwrap(); + }]})).unwrap(); assert_eq!( result["actions"][0]["invoke"]["params"]["opaque"], json!([1, 2]) @@ -503,41 +496,49 @@ mod tests { ("x", json!([item.clone()])), ("abc", json!([item.clone(), item])), ] { - assert!(decode_command( - json!({"kind":"actions","text":text,"value":{"actions":items}}) - ) - .is_err()); + assert!(decode("actions", text, json!({"actions":items})).is_err()); } // Column 2 splits an astral character's UTF-16 surrogate pair. - assert!( - decode_command(json!({"kind":"actions","text":"😀","value":{"actions":[{ + assert!(decode( + "actions", + "😀", + json!({"actions":[{ "id":"a","range":{"start":{"line":1,"column":2},"end":{"line":1,"column":3}}, "title":"Execute","icon":"run","invoke":{"functionID":"action.execute"} - }]}})) - .is_err() - ); + }]}) + ) + .is_err()); } #[test] - fn version_one_keeps_the_original_icons_and_bound() { + fn actions_are_bounded_by_the_icons_and_source_size_the_host_declared() { let item = |icon: &str| { json!({"actions":[{"id":"a","range":{"start":{"line":1,"column":1},"end":{"line":1,"column":1}}, "title":"Run","icon":icon,"invoke":{"functionID":"markers.run"}}]}) }; - assert!( - decode_command(json!({"kind":"actions","text":"x","value":item("debug")})).is_err() - ); - let large = "x".repeat(LEGACY_SOURCE_BYTES + 1); - assert!( - decode_command(json!({"kind":"actions","text":large,"value":item("run")})).is_err() - ); - assert!(v2("actions", &large, item("debug")).is_ok()); - assert!(decode_command(json!({"kind":"invokeResult","value":{}})).is_err()); - // A version 1 payload cannot smuggle host icons. + let declared = |icons: Value, source_bytes: usize, text: &str, icon: &str| { + decode_command( + json!({"version":0,"kind":"actions","icons":icons,"sourceBytes":source_bytes, + "text":text,"value":item(icon)}), + ) + }; + // Without the host's icons and bound there is nothing to check against. assert!(decode_command( - json!({"kind":"actions","icons":["debug"],"text":"x","value":item("debug")}) + json!({"version": 0, "kind":"actions","text":"x","value":item("run")}) ) .is_err()); + assert!(declared(json!(["run", "debug"]), 4, "xxxx", "debug").is_ok()); + assert!(declared(json!(["run"]), 4, "xxxx", "debug").is_err()); + assert!(declared(json!(["run"]), 4, "xxxxx", "run").is_err()); + // The request names a decode version this Core accepts. + for version in [json!(1), Value::Null] { + let mut request = json!({"kind":"invokeResult","value":{}}); + if !version.is_null() { + request["version"] = version; + } + assert!(decode_command(request).is_err()); + } + assert!(decode_command(json!({"version":0,"kind":"invokeResult","value":{}})).is_ok()); } #[test] @@ -550,7 +551,7 @@ mod tests { "title":"Run main","icon":"run","invoke":{"functionID":"go.markers.invoke","params":{"entry":"main"}}, "alternatives":alternatives}]}) }; - let accepted = v2("actions", text, marker(json!([ + let accepted = decode("actions", text, marker(json!([ {"id":"debug","title":"Debug main","icon":"debug","invoke":{"functionID":"go.markers.invoke","params":{"debug":true}}}, {"id":"test","title":"Test package","icon":"test","invoke":{"functionID":"go.markers.invoke"}} ]))).unwrap(); @@ -566,20 +567,16 @@ mod tests { .map(|n| alternative(&n.to_string(), "test")) .collect::>()), ] { - assert!(v2("actions", text, marker(alternatives)).is_err()); + assert!(decode("actions", text, marker(alternatives)).is_err()); } - assert!(decode_command( - json!({"kind":"actions","text":text,"value":marker(json!([alternative("a","run")]))}) - ) - .is_err()); // Icon names are lowercase tokens the host declared. assert!(decode_command( - json!({"kind":"actions","version":2,"icons":["Run"],"sourceBytes":10,"text":"x", + json!({"version":0,"kind":"actions","icons":["Run"],"sourceBytes":10,"text":"x", "value":{"actions":[]}}) ) .is_err()); assert!(decode_command( - json!({"kind":"actions","version":2,"icons":["run"],"sourceBytes":MAX_SOURCE_BYTES + 1, + json!({"version":0,"kind":"actions","icons":["run"],"sourceBytes":MAX_SOURCE_BYTES + 1, "text":"x","value":{"actions":[]}}) ) .is_err()); @@ -589,7 +586,7 @@ mod tests { fn invoke_results_reveal_bounded_locations_and_keep_other_members_private() { let location = json!({"uri":"file:///work/messages.properties", "range":{"start":{"line":4,"character":0},"end":{"line":4,"character":12}}}); - let result = v2( + let result = decode( "invokeResult", "", json!({"reveal":{"locations":[location.clone()]},"private":1}), @@ -597,17 +594,17 @@ mod tests { .unwrap(); assert_eq!(result, json!({"reveal":{"locations":[location.clone()]}})); assert_eq!( - v2("invokeResult", "", json!({"started":true})).unwrap(), + decode("invokeResult", "", json!({"started":true})).unwrap(), json!({}) ); - assert_eq!(v2("invokeResult", "", Value::Null).unwrap(), json!({})); + assert_eq!(decode("invokeResult", "", Value::Null).unwrap(), json!({})); for reveal in [ json!({"locations":[]}), json!({"locations":vec![location; 101]}), json!({"locations":[{"uri":"relative/path","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":0}}}]}), json!({"locations":[{"uri":"file:///a","range":{"start":{"line":2,"character":0},"end":{"line":1,"character":0}}}]}), ] { - assert!(v2("invokeResult", "", json!({"reveal":reveal})).is_err()); + assert!(decode("invokeResult", "", json!({"reveal":reveal})).is_err()); } } @@ -653,7 +650,7 @@ mod tests { fn contract_fixture_cases() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/editor/editor-actions-v2.json" + "/../../shared/contracts/plugins/v1/fixtures/editor/editor-actions.json" ))) .unwrap(); let host: EditorHostOptions = serde_json::from_value(fixture["host"].clone()).unwrap(); @@ -661,7 +658,7 @@ mod tests { for case in fixture["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); let kind = case["kind"].as_str().unwrap(); - let decoded = decode_command(json!({"kind": kind, "version": 2, + let decoded = decode_command(json!({"version": 0, "kind": kind, "icons": host.action_icons, "sourceBytes": host.action_source_bytes, "text": case.get("text").cloned().unwrap_or(json!("")), "value": case["value"]})); match case["expected"]["status"].as_str().unwrap() { diff --git a/rust/lithe-core/src/plugins/executables.rs b/rust/lithe-core/src/plugins/executables.rs index f604858e..a86690e9 100644 --- a/rust/lithe-core/src/plugins/executables.rs +++ b/rust/lithe-core/src/plugins/executables.rs @@ -116,18 +116,6 @@ pub(crate) enum Platform { Windows, } -impl ExecutableCandidate { - pub(crate) fn uses_resolution(&self) -> bool { - matches!( - self.source, - ExecutableSource::Setting { .. } | ExecutableSource::Toolchain { .. } - ) || self.id.is_some() - || self.priority.is_some() - || self.insert_before.is_some() - || self.insert_after.is_some() - } -} - /// Stable topological sort: explicit edges win, otherwise higher priority then /// original array index wins. No cross-set anchors or arbitrary host code. pub(crate) fn ordered_indices( diff --git a/rust/lithe-core/src/plugins/grammar.rs b/rust/lithe-core/src/plugins/grammar.rs index d18ecde1..2853bf2d 100644 --- a/rust/lithe-core/src/plugins/grammar.rs +++ b/rust/lithe-core/src/plugins/grammar.rs @@ -1,14 +1,11 @@ -//! Declarative syntax highlighting of plugin languages, `languages` 3. +//! Declarative syntax highlighting of plugin languages (`languages`). //! //! A language may name a grammar file in its package and a small editing //! configuration (comments and brackets). `kind` names the engine that runs //! the grammar, so a later version can add another engine without changing -//! what an existing grammar means. Version 3 accepts only `textmate`: a +//! what an existing grammar means. Today the only engine is `textmate`: a //! TextMate grammar in the JSON form VS Code reads (`.tmLanguage.json`), so a //! plugin can ship the grammar a VS Code extension already maintains. -//! Version 2 named Monaco's Monarch tokenizer; the desktop editor no longer -//! embeds Monaco, so that version is withdrawn and a manifest requiring it is -//! refused as unsupported. //! //! The host reads the file from the read-only package and passes its text to //! `plugins.declarative.decodeGrammar`; Core never opens package files. Core @@ -27,9 +24,6 @@ use super::declarative::{invalid, is_safe_relative_path, PluginRejection, Valida use serde::{Deserialize, Serialize}; use serde_json::Value; -/// First `languages` version with `grammar` and `configuration`. -pub(crate) const GRAMMAR_LANGUAGES_VERSION: u32 = 3; - /// Largest grammar file, in UTF-8 bytes. VS Code's C++ grammar is 662 KiB. pub(crate) const MAX_GRAMMAR_BYTES: usize = 2 * 1024 * 1024; /// Most rules in one grammar: every pattern, repository entry and capture. diff --git a/rust/lithe-core/src/plugins/host_api.rs b/rust/lithe-core/src/plugins/host_api.rs index 374d547b..7574a1a4 100644 --- a/rust/lithe-core/src/plugins/host_api.rs +++ b/rust/lithe-core/src/plugins/host_api.rs @@ -15,9 +15,7 @@ pub(crate) fn describe_command(payload: Value) -> Result { if payload.as_object().is_none_or(|object| !object.is_empty()) { return Err(invalid()); } - // Include every method compiled into this Core, irrespective of which - // protocol version a particular backend later negotiates. - let implicit = plugin_function::capabilities_answer(u64::MAX, &BTreeSet::new()); + let implicit = plugin_function::capabilities_answer(0, &BTreeSet::new()); let methods: Vec = HOST_METHODS .iter() .map(|entry| { @@ -63,7 +61,7 @@ pub(crate) fn authorize_command(payload: Value) -> Result { return Ok(json!({"allowed":false,"code":"plugin.host.unavailable"})); } let grants = input.grants.into_iter().collect(); - let capabilities = plugin_function::capabilities_answer(u64::MAX, &grants); + let capabilities = plugin_function::capabilities_answer(0, &grants); let allowed = capabilities["services"][&input.service]["methods"] .as_array() .is_some_and(|methods| methods.iter().any(|method| method == &input.method)); @@ -83,7 +81,7 @@ mod tests { use super::*; #[test] - fn catalog_preserves_legacy_and_individual_grants() { + fn catalog_separates_service_grants_from_individual_method_grants() { let catalog = describe_command(json!({})).unwrap(); let entries = catalog["methods"].as_array().unwrap(); let method = |name: &str| { diff --git a/rust/lithe-core/src/plugins/keymap_import.rs b/rust/lithe-core/src/plugins/keymap_import.rs index 0c3e6014..534dac47 100644 --- a/rust/lithe-core/src/plugins/keymap_import.rs +++ b/rust/lithe-core/src/plugins/keymap_import.rs @@ -554,14 +554,21 @@ mod tests { .unwrap(); let validate = crate::plugins::declarative::validate_manifest_value; assert!(validate(plugin.clone()).is_ok()); - let old_host = json!({"backend": [1, 2], "serverProcess": [1]}); - let new_host = - json!({"backend": [1, 2], "serverProcess": [1], "keymapImportProviders": [1]}); + let host_without_importers = json!({"backend": [0]}); + let host_with_importers = json!({"backend": [0], "keymapImportProviders": [0]}); let command = crate::plugins::declarative::validate_command; - let rejected = command(json!({"manifest": plugin, "hostCapabilities": old_host})).unwrap(); + let rejected = command(json!({ + "manifest": plugin, + "hostCapabilities": host_without_importers + })) + .unwrap(); assert_eq!(rejected["status"], "rejected"); assert_eq!(rejected["error"]["code"], "plugin.api.unsupported"); - let accepted = command(json!({"manifest": plugin, "hostCapabilities": new_host})).unwrap(); + let accepted = command(json!({ + "manifest": plugin, + "hostCapabilities": host_with_importers + })) + .unwrap(); assert_eq!(accepted["status"], "ok"); let mut duplicate = plugin.clone(); @@ -622,9 +629,12 @@ mod tests { validate(no_source_grant).unwrap_err().code, "plugin.permission.undeclared" ); - let mut old_backend = plugin.clone(); - old_backend["requires"]["backend"] = json!(1); - assert!(validate(old_backend).is_err()); + let mut unknown_backend_version = plugin.clone(); + unknown_backend_version["requires"]["backend"] = json!(1); + assert_eq!( + validate(unknown_backend_version).unwrap_err().code, + "plugin.api.unsupported" + ); let mut unsafe_launch = plugin.clone(); unsafe_launch["backend"]["launch"] = json!({"kind": "runtime", "tool": "node", "entry": "main.mjs"}); diff --git a/rust/lithe-core/src/plugins/language_server_sessions.rs b/rust/lithe-core/src/plugins/language_server_sessions.rs index 1d24ad60..affece0a 100644 --- a/rust/lithe-core/src/plugins/language_server_sessions.rs +++ b/rust/lithe-core/src/plugins/language_server_sessions.rs @@ -1,12 +1,12 @@ //! Plugin-owned language server sessions (plan API-25, with API-20 state). //! -//! A plugin whose backend drives its own language server declares, at -//! `languageServers` 3, which requests and notifications the backend may -//! send to that server, which `workspace/executeCommand` commands run one at -//! a time, which server notifications reach the backend, how long per- +//! A plugin whose backend drives its own language server declares, in +//! `languageServers`, which requests and notifications the backend may send +//! to that server, which `workspace/executeCommand` commands run one at a +//! time, which server notifications reach the backend, how long per- //! workspace state is kept, and whether the server works on a remote -//! workspace. At `serverProcess` 3 the backend may also resolve the command -//! line each time the server starts (`launch.kind: "backend"`). +//! workspace. The backend may also resolve the command line each time the +//! server starts (`launch.kind: "backend"`). //! //! Core validates those declarations, turns them into the session policy //! the LSP engine enforces, decodes the backend's launch answer, and selects @@ -86,9 +86,9 @@ pub(crate) fn is_host_owned_method(method: &str) -> bool { method.starts_with("$/") || HOST_OWNED_METHODS.contains(&method) } -/// The `languageServers` 3 session fields, flattened into the server's -/// declaration. Every field is optional so a version 1 or 2 manifest that -/// names one can be refused with the field it used. +/// The `languageServers` session fields, flattened into the server's +/// declaration. Every field is optional: a server that its backend does not +/// drive declares none of them. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SessionDeclaration { @@ -160,21 +160,6 @@ pub enum RemoteSupport { } impl SessionDeclaration { - /// The first session field the manifest names, for refusing it below - /// `languageServers` 3. - fn first_declared(&self) -> Option<&'static str> { - [ - ("requestMethods", self.request_methods.is_some()), - ("commands", self.commands.is_some()), - ("serializedCommands", self.serialized_commands.is_some()), - ("forwardNotifications", self.forward_notifications.is_some()), - ("state", self.state.is_some()), - ("remote", self.remote.is_some()), - ] - .into_iter() - .find_map(|(name, present)| present.then_some(name)) - } - /// Whether the backend may talk to the server, which needs the /// `languageServer.request` permission. pub(crate) fn exposes_server(&self) -> bool { @@ -186,16 +171,7 @@ impl SessionDeclaration { } /// Checks the session fields of the server at `field`. - pub(crate) fn validate(&self, field: &str, capability_version: u32) -> Validation<()> { - if capability_version < 3 { - return match self.first_declared() { - Some(name) => Err(invalid( - format!("{field}.{name}"), - "Language server session fields require languageServers 3.", - )), - None => Ok(()), - }; - } + pub(crate) fn validate(&self, field: &str) -> Validation<()> { let methods = unique_names( self.request_methods.as_deref(), &format!("{field}.requestMethods"), diff --git a/rust/lithe-core/src/plugins/marketplace.rs b/rust/lithe-core/src/plugins/marketplace.rs index 74eb87cf..528f66d7 100644 --- a/rust/lithe-core/src/plugins/marketplace.rs +++ b/rust/lithe-core/src/plugins/marketplace.rs @@ -1,4 +1,4 @@ -//! Plugin marketplace contract v1: index validation, platform and plugin API +//! Plugin marketplace contract: index validation, platform and plugin API //! filtering, source URL resolution, and install reviews. //! //! A marketplace source is a JSON index listing plugins and their release @@ -8,7 +8,7 @@ //! start, so macOS and Windows present the same catalog and the same trust //! prompts. The contract lives in `shared/contracts/plugins/v1/`. -use super::capabilities::{self, HostCapabilities, Requires, CAPABILITIES_API_VERSION}; +use super::capabilities::{self, HostCapabilities, Requires}; use super::declarative::{ is_official_namespace, is_plugin_id, is_semantic_version, validate_manifest_value, PluginRejection, Validation, SUPPORTED_PLUGIN_API_VERSIONS, @@ -20,7 +20,7 @@ use std::collections::{BTreeMap, BTreeSet}; use url::Url; /// Marketplace index schema version understood by this Core build. -pub const MARKETPLACE_INDEX_SCHEMA_VERSION: u64 = 1; +pub const MARKETPLACE_INDEX_SCHEMA_VERSION: u64 = 0; /// Index file a GitHub repository URL resolves to. pub const REPOSITORY_INDEX_FILE_NAME: &str = "lithe-plugin-index.json"; /// Platforms with a Lithe host today. Other well-formed identifiers are @@ -71,8 +71,9 @@ struct IndexVersion { version: String, #[serde(rename = "pluginAPIVersion")] plugin_api_version: u64, - /// Capability versions of a level 3 package, copied from its manifest - /// so hosts can tell before downloading whether they can run it. + /// Capability versions of the package, copied from its manifest so hosts + /// can tell before downloading whether they can run it. Kept optional + /// while decoding so a missing value is a precise index error. #[serde(default)] requires: Option, /// Calendar date `YYYY-MM-DD`, used for sorting and display only. @@ -396,16 +397,13 @@ fn validate_entry( "latestReleaseDate": latest.version.release_date, "availability": availability, "versions": versions.iter().map(|listed| { - let mut value = json!({ + json!({ "version": listed.version.version, "pluginAPIVersion": listed.version.plugin_api_version, + "requires": listed.requires, "releaseDate": listed.version.release_date, "platforms": listed.platforms, - }); - if let Some(requires) = &listed.requires { - value["requires"] = json!(requires); - } - value + }) }).collect::>(), })) } @@ -414,28 +412,21 @@ fn validate_entry( struct ListedVersion<'a> { number: (u32, u32, u32), version: &'a IndexVersion, - requires: Option, + requires: Requires, platforms: BTreeSet, } -/// Reads a version's `requires`, which level 3 lists and earlier levels do -/// not. Unknown capability IDs are kept: they make the version unavailable -/// on this host instead of rejecting the whole entry. -fn index_requires(version: &IndexVersion, field: &str) -> Validation> { +/// Reads a version's mandatory `requires`. Unknown capability IDs are kept: +/// they make the version unavailable on this host instead of rejecting the +/// whole entry. +fn index_requires(version: &IndexVersion, field: &str) -> Validation { let field = format!("{field}.requires"); - match (&version.requires, version.plugin_api_version) { - (None, level) if level == u64::from(CAPABILITIES_API_VERSION) => Err(index_invalid( + match &version.requires { + None => Err(index_invalid( field, - "A pluginAPIVersion 3 version lists the capabilities it uses in requires.", + "A version lists the capabilities it uses in requires.", )), - (Some(_), level) if level < u64::from(CAPABILITIES_API_VERSION) => Err(index_invalid( - field, - "Capability versions require pluginAPIVersion 3.", - )), - (None, _) => Ok(None), - (Some(requires), _) => { - capabilities::parse_requires(requires, &field, "plugin.index.invalid").map(Some) - } + Some(requires) => capabilities::parse_requires(requires, &field, "plugin.index.invalid"), } } @@ -460,16 +451,9 @@ fn availability( { continue; } - let hosted = match &listed.requires { - // A level 3 version runs where the host implements every - // capability version it requires, whatever else it lacks. - Some(requires) => capabilities::hosts(requires, host_capabilities), - // Core validates level 2 UI declarations on every platform, but - // only macOS has the level 2 UI host. Do not advertise an adapter - // Windows cannot run merely because validation succeeded. - None => version.plugin_api_version < 2 || platform.starts_with("darwin-"), - }; - if !hosted { + // A version runs where the host implements every capability version + // it requires, whatever else it lacks. + if !capabilities::hosts(&listed.requires, host_capabilities) { continue; } let download = version @@ -770,8 +754,8 @@ struct ValidateIndexPayload { index: Value, source_kind: SourceKind, platform: String, - /// Capability versions the host runs; without them no level 3 version - /// is offered. + /// Capability versions the host runs; without them no version is + /// offered. #[serde(default)] host_capabilities: Option, } @@ -794,8 +778,8 @@ struct InstallReviewPayload { /// first installation. #[serde(default)] approved_programs: Option>, - /// Capability versions the host runs. When sent, a level 3 package that - /// requires anything else is refused. + /// Capability versions the host runs. When sent, a package that requires + /// anything else is refused. #[serde(default)] host_capabilities: Option, } diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index 68e7142e..9f93948f 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -1,4 +1,4 @@ -//! Plugin manifest parsing, compatibility checks, and deterministic catalog merging. +//! Plugin manifests, capabilities, installation and the services plugins contribute. pub(crate) mod backend; pub mod capabilities; @@ -27,14 +27,6 @@ pub(crate) mod settings; /// Plugin pages in host tool windows: loading rules and the web bridge. pub(crate) mod web_content; -use serde::Deserialize; -use std::collections::{BTreeMap, BTreeSet}; - -/// Manifest schema understood by this Core build. -pub const PLUGIN_MANIFEST_SCHEMA_VERSION: u32 = 1; -/// Host/plugin API level required by compatible packages. -pub const PLUGIN_API_VERSION: u32 = 1; - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] /// Strict three-component semantic version used for compatibility comparisons. pub struct PluginVersion { @@ -59,354 +51,3 @@ impl PluginVersion { parts.next().is_none().then_some(version) } } - -#[derive(Debug, Clone, PartialEq, Eq)] -/// Deterministic reason a catalog cannot be loaded by the current host. -pub enum PluginValidationError { - /// The catalog is not valid JSON or does not match the manifest shape. - InvalidJson, - /// A catalog or plugin uses an unknown manifest schema. - UnsupportedSchema { - /// Plugin identifier, or `catalog` when the top-level schema failed. - plugin: String, - /// Unsupported manifest schema version found in the input. - version: u32, - }, - /// A catalog or plugin targets a different plugin API. - UnsupportedApi { - /// Plugin identifier, or `catalog` when the top-level API failed. - plugin: String, - /// Unsupported plugin API level found in the input. - version: u32, - }, - /// A version does not use the strict three-component format. - InvalidVersion { - /// Plugin whose version or compatibility bound is malformed. - plugin: String, - /// Original version string that could not be parsed. - value: String, - }, - /// The current host falls outside the package's declared version interval. - IncompatibleHost { - /// Plugin identifier, or `catalog` for a fixture-host mismatch. - plugin: String, - }, - /// Entrypoint metadata is incomplete or inconsistent with its kind. - InvalidEntrypoint { - /// Plugin containing inconsistent loading or publisher metadata. - plugin: String, - }, - /// More than one package declares the same plugin identifier. - DuplicatePlugin(String), - /// More than one package claims ownership of the same module identifier. - DuplicateModule(String), - /// A plugin contains no modules and therefore cannot contribute behavior. - EmptyPlugin(String), - /// Plugin packages are not in canonical identifier order. - UnsortedPlugins, - /// A package's module identifiers are not in canonical order. - UnsortedModules { - /// Plugin whose module identifiers are not in canonical order. - plugin: String, - }, - /// Language recognition or capability ownership is invalid. - InvalidLanguageSupport { - /// Plugin declaring the invalid language contribution. - plugin: String, - /// Language identifier whose recognition or module ownership is invalid. - language: String, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Top-level plugin catalog fixture consumed by compatibility verification. -pub struct PluginCatalogFixture { - /// Version of the catalog JSON shape. - pub schema_version: u32, - /// Exact host version for which the fixture was assembled. - pub host_version: String, - /// Plugin API level shared by every package in the catalog. - #[serde(rename = "pluginAPIVersion")] - pub plugin_api_version: u32, - /// Packages sorted by stable plugin identifier. - pub plugins: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Compatibility and ownership metadata for one plugin package. -pub struct PluginPackageManifest { - /// Stable package identifier used as the catalog key. - pub id: String, - /// Human-readable name presented by host applications. - pub display_name: String, - /// Package version in strict `major.minor.patch` form. - pub version: String, - /// Plugin API level against which the package was built. - pub api_version: u32, - /// Inclusive lower and optional exclusive upper host bounds. - pub host_compatibility: HostCompatibility, - /// Publisher identity and signature policy. - pub vendor: PluginVendor, - /// Native or built-in loading metadata. - pub entrypoint: PluginEntrypoint, - /// Stable module identifiers owned by this package, in sorted order. - #[serde(rename = "moduleIDs")] - pub module_ids: Vec, - /// Language capabilities contributed by the package. - #[serde(default)] - pub language_supports: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// File recognition and module ownership for one contributed language. -pub struct LanguageSupportManifest { - /// Lowercase stable language identifier. - pub id: String, - /// Human-readable language name. - pub display_name: String, - /// Extensions without a leading dot, kept in deterministic order. - #[serde(default)] - pub file_extensions: Vec, - /// Exact file names recognized as this language. - #[serde(default)] - pub file_names: Vec, - /// Project marker names that activate language support for a workspace. - #[serde(default)] - pub project_file_names: Vec, - /// Package-owned module providing language-server integration. - #[serde(rename = "languageServerModuleID")] - pub language_server_module_id: Option, - /// Package-owned module providing run configurations. - #[serde(rename = "executionModuleID")] - pub execution_module_id: Option, - /// Package-owned module providing test integration. - #[serde(rename = "testingModuleID")] - pub testing_module_id: Option, - /// Package-owned module providing debug integration. - #[serde(rename = "debugModuleID")] - pub debug_module_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Half-open host-version interval supported by a plugin. -pub struct HostCompatibility { - /// Oldest compatible host version, inclusive. - pub minimum: String, - /// First incompatible host version, when an upper bound is required. - pub maximum_exclusive: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Plugin publisher identity and the signature relationship required by the host. -pub struct PluginVendor { - /// Stable publisher identifier. - pub id: String, - /// Human-readable publisher name. - pub display_name: String, - /// Signature policy; currently only `sameTeamAsHost` is accepted. - pub signature_requirement: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Mutually exclusive loading metadata for built-in and native-bundle plugins. -pub struct PluginEntrypoint { - /// Entrypoint discriminator: `builtIn` or `nativeBundle`. - pub kind: String, - /// Build target used by a built-in plugin. - pub target_name: Option, - /// Bundle identifier required for a native plugin. - pub bundle_identifier: Option, - /// Principal class required for a native plugin. - pub principal_class: Option, - /// Workspace-relative bundle location required for a native plugin. - pub bundle_path: Option, -} - -/// Validates a complete catalog and returns the owning plugin for every module. -/// -/// Validation also enforces deterministic ordering, host/API compatibility, -/// entrypoint consistency, and that language capabilities reference only -/// modules owned by their declaring package. -pub fn validate_plugin_catalog_json( - input: &str, - host_version: PluginVersion, -) -> Result, PluginValidationError> { - let catalog: PluginCatalogFixture = - serde_json::from_str(input).map_err(|_| PluginValidationError::InvalidJson)?; - if catalog.schema_version != PLUGIN_MANIFEST_SCHEMA_VERSION { - return Err(PluginValidationError::UnsupportedSchema { - plugin: "catalog".into(), - version: catalog.schema_version, - }); - } - if catalog.plugin_api_version != PLUGIN_API_VERSION { - return Err(PluginValidationError::UnsupportedApi { - plugin: "catalog".into(), - version: catalog.plugin_api_version, - }); - } - let catalog_host = parse_version("catalog", &catalog.host_version)?; - if catalog_host != host_version { - return Err(PluginValidationError::IncompatibleHost { - plugin: "catalog".into(), - }); - } - let plugin_ids: Vec<&str> = catalog - .plugins - .iter() - .map(|plugin| plugin.id.as_str()) - .collect(); - if !plugin_ids.windows(2).all(|pair| pair[0] < pair[1]) { - return Err(PluginValidationError::UnsortedPlugins); - } - - let mut seen_plugins = BTreeSet::new(); - let mut module_owners = BTreeMap::new(); - for plugin in catalog.plugins { - if !seen_plugins.insert(plugin.id.clone()) { - return Err(PluginValidationError::DuplicatePlugin(plugin.id)); - } - if plugin.api_version != PLUGIN_API_VERSION { - return Err(PluginValidationError::UnsupportedApi { - plugin: plugin.id, - version: plugin.api_version, - }); - } - let _version = parse_version(&plugin.id, &plugin.version)?; - let minimum = parse_version(&plugin.id, &plugin.host_compatibility.minimum)?; - let maximum = plugin - .host_compatibility - .maximum_exclusive - .as_deref() - .map(|value| parse_version(&plugin.id, value)) - .transpose()?; - if host_version < minimum || maximum.is_some_and(|value| host_version >= value) { - return Err(PluginValidationError::IncompatibleHost { plugin: plugin.id }); - } - if plugin.display_name.is_empty() - || plugin.vendor.id.is_empty() - || plugin.vendor.display_name.is_empty() - || plugin.vendor.signature_requirement != "sameTeamAsHost" - || !valid_entrypoint(&plugin.entrypoint) - { - return Err(PluginValidationError::InvalidEntrypoint { plugin: plugin.id }); - } - if plugin.module_ids.is_empty() { - return Err(PluginValidationError::EmptyPlugin(plugin.id)); - } - if !plugin.module_ids.windows(2).all(|pair| pair[0] < pair[1]) { - return Err(PluginValidationError::UnsortedModules { plugin: plugin.id }); - } - validate_language_supports(&plugin)?; - for module_id in plugin.module_ids { - if module_owners - .insert(module_id.clone(), plugin.id.clone()) - .is_some() - { - return Err(PluginValidationError::DuplicateModule(module_id)); - } - } - } - Ok(module_owners) -} - -fn validate_language_supports(plugin: &PluginPackageManifest) -> Result<(), PluginValidationError> { - let owned_modules: BTreeSet<&str> = plugin.module_ids.iter().map(String::as_str).collect(); - let mut language_ids = BTreeSet::new(); - for support in &plugin.language_supports { - let module_ids: Vec<&str> = [ - support.language_server_module_id.as_deref(), - support.execution_module_id.as_deref(), - support.testing_module_id.as_deref(), - support.debug_module_id.as_deref(), - ] - .into_iter() - .flatten() - .collect(); - let recognition_is_empty = support.file_extensions.is_empty() - && support.file_names.is_empty() - && support.project_file_names.is_empty(); - let invalid_names = support.id.is_empty() - || support.id != support.id.trim().to_lowercase() - || support.display_name.is_empty() - || !strictly_sorted(&support.file_extensions) - || !strictly_sorted(&support.file_names) - || !strictly_sorted(&support.project_file_names) - || support - .file_extensions - .iter() - .any(|value| value.starts_with('.') || value.contains('/')) - || support.file_names.iter().any(|value| value.contains('/')) - || support - .project_file_names - .iter() - .any(|value| value.contains('/')); - if !language_ids.insert(support.id.as_str()) - || recognition_is_empty - || invalid_names - || module_ids.is_empty() - || !module_ids.iter().all(|id| owned_modules.contains(id)) - { - return Err(PluginValidationError::InvalidLanguageSupport { - plugin: plugin.id.clone(), - language: support.id.clone(), - }); - } - } - Ok(()) -} - -fn strictly_sorted(values: &[String]) -> bool { - values.windows(2).all(|pair| pair[0] < pair[1]) -} - -fn parse_version(plugin: &str, value: &str) -> Result { - PluginVersion::parse(value).ok_or_else(|| PluginValidationError::InvalidVersion { - plugin: plugin.into(), - value: value.into(), - }) -} - -fn valid_entrypoint(entrypoint: &PluginEntrypoint) -> bool { - match entrypoint.kind.as_str() { - "builtIn" => { - entrypoint - .target_name - .as_ref() - .is_some_and(|value| !value.is_empty()) - && entrypoint.bundle_identifier.is_none() - && entrypoint.principal_class.is_none() - && entrypoint.bundle_path.is_none() - } - "nativeBundle" => { - entrypoint.target_name.is_none() - && entrypoint - .bundle_identifier - .as_ref() - .is_some_and(|value| !value.is_empty()) - && entrypoint - .principal_class - .as_ref() - .is_some_and(|value| !value.is_empty()) - && entrypoint - .bundle_path - .as_ref() - .is_some_and(|value| valid_relative_path(value)) - } - _ => false, - } -} - -fn valid_relative_path(value: &str) -> bool { - !value.is_empty() - && !value.starts_with('/') - && !value - .split('/') - .any(|component| component == ".." || component.is_empty()) -} diff --git a/rust/lithe-core/src/plugins/native_ui.rs b/rust/lithe-core/src/plugins/native_ui.rs index 6bd8f54a..fb172426 100644 --- a/rust/lithe-core/src/plugins/native_ui.rs +++ b/rust/lithe-core/src/plugins/native_ui.rs @@ -184,8 +184,8 @@ fn valid_info(value: Option<&str>) -> bool { impl NativeUI { pub(super) fn validate(&self) -> Result<(), &'static str> { - if self.schema_version != 1 || !identifier(&self.id) || self.actions.len() > 64 { - return Err("Native UI requires schemaVersion 1, a stable ID and at most 64 actions."); + if self.schema_version != 0 || !identifier(&self.id) || self.actions.len() > 64 { + return Err("Native UI requires schemaVersion 0, a stable ID and at most 64 actions."); } if self .source diff --git a/rust/lithe-core/src/plugins/output_links.rs b/rust/lithe-core/src/plugins/output_links.rs index 9ff7862d..a1bccaa3 100644 --- a/rust/lithe-core/src/plugins/output_links.rs +++ b/rust/lithe-core/src/plugins/output_links.rs @@ -64,12 +64,12 @@ pub(crate) enum OutputScope { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct OutputLinkResolve { - /// A function of the plugin's `backend` 2 process. + /// A function of the plugin's `backend` process. pub(crate) function: String, } /// Checks every declaration and sorts them by ID. `has_backend_functions` -/// says whether the plugin has a `backend` 2 process to call `resolve` on. +/// says whether the plugin has a `backend` process to call `resolve` on. pub(crate) fn validate_declarations( matchers: &mut [OutputLinkMatcher], has_backend_functions: bool, @@ -99,7 +99,7 @@ pub(crate) fn validate_declarations( if !has_backend_functions { return Err(invalid( format!("{field}.resolve"), - "A resolve function needs a backend 2 process.", + "A resolve function needs a backend process.", )); } if !crate::lsp::interface::plugin_function::is_function_id(&resolve.function) { diff --git a/rust/lithe-core/src/plugins/plugin_functions.rs b/rust/lithe-core/src/plugins/plugin_functions.rs index 998d8914..d73c7149 100644 --- a/rust/lithe-core/src/plugins/plugin_functions.rs +++ b/rust/lithe-core/src/plugins/plugin_functions.rs @@ -12,7 +12,7 @@ //! //! Trust never follows the call. The receiving function runs with the //! receiving plugin's own permissions and the caller gains none of them. -//! `context` is a closed set; version 1 of the capability has one value, +//! `context` is a closed set with one value today, //! `workspace`: a local trusted workspace scheduled by the host. An export //! without a context the host knows is rejected, so a function that nobody //! described the acceptable context of is never callable across plugins. @@ -87,25 +87,16 @@ impl PluginFunctionsDeclaration { } /// Checks a section and sorts both lists by function ID, so the same -/// declaration always plans the same way. `backend_version` is the plugin's -/// `requires.backend`, 0 without a backend: only a backend 2 process can -/// register functions for the protocol that carries relayed calls. -pub(crate) fn validate( - section: &mut PluginFunctionsDeclaration, - backend_version: u32, -) -> Validation<()> { +/// declaration always plans the same way. The caller has already checked that +/// the plugin declares a backend, the only process that registers functions +/// for the protocol that carries relayed calls. +pub(crate) fn validate(section: &mut PluginFunctionsDeclaration) -> Validation<()> { if section.exports.is_empty() && section.calls.is_empty() { return Err(invalid( "pluginFunctions", "pluginFunctions declares no exports and no calls.", )); } - if backend_version < super::backend::WORKSPACE_CONTEXT_VERSION { - return Err(invalid( - "requires.backend", - "Exported and called plugin functions require backend 2.", - )); - } if section.exports.len() > MAX_ENTRIES { return Err(invalid( "pluginFunctions.exports", @@ -212,7 +203,7 @@ mod tests { section .exports .insert(0, export("zeta.run", 1, Some("workspace"))); - validate(&mut section, 2).expect("section is valid"); + validate(&mut section).expect("section is valid"); assert_eq!(section.exports[0].id, "project.describe"); assert_eq!(section.exports[1].id, "zeta.run"); } @@ -222,7 +213,7 @@ mod tests { for context in [None, Some("remote"), Some(""), Some("Workspace")] { let mut section = section(); section.exports[0].context = context.map(str::to_string); - let rejection = validate(&mut section, 2).expect_err("context is rejected"); + let rejection = validate(&mut section).expect_err("context is rejected"); assert_eq!(rejection.code, "plugin.manifest.invalid"); assert_eq!( rejection.field.as_deref(), @@ -235,21 +226,21 @@ mod tests { fn ids_versions_and_counts_are_bounded() { let mut bad_id = section(); bad_id.exports[0].id = "Project.Describe".to_string(); - assert!(validate(&mut bad_id, 2).is_err()); + assert!(validate(&mut bad_id).is_err()); let mut zero = section(); zero.calls[0].version = 0; - assert!(validate(&mut zero, 2).is_err()); + assert!(validate(&mut zero).is_err()); let mut huge = section(); huge.exports[0].version = MAX_CONTRACT_VERSION + 1; - assert!(validate(&mut huge, 2).is_err()); + assert!(validate(&mut huge).is_err()); let mut duplicate = section(); duplicate .exports .push(export("project.describe", 2, Some("workspace"))); - assert!(validate(&mut duplicate, 2).is_err()); + assert!(validate(&mut duplicate).is_err()); let mut many = section(); many.calls = (0..=MAX_ENTRIES) @@ -258,13 +249,11 @@ mod tests { version: 1, }) .collect(); - assert!(validate(&mut many, 2).is_err()); + assert!(validate(&mut many).is_err()); } #[test] - fn the_section_needs_backend_two_and_some_entry() { - assert!(validate(&mut section(), 1).is_err()); - assert!(validate(&mut section(), 0).is_err()); - assert!(validate(&mut PluginFunctionsDeclaration::default(), 2).is_err()); + fn the_section_needs_some_entry() { + assert!(validate(&mut PluginFunctionsDeclaration::default()).is_err()); } } diff --git a/rust/lithe-core/src/plugins/settings.rs b/rust/lithe-core/src/plugins/settings.rs index 8cf4c8f6..a0baecce 100644 --- a/rust/lithe-core/src/plugins/settings.rs +++ b/rust/lithe-core/src/plugins/settings.rs @@ -2,12 +2,11 @@ //! values a host may store for them, and how stored layers become the value //! a launch uses. //! -//! Version 1 of the `settings` capability is the API level 2 section: -//! integer, enum, and boolean options stored once per plugin. Version 2 adds -//! the fields hosts need to place and apply a setting without plugin-specific -//! code: which settings page shows it (`page`, `section`), whether a project -//! may override it (`scope`), when a change takes effect (`effect`), and the -//! `multiSelect`, `text`, `file`, and `directory` types. +//! The `settings` capability declares integer, enum, boolean, `multiSelect`, +//! `text`, `file`, and `directory` options. Its fields give hosts what they +//! need to place and apply a setting without plugin-specific code: which +//! settings page shows it (`page`, `section`), whether a project may override +//! it (`scope`), and when a change takes effect (`effect`). //! //! Hosts never merge values themselves. They send the stored layers and Core //! answers with the effective value of every setting and the layer it came @@ -34,13 +33,13 @@ const SETTING_INTEGER: &str = "integer"; const SETTING_ENUM: &str = "enum"; /// On or off. const SETTING_BOOLEAN: &str = "boolean"; -/// Any subset of a declared list of values. `settings` 2. +/// Any subset of a declared list of values. const SETTING_MULTI_SELECT: &str = "multiSelect"; -/// Free text that matches a declared pattern. `settings` 2. +/// Free text that matches a declared pattern. const SETTING_TEXT: &str = "text"; -/// An absolute path to a file, or empty. `settings` 2. +/// An absolute path to a file, or empty. const SETTING_FILE: &str = "file"; -/// An absolute path to a directory, or empty. `settings` 2. +/// An absolute path to a directory, or empty. const SETTING_DIRECTORY: &str = "directory"; /// Most choices one enum setting may offer. const MAX_ENUM_OPTIONS: usize = 16; @@ -98,7 +97,7 @@ pub(crate) enum SettingPage { /// A button the host draws on a settings page that invokes one of the same /// plugin's [plugin functions](crate::lsp::interface::plugin_function). The /// host owns the button; the plugin owns what the function does, so a -/// "Reindex" or "Download sources" action needs no host code. `settings` 2. +/// "Reindex" or "Download sources" action needs no host code. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct SettingAction { @@ -252,27 +251,26 @@ pub(crate) struct SettingDeclaration { /// Choices of an enum or multiSelect setting, in display order. #[serde(default, skip_serializing_if = "Vec::is_empty")] options: Vec, - /// Whether a project may override the value. `settings` 2; `global` - /// when absent. + /// Whether a project may override the value; `global` when absent. #[serde(default, skip_serializing_if = "Option::is_none")] scope: Option, - /// When a change takes effect. `settings` 2; `restart` when absent. + /// When a change takes effect; `restart` when absent. #[serde(default, skip_serializing_if = "Option::is_none")] effect: Option, - /// Page that shows the setting besides the plugin's own. `settings` 2. + /// Page that shows the setting besides the plugin's own. #[serde(default, skip_serializing_if = "Option::is_none")] page: Option, - /// Heading the setting is grouped under on its page. `settings` 2. + /// Heading the setting is grouped under on its page. #[serde(default, skip_serializing_if = "Option::is_none")] section: Option, /// Regular expression a text value must match as a whole. Required for - /// text settings. `settings` 2. + /// text settings. #[serde(default, skip_serializing_if = "Option::is_none")] pattern: Option, - /// Longest text value, in characters. `settings` 2. + /// Longest text value, in characters. #[serde(default, skip_serializing_if = "Option::is_none")] max_length: Option, - /// Hint shown in an empty text, file, or directory field. `settings` 2. + /// Hint shown in an empty text, file, or directory field. #[serde(default, skip_serializing_if = "Option::is_none")] placeholder: Option, } @@ -401,23 +399,6 @@ impl SettingDeclaration { .map(str::to_owned)) } - fn is_version_2_kind(&self) -> bool { - matches!( - self.kind.as_str(), - SETTING_MULTI_SELECT | SETTING_TEXT | SETTING_FILE | SETTING_DIRECTORY - ) - } - - fn uses_version_2_fields(&self) -> bool { - self.scope.is_some() - || self.effect.is_some() - || self.page.is_some() - || self.section.is_some() - || self.pattern.is_some() - || self.max_length.is_some() - || self.placeholder.is_some() - } - fn effect(&self) -> SettingEffect { self.effect.unwrap_or(SettingEffect::Restart) } @@ -477,15 +458,8 @@ impl SettingDeclaration { self.options.iter().any(|option| option.value == value) } - /// Checks the declaration against the `settings` capability version the - /// manifest was written for. - pub(crate) fn validate(&self, field: &str, version: u32) -> Validation<()> { - if version < 2 && (self.is_version_2_kind() || self.uses_version_2_fields()) { - return Err(invalid( - field.to_string(), - "This setting requires version 2 of the settings capability.", - )); - } + /// Checks the declaration against the `settings` capability contract. + pub(crate) fn validate(&self, field: &str) -> Validation<()> { if !is_setting_identifier(&self.id) { return Err(invalid( format!("{field}.id"), @@ -824,18 +798,16 @@ mod tests { } #[test] - fn version_1_refuses_version_2_fields_and_types() { + fn scoped_and_text_settings_are_valid_declarations() { let scoped = setting(json!({ "id": "heap", "type": "integer", "title": "Heap", "default": 1, "minimum": 1, "maximum": 2, "scope": "project" })); - assert!(scoped.validate("settings[0]", 1).is_err()); - assert!(scoped.validate("settings[0]", 2).is_ok()); + assert!(scoped.validate("settings[0]").is_ok()); let text = setting(json!({ "id": "name", "type": "text", "title": "Name", "default": "a", "pattern": "[a-z]+" })); - assert!(text.validate("settings[0]", 1).is_err()); - assert!(text.validate("settings[0]", 2).is_ok()); + assert!(text.validate("settings[0]").is_ok()); } #[test] @@ -863,7 +835,7 @@ mod tests { let missing_pattern = setting(json!({ "id": "profile", "type": "text", "title": "Profile", "default": "dev" })); - assert!(missing_pattern.validate("settings[0]", 2).is_err()); + assert!(missing_pattern.validate("settings[0]").is_err()); } #[test] @@ -871,7 +843,7 @@ mod tests { let file = setting(json!({ "id": "settingsFile", "type": "file", "title": "Settings", "default": "" })); - assert!(file.validate("settings[0]", 2).is_ok()); + assert!(file.validate("settings[0]").is_ok()); assert!(file.accepts(&json!(""))); assert!(file.accepts(&json!("/Users/me/settings.xml"))); assert!(file.accepts(&json!("C:\\Users\\me\\settings.xml"))); @@ -885,7 +857,7 @@ mod tests { "id": "features", "type": "multiSelect", "title": "Features", "default": ["a"], "options": [{"value": "a", "title": "A"}, {"value": "b", "title": "B"}] })); - assert!(profiles.validate("settings[0]", 2).is_ok()); + assert!(profiles.validate("settings[0]").is_ok()); assert!(profiles.accepts(&json!(["b", "a"]))); assert!(profiles.accepts(&json!([]))); assert!(!profiles.accepts(&json!(["a", "a"]))); @@ -970,12 +942,12 @@ mod tests { "id": "trace", "type": "boolean", "title": "Trace", "default": true, "page": {"languageID": "toml"}, "section": "Language server" })); - assert!(paged.validate("settings[0]", 2).is_ok()); + assert!(paged.validate("settings[0]").is_ok()); let wrong = setting(json!({ "id": "trace", "type": "boolean", "title": "Trace", "default": true, "page": {"languageID": "has space"} })); - assert!(wrong.validate("settings[0]", 2).is_err()); + assert!(wrong.validate("settings[0]").is_err()); } } diff --git a/rust/lithe-core/src/plugins/settings_document_tests.rs b/rust/lithe-core/src/plugins/settings_document_tests.rs index ad711928..d9bb8726 100644 --- a/rust/lithe-core/src/plugins/settings_document_tests.rs +++ b/rust/lithe-core/src/plugins/settings_document_tests.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/settings/settings-v2.plugin.json" + "/../../shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json" ))) .unwrap() } @@ -49,14 +49,14 @@ fn settings_state_dispatches_with_core_owned_sources_and_normalized_scope() { "plugins.declarative.settingsState", json!({ "manifest": manifest(), - "globalDocument": "{\"dev.lithe.fixture.settings-v2\":{\"dialyzer\":false}}", - "projectDocument": "{\"dev.lithe.fixture.settings-v2\":{\"mixEnv\":\"test\"}}" + "globalDocument": "{\"dev.lithe.fixture.settings\":{\"dialyzer\":false}}", + "projectDocument": "{\"dev.lithe.fixture.settings\":{\"mixEnv\":\"test\"}}" }), ); assert_eq!(result["ok"], true, "{result}"); assert_eq!(result["data"]["status"], "ok", "{result}"); let state = &result["data"]["state"]; - assert_eq!(state["pluginID"], "dev.lithe.fixture.settings-v2"); + assert_eq!(state["pluginID"], "dev.lithe.fixture.settings"); let rows = state["rows"].as_array().unwrap(); let dialyzer = rows .iter() diff --git a/rust/lithe-core/src/plugins/web_content.rs b/rust/lithe-core/src/plugins/web_content.rs index 88ccd2eb..0a8d9f1c 100644 --- a/rust/lithe-core/src/plugins/web_content.rs +++ b/rust/lithe-core/src/plugins/web_content.rs @@ -1,4 +1,4 @@ -//! Plugin web content (`ui` 2): the rules a host applies around a plugin page. +//! Plugin web content (`ui`): the rules a host applies around a plugin page. //! //! The native host owns the web view, file reads, storage, dialogs and the //! calls a page makes; this module decides what a page is served, which bridge @@ -22,8 +22,8 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; /// URL scheme of plugin pages; the host is the plugin ID, one origin per plugin. const SCHEME: &str = "lithe-plugin"; -/// Bridge version; 2 adds the file dialogs to version 1. -const BRIDGE_VERSION: u32 = 2; +/// Version of the page bridge, the `ui` capability's web content version. +const BRIDGE_VERSION: u32 = 0; /// Package path of the host's bridge script. No package file under its /// directory is served: the host answers this path with the bootstrap script. const BOOTSTRAP_PATH: &str = ".lithe-host/bridge.js"; @@ -51,23 +51,23 @@ const PATH_LIMIT: usize = 4096; const EXTENSION_COUNT: usize = 16; const EXTENSION_LIMIT: usize = 16; -/// Bridge methods and the bridge version that introduced them. -const METHODS: &[(&str, u32)] = &[ - ("build.instances", 1), - ("build.refresh", 1), - ("build.runTask", 1), - ("build.runTests", 1), - ("build.cancel", 1), - ("build.dependencies", 1), - ("build.setOptions", 1), - ("workspace.openFile", 1), - ("ui.getState", 1), - ("ui.setState", 1), - ("functions.list", 1), - ("functions.invoke", 1), - ("functions.cancel", 1), - ("dialog.openFile", 2), - ("dialog.saveFile", 2), +/// Methods a page may request through the bridge. +const METHODS: &[&str] = &[ + "build.instances", + "build.refresh", + "build.runTask", + "build.runTests", + "build.cancel", + "build.dependencies", + "build.setOptions", + "workspace.openFile", + "ui.getState", + "ui.setState", + "functions.list", + "functions.invoke", + "functions.cancel", + "dialog.openFile", + "dialog.saveFile", ]; /// Events a host may deliver to a page. @@ -624,10 +624,7 @@ fn check( method: &str, params: Map, ) -> Result, Refusal> { - let known = METHODS - .iter() - .any(|(name, version)| *name == method && *version <= BRIDGE_VERSION); - if !known || (method.starts_with("build.") && !page.build_systems) { + if !METHODS.contains(&method) || (method.starts_with("build.") && !page.build_systems) { return Err(refuse( "plugin.web.unknownMethod", format!("This host does not answer {method}."), diff --git a/rust/lithe-core/src/plugins/web_content_tests.rs b/rust/lithe-core/src/plugins/web_content_tests.rs index d4a6358b..37eeefa9 100644 --- a/rust/lithe-core/src/plugins/web_content_tests.rs +++ b/rust/lithe-core/src/plugins/web_content_tests.rs @@ -27,7 +27,7 @@ fn open(build_systems: bool) -> u64 { opened["url"], "lithe-plugin://dev.lithe.fixture.web-panel/ui/index.html" ); - assert_eq!(opened["bridgeVersion"], 2); + assert_eq!(opened["bridgeVersion"], 0); opened["page"].as_u64().expect("page") } @@ -74,7 +74,7 @@ fn resources_stay_inside_the_package() { assert_eq!(bootstrap["kind"], "bootstrap"); let script = bootstrap["body"].as_str().expect("script"); assert!(script.contains("\"pluginID\":\"dev.lithe.fixture.web-panel\"")); - assert!(script.contains("version: 2")); + assert!(script.contains("version: 0")); assert!(!script.contains("__LITHE_")); let entry = resource("/ui/index.html"); assert_eq!(entry["kind"], "file"); diff --git a/rust/lithe-core/src/project_generators/form.rs b/rust/lithe-core/src/project_generators/form.rs index eb532d4a..f5280dbc 100644 --- a/rust/lithe-core/src/project_generators/form.rs +++ b/rust/lithe-core/src/project_generators/form.rs @@ -7,7 +7,7 @@ use super::manifest::{ Conditions, GeneratorField, ProjectGeneratorDeclaration, FIELD_BOOLEAN, FIELD_ENUM, - FIELD_MULTI_SELECT, FIELD_RUNTIME, FIELD_TEXT, FIELD_TOOL, + FIELD_MULTI_SELECT, FIELD_TEXT, FIELD_TOOL, }; use super::template::{FieldValue, Values}; use crate::plugins::declarative::version_components; @@ -18,14 +18,6 @@ use std::collections::{BTreeMap, BTreeSet}; /// Longest project name, in characters. const MAX_PROJECT_NAME: usize = 128; -/// A runtime installation the host discovered and the user selected. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct RuntimeSelection { - pub(crate) home: String, - pub(crate) version: String, -} - /// An installation of a tool that the host found through the tool /// definition's discovery, so the host has already checked that it is real. #[derive(Debug, Clone, Deserialize)] @@ -48,9 +40,6 @@ pub(crate) struct FormInput { pub(crate) values: BTreeMap, #[serde(default)] pub(crate) edited: BTreeSet, - /// Selected installations of runtime fields. - #[serde(default)] - pub(crate) runtimes: BTreeMap, /// Installations the host found for each tool field, by field ID. #[serde(default)] pub(crate) tools: BTreeMap>, @@ -64,7 +53,6 @@ pub(crate) enum FieldProblem { TooLong, InvalidFormat, UnsupportedValue, - RuntimeTooOld, /// The selected tool installation is older than the field's `minimumVersion`. ToolTooOld, /// The value names a newer version than `maximumVersion` allows. @@ -78,7 +66,6 @@ impl FieldProblem { Self::TooLong => "tooLong", Self::InvalidFormat => "invalidFormat", Self::UnsupportedValue => "unsupportedValue", - Self::RuntimeTooOld => "runtimeTooOld", Self::ToolTooOld => "toolTooOld", Self::VersionTooHigh => "versionTooHigh", } @@ -285,30 +272,6 @@ fn resolve( }; (json!(items), FieldValue::List(items), problem) } - FIELD_RUNTIME => match input.runtimes.get(&field.id) { - Some(selection) if !selection.home.trim().is_empty() => { - let too_old = field - .minimum_version - .as_deref() - .is_some_and(|minimum| release(&selection.version) < release(minimum)); - ( - json!(selection.home), - FieldValue::Runtime { - home: selection.home.clone(), - version: selection.version.clone(), - }, - too_old.then_some(FieldProblem::RuntimeTooOld), - ) - } - _ => ( - json!(""), - FieldValue::Runtime { - home: String::new(), - version: String::new(), - }, - (!field.optional).then_some(FieldProblem::Required), - ), - }, FIELD_TOOL => resolve_tool(field, input), _ => (Value::Null, FieldValue::Text(String::new()), None), } @@ -340,6 +303,7 @@ fn resolve_tool( FieldValue::Tool { home: installation.home.clone(), executable: installation.executable.clone(), + version: installation.version.clone(), }, (!meets_minimum(field, installation)).then_some(FieldProblem::ToolTooOld), ), @@ -348,6 +312,7 @@ fn resolve_tool( FieldValue::Tool { home: String::new(), executable: String::new(), + version: String::new(), }, (!field.optional).then_some(FieldProblem::Required), ), @@ -481,7 +446,6 @@ mod tests { project_name: project_name.into(), values: serde_json::from_value(values).unwrap(), edited: edited.iter().map(|id| id.to_string()).collect(), - runtimes: BTreeMap::new(), tools: BTreeMap::new(), } } @@ -549,36 +513,28 @@ mod tests { } #[test] - fn rejects_unsafe_text_and_old_runtimes() { + fn rejects_unsafe_text_and_old_tools() { let generator = generator(json!([ {"id": "groupId", "type": "text", "title": "Group", "pattern": "[a-z.]+"}, - {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"} + {"id": "jdk", "type": "tool", "title": "JDK", "tool": "java", "minimumVersion": "17"} ])); let mut form = input("demo", json!({"groupId": "-Dx=y"}), &["groupId"]); - form.runtimes.insert( - "jdk".into(), - RuntimeSelection { - home: "/jdk".into(), - version: "11.0.2".into(), - }, - ); + form.tools + .insert("jdk".into(), vec![installation("/jdk", "11.0.2")]); let state = evaluate(&generator, &form); assert_eq!( field(&state, "groupId").problem, Some(FieldProblem::InvalidFormat) ); - assert_eq!( - field(&state, "jdk").problem, - Some(FieldProblem::RuntimeTooOld) - ); + assert_eq!(field(&state, "jdk").problem, Some(FieldProblem::ToolTooOld)); } /// A target release newer than the selected JDK cannot compile, so the /// bound follows the JDK; before a JDK is chosen nothing is compared. #[test] - fn maximum_version_follows_an_earlier_runtime() { + fn maximum_version_follows_an_earlier_tool() { let generator = generator(json!([ - {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"}, + {"id": "jdk", "type": "tool", "title": "JDK", "tool": "java", "minimumVersion": "17"}, {"id": "javaVersion", "type": "enum", "title": "Java", "maximumVersion": "${field.jdk.major}", "options": [{"value": "17", "title": "17"}, {"value": "21", "title": "21"}]} ])); @@ -592,13 +548,8 @@ mod tests { ("21.0.4", None), ("25", None), ] { - form.runtimes.insert( - "jdk".into(), - RuntimeSelection { - home: "/jdk".into(), - version: version.into(), - }, - ); + form.tools + .insert("jdk".into(), vec![installation("/jdk", version)]); assert_eq!( field(&evaluate(&generator, &form), "javaVersion").problem, problem, @@ -643,6 +594,7 @@ mod tests { FieldValue::Tool { home: "/py/3.13".into(), executable: "/py/3.13/bin/tool".into(), + version: "3.13.1".into(), } ); @@ -727,19 +679,13 @@ mod tests { let state = evaluate(&tool_generator(false), &form); assert_eq!(field(&state, "sdk").value, json!("/first")); - // Runtime fields compare the same way; any tool-definition ID names one. - let runtime = generator(json!([ - {"id": "sdk", "type": "runtime", "title": "SDK", "runtime": "fixture-sdk", "minimumVersion": "17.0"} - ])); - let mut form = input("demo", json!({}), &[]); - form.runtimes.insert( - "sdk".into(), - RuntimeSelection { - home: "/sdk".into(), - version: "17".into(), - }, - ); - assert_eq!(field(&evaluate(&runtime, &form), "sdk").problem, None); + // A minimum with a trailing zero accepts the shorter spelling too. + let short = generator(json!([{ + "id": "sdk", "type": "tool", "title": "SDK", "tool": "fixture-sdk", + "minimumVersion": "17.0" + }])); + let form = with_tools(input("demo", json!({}), &[]), &[("/sdk", "17")]); + assert_eq!(field(&evaluate(&short, &form), "sdk").problem, None); } /// An old copy of an optional tool must not block a project that could be @@ -757,6 +703,7 @@ mod tests { FieldValue::Tool { home: String::new(), executable: String::new(), + version: String::new(), } ); assert!(!field(&state, "sdk").installations[0].meets_minimum); @@ -789,6 +736,7 @@ mod tests { FieldValue::Tool { home: String::new(), executable: String::new(), + version: String::new(), } ); } diff --git a/rust/lithe-core/src/project_generators/manifest.rs b/rust/lithe-core/src/project_generators/manifest.rs index 3cb1d2e2..0148b6e1 100644 --- a/rust/lithe-core/src/project_generators/manifest.rs +++ b/rust/lithe-core/src/project_generators/manifest.rs @@ -82,12 +82,7 @@ pub(crate) const FIELD_TEXT: &str = "text"; pub(crate) const FIELD_ENUM: &str = "enum"; pub(crate) const FIELD_BOOLEAN: &str = "boolean"; pub(crate) const FIELD_MULTI_SELECT: &str = "multiSelect"; -pub(crate) const FIELD_RUNTIME: &str = "runtime"; pub(crate) const FIELD_TOOL: &str = "tool"; -/// The `requires.projectGenerators` version that introduces tool fields and -/// retires runtime fields. A generator declared with version 2 selects every -/// tool through its definition; version 1 keeps the runtime field. -const TOOL_FIELDS_VERSION: u32 = 2; /// Where a tool field's selection is saved besides the generation steps. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -99,7 +94,7 @@ pub(crate) enum ToolPin { } /// One input of a generator. The types follow plugin settings, minus -/// integer and paths, plus `runtime` (version 1) and `tool` (version 2). +/// integer and paths, plus `tool`. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct GeneratorField { @@ -134,17 +129,14 @@ pub(crate) struct GeneratorField { pub(crate) max_length: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) placeholder: Option, - /// A text, runtime, or tool field may be left empty. + /// A text or tool field may be left empty. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub(crate) optional: bool, - /// Runtime a runtime field selects. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) runtime: Option, /// Tool definition ID a tool field selects an installation of. Which /// plugin defines it is not fixed here, so any plugin may add a tool. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) tool: Option, - /// Oldest acceptable runtime or tool version. + /// Oldest acceptable tool version. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) minimum_version: Option, /// Saves a tool field's selection once the project exists. @@ -424,13 +416,7 @@ impl GeneratorField { /// Validates every generator and sorts fields-free collections that have no /// meaningful order. Field and step order are kept: fields are drawn and /// defaults evaluated in order, steps run in order. -/// -/// `version` is the manifest's `requires.projectGenerators`; it decides -/// whether fields select a runtime (1) or a tool (2). -pub(crate) fn validate_declarations( - generators: &[ProjectGeneratorDeclaration], - version: u32, -) -> Validation<()> { +pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) -> Validation<()> { if generators.len() > MAX_GENERATORS { return Err(invalid( "projectGenerators", @@ -446,13 +432,13 @@ pub(crate) fn validate_declarations( "A project generator is declared more than once.", )); } - generator.validate(&field, version)?; + generator.validate(&field)?; } Ok(()) } impl ProjectGeneratorDeclaration { - fn validate(&self, field: &str, version: u32) -> Validation<()> { + fn validate(&self, field: &str) -> Validation<()> { if !is_generator_id(&self.id) { return Err(invalid( format!("{field}.id"), @@ -487,7 +473,7 @@ impl ProjectGeneratorDeclaration { let item_field = format!("{field}.fields[{index}]"); // A field reads only fields declared before it, so defaults are // evaluated in one pass and visibility never forms a cycle. - item.validate(&item_field, &declared, version)?; + item.validate(&item_field, &declared)?; if declared.insert(item.id.as_str(), item).is_some() { return Err(invalid( format!("{item_field}.id"), @@ -534,13 +520,7 @@ impl ProjectGeneratorDeclaration { } impl GeneratorField { - fn validate( - &self, - field: &str, - earlier: &BTreeMap<&str, &GeneratorField>, - version: u32, - ) -> Validation<()> { - self.check_kind_for_version(field, version)?; + fn validate(&self, field: &str, earlier: &BTreeMap<&str, &GeneratorField>) -> Validation<()> { if !is_setting_identifier(&self.id) { return Err(invalid( format!("{field}.id"), @@ -563,10 +543,9 @@ impl GeneratorField { "options" => matches!(kind, FIELD_ENUM | FIELD_MULTI_SELECT), "presentation" => kind == FIELD_ENUM, "text" => kind == FIELD_TEXT, - "optional" => matches!(kind, FIELD_TEXT | FIELD_RUNTIME | FIELD_TOOL), - "runtime" => kind == FIELD_RUNTIME, + "optional" => matches!(kind, FIELD_TEXT | FIELD_TOOL), "tool" => kind == FIELD_TOOL, - "minimumVersion" => matches!(kind, FIELD_RUNTIME | FIELD_TOOL), + "minimumVersion" => kind == FIELD_TOOL, "maximumVersion" => matches!(kind, FIELD_TEXT | FIELD_ENUM), _ => false, }; @@ -578,7 +557,6 @@ impl GeneratorField { self.pattern.is_some() || self.max_length.is_some() || self.placeholder.is_some(), ), ("optional", self.optional), - ("runtime", self.runtime.is_some()), ("tool", self.tool.is_some() || self.pin_as.is_some()), ("minimumVersion", self.minimum_version.is_some()), ("maximumVersion", self.maximum_version.is_some()), @@ -688,33 +666,6 @@ impl GeneratorField { )); } } - FIELD_RUNTIME => { - // The runtime is the tool definition with the same ID; the - // host discovers installations and Core only checks the - // version. - if !self - .runtime - .as_deref() - .is_some_and(crate::toolchain::definitions::tool_id) - { - return Err(invalid( - format!("{field}.runtime"), - "A runtime field names a tool-definition ID.", - )); - } - if let Some(version) = &self.minimum_version { - crate::plugins::declarative::validate_minimum_version( - version, - &format!("{field}.minimumVersion"), - )?; - } - if self.default.is_some() { - return Err(invalid( - format!("{field}.default"), - "The host chooses the initial runtime.", - )); - } - } FIELD_TOOL => { if !self .tool @@ -742,31 +693,12 @@ impl GeneratorField { _ => { return Err(invalid( format!("{field}.type"), - "A field type is text, enum, boolean, multiSelect, runtime, or tool.", + "A field type is text, enum, boolean, multiSelect, or tool.", )) } } Ok(()) } - - /// Runtime fields belong to `projectGenerators` 1 and tool fields to 2, - /// so one generator never mixes the old runtime path with tool - /// definitions. The old path is removed once no plugin uses version 1. - fn check_kind_for_version(&self, field: &str, version: u32) -> Validation<()> { - if self.is(FIELD_TOOL) && version < TOOL_FIELDS_VERSION { - return Err(invalid( - format!("{field}.type"), - "A tool field needs requires.projectGenerators 2.", - )); - } - if self.is(FIELD_RUNTIME) && version >= TOOL_FIELDS_VERSION { - return Err(invalid( - format!("{field}.type"), - "projectGenerators 2 selects installations with a tool field, not a runtime field.", - )); - } - Ok(()) - } } impl GeneratorStep { @@ -911,9 +843,9 @@ fn check_conditions( let comparable = |value: &Value| match target.kind.as_str() { FIELD_ENUM => value.as_str().is_some_and(|text| target.offers(text)), FIELD_BOOLEAN => value.is_boolean(), - // A runtime or tool field compares as its home, so - // `notEquals ""` asks whether an installation was chosen. - FIELD_TEXT | FIELD_RUNTIME | FIELD_TOOL => value.is_string(), + // A tool field compares as its home, so `notEquals ""` asks + // whether an installation was chosen. + FIELD_TEXT | FIELD_TOOL => value.is_string(), _ => false, }; if !comparisons.into_iter().all(comparable) { @@ -970,15 +902,15 @@ fn check_variables( if !variable_is_known(variable, |id| fields.get(id).copied()) { return Err(invalid( field, - "The template reads a field that is not declared earlier, a runtime version of a field that is not a runtime, or the executable of a field that is not a tool.", + "The template reads a field that is not declared earlier, or the version or executable of a field that is not a tool.", )); } } Ok(()) } -/// Whether a variable names a field `lookup` knows, with a version suffix -/// only on runtime fields and `.executable` only on tool fields. +/// Whether a variable names a field `lookup` knows, with the `.version`, +/// `.major`, and `.executable` suffixes only on tool fields. pub(crate) fn variable_is_known<'a>( variable: &Variable, lookup: impl Fn(&str) -> Option<&'a GeneratorField>, @@ -986,10 +918,9 @@ pub(crate) fn variable_is_known<'a>( match &variable.source { Source::ProjectName => true, Source::Field(id) => lookup(id).is_some(), - Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => { - lookup(id).is_some_and(|field| field.is(FIELD_RUNTIME)) + Source::ToolVersion(id) | Source::ToolMajor(id) | Source::ToolExecutable(id) => { + lookup(id).is_some_and(|field| field.is(FIELD_TOOL)) } - Source::ToolExecutable(id) => lookup(id).is_some_and(|field| field.is(FIELD_TOOL)), } } diff --git a/rust/lithe-core/src/project_generators/mod.rs b/rust/lithe-core/src/project_generators/mod.rs index e1facf5b..9e77afb0 100644 --- a/rust/lithe-core/src/project_generators/mod.rs +++ b/rust/lithe-core/src/project_generators/mod.rs @@ -1,5 +1,6 @@ -//! Project generators declared by API level 3 plugins: the entries of the -//! New Project window and the deterministic half of creating a project. +//! Project generators declared by plugins that list `projectGenerators` in +//! `requires`: the entries of the New Project window and the deterministic +//! half of creating a project. //! //! A plugin describes a way to start a project as data (`projectGenerators` //! in `plugin.json`): where it is listed, which fields the user fills in, @@ -12,7 +13,7 @@ //! processes in a staging directory, and move the finished project into //! place. //! -//! A version 2 generator picks tool installations with `tool` fields. The +//! A generator picks tool installations with `tool` fields. The //! host lists the installations of the tool a field names (through the same //! tool definitions and discovery the settings use); Core chooses among them //! (the newest that meets `minimumVersion`), reports the choice in the form, diff --git a/rust/lithe-core/src/project_generators/plan.rs b/rust/lithe-core/src/project_generators/plan.rs index 6bf6f57f..f3891f50 100644 --- a/rust/lithe-core/src/project_generators/plan.rs +++ b/rust/lithe-core/src/project_generators/plan.rs @@ -1,9 +1,7 @@ //! Turning a filled-in form into the steps a host runs. use super::form::{self, FormInput, FormState}; -use super::manifest::{ - variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_RUNTIME, FIELD_TOOL, -}; +use super::manifest::{variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_TOOL}; use super::template::{self, FieldValue, Values, Variable}; use crate::plugins::declarative::{is_safe_relative_path, PluginRejection, Validation}; use serde_json::{json, Value}; @@ -136,21 +134,6 @@ pub(crate) fn plan( } } - let runtimes: Vec = generator - .fields - .iter() - .filter(|field| field.is(FIELD_RUNTIME)) - .filter_map(|field| match state.values.get(&field.id) { - Some(FieldValue::Runtime { home, version }) if !home.is_empty() => Some(json!({ - "field": field.id, - "runtime": field.runtime, - "home": home, - "version": version, - })), - _ => None, - }) - .collect(); - let uses_network = generator .steps .iter() @@ -161,7 +144,6 @@ pub(crate) fn plan( "projectName": input.project_name, "steps": steps, "openFiles": open_files, - "runtimes": runtimes, "toolPins": tool_pins(generator, &state), "usesNetwork": uses_network, })) @@ -178,7 +160,9 @@ fn tool_pins(generator: &ProjectGeneratorDeclaration, state: &FormState) -> Vec< .filter_map(|field| { let pin_as = field.pin_as?; match state.values.get(&field.id) { - Some(FieldValue::Tool { home, executable }) if !home.is_empty() => Some(json!({ + Some(FieldValue::Tool { + home, executable, .. + }) if !home.is_empty() => Some(json!({ "field": field.id, "tool": field.tool, "pinAs": pin_as, diff --git a/rust/lithe-core/src/project_generators/template.rs b/rust/lithe-core/src/project_generators/template.rs index 1be3789b..f871805c 100644 --- a/rust/lithe-core/src/project_generators/template.rs +++ b/rust/lithe-core/src/project_generators/template.rs @@ -15,12 +15,12 @@ pub(crate) enum Source { /// The project name the user typed. ProjectName, /// A field's value: text, the chosen option, `true`/`false`, a - /// comma-separated list, or the home directory of a runtime or tool. + /// comma-separated list, or the home directory of a tool. Field(String), - /// The full version of a runtime field's selection. - RuntimeVersion(String), - /// The major version of a runtime field's selection, such as `21`. - RuntimeMajor(String), + /// The full version of a tool field's selection. + ToolVersion(String), + /// The major version of a tool field's selection, such as `21`. + ToolMajor(String), /// The executable of a tool field's selection, the file the tool /// definition resolved inside the home. ToolExecutable(String), @@ -55,8 +55,8 @@ impl Variable { let rest = name.strip_prefix("field.")?; match rest.split_once('.') { None => Source::Field(rest.to_string()), - Some((id, "version")) => Source::RuntimeVersion(id.to_string()), - Some((id, "major")) => Source::RuntimeMajor(id.to_string()), + Some((id, "version")) => Source::ToolVersion(id.to_string()), + Some((id, "major")) => Source::ToolMajor(id.to_string()), Some((id, "executable")) => Source::ToolExecutable(id.to_string()), Some(_) => return None, } @@ -78,8 +78,8 @@ impl Variable { match &self.source { Source::ProjectName => None, Source::Field(id) - | Source::RuntimeVersion(id) - | Source::RuntimeMajor(id) + | Source::ToolVersion(id) + | Source::ToolMajor(id) | Source::ToolExecutable(id) => Some(id), } } @@ -96,14 +96,11 @@ pub(crate) enum FieldValue { Text(String), Flag(bool), List(Vec), - Runtime { - home: String, - version: String, - }, /// The installation a tool field selected. Tool { home: String, executable: String, + version: String, }, } @@ -113,7 +110,7 @@ impl FieldValue { Self::Text(text) => text.clone(), Self::Flag(flag) => flag.to_string(), Self::List(items) => items.join(","), - Self::Runtime { home, .. } | Self::Tool { home, .. } => home.clone(), + Self::Tool { home, .. } => home.clone(), } } } @@ -133,12 +130,12 @@ impl Values<'_> { .get(id) .map(FieldValue::text) .unwrap_or_default(), - Source::RuntimeVersion(id) => match self.fields.get(id) { - Some(FieldValue::Runtime { version, .. }) => version.clone(), + Source::ToolVersion(id) => match self.fields.get(id) { + Some(FieldValue::Tool { version, .. }) => version.clone(), _ => String::new(), }, - Source::RuntimeMajor(id) => match self.fields.get(id) { - Some(FieldValue::Runtime { version, .. }) => major_version(version), + Source::ToolMajor(id) => match self.fields.get(id) { + Some(FieldValue::Tool { version, .. }) => major_version(version), _ => String::new(), }, Source::ToolExecutable(id) => match self.fields.get(id) { @@ -263,7 +260,11 @@ mod tests { ); assert_eq!( Variable::parse("field.jdk.major").map(|variable| variable.source), - Some(Source::RuntimeMajor("jdk".into())) + Some(Source::ToolMajor("jdk".into())) + ); + assert_eq!( + Variable::parse("field.jdk.version").map(|variable| variable.source), + Some(Source::ToolVersion("jdk".into())) ); assert_eq!( Variable::parse("field.python.executable").map(|variable| variable.source), @@ -275,12 +276,13 @@ mod tests { } #[test] - fn tool_fields_read_as_home_and_executable() { + fn tool_fields_read_as_home_executable_and_version() { let fields = values(&[( "python", FieldValue::Tool { home: "/opt/python".into(), executable: "/opt/python/bin/python3".into(), + version: "3.12.4".into(), }, )]); let values = Values { @@ -293,8 +295,12 @@ mod tests { expand("${field.python.executable} -m venv ."), "/opt/python/bin/python3 -m venv ." ); + assert_eq!(expand("${field.python.version}"), "3.12.4"); + assert_eq!(expand("${field.python.major}"), "3"); // A field that holds no tool reads as empty, never as another value. assert_eq!(expand("${field.missing.executable}"), ""); + assert_eq!(expand("${field.missing.version}"), ""); + assert_eq!(expand("${field.missing.major}"), ""); } #[test] diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index f845e330..1cfad8be 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -168,8 +168,6 @@ pub enum CoreCommand { DebugTransportHandle, /// Forgets the lifecycle of a stopped byte stream (`debug.transport.release`). DebugTransportRelease, - /// Compares a launch runtime's version output with its requirement (`plugins.declarative.checkRuntimeVersion`). - PluginsDeclarativeCheckRuntimeVersion, /// Merges a plugin's stored setting layers into effective values (`plugins.declarative.resolveSettings`). PluginsDeclarativeResolveSettings, /// Projects stored documents into plugin setting rows and launch layers. @@ -912,9 +910,6 @@ impl CoreCommand { "debug.transport.policy" => Some(Self::DebugTransportPolicy), "debug.transport.handle" => Some(Self::DebugTransportHandle), "debug.transport.release" => Some(Self::DebugTransportRelease), - "plugins.declarative.checkRuntimeVersion" => { - Some(Self::PluginsDeclarativeCheckRuntimeVersion) - } "plugins.declarative.resolveSettings" => Some(Self::PluginsDeclarativeResolveSettings), "plugins.settings.catalog" => Some(Self::PluginsSettingsCatalog), "plugins.settings.navigation" => Some(Self::PluginsSettingsNavigation), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index d0ad7cee..35826374 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -393,11 +393,6 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::plugins::declarative::debug_executable_plan_command, ), - CoreCommand::PluginsDeclarativeCheckRuntimeVersion => plugin_response( - id, - parsed.payload, - crate::plugins::declarative::check_runtime_version_command, - ), CoreCommand::PluginsDeclarativeResolveSettings => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/build_configuration.rs b/rust/lithe-core/src/tests/build_configuration.rs index 9a4a71a0..d6275fb9 100644 --- a/rust/lithe-core/src/tests/build_configuration.rs +++ b/rust/lithe-core/src/tests/build_configuration.rs @@ -42,7 +42,7 @@ fn edit_request() -> Value { } #[test] -fn native_configuration_requires_capability_seven_and_preserves_option_arguments() { +fn native_configuration_needs_a_provider_host_and_preserves_option_arguments() { let executable = if cfg!(windows) { "C:/fixture/mvn.cmd" } else { @@ -68,14 +68,11 @@ fn native_configuration_requires_capability_seven_and_preserves_option_arguments )["status"], "rejected" ); - for version in 1..7 { - let mut old = manifest(); - old["requires"]["buildProviders"] = json!(version); - assert_eq!( - validate_command(json!({"manifest":old})).unwrap()["status"], - "rejected" - ); - } + let refused = validate_command(json!({"manifest":manifest(),"hostCapabilities":{ + "buildSystems":[0],"settings":[0]}})) + .unwrap(); + assert_eq!(refused["status"], "rejected", "{refused}"); + assert_eq!(refused["error"]["field"], "requires.buildProviders"); } #[test] @@ -207,7 +204,7 @@ fn native_editing_rejects_stale_unknown_read_only_and_escaping_requests() { #[test] fn native_edit_receipt_is_bound_to_confirmed_source_and_contains_no_document() { let request = edit_request(); - let receipt = json!({"format":"litheConfigurationEdit","version":1,"sourceID":"user", "revision":format!("sha256:{}", "b".repeat(64)),"changed":true}); + let receipt = json!({"format":"litheConfigurationEdit","version":0,"sourceID":"user", "revision":format!("sha256:{}", "b".repeat(64)),"changed":true}); let payload = |result: Value| json!({"snapshot":request["snapshot"],"edits":request["edits"],"result":result}); assert_eq!( call( @@ -230,7 +227,7 @@ fn native_edit_receipt_is_bound_to_confirmed_source_and_contains_no_document() { "rejected" ); } - let unchanged = json!({"format":"litheConfigurationEdit","version":1,"sourceID":"user","revision":request["edits"]["revision"],"changed":false}); + let unchanged = json!({"format":"litheConfigurationEdit","version":0,"sourceID":"user","revision":request["edits"]["revision"],"changed":false}); assert_eq!( call(build_decode_configuration_edit_command, payload(unchanged))["status"], "ok" diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index a61b9dc6..da9503ba 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -1,5 +1,5 @@ -//! Build systems declared by API level 3 plugins: manifest validation, plan -//! expansion, output decoding, and detection over real workspace files. +//! Build systems declared by plugins: manifest validation, plan expansion, +//! output decoding, and detection over real workspace files. use super::support::temporary_root; use crate::build::{ @@ -225,24 +225,24 @@ fn instance_ids(detection: &Value) -> Vec { } #[test] -fn instance_option_scope_requires_a_capable_host_and_valid_declaration() { +fn instance_option_scope_requires_a_host_offering_build_systems_and_a_valid_declaration() { let mut manifest = fixture("build-instance-options.plugin.json"); - let rejected = validate_command( - json!({"manifest": manifest, "hostCapabilities": {"buildSystems": [1, 2]}}), - ) - .unwrap(); - assert_eq!(rejected["status"], "rejected", "{rejected}"); - manifest["requires"]["buildSystems"] = json!(2); - let rejected = validate_command(json!({"manifest": manifest})).unwrap(); + let rejected = + validate_command(json!({"manifest": manifest, "hostCapabilities": {"settings": [0]}})) + .unwrap(); assert_eq!(rejected["status"], "rejected", "{rejected}"); - manifest["requires"]["buildSystems"] = json!(3); + assert_eq!(rejected["error"]["field"], "requires.buildSystems"); + let accepted = + validate_command(json!({"manifest": manifest, "hostCapabilities": {"buildSystems": [0]}})) + .unwrap(); + assert_eq!(accepted["status"], "ok", "{accepted}"); manifest["buildSystems"][0]["options"][0]["scope"] = json!("project"); let rejected = validate_command(json!({"manifest": manifest})).unwrap(); assert_eq!(rejected["status"], "rejected", "{rejected}"); } #[test] -fn canonical_toolchain_candidates_keep_instance_identity_and_require_version_two() { +fn canonical_toolchain_candidates_keep_instance_identity() { let mut manifest = fixture("build-toolchains.plugin.json"); let plan = call( "invocationPlan", @@ -262,10 +262,6 @@ fn canonical_toolchain_candidates_keep_instance_identity_and_require_version_two plan["invocation"]["runtimeDependencies"][0]["version"]["arguments"], json!(["--version"]) ); - manifest["requires"]["executableResolution"] = json!(1); - let rejected = validate_command(json!({"manifest":manifest})).unwrap(); - assert_eq!(rejected["status"], "rejected", "{rejected}"); - manifest["requires"]["executableResolution"] = json!(2); manifest["buildSystems"][0]["invocation"]["runtimeDependencies"][0]["executables"][0] ["toolchainID"] = json!("../invalid"); let rejected = validate_command(json!({"manifest":manifest})).unwrap(); @@ -388,7 +384,7 @@ fn provider_test_reports_are_inline_bounded_and_keep_project_identity() { ); assert_eq!(plan["plan"]["results"], json!({"decoder":"provider"})); assert_eq!(plan["plan"]["provider"]["params"]["testResults"], true); - let report = json!({"format":"litheTestResults","version":1,"tests":[ + let report = json!({"format":"litheTestResults","version":0,"tests":[ {"project":"nested/a","suite":"demo.Same","case":"checks","status":"passed","path":"nested/a/source.java","line":1}, {"project":"nested/b","suite":"demo.Same","case":"checks","status":"failed","message":"assertion failed"} ]}); @@ -405,12 +401,12 @@ fn provider_test_reports_are_inline_bounded_and_keep_project_identity() { assert_eq!(decoded["results"]["tests"][1]["project"], "nested/b"); for invalid in [ json!(null), - json!({"format":"wrong","version":1,"tests":[]}), - json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"unknown"}]}), - json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","project":"../escape"}]}), - json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","path":"outside.java"}]}), - json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","line":0}]}), - json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","invocations":0}]}), + json!({"format":"wrong","version":0,"tests":[]}), + json!({"format":"litheTestResults","version":0,"tests":[{"suite":"s","case":"c","status":"unknown"}]}), + json!({"format":"litheTestResults","version":0,"tests":[{"suite":"s","case":"c","status":"passed","project":"../escape"}]}), + json!({"format":"litheTestResults","version":0,"tests":[{"suite":"s","case":"c","status":"passed","path":"outside.java"}]}), + json!({"format":"litheTestResults","version":0,"tests":[{"suite":"s","case":"c","status":"passed","line":0}]}), + json!({"format":"litheTestResults","version":0,"tests":[{"suite":"s","case":"c","status":"passed","invocations":0}]}), ] { let mut input = request.clone(); input["result"] = invalid; @@ -425,12 +421,6 @@ fn provider_test_reports_are_inline_bounded_and_keep_project_identity() { call("decodeTestResults", manifest.clone(), "gradle", absent)["status"], "ok" ); - let mut old = manifest.clone(); - old["requires"]["buildProviders"] = json!(7); - assert_ne!( - validate_command(json!({"manifest":old})).unwrap()["status"], - "ok" - ); let mut command = manifest; command["buildSystems"][0]["test"]["source"] = json!("command"); assert_ne!( @@ -440,10 +430,10 @@ fn provider_test_reports_are_inline_bounded_and_keep_project_identity() { } #[test] -fn provider_test_coverage_is_bounded_canonical_and_does_not_change_legacy_results() { +fn provider_test_coverage_is_bounded_canonical_and_absent_unless_reported() { let workspace = TemporaryWorkspace::new("canonical-coverage"); let decode = |coverage: Option| { - let mut result = json!({"format":"litheTestResults","version":1,"tests":[]}); + let mut result = json!({"format":"litheTestResults","version":0,"tests":[]}); if let Some(coverage) = coverage { result["coverage"] = coverage; } @@ -487,7 +477,7 @@ fn canonical_repeated_cases_sum_only_complete_safe_durations() { let left = &case["values"][0]; let right = &case["values"][1]; let expected = &case["durationMillis"]; - let result = json!({"format":"litheTestResults","version":1,"tests":[ + let result = json!({"format":"litheTestResults","version":0,"tests":[ {"project":".","suite":"Suite","case":"checks","status":"passed","durationMillis":left}, {"project":".","suite":"Suite","case":"checks","status":"failed","durationMillis":right} ]}); @@ -541,14 +531,9 @@ fn opaque_test_selectors_require_a_declared_namespace_and_never_become_host_filt let mut missing = request; missing.as_object_mut().unwrap().remove("selectorKind"); assert_ne!( - call("testPlan", manifest.clone(), "gradle", missing)["status"], + call("testPlan", manifest, "gradle", missing)["status"], "ok" ); - for version in 1..9 { - let mut old = manifest.clone(); - old["requires"]["buildProviders"] = json!(version); - assert_ne!(validate(old)["status"], "ok"); - } } #[test] @@ -807,7 +792,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { "state/model.json", &json!({ "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [ { "path": format!("{root}"), "name": "shop", "properties": { "taskPrefix": ":" } }, { "path": "app", "name": "app", "properties": { "taskPrefix": ":app:" } } @@ -825,7 +810,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { "state/deps.json", &json!({ "format": "litheDependencies", - "version": 1, + "version": 0, "dependencies": [{ "name": "spring-web", "group": "org.springframework", @@ -1076,7 +1061,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { }) .collect(); let mut document = json!({ - "format": "litheBuildModel", "version": 1, + "format": "litheBuildModel", "version": 0, "projects": [{ "path": "app", "name": "app" }], "targets": entries, "tasks": entries, "contentRoots": entries }); @@ -1228,7 +1213,7 @@ fn canonical_content_roots_are_relative_to_the_instance_root() { "state/model.json", &json!({ "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [ { "name": "shop" }, { "path": "app", "name": "app" } @@ -1622,7 +1607,7 @@ fn reported_output_paths_reject_escaping_symlinks() { ); workspace.write( "app/build/test-results/test/report.json", - r#"{"format": "litheTestResults", "version": 1, "tests": [ + r#"{"format": "litheTestResults", "version": 0, "tests": [ {"suite": "Report", "case": "external", "status": "failed", "path": "linked/Secret.java", "line": 1}, {"suite": "Report", "case": "local", "status": "failed", "path": "alias/Inside.java", "line": 1} ]}"#, @@ -1739,7 +1724,7 @@ fn task_categories_and_option_placements_are_display_only() { "state/model.json", &json!({ "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [{ "name": "root" }], "tasks": [ { "name": "run", "template": "gradle-task", "category": ["Tasks", "application"], "properties": { "path": ":run" } }, @@ -1859,12 +1844,6 @@ fn explicit_build_tool_path_requires_supported_global_file_setting() { invalid["settings"][0][field] = value; assert_eq!(validate(invalid)["status"], "rejected", "{field}"); } - let mut old = manifest.clone(); - old["requires"] - .as_object_mut() - .unwrap() - .remove("executableResolution"); - assert_eq!(validate(old)["status"], "rejected"); let mut missing = manifest; missing["buildSystems"][0]["invocation"]["executables"][0]["settingID"] = json!("missing"); assert_eq!(validate(missing)["status"], "rejected"); @@ -1926,35 +1905,18 @@ fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { } #[test] -fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { - let mut manifest: Value = serde_json::from_str(contract_file!( +fn invalid_tool_path_values_reject_plans_while_the_settings_display_stays_tolerant() { + let manifest: Value = serde_json::from_str(contract_file!( "fixtures/build-systems/build-tool-path.plugin.json" )) .unwrap(); - manifest["requires"] - .as_object_mut() - .unwrap() - .remove("executableResolution"); - manifest["buildSystems"][0]["invocation"]["executables"] - .as_array_mut() - .unwrap() - .remove(0); - manifest["requires"]["buildSystems"] = json!(2); - manifest["buildSystems"][0]["invocation"]["executableSetting"] = json!("toolPath"); - let run = |value| { - call( - "invocationPlan", - manifest.clone(), - "fixture", - json!({"root":".","platform":"macos","settings":{"toolPath":value}}), - ) - }; - let result = run(json!("/tools/custom build")); - assert_eq!(result["status"], "ok", "{result}"); - assert_eq!(result["invocation"]["executables"][0]["kind"], "setting"); - assert!(result["invocation"].get("configuredExecutable").is_none()); - assert_eq!(run(json!(false))["status"], "rejected"); - // The existing settings UI keeps its tolerant display behavior. + let result = call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({"root":".","platform":"macos","settings":{"toolPath":false}}), + ); + assert_eq!(result["status"], "rejected", "{result}"); let displayed = crate::plugins::declarative::resolve_settings_command( json!({"manifest":manifest,"settings":{"toolPath":false}}), ) @@ -1964,16 +1926,11 @@ fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { } #[test] -fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { +fn custom_runtimes_share_the_candidate_plan() { let mut manifest: Value = serde_json::from_str(contract_file!( "fixtures/build-systems/build-tool-path.plugin.json" )) .unwrap(); - let old_host = validate_command( - json!({"manifest":manifest,"hostCapabilities":{"buildSystems":[1,2],"settings":[1,2]}}), - ) - .unwrap(); - assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); manifest["buildSystems"][0]["invocation"]["runtimeDependencies"] = json!([{ "id":"custom", "executables":[ {"id":"selected","kind":"setting","settingID":"toolPath"}, @@ -1999,27 +1956,24 @@ fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { assert_eq!(validate(manifest)["status"], "rejected"); } -/// A provider-only task is admitted only with the host capability and never -/// becomes argv. Selection boundaries and effectful admission remain shared. +/// A provider-only task is admitted only by a host that offers the provider +/// capability and never becomes argv. Selection boundaries and effectful +/// admission remain shared. #[test] -fn provider_execution_tasks_are_typed_and_version_gated() { +fn provider_execution_tasks_are_typed_and_need_a_provider_host() { let manifest: Value = serde_json::from_str(contract_file!( "fixtures/build-providers/build-provider-execution.plugin.json" )) .unwrap(); let validated = validate(manifest.clone()); assert_eq!(validated["status"], "ok", "{validated}"); - let mut old = manifest.clone(); - old["requires"]["buildProviders"] = json!(1); - assert_eq!(validate(old)["status"], "rejected"); let mut command = manifest.clone(); command["buildSystems"][0]["tasks"][0]["arguments"] = json!(["build"]); assert_eq!(validate(command)["status"], "rejected"); - // An older host must refuse admission, not try a command fallback. + // A host without the provider capability must refuse admission, not try a + // command fallback. let refused = validate_command(json!({ - "manifest": manifest, "hostCapabilities": { - "buildProviders":[1], "buildSystems":[1], "serverProcess":[1], "settings":[1] - } + "manifest": manifest, "hostCapabilities": {"buildSystems":[0], "settings":[0]} })) .unwrap(); assert_eq!(refused["status"], "rejected"); @@ -2064,7 +2018,7 @@ fn provider_execution_tasks_are_typed_and_version_gated() { } #[test] -fn preparation_tasks_are_declared_versioned_and_unambiguous() { +fn preparation_tasks_are_declared_and_unambiguous() { let manifest: Value = serde_json::from_str(contract_file!( "fixtures/build-providers/build-provider-preparation.plugin.json" )) @@ -2087,13 +2041,14 @@ fn preparation_tasks_are_declared_versioned_and_unambiguous() { 1, "Automatic invocation bindings are behavioral, not display-only metadata" ); - let mut unversioned = manifest.clone(); - unversioned["requires"]["buildSystems"] = json!(3); - assert_eq!(validate(unversioned)["status"], "rejected"); - let old_host = validate_command(json!({"manifest":manifest, - "hostCapabilities":{"buildSystems":[1,2,3],"buildProviders":[1,2],"serverProcess":[1],"settings":[1]}})).unwrap(); - assert_eq!(old_host["status"], "rejected"); - assert_eq!(old_host["error"]["field"], "requires.buildSystems"); + let provider_only_host = validate_command(json!({"manifest":manifest, + "hostCapabilities":{"buildProviders":[0],"settings":[0]}})) + .unwrap(); + assert_eq!(provider_only_host["status"], "rejected"); + assert_eq!( + provider_only_host["error"]["field"], + "requires.buildSystems" + ); for binding in [ json!({"purpose":"run","sourceRoles":[]}), @@ -2190,21 +2145,17 @@ fn provider_tests_preserve_filters_and_report_ownership() { ); } -/// Older hosts/processes must reject provider tests rather than silently run -/// every test or fall back to host-owned business logic. +/// Hosts without the provider capability, and manifests without an owned +/// process, must reject provider tests rather than silently run every test or +/// fall back to host-owned business logic. #[test] -fn provider_tests_require_version_three_and_an_owned_process() { +fn provider_tests_require_a_provider_host_and_an_owned_process() { let manifest: Value = serde_json::from_str(contract_file!( "fixtures/build-providers/build-provider-tests.plugin.json" )) .unwrap(); - for version in [1, 2] { - let mut old = manifest.clone(); - old["requires"]["buildProviders"] = json!(version); - assert_eq!(validate(old)["status"], "rejected"); - } let refused = validate_command(json!({"manifest":manifest,"hostCapabilities":{ - "buildProviders":[1,2],"buildSystems":[1],"serverProcess":[1],"settings":[1]}})) + "buildSystems":[0],"settings":[0]}})) .unwrap(); assert_eq!(refused["error"]["field"], "requires.buildProviders"); for (field, value) in [ @@ -2286,11 +2237,11 @@ fn provider_test_discovery_is_bounded_deterministic_and_runnable() { assert_eq!(runnable["status"], "ok", "{runnable}"); } for invalid in [ - json!({"format":"litheTestDiscovery","version":2,"tests":[]}), - json!({"format":"litheTestDiscovery","version":1,"tests":vec![json!({});10001]}), - json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a"}]}), - json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a","case":"b","project":{"name":"escape","path":"../escape"}}]}), - json!({"format":"litheTestDiscovery","version":1,"tests":[{"suite":"a","case":"bad\u{0}"}]}), + json!({"format":"litheTestDiscovery","version":1,"tests":[]}), + json!({"format":"litheTestDiscovery","version":0,"tests":vec![json!({});10001]}), + json!({"format":"litheTestDiscovery","version":0,"tests":[{"suite":"a"}]}), + json!({"format":"litheTestDiscovery","version":0,"tests":[{"suite":"a","case":"b","project":{"name":"escape","path":"../escape"}}]}), + json!({"format":"litheTestDiscovery","version":0,"tests":[{"suite":"a","case":"bad\u{0}"}]}), ] { let rejected = decode(invalid); assert_eq!( @@ -2349,21 +2300,18 @@ fn provider_run_preparation_selects_targets_and_validates_launch_data() { invalid[field] = value; assert_eq!(plan(invalid)["status"], "rejected"); } - for version in [1, 2, 3] { - let mut old = manifest.clone(); - old["requires"]["buildProviders"] = json!(version); - assert_eq!(validate(old)["status"], "rejected"); - } for (field, value) in [ ("fromTasks", json!({"templates":["run"]})), ("arguments", json!(["Main"])), - ("execution", json!("service")), ("extension", json!({"main":"Main"})), ] { let mut invalid = manifest.clone(); invalid["buildSystems"][0]["run"][0][field] = value; assert_eq!(validate(invalid)["status"], "rejected"); } + let mut service = manifest.clone(); + service["buildSystems"][0]["run"][0]["execution"] = json!("service"); + assert_eq!(validate(service)["status"], "ok"); let workspace = TemporaryWorkspace::new("provider-run-launch"); workspace.write("app/Main.java", "class Main {}"); let decode = |result: Value| { @@ -2438,11 +2386,11 @@ fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() "{planned}" ); assert_eq!(planned["plan"]["decoder"], "litheDebugPreparation"); - for (field, value) in [("buildProviders", json!(4)), ("debugAdapters", json!(1))] { - let mut old = manifest.clone(); - old["requires"][field] = value; - assert_eq!(validate(old)["status"], "rejected"); - } + let refused = validate_command(json!({"manifest":manifest,"hostCapabilities":{ + "buildProviders":[0],"buildSystems":[0],"settings":[0]}})) + .unwrap(); + assert_eq!(refused["status"], "rejected"); + assert_eq!(refused["error"]["field"], "requires.debugAdapters"); for adapter in ["other/native", "missing", ""] { let mut invalid = manifest.clone(); invalid["buildSystems"][0]["run"][0]["debug"]["adapter"] = json!(adapter); @@ -2625,10 +2573,9 @@ fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { "fixtures/build-systems/build-model-consumer.plugin.json" )) .unwrap(); - for change in ["oldWire", "noCapability", "reexport", "unknownOption"] { + for change in ["noCapability", "reexport", "unknownOption"] { let mut manifest = consumer.clone(); match change { - "oldWire" => manifest["requires"]["buildProviders"] = json!(5), "noCapability" => { manifest["requires"] .as_object_mut() @@ -2770,14 +2717,13 @@ fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() plan(manifest.clone(), absent).unwrap()["status"], "rejected" ); - let mut old = manifest.clone(); - old["requires"]["buildProviders"] = json!(9); - assert_eq!(validate(old.clone())["status"], "rejected"); - old["buildSystems"][0]["run"][0]["execution"] = json!("application"); + let mut application_manifest = manifest.clone(); + application_manifest["buildSystems"][0]["run"][0]["execution"] = json!("application"); let mut application = request.clone(); application["launchOptions"]["execution"] = json!("application"); - assert_eq!(validate(old.clone())["status"], "ok"); - assert_eq!(plan(old, application).unwrap()["status"], "rejected"); + assert_eq!(validate(application_manifest.clone())["status"], "ok"); + let planned = plan(application_manifest, application).unwrap(); + assert_eq!(planned["status"], "ok", "{planned}"); let decode = |options: Value, result: Value| { build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"workspaceRoot":workspace.root(),"root":"app","launchOptions":options,"result":result}})).unwrap() @@ -2804,7 +2750,7 @@ fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() /// Runtime debugging requires an explicit process agreement and never a provider-selected host. #[test] -fn provider_connection_debug_options_require_version_eleven_and_a_declared_registry_type() { +fn provider_connection_debug_options_require_a_declared_registry_type() { let mut manifest: Value = serde_json::from_str(contract_file!( "fixtures/build-providers/build-provider-run-options.plugin.json" )) @@ -2831,8 +2777,6 @@ fn provider_connection_debug_options_require_version_eleven_and_a_declared_regis ) }; assert_eq!(plan(&manifest, &request).unwrap()["status"], "rejected"); - manifest["requires"]["buildProviders"] = json!(11); - assert_eq!(plan(&manifest, &request).unwrap()["status"], "rejected"); let runs = manifest["buildSystems"][0]["run"].as_array_mut().unwrap(); runs.iter_mut().find(|run| run["id"] == "service").unwrap()["debug"] = json!({"configurationType":"java"}); diff --git a/rust/lithe-core/src/tests/community_build_plugins.rs b/rust/lithe-core/src/tests/community_build_plugins.rs index 0516e01d..7a09b4f2 100644 --- a/rust/lithe-core/src/tests/community_build_plugins.rs +++ b/rust/lithe-core/src/tests/community_build_plugins.rs @@ -11,6 +11,10 @@ //! 581b84b, documents captured at d669dad with Gradle 9.6.1 on //! `test/fixtures/multi`. //! +//! Both `plugin.json` files declare `pluginAPIVersion` 0 with +//! `requires: {"buildSystems": 0}`, and the captured documents carry +//! `version` 0. +//! //! The captured documents write the scratch workspace as `${workspace}`. To //! refresh them, run `node scripts/package.mjs` and then //! `LITHE_CAPTURE_DIR= node scripts/test.mjs` in the plugin repository, diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 26bd8ab9..53fc5ae3 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -1,7 +1,6 @@ use crate::plugins::declarative::{ - check_runtime_version_command, discover_tests_command, language_server_launch_plan_command, - resolve_settings_command, run_plan_command, test_plan_command, validate_command, - workspace_configuration_command, + discover_tests_command, language_server_launch_plan_command, resolve_settings_command, + run_plan_command, test_plan_command, validate_command, workspace_configuration_command, }; use serde_json::{json, Value}; @@ -34,7 +33,7 @@ const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); -const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings/settings-v2.plugin.json"); +const SETTINGS_FIXTURE: &str = contract_file!("fixtures/settings/settings.plugin.json"); const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend/backend.plugin.json"); const WORKSPACE_BACKEND_FIXTURE: &str = contract_file!("fixtures/backend/backend-workspace.plugin.json"); @@ -66,7 +65,6 @@ const PROJECT_GENERATOR_TOOL_FIXTURE: &str = contract_file!("fixtures/project-generators/project-generator-tool-field.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/manifest/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/languages/command-plans.json"); -const RUNTIME_VERSIONS: &str = contract_file!("fixtures/languages/runtime-versions.json"); fn fixture(name: &str) -> Value { let source = match name { @@ -82,7 +80,7 @@ fn fixture(name: &str) -> Value { "build-command-output-file.plugin.json" => GRADLE_BUILD_FIXTURE, "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, - "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, + "settings.plugin.json" => SETTINGS_FIXTURE, "backend.plugin.json" => BACKEND_FIXTURE, "backend-workspace.plugin.json" => WORKSPACE_BACKEND_FIXTURE, "plugin-functions-exporter.plugin.json" => FUNCTION_EXPORTER_FIXTURE, @@ -134,17 +132,18 @@ fn contract_fixtures_and_official_plugins_validate() { } } -/// The package cannot grant itself host-seal trust on an older host or -/// downgrade independently installed copies to unsigned/local-only trust. +/// The package cannot grant itself host-seal trust on a host that does not +/// offer `packageTrust`, or downgrade independently installed copies to +/// unsigned/local-only trust. #[test] fn bundled_publisher_trust_requires_an_explicit_host_capability() { let manifest: Value = serde_json::from_str(PUBLISHER_BUNDLED_FIXTURE).unwrap(); - let mut host = json!({"languages":[1], "languageServers":[1], "serverProcess":[1]}); + let mut host = json!({"languages":[0], "languageServers":[0]}); let refused = validate_command(json!({"manifest": manifest, "hostCapabilities": host})).unwrap(); assert_eq!(refused["error"]["code"], "plugin.api.unsupported"); assert_eq!(refused["error"]["field"], "requires.packageTrust"); - host["packageTrust"] = json!([1]); + host["packageTrust"] = json!([0]); let accepted = validate_command(json!({"manifest": manifest, "hostCapabilities": host})).unwrap(); assert_eq!(accepted["status"], "ok", "{accepted}"); @@ -172,13 +171,6 @@ fn bundled_publisher_trust_requires_an_explicit_host_capability() { .unwrap() .remove("packageTrust"); assert_eq!(validate(missing)["error"]["field"], "requires"); - let mut legacy = manifest.clone(); - legacy["pluginAPIVersion"] = json!(2); - legacy.as_object_mut().unwrap().remove("requires"); - assert_eq!( - validate(legacy)["error"]["field"], - "vendor.bundledSignatureRequirement" - ); let mut unused = manifest; unused["vendor"] .as_object_mut() @@ -251,6 +243,13 @@ fn invalid_manifest_fixtures_report_their_stable_codes() { "{}: {outcome}", case["name"] ); + if let Some(field) = case.get("expectedField") { + assert_eq!( + &outcome["error"]["field"], field, + "{}: {outcome}", + case["name"] + ); + } } } @@ -293,13 +292,14 @@ fn command_plan_fixtures_match_golden_expansions() { } } -/// Packages that already ship declare API level 1, where `${...}` in a -/// language-server argument is literal text. Expanding or rejecting it there -/// would change the command line of an installed plugin. +/// `${...}` in a language-server argument is a launch variable: the plan +/// expands it, and `${pluginDir}` must be followed by a path inside the +/// package. #[test] -fn api_level_1_language_server_arguments_stay_literal() { +fn language_server_arguments_expand_launch_variables() { let mut manifest = fixture("language-packaged-server.plugin.json"); - manifest["languageServers"][0]["arguments"] = json!(["--stdio", "--log=${pluginDir}"]); + manifest["languageServers"][0]["arguments"] = + json!(["--stdio", "--config=${pluginDir}/config/server.json"]); assert_eq!(validate(manifest.clone())["status"], "ok"); let outcome = language_server_launch_plan_command(json!({ "manifest": manifest, @@ -314,154 +314,19 @@ fn api_level_1_language_server_arguments_stay_literal() { .expect("payload is well formed"); assert_eq!( outcome["plan"]["arguments"], - json!(["--stdio", "--log=${pluginDir}"]) + json!(["--stdio", "--config=/plugins/php/config/server.json"]) ); -} - -/// The install review must show a change to anything that decides what a -/// runtime-launched server executes, including the code it is told to load -/// through its initialization options. -#[test] -fn runtime_launch_and_initialization_options_are_reviewed_programs() { - let outcome = crate::plugins::marketplace::install_review_command(json!({ - "manifest": fixture("publisher-bundled.plugin.json"), - })) - .expect("payload is well formed"); - let programs = outcome["review"]["programs"].as_array().unwrap(); - let server = programs - .iter() - .find(|program| program["kind"] == "runtimeLanguageServer") - .expect("the runtime launch is listed"); - assert_eq!(server["entry"], "server.jar"); - // The runtime the plugin ships is reviewed as part of its program; it is - // not a runtime the host is asked to provide. - assert_eq!(server["packagedRuntime"], "runtime/${arch}/bin/java"); - assert!(programs.iter().all(|program| program["kind"] != "runtime")); - - let outcome = crate::plugins::marketplace::install_review_command(json!({ - "manifest": fixture("node-runtime.plugin.json"), - })) - .expect("payload is well formed"); - assert!(outcome["review"]["programs"] - .as_array() - .unwrap() - .contains(&json!({ - "kind": "runtime", "tool": "node", "minimumVersion": "18.17" - }))); -} - -/// Hosts filter runtime candidates with this outcome, so both products must -/// read the same version from the same probe output. -#[test] -fn runtime_version_fixtures_match_golden_outcomes() { - let fixtures: Value = serde_json::from_str(RUNTIME_VERSIONS).unwrap(); - for case in fixtures["cases"].as_array().unwrap() { - let outcome = - check_runtime_version_command(case["request"].clone()).expect("payload is well formed"); - if let Some(code) = case.get("expectedCode") { - assert_eq!(outcome["status"], "rejected", "{}: {outcome}", case["name"]); - assert_eq!(&outcome["error"]["code"], code, "{}", case["name"]); - } else { - assert_eq!(outcome["status"], "ok", "{}: {outcome}", case["name"]); - assert_eq!(outcome["check"], case["expected"], "{}", case["name"]); - } - } -} - -fn fixture_runtime_launch_plan(manifest: Value) -> Value { - language_server_launch_plan_command(json!({ - "manifest": manifest, - "languageID": "fixture", - "request": { - "pluginDirectory": "/plugins/fixture", - "stateDirectory": "/state/fixture", - "workspaceStateDirectory": "/state/fixture/workspace", - "architecture": "arm64" - } - })) - .expect("payload is well formed") -} - -/// Below `serverProcess` 4 a runtime launch whose tool is not a host runtime -/// names the tool definition with the same ID. Its plan has the shape of an -/// explicit tool reference, and the host adds no arguments of its own. -#[test] -fn runtime_launch_of_a_tool_definition_id_is_a_tool_plan() { - let mut manifest = fixture("node-runtime.plugin.json"); - manifest["languageServers"][0]["launch"]["tool"] = json!("java"); - assert_eq!(validate(manifest.clone())["status"], "ok"); - let outcome = fixture_runtime_launch_plan(manifest); - assert_eq!(outcome["status"], "ok", "{outcome}"); - assert_eq!( - outcome["plan"]["launch"], - json!({ - "kind": "tool", - "tool": "java", - "entry": "/plugins/fixture/language-server/lib/server.js", - "runtimeArguments": [ - "--max-old-space-size=2048", - "--require=/plugins/fixture/language-server/lib/preload.js" - ], - "minimumVersion": "18.17" - }) - ); - assert_eq!( - outcome["plan"]["arguments"], - json!([ - "--stdio", - "--log-level=info", - "--storage", - "/state/fixture/storage", - "--workspace-cache", - "/state/fixture/workspace" - ]) - ); - assert_eq!( - outcome["plan"]["runtimeRequirements"], - json!([{ "tool": "java", "minimumVersion": "18.17", "kind": "tool" }]) - ); -} - -/// `node` stays a host runtime: Core probes its version and passes the entry -/// as the first positional argument. -#[test] -fn node_runtime_launch_keeps_the_host_runtime_plan() { - let outcome = fixture_runtime_launch_plan(fixture("node-runtime.plugin.json")); - assert_eq!(outcome["status"], "ok", "{outcome}"); - assert_eq!( - outcome["plan"]["launch"], - json!({ - "kind": "runtime", - "tool": "node", - "entry": "language-server/lib/server.js", - "minimumVersion": "18.17", - "versionArguments": ["--version"] - }) - ); - assert_eq!( - outcome["plan"]["arguments"][2], - "/plugins/fixture/language-server/lib/server.js" - ); - assert_eq!( - outcome["plan"]["runtimeRequirements"], - json!([{ "tool": "node", "minimumVersion": "18.17" }]) + assert!( + outcome["plan"]["requiredPackagePaths"] + .as_array() + .unwrap() + .contains(&json!("config/server.json")), + "{outcome}" ); -} -/// Only an ID that cannot name a tool definition is unsupported; a -/// plugin-qualified ID needs `serverProcess` 4 and `requiresTools`. -#[test] -fn runtime_launch_rejects_an_invalid_tool_definition_id() { - for tool in ["Java", "java 17", "org.example.tools:java"] { - let mut manifest = fixture("node-runtime.plugin.json"); - manifest["languageServers"][0]["launch"]["tool"] = json!(tool); - let outcome = validate(manifest); - assert_eq!(outcome["status"], "rejected", "{tool}: {outcome}"); - assert_eq!( - outcome["error"]["code"], "plugin.runtime.unsupported", - "{tool}" - ); - } + let mut bare = fixture("language-packaged-server.plugin.json"); + bare["languageServers"][0]["arguments"] = json!(["--stdio", "--log=${pluginDir}"]); + assert_eq!(validate(bare)["error"]["code"], "plugin.template.invalid"); } /// The validation probe runs before the host has read the plugin's settings, @@ -469,7 +334,7 @@ fn runtime_launch_rejects_an_invalid_tool_definition_id() { #[test] fn validation_probe_cannot_use_settings() { let mut manifest = fixture("language-system-command.plugin.json"); - manifest["pluginAPIVersion"] = json!(2); + manifest["requires"]["settings"] = json!(0); manifest["settings"] = json!([ { "id": "verbose", "type": "boolean", "title": "Verbose", "default": false } ]); @@ -499,84 +364,62 @@ fn settings_are_normalized_in_identifier_order() { assert_eq!(ids, ["heapMB", "logLevel", "telemetry"]); } -/// API level 3 versions each section on its own: a manifest lists exactly -/// the capabilities it uses, a missing one is named precisely, and a host -/// that sends its capabilities refuses only plugins that need one it lacks. +/// A runtime launch names a tool definition the manifest lists in +/// `requiresTools`; the host adds no runtime of its own. #[test] -fn level_3_manifests_list_exactly_the_capabilities_they_use() { - let rejected = |outcome: Value| { - assert_eq!(outcome["status"], "rejected", "{outcome}"); - ( - outcome["error"]["code"].as_str().unwrap().to_string(), - outcome["error"]["field"].as_str().unwrap().to_string(), - ) - }; +fn runtime_launch_names_a_listed_tool() { + let mut manifest = fixture("node-runtime.plugin.json"); + manifest["languageServers"][0]["launch"]["tool"] = json!("java"); + let unlisted = validate(manifest.clone()); + assert_eq!(unlisted["status"], "rejected", "{unlisted}"); + assert_eq!(unlisted["error"]["code"], "plugin.runtime.undeclared"); + assert!( + unlisted["error"]["field"] + .as_str() + .unwrap() + .ends_with("launch.tool"), + "{unlisted}" + ); + + manifest["requiresTools"] = json!(["java"]); + assert_eq!(validate(manifest)["status"], "ok"); +} + +/// A host that sends its capabilities refuses only the plugins that need a +/// capability it does not offer; a plugin without build systems never depends +/// on them. +#[test] +fn a_host_refuses_only_plugins_that_need_a_capability_it_does_not_offer() { let mix = fixture("build-provider-language-server.plugin.json"); let outcome = validate(mix.clone()); assert_eq!(outcome["status"], "ok", "{outcome}"); assert_eq!( outcome["plugin"]["requires"], - json!({ "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }) + json!({ "buildProviders": 0, "buildSystems": 0, "languageServers": 0, "languages": 0 }) ); - let legacy = validate(fixture("language-packaged-server.plugin.json")); - assert!(legacy["plugin"].get("requires").is_none(), "{legacy}"); - let mut future = mix.clone(); - future["requires"]["debuggers"] = json!(1); - future["debuggers"] = json!([{ "id": "lldb" }]); - assert_eq!( - rejected(validate(future)), - ("plugin.api.unsupported".into(), "requires.debuggers".into()) - ); - let mut missing = mix.clone(); - missing["requires"] - .as_object_mut() - .unwrap() - .remove("serverProcess"); - assert_eq!( - rejected(validate(missing)), - ("plugin.manifest.invalid".into(), "requires".into()) - ); - let mut unused = mix.clone(); - unused["requires"]["settings"] = json!(1); - assert_eq!( - rejected(validate(unused)), - ("plugin.manifest.invalid".into(), "requires.settings".into()) - ); - - // A level 3 plugin without build systems never depends on them. - let mut language_only = fixture("language-packaged-server.plugin.json"); - language_only["pluginAPIVersion"] = json!(3); - language_only["requires"] = json!({ - "executionProviders": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 - }); - let host_without_builds = json!({ - "executionProviders": [1], "languageServers": [1], "languages": [1], "serverProcess": [1] - }); let validate_on = |manifest: &Value, host: &Value| { validate_command(json!({ "manifest": manifest, "hostCapabilities": host })) .expect("payload is well formed") }; + let language_only = fixture("language-packaged-server.plugin.json"); + let host_without_builds = json!({ + "executionProviders": [0], "languageServers": [0], "languages": [0] + }); assert_eq!( validate_on(&language_only, &host_without_builds)["status"], "ok" ); - assert_eq!( - rejected(validate_on(&mix, &host_without_builds)), - ( - "plugin.api.unsupported".into(), - "requires.buildProviders".into() - ) - ); + + let refused = validate_on(&mix, &host_without_builds); + assert_eq!(refused["status"], "rejected", "{refused}"); + assert_eq!(refused["error"]["code"], "plugin.api.unsupported"); + assert_eq!(refused["error"]["field"], "requires.buildProviders"); + let mut host_with_builds = host_without_builds.clone(); - host_with_builds["buildSystems"] = json!([1]); - host_with_builds["buildProviders"] = json!([1]); + host_with_builds["buildSystems"] = json!([0]); + host_with_builds["buildProviders"] = json!([0]); assert_eq!(validate_on(&mix, &host_with_builds)["status"], "ok"); - // Capability versions do not apply below level 3. - assert_eq!( - validate_on(&fixture("language-packaged-server.plugin.json"), &json!({}))["status"], - "ok" - ); } /// Provider orchestration is independent of declarative build commands and @@ -584,8 +427,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { #[test] fn build_providers_require_their_own_host_capability() { let mut host = json!({ - "buildSystems": [1], "languageServers": [1], "languages": [1], - "serverProcess": [1], "settings": [1] + "buildSystems": [0], "languageServers": [0], "languages": [0], "settings": [0] }); let template = validate_command(json!({ "manifest": fixture("build-static-file.plugin.json"), "hostCapabilities": host @@ -598,7 +440,7 @@ fn build_providers_require_their_own_host_capability() { ("build-provider-language-server.plugin.json", "mix"), ] { let manifest = fixture(name); - for versions in [None, Some(json!([2])), Some(json!([1]))] { + for versions in [None, Some(json!([1])), Some(json!([0]))] { host.as_object_mut().unwrap().remove("buildProviders"); if let Some(versions) = versions { host["buildProviders"] = versions; @@ -607,7 +449,7 @@ fn build_providers_require_their_own_host_capability() { "manifest": manifest, "hostCapabilities": host })) .unwrap(); - if host["buildProviders"] == json!([1]) { + if host["buildProviders"] == json!([0]) { assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); } else { assert_eq!(outcome["status"], "rejected", "{name}: {outcome}"); @@ -637,12 +479,43 @@ fn build_providers_require_their_own_host_capability() { } } +/// The install review must show a change to anything that decides what a +/// runtime-launched server executes, including the code it is told to load +/// through its initialization options. +#[test] +fn runtime_launch_and_initialization_options_are_reviewed_programs() { + let outcome = crate::plugins::marketplace::install_review_command(json!({ + "manifest": fixture("publisher-bundled.plugin.json"), + })) + .expect("payload is well formed"); + let programs = outcome["review"]["programs"].as_array().unwrap(); + let server = programs + .iter() + .find(|program| program["kind"] == "runtimeLanguageServer") + .expect("the runtime launch is listed"); + assert_eq!(server["entry"], "server.jar"); + // The runtime the plugin ships is reviewed as part of its program; it is + // not a runtime the host is asked to provide. + assert_eq!(server["packagedRuntime"], "runtime/${arch}/bin/java"); + assert!(programs.iter().all(|program| program["kind"] != "runtime")); + + let outcome = crate::plugins::marketplace::install_review_command(json!({ + "manifest": fixture("node-runtime.plugin.json"), + })) + .expect("payload is well formed"); + assert!(outcome["review"]["programs"] + .as_array() + .unwrap() + .contains(&json!({ + "kind": "runtime", "tool": "node", "minimumVersion": "18.17" + }))); +} + #[test] fn malformed_payload_is_a_core_failure_not_a_rejection() { assert!(run_plan_command(json!({ "manifest": {} })).is_err()); assert!(validate_command(json!("plugin.json")).is_err()); assert!(language_server_launch_plan_command(json!({ "manifest": {} })).is_err()); - assert!(check_runtime_version_command(json!({ "tool": "java" })).is_err()); } fn apply_edit(manifest: &mut Value, edit: &Value) { @@ -697,10 +570,10 @@ fn ui_contributions_are_normalized_without_interpreting_text() { } /// A plugin may bring its own page instead of literal text. The page is a -/// package file behind its own consent, and a host that runs only `ui` 1 +/// package file behind its own consent, and a host that does not offer `ui` /// never receives it, so it cannot fall back to showing the page as text. #[test] -fn web_tool_windows_need_ui_2_their_permission_and_a_package_page() { +fn web_tool_windows_need_a_host_that_offers_ui_and_a_package_page() { let outcome = validate(fixture("web-panel.plugin.json")); assert_eq!(outcome["status"], "ok", "{outcome}"); let windows = &outcome["plugin"]["ui"]["toolWindows"]; @@ -720,11 +593,11 @@ fn web_tool_windows_need_ui_2_their_permission_and_a_package_page() { })) .expect("payload is well formed") }; - let text_only = validate_on(json!({ "ui": [1] })); - assert_eq!(text_only["status"], "rejected", "{text_only}"); - assert_eq!(text_only["error"]["code"], "plugin.api.unsupported"); - assert_eq!(text_only["error"]["field"], "requires.ui"); - assert_eq!(validate_on(json!({ "ui": [1, 2] }))["status"], "ok"); + let without_ui = validate_on(json!({ "languages": [0] })); + assert_eq!(without_ui["status"], "rejected", "{without_ui}"); + assert_eq!(without_ui["error"]["code"], "plugin.api.unsupported"); + assert_eq!(without_ui["error"]["field"], "requires.ui"); + assert_eq!(validate_on(json!({ "ui": [0] }))["status"], "ok"); for entry in [ "index.html", @@ -783,14 +656,17 @@ fn plugin_backend_is_planned_without_a_workspace_and_names_its_host_services() { assert_eq!(launch["plan"]["requiredDirectories"], json!(["state"])); assert_eq!( launch["plan"]["backend"], - json!({ "hostServices": ["credentials"] }) + json!({ "hostServices": ["credentials"], "version": 0 }) ); // Without credentials.store the session may request no host service. let mut manifest = fixture("backend.plugin.json"); manifest["permissions"] = json!(["backend.launch", "ui.contribute", "ui.web"]); let launch = plan_for(manifest, request.clone()); - assert_eq!(launch["plan"]["backend"], json!({ "hostServices": [] })); + assert_eq!( + launch["plan"]["backend"], + json!({ "hostServices": [], "version": 0 }) + ); // A plugin without a backend has nothing to start. let launch = plan_for(fixture("web-panel.plugin.json"), request.clone()); @@ -826,19 +702,17 @@ fn plugin_backend_is_planned_without_a_workspace_and_names_its_host_services() { })) .expect("payload is well formed") }; - let without_backend = hosted(json!({ "ui": [1, 2], "serverProcess": [1] })); + let without_backend = hosted(json!({ "ui": [0] })); assert_eq!(without_backend["error"]["code"], "plugin.api.unsupported"); assert_eq!(without_backend["error"]["field"], "requires.backend"); - assert_eq!( - hosted(json!({ "backend": [1], "ui": [1, 2], "serverProcess": [1] }))["status"], - "ok" - ); + assert_eq!(hosted(json!({ "backend": [0], "ui": [0] }))["status"], "ok"); } -/// A `backend` 2 process starts without a page, its plan names its version -/// so the host may start it on `onWorkspaceOpen`, and `workspace.metadata` -/// grants the `workspace` host service. A host that only runs `backend` 1 -/// refuses the manifest instead of starting a process it would misuse. +/// A `backend` process starts without a page, its plan names the `backend` +/// version so the host may start it on `onWorkspaceOpen`, and +/// `workspace.metadata` grants the `workspace` host service. A host that does +/// not offer `backend` refuses the manifest instead of starting a process it +/// would misuse. #[test] fn workspace_backend_is_planned_with_its_version_and_workspace_service() { let outcome = validate(fixture("backend-workspace.plugin.json")); @@ -856,7 +730,7 @@ fn workspace_backend_is_planned_with_its_version_and_workspace_service() { assert_eq!(launch["status"], "ok", "{launch}"); assert_eq!( launch["plan"]["backend"], - json!({ "hostServices": ["workspace"], "version": 2 }) + json!({ "hostServices": ["workspace"], "version": 0 }) ); let hosted = |host: Value| { @@ -866,18 +740,10 @@ fn workspace_backend_is_planned_with_its_version_and_workspace_service() { })) .expect("payload is well formed") }; - let old_host = hosted(json!({ "backend": [1], "serverProcess": [1] })); - assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); - assert_eq!(old_host["error"]["field"], "requires.backend"); - assert_eq!( - hosted(json!({ "backend": [1, 2], "serverProcess": [1] }))["status"], - "ok" - ); - - // A page-only backend 1 manifest stays valid as backend 2. - let mut manifest = fixture("backend.plugin.json"); - manifest["requires"]["backend"] = json!(2); - assert_eq!(validate(manifest)["status"], "ok"); + let without_backend = hosted(json!({ "languages": [0] })); + assert_eq!(without_backend["error"]["code"], "plugin.api.unsupported"); + assert_eq!(without_backend["error"]["field"], "requires.backend"); + assert_eq!(hosted(json!({ "backend": [0] }))["status"], "ok"); } /// An exporting plugin is sorted by function ID and starts for a call without @@ -917,13 +783,13 @@ fn plugin_functions_are_normalized_and_granted_only_to_callers() { assert_eq!(exporter_plan["status"], "ok", "{exporter_plan}"); assert_eq!( exporter_plan["plan"]["backend"], - json!({ "hostServices": [], "version": 2 }) + json!({ "hostServices": [], "version": 0 }) ); let caller_plan = backend_plan("plugin-functions-caller.plugin.json"); assert_eq!(caller_plan["status"], "ok", "{caller_plan}"); assert_eq!( caller_plan["plan"]["backend"], - json!({ "hostServices": ["functions"], "version": 2 }) + json!({ "hostServices": ["functions"], "version": 0 }) ); } @@ -938,12 +804,13 @@ fn plugin_functions_need_a_host_that_hosts_them() { })) .expect("payload is well formed") }; - let old_host = hosted(json!({ "backend": [1, 2], "serverProcess": [1] })); - assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); - assert_eq!(old_host["error"]["field"], "requires.pluginFunctions"); - let host = hosted(json!({ - "backend": [1, 2], "serverProcess": [1], "pluginFunctions": [1] - })); + let without_functions = hosted(json!({ "backend": [0] })); + assert_eq!(without_functions["error"]["code"], "plugin.api.unsupported"); + assert_eq!( + without_functions["error"]["field"], + "requires.pluginFunctions" + ); + let host = hosted(json!({ "backend": [0], "pluginFunctions": [0] })); assert_eq!(host["status"], "ok", "{host}"); } @@ -991,7 +858,8 @@ fn ui_icon_declarations_validate_and_preserve_theme_resources() { } } -/// Native controls are opt-in; legacy hosts reject them before presentation. +/// Native controls are opt-in; a host that does not offer `ui` rejects them +/// before presentation. #[test] fn native_ui_validates_version_ownership_actions_and_budgets() { let manifest: Value = @@ -1012,10 +880,13 @@ fn native_ui_validates_version_ownership_actions_and_budgets() { invalid["ui"]["toolWindows"][0]["native"]["actions"][1]["icon"] = icon; assert_eq!(validate(invalid)["status"], "rejected"); } - let old_host = validate_command(json!({"manifest": manifest, "hostCapabilities": {"ui":[1,2], "buildSystems":[1], "buildProviders":[1], "serverProcess":[1]}})).unwrap(); - assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); + let without_ui = validate_command(json!({"manifest": manifest, "hostCapabilities": {"buildSystems":[0], "buildProviders":[0]}})).unwrap(); + assert_eq!(without_ui["error"]["code"], "plugin.api.unsupported"); + assert_eq!(without_ui["error"]["field"], "requires.ui"); + let hosted = validate_command(json!({"manifest": manifest, "hostCapabilities": {"ui":[0], "buildSystems":[0], "buildProviders":[0]}})).unwrap(); + assert_eq!(hosted["status"], "ok", "{hosted}"); for (pointer, value) in [ - ("/ui/toolWindows/0/native/schemaVersion", json!(2)), + ("/ui/toolWindows/0/native/schemaVersion", json!(1)), ( "/ui/toolWindows/0/native/source/buildSystemID", json!("foreign"), @@ -1033,7 +904,7 @@ fn native_ui_validates_version_ownership_actions_and_budgets() { "/ui/toolWindows/0/native/nodes", json!(vec![json!({"id":"row","label":"x"}); 2001]), ), - ("/requires/ui", json!(2)), + ("/requires/ui", json!(1)), ] { let mut invalid = manifest.clone(); *invalid.pointer_mut(pointer).unwrap() = value; @@ -1219,12 +1090,12 @@ fn native_public_controls_validate_types_ranges_info_and_buttons() { assert_eq!(validate(button_only)["status"], "ok"); } -/// A level 3 manifest using settings v2: scoped, page-placed settings with an -/// `immediate` one that only the server's `workspaceConfiguration` may read. -/// The shared settings v2 fixture: project and immediate settings on a -/// language page, a multi-select on a build-system page, and a page button. +/// The shared settings fixture: scoped, page-placed settings with an +/// `immediate` one that only the server's `workspaceConfiguration` may read, +/// project and immediate settings on a language page, a multi-select on a +/// build-system page, and a page button. fn settings_v2_manifest() -> Value { - fixture("settings-v2.plugin.json") + fixture("settings.plugin.json") } #[test] @@ -1310,10 +1181,13 @@ fn settings_v2_rejects_unsafe_setting_use() { let mut bad_action = settings_v2_manifest(); bad_action["settingActions"][0]["function"] = json!("Not A Function"); rejected(bad_action); - // v2 fields need v2. - let mut v1 = settings_v2_manifest(); - v1["requires"]["settings"] = json!(1); - rejected(v1); + // Settings are a capability the manifest has to list. + let mut unlisted = settings_v2_manifest(); + unlisted["requires"] + .as_object_mut() + .unwrap() + .remove("settings"); + rejected(unlisted); } /// Runs one case of the shared debug adapter rejection fixture through the @@ -1343,6 +1217,11 @@ fn debug_rejection(baseline: &Value, case: &Value) -> (String, String) { manifest.as_object_mut().unwrap().remove("requires"); } } + // A case lists the tools its processes may name; without the member, no + // tool is listed. + if let Some(tools) = metadata.get("requiresTools") { + manifest["requiresTools"] = tools.clone(); + } manifest["permissions"] = metadata["permissions"].clone(); manifest["activationEvents"] = json!(events); // The fixture's own settings belong to its own adapters; a case declares the @@ -1583,7 +1462,7 @@ fn debug_adapter_inside_a_backend_launched_language_server_plans_without_scratch // The server's session asks the backend with the directories of its own // start; resolving the adapter must not demand them. let mut manifest = fixture("language-server-backend-launch.plugin.json"); - manifest["requires"]["debugAdapters"] = json!(1); + manifest["requires"]["debugAdapters"] = json!(0); manifest["permissions"] .as_array_mut() .unwrap() @@ -2041,7 +1920,7 @@ fn debug_start_arguments_refuse_path_mappings_the_adapter_cannot_use() { #[test] fn debug_start_arguments_keep_a_remote_target_to_adapters_that_attach_to_one() { - // The fixture's adapters attach only, so a launch is refused as before. + // The fixture's adapters attach only, so a launch is refused. let launch = remote_start_with( fixture("debug-adapter-remote-attach.plugin.json"), "flat", @@ -2288,20 +2167,29 @@ fn debug_start_arguments_refuse_stale_registrations_and_missing_target_facts() { assert_eq!(stale["error"]["code"], "debug.provider.stale"); } -/// Backend commands are install-time gated by ui 4, and read permissions grant -/// only their own methods rather than an entire growing service namespace. +/// Backend commands are install-time gated by the `ui` capability, and read +/// permissions grant only their own methods rather than an entire growing +/// service namespace. #[test] -fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { +fn pure_backend_commands_need_ui_and_grant_methods_individually() { let manifest: Value = serde_json::from_str(contract_file!( "fixtures/backend/command-backend.plugin.json" )) .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); - let mut old = manifest.clone(); - old["requires"]["ui"] = json!(3); - assert_ne!(validate(old)["status"], "ok"); - let hosted = validate_command(json!({"manifest": manifest, "hostCapabilities": {"backend":[2],"serverProcess":[1],"ui":[1,2,3]}})).unwrap(); - assert_eq!(hosted["error"]["code"], "plugin.api.unsupported"); + let mut unlisted = manifest.clone(); + unlisted["requires"].as_object_mut().unwrap().remove("ui"); + assert_ne!(validate(unlisted)["status"], "ok"); + let without_ui = + validate_command(json!({"manifest": manifest, "hostCapabilities": {"backend":[0]}})) + .unwrap(); + assert_eq!(without_ui["error"]["code"], "plugin.api.unsupported"); + assert_eq!(without_ui["error"]["field"], "requires.ui"); + let hosted = validate_command( + json!({"manifest": manifest, "hostCapabilities": {"backend":[0],"ui":[0]}}), + ) + .unwrap(); + assert_eq!(hosted["status"], "ok", "{hosted}"); let plan = language_server_launch_plan_command(json!({ "manifest": manifest, "backend": true, "request": {"pluginDirectory":"/plugins/example","stateDirectory":"/state/example","architecture":"arm64"} @@ -2314,7 +2202,7 @@ fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { } #[test] -fn terminal_permission_grants_only_owned_terminal_methods_and_requires_backend_two() { +fn terminal_permission_grants_only_owned_terminal_methods() { let manifest: Value = serde_json::from_str(contract_file!( "fixtures/backend/terminal-backend.plugin.json" )) @@ -2330,9 +2218,13 @@ fn terminal_permission_grants_only_owned_terminal_methods_and_requires_backend_t } assert!(!grants.contains(&json!("terminal"))); assert!(!grants.contains(&json!("lithe/host/terminal/sendText"))); - let mut old = manifest; - old["requires"]["backend"] = json!(1); - assert_ne!(validate(old)["status"], "ok"); + // A manifest with a backend lists the `backend` capability. + let mut unlisted = manifest; + unlisted["requires"] + .as_object_mut() + .unwrap() + .remove("backend"); + assert_ne!(validate(unlisted)["status"], "ok"); } #[test] diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/app-dependencies.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/app-dependencies.json index 26200d4b..b7546734 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/app-dependencies.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/app-dependencies.json @@ -1,6 +1,6 @@ { "format": "litheDependencies", - "version": 1, + "version": 0, "dependencies": [ { "name": "compileClasspath", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/dependencies.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/dependencies.json index 784c3381..417dcd81 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/dependencies.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/dependencies.json @@ -1,6 +1,6 @@ { "format": "litheDependencies", - "version": 1, + "version": 0, "dependencies": [ { "name": "multi", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/model.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/model.json index ad0aaeb8..912776f9 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/model.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/model.json @@ -1,6 +1,6 @@ { "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [ { "path": "${workspace}", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/plugin.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/plugin.json index 70be0345..f4e07a8c 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/plugin.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/gradle/plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "io.github.lithe-idea.gradle-build", "displayName": "Gradle", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildSystems": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildSystems": 0 }, "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/app-dependencies.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/app-dependencies.json index ddaf6f9c..e63ee241 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/app-dependencies.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/app-dependencies.json @@ -1,6 +1,6 @@ { "format": "litheDependencies", - "version": 1, + "version": 0, "dependencies": [ { "name": "core", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/dependencies.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/dependencies.json index c164286a..167fb578 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/dependencies.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/dependencies.json @@ -1,6 +1,6 @@ { "format": "litheDependencies", - "version": 1, + "version": 0, "dependencies": [ { "name": "reactor", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/model.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/model.json index eff38e3e..cd9b2bc3 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/model.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/model.json @@ -1,6 +1,6 @@ { "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [ { "path": "${workspace}", diff --git a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/plugin.json b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/plugin.json index 66673866..8d391675 100644 --- a/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/plugin.json +++ b/rust/lithe-core/src/tests/fixtures/community_build_plugins/maven/plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "io.github.lithe-idea.maven-build", "displayName": "Maven", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildSystems": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildSystems": 0 }, "vendor": { "id": "io.github.lithe-idea", "displayName": "Lithe Community", diff --git a/rust/lithe-core/src/tests/language_server_sessions.rs b/rust/lithe-core/src/tests/language_server_sessions.rs index 625f7320..ad772261 100644 --- a/rust/lithe-core/src/tests/language_server_sessions.rs +++ b/rust/lithe-core/src/tests/language_server_sessions.rs @@ -31,16 +31,17 @@ fn parse(source: &str) -> Value { serde_json::from_str(source).expect("fixture is valid JSON") } -/// Capability versions of a host that runs the backend-launch fixture. +/// The capabilities the backend-launch fixture requires. +const FIXTURE_CAPABILITIES: [&str; 4] = + ["backend", "languageServers", "languages", "toolDefinitions"]; + +/// Capability offer of a host that runs the backend-launch fixture. fn full_host() -> Value { - json!({ - "backend": [1, 2], - "executableResolution": [1, 2], - "languageServers": [1, 2, 3], - "languages": [1], - "serverProcess": [1, 3], - "toolDefinitions": [1], - }) + let mut host = serde_json::Map::new(); + for capability in FIXTURE_CAPABILITIES { + host.insert(capability.to_string(), json!([0])); + } + Value::Object(host) } #[test] @@ -65,18 +66,24 @@ fn backend_launch_fixture_keeps_its_session_declaration() { } #[test] -fn backend_launch_needs_a_host_that_runs_server_process_3() { +fn backend_launch_needs_a_host_that_offers_every_required_capability() { let manifest = parse(BACKEND_LAUNCH_FIXTURE); - let mut old_host = full_host(); - old_host["serverProcess"] = json!([1, 2]); - let refused = - validate_command(json!({ "manifest": manifest, "hostCapabilities": old_host })).unwrap(); - assert_eq!( - refused["error"]["code"], "plugin.api.unsupported", - "{refused}" - ); - assert_eq!(refused["error"]["field"], "requires.serverProcess"); + for capability in FIXTURE_CAPABILITIES { + let mut host = full_host(); + host.as_object_mut().unwrap().remove(capability); + let refused = + validate_command(json!({ "manifest": manifest, "hostCapabilities": host })).unwrap(); + assert_eq!( + refused["error"]["code"], "plugin.api.unsupported", + "{capability}: {refused}" + ); + assert_eq!( + refused["error"]["field"], + json!(format!("requires.{capability}")), + "{capability}: {refused}" + ); + } let accepted = validate_command(json!({ "manifest": manifest, "hostCapabilities": full_host() })).unwrap(); diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index fb583373..c5b67d09 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -151,7 +151,7 @@ fn invalid_index_entries_report_their_stable_codes() { apply_edit(&mut entry, edit); } let outcome = validate( - json!({ "schemaVersion": 1, "plugins": [entry] }), + json!({ "schemaVersion": 0, "plugins": [entry] }), "thirdParty", "darwin-arm64", ); @@ -171,13 +171,13 @@ fn invalid_index_entries_report_their_stable_codes() { fn whole_index_problems_reject_the_index() { for (index, code) in [ ( - json!({ "schemaVersion": 2, "plugins": [] }), + json!({ "schemaVersion": 1, "plugins": [] }), "plugin.index.schemaUnsupported", ), (json!({ "plugins": [] }), "plugin.index.invalid"), - (json!({ "schemaVersion": 1 }), "plugin.index.invalid"), + (json!({ "schemaVersion": 0 }), "plugin.index.invalid"), ( - json!({ "schemaVersion": 1, "plugins": [], "mirror": "x" }), + json!({ "schemaVersion": 0, "plugins": [], "mirror": "x" }), "plugin.index.invalid", ), (json!([]), "plugin.index.invalid"), @@ -255,38 +255,10 @@ fn install_review_fixtures_match_golden_outcomes() { } } -/// Shared validation is not evidence of a Windows UI adapter. API level 2 -/// entries must remain unavailable there while macOS can install them. +/// A version carries its `requires` to the host, unknown capability IDs +/// included, so a host can say what an unavailable version needs. #[test] -fn ui_api_availability_requires_an_implemented_platform_host() { - let mut index = json_file(INDEX); - let entry = index["plugins"] - .as_array_mut() - .unwrap() - .iter_mut() - .find(|entry| entry["id"] == "io.example.future-tools") - .unwrap(); - entry["versions"][0]["pluginAPIVersion"] = json!(2); - entry["versions"][0]["downloads"][0]["platforms"] = json!(["darwin-arm64", "windows-x64"]); - for (platform, expected) in [ - ("darwin-arm64", "available"), - ("windows-x64", "unsupported"), - ] { - let result = validate(index.clone(), "thirdParty", platform); - let plugin = result["index"]["plugins"] - .as_array() - .unwrap() - .iter() - .find(|entry| entry["id"] == "io.example.future-tools") - .unwrap(); - assert_eq!(plugin["availability"]["status"], expected, "{result}"); - } -} - -/// A level 3 version carries its `requires` to the host, unknown capability -/// IDs included, so a host can say what an unavailable version needs. -#[test] -fn level_3_versions_list_their_capabilities() { +fn versions_list_their_capabilities() { let outcome = validate(json_file(INDEX), "thirdParty", "darwin-arm64"); let elixir = outcome["index"]["plugins"] .as_array() @@ -296,15 +268,15 @@ fn level_3_versions_list_their_capabilities() { .unwrap(); assert_eq!( elixir["versions"][0]["requires"], - json!({ "debuggers": 1, "languages": 1 }) + json!({ "debuggers": 0, "languages": 0 }) ); assert_eq!( elixir["versions"][2]["requires"], - json!({ "languageServers": 1, "languages": 1, "serverProcess": 1 }) + json!({ "languageServers": 0, "languages": 0 }) ); assert_eq!( elixir["versions"][1]["requires"], - json!({ "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }) + json!({ "buildProviders": 0, "buildSystems": 0, "languageServers": 0, "languages": 0 }) ); let zig = outcome["index"]["plugins"] .as_array() @@ -312,11 +284,14 @@ fn level_3_versions_list_their_capabilities() { .iter() .find(|entry| entry["id"] == "io.example.zig-support") .unwrap(); - assert!(zig["versions"][0].get("requires").is_none(), "{zig}"); + assert_eq!( + zig["versions"][0]["requires"], + json!({ "languageServers": 0, "languages": 0 }) + ); } /// Market filtering uses the same requirements for own and shared providers; -/// hosts with only declarative builds keep the older language-only version. +/// hosts without build providers keep the language-only version. #[test] fn build_provider_versions_require_host_support_before_download() { for manifest in [json_file(BUILD_PROVIDER_FIXTURE), json_file(MIX_FIXTURE)] { @@ -329,13 +304,14 @@ fn build_provider_versions_require_host_support_before_download() { .unwrap(); entry["versions"][1]["requires"] = manifest["requires"].clone(); let mut host = json!({ - "buildSystems": [1], "languages": [1], "languageServers": [1], - "serverProcess": [1], "settings": [1] + "buildSystems": [0], "languages": [0], "languageServers": [0], "settings": [0] }); for (versions, expected_version) in [ + // The host offers no buildProviders. (None, "1.0.0"), - (Some(json!([2])), "1.0.0"), - (Some(json!([1])), "2.0.0"), + // The host offers only a contract version Core does not speak. + (Some(json!([1])), "1.0.0"), + (Some(json!([0])), "2.0.0"), ] { host.as_object_mut().unwrap().remove("buildProviders"); if let Some(versions) = versions { @@ -357,36 +333,7 @@ fn build_provider_versions_require_host_support_before_download() { } } -/// Capability versions are a level 3 rule: a host that sends them still -/// sees level 2 entries only where the level 2 UI host exists. -#[test] -fn host_capabilities_do_not_change_legacy_levels() { - let mut index = json_file(INDEX); - let entry = index["plugins"] - .as_array_mut() - .unwrap() - .iter_mut() - .find(|entry| entry["id"] == "io.example.future-tools") - .unwrap(); - entry["versions"][0]["pluginAPIVersion"] = json!(2); - entry["versions"][0]["downloads"][0]["platforms"] = json!(["darwin-arm64", "windows-x64"]); - let host = json!({ "languages": [1], "ui": [1] }); - for (platform, expected) in [ - ("darwin-arm64", "available"), - ("windows-x64", "unsupported"), - ] { - let result = validate_for_host(index.clone(), "thirdParty", platform, Some(&host)); - let plugin = result["index"]["plugins"] - .as_array() - .unwrap() - .iter() - .find(|entry| entry["id"] == "io.example.future-tools") - .unwrap(); - assert_eq!(plugin["availability"]["status"], expected, "{result}"); - } -} - -/// Download availability must reject the new trust semantics before any +/// Download availability rejects publisher-bundled trust semantics before any /// archive is transferred, even when the host meets every other requirement. #[test] fn publisher_bundled_versions_require_host_trust_support_before_download() { @@ -398,8 +345,8 @@ fn publisher_bundled_versions_require_host_trust_support_before_download() { .find(|entry| entry["id"] == "io.example.elixir-tools") .unwrap(); entry["versions"] = json!([{ - "version":"3.0.0", "pluginAPIVersion":3, "releaseDate":"2026-10-25", - "requires":{"languages":1,"packageTrust":1}, + "version":"3.0.0", "pluginAPIVersion":0, "releaseDate":"2026-10-25", + "requires":{"languages":0,"packageTrust":0}, "downloads":[{"platforms":["darwin-arm64"], "downloadURL":"https://github.com/example/elixir-tools/releases/download/v3.0.0/elixir.zip", "sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}] @@ -409,8 +356,8 @@ fn publisher_bundled_versions_require_host_trust_support_before_download() { entry["versions"][0]["downloads"][0]["downloadURL"] = json!(format!("{repository}/releases/download/v3.0.0/elixir.zip")); for (capabilities, expected) in [ - (json!({"languages":[1]}), "unsupported"), - (json!({"languages":[1],"packageTrust":[1]}), "available"), + (json!({"languages":[0]}), "unsupported"), + (json!({"languages":[0],"packageTrust":[0]}), "available"), ] { let outcome = validate_for_host( index.clone(), diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 7dd14981..fbb12a0c 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -24,7 +24,6 @@ mod github; mod history_events; mod language_server_sessions; mod marketplace_plugins; -mod plugins; mod project; mod project_generators; mod protocol; diff --git a/rust/lithe-core/src/tests/plugin_editor_resources.rs b/rust/lithe-core/src/tests/plugin_editor_resources.rs index 1a360554..c15e0bd5 100644 --- a/rust/lithe-core/src/tests/plugin_editor_resources.rs +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -280,21 +280,12 @@ fn a_grammar_only_plugin_validates_without_a_language_server() { assert_eq!(language["grammar"]["path"], "grammars/http.tmLanguage.json"); } -/// `languages` 2 named Monaco's Monarch tokenizer, which no editor runs any -/// more; a manifest written against it is unsupported rather than misread. +/// TextMate is the only grammar engine the editor runs; a grammar of any +/// other kind is an invalid manifest. #[test] -fn the_withdrawn_monarch_version_is_unsupported() { +fn a_grammar_kind_other_than_textmate_is_invalid() { let mut plugin = manifest(GRAMMAR_PLUGIN); - plugin["requires"]["languages"] = json!(2); - plugin["languages"][0]["grammar"]["kind"] = json!("monarch"); - let outcome = validate_command(json!({ "manifest": plugin })).unwrap(); - assert_eq!( - outcome["error"]["code"], "plugin.api.unsupported", - "{outcome}" - ); - - let mut plugin = manifest(GRAMMAR_PLUGIN); - plugin["languages"][0]["grammar"]["kind"] = json!("monarch"); + plugin["languages"][0]["grammar"]["kind"] = json!("tokenizer"); let outcome = validate_command(json!({ "manifest": plugin })).unwrap(); assert_eq!( outcome["error"]["code"], "plugin.manifest.invalid", @@ -314,8 +305,7 @@ fn configuration_only_contribution_decodes_without_grammar() { "fixtures/backend/backend-workspace.plugin.json" )); plugin["backend"] = tool["backend"].clone(); - plugin["requires"]["backend"] = json!(2); - plugin["requires"]["serverProcess"] = json!(1); + plugin["requires"]["backend"] = json!(0); plugin["permissions"] = json!(["backend.launch"]); plugin["activationEvents"] = json!(["onWorkspaceOpen"]); let result = diff --git a/rust/lithe-core/src/tests/plugin_run_debug.rs b/rust/lithe-core/src/tests/plugin_run_debug.rs index 84173d83..477934a1 100644 --- a/rust/lithe-core/src/tests/plugin_run_debug.rs +++ b/rust/lithe-core/src/tests/plugin_run_debug.rs @@ -361,8 +361,8 @@ fn output_link_fixture_cases_match_the_same_in_every_host() { #[test] fn output_link_matchers_alone_are_a_complete_plugin() { let manifest = json!({ - "schemaVersion": 2, "id": "dev.example.links", "displayName": "Script Links", - "version": "1.0.0", "pluginAPIVersion": 4, "requires": {"outputLinkMatchers": 1}, + "schemaVersion": 0, "id": "dev.example.links", "displayName": "Script Links", + "version": "1.0.0", "pluginAPIVersion": 0, "requires": {"outputLinkMatchers": 0}, "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, "permissions": [], "activationEvents": [], "languages": [], "outputLinkMatchers": [ diff --git a/rust/lithe-core/src/tests/plugins.rs b/rust/lithe-core/src/tests/plugins.rs deleted file mode 100644 index 81d8c88f..00000000 --- a/rust/lithe-core/src/tests/plugins.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::plugins::{validate_plugin_catalog_json, PluginValidationError, PluginVersion}; -const OFFICIAL_PLUGINS: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/official-v1.json" -)); -const LANGUAGE_SUPPORT_FIXTURE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/language-support-v1.json" -)); -#[test] -fn official_plugin_catalog_is_valid_and_contains_only_released_downloads() { - let owners = validate_plugin_catalog_json( - OFFICIAL_PLUGINS, - PluginVersion { - major: 0, - minor: 3, - patch: 0, - }, - ) - .expect("official plugin fixture should validate"); - assert!(owners.is_empty()); -} - -#[test] -fn language_support_catalog_allows_execution_and_testing_to_share_a_module() { - let owners = validate_plugin_catalog_json( - LANGUAGE_SUPPORT_FIXTURE, - PluginVersion { - major: 0, - minor: 3, - patch: 0, - }, - ) - .expect("language support fixture should validate"); - assert_eq!(owners.len(), 2); - assert_eq!( - owners.get("dev.lithe.fixture.go.language-server"), - Some(&"dev.lithe.fixture.go-support".to_string()) - ); - assert_eq!( - owners.get("dev.lithe.fixture.go.execution"), - Some(&"dev.lithe.fixture.go-support".to_string()) - ); -} - -#[test] -fn incompatible_host_is_rejected_deterministically() { - let error = validate_plugin_catalog_json( - OFFICIAL_PLUGINS, - PluginVersion { - major: 0, - minor: 4, - patch: 0, - }, - ) - .unwrap_err(); - assert_eq!( - error, - PluginValidationError::IncompatibleHost { - plugin: "catalog".into() - } - ); -} diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 366d7fe5..ccba4a29 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -46,11 +46,12 @@ fn fixtures_path() -> PathBuf { /// language requirement has exactly one provider. fn java_language_only() -> Value { json!({ - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.java-language", "displayName": "Java Language Fixture", "version": "0.1.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": {"languages": 0, "languageServers": 0}, "vendor": {"id": "dev.lithe", "displayName": "Lithe", "signatureRequirement": "publisherPackage"}, "permissions": ["languageServer.launch"], "activationEvents": ["onLanguage:java"], @@ -70,12 +71,12 @@ fn java_language_only() -> Value { } fn host() -> Value { - json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], - "buildSystems": [1], "projectGenerators": [1, 2], "debugAdapters": [1], "packageTrust": [1], "toolDefinitions": [1], "executableResolution": [1, 2]}) + json!({"languages": [0], "languageServers": [0], "buildSystems": [0], + "projectGenerators": [0], "toolDefinitions": [0]}) } /// A case names its manifest fixture in `manifest`; the default is the -/// version 1 generator fixture. +/// generator fixture. fn case_manifest(case: &Value) -> Value { match case["manifest"].as_str() { Some(file) => json( @@ -128,7 +129,7 @@ fn catalog_lists_generators_with_requirement_providers() { {"manifest": json(FIXTURE), "enabled": true}, {"manifest": build, "enabled": false}, {"manifest": java_language_only(), "enabled": true}, - {"manifest": {"schemaVersion": 2, "id": "broken"}, "enabled": true} + {"manifest": {"schemaVersion": 0, "id": "broken"}, "enabled": true} ], "hostCapabilities": host(), })) @@ -162,7 +163,7 @@ fn catalog_lists_generators_with_requirement_providers() { fn catalog_skips_plugins_the_host_cannot_run() { let result = project_generator_catalog_command(json!({ "plugins": [{"manifest": json(FIXTURE), "enabled": true}], - "hostCapabilities": {"languages": [1]}, + "hostCapabilities": {"languages": [0]}, })) .unwrap(); assert_eq!(result["catalog"]["generators"], json!([])); @@ -170,6 +171,10 @@ fn catalog_skips_plugins_the_host_cannot_run() { result["catalog"]["issues"][0]["error"]["code"], "plugin.api.unsupported" ); + assert_eq!( + result["catalog"]["issues"][0]["error"]["field"], + "requires.projectGenerators" + ); } /// The catalog tells the host which plugins declare the tool a field selects, @@ -237,26 +242,25 @@ fn catalog_names_the_plugins_that_define_each_tool() { "{tool}" ); } - // Version 1 generators have no tool fields. - let version_1 = project_generator_catalog_command(json!({ + // A generator without tool fields lists no tools, and a tool nobody + // defines has no provider. + let plain = project_generator_catalog_command(json!({ "plugins": [{"manifest": json(FIXTURE), "enabled": true}], "hostCapabilities": host(), })) .unwrap(); - assert_eq!(version_1["catalog"]["generators"][0]["tools"], json!([])); -} - -#[test] -fn catalog_skips_tool_fields_on_hosts_without_projectgenerators_2() { - let result = project_generator_catalog_command(json!({ - "plugins": [{"manifest": json(TOOL_FIELD_FIXTURE), "enabled": true}], - "hostCapabilities": {"projectGenerators": [1]}, - })) - .unwrap(); - assert_eq!(result["catalog"]["generators"], json!([])); + let entries = plain["catalog"]["generators"].as_array().unwrap(); + let tools = |id: &str| { + entries + .iter() + .find(|entry| entry["generator"]["id"] == id) + .unwrap_or_else(|| panic!("{id}: {plain}"))["tools"] + .clone() + }; + assert_eq!(tools("initializr"), json!([])); assert_eq!( - result["catalog"]["issues"][0]["error"]["field"], - "requires.projectGenerators" + tools("app"), + json!([{"fieldID": "jdk", "tool": "java", "providers": []}]) ); } @@ -295,7 +299,17 @@ fn rejects_invalid_generator_declarations() { ( "missing capability", Box::new(|manifest| { - manifest["requires"] = json!({"languages": 1}); + manifest["requires"] = json!({"languages": 0}); + }), + "plugin.manifest.invalid", + ), + ( + "runtime field", + Box::new(|manifest| { + manifest["projectGenerators"][0]["fields"][1] = json!({ + "id": "jdk", "type": "runtime", "title": "JDK", + "runtime": "java", "minimumVersion": "17" + }); }), "plugin.manifest.invalid", ), @@ -351,12 +365,37 @@ fn rejects_invalid_generator_declarations() { } #[test] -fn project_generators_need_api_level_3() { +fn project_generators_must_be_listed_in_requires() { let mut manifest = json(FIXTURE); - manifest["pluginAPIVersion"] = json!(2); manifest.as_object_mut().unwrap().remove("requires"); let result = validate_command(json!({"manifest": manifest})).unwrap(); - assert_eq!(result["status"], "rejected"); + assert_eq!(result["status"], "rejected", "{result}"); + assert_eq!( + result["error"]["code"], "plugin.manifest.invalid", + "{result}" + ); + assert_eq!(result["error"]["field"], "requires", "{result}"); + + let mut manifest = json(FIXTURE); + manifest["requires"] = json!({"languages": 0}); + let result = validate_command(json!({"manifest": manifest})).unwrap(); + assert_eq!(result["status"], "rejected", "{result}"); + assert_eq!(result["error"]["field"], "requires", "{result}"); + assert!( + result["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("projectGenerators")), + "{result}" + ); + + let mut manifest = json(FIXTURE); + manifest["pluginAPIVersion"] = json!(1); + let result = validate_command(json!({"manifest": manifest})).unwrap(); + assert_eq!( + result["error"]["code"], "plugin.api.unsupported", + "{result}" + ); + assert_eq!(result["error"]["field"], "pluginAPIVersion", "{result}"); } /// A generator command runs a program on the user's Mac, so the install diff --git a/rust/lithe-core/src/tests/toolchain_definitions.rs b/rust/lithe-core/src/tests/toolchain_definitions.rs index b863e8fc..fd18d487 100644 --- a/rust/lithe-core/src/tests/toolchain_definitions.rs +++ b/rust/lithe-core/src/tests/toolchain_definitions.rs @@ -21,8 +21,8 @@ fn definition(id: &str) -> Value { } fn manifest(id: &str, definition: Value) -> Value { - json!({"schemaVersion":2,"id":id,"displayName":"Fixture tool","version":"1.0.0", - "pluginAPIVersion":3,"requires":{"toolDefinitions":1,"executableResolution":2}, + json!({"schemaVersion":0,"id":id,"displayName":"Fixture tool","version":"1.0.0", + "pluginAPIVersion":0,"requires":{"toolDefinitions":0}, "vendor":{"id":"org.example","displayName":"Fixture publisher","signatureRequirement":"publisherPackage"}, "toolDefinitions":[definition]}) } @@ -111,8 +111,8 @@ fn registry_leaves_a_contested_tool_id_unowned_and_registers_the_rest() { fn registry_keeps_providers_that_name_a_contested_tool() { let first = manifest("dev.lithe.fixture.first", definition("compiler")); let second = manifest("dev.lithe.fixture.second", definition("compiler")); - let provider = json!({"schemaVersion":2,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", - "pluginAPIVersion":3,"requires":{"toolchainProviders":1,"backend":2,"serverProcess":1}, + let provider = json!({"schemaVersion":0,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", + "pluginAPIVersion":0,"requires":{"toolchainProviders":0,"backend":0}, "vendor":{"id":"org.example","displayName":"Fixture","signatureRequirement":"publisherPackage"}, "permissions":["backend.launch","toolchains.provide"], "toolchainProviders":[{"id":"installs","displayName":"Installs","tools":["compiler"]}], @@ -213,9 +213,9 @@ fn definitions_reuse_shared_candidate_graph_ordering() { } #[test] -fn providers_use_existing_packaged_backend_and_new_query_trigger_only() { - let plugin = json!({"schemaVersion":2,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", - "pluginAPIVersion":3,"requires":{"toolchainProviders":1,"backend":2,"serverProcess":1}, +fn providers_use_packaged_backend_and_query_trigger_only() { + let plugin = json!({"schemaVersion":0,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", + "pluginAPIVersion":0,"requires":{"toolchainProviders":0,"backend":0}, "vendor":{"id":"org.example","displayName":"Fixture","signatureRequirement":"publisherPackage"}, "permissions":["backend.launch","toolchains.provide"], "toolchainProviders":[{"id":"manager","displayName":"Manager","tools":["compiler"],"watchFiles":[{"pattern":".compiler-version","reader":"native"}]}], @@ -246,8 +246,8 @@ fn providers_use_existing_packaged_backend_and_new_query_trigger_only() { #[test] fn providers_without_watch_files_report_installations_only() { - let plugin = json!({"schemaVersion":2,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", - "pluginAPIVersion":3,"requires":{"toolchainProviders":1,"backend":2,"serverProcess":1}, + let plugin = json!({"schemaVersion":0,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", + "pluginAPIVersion":0,"requires":{"toolchainProviders":0,"backend":0}, "vendor":{"id":"org.example","displayName":"Fixture","signatureRequirement":"publisherPackage"}, "permissions":["backend.launch","toolchains.provide"], "toolchainProviders":[{"id":"installs","displayName":"Installs","tools":["compiler"]}], @@ -262,8 +262,7 @@ fn providers_without_watch_files_report_installations_only() { fn a_definition_plugin_may_provide_installations_of_its_own_tool() { let id = "dev.lithe.fixture.compiler"; let mut plugin = manifest(id, definition(&format!("{id}:compiler"))); - plugin["requires"] = json!({"toolDefinitions":1,"executableResolution":2, - "toolchainProviders":1,"backend":2,"serverProcess":1}); + plugin["requires"] = json!({"toolDefinitions":0,"toolchainProviders":0,"backend":0}); plugin["permissions"] = json!(["backend.launch", "toolchains.provide"]); plugin["toolchainProviders"] = json!([{"id":"installs","displayName":"Installs","tools":[format!("{id}:compiler")]}]); @@ -275,7 +274,7 @@ fn a_definition_plugin_may_provide_installations_of_its_own_tool() { } #[test] -fn build_toolchain_consumers_keep_resolution_two_without_owning_definitions() { +fn build_toolchain_consumers_reference_definitions_without_owning_them() { let mut plugin: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json" @@ -287,24 +286,19 @@ fn build_toolchain_consumers_keep_resolution_two_without_owning_definitions() { assert!(validate_manifest_value(plugin.clone()).is_ok()); plugin["buildSystems"][0]["invocation"]["executables"][0]["toolchainID"] = json!("dev.lithe.fixture.compiler:../compiler"); - assert!(validate_manifest_value(plugin.clone()).is_err()); - plugin["buildSystems"][0]["invocation"]["executables"][0]["toolchainID"] = - json!("dev.lithe.fixture.compiler:compiler"); - plugin["requires"]["executableResolution"] = json!(1); assert!(validate_manifest_value(plugin).is_err()); } #[test] -fn executable_resolution_two_does_not_enable_toolchain_fields_in_legacy_build_sets() { +fn tool_definitions_do_not_enable_toolchain_layers_in_build_executable_sets() { let mut plugin: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json" ))) .unwrap(); - plugin["requires"]["executableResolution"] = json!(2); - assert!(validate_manifest_value(plugin.clone()).is_err()); - plugin["requires"]["toolDefinitions"] = json!(1); + plugin["requires"]["toolDefinitions"] = json!(0); plugin["toolDefinitions"] = json!([definition("dev.lithe.fixture.build-tool-path:compiler")]); + assert!(validate_manifest_value(plugin.clone()).is_ok()); plugin["buildSystems"][0]["invocation"]["executables"][0]["layer"] = json!("project"); assert!(validate_manifest_value(plugin).is_err()); } @@ -312,26 +306,52 @@ fn executable_resolution_two_does_not_enable_toolchain_fields_in_legacy_build_se #[test] fn production_toolchain_fixtures_require_explicit_host_capabilities() { let sources = [ - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" - )), - include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json" - )), + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" + )), + "requires.toolDefinitions", + ), + ( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json" + )), + "requires.toolchainProviders", + ), ]; - let old_host = json!({"backend":[1,2],"serverProcess":[1],"executableResolution":[1]}); - let capable_host = json!({"backend":[1,2],"serverProcess":[1],"executableResolution":[1,2],"toolDefinitions":[1],"toolchainProviders":[1]}); - for source in sources { + let limited_host = json!({"backend":[0]}); + let capable_host = json!({"backend":[0],"toolDefinitions":[0],"toolchainProviders":[0]}); + for (source, missing) in sources { let manifest: Value = serde_json::from_str(source).unwrap(); assert!(validate_manifest_value(manifest.clone()).is_ok()); let rejected = - validate_command(json!({"manifest":manifest,"hostCapabilities":old_host})).unwrap(); + validate_command(json!({"manifest":manifest,"hostCapabilities":limited_host})).unwrap(); assert_eq!(rejected["status"], "rejected", "{rejected}"); assert_eq!(rejected["error"]["code"], "plugin.api.unsupported"); + assert_eq!(rejected["error"]["field"], missing, "{rejected}"); let accepted = validate_command(json!({"manifest":manifest,"hostCapabilities":capable_host})).unwrap(); assert_eq!(accepted["status"], "ok", "{accepted}"); } } + +/// Launches, tool references, and executable candidates belong to the base +/// contract, so naming them in `requires` is refused. +#[test] +fn base_contract_features_are_not_listed_in_requires() { + for capability in ["executableResolution", "serverProcess"] { + let mut plugin = manifest( + "dev.lithe.fixture.compiler", + definition("dev.lithe.fixture.compiler:compiler"), + ); + plugin["requires"][capability] = json!(0); + let rejection = validate_manifest_value(plugin).unwrap_err(); + assert_eq!(rejection.code(), "plugin.api.unsupported"); + assert_eq!( + rejection.field(), + Some(format!("requires.{capability}").as_str()) + ); + } +} diff --git a/rust/lithe-core/src/tests/toolchain_resolution.rs b/rust/lithe-core/src/tests/toolchain_resolution.rs index 7ac94333..90d70d25 100644 --- a/rust/lithe-core/src/tests/toolchain_resolution.rs +++ b/rust/lithe-core/src/tests/toolchain_resolution.rs @@ -691,8 +691,7 @@ fn self_provided(input: &mut Value) { .as_object_mut() .unwrap() .remove("discovery"); - manifest["requires"] = json!({"toolDefinitions":1,"executableResolution":2, - "toolchainProviders":1,"backend":2,"serverProcess":1}); + manifest["requires"] = json!({"toolDefinitions":0,"toolchainProviders":0,"backend":0}); manifest["permissions"] = json!(["backend.launch", "toolchains.provide"]); manifest["toolchainProviders"] = json!([{"id":"installs","displayName":"Installed releases","tools":["java"]}]); diff --git a/rust/lithe-core/src/tests/vcs.rs b/rust/lithe-core/src/tests/vcs.rs index 9fdba4e0..89665259 100644 --- a/rust/lithe-core/src/tests/vcs.rs +++ b/rust/lithe-core/src/tests/vcs.rs @@ -1,4 +1,4 @@ -//! VCS providers declared by API level 3 plugins: manifest validation, +//! VCS providers declared by plugins: manifest validation, //! install review, launch plans, root detection over real directories, and //! reply decoding, checked against `shared/fixtures/vcs/model-v1.json` and //! the Subversion provider manifest fixture. @@ -88,8 +88,8 @@ fn the_provider_fixture_validates_and_is_reviewed_with_its_tool() { } /// A provider changes the working copy and starts its own process, so the -/// manifest must ask for both, name its activation event, and say which -/// contract versions it needs; Git stays reserved for the built-in support. +/// manifest must ask for both, name its activation event, and list the +/// capability it uses; Git stays reserved for the built-in support. #[test] fn provider_manifests_state_permissions_activation_and_requirements() { let rejected = |mutate: fn(&mut Value)| { @@ -114,8 +114,12 @@ fn provider_manifests_state_permissions_activation_and_requirements() { "{missing_activation}" ); let missing_requirement = rejected(|manifest| { - manifest["requires"] = json!({ "serverProcess": 1 }); + manifest["requires"] = json!({ "languages": 0 }); }); + assert_eq!( + missing_requirement["field"], "requires", + "{missing_requirement}" + ); assert!( missing_requirement.to_string().contains("vcsProviders"), "{missing_requirement}" @@ -125,8 +129,12 @@ fn provider_manifests_state_permissions_activation_and_requirements() { manifest["activationEvents"] = json!(["onVcsRoot:git"]); }); assert_eq!(reserved["code"], "plugin.vcs.reserved", "{reserved}"); - let level_two = rejected(|manifest| manifest["pluginAPIVersion"] = json!(2)); - assert_ne!(level_two["code"], "plugin.vcs.reserved", "{level_two}"); + let unsupported = rejected(|manifest| manifest["pluginAPIVersion"] = json!(1)); + assert_eq!( + unsupported["code"], "plugin.api.unsupported", + "{unsupported}" + ); + assert_eq!(unsupported["field"], "pluginAPIVersion", "{unsupported}"); let escaping = rejected(|manifest| { manifest["vcsProviders"][0]["metadataPaths"] = json!(["../wc.db"]); }); @@ -396,6 +404,6 @@ fn plugin_replies_match_the_validation_cases() { "capabilities": capabilities, "reply": {}, })) - .expect_err("push has no reply shape in version 1"); + .expect_err("push has no reply shape"); assert!(matches!(error.code, ErrorCode::InvalidRequest), "{error:?}"); } diff --git a/rust/lithe-core/src/vcs/manifest.rs b/rust/lithe-core/src/vcs/manifest.rs index daa8deca..11b9831d 100644 --- a/rust/lithe-core/src/vcs/manifest.rs +++ b/rust/lithe-core/src/vcs/manifest.rs @@ -1,5 +1,5 @@ -//! `vcsProviders[]` of an API level 3 plugin manifest: what a host must know -//! about a VCS before starting any process for it. +//! `vcsProviders[]` of a plugin manifest: what a host must know about a VCS +//! before starting any process for it. //! //! A declaration names the VCS, the static markers that identify its roots, //! the metadata the host watches to refresh status, the client tool the @@ -117,7 +117,6 @@ fn is_marker_name(value: &str) -> bool { /// What the declaration context needs from the manifest. pub(crate) struct DeclarationContext<'a> { - pub(crate) plugin_api_version: u32, pub(crate) declared_settings: &'a DeclaredSettings, /// Whether tool names are host runtimes or tool-definition IDs. pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, @@ -131,12 +130,6 @@ pub(crate) fn validate_declarations( if providers.is_empty() { return Ok(()); } - if context.plugin_api_version < 3 { - return Err(invalid( - "vcsProviders", - "VCS providers require plugin API level 3.", - )); - } if providers.len() > MAX_PROVIDERS { return Err(invalid( "vcsProviders", @@ -246,7 +239,6 @@ impl VcsProviderDeclaration { self.process.validate( field, - context.plugin_api_version, context.declared_settings, ServerRole::VcsProvider, context.runtime_tools, diff --git a/rust/lithe-core/src/vcs/model.rs b/rust/lithe-core/src/vcs/model.rs index f9f474aa..8872f416 100644 --- a/rust/lithe-core/src/vcs/model.rs +++ b/rust/lithe-core/src/vcs/model.rs @@ -34,7 +34,7 @@ pub(crate) const CAPABILITIES: &[(&str, bool)] = &[ ]; /// Version of each capability this Core build understands. -const CAPABILITY_VERSION: u64 = 1; +const CAPABILITY_VERSION: u64 = 0; /// A snapshot keeps at most this many changes and is then marked truncated. pub(crate) const MAX_CHANGES: usize = 100_000; @@ -543,8 +543,8 @@ mod tests { #[test] fn negotiation_keeps_known_version_one_capabilities_and_requires_the_core_set() { let negotiated = negotiated_capabilities(&json!({ - "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, - "annotate": 2, "futureThing": 1, "update": 1 + "status": 0, "baseContent": 0, "checkin": 0, "rollback": 0, + "annotate": 1, "futureThing": 0, "update": 0 })) .expect("required set present"); assert_eq!( @@ -553,7 +553,7 @@ mod tests { ); // A provider without rollback cannot back the generic commit UI. assert!( - negotiated_capabilities(&json!({ "status": 1, "baseContent": 1, "checkin": 1 })) + negotiated_capabilities(&json!({ "status": 0, "baseContent": 0, "checkin": 0 })) .is_none() ); assert!(negotiated_capabilities(&json!(["status"])).is_none()); diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 6a6b9018..09af81db 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1,6 +1,7 @@ # Plugin contract v1: declarative plugins -A declarative plugin currently describes language support or first-stage UI contributions as data. The host reads +A declarative plugin describes language support, build and run integration, version control, +debugging, project generation and host-rendered UI as data. The host reads `plugin.json`, validates it, checks the applicable signature or community approval, and launches the declared external processes itself. The host never loads plugin code into its own process, so a package does not depend on the host's binary layout and does @@ -19,18 +20,18 @@ This directory is the cross-platform source of truth: | Fixture folder | Contents | | --- | --- | | `fixtures/manifest/` | `hello-lithe.plugin.json`, the smallest UI plugin, and `invalid-manifests.json`: edits to a valid manifest and the rejection code every host must report | -| `fixtures/languages/` | Language support: packaged, system-command, and host-integration language servers, `node-runtime` for a discovered runtime, and `language-grammar` with `grammars/`; `command-plans.json` holds golden run/test command expansions and language-server launch plans, `runtime-versions.json` the golden outcomes of the launch runtime version check; `language-server-backend-launch.plugin.json` is a language server whose backend resolves the launch and a session policy (`languageServers` 3, `serverProcess` 3), and `language-server-launch-resolution.json` holds its golden first-step plans and decoded answers | +| `fixtures/languages/` | Language support: packaged, system-command, and host-integration language servers, `node-runtime` for a runtime launch, and `language-grammar` with `grammars/`; `command-plans.json` holds golden run/test command expansions and language-server launch plans; `language-server-backend-launch.plugin.json` is a language server whose backend resolves the launch and a session policy, and `language-server-launch-resolution.json` holds its golden first-step plans and decoded answers | | `fixtures/backend/` | Plugin backends (`backend`, `backend-workspace`, `command-backend`, `terminal-backend`); cross-plugin calls (`plugin-functions-exporter`, `plugin-functions-caller`); `function-protocol.json` holds golden plugin-function negotiation, registration, invocation, outcome, progress, and refusal cases, `plugin-logging.json` the golden `litheLogging` negotiation and notifications | -| `fixtures/editor/` | Editor actions served by a backend: `editor-actions.plugin.json`, version 2 icons, alternatives and click results in `editor-actions-v2.json`; editor providers in `editor-providers.plugin.json` with registration and result cases in `editor-providers.json` | +| `fixtures/editor/` | Editor actions served by a backend: `editor-actions.plugin.json`, icons, alternatives and click results in `editor-actions.json`; editor providers in `editor-providers.plugin.json` with registration and result cases in `editor-providers.json` | | `fixtures/ui/` | Native controls, forms, and panels, and a web panel | -| `fixtures/settings/` | Plugin settings declared with `settings: 2` | -| `fixtures/build-systems/` | Level 3 build systems (`build-*`: static file, command output, host integration, instance options, model producer and consumer, tool path, toolchains); `build-plans.json` holds golden build-system plans and decoded outputs | +| `fixtures/settings/` | Plugin settings | +| `fixtures/build-systems/` | Build systems (`build-*`: static file, command output, host integration, instance options, model producer and consumer, tool path, toolchains); `build-plans.json` holds golden build-system plans and decoded outputs | | `fixtures/build-providers/` | Build providers (`build-provider*`) and the golden cases of their configuration, run options, run and debug preparation, execution, and test protocols | | `fixtures/debug-adapters/` | Debug adapters (standalone, served by a language server, remote attach, and `debug-adapter-features` with declared features and defaults), `debug-adapter-rejections.json`, and `debug-start.json`: what `debug.decodeBackendConfiguration` returns for each `debug/start` case | -| `fixtures/run/` | Run sessions and console links: `tool-reference-launch` names its runtimes by tool-definition ID (`serverProcess` 4) and declares output link matchers; `run-start.json` holds what `run.decodeStart` returns for each case, including the rejections, and `output-link-matchers.json` the golden matches and resolutions | +| `fixtures/run/` | Run sessions and console links: `tool-reference-launch` names its runtimes by tool-definition ID and declares output link matchers; `run-start.json` holds what `run.decodeStart` returns for each case, including the rejections, and `output-link-matchers.json` the golden matches and resolutions | | `fixtures/vcs/` | A version control provider | | `fixtures/keymap-import/` | Two independent shortcut import providers sharing one packaged backend | -| `fixtures/project-generators/` | Project generators: template files (`templates/`), a scaffolding command, and an archive download; `project-generator-tool-field.plugin.json` with `templates/tool-field/` picks tool installations with `tool` fields (`projectGenerators` 2); `project-plans.json` holds golden `project.*` form evaluations, generation plans, and name checks, and a case's optional `manifest` names the fixture it runs against (the version 1 generator by default) | +| `fixtures/project-generators/` | Project generators: template files (`templates/`), a scaffolding command, and an archive download; `project-generator-tool-field.plugin.json` with `templates/tool-field/` picks tool installations with `tool` fields; `project-plans.json` holds golden `project.*` form evaluations, generation plans, and name checks, and a case's optional `manifest` names the fixture it runs against (`project-generator.plugin.json` by default) | | `fixtures/toolchains/` | Production-shape fixtures for tool definitions and a packaged toolchain provider; they require `toolDefinitions` and `toolchainProviders`, which only macOS advertises | | `fixtures/package-trust/` | `publisher-bundled.plugin.json` and `signature-vectors.json`: public cross-platform vectors for the package publisher signature: the RFC 8032 test key, a fixed package with non-ASCII file names, its canonical payload, RFC 8032 deterministic signature, and content digest. Both platforms must build the same canonical payload and digest and accept the recorded signature; signers that add randomness, such as CryptoKit, check the signature by verification instead of byte comparison | | `fixtures/marketplace/` | Marketplace index, availability, source URL, and install review golden cases | @@ -38,7 +39,7 @@ This directory is the cross-platform source of truth: Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in `rust/lithe-core/src/build/`) and exposed through the -`plugins.declarative.*` commands in [`shared/contracts/rust-core-api.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md). Hosts must not +`plugins.declarative.*` commands in [`shared/contracts/rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md). Hosts must not reimplement them. Plugin authors do not need this repository. The public @@ -52,18 +53,16 @@ fields are rejected; editors associate the schema by file name instead. ## Plugin-owned toolchains (Core foundation) -The design follows the rewrite plan in -[PR #1581](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). All tool definitions belong to enabled plugins; the host supplies only generic query, validation, resolution, persistence, and presentation mechanisms. It does not add a built-in tool-definition table or interpret version-manager configuration formats. -The [implemented Core contract](toolchains/README.md) now accepts `toolDefinitions: 1` -and `toolchainProviders: 1` declarations and provides the `toolchains.*` commands -listed there. -`executableResolution: 2` applies to tool-definition candidates only; it does not -admit new sources into existing Build candidate sets. Definitions are entirely +The [implemented Core contract](toolchains/README.md) accepts `toolDefinitions: 0` +and `toolchainProviders: 0` declarations and provides the `toolchains.*` commands +listed there. Tool-definition candidates follow +[Executable resolution](#executable-resolution) and do not admit new sources into +existing Build candidate sets. Definitions are entirely plugin-owned. Any plugin may define a short ID such as `compiler` or a qualified `:`; the host supplies no provenance evidence. A short ID defined by more than one enabled plugin belongs to none of them: the registry omits @@ -85,7 +84,7 @@ implementations such as mise belong to their own plugin packages. See `keymapImportProviders` contains one to eight `{id, displayName, recognition, inputs}` entries. IDs are unique within the plugin, start with a lowercase letter, contain only lowercase ASCII letters, digits and hyphens, and are at most 64 bytes. The -plugin requires `keymapImportProviders: 1`, `backend: 2`, `serverProcess: 1`, +plugin requires `keymapImportProviders: 0`, `backend: 0`, `backend.launch`, `keymaps.provide` and `keymaps.readSelectedSources`. Its backend starts from a packaged executable or packaged runtime without discovered runtime dependencies. The host starts it for an explicit import; it is not a startup or workspace-open @@ -117,18 +116,18 @@ selected root, bound each file to 1 MiB and the snapshot to 8 MiB, and expose only scoped opaque handles in chunks of at most 128 KiB. A manifest cannot authorize arbitrary paths or access an unselected source. -Core offers `experimental.litheKeymapImport: {versions: [1], providers}` only +Core offers `experimental.litheKeymapImport: {versions: [0], providers}` only to a plugin-owned session with declared providers. The backend selects a -nonempty duplicate-free subset as `{version: 1, providers}`. A request to an +nonempty duplicate-free subset as `{version: 0, providers}`. A request to an unselected provider is refused. `lithe/keymap/import` carries only `{providerID, snapshotID}`. The snapshot is an opaque host-owned reference; the provider never interprets it as a path. Core bounds the operation and validates normalized results against the host's action catalog. Providers own source formats and must not silently promote conditional or multi-stroke rules to global shortcuts. The exact Core commands, limits and fixtures are in the -[Rust Core API](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md#normalized-shortcut-import-validation). +[Rust Core API](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md#normalized-shortcut-import-validation). -When granted individually, protocol 3 methods `lithe/host/keymapImport/listInputs` +When granted individually, methods `lithe/host/keymapImport/listInputs` and `lithe/host/keymapImport/readChunk` take `snapshotID` and `providerID`. Listing returns `{files: [{id, path, byteCount}]}`; a read additionally takes `fileID`, a nonnegative `offset` and `maximumBytes` from 1 through 131072 and @@ -138,10 +137,10 @@ and provider, revokes them when the import or session ends, and rejects files replaced or changed after capture. A new snapshot for that provider revokes the previous one. Errors never disclose absolute paths. -macOS advertises `keymapImportProviders` 1 and implements the scoped reader. +macOS advertises `keymapImportProviders` 0 and implements the scoped reader. Windows has no import source reader and does not advertise the capability; a host advertises it only with the complete import workflow. The official Node, -Java and Rust SDKs implement `litheKeymapImport` 1. +Java and Rust SDKs implement `litheKeymapImport` 0. ## Package layout @@ -160,31 +159,32 @@ symbolic links. A host treats an installed package as read-only. | Field | Meaning | | --- | --- | -| `schemaVersion` | Always `2`. Schema `1` was the retired macOS native-bundle manifest; hosts refuse it with `plugin.schema.unsupported`. | +| `schemaVersion` | Always `0`. Any other value is refused with `plugin.schema.unsupported`. | | `id` | Stable plugin ID: lowercase letters, digits, `.` and `-`. | | `displayName` | Name shown in plugin management. | | `version` | Plugin version, `major.minor.patch`. Independent of the Lithe version. | -| `pluginAPIVersion` | Declarative plugin API level of the manifest format. Hosts list the levels they support; Lithe's own version is never used for compatibility. This contract defines levels `1`, `2`, `3`, and `4`; see [API levels](#api-levels). | -| `requires` | Level 3 only, and required there: every capability the manifest uses, with the version of that capability's contract it was written against; see [Capability versions](#capability-versions-level-3). | +| `pluginAPIVersion` | Always `0`. Any other value is refused with `plugin.api.unsupported` and `field: "pluginAPIVersion"`. Lithe's own version is never used for compatibility. See [Versioning](#versioning). | +| `requires` | Required: every capability the manifest uses, each with version `0`; see [Capabilities](#capabilities). | +| `requiresTools` | Every tool-definition ID the manifest's launches and runtime dependencies name; see [Tool references](#tool-references). | | `vendor` | `id`, `displayName`, and `signatureRequirement` (see Trust). | | `permissions` | Capabilities the plugin may ask the host to perform. | | `activationEvents` | When the host may start each provider. | | `languages` | Languages the plugin recognizes. | | `languageServers` | At most one language server per declared language. | | `executionProviders` | At most one run/test provider per declared language. | -| `settings` | User-adjustable plugin options (level 2 and later). | -| `ui` | Host-rendered tool windows and commands (level 2 and later). | -| `buildSystems` | Project-level build tools (level 3). | -| `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains/README.md)); requires `toolDefinitions: 1` and `executableResolution: 2`; macOS hosts read-only discovery and resolves Build and run tools. | -| `toolchainProviders` | Backend fact-provider declarations ([Core foundation](toolchains/README.md)); requires `toolchainProviders: 1`, backend/server-process capabilities and `toolchains.provide`. | -| `keymapImportProviders` | Independent shortcut import providers; requires `keymapImportProviders: 1`, `backend: 2`, `serverProcess: 1` and `keymaps.provide`. macOS provides access to explicitly selected sources; Windows does not yet advertise this capability. | -| `pluginFunctions` | [Functions the plugin exports to, and calls from, other plugins](#cross-plugin-calls-protocol-version-4); requires `pluginFunctions: 1`, `backend: 2` and `serverProcess: 1`, and grants no permission. macOS relays the calls; Windows does not advertise this capability. | - -Unknown fields are refused (`plugin.manifest.invalid`) so an older host -rejects a package instead of ignoring part of it. At levels 1 and 2 a new -field needs a new `pluginAPIVersion`. From level 3 on it needs a new version -of the one capability that owns it, and only plugins that use the field -declare that version. +| `settings` | User-adjustable plugin options. | +| `ui` | Host-rendered tool windows and commands. | +| `buildSystems` | Project-level build tools. | +| `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains/README.md)); requires `toolDefinitions: 0`; macOS hosts read-only discovery and resolves Build and run tools. | +| `toolchainProviders` | Backend fact-provider declarations ([Core foundation](toolchains/README.md)); requires `toolchainProviders: 0`, `backend: 0` and `toolchains.provide`. | +| `keymapImportProviders` | Independent shortcut import providers; requires `keymapImportProviders: 0`, `backend: 0` and `keymaps.provide`. macOS provides access to explicitly selected sources; Windows does not yet advertise this capability. | +| `pluginFunctions` | [Functions the plugin exports to, and calls from, other plugins](#cross-plugin-calls); requires `pluginFunctions: 0` and `backend: 0`, and grants no permission. macOS relays the calls; Windows does not advertise this capability. | + +Unknown fields are refused (`plugin.manifest.invalid`) so a host rejects a +package instead of ignoring part of it. A new optional field in an existing +section needs no new version; a change to what a section means gives the one +capability that owns it version `1`, and only plugins that use that change +declare it (see [Versioning](#versioning)). ### Languages @@ -195,9 +195,9 @@ present. A leading dot is optional only in `fileExtensions` (`.php` equals is part of the name, so `.toolrc` and `.env.example` stay hidden-file names. Optional `dependencies` lists `managementFileNames`, `projectDependencyPaths`, and `projectBinaryPaths` relative to the project. -#### Grammars and editor configuration (`languages` 3) +#### Grammars and editor configuration -`languages` 3 adds two optional fields to a language. Both are data: the host +A language has two optional fields, `grammar` and `configuration`. Both are data: the host reads them from the package and the editor applies them without starting any process, so a language with a grammar needs no language server, backend, or activation event, and a plugin may consist of grammars alone. @@ -217,15 +217,9 @@ activation event, and a plugin may consist of grammars alone. } ``` -- `grammar.kind` names the highlighting engine. Version 3 defines only - `textmate`; another engine is added as a new kind in a later version, so a - plugin can move between engines without changing anything else. -- Version 2 defined `monarch`, the JSON form of a Monaco Monarch tokenizer. - The desktop editor does not embed Monaco, so version 2 is withdrawn: Core - does not support it, and a manifest that requires `languages: 2` is - `plugin.api.unsupported`. A Monarch grammar is rewritten as a TextMate - grammar under `languages: 3`; the manifest changes only in `kind`, `path`, - and the required version. +- `grammar.kind` names the highlighting engine. The contract defines only + `textmate`; another engine is added as a new kind, so a plugin can move + between engines without changing anything else. - This is a static package contribution: `languages[]` belongs to the manifest and `grammar.path` references a package JSON file. No public host method accepts inline grammar JSON to dynamically register, replace, or dispose a highlighting @@ -233,13 +227,13 @@ activation event, and a plugin may consist of grammars alone. not imply such an API or automatic reload after arbitrary file changes; installed packages remain read-only. - Adding another language or grammar using the supported structure needs only - plugin data; changing its rules does not itself raise the API version. A new + plugin data; changing its rules needs no contract change. A new engine or capability kind still needs an implemented host adapter and an explicit contract; an arbitrary new `kind` string cannot supply one. - This contribution does not register completion, hover, definition, symbol, formatting, rename, or Code Action providers backed by ordinary plugin functions. A backend binds the provider types a host routes with - [`editor/registerProviders`](#editor-language-feature-providers-protocol-version-3); + [`editor/registerProviders`](#editor-language-feature-providers); existing LSP support and `editor/registerActions` are separate capabilities. - `grammar.path` is a package-relative file that stays inside the package. Hosts read it read-only from the installed package, pass its text to @@ -287,8 +281,6 @@ activation event, and a plugin may consist of grammars alone. backtracking of each regex search and the time one line may take, so a grammar cannot stall editing; a line not yet tokenized, or that the grammar could not tokenize within that budget, is drawn as plain text. -- Declaring `languages: 3` without using either field is - `plugin.manifest.invalid`; using them under `languages: 1` is too. ### Language servers @@ -296,92 +288,22 @@ activation event, and a plugin may consist of grammars alone. | --- | --- | | `languageID` | A declared language. | | `languageIdentifier` | LSP `languageId` for documents; defaults to `languageID`. | -| `launch` | `{"kind": "packaged", "path": …}` runs an executable inside the package. `{"kind": "systemCommand", "commands": [...]}` lists bare command names the host resolves with its tool discovery (PATH, project toolchains, user overrides). API 2 adds `{"kind": "runtime", …}`; see Runtime launch. `serverProcess` 3 adds `{"kind": "backend", …}`, whose command line the plugin backend answers at each start; see [Backend-resolved launch](#backend-resolved-launch-serverprocess-3). | -| `arguments` | Arguments passed to the server. Literal text at API 1; launch templates at API 2. | +| `launch` | `{"kind": "packaged", "path": …}` runs an executable inside the package. `{"kind": "systemCommand", "commands": [...]}` lists bare command names the host resolves with its tool discovery (PATH, project toolchains, user overrides). `{"kind": "runtime", …}` starts a package file with a packaged or discovered runtime; see [Runtime launch](#runtime-launch). `{"kind": "backend", …}` has the plugin backend answer the command line at each start; see [Backend-resolved launch](#backend-resolved-launch). The launch fields are described in [Process start](#process-start). | +| `arguments` | Arguments passed to the server, written as [launch templates](#launch-templates). | | `validationArguments` | Probe that must exit with status 0 before a candidate is used; empty means the host trusts the candidate and lets the LSP handshake decide. | -| `runtimeDependencies` | Runtimes the host must discover and pass to the server. The host runtimes are `node` and `go`; any other valid short tool-definition ID names the tool definition with that ID (see [Tool references](#tool-references-serverprocess-4)). The host exports each path as `LITHE__PATH` (`LITHE_NODE_PATH`, `LITHE_GO_PATH`; a user-set value takes precedence) and prepends its directory to the server's `PATH`, because servers such as gopls run the tool themselves and an app opened from the Dock does not inherit the shell `PATH`. A project-local copy is never chosen, since servers start automatically when a file opens. | - -| `initializationOptions` | API 2. A JSON object sent as the LSP `initializationOptions`. Every string value inside it is a launch template. | -| `hostIntegration` | API 2. Any plugin may declare one. A named integration implemented in the host, with the package resources it reads; see Host integrations. | -| `requestMethods`, `commands`, `serializedCommands`, `forwardNotifications`, `state`, `remote` | `languageServers` 3. What the plugin backend may ask of the running server and how the host keeps its state; see [Plugin-owned sessions](#plugin-owned-sessions-languageservers-3). | +| `runtimeDependencies` | Tools the host must discover and pass to the server, each a tool-definition ID that `requiresTools` lists (see [Tool references](#tool-references)). The host exports each path as `LITHE__PATH` (`LITHE_NODE_PATH`, `LITHE_GO_PATH`; a user-set value takes precedence) and prepends its directory to the server's `PATH`, because servers such as gopls run the tool themselves and an app opened from the Dock does not inherit the shell `PATH`. A project-local copy is never chosen, since servers start automatically when a file opens. | +| `initializationOptions` | A JSON object sent as the LSP `initializationOptions`. Every string value inside it is a launch template. | +| `hostIntegration` | Any plugin may declare one. A named integration implemented in the host, with the package resources it reads; see [Language server host integrations](#language-server-host-integrations). | +| `requestMethods`, `commands`, `serializedCommands`, `forwardNotifications`, `state`, `remote` | What the plugin backend may ask of the running server and how the host keeps its state; see [Plugin-owned sessions](#plugin-owned-sessions). | The host owns the language-server process lifecycle, the LSP session, and -stopping the process when the provider is disabled. An API 3 build provider is -the same kind of process: it declares `launch`, `arguments`, +stopping the process when the provider is disabled. A build provider with its own +process is the same kind of process: it declares `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, and `initializationOptions` with the same meaning and validation; see [Build providers](#build-providers). -#### Runtime launch (API 2) -`{"kind": "runtime", "tool", "packagedRuntime"?, "minimumVersion"?, -"runtimeArguments"?, "entry"}` starts a file inside the package with a -runtime, so the package needs no launcher script. The runtime is either -shipped by the plugin (`packagedRuntime`) or discovered by the host. - -| Field | Meaning | -| --- | --- | -| `tool` | `node`, the host runtime, or a tool-definition ID whose definition decides how the entry is passed (see [Tool references](#tool-references-serverprocess-4)). | -| `packagedRuntime` | Package-relative path of the runtime executable the plugin ships, for example `runtime/${arch}/bin/node`; `${arch}` is the only variable it may use. The host starts exactly this file: it never looks the tool up on the machine, and no user override or other plugin can replace it. A plugin that needs a specific runtime owns it this way, so the host carries no knowledge of where that runtime lives. It excludes `minimumVersion`. | -| `minimumVersion` | Lowest acceptable runtime version: one to three dot-separated numbers such as `21` or `18.17`, for a runtime the host discovers. Only the components it names are compared, so `21` accepts every 21.x. A runtime whose version cannot be read does not qualify. | -| `runtimeArguments` | Launch templates placed before the entry, such as runtime options. | -| `entry` | Package-relative entry file. For `node` the command line is ` `. | - -The launch tool is already a requirement and must not be repeated in -`runtimeDependencies`. A runtime launch declares no `validationArguments`: -the host's probe of the runtime is its version check. For `node` the launch -plan carries the probe's `versionArguments` (`--version`); the host runs the -candidate with them, capturing stderr together with stdout, and passes the -output to `plugins.declarative.checkRuntimeVersion`, which returns -`{satisfied, version?}`. A candidate that does not satisfy the requirement is -not used, the same as a candidate that fails a validation probe. The reported -version is the first dotted number on the first output line that contains a -digit; see `fixtures/languages/runtime-versions.json`. Core probes no other -runtime: `checkRuntimeVersion` answers `plugin.runtime.unsupported` for any -other `tool`. - -Any other valid short tool-definition ID in `tool` (lowercase letters, digits -and `-`, starting with a letter, at most 64 characters) is an implicit -reference to the tool definition with the same ID, at every `serverProcess` -version. Its launch plan has the tool shape of -[Tool references](#tool-references-serverprocess-4): the definition's `launch` -template places the entry, and `toolchains.checkVersion` checks -`minimumVersion`. The host adds no runtime-specific option such as a `-jar` -flag; the definition's template supplies whatever the tool needs. - -#### Tool references (`serverProcess` 4) - -`serverProcess` 4 stops naming runtimes from the host's closed lists (the -`tool` values that versions 1 to 3 accept) and names them by -[tool-definition ID](toolchains/README.md) instead, so a host never learns a tool from -a name. The plugin lists every tool it starts -a process with in the top-level `requiresTools`, and the definitions come from -`toolDefinitions` of the plugin itself or of another enabled plugin. - -| Field | Meaning | -| --- | --- | -| `requiresTools` | At most 32 tool-definition IDs, each short (`interpreter`) or plugin-qualified (`org.example.tools:interpreter`), without duplicates; stored sorted. Present exactly when `requires.serverProcess` is 4: the version without the list is `plugin.manifest.invalid` at `requires.serverProcess`, the list without the version is invalid at `requiresTools`. | -| `launch.tool` of a `runtime` launch, `runtimeDependencies[].tool`, `runSelectedRuntime.tool` of a debug adapter | A tool-definition ID that `requiresTools` lists. An ID it does not list is `plugin.runtime.undeclared` with the `field` that names it. Versions 1 to 3 accept the closed lists (`node` to launch an entry, `node` and `go` as dependencies) and, without `requiresTools`, any valid short tool-definition ID as an implicit reference to the definition with the same ID; only an invalid ID is `plugin.runtime.unsupported`. | - -The definition, not the host, decides how its executable starts an entry: its -`launch` template says where `runtimeArguments`, the entry and `arguments` go -(`["${executable}", "${runtimeArguments}", "${entry}", "${arguments}"]` runs -` `, a template without `${entry}` -runs no entry). The launch plan of a version 4 process therefore returns the -parts, not a command line: - -| Field | Meaning | -| --- | --- | -| `launch` | `{kind: "tool", tool, entry, runtimeArguments, minimumVersion?, packagedRuntime?}`. `entry` is the absolute path of the package file, `runtimeArguments` are expanded, and `packagedRuntime` is the expanded path of a runtime the plugin ships (the host then uses that file as the tool's executable and never looks the tool up). | -| `arguments` | Only the plugin's own arguments; the host places them where the definition's `${arguments}` is. | -| `runtimeRequirements` | `{kind: "tool", tool, minimumVersion?}` for every tool the host must resolve. | - -The host resolves each tool through the four layers of -[toolchains/README.md](toolchains/README.md), checks `minimumVersion` with -`toolchains.checkVersion`, and expands the start with `toolchains.instantiateLaunch`. -A tool that resolves to no executable is `plugin.host.toolUnresolved` -(`details.reason` says why). See `fixtures/run/tool-reference-launch.plugin.json`. - -#### Host integrations (API 2) +#### Language server host integrations Most of a language server is described by its launch. A few servers also need behavior that data cannot express: a project model computed from build files, @@ -409,7 +331,7 @@ server process, which an installed plugin may already do once the user confirms the install review. An ID the host does not implement is refused with `plugin.integration.unsupported`. A build system's `hostIntegration` is a different declaration and stays limited to official plugins; see -[Host integrations](#host-integrations) under API level 3: build systems. +[Host integrations](#host-integrations) under [Build systems](#build-systems). Each resource is a package-relative path, or a list of them, that may vary only by `${arch}`. An integration defines which names it reads; a missing @@ -437,60 +359,119 @@ normalized manifest lists capabilities sorted and without duplicates. The launch plan returns `hostIntegration` as `{id, resources}` with every path expanded, and lists the paths in `requiredPackagePaths`. -#### Launch templates (API 2) -`arguments`, `validationArguments`, `runtimeArguments`, and the string values -of `initializationOptions` may use these variables. A package therefore never -contains a machine path, and the host decides where state lives. +#### Workspace configuration -| Variable | Value | -| --- | --- | -| `${pluginDir}` | Root of the installed, read-only package. It must be used at most once per value and be followed by a `/`-rooted path inside the package; that path may vary only by `${arch}`. | -| `${stateDir}` | Writable directory the host keeps for this plugin's server and removes when the plugin is uninstalled. Not available in `validationArguments`. | -| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`, nor in a [plugin backend](#plugin-backend), which belongs to no workspace. | -| `${logDir}` | Absolute current-session `plugins//files/` directory, explicitly supplied by the host as `logDirectory`. Available for language servers and plugin backends, never in `validationArguments`. The host creates it in writable log storage, outside the installed package; Core does not derive it from package resources. | -| `${arch}` | Host CPU architecture: `arm64` or `x86_64`. | -| `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | +A language server may declare `workspaceConfiguration`, an object whose +string leaves may be `${setting:}` templates. +Core expands it with the merged settings +(`plugins.declarative.workspaceConfiguration`, and the launch plan's +`workspaceConfiguration`), answers the server's `workspace/configuration` +requests by section path from it, and sends +`workspace/didChangeConfiguration` after `initialized`. Hosts send a new +value with `lsp.updateWorkspaceConfiguration` when an `immediate` setting +changes; the running server receives it without a restart. -`plugins.declarative.languageServerLaunchPlan` expands a server's launch. The -host selects the process with exactly one of `languageID` (a language server), -`buildSystemID` (a build provider), `vcsProviderID` (a VCS provider), and -`backend: true` (the [plugin backend](#plugin-backend)), passes `{pluginDirectory, -stateDirectory, workspaceStateDirectory?, logDirectory?, temporaryDirectory?, architecture, -settings?}`, and receives. `workspaceStateDirectory` may be omitted only when the selected -process does not use `${workspaceStateDir}`; otherwise the request is -`plugin.launch.invalidRequest`. The same error applies when a template uses `${logDir}` but `logDirectory` is absent, empty, or not absolute. +#### Plugin-owned sessions + +A plugin backend drives its own server only through what the server +declares. A server may declare: | Field | Meaning | | --- | --- | -| `owner` | `{pluginID, version}` of the installed package Core validated for this plan. The host starts the server for that exact installation and uses this identity, not one it read elsewhere, when it later relays requests or reports the session. | -| `launch` | `{kind: "packaged", path}`, `{kind: "systemCommand", commands}`, or `{kind: "runtime", tool, entry, packagedRuntime?, minimumVersion?, versionArguments?}`: what the host resolves to an executable. `packagedRuntime` is the expanded package-relative path of a shipped runtime. `minimumVersion` and `versionArguments` appear together when a discovered runtime must meet a minimum version. A `runtime` launch whose `tool` names a tool definition, by `serverProcess` 4 or implicitly, returns the `{kind: "tool", …}` shape of [Tool references](#tool-references-serverprocess-4) instead. A [backend-resolved launch](#backend-resolved-launch-serverprocess-3) returns `{kind: "backend", function, timeoutMilliseconds}` here, and `{kind: "tool", tool}` appears in the plan `plugins.languageServers.decodeLaunch` returns. | -| `arguments` | Complete argument list after the executable, including the entry of a `node` runtime launch. A tool-shaped launch returns only the plugin's own arguments. | -| `validationArguments` | Expanded probe arguments. | -| `runtimeRequirements` | `{tool, minimumVersion?, kind?}` for every runtime the host must discover, a discovered launch tool included, sorted by tool. `kind: "tool"` marks a tool definition the host resolves through [toolchains](toolchains/README.md); without it the tool is a host runtime. A packaged runtime is not listed. Only the launch tool can carry a minimum version. | -| `initializationOptions` | Expanded options; absent when the server declares none. | -| `requiredDirectories` | `state`, `workspaceState`, `log`, and/or `temporary`: the directories the host must create before starting the server. | -| `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. | -| `resolveParams` | Present only for a backend-resolved launch: the params of the launch function, without the `context` and the optional `tools` the host adds. | -| `environment`, `writablePaths` | Present only in the plan `decodeLaunch` returns: the variables of the process, and the paths it writes. | -| `sessionPolicy` | Present for a language server of `languageServers` 3: the [session policy](#plugin-owned-sessions-languageservers-3) the host passes to `lsp.startServer` unchanged. | -| `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | -| `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | -| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant, plus `version: 2` for a [`backend` 2](#backend-2-workspace-contexts) process. The host passes `hostServices` to `lsp.startServer.hostServices` unchanged. | +| `requestMethods` | LSP methods the backend may send with `languageServer/request` or `languageServer/notify`; at most 64. | +| `commands` | The `workspace/executeCommand` commands the backend may run; at most 128. Declared exactly when `requestMethods` lists `workspace/executeCommand`. | +| `serializedCommands` | A subset of `commands` that runs one at a time: a request for a command already running joins it and receives the same answer. | +| `forwardNotifications` | Server notifications the host forwards to the backend; at most 32. | +| `state` | `{scope: "workspace", retention: {maxEntries, maxAgeDays}}`. The host prunes the server's per-workspace state directories (see below). `maxEntries` is 1 to 64, `maxAgeDays` 1 to 365. | +| `remote` | `supported` (default) or `unsupported`. | -A language without a server, an undeclared build system, a build system -without a provider, an undeclared VCS provider, and a plugin without a -backend 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. +- A name is non-empty text of at most 256 bytes without whitespace or control + characters, unique within its list. Names the host sends itself + (`initialize`, `initialized`, `shutdown`, `exit`, `textDocument/didOpen`, + `didChange`, `didClose`, `didSave`, `willSave`, `willSaveWaitUntil`, + `workspace/didChangeWorkspaceFolders`, and every `$/` method) cannot appear + in `requestMethods` or `forwardNotifications`. +- A server that lists `requestMethods` or `forwardNotifications` requires the + `languageServer.request` permission; declaring the permission without such a + server is `plugin.manifest.invalid`. +- The launch plan's `sessionPolicy` is these fields as sorted lists plus + `serverID` (the `languageID`) and `gates`. The host sends it unchanged as + `lsp.startServer.sessionPolicy`; Core's LSP engine enforces it, so a host + cannot widen it and a backend cannot step outside it. +- A server with `remote: "unsupported"` is not started for a remote + workspace: `lsp.startServer` answers `not_supported`. + +What the engine enforces on `lsp.pluginServerRequest` and +`lsp.pluginServerNotify`, the two commands behind the host methods: + +- A request must be in `requestMethods`, and a `workspace/executeCommand` + must carry a `params.command` in `commands`; anything else is + `plugin.host.methodNotDeclared`. A notification may be + `workspace/didChangeConfiguration` (the backend always knows the server's + settings; the value also becomes the settings the server reads through + `workspace/configuration`) or a `requestMethods` entry other than + `workspace/executeCommand`. The host's own methods stay refused. +- The session must belong to the calling plugin + (`plugin.host.foreignContribution` otherwise) and be running + (`plugin.host.languageServerUnavailable` otherwise, including every request + still pending when the process stops, crashes, restarts, or the plugin is + disabled). +- A request waits at most `timeoutMilliseconds` (default 60000, at most + 300000) and ends with `plugin.host.timeout`; cancelling it sends the server + `$/cancelRequest` and ends it with `plugin.host.cancelled`. A server's JSON-RPC + error is `plugin.host.languageServerError` with `details: {code, message}`; + the server's `data` is not forwarded. A result over the host's result bound + is `plugin.host.limitExceeded`. +- A **gate** is `{id, until: "notification", method, match}`, declared by the + launch answer. It opens when the server sends a notification with that + `method` whose params contain every member of `match` (nested objects match + the same way). Until every gate has opened, `request` calls wait in order, + at most 128; the 129th is `plugin.host.busy`. A waiting request still obeys + its timeout and cancellation. Notifications are never held. +- The first request for a `serializedCommands` command runs; a later request + for the same command, while the first is running or waiting, joins it and is + answered with the first one's outcome. Cancelling the first request ends the + joined requests with it. +- `forwardNotifications` reach the backend as + `lithe/host/languageServer/didReceiveNotification`. + `workspace/didChangeConfiguration` is the only notification Core consumes + itself. -#### Structured plugin diagnostics (logging protocol 1) +**State and retention.** The host keeps one state directory per workspace for +each server, under the plugin's storage (never under the package or application +bundle), and gives the server its own as `workspaceStateDirectory`. The +directories survive disabling and updating the plugin. The plugin chooses the +sub-directory names inside its `workspaceStateDirectory`; the host chooses the +names of the per-workspace directories themselves and never looks inside them. + +The host refreshes a workspace directory's modification time on every start of +the server and sweeps in the background after a start. It asks +`plugins.languageServers.stateRetention` with `{retention, nowUnixSeconds, +entries: [{name, lastModifiedUnixSeconds}], activeNames}`, where `entries` are +the per-workspace directories it found and `activeNames` are those of open +workspaces, and deletes the `expiredNames` it returns: a directory older than +`maxAgeDays` and, of the rest, all but the newest `maxEntries` that open +workspaces leave free. A workspace that is open is never pruned. Names that are +not a single path component are ignored, and the host deletes only names it +listed. A plugin that declares no `state.retention` keeps everything. + +The host can also discard the state of one workspace; the server rebuilds it +the next time it starts. That is an engine path only, with no user-facing +action. + +**Preparation status.** `languageServer/setStatus` reports how ready the +server is. It exists because `lithe/function/progress` belongs to a call that +is running, while preparation (importing a build, indexing) outlives any call. + +Example package: `fixtures/languages/language-server-backend-launch.plugin.json`. +### Structured plugin diagnostics Every plugin-owned process may negotiate `capabilities.experimental.litheLogging`: -the host offers `{ "versions": [1] }` in `initialize`, and the SDK answers -`{ "version": 1 }`. The current official SDKs require that offer. This protocol -is independent of function, build, VCS, and toolchain protocol versions. +the host offers `{ "versions": [0] }` in `initialize`, and the SDK answers +`{ "version": 0 }`. The official SDKs require that offer. The protocol has no +version of its own: its version is `pluginAPIVersion`, and it is negotiated +independently of the other runtime protocols. After successful initialization, the backend sends a JSON-RPC notification `lithe/log`. Its params contain only these fields; unknown fields are rejected: @@ -548,7 +529,7 @@ carries one `code`: User cancellation and superseded attempts record nothing. Plugin output, paths, and error text never appear in these facts. The executing owner and the host-facing command are specified as `diagnostics.recordPluginOperation` in -[`rust-core-api.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md). +[`rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md). Severity follows the actual result. An operation that failed for the user is ERROR, a recovery or fallback is WARN, and a useful successful user operation @@ -565,16 +546,15 @@ structured catalog events. Keep metadata and caches in `${stateDir}` or `${workspaceStateDir}`, and never infer a writable log path from `${pluginDir}`. These runtime files do not modify installed resources or participate in build resource reuse. Hosts without a local `logDirectory` reject launches requiring -`${logDir}`. The current macOS writer persists v2; Windows's writer migration -remains a separate platform task. +`${logDir}`. -#### Plugin settings (API 2) +### Plugin settings A plugin may declare options the user can change, one set per plugin and shared by every workspace. The host shows them on the plugin's page, stores the values, and passes the stored values as `settings` (setting ID to JSON -value) with each launch plan request. Hosts restart the plugin's language -servers when a value changes. +value) with each launch plan request. A change restarts the plugin's processes +unless the setting's [`effect`](#placement-scope-effect-and-page-buttons) says otherwise. ```json "settings": [ @@ -589,7 +569,7 @@ servers when a value changes. | Field | Meaning | | --- | --- | | `id` | Starts with a lowercase letter, then letters and digits. Unique within the plugin. | -| `type` | `integer` (requires `minimum` and `maximum`, optional `unit`), `enum` (requires one to sixteen `options`, each `{value, title}`; values contain only letters, digits, `.`, `_`, `-`), or `boolean`. | +| `type` | `integer` (requires `minimum` and `maximum`, optional `unit`), `enum` (requires one to sixteen `options`, each `{value, title}`; values contain only letters, digits, `.`, `_`, `-`), `boolean`, `multiSelect` (one to 32 `options`; the value is an array of distinct option values), `text` (requires `pattern`, matched against the whole value; optional `maxLength`, default 256 and at most 1024 Unicode scalars; optional `placeholder`), or `file` and `directory` (an absolute path of at most 1024 Unicode scalars, or empty for "not set"; optional `placeholder`). Text and paths never start with `-` and contain no control characters. | | `title`, `description` | Text the host shows; the host localizes only its own chrome. | | `default` | Value used until the user changes it. Its JSON type follows `type`, and it must be accepted by the declaration. | @@ -605,14 +585,14 @@ with `plugin.manifest.invalid` or `plugin.template.invalid`. The settings declaration is not part of an install review's program list, because no value can change what runs beyond what the templates already show. -#### Settings v2: placement, scope, effect, and page buttons +#### Placement, scope, effect, and page buttons -Version 2 of the `settings` capability (`requires.settings: 2`, level 3) lets -a plugin place its options anywhere in the host's settings without the host +A setting may also say where it is shown and when a change takes effect, so +a plugin can place its options anywhere in the host's settings without the host knowing the plugin. Hosts keep only generic registration points: a page per language and per build system, sections, one control per setting type, a project/global layer switch, and buttons. What appears there comes from the -manifest. Fixture: `fixtures/settings/settings-v2.plugin.json`. +manifest. Fixture: `fixtures/settings/settings.plugin.json`. ```json "settings": [ @@ -629,7 +609,6 @@ manifest. Fixture: `fixtures/settings/settings-v2.plugin.json`. | Field | Meaning | | --- | --- | -| `type` | Adds `multiSelect` (one to 32 `options`; the value is an array of distinct option values), `text` (requires `pattern`, matched against the whole value; optional `maxLength`, default 256 and at most 1024 Unicode scalars; optional `placeholder`), `file` and `directory` (an absolute path of at most 1024 Unicode scalars, or empty for "not set"; optional `placeholder`). Text and paths never start with `-` and contain no control characters. | | `scope` | `global` (default): one value for every project. `project`: the open project may override it. | | `effect` | When a change takes effect. `restart` (default): the next start of the plugin's processes. `immediate`: pushed to the running language server through `workspaceConfiguration`. `reload`: the next build-model reload; hosts reload the plugin's build models. `nextRun`: the next run or build command. | | `page` | `{ "languageID": … }` or `{ "buildSystemID": … }`: the settings page that shows the setting besides the plugin's own page. Any plugin may add to a page; the host shows the page while an enabled plugin contributes to it. | @@ -641,7 +620,7 @@ function `function` of the same plugin with empty params, served by the plugin's own provider process for the open project (see [plugin functions](#plugin-functions)). `confirm: true` makes the host ask first. The host owns the button and knows nothing about what the function -does. Declaring actions counts as using `settings` and needs version 2. +does. Declaring actions counts as using `settings`. Placement and use rules, checked by Core at validation: @@ -650,8 +629,6 @@ Placement and use rules, checked by Core at validation: `initializationOptions`, so a change never needs a new process. - A `text`, `file`, or `directory` setting is a whole template value or the value of one `name=` argument, never part of a longer argument. -- Any v2 field or type, or any action, with `requires.settings` below 2 is - refused with `plugin.manifest.invalid`. **Layers.** Hosts store a global value per plugin and setting, and a project value in the project's `.lithe/plugin-settings.json` @@ -662,19 +639,311 @@ skipping a stored value the declaration no longer accepts. `plugins.declarative.resolveSettings` returns each effective value with its `source` (`project`, `global`, or `default`). -#### Workspace configuration (`languageServers` 2) +### Execution providers -A language server may declare `workspaceConfiguration`, an object whose -string leaves may be `${setting:}` templates. With -`requires.languageServers: 2`, Core expands it with the merged settings -(`plugins.declarative.workspaceConfiguration`, and the launch plan's -`workspaceConfiguration`), answers the server's `workspace/configuration` -requests by section path from it, and sends -`workspace/didChangeConfiguration` after `initialized`. Hosts send a new -value with `lsp.updateWorkspaceConfiguration` when an `immediate` setting -changes; the running server receives it without a restart. +`run` and `test` each name an `executable`: exactly one of a bare `command` +resolved on the host's tool path, or a host-owned project `toolchain` ID such +as `project-go`. The working directory is always the workspace root. + +`test` also declares: + +| Field | Meaning | +| --- | --- | +| `frameworkID` | Stable framework label such as `phpunit` or `go`. | +| `workspaceLabel` | Label of the "all tests" item and plan. | +| `projectMarkers` | File names, any one of which makes the workspace a test project. | +| `testFilePatterns` | Case-insensitive file-name patterns using only `*`. | +| `workspaceArguments`, `fileArguments`, `testCaseArguments` | Argument templates per scope. | + +Argument templates are argv elements, never shell text. Each element is a +string or `{"value": …, "when": "fileSelected" | "noFileSelected"}`; +conditional elements are only allowed in `testCaseArguments`, where a file is +optional. + +| Variable | Available in | Value | +| --- | --- | --- | +| `${file}` | run, file, test case | Workspace-relative file. A name starting with `-` becomes `./`. | +| `${fileDirectory}` | run, file, test case | `./` of the file, or `.` at the workspace root. | +| `${testName}` | test case | Test identifier with surrounding whitespace removed. | +| `${testName:regex}` | test case | The identifier with regular-expression metacharacters escaped. | +| `${userArguments}` | run | The user's program arguments; must be a whole element and may expand to zero elements. | + +In `testCaseArguments`, an element that uses `${file}` or `${fileDirectory}` +must be guarded with `"when": "fileSelected"`, and some element must use the +test name. + +Discovery returns `{id: ":workspace", kind: "workspace"}` followed by +`{id: ":file:", kind: "file", relativeFilePath}` for matching +files sorted by path, or an empty list when no project marker is present. + +## Versioning + +Every version in this contract is `0`. A change adds a version to exactly one +place, so a plugin states what it needs and a host states what it runs, each +per capability. + +1. **Capabilities start at `0`.** A manifest lists the capabilities it uses in + `requires`, each with version `0`. A capability version names one manifest + section and, when the capability owns one, the runtime protocol that goes + with it. +2. **A runtime protocol has no version of its own.** Its version is the owning + capability's, and a host offers exactly that version + (`{"versions": []}`; the process answers `{"version": }`). The protocol versions today are `litheBuild` from + `buildProviders`, `litheFunctions` from `backend`, `litheVcs` from + `vcsProviders`, the toolchain provider protocol from `toolchainProviders`, + `litheKeymapImport` from `keymapImportProviders`, and `litheLogging` from + `pluginAPIVersion`. All of them are `0`. +3. **A host declares a capability version when it can serve every form of + it.** See [Host capabilities](#host-capabilities). +4. **A future change gives one capability version `1`.** The manifest section + and the protocol move together, a plugin that uses the change lists + `"": 1`, and every other plugin keeps `0` and stays installable + wherever it was. An additive optional field in an existing section or + message needs no new version. + +`pluginAPIVersion` versions only the base fields: `schemaVersion`, `id`, +`displayName`, `version`, `vendor`, `permissions`, `activationEvents`, +`requires`, and `requiresTools`. It is always `0`; any other value is refused +with `plugin.api.unsupported` and `field: "pluginAPIVersion"`. + +The other versioned documents start at `0` too: + +| Document | Version field | Value | +| --- | --- | --- | +| Plugin manifest ([schema](plugin-manifest.schema.json)); it names the file format, and any other value is `plugin.schema.unsupported` | `schemaVersion` | `0` | +| Native UI document ([schema](native-ui.schema.json)) | `schemaVersion` | `0` | +| Marketplace index ([schema](plugin-index.schema.json)) | `schemaVersion` | `0` | +| Canonical build documents (`litheBuildModel`, `litheDependencies`, `litheTestResults`, `litheTestDiscovery`, `litheBuildConfiguration`, `litheConfigurationEdit`) and the `modelInputs` snapshot | `version` | `0` | +| `plugins.editorActions.decode` request | `version` | `0` (required; Core accepts only `0`, otherwise `Unsupported editor action validation version.`) | +| Plugin web bridge | `window.lithe.version` (`bridgeVersion`) | `0` | +| `backend` member of a launch plan | `version` | the manifest's `requires.backend` | + +The `version` of an exported or called plugin function (1 to 1000) is not a +contract version: the plugin that exports the function owns it. + +### Capabilities + +```json +"pluginAPIVersion": 0, +"requires": { "buildProviders": 0, "buildSystems": 0, "languageServers": 0, "languages": 0 } +``` + +- `requires` is required and lists at least one capability. IDs are + lowerCamelCase identifiers of at most 64 characters; versions are + non-negative integers. +- Core reads `requires` before it decodes any section. A capability this + contract does not define, or a version other than `0`, is + `plugin.api.unsupported` with `field: "requires."`, even when the + manifest also holds a section Core does not know, so the user learns which + capability needs a newer Lithe instead of seeing an unknown field. + [Process start](#process-start) and + [Executable resolution](#executable-resolution) belong to the base contract + and are not capabilities: `requires.serverProcess` is refused like any + unknown capability. +- `requires` lists exactly the capabilities the manifest uses. Leaving out a + used one, or listing an unused one, is `plugin.manifest.invalid`. Any + `buildSystems[].provider` requires `buildProviders`, whether it starts its + own process or shares a language server. A template-only build plugin must + omit `buildProviders`. Every VCS provider starts its own process and speaks + `litheVcs`, so `vcsProviders` is its capability; `backend` is the capability + of the plugin backend. +- The base fields change only with a new `pluginAPIVersion`. + +Every capability has exactly one manifest section, and each runtime protocol +belongs to one capability: + +| Capability | Manifest section | Runtime protocol | A host that declares it | +| --- | --- | --- | --- | +| `languages` | [`languages[]`](#languages), with TextMate [`grammar` and `configuration`](#grammars-and-editor-configuration) | — | Reads grammars and editor configuration from the package and applies them in the editor without starting a process. | +| `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | — | Applies that trust rule to the bundled copy; see [Trust](#trust). | +| `languageServers` | [`languageServers[]`](#language-servers) apart from their launch fields, including [`workspaceConfiguration`](#workspace-configuration) and the [session fields](#plugin-owned-sessions) | — | Starts the server as [Process start](#process-start) says, delivers its workspace configuration, and serves its plugin-owned sessions. | +| `executionProviders` | [`executionProviders[]`](#execution-providers) | — | Runs and discovers tests through the declared commands. | +| `settings` | [`settings[]` and `settingActions[]`](#plugin-settings) with [placement, scope, effect and page buttons](#placement-scope-effect-and-page-buttons) | — | Shows the declared settings pages and applies their effects. | +| `ui` | [`ui`](#host-owned-workbench-ui): text, [web](#web-content) and [native](#host-rendered-native-content) tool windows, commands, and [backend-only commands](#backend-only-commands) | — | Renders every kind of window and command, and serves the web bridge. | +| `buildSystems` | [`buildSystems[]`](#build-systems) without `provider`: detection, options, project model, tasks, run entries, tests, diagnostics, dependencies, sync, and [declarative preparation](#declarative-project-preparation) | — | Detects projects, loads the model, and runs the declared commands. | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `litheBuild` | Starts the provider and speaks `litheBuild` with workspace-owned sessions. | +| `buildModelInputs` | `buildSystems[].modelSharing` and `modelInputs`: [optional read-only model inputs](#optional-read-only-model-inputs) | — | Supplies the model snapshot between enabled workspace plugins. | +| `vcsProviders` | [`vcsProviders[]`](#vcs-providers) | `litheVcs` | Starts the provider and speaks `litheVcs`. | +| `debugAdapters` | [`debugAdapters[]`](#debug-adapters) | — | Starts the adapter and applies its [declared features and defaults](#adapter-features-and-defaults). | +| `outputLinkMatchers` | [`outputLinkMatchers[]`](#output-link-matchers) | — | Turns Run and Test console text into links through the declared patterns. | +| `projectGenerators` | [`projectGenerators[]`](#project-generators), including [`tool` fields](#tool-fields) | — | Lists the entries in the New Project window and generates the project. | +| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `litheFunctions` | Starts the backend with or without a page, serves the host services its permissions grant, and speaks `litheFunctions`. | +| `toolDefinitions` | `toolDefinitions[]`: [tool identities, layouts, discovery and four-layer candidates](toolchains/README.md) | — | Resolves the definitions through the four layers. | +| `toolchainProviders` | `toolchainProviders[]`: [provider declarations and permission-bound answer validation](toolchains/README.md) | toolchain provider protocol (`litheToolchains`) | Starts the provider and validates its answers. | +| `pluginFunctions` | [`pluginFunctions`](#cross-plugin-calls): exported functions and declared calls between plugins | — (the relay methods are part of `litheFunctions`) | Relays calls between plugins. | +| `keymapImportProviders` | `keymapImportProviders[]`: [independent shortcut import providers](#shortcut-import-providers-core-foundation) | `litheKeymapImport` | Starts the provider and imports the shortcuts it reads. | + +### Host capabilities + +Validating a capability is not running it. Core validates every capability it +defines on every platform, but each host implements only some of them, so a +host states what it runs as `hostCapabilities: { "": [versions] }` +in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and +`plugins.marketplace.installReview`: + +- A host declares a capability version when it accepts every manifest form of + that version and speaks its runtime protocol. Features a provider offers on + top of that (tests, run preparation, commands) may go unpresented, and a + plugin must not depend on them being called. Host services a plugin needs + are not declared here: it discovers them at runtime with `getCapabilities` + and its permissions. +- `validate` and `installReview` refuse a manifest whose `requires` names a + version missing from `hostCapabilities` with `plugin.api.unsupported` and + `field: "requires."`. Without `hostCapabilities` they check only + what Core supports. +- `validateIndex` offers a version only when the host and this Core build both + support every entry of its `requires`; without `hostCapabilities` it offers + none. A plugin that uses no build systems is therefore offered on a host + that cannot run them. + +Core validates the versions it supports without a host table, but admission +must use the actual host table; Core's protocol support alone does not enable a +capability on a host. + +### Publishing + +- A released capability version never changes meaning, and hosts keep + supporting it. +- A plugin declares exactly the capabilities it uses, each at the lowest + version that covers what it uses. Raising one without using what it adds only + cuts off older hosts. +- A new capability version is added only for a change a host must not ignore. + Plugins that do not use the change keep their version and stay installable + wherever they were. +- Moving a plugin to a new capability version requires a new plugin version, + and its index entry keeps the last version with the previous requirements so + older hosts still have a version to install. Published packages are signed + and are never rewritten. +- A host release that supports a capability version ships before any plugin + version that declares it. +- The manifest holds only what a host must know before it starts a process: + what to start, with which permissions, for which files and projects. What a + running process can answer is negotiated over its own protocol (see + [Build providers](#build-providers)), never declared in the manifest. + +## Process start + +The host starts every process a plugin declares: a language server, a build +provider with its own process, a VCS provider, a debug adapter and the plugin +backend. They share one set of launch fields, one launch-template language and +one launch plan. All of it belongs to the base contract (`pluginAPIVersion` 0): +no capability is declared to use it. + +| `launch.kind` | Starts | +| --- | --- | +| `packaged` | An executable inside the package: `{path}`. | +| `systemCommand` | A bare command name the host resolves with its tool discovery: `{commands}`. | +| `runtime` | A package file run by a tool-definition's executable, shipped or discovered; see [Runtime launch](#runtime-launch). | +| `backend` | A language server whose command line the plugin backend answers at each start; see [Backend-resolved launch](#backend-resolved-launch). | + +`arguments`, `validationArguments`, `runtimeDependencies`, and +`initializationOptions` mean what the [language server fields](#language-servers) +say. A tool is always named by [tool-definition ID](#tool-references): the host +has no list of runtimes it knows by name. + +### Runtime launch + +`{"kind": "runtime", "tool", "packagedRuntime"?, "minimumVersion"?, +"runtimeArguments"?, "entry"}` starts a file inside the package with a +runtime, so the package needs no launcher script. The runtime is either +shipped by the plugin (`packagedRuntime`) or discovered by the host. + +| Field | Meaning | +| --- | --- | +| `tool` | A tool-definition ID that `requiresTools` lists; the definition decides how the entry is passed (see [Tool references](#tool-references)). | +| `packagedRuntime` | Package-relative path of the runtime executable the plugin ships, for example `runtime/${arch}/bin/node`; `${arch}` is the only variable it may use. The host starts exactly this file: it never looks the tool up on the machine, and no user override or other plugin can replace it. A plugin that needs a specific runtime owns it this way, so the host carries no knowledge of where that runtime lives. It excludes `minimumVersion`. | +| `minimumVersion` | Lowest acceptable version of a runtime the host discovers: one to three dot-separated numbers such as `21` or `18.17`. Only the components it names are compared, so `21` accepts every 21.x. A runtime whose version cannot be read does not qualify. | +| `runtimeArguments` | Launch templates placed before the entry, such as runtime options. | +| `entry` | Package-relative entry file. | -#### Backend-resolved launch (`serverProcess` 3) +The launch tool is already a requirement and must not be repeated in +`runtimeDependencies`. A runtime launch declares no `validationArguments`: the +tool definition checks the version of the executable it resolves, and a +candidate that does not satisfy `minimumVersion` is not used, the same as a +candidate that fails a validation probe. + +### Tool references + +A launch names every runtime by [tool-definition ID](toolchains/README.md), so a +host never learns a tool from a name. The manifest lists every tool it starts a +process with in the top-level `requiresTools`, and the definitions come from +`toolDefinitions` of the plugin itself or of another enabled plugin. + +| Field | Meaning | +| --- | --- | +| `requiresTools` | At most 32 tool-definition IDs, each short (`interpreter`) or plugin-qualified (`org.example.tools:interpreter`), without duplicates; stored sorted. | +| `launch.tool` of a `runtime` launch, `runtimeDependencies[].tool`, `runSelectedRuntime.tool` of a debug adapter | A tool-definition ID that `requiresTools` lists. An ID it does not list is `plugin.runtime.undeclared` with the `field` that names it. | +| `process.executable.tool` of a `lithe/host/run/start` request | A tool-definition ID that `requiresTools` lists. An ID it does not list is refused with `plugin.run.invalidStart` and `field: "process.executable.tool"`. | + +The definition, not the host, decides how its executable starts an entry: its +`launch` template says where `runtimeArguments`, the entry and `arguments` go +(`["${executable}", "${runtimeArguments}", "${entry}", "${arguments}"]` runs +` `, a template without `${entry}` +runs no entry). The launch plan of a process therefore returns the parts, not a +command line: + +| Field | Meaning | +| --- | --- | +| `launch` | `{kind: "tool", tool, entry, runtimeArguments, minimumVersion?, packagedRuntime?}`. `entry` is the absolute path of the package file, `runtimeArguments` are expanded, and `packagedRuntime` is the expanded path of a runtime the plugin ships (the host then uses that file as the tool's executable and never looks the tool up). | +| `arguments` | Only the plugin's own arguments; the host places them where the definition's `${arguments}` is. | +| `runtimeRequirements` | `{kind: "tool", tool, minimumVersion?}` for every tool the host must resolve. | + +The host resolves each tool through the four layers of +[toolchains/README.md](toolchains/README.md), checks `minimumVersion` with +`toolchains.checkVersion`, which checks the version of the executable the +definition resolves, and expands the start with `toolchains.instantiateLaunch`. +A tool that resolves to no executable is `plugin.host.toolUnresolved` +(`details.reason` says why). See `fixtures/run/tool-reference-launch.plugin.json`. + +### Launch templates + +`arguments`, `validationArguments`, `runtimeArguments`, and the string values +of `initializationOptions` may use these variables. A package therefore never +contains a machine path, and the host decides where state lives. + +| Variable | Value | +| --- | --- | +| `${pluginDir}` | Root of the installed, read-only package. It must be used at most once per value and be followed by a `/`-rooted path inside the package; that path may vary only by `${arch}`. | +| `${stateDir}` | Writable directory the host keeps for this plugin's server and removes when the plugin is uninstalled. Not available in `validationArguments`. | +| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`, nor in a [plugin backend](#plugin-backend), which belongs to no workspace. | +| `${logDir}` | Absolute current-session `plugins//files/` directory, explicitly supplied by the host as `logDirectory`. Available for language servers and plugin backends, never in `validationArguments`. The host creates it in writable log storage, outside the installed package; Core does not derive it from package resources. | +| `${arch}` | Host CPU architecture: `arm64` or `x86_64`. | +| `${setting:}` | Current value of a declared [plugin setting](#plugin-settings). 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), +`buildSystemID` (a build provider), `vcsProviderID` (a VCS provider), and +`backend: true` (the [plugin backend](#plugin-backend)), passes `{pluginDirectory, +stateDirectory, workspaceStateDirectory?, logDirectory?, temporaryDirectory?, architecture, +settings?}`, and receives. `workspaceStateDirectory` may be omitted only when the selected +process does not use `${workspaceStateDir}`; otherwise the request is +`plugin.launch.invalidRequest`. The same error applies when a template uses `${logDir}` but `logDirectory` is absent, empty, or not absolute. + +| Field | Meaning | +| --- | --- | +| `owner` | `{pluginID, version}` of the installed package Core validated for this plan. The host starts the server for that exact installation and uses this identity, not one it read elsewhere, when it later relays requests or reports the session. | +| `launch` | `{kind: "packaged", path}`, `{kind: "systemCommand", commands}`, or `{kind: "tool", tool, entry, runtimeArguments, packagedRuntime?, minimumVersion?}`: what the host resolves to an executable. A `runtime` launch returns the tool shape of [Tool references](#tool-references). A [backend-resolved launch](#backend-resolved-launch) returns `{kind: "backend", function, timeoutMilliseconds}` here, and `{kind: "tool", tool}` appears in the plan `plugins.languageServers.decodeLaunch` returns. | +| `arguments` | Argument list after the executable. A tool-shaped launch returns only the plugin's own arguments. | +| `validationArguments` | Expanded probe arguments. | +| `runtimeRequirements` | `{kind: "tool", tool, minimumVersion?}` for every tool the host must resolve through [toolchains](toolchains/README.md), a launch tool included, sorted by tool. A packaged runtime is not listed. Only the launch tool can carry a minimum version. | +| `initializationOptions` | Expanded options; absent when the server declares none. | +| `requiredDirectories` | `state`, `workspaceState`, `log`, and/or `temporary`: the directories the host must create before starting the server. | +| `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. | +| `resolveParams` | Present only for a backend-resolved launch: the params of the launch function, without the `context` and the optional `tools` the host adds. | +| `environment`, `writablePaths` | Present only in the plan `decodeLaunch` returns: the variables of the process, and the paths it writes. | +| `sessionPolicy` | Present for a language server that declares session fields: the [session policy](#plugin-owned-sessions) the host passes to `lsp.startServer` unchanged. | +| `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/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | +| `backend` | Present only for a `backend` selection: `{hostServices, version}`, the sorted [host services](#host-services) the plugin's permissions grant, plus `version`, the manifest's `requires.backend` (`0`). The host passes `hostServices` to `lsp.startServer.hostServices` unchanged. | + +A language without a server, an undeclared build system, a build system +without a provider, an undeclared VCS provider, and a plugin without a +backend are `plugin.launch.unavailable`. Core never creates or +reads any of these paths. + +### Backend-resolved launch A server whose command line depends on what the plugin finds when it starts (a version it resolves, a tool it bootstraps, options it derives from the @@ -685,12 +954,9 @@ declaring one: "launch": {"kind": "backend", "function": "launch.resolve", "timeoutMilliseconds": 30000} ``` -- Only a language server can use it. `requires.serverProcess` is `3`, the - lowest version covering it, or `4` when the manifest also lists - `requiresTools`; the plugin declares a `backend` of - version 2 or later, and `pluginAPIVersion` is at least 4. `function` is a - plugin function ID. `timeoutMilliseconds` is 1000 to 120000 and defaults to - 30000. +- Only a language server can use it. The plugin declares a `backend`. + `function` is a plugin function ID. `timeoutMilliseconds` is 1000 to 120000 + and defaults to 30000. - The server declares none of `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions`, or `hostIntegration`: the answer carries the first four, and a host integration is host code, which @@ -721,298 +987,137 @@ declaring one: - The host passes `{manifest, languageID, request, result}` to `plugins.languageServers.decodeLaunch`, which returns `{status: "ok", plan}` or a rejection. The plan is an ordinary launch plan (`launch` is `packaged` - or `tool`) whose `sessionPolicy` also carries the answer's `gates`; the host - starts it like any other. A server that is not launched by the backend is - `plugin.launch.notBackend`, so a host never runs an answer it did not ask - for. - -`resolveParams`: - -| Field | Meaning | -| --- | --- | -| `serverID` | The server's `languageID`; also its ID in the `languageServer` host methods. | -| `platform` | `{os, arch}`: the host operating system and CPU architecture (`arm64` or `x86_64`). | -| `stateDirectory`, `workspaceStateDirectory`, `logDirectory`, `temporaryDirectory` | The writable directories of this start, absolute and outside the package and the application bundle. | -| `settings` | The effective value of every declared plugin setting. | - -The answer is an object with these fields; an unknown field is refused. - -| Field | Meaning | -| --- | --- | -| `executable` | `{kind: "packaged", path}` is a file inside the plugin package, read-only, listed in the plan's `requiredPackagePaths`. `{kind: "tool", tool}` names a tool definition of the plugin; the host resolves it like any tool launch. | -| `arguments` | At most 1024 strings of at most 32 KiB, without NUL. | -| `environment` | At most 256 variables; a name is non-empty without `=`, a value is at most 32 KiB. | -| `initializationOptions`, `workspaceConfiguration` | JSON objects of at most 1 MiB each. | -| `gates` | At most 8; see [Plugin-owned sessions](#plugin-owned-sessions-languageservers-3). | -| `writablePaths` | At most 32 paths the server writes. Each lies inside one of the four directories; `.` and `..` components are refused. The plan carries them sorted, and the host creates their parent directories before the start. | - -Rejections: `plugin.launch.invalidResult` for a malformed or oversized answer -or gate, `plugin.launch.outsidePackage` for a packaged path that leaves the -package, `plugin.launch.undeclaredTool` for a tool the plugin does not -define, `plugin.launch.unwritablePath` for a writable path outside the host -directories, `plugin.launch.invalidRequest` for a request without a usable -directory, `plugin.launch.unavailable` for a language without a server, and -`plugin.launch.notBackend`. See `fixtures/languages/language-server-backend-launch.plugin.json` -and `fixtures/languages/language-server-launch-resolution.json`. - -Host limits of the macOS host today: a `{kind: "tool"}` answer is resolved -with the user's tool choice and discovery, and a program that lies inside the -project is refused, because opening a project must not run it; a server whose -launch the backend resolves does not start in a remote workspace; and the -scratch `temporaryDirectory` is removed when the server process ends. - -#### Plugin-owned sessions (`languageServers` 3) - -A plugin backend drives its own server only through what the server -declares. With `requires.languageServers: 3` a server may declare: - -| Field | Meaning | -| --- | --- | -| `requestMethods` | LSP methods the backend may send with `languageServer/request` or `languageServer/notify`; at most 64. | -| `commands` | The `workspace/executeCommand` commands the backend may run; at most 128. Declared exactly when `requestMethods` lists `workspace/executeCommand`. | -| `serializedCommands` | A subset of `commands` that runs one at a time: a request for a command already running joins it and receives the same answer. | -| `forwardNotifications` | Server notifications the host forwards to the backend; at most 32. | -| `state` | `{scope: "workspace", retention: {maxEntries, maxAgeDays}}`. The host prunes the server's per-workspace state directories (see below). `maxEntries` is 1 to 64, `maxAgeDays` 1 to 365. | -| `remote` | `supported` (default) or `unsupported`. | - -- A name is non-empty text of at most 256 bytes without whitespace or control - characters, unique within its list. Names the host sends itself - (`initialize`, `initialized`, `shutdown`, `exit`, `textDocument/didOpen`, - `didChange`, `didClose`, `didSave`, `willSave`, `willSaveWaitUntil`, - `workspace/didChangeWorkspaceFolders`, and every `$/` method) cannot appear - in `requestMethods` or `forwardNotifications`. -- Below `languageServers` 3 each of these fields is `plugin.manifest.invalid`. - A server that lists `requestMethods` or `forwardNotifications` requires the - `languageServer.request` permission; declaring the permission without such a - server is `plugin.manifest.invalid`. -- The launch plan's `sessionPolicy` is these fields as sorted lists plus - `serverID` (the `languageID`) and `gates`. The host sends it unchanged as - `lsp.startServer.sessionPolicy`; Core's LSP engine enforces it, so a host - cannot widen it and a backend cannot step outside it. -- A server with `remote: "unsupported"` is not started for a remote - workspace: `lsp.startServer` answers `not_supported`. - -What the engine enforces on `lsp.pluginServerRequest` and -`lsp.pluginServerNotify`, the two commands behind the host methods: - -- A request must be in `requestMethods`, and a `workspace/executeCommand` - must carry a `params.command` in `commands`; anything else is - `plugin.host.methodNotDeclared`. A notification may be - `workspace/didChangeConfiguration` (the backend always knows the server's - settings; the value also becomes the settings the server reads through - `workspace/configuration`) or a `requestMethods` entry other than - `workspace/executeCommand`. The host's own methods stay refused. -- The session must belong to the calling plugin - (`plugin.host.foreignContribution` otherwise) and be running - (`plugin.host.languageServerUnavailable` otherwise, including every request - still pending when the process stops, crashes, restarts, or the plugin is - disabled). -- A request waits at most `timeoutMilliseconds` (default 60000, at most - 300000) and ends with `plugin.host.timeout`; cancelling it sends the server - `$/cancelRequest` and ends it with `plugin.host.cancelled`. A server's JSON-RPC - error is `plugin.host.languageServerError` with `details: {code, message}`; - the server's `data` is not forwarded. A result over the host's result bound - is `plugin.host.limitExceeded`. -- A **gate** is `{id, until: "notification", method, match}`, declared by the - launch answer. It opens when the server sends a notification with that - `method` whose params contain every member of `match` (nested objects match - the same way). Until every gate has opened, `request` calls wait in order, - at most 128; the 129th is `plugin.host.busy`. A waiting request still obeys - its timeout and cancellation. Notifications are never held. -- The first request for a `serializedCommands` command runs; a later request - for the same command, while the first is running or waiting, joins it and is - answered with the first one's outcome. Cancelling the first request ends the - joined requests with it. -- `forwardNotifications` reach the backend as - `lithe/host/languageServer/didReceiveNotification`. - `workspace/didChangeConfiguration` is the only notification Core consumes - itself. - -**State and retention.** The host keeps one state directory per workspace for -each server, under the plugin's storage (never under the package or application -bundle), and gives the server its own as `workspaceStateDirectory`. The -directories survive disabling and updating the plugin. The plugin chooses the -sub-directory names inside its `workspaceStateDirectory`; the host chooses the -names of the per-workspace directories themselves and never looks inside them. - -The host refreshes a workspace directory's modification time on every start of -the server and sweeps in the background after a start. It asks -`plugins.languageServers.stateRetention` with `{retention, nowUnixSeconds, -entries: [{name, lastModifiedUnixSeconds}], activeNames}`, where `entries` are -the per-workspace directories it found and `activeNames` are those of open -workspaces, and deletes the `expiredNames` it returns: a directory older than -`maxAgeDays` and, of the rest, all but the newest `maxEntries` that open -workspaces leave free. A workspace that is open is never pruned. Names that are -not a single path component are ignored, and the host deletes only names it -listed. A plugin that declares no `state.retention` keeps everything. - -The host can also discard the state of one workspace; the server rebuilds it -the next time it starts. In this version that is an engine path only, with no -user-facing action yet. - -**Preparation status.** `languageServer/setStatus` reports how ready the -server is. It exists because `lithe/function/progress` belongs to a call that -is running, while preparation (importing a build, indexing) outlives any call. - -Example package: `fixtures/languages/language-server-backend-launch.plugin.json`. - -### Execution providers - -`run` and `test` each name an `executable`: exactly one of a bare `command` -resolved on the host's tool path, or a host-owned project `toolchain` ID such -as `project-go`. The working directory is always the workspace root. - -`test` also declares: - -| Field | Meaning | -| --- | --- | -| `frameworkID` | Stable framework label such as `phpunit` or `go`. | -| `workspaceLabel` | Label of the "all tests" item and plan. | -| `projectMarkers` | File names, any one of which makes the workspace a test project. | -| `testFilePatterns` | Case-insensitive file-name patterns using only `*`. | -| `workspaceArguments`, `fileArguments`, `testCaseArguments` | Argument templates per scope. | - -Argument templates are argv elements, never shell text. Each element is a -string or `{"value": …, "when": "fileSelected" | "noFileSelected"}`; -conditional elements are only allowed in `testCaseArguments`, where a file is -optional. - -| Variable | Available in | Value | -| --- | --- | --- | -| `${file}` | run, file, test case | Workspace-relative file. A name starting with `-` becomes `./`. | -| `${fileDirectory}` | run, file, test case | `./` of the file, or `.` at the workspace root. | -| `${testName}` | test case | Test identifier with surrounding whitespace removed. | -| `${testName:regex}` | test case | The identifier with regular-expression metacharacters escaped. | -| `${userArguments}` | run | The user's program arguments; must be a whole element and may expand to zero elements. | + or `tool`) whose `sessionPolicy` also carries the answer's `gates`; the host + starts it like any other. A server that is not launched by the backend is + `plugin.launch.notBackend`, so a host never runs an answer it did not ask + for. -In `testCaseArguments`, an element that uses `${file}` or `${fileDirectory}` -must be guarded with `"when": "fileSelected"`, and some element must use the -test name. +`resolveParams`: -Discovery returns `{id: ":workspace", kind: "workspace"}` followed by -`{id: ":file:", kind: "file", relativeFilePath}` for matching -files sorted by path, or an empty list when no project marker is present. +| Field | Meaning | +| --- | --- | +| `serverID` | The server's `languageID`; also its ID in the `languageServer` host methods. | +| `platform` | `{os, arch}`: the host operating system and CPU architecture (`arm64` or `x86_64`). | +| `stateDirectory`, `workspaceStateDirectory`, `logDirectory`, `temporaryDirectory` | The writable directories of this start, absolute and outside the package and the application bundle. | +| `settings` | The effective value of every declared plugin setting. | -## API levels +The answer is an object with these fields; an unknown field is refused. -| Level | Adds | +| Field | Meaning | | --- | --- | -| `1` | Languages, `packaged` and `systemCommand` language servers, `node` and `go` runtime dependencies and [implicit tool-definition references](#tool-references-serverprocess-4), run and test providers. | -| `2` | `runtime` launches with a packaged or discovered runtime, host integrations, launch templates, `initializationOptions`, and plugin settings. | -| `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). | -| `4` | [`debugAdapters`](#api-level-4-debug-adapters) and `serverProcess` 2 (`runSelectedRuntime`). | +| `executable` | `{kind: "packaged", path}` is a file inside the plugin package, read-only, listed in the plan's `requiredPackagePaths`. `{kind: "tool", tool}` names a tool definition of the plugin; the host resolves it like any tool launch. | +| `arguments` | At most 1024 strings of at most 32 KiB, without NUL. | +| `environment` | At most 256 variables; a name is non-empty without `=`, a value is at most 32 KiB. | +| `initializationOptions`, `workspaceConfiguration` | JSON objects of at most 1 MiB each. | +| `gates` | At most 8; see [Plugin-owned sessions](#plugin-owned-sessions). | +| `writablePaths` | At most 32 paths the server writes. Each lies inside one of the four directories; `.` and `..` components are refused. The plan carries them sorted, and the host creates their parent directories before the start. | + +Rejections: `plugin.launch.invalidResult` for a malformed or oversized answer +or gate, `plugin.launch.outsidePackage` for a packaged path that leaves the +package, `plugin.launch.undeclaredTool` for a tool the plugin does not +define, `plugin.launch.unwritablePath` for a writable path outside the host +directories, `plugin.launch.invalidRequest` for a request without a usable +directory, `plugin.launch.unavailable` for a language without a server, and +`plugin.launch.notBackend`. See `fixtures/languages/language-server-backend-launch.plugin.json` +and `fixtures/languages/language-server-launch-resolution.json`. -A level 2 feature in a level 1 manifest is refused with -`plugin.manifest.invalid`, -so a package cannot use a feature that hosts of its declared level would -reject for a different reason. Level 1 language-server arguments stay literal -at every host level. +Host limits of the macOS host today: a `{kind: "tool"}` answer is resolved +with the user's tool choice and discovery, and a program that lies inside the +project is refused, because opening a project must not run it; a server whose +launch the backend resolves does not start in a remote workspace; and the +scratch `temporaryDirectory` is removed when the server process ends. -### Capability versions (level 3) +## Executable resolution -Levels 1 and 2 version the whole manifest: a change to one section raised the -level of every plugin, and hid plugins that never use that section from older -hosts. Level 3 versions each section on its own. A level 3 manifest lists in -`requires` exactly the capabilities it uses: +Declare executable sources in `invocation.executables`, in preference order. +Array order is the recommended form; no custom path setting is required for a +plugin that only needs automatic discovery. The source kinds are +`projectFile`, `environment`, `command`, `runtime`, `setting` and `toolchain`. +Executable resolution belongs to the base contract: no capability is declared +to use it. `setting` and the ordering metadata are described below: ```json -"pluginAPIVersion": 3, -"requires": { "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 } +"executables": [ + { "id": "selected", "kind": "setting", "settingID": "toolPath" }, + { "id": "wrapper", "kind": "projectFile", "path": "mvnw" }, + { "id": "system", "kind": "command", "command": "mvn" } +] ``` -| Capability | Covers | Versions | -| --- | --- | --- | -| `languages` | `languages[]` | `1`, `3` (adds a TextMate [`grammar` and `configuration`](#grammars-and-editor-configuration-languages-3)); `2`, a Monarch grammar, is withdrawn | -| `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | `1` | -| `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)), `3` (adds the [session fields](#plugin-owned-sessions-languageservers-3)) | -| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1`, `2` (adds `runSelectedRuntime` for [debug adapters](#api-level-4-debug-adapters); level 4; v2 Core only), `3` (adds the [backend-resolved launch](#backend-resolved-launch-serverprocess-3) of a language server), `4` (names runtimes by [tool-definition ID](#tool-references-serverprocess-4)). Versions are cumulative: a manifest declares the lowest one covering every launch it uses. macOS advertises `1`, `3`, and `4`, Windows `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` (web content), `3` (native controls), `4` ([backend-only commands](#backend-only-commands-ui-4)) | -| `buildSystems` | Declarative build detection, models, and command plans; v2 executable setting adapter; v3 instance-scoped options; v4 declarative preparation roles | `1`, `2`, `3`, `4` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs, v7 native configuration, v8 inline test reports, v9 opaque semantic test selectors, v10 typed run options and service/task preparation, v11 registry connection debugging | `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11` | -| `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | -| `vcsProviders` | `vcsProviders[]` | `1` | -| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment; v3 adds [declared features and defaults](#adapter-features-and-defaults-debugadapters-3) | `1`, `2`, `3` | -| `outputLinkMatchers` | [`outputLinkMatchers[]`](#output-link-matchers-outputlinkmatchers-1): patterns that turn Run and Test console text into links | `1` | -| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window; v2 adds the [`tool` field](#tool-fields) | `1`, `2` | -| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | -| `toolDefinitions` | [Tool identities, layouts, discovery and four-layer candidates](toolchains/README.md) | `1` (Core; macOS settings pages, Build and run tools) | -| `toolchainProviders` | [Provider declarations and permission-bound answer validation](toolchains/README.md) | `1` (Core; macOS settings pages, Build and run tools) | -| `pluginFunctions` | [`pluginFunctions`](#cross-plugin-calls-protocol-version-4): exported functions and declared calls between plugins | `1` (Core; macOS relays calls) | -| `executableResolution` | [Shared executable candidates](#shared-executable-candidates-executableresolution-12): candidate ordering and strict selection; v2 adds tool-definition candidates and canonical project/subproject toolchain registry bindings without changing existing Build candidate sets | `1`, `2` (v2 Core; macOS settings pages, Build and run tools) | -| `keymapImportProviders` | `keymapImportProviders[]`: independent shortcut import providers | `1` (macOS; Windows does not advertise it yet) | - -Version 1 of every capability means what its section means at level 2 -(runtime launches, launch templates, `initializationOptions`, and so on), so -moving a level 2 manifest to level 3 only adds `requires`. - -- `requires` is required at level 3 and refused below it - (`plugin.manifest.invalid`). IDs are lowerCamelCase identifiers of at most - 64 characters; versions are positive integers. -- Core reads `requires` before it decodes any section. A capability or - version this Core build does not support is `plugin.api.unsupported` with - `field: "requires."`, even when the manifest also holds a - section Core does not know, so the user learns which capability needs a - newer Lithe instead of seeing an unknown field. -- `requires` lists exactly the capabilities the manifest uses. Leaving out a - used one, or listing an unused one, is `plugin.manifest.invalid`. Every - language server, and every build provider with its own process, uses - `serverProcess`; a shared build provider does not add another process. Any - `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. Every VCS provider starts its own - process, so `vcsProviders` always comes with `serverProcess`, and so does - `backend`. -- The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, - `permissions`, `activationEvents`, and `requires` itself) change only with a - new `pluginAPIVersion`. +A `setting` references a declared **global file** setting with `effect: "reload"` +or `"nextRun"`. Core receives raw `request.settings`. An absent +value uses the declared default; an explicit empty string emits a setting candidate +with `path: null`, which skips. A nonempty value must satisfy the setting rules, +be absolute, and have no surrounding whitespace or controls. Invalid stored +values reject the invocation instead of invoking the normal settings display +resolver's fallback-to-default behavior. Project overrides never select tools. +The host checks file permissions and versions: a configured failure is terminal, +including a failure to probe, and must not fall back to subsequent candidates or +a Provider's own lookup. Paths with spaces remain single paths. The selected +setting's directory precedes PATH. Runtime candidates construct their environment +before version probing and reuse it for execution; a rejected candidate's +environment does not leak into the next choice. Provider hints and task/test +execution use this same plan/selection mechanism. If resolution fails before a +configured candidate is reached (for example, an earlier Runtime is missing), +any nonempty setting in the tool or Runtime candidate sets still forbids the +Provider's independent lookup. All-empty settings retain automatic-discovery +fallback. This check applies after failure, so an earlier successful candidate +is unaffected by a later unused setting. -Validating a capability is not running it. Core validates every capability it -supports on every platform, but each host implements only some of them, so a -host states what it runs as `hostCapabilities: { "": [versions] }` -in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and -`plugins.marketplace.installReview`: +Optional metadata applies within **one candidate set owned by one plugin**: -- `validate` and `installReview` refuse a level 3 manifest whose `requires` - names a version missing from `hostCapabilities` with - `plugin.api.unsupported` and `field: "requires."`. Without - `hostCapabilities` they check only what Core supports. -- `validateIndex` offers a level 3 version only when the host and this Core - build both support every entry of its `requires`; without - `hostCapabilities` it offers none. A plugin that uses no build systems is - therefore offered on a host that cannot run them. -- `hostCapabilities` has no effect on levels 1 and 2. - -The macOS host sends its capability table with validation, marketplace filtering -and install review. It advertises `buildSystems: [1, 2, 3, 4]` and -`buildProviders: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]`; native Provider models, dependencies, tasks, tests and launch preparation -share workspace-owned sessions. Backend and VCS support use their own entries -in the same table. Core validates supported versions without a host table, -but admission must use the actual host table. Windows does not yet advertise -Build Provider orchestration; Core protocol support alone does not enable it. - -Rules for publishing across levels and capability versions: - -- Levels and capability versions only add. A released version never changes - meaning, and hosts keep supporting older versions. -- A plugin declares the lowest level, and for each capability the lowest - version, it needs. Raising one without using what it adds only cuts off - older hosts. -- A new capability version is added only for a change an older host must not - ignore. Plugins that do not use the change keep the older version and stay - installable wherever they were. -- Raising a plugin's level or a capability version requires a new plugin - version, and its index entry keeps the last version with the previous - requirements so older hosts still have a version to install. Published - packages are signed and are never rewritten. -- A host release that supports a level or capability version ships before any - plugin version that declares it. -- The manifest holds only what a host must know before it starts a process: - what to start, with which permissions, for which files and projects. What a - running process can answer is negotiated over its own protocol (see - [Build providers](#build-providers)), never declared in the manifest. +- `id` is a unique stable identifier; anonymous candidates cannot be anchors. +- `priority` is a signed 32-bit integer; higher wins, absent means 0. Ties retain + original array order. +- `insertBefore` or `insertAfter` names an ID in this set. One candidate cannot + combine either with `priority`, or declare both insertion fields. Constraints + mean relative order, not immediate adjacency. +- Normalize the complete set into a directed graph before platform filtering. + Repeatedly select a zero-indegree node by descending priority, then original + index; emit it and remove its edges. Explicit edges take precedence over + priority. Duplicate/invalid IDs, unknown anchors, cycles/self-edges and + conflicting metadata are manifest errors. Filter platforms only after sorting, + so anchors remain valid even if they do not apply to the current platform. + +`plugins::executables` owns the reusable source types and deterministic order; +`ExecutableCandidateResolver` receives file-system, Setting (already validated by +Core), Environment, Runtime and command-discovery capabilities from the host. +Results distinguish `skip`, `resolved` and `terminalFailure`; plugins declare data +and cannot inject host native code. Each operation uses a value snapshot for its +settings/environment and ordered plan. Passive import does not resolve/probe +runtimes or execute project wrappers. A project candidate remains project code +and is only executed through the consumer's existing trust/user-action workflow. + +Executable candidates are used by build systems and their custom runtime +dependencies. Own build providers adapt command/runtime launches through +`BuildToolResolver` and receive the selected build tool as `tool.executable`; +their process protocol is unchanged. Language servers, execution providers, +and other launch forms name a [tool definition](#tool-references) instead of +declaring a candidate list. Any launch form adapted to this resolver must +preserve the package-realpath/executable checks, per-operation consent (there +is no workspace trust state yet), auto-start restrictions, runtime ownership +and cancellation. + +See [`build-tool-path.plugin.json`](fixtures/build-systems/build-tool-path.plugin.json). + +A candidate may use +`{ "kind": "toolchain", "toolchainID": "org.example.compiler:compiler" }`. +Core accepts the same short/qualified ID grammar as tool definitions and adds the +normalized instance `root`. The macOS adapter resolves installed definitions +against fresh native evidence and directory pins. Unavailable selections are terminal, +including when a prerequisite runtime fails first; they cannot silently fall +back to Provider discovery. +A host-managed `node` or `go` dependency may select one such candidate, +while retaining Core's version probe. Any other dependency that declares no +`executables` is already this candidate for the definition with its own ID, and +that definition checks `minimumVersion`. Maven declares its namespace-owned tool +and uses the short `java` definition for its Java prerequisite; no Maven-specific +JDK registry entry is added. Its backend bootstrap remains a separate consumer. +Passive import performs no version or runtime probes; a definition that requires +verification is not reported as an already verified import executable. +A toolchain that resolves to a project file remains project code, +including through a symlink, and native configuration requires explicit Read +before running it. See [`build-toolchains.plugin.json`](fixtures/build-systems/build-toolchains.plugin.json). ## Permissions and activation @@ -1027,19 +1132,19 @@ Rules for publishing across levels and capability versions: | `debug.attach` | an adapter lists `attach` in `requests` (and only then); it also covers attaching to a remote host and port | | `project.generate` | `projectGenerators` is not empty | | `ui.contribute` | `ui` is declared | -| `ui.web` | a tool window shows [web content](#web-content-ui-2) | +| `ui.web` | a tool window shows [web content](#web-content) | | `backend.launch` | `backend` is declared | | `keymaps.provide` | `keymapImportProviders` is nonempty; permits offering mappings, not reading files by itself | | `keymaps.readSelectedSources` | `keymapImportProviders` is nonempty; permits reading only declared files in an explicitly selected import source | -| `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | -| `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | -| `editor.actions` | the backend registers independent editor actions; requires `backend` 2 and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | -| `editor.providers` | the backend supplies [editor language features](#editor-language-feature-providers-protocol-version-3) for matching files; requires `backend` 2 and `documents.read`, grants only `editor/registerProviders`, not document edits | -| `terminal.create` | the backend creates and manages its own [native terminals](#plugin-owned-native-terminals-function-protocol-3), including bounded display history for reopen; requires `backend` 2 | -| `run.start` | the backend starts and stops host-owned [Run sessions](#run-and-debug-sessions-protocol-version-3); grants `lithe/host/run/start`, `lithe/host/run/stop` and the `lithe/host/run/didExit` notification; requires `backend` 2 | -| `debug.start` | the backend starts a [debug session](#run-and-debug-sessions-protocol-version-3) on one of its own adapters; grants only `lithe/host/debug/start`; requires `backend` 2 and a declared `debugAdapters` entry | -| `languageServer.request` | a `languageServers` 3 server lists `requestMethods` or `forwardNotifications`, and only then; requires `backend` 2. Grants the [`languageServer` host methods](#language-server-host-methods) for the plugin's own servers and their two notifications, nothing else | -| `documents.contentProvider` | the backend registers a [content provider](#content-providers); requires `backend` 2. Grants `documents/registerContentProvider` only, not reading or editing documents | +| `credentials.store` | the backend uses the [credentials host service](#host-services); refused without a `backend` | +| `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery); refused without `backend` | +| `editor.actions` | the backend registers independent editor actions; requires `backend` and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | +| `editor.providers` | the backend supplies [editor language features](#editor-language-feature-providers) for matching files; requires `backend` and `documents.read`, grants only `editor/registerProviders`, not document edits | +| `terminal.create` | the backend creates and manages its own [native terminals](#plugin-owned-native-terminals), including bounded display history for reopen; requires `backend` | +| `run.start` | the backend starts and stops host-owned [Run sessions](#run-and-debug-sessions); grants `lithe/host/run/start`, `lithe/host/run/stop` and the `lithe/host/run/didExit` notification; requires `backend` | +| `debug.start` | the backend starts a [debug session](#run-and-debug-sessions) on one of its own adapters; grants only `lithe/host/debug/start`; requires `backend` and a declared `debugAdapters` entry | +| `languageServer.request` | a language server lists `requestMethods` or `forwardNotifications`, and only then; requires `backend`. Grants the [`languageServer` host methods](#language-server-host-methods) for the plugin's own servers and their two notifications, nothing else | +| `documents.contentProvider` | the backend registers a [content provider](#content-providers); requires `backend`. Grants `documents/registerContentProvider` only, not reading or editing documents | | Activation event | Meaning | | --- | --- | @@ -1048,8 +1153,8 @@ Rules for publishing across levels and capability versions: | `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. | -| `onDebug:` | Start the debug adapter when a debug session needs it (API level 4). | -| `onWorkspaceOpen` | Start the [`backend` 2](#backend-2-workspace-contexts) process once a workspace is open. Optional, names no ID, refused without `backend` 2. | +| `onDebug:` | Start the debug adapter when a debug session needs it. | +| `onWorkspaceOpen` | Start the [`backend`](#backend-lifecycle-and-workspace-contexts) process once a workspace is open. Optional, names no ID, refused without `backend`. | Each provider needs its event and each event needs a provider; the only optional event is `onWorkspaceOpen`. There is no event that starts a plugin @@ -1070,12 +1175,11 @@ plugin only permits activation; nothing starts until its event occurs. against the index checksum, or from a user-selected local ZIP or unpacked directory after taking a private snapshot. Both paths require the user to confirm the install review. IDs in the official namespace (`dev.lithe`, - `dev.lithe.*`) may not use it (`plugin.manifest.invalid`). Hosts that predate - this value refuse such packages as invalid manifests, which is intended. + `dev.lithe.*`) may not use it (`plugin.manifest.invalid`). A signature document, when present, must still be verified; community trust cannot silently ignore an invalid signature. -API level 3 packages can declare `requires.packageTrust: 1` and +A package can declare `requires.packageTrust: 0` and `vendor.bundledSignatureRequirement: "sameTeamAsHost"`, together with `vendor.signatureRequirement: "publisherPackage"`. Only official-namespace IDs may use this declaration. Both the primary and bundled policies must match the @@ -1084,9 +1188,9 @@ inside the host's sealed installation and whose origin is `bundled` may omit the publisher signature. Every independently installed, imported or rolled-back copy still needs the publisher signature. A present signature is always checked, even inside the host seal. The optional declaration alone grants no trust. -Hosts without `packageTrust: 1` filter this version before download and reject -its manifest; no new application-version field is needed. An older manifest -without the declaration keeps its existing policy. The valid example is +A host that does not declare `packageTrust: 0` in `hostCapabilities` filters this +version before download and rejects its manifest. A manifest without the +declaration gets no bundled exemption. The valid example is `fixtures/package-trust/publisher-bundled.plugin.json`. A host pins the signature requirement of its official plugin IDs so a package @@ -1138,7 +1242,7 @@ every product shows the same catalog and the same install review. | `homepage` | Optional https URL. | | `tags` | Optional single words, lowercased and sorted by Core. | | `icon` | Optional `raw.githubusercontent.com` file of the same repository. | -| `versions[]` | `version`, required `pluginAPIVersion`, `requires` (level 3 only, and required there: the package manifest's `requires`; capability IDs this contract does not define are allowed and make the version unavailable), `releaseDate` (`YYYY-MM-DD`), and `downloads[]`. | +| `versions[]` | `version`, required `pluginAPIVersion`, required `requires` (the package manifest's `requires`; a capability or capability version this contract does not define is allowed and makes the version unavailable), `releaseDate` (`YYYY-MM-DD`), and `downloads[]`. | | `downloads[]` | `platforms` (for example `darwin-arm64`, `darwin-x64`, `windows-x64`, `windows-arm64`), `downloadURL`, and the archive's lowercase hex `sha256`. A platform may appear in at most one download of a version. | A `downloadURL` must be a `.zip` GitHub Release asset of the entry's own @@ -1150,7 +1254,7 @@ plugin at another project's release. `plugins.marketplace.validateIndex` accepts `{ "index": object, "sourceKind": "official" | "thirdParty", "platform": string, "hostCapabilities"?: object }`. -A malformed index root or an unsupported `schemaVersion` rejects the whole +A malformed index root or a `schemaVersion` other than `0` rejects the whole index. Otherwise it returns `{ "status": "ok", "index": { "plugins", "rejected" } }`: - `rejected` lists refused entries in index order with their `error`. @@ -1158,17 +1262,16 @@ index. Otherwise it returns `{ "status": "ok", "index": { "plugins", "rejected" `plugin.index.officialNamespace`; a repeated ID keeps the first listing and refuses later ones with `plugin.index.duplicate`. - `plugins` is sorted by ID. Each accepted entry is normalized, lists - `versions` newest first (each with its `requires` at level 3), reports + `versions` newest first (each with its `requires`), reports `platforms`, `latestVersion`, and `latestReleaseDate` of the newest version, and an `availability`: - `{status: "available", version, pluginAPIVersion, releaseDate, downloadURL, sha256}` for the newest version with this platform's archive that the host can - run: level 1, level 2 on a platform with the level 2 UI host (macOS), or - level 3 with every capability of its `requires` hosted (see - [Capability versions](#capability-versions-level-3)); + run: `pluginAPIVersion` `0` and every capability of its `requires` hosted + at the version it names (see [Versioning](#versioning)); - `{status: "unsupported", code: "plugin.index.apiUnsupported", platform, message}` - when the platform is listed only by versions needing a newer plugin API - or a capability the host does not run; + when the platform is listed only by versions that need a capability, a + capability version or a `pluginAPIVersion` the host does not run; - `{status: "unsupported", code: "plugin.index.platformUnsupported", platform, message}` when no version lists the platform. Hosts show "not supported on " instead of an install action. @@ -1192,7 +1295,7 @@ index. Otherwise it returns `{ "status": "ok", "index": { "plugins", "rejected" `{ "manifest": object, "expectedID"?, "expectedVersion"?, "approvedPrograms"?: [object], "hostCapabilities"?: object }` and returns `{ "status": "ok", "review": { id, version, permissions, programs, newPrograms } }`. A package whose ID or version differs from the index entry is refused with -`plugin.package.mismatch`, and a level 3 package that requires a capability +`plugin.package.mismatch`, and a package that requires a capability version missing from `hostCapabilities` with `plugin.api.unsupported`. `programs` lists everything the host may start for the plugin, sorted by `kind`, language, and tool: @@ -1307,7 +1410,7 @@ therefore keeps Windows `partial` and macOS runtime verification `pending`; static checks and Node resource tests alone do not verify the UI confirmation, complete program rendering, cleanup, or bundle hashes in a real workflow. -## API level 3: build systems +## Build systems `buildSystems` describes a project-level build tool (Cargo, Go, Gradle, Maven, …) as data. Every build tool gets the same model and the same @@ -1324,7 +1427,7 @@ plugins. `build-provider` and `build-provider-language-server` show the two forms of a build provider, and `fixtures/build-systems/build-plans.json` is the golden list of plans and decoded outputs every host gets from them. -A build system needs `pluginAPIVersion` 3 with `buildSystems` in `requires`, +A build system needs `buildSystems: 0` in `requires`, the `process.build` permission, and the activation event `onBuildSystem:`. IDs of build systems, tasks, and run entries start with a lowercase letter and use lowercase letters, @@ -1336,7 +1439,7 @@ declaration are relative, use `/`, and stay inside their base directory | --- | --- | | `detection` | `markers` are file names; a directory holding one is a candidate root. `nesting: "outermost"` folds deeper candidates into the outer instance (Gradle builds, Maven reactors); `"separate"` (default) keeps each (Cargo packages). `priority` (0–100, default 50) decides between plugins declaring the same ID; ties go to the smaller plugin ID. | | `invocation` | `executables` in preference order: `projectFile` (a wrapper inside the root, with `requires` files; always runs project code), `environment` (a path below a variable listed in `environmentInputs`), `command` (a bare name found on `PATH`), `runtime` (a declared runtime dependency itself). `platforms` limits a candidate. `globalArguments` precede every command. `version` probes the executable with literal `arguments` and a `pattern` whose first group is the version; `minimumVersion` needs a probe. | -| `runtimeDependencies` | `node` and `go` are host-managed: only `id` and `minimumVersion`, discovered with the user's runtime settings; `build.invocationPlan` returns the probe arguments Core reads their version with, so hosts keep no per-runtime probe. Any other runtime without `executables` names the [tool definition](toolchains/README.md) with the same ID, so its `id` must be a valid tool-definition ID and it declares only `minimumVersion`: `build.invocationPlan` returns it with `hostManaged: false`, `version: null`, and the single candidate `{ "kind": "toolchain", "toolchainID": , "root": }`, whose resolution checks `minimumVersion`, and `build.checkToolVersion` for it is `plugin.build.unavailable` because Core reads no version. This needs no `executableResolution` 2. Any other runtime describes its own `executables` and `version` probe, so the runtime list is open; a runtime whose only candidate is a `toolchain` candidate may omit the probe, since the definition checks the version. | +| `runtimeDependencies` | `node` and `go` are host-managed: only `id` and `minimumVersion`, discovered with the user's runtime settings; `build.invocationPlan` returns the probe arguments Core reads their version with, so hosts keep no per-runtime probe. Any other runtime without `executables` names the [tool definition](toolchains/README.md) with the same ID, so its `id` must be a valid tool-definition ID and it declares only `minimumVersion`: `build.invocationPlan` returns it with `hostManaged: false`, `version: null`, and the single candidate `{ "kind": "toolchain", "toolchainID": , "root": }`, whose resolution checks `minimumVersion`, and `build.checkToolVersion` for it is `plugin.build.unavailable` because Core reads no version. Any other runtime describes its own `executables` and `version` probe, so the runtime list is open; a runtime whose only candidate is a `toolchain` candidate may omit the probe, since the definition checks the version. | | `options` | Build dimensions the user chooses: `boolean`, `singleSelect`, `multiSelect`, `text`, `file`, `directory`. Each contributes `arguments` and `environment` (with `${value}`) when it has a value. Choices are declared, come from the model (`choicesFromModel`), or are free (`allowCustom`). | | `provider` | The process that answers model and dependency requests when no command prints them; see [Build providers](#build-providers). | | `model` | `static` (a `toml` file read during detection, no process), `command` (a command whose output Core decodes; it states `executesProjectCode` and `mayUseNetwork`), `provider`, or `hostIntegration`. | @@ -1348,7 +1451,7 @@ declaration are relative, use `/`, and stay inside their base directory | `sync` | Files whose change makes the model stale, relative to the root and to each project. | | `layout` | `excluded` directories relative to each project, added to every project as `excluded` content roots. | -With `requires.buildSystems: 3`, an option may declare `scope: "instance"`. +An option may declare `scope: "instance"`. Omitted scope (or `"workspace"`) keeps workspace/build-system storage. Instance values are independently persisted by normalized detected root: changing or resetting `a` cannot change `b`, even when both accept the same choices. Explicit @@ -1446,24 +1549,24 @@ or the tool itself with a plugin-supplied init script, as Gradle does) prints `litheBuildModel` or `litheDependencies`, or a [build provider](#build-providers) answers. Unlike a static file, a command runs only when the host starts it on request. The map does not grow into a -transformation language: a later `buildSystems` version adds a rule only when +transformation language: a rule is added only when real plugins need it and it cannot reasonably live in the plugin. Canonical formats let a plugin's own script print the shape Core expects: | Format | Shape | | --- | --- | -| `litheBuildModel` | `{ "format": "litheBuildModel", "version": 1, "projects", "targets", "tasks", "contentRoots", "optionValues" }` with the model's field names; `tasks[].category` is an array of headings | -| `litheTestResults` | `{ "format": "litheTestResults", "version": 1, "tests": [{ "suite", "case", "status", "message"?, "durationMillis"?, "path"?, "line"? }] }` | -| `litheDependencies` | `{ "format": "litheDependencies", "version": 1, "dependencies": [{ "name", "group"?, "version"?, "scope"?, "properties"?, "children" }] }` | +| `litheBuildModel` | `{ "format": "litheBuildModel", "version": 0, "projects", "targets", "tasks", "contentRoots", "optionValues" }` with the model's field names; `tasks[].category` is an array of headings | +| `litheTestResults` | `{ "format": "litheTestResults", "version": 0, "tests": [{ "suite", "case", "status", "message"?, "durationMillis"?, "path"?, "line"? }] }` | +| `litheDependencies` | `{ "format": "litheDependencies", "version": 0, "dependencies": [{ "name", "group"?, "version"?, "scope"?, "properties"?, "children" }] }` | | `litheDiagnostics` | output lines `{ "lithe": "diagnostic", "path", "line", "column"?, "severity"?, "message" }` | -Unknown fields in canonical documents are ignored so newer scripts work with -older hosts. Output that does not fit its decoder is `plugin.build.invalidOutput`. +Unknown fields in canonical documents are ignored, so a script may add fields +without a new version. Output that does not fit its decoder is `plugin.build.invalidOutput`. -### Provider task execution (buildProviders 2) +### Provider task execution -A task with `source: "provider"` requires `requires.buildProviders: 2` or newer. +A task with `source: "provider"` is answered by the plugin's [build provider](#build-providers). It keeps the ordinary task ID, scopes, target tags and model selection, but `arguments` must be `[]` and `execution` must be `task`. Application/service launch and test discovery are separate capabilities. Provider model-task @@ -1484,12 +1587,11 @@ same invocation resolver; explicit invalid tool/runtime selection is a failure, never permission for independent discovery. It must reject stale project facts rather than guess a target. -The host offers `litheBuild.versions: [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]`. Version 2 adds task execution -and its progress notification to the two version 1 methods. A process that -chooses version 1 cannot receive execution; unsupported negotiation fails -explicitly. A plugin that needs this behavior declares capability version 2 -so unsupported hosts refuse installation. The two version numbers serve -different purposes: installation admission and per-process negotiation. +The host offers `litheBuild.versions: [0]`: the protocol's version is the version +of `buildProviders` the manifest requires, and the provider answers +`{ "version": 0 }`. Unsupported negotiation fails explicitly. Task execution and +its progress notification belong to this protocol with `lithe/build/model` and +`lithe/build/dependencies`. Core sets `params.operationID` to the unique JSON-RPC request ID (as a string); callers cannot choose it. `lithe/build/progress` notifications echo that ID and @@ -1518,13 +1620,11 @@ additional runtime, directory or cross-plugin private cache is required. Only public typed results may cross domain boundaries. Canonical examples: `fixtures/build-providers/build-provider-execution.plugin.json` and -`fixtures/build-providers/build-execution-protocol.json`. Protocol v2 is a construction -primitive for #1495; it does not by itself migrate Maven or Java consumers. +`fixtures/build-providers/build-execution-protocol.json`. -### Provider test execution (buildProviders 3) +### Provider test execution -A build system's `test` may declare `source: "provider"` with -`requires.buildProviders: 3`. It retains the existing scopes, target tags and +A build system's `test` may declare `source: "provider"`. It keeps the existing scopes, target tags and supported filters, but `arguments` must be `[]`. It requires a plugin-owned Provider process; `results` may be omitted or use `junitXml`/`litheTestResults` reports. `hostIntegration` is refused: the plugin owns test execution and @@ -1540,9 +1640,7 @@ widening the test run. Paths, scopes and declared filters are validated before the request is sent. The host's report project list only resolves report globs; it is not a second backend project model. -Only a process choosing protocol version 3 or newer receives `lithe/build/test`. -Versions 1 and 2 explicitly refuse tests. Version 3 retains every version 2 -task behavior. Tests use the same Core-assigned operation identity, +A provider receives `lithe/build/test` only for a `test` that declares `source: "provider"`. Tests use the same Core-assigned operation identity, `lithe/build/progress`, tool hint, bounded `{ exitCode, output }` result, timeout, cancellation and late-result rules as task execution. Reports are read after execution using the existing freshness, path and size checks. @@ -1561,10 +1659,10 @@ Provider tests may additionally declare `discovery: true`. This requires both `build.testDiscoveryPlan` accepts the common plan fields and optional `refresh` and `tool`, returning a request for the whole instance: `lithe/build/discoverTests` with its effective options. Project/output-file -selection is refused. This method also requires protocol version 3 or newer and uses +selection is refused. This method uses the existing bounded request/cancellation lifecycle, without execution progress. -The provider returns `{ "format": "litheTestDiscovery", "version": 1, +The provider returns `{ "format": "litheTestDiscovery", "version": 0, "tests": [{ "suite", "case", "project"?, "target"? }] }`. Project and target are the usual typed selections with workspace-relative paths. There may be at most 10,000 items and 32 MiB UTF-8 JSON. Unknown document/item fields are @@ -1588,15 +1686,14 @@ list before submitting its exact selectors to `lithe/build/test`; stale toolbar selections cannot run. The backend must reject a selector whose facts have become stale externally. Canonical example: `fixtures/build-providers/build-test-discovery.json`. -### Provider run preparation (buildProviders 4) +### Provider run preparation -A run declaration may use `source: "provider"` with -`requires.buildProviders: 4`. This increment supports application targets only: -`fromTargets.tags` is required; `fromTasks`, service execution, nonempty -`arguments`, and extension templates are refused. The optional `name` is a -literal display name. `workingDirectory` is supplied by the prepared result; -the declaration's command-source default has no effect. These declarations -never generate empty command-based persisted Run entries. +A run declaration may use `source: "provider"`. It uses `fromTargets.tags`, +which is required; `fromTasks`, nonempty `arguments`, and extension templates +are refused. The optional `name` is a literal display name, and `execution` is +`application` (default), `service` or `task`. `workingDirectory` is supplied by +the prepared result; the declaration's command-source default has no effect. +These declarations never generate empty command-based persisted Run entries. `build.prepareRunPlan` validates a selected `run` declaration ID, required `project` and `target` selections, effective options, and bounded @@ -1607,8 +1704,7 @@ The host may add the resolved build tool hint. The provider owns upstream synchronization, build, classpath and launch facts; Core does not reconstruct those facts from source files or build configuration. -Protocol versions 4 and 5 accept this method. Version 4 retains task and test -support. Preparation shares the Core-assigned wire `operationID`, progress, +Preparation shares the Core-assigned wire `operationID`, progress, timeout, cancellation and stale-result handling of execution. It ends when preparation finishes; the application is a separate host-owned process. A JSON-RPC error aborts preparation and cannot launch an application. It retains @@ -1638,14 +1734,13 @@ Unknown fields are ignored. The host calls `build.decodeRunPreparation` with its workspace root, instance root and raw result; any malformed plan fails as `plugin.build.invalidOutput` before executable resolution. -#### Typed run options and service/task preparation (version 10) +#### Typed run options and service/task preparation -Version 10 retains versions 1–9 and extends the same `lithe/build/prepareRun` -method. A provider run may declare `execution: "service"` or `"task"` only -with `requires.buildProviders: 10`. It still uses `fromTargets.tags`, never -host command or extension templates. Preparation ends before the host starts -the process; the host owns stdout/stderr, process identity, Stop and cleanup. -This version does not introduce provider-owned background services. +The same `lithe/build/prepareRun` method carries typed launch options. A +provider run may declare `execution: "service"` or `"task"`; it uses +`fromTargets.tags`, never host command or extension templates. Preparation ends +before the host starts the process; the host owns stdout/stderr, process +identity, Stop and cleanup. A provider does not own background services. The optional `launchOptions` object contains required `execution` (`application`, `service` or `task`), `runtimeArguments` and `arguments` @@ -1659,10 +1754,9 @@ values. Options describe resolved user choices; providers must preserve argument boundaries and cannot silently ignore unsupported choices. `execution` must match the run declaration (default `application`). Service -and task preparation require `launchOptions`; legacy application preparation -without it retains version 4 behavior. Hosts must reject options before -sending them to a session that negotiated a version below 10. Both SDKs -likewise reject unsupported or malformed options before invoking a handler. +and task preparation require `launchOptions`; application preparation without +it uses the declaration's defaults. Both SDKs reject malformed options before +invoking a handler. Build options, resolved tool hints and native settings remain their existing sources; launch options do not create a second profile or tool configuration. @@ -1676,9 +1770,9 @@ launch result validation and cancellation/stale-result behavior are unchanged. Canonical fixtures: `fixtures/build-providers/build-provider-run-options.plugin.json` and `fixtures/build-providers/build-run-options.json`. -#### Registry connection debugging (buildProviders 11) +#### Registry connection debugging -Version 11 retains versions 1–10. A provider service run may declare +A provider service run may declare `debug: {"configurationType":"example"}`. This is a public DebugAdapter Registry alias, not another plugin's private language server or a local adapter ID. The host selects one currently enabled adapter advertising the type and `attach`, @@ -1693,8 +1787,7 @@ fail before starting the provider process. It sends: These are the exact three debug fields and three target fields. The host owns the port (integer 1–65535), process, readiness and cleanup. Explicit null, unknown fields, remote hosts and application/task execution are rejected. The type must -match the selected run declaration. Both manifest and process agreement must -support v11; older SDKs reject before invoking a handler. The provider uses its +match the selected run declaration. The provider uses its upstream engine to suspend the service until attachment; it owns runtime-specific flags and fails if it cannot honor the request. It returns the ordinary portable Run process plan, without selecting an endpoint or returning DAP arguments. @@ -1706,23 +1799,21 @@ produced by existing `debug.adapters.startArguments` with a `connection` target; the selected registered adapter owns its transport. In particular, `tcpLanguageServer` may refer only to a language server declared by that adapter's own plugin. No built-in language debugger fallback is permitted. -This does not change same-package native `prepareDebug` (v5). +This does not change same-package native `prepareDebug`. Canonical fixture: `fixtures/build-providers/build-connection-debug-options.json`. -### Native debug preparation (version 5) +### Native debug preparation -A provider run may additionally declare `debug: {"adapter":"native"}` with -`requires.buildProviders: 5` and `requires.debugAdapters: 2`. `adapter` is a +A provider run may additionally declare `debug: {"adapter":"native"}`. `adapter` is a local ID declared by this plugin; qualified cross-package references are refused. The adapter must serve launch of executable targets, with bindings `program` ← `target.path`, `cwd` ← `target.workingDirectory`, `args` ← `target.arguments`, and `env` ← `target.environment`. These fields cannot be overridden by adapter -default arguments. This first increment covers native executable launch only; +default arguments. It covers native executable launch only; it does not accept attach endpoints, backend-selected adapters or arbitrary DAP JSON. `build.prepareDebugPlan` validates the same selected run/project/target and -arguments as run preparation and returns `lithe/build/prepareDebug`. Only wire -version 5 accepts it. Progress, cancellation, timeouts, tool hints, workspace +arguments as run preparation and returns `lithe/build/prepareDebug`. Progress, cancellation, timeouts, tool hints, workspace ownership and JSON-RPC failure handling follow run preparation. A successful result contains exactly these four fields: @@ -1765,23 +1856,21 @@ raw `userArguments`, never an executable, environment, or cached classpath. Every restart re-resolves the enabled instance/model and sends a fresh preparation. Missing plugins/targets/models fail explicitly; there is no command fallback. Saving and running work before detector-generated Run configurations exist. -Service coordination, debug target registration and Java/Maven migration remain -separate. See `fixtures/build-providers/build-provider-run-preparation.plugin.json` +See `fixtures/build-providers/build-provider-run-preparation.plugin.json` and `fixtures/build-providers/build-run-preparation.json`. -### Optional native configuration (buildProviders 7) +### Optional native configuration -An own-process provider may opt into `buildSystems[].configuration: {"source":"provider"}` -with `requires.buildProviders: 7`. Shared language-server providers cannot opt in. +An own-process provider may opt into `buildSystems[].configuration: {"source":"provider"}`. +Shared language-server providers cannot opt in. `build.configurationPlan` uses the same selected instance/options/environment/tool snapshot as model loading and emits `lithe/build/configuration`. It has no project, -output file, or mutation parameters. Wire versions below 7 refuse the method. -The negotiated v7 server feature is `litheBuildConfiguration: true`; earlier methods -retain their existing admission rules. Configuration reads are bounded requests +output file, or mutation parameters. A provider that implements the method +advertises the server feature `litheBuildConfiguration: true`. Configuration reads are bounded requests with the existing cancellation, timeout, workspace and package ownership. `build.decodeConfiguration` validates `result` and returns `configuration`: -`{"format":"litheBuildConfiguration","version":1,"sources":[...]}`. There are at most +`{"format":"litheBuildConfiguration","version":0,"sources":[...]}`. There are at most 8 sources, each with a unique provider-owned `id`, `scope` (`installation`, `user`, `workspace`), file `uri`, exact byte `revision` (`missing` or `sha256:` plus 64 lower-case hex digits), `writable`, and up to 64 `fields`. @@ -1811,7 +1900,7 @@ that native override. Unchanged inherited values must not create overrides. `build.configurationEditPlan` takes the same instance/tool/options context, `snapshot`, confirmed `edits`, and a fresh, empty, **already existing** `outputFile` inside the host's isolated workspace state. It validates the bounded field IDs, -types and revision, then plans `lithe/build/editConfiguration` (protocol 7). +types and revision, then plans `lithe/build/editConfiguration`. The provider owns its native format and uses an upstream/mature reader/writer; Maven XML, JSON and TOML do not add host format cases. The provider verifies the source revision and writes replacement bytes only to that private staging file. @@ -1820,7 +1909,7 @@ file before writing, and never truncate another file. A cancelled operation cann recreate removed staging. Preserve unrelated content, format-supported comments and secrets privately. -The reply is only `{format:"litheConfigurationEdit", version:1, sourceID, +The reply is only `{format:"litheConfigurationEdit", version:0, sourceID, revision, changed}`. `build.decodeConfigurationEdit` binds this receipt to the same snapshot/draft and rejects extra fields. Full documents and private bytes must never appear in RPC, UI, logs or model inputs. The native adapter validates @@ -1845,7 +1934,7 @@ are not cross-worktree reusable. Installed packages remain byte-for-byte unchang Fixtures: [declaration](fixtures/build-providers/build-provider-configuration.plugin.json) and [native facts](fixtures/build-providers/build-configuration.json). -### Optional read-only model inputs (buildModelInputs 1) +### Optional read-only model inputs A producer opts into `buildSystems[].modelSharing: { options: [] }`. Only normalized `projects`, `targets` and `contentRoots`, including their @@ -1856,9 +1945,9 @@ may be shared; omitted options stay private. A configuration fingerprint identifies all effective options without publishing their values. A consumer declares `modelInputs: [{ pluginID, buildSystemID }]`, requires -`buildModelInputs: 1` and `buildProviders: 6`, and owns a provider and model. +`buildModelInputs: 0` and `buildProviders: 0`, and owns a provider and model. Slots are exact optional identities; Core sorts them by plugin ID and build -system ID and rejects duplicates. Version 1 producers cannot also consume +system ID and rejects duplicates. A producer cannot also consume inputs, so consumers cannot recursively re-export a model. The native host calls `build.resolveModelInputs` with the consumer manifest @@ -1868,7 +1957,7 @@ workspace activation, never identities supplied by another backend. Input states are `ready`, `loading`, `stale`, or `failed`; only ready instances include a complete normalized model. A truncated model becomes stale. Core revalidates source manifests, ownership, shape and path containment and -returns `modelInputs: {version: 1, revision, inputs}`, where each input is: +returns `modelInputs: {version: 0, revision, inputs}`, where each input is: ```json { @@ -1898,9 +1987,9 @@ The host supplies this snapshot as the common plan request's optional `modelInputs`. It is required exactly for a consumer with declared slots. Every Provider plan copies it unchanged into `params.modelInputs`; Core revalidates the snapshot and rejects extra fields or mismatched slots. -The running backend must negotiate `litheBuild` version 6 independently of -manifest admission. Older versions reject an input-bearing request even for -an existing method such as `lithe/build/model`. +The running provider must have negotiated `litheBuild`; a session that has not +rejects an input-bearing request even for an existing method such as +`lithe/build/model`. A revision belongs to one workspace activation. The host revokes consumer model/dependency loads, discovery and in-flight execution/preparation when a @@ -1918,12 +2007,11 @@ Java/Maven business migration remain separate work. Shared fixtures: `fixtures/build-systems/build-model-producer.plugin.json` and `fixtures/build-systems/build-model-consumer.plugin.json`. -### Inline provider test reports (buildProviders 8) +### Inline provider test reports -A provider test may declare `results: { "decoder": "provider" }` with -`requires.buildProviders: 8`. Core adds `testResults: true` to the test request; -wire versions below 8 refuse it before invocation. The reply includes -`testResults: { "format": "litheTestResults", "version": 1, "tests": [...] }` +A provider test may declare `results: { "decoder": "provider" }`. Core adds +`testResults: true` to the test request. The reply includes +`testResults: { "format": "litheTestResults", "version": 0, "tests": [...] }` next to `exitCode` and `output`. Ordinary execute replies may include the same report when the task also ran tests. Missing required or malformed reports fail explicitly; there is no host report-file fallback. @@ -1958,6 +2046,50 @@ The report is operation state in a platform-provided writable directory, never installed package state or a cross-worktree cache. Fixture: `fixtures/build-providers/build-provider-reports.plugin.json`. +### Opaque semantic test selectors + +Provider test declarations may advertise `selectorKinds`, up to 16 distinct public +namespace identifiers (120 bytes each). A test +request may then carry `selectorKind` and `selectors` instead of `suite`/`case`. +The namespace must be declared; at most 64 nonempty, control-free UTF-8 values of +4096 bytes each are allowed. An empty list explicitly selects all tests within the +request's normal project/workspace scope. Values remain opaque to the host and +Core: only the owning language engine supplies semantic identities and the build +backend translates them into its upstream runner's selection syntax. Providers +must reject identities they cannot represent exactly, rather than widen execution. + +Ignoring a filter is unsafe, so Core rejects these fields before dispatch +unless the test declares the namespace. The operation identity, source/model +and configuration validity, cancellation, progress, and inline report rules +still apply. The shared example is `fixtures/build-providers/build-provider-selectors.plugin.json`. + +### Declarative project preparation + +A project task may declare `preparation: { "purpose": "run", "sourceRoles": +["source", "generatedSource"] }`. Purpose is +an opaque workflow identifier; `run` identifies preparation before resolving a +language launch. Roles are the canonical model content-root kinds. A provider +can declare a different task for `testSource`/`generatedTestSource`, with any +private task IDs. Hosts never prescribe task names or infer a role from a file +extension, build descriptor, class name or missing model entry. + +Bindings are unique by purpose/role within each build system. Preparation +requires a project-scoped task, task execution, no argument prompt, and no +`fromModel`. Empty, overlapping, duplicate or unknown roles are rejected. +Hosts select the deepest owning project and its deepest content root from the +current model, then require exactly one declared matching task. Missing or +ambiguous declarations fail; they do not fall back to a conventional task ID. +The selected ID travels through the existing execute plan and backend method. +Shared fixture: +`fixtures/build-providers/build-provider-preparation.plugin.json`. + +The build backend owns filtering, custom resource directories, profiles and +reactor semantics. Hosts await successful completion, retain one operation +identity through cancellation/configuration changes, and only then resolve and +launch Java. Per-run option/tool overrides are ephemeral host inputs to the +existing validated plan; they never mutate saved build defaults or claim that +the persistent model reflects a different configuration. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -1998,9 +2130,7 @@ The Core-only `tool` hint may include a bounded `environment` string map. Core m The host passes `refresh` and `tool` in the `build.modelPlan` and `build.dependencyPlan` requests; Core validates `tool.executable` as an absolute path (`plugin.build.invalidSelection` otherwise) and copies both into -`params` only when they are set, so a request without them is the same as -before they existed. Command sources ignore both. Both are optional data -within protocol version 1. +`params` only when they are set. Command sources ignore both. The host passes the JSON result of the request as `result` to `build.decodeModel` or `build.decodeDependencies`, which decode it like the @@ -2010,24 +2140,26 @@ another format is `plugin.build.invalidOutput`. The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider -can add optional data within a negotiated protocol version: - -- The host sends `capabilities.experimental.litheBuild: { "versions": [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }`, - the build-provider protocol versions it speaks. Version 1 is the two - requests above with the params and result formats of this section; - version 2 adds typed task execution; version 3 adds typed test execution - and discovery; version 4 adds application run preparation; version 5 adds native debug preparation; - version 6 accepts the host's explicitly declared read-only model inputs; - version 7 adds source-backed native configuration; version 8 adds inline test reports; - version 9 adds opaque semantic test selectors; version 10 adds typed run options and - service/task run preparation; version 11 adds registry connection debugging. -- The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` +can add optional data: + +- The host sends `capabilities.experimental.litheBuild: { "versions": [0] }`. + The protocol has no version of its own: it is the version of `buildProviders` + the manifest requires. It is the two requests above with the params and result + formats of this section, together with the typed requests of the sections + before it: task and test execution, test discovery, run preparation, native + debug preparation, source-backed native configuration, inline test reports, + opaque semantic test selectors, typed run options, and registry connection + debugging. Read-only model inputs travel in the plan requests. A host presents + the features it implements; a provider must not depend on a host calling any + of them. +- The provider answers `capabilities.experimental.litheBuild: { "version": 0 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a shared provider still serves its language. - Both sides ignore fields they do not know in these objects and in request - params and results, so either side can add optional data within a version. - A change the other side must not ignore needs a new protocol version. + params and results, so either side can add optional data. A change the other + side must not ignore gives `buildProviders` version `1`, and the protocol + moves with it. A host that declares `buildProviders` keeps providers apart: @@ -2073,16 +2205,17 @@ 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 +## 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 +drives, and the shared [process launch](#process-start) 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. +the manifest. The host offers `{ "versions": [0] }`: the protocol's version is the +version of `vcsProviders`. 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 @@ -2090,18 +2223,17 @@ 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). +defined in [the VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md). -## API level 4: debug adapters +## Debug adapters `debugAdapters[]` declares debug adapters the plugin implements. Each one is a DAP process the host starts and talks to over stdio or a loopback TCP port, or a debug server hosted by the plugin's own language server. The declaration is data only: there is no resolver, workflow, host integration, -or `initializationOptions`, and any such field is refused. Requires -`requires.debugAdapters` 1, `debug.launch` (plus `debug.attach` when an +or `initializationOptions`, and any such field is refused. It requires +`debugAdapters: 0` in `requires`, `debug.launch` (plus `debug.attach` when an adapter serves `attach`), and one `onDebug:` activation event per adapter. -A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest may not require `debugAdapters` or `serverProcess` 2. | Field | Meaning | | --- | --- | @@ -2110,18 +2242,18 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `requests` | `launch` (default) and/or `attach`. | | `selectors[]` | Optional automatic matching by `targetKinds` and `languageIDs`; absent means explicit selection only. | | `configurationTypes` | Imported `launch.json` `type` values the adapter answers to. | -| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, and for `attach` only `connection` ← `target.host`, `target.port`, `target.pathMappings`; version 2 also supports bounded `args` ← `target.arguments` and `env` ← `target.environment`; see [Remote attach](#remote-attach)). A configuration for an undeclared request is refused. | +| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, and for `attach` only `connection` ← `target.host`, `target.port`, `target.pathMappings`; also bounded `args` ← `target.arguments` and `env` ← `target.environment`; see [Remote attach](#remote-attach)). A configuration for an undeclared request is refused. | | `configurations.launch.executable` | For a target that is a source file the adapter cannot debug directly (C, C++, Swift): `setting` names a `file` setting of the plugin, `candidates[]` (up to 8) are the paths a host looks for when the user chose nothing. Each candidate starts with `${workspaceFolder}/` or `${fileDirectory}/`, may use `${fileBasenameNoExtension}`, and has no `..`. Declare it together with `targetBindings.program: "target.executable"` and neither without the other; a launch fills `program` with the path the host settled on. The user's setting always wins: a chosen path that is not a runnable file is an error, not a reason to try the candidates. The host shows the path it found in that setting, and the setting's `description` is where the plugin teaches how to produce the program. | | `capabilities.disabled` | Standard DAP `supports*` names or `exceptionBreakpointFilters` the host treats as absent. | -| `features` | `debugAdapters` 3. Adapter-specific DAP extensions the adapter implements; see [Adapter features and defaults](#adapter-features-and-defaults-debugadapters-3). | -| `defaults` | `debugAdapters` 3. Values the adapter starts with when the user configured none. | +| `features` | Adapter-specific DAP extensions the adapter implements; see [Adapter features and defaults](#adapter-features-and-defaults). | +| `defaults` | Values the adapter starts with when the user configured none. | | `console.commandPrefix` | Text (1–8 characters, no spaces; for lldb-dap a backtick) the adapter's console reads as "what follows is a debugger command". The host puts it before the typed line only when it asks the adapter to complete, and moves the adapter's answer back, so the user never sees it. An adapter that treats a bare word as an expression (lldb-dap) otherwise mixes language keywords such as `long` into the first word's completions; the prefix limits them to commands. Declare at least this or `console.commands[]` when `console` is present. | | `console.commands[]` | Debugger commands (`name`, optional one-line `description`) the Debug Console offers as completions. An adapter that answers the DAP `completions` request completes live, so list commands only for an adapter that does not (or whose live answers lack some commands); the host merges both. Names are inserted as written, unique, and not padded with spaces; descriptions are shown as written and not translated. | -| `launch.process` | The shared `serverProcess` launch fields without `initializationOptions`, or `{"launch": {"kind": "runSelectedRuntime", "tool": "python" \| "java" \| "node"}}`, which needs `serverProcess` 2 or later and is the only use of 2; from `serverProcess` 4 `tool` is a [tool-definition ID](#tool-references-serverprocess-4). Required for every transport except `tcpLanguageServer`, which must not have it. | +| `launch.process` | The shared [process launch](#process-start) fields without `initializationOptions`, or `{"launch": {"kind": "runSelectedRuntime", "tool": ""}}`, which starts the adapter with the executable the user's runtime selection resolves for that tool; the ID is listed in `requiresTools` (see [Tool references](#tool-references)). Required for every transport except `tcpLanguageServer`, which must not have it. | | `launch.transport` | `stdio`, `tcpAnnounced` (`endpoint`: `stream`, one-line `prefix`, `value` = `port` or `hostPort`), `tcpPort` (`endpoint.port`), or `tcpLanguageServer` (`endpoint`: `languageID`, `command`). TCP endpoints are loopback only. | | `launch.endpoint` for `tcpLanguageServer` | For an adapter that lives inside a language server and has no process of its own (the Java Debug Server runs in JDT LS). `languageID` names a `languageServers[]` entry of the same plugin; `command` (1–128 characters of letters, digits, `.`, `_`, `:`, `-`) is the `workspace/executeCommand` the host sends to that server for each session. The server answers with the loopback port to connect to, as an integer or a numeric string from 1 to 65535. The host starts the language server for the workspace first, as it does for editing. The command is part of what the user reviews at install and update, and changing it asks again. | -### Adapter features and defaults (`debugAdapters` 3) +### Adapter features and defaults A host never recognizes an adapter by its ID. What is specific to an adapter is declared, and the shared debug engine follows the declaration: @@ -2151,11 +2283,9 @@ is declared, and the shared debug engine follows the declaration: Names (`request`, `event`, fields, `argument`) are a letter followed by up to 63 letters, digits, `.`, `_`, or `-`. A declaration with a malformed name, or a -field this version does not define, is `plugin.manifest.invalid` with the -`field` that names it, and any `features` or `defaults` requires -`requires.debugAdapters` 3. The launch plan's `debugAdapter` descriptor carries -both so the host can pass them to `debug.createSession`. A plugin that declares -neither keeps the behavior of `debugAdapters` 1 and 2. +field the contract does not define, is `plugin.manifest.invalid` with the +`field` that names it. The launch plan's `debugAdapter` descriptor carries +both so the host can pass them to `debug.createSession`. ### Remote attach @@ -2263,12 +2393,12 @@ selection, a target missing a fact a binding reads; the request defaults to `fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json` declares six adapters that differ in how they take a connection. -## Output link matchers (`outputLinkMatchers` 1) +## Output link matchers `outputLinkMatchers[]` lets a plugin make text in the Run and Test consoles clickable, such as a traceback frame or a panic location. The host knows no tool's output format: the plugin declares the pattern and Core finds the -matches. Requires `requires.outputLinkMatchers` 1. Matchers alone are a +matches. Requires `outputLinkMatchers: 0` in `requires`. Matchers alone are a complete plugin: one that declares nothing else is valid, and matchers without `resolve` need no backend. @@ -2283,7 +2413,7 @@ complete plugin: one that declares nothing else is valid, and matchers without | `id` | Plugin-local ID: a lowercase letter followed by up to 63 lowercase letters, digits, `.`, `_`, or `-`; unique in the plugin. At most 16 matchers per plugin. | | `pattern` | A regular expression of 1 to 512 bytes matched against one console line. It uses the Rust `regex` syntax, so it has no backreferences and no look-around, and its compiled size is limited. It names at most 8 groups, each a camelCase name, and must not match empty text. | | `scope` | `run` or `test`: the console the matcher applies to. Absent means both. | -| `resolve.function` | Optional ID of a function of the plugin's `backend` 2 process. Needs `backend` 2. | +| `resolve.function` | Optional ID of a function of the plugin's `backend` process. Needs `backend`. | Without `resolve`, the pattern must capture a `path` group and may capture `line` and `column` (1-based); no other group name is accepted. A relative @@ -2322,60 +2452,56 @@ process that serves only the plugin itself, independent of any build system, language, or workspace: ```json -"pluginAPIVersion": 3, -"requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, +"pluginAPIVersion": 0, +"requires": { "backend": 0, "ui": 0 }, "permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], "backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] } ``` -- `backend` holds exactly the [`serverProcess`](#capability-versions-level-3) - launch fields, with the same templates and install review; the review lists - it as `packagedBackend`, `systemCommandBackend`, or `runtimeBackend`. It - needs `pluginAPIVersion` 3, `requires.backend: 1`, `requires.serverProcess: - 1`, and the `backend.launch` permission. `fixtures/backend/backend.plugin.json` is +- `backend` holds exactly the [process launch](#process-start) fields, with the + same templates and install review; the review lists it as `packagedBackend`, + `systemCommandBackend`, or `runtimeBackend`. It needs `requires.backend: 0` + and the `backend.launch` permission. `fixtures/backend/backend.plugin.json` is the example; the backend cases of `fixtures/manifest/invalid-manifests.json` are the rejections. -- In `backend` 1 only the plugin's own [web pages](#web-content-ui-2) call it, - so a backend without a web tool window is `plugin.manifest.invalid`. - It belongs to no workspace: `${workspaceStateDir}` is refused, and its writable state is `${stateDir}`. The host starts it with `${stateDir}` as root and working directory. -- The host keeps at most one backend process per plugin, starts it on the - first `functions.*` call from one of the plugin's pages, and ends it when - the plugin is disabled, updated, or uninstalled, and when the host quits. +- The host keeps at most one backend process per plugin, starts it on the first + trigger listed under [Backend lifecycle and workspace contexts](#backend-lifecycle-and-workspace-contexts), + and ends it when the plugin is disabled, updated, or uninstalled, and when the + host quits. Its consent is the install review: it needs no per-workspace prompt. -- The backend speaks the [plugin-function protocol](#plugin-functions) and, - from version 2, may ask the host for the - [host services](#host-services-protocol-version-2) its permissions grant. +- The backend speaks the [plugin-function protocol](#plugin-functions) and may + ask the host for the [host services](#host-services) its permissions grant. It owns no domain contract: it never answers `lithe/build/*` or VCS requests. - A plugin publishes one archive per platform, so its packaged path does not vary by `${arch}`; a macOS archive may carry a universal binary. -### Backend 2: workspace contexts +### Backend lifecycle and workspace contexts -`requires.backend: 2` keeps every rule of version 1 and changes when the -process starts and what it learns about workspaces. -`fixtures/backend/backend-workspace.plugin.json` is the example. +A backend may start without a page and learns about workspaces only through +contexts. `fixtures/backend/backend-workspace.plugin.json` is the example. - It may start without a page. It needs at least one way to start: a web - tool window (first page call, as in version 1), a `ui.commands[]` entry + tool window (first page call), a `ui.commands[]` entry (its `onCommand:` also starts the backend), the `onWorkspaceOpen` event, or a provider the host queries: a toolchain or keymap import provider, or a language server with `launch.kind: "backend"`, whose start - asks the backend for its command line. A backend 2 with none of them is + asks the backend for its command line. A backend with none of them is `plugin.manifest.invalid`. -- It is still one process per plugin, shared by every open workspace and +- It is one process per plugin, shared by every open workspace and window, on the machine that runs Lithe, also for remote workspaces. No workspace is process state: each call names its workspace explicitly - (a context, below), so `${workspaceStateDir}` stays refused. + (a context, below), so `${workspaceStateDir}` is refused. - Starting on `onWorkspaceOpen` never delays opening the workspace. The host starts such backends after the window is shown, one plugin at a time, and a backend that does not finish `initialize` within its initialize timeout is stopped and reported like any failed server start. A failed or disabled backend never blocks the workspace. - The `workspace.metadata` permission grants the - [`workspace` host service](#workspace-contexts-and-capability-discovery-protocol-version-3). + [`workspace` host service](#workspace-contexts-and-capability-discovery). It reveals the open workspaces' names, root folders and IDs, their authority, and the plugin's per-workspace state directory; it reads no file content. @@ -2383,13 +2509,10 @@ process starts and what it learns about workspaces. reviewed. Lithe has no workspace trust state at the moment: an operation that runs or changes something asks the user per operation, as the host's own controls do. -- A host that offers only `backend: [1]` refuses the manifest with - `plugin.api.unsupported` (`requires.backend`), so an older host never - starts a process that expects contexts. ## Project generators -`projectGenerators` (requires `projectGenerators` 1 or 2 and the +`projectGenerators` (requires `projectGenerators: 0` in `requires` and the `project.generate` permission) adds entries to the host's New Project window. The host owns the window: the entry list and its search, the project name and location, the "create Git repository" option, the empty project, and the Create button. A @@ -2407,7 +2530,7 @@ generator ID. {"id": "groupId", "type": "text", "title": "Group", "default": "com.example", "pattern": "[a-z][a-z0-9.]*"}, {"id": "packageName", "type": "text", "title": "Package name", "default": "${field.groupId}.${project.name|package}", "pattern": "[a-z][a-z0-9.]*"}, - {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"} + {"id": "jdk", "type": "tool", "title": "JDK", "tool": "java", "minimumVersion": "17"} ], "requirements": [{"languageID": "java"}, {"buildSystemID": "maven"}], "steps": [{"kind": "archive", "url": "https://start.spring.io/starter.zip", @@ -2433,14 +2556,18 @@ generator ID. | `enum` | One option | `options` (1–16 `{value, title}`), `default` (the first option otherwise), `presentation` (`segmented` or `menu`), `maximumVersion` | | `boolean` | `true` or `false` | `default` | | `multiSelect` | A list of options, kept in declaration order | `options` (1–128, each with an optional `group` heading), `default` | -| `runtime` | A host-discovered installation (`projectGenerators` 1 only) | `runtime` (a valid tool-definition ID; any other value is `plugin.manifest.invalid`), `minimumVersion`, `optional`. The host chooses the initial value. | -| `tool` | An installation of a [tool definition](toolchains/README.md), by home directory (`projectGenerators` 2 only) | `tool` (a tool definition ID), `minimumVersion`, `pinAs`, `optional`. See [Tool fields](#tool-fields). | +| `tool` | An installation of a [tool definition](toolchains/README.md), by home directory | `tool` (a tool definition ID), `minimumVersion`, `pinAs`, `optional`. See [Tool fields](#tool-fields). | + +A field type is `text`, `enum`, `boolean`, `multiSelect`, or `tool`; any other +type is `plugin.manifest.invalid`. `minimumVersion` is allowed only on a `tool` +field, and `optional` only on a `text` or `tool` field. `maximumVersion` is a template over earlier fields naming the highest version -the value may be, compared numerically by dotted components. A target Java -release that must not exceed the selected JDK declares -`"maximumVersion": "${field.jdk.major}"`; while the bound expands to an empty -string (no JDK chosen yet) nothing is checked. +the value may be, compared numerically by dotted components. It reads +`${field..major}` of a `tool` field: a target Java release that must not +exceed the selected JDK declares `"maximumVersion": "${field.jdk.major}"`. +While the bound expands to an empty string (no tool chosen yet) nothing is +checked. Every field takes `id` (lowerCamelCase), `title`, `description` (shown behind an info tip), `advanced` (listed under the collapsed "Advanced Settings" @@ -2455,7 +2582,7 @@ spaces. ### Tool fields -A `tool` field (requires `projectGenerators` 2) lets the user pick one +A `tool` field lets the user pick one installation of any tool that a plugin declares in [`toolDefinitions`](toolchains/README.md), such as a Python interpreter, a Node.js runtime, or a JDK. The generator names no host feature and no language: the @@ -2484,18 +2611,12 @@ show it disabled. When the selection is below `minimumVersion` the form reports `toolTooOld`, except that an optional field the user has not set stays empty rather than picking an installation that is too old. -A generator with `projectGenerators` 2 must not declare a `runtime` field, and -a generator with `projectGenerators` 1 must not declare a `tool` field -(`plugin.manifest.invalid`). Version 1 generators keep working unchanged. -A host that lacks `projectGenerators` 2 refuses the whole manifest with -`plugin.api.unsupported` (`requires.projectGenerators`). - ### Conditions `when` and `visibleWhen` take one condition or a list that must all hold: `{"field": "", "equals" | "notEquals": }` or `{"field": "", "oneOf": [, …]}`. Values are option values for `enum`, `true`/`false` -for `boolean`, and strings for `text`, `runtime`, and `tool` (`notEquals: ""` +for `boolean`, and strings for `text` and `tool` (`notEquals: ""` tests that an optional field is filled; a tool field compares as its home directory). A condition on a hidden field is false. @@ -2503,11 +2624,13 @@ directory). A condition on a hidden field is false. Strings in the manifest use `${…}`: `${project.name}`, `${field.}` (a text or option value, `true`/`false`, a comma-separated list, or the home -directory of a runtime or tool), `${field..version}` and -`${field..major}` (runtime fields only; `21` for `21.0.4`, `8` for -`1.8.0_412`), and `${field..executable}` (tool fields only: the path of -the tool's executable in the chosen installation). Modifiers follow `|` and -apply in order: `path` turns dots into `/`, `package` makes a valid package name +directory of a tool), `${field..version}` and `${field..major}` (tool +fields only: the version of the chosen installation, and its major number, +`21` for `21.0.4` and `8` for `1.8.0_412`), and `${field..executable}` +(tool fields only: the path of the tool's executable in the chosen +installation). The three suffixed variables are rejected when the manifest is +validated if the field is not a `tool` field. Modifiers follow `|` and apply +in order: `path` turns dots into `/`, `package` makes a valid package name (`com.Example.my-app` → `com.example.myapp`), and `lower` lowercases. Template files use `{{ … }}` with the same variables, because POMs, Gradle @@ -2539,21 +2662,20 @@ file, and 8 MiB in total; archives to 20,000 entries and 512 MiB extracted. | --- | --- | --- | | `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], tools: [{fieldID, tool, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | | `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?, installations?}], activeRequirements, valid}` | -| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], toolPins: [{field, tool, pinAs, home, executable}], usesNetwork}` | +| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, toolPins: [{field, tool, pinAs, home, executable}], usesNetwork}` | | `project.checkName` | `{projectName}` | `name: {problem}` | | `project.extractArchive` | `{archivePath, destination}` | `extracted: {files}` | -`form` is `{projectName, values, edited, runtimes, tools}`. `values` maps field +`form` is `{projectName, values, edited, tools}`. `values` maps field IDs to values; a text value counts only when its ID is in `edited`, so derived -defaults keep following the fields they read. `runtimes` maps runtime field -IDs to `{home, version}` of the user's selection. `tools` maps tool field IDs +defaults keep following the fields they read. `tools` maps tool field IDs to the installations the host found for the field's tool, in the order the host prefers them, as `[{home, executable, version}]`; the field's `values` entry is the `home` of the user's choice. A tool field in the result carries `installations: [{home, executable, version, meetsMinimum}]`, so the host draws its picker from Core's answer, and its `value` is the installation Core selected. A field `problem` is one of `required`, `tooLong`, `invalidFormat`, -`unsupportedValue`, `runtimeTooOld`, `toolTooOld`, and `versionTooHigh`; hosts +`unsupportedValue`, `toolTooOld`, and `versionTooHigh`; hosts localize by code. Plan steps are `{kind: "writeFiles", files: [{path, contents, executable}]}`, `{kind: "command", command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}`, and @@ -2569,8 +2691,7 @@ definition is trusted, and whether the tool is installed, is decided by the host's tool registry and discovery. After the steps, the host moves the staging directory to its final location -(refusing an existing one), may record a `java` runtime as the project's JDK -(version 1 runtime fields only), saves the `toolPins` as the project's tool +(refusing an existing one), saves the `toolPins` as the project's tool choices through the same store the tool settings page edits, creates the Git repository when asked, and opens the project. A failure or cancellation removes the staging directory and leaves nothing behind @@ -2584,10 +2705,10 @@ A plugin backend (a language server, build provider, or serializable params and results. A function is private to its plugin unless the manifest exports it: the host invokes a private function on behalf of the same plugin, for example from that plugin's own window, and another plugin -can call only an [exported function](#cross-plugin-calls-protocol-version-4). +can call only an [exported function](#cross-plugin-calls). Functions complement the -typed domain protocols; they never replace them. Versions 1 to 4 of the protocol are -implemented in Rust Core (`rust/lithe-core/src/lsp/interface/plugin_function.rs`, +typed domain protocols; they never replace them. The protocol is implemented in +Rust Core (`rust/lithe-core/src/lsp/interface/plugin_function.rs`, `function_relay.rs`). macOS starts every build provider process as a session of the plugin that owns it and invokes its functions from that plugin's web pages (the @@ -2604,7 +2725,7 @@ Every capability has one owner, who defines its contract, and one direction: | --- | --- | --- | --- | --- | | Plugin provides a domain capability | Lithe: the typed domain contract | The plugin, declaratively or by its backend | A manifest `source`, Core plans and decodes, a typed `lithe/build/*` request | Build model, dependency tree; later task execution | | Plugin provides a plugin function | The plugin: its own params and results | The plugin's backend | `lithe/function/invoke` | Refresh a private snapshot, a step behind a button of the plugin's own UI | -| Plugin calls a host capability | The host | The host | `lithe/host/*` requests, offered per protocol version | Run a process through the host's runner, write output, store state | +| Plugin calls a host capability | The host | The host | `lithe/host/*` requests, offered by the protocol | Run a process through the host's runner, write output, store state | | Plugin calls another plugin's exported function | The exporting plugin: its own params and results, under an exported version | The exporting plugin's backend | `lithe/host/functions/invoke`, relayed by Core as `lithe/function/invoke` | A language plugin asks a build plugin for the options it contributes | Two rules keep these directions from forming a loop: @@ -2615,15 +2736,15 @@ Two rules keep these directions from forming a loop: `lithe/build/*` method is refused with `plugin.function.recursiveCall`. A handler that needs, say, a Maven run asks for the host's process capability, never for the task that its own plugin implements. The - [relay methods](#cross-plugin-calls-protocol-version-4) are a different + [relay methods](#cross-plugin-calls) are a different request: they name another plugin's exported function, Core refuses a call that would come back to a plugin already waiting on the call chain, and the old refusals above stay in force. - Host capabilities are primitive host services, never another entry into a - domain registry. Each is added to a protocol version together with its - params, permission, and the operation it must belong to. Version 1 offers - none: every `lithe/host/*` request is refused with `plugin.host.unavailable`. - Version 2 adds the [credentials](#host-services-protocol-version-2) service. + domain registry. Each is defined together with its params, permission, and + the operation it must belong to. A request for a service the host does not + offer is refused with `plugin.host.unavailable`; the services are listed + under [Host services](#host-services). ### Declarative or backend implementation @@ -2639,22 +2760,23 @@ Two rules keep these directions from forming a loop: declaring, adding, or removing private functions changes neither `pluginAPIVersion` nor any `requires` version. Only a function that another plugin may call, and a call to such a function, are declared, in - [`pluginFunctions`](#cross-plugin-calls-protocol-version-4). -- A new kind of domain implementation, such as a provider for tasks, is a new - version of the capability that owns it (`buildProviders`), so only plugins - that use it are filtered from hosts that cannot run it. + [`pluginFunctions`](#cross-plugin-calls). +- A new kind of domain implementation is a new version of the capability that + owns it, so only plugins that use it are filtered from hosts that cannot run + it. ### Negotiation For a session owned by a plugin (`lsp.startServer.pluginId`), the host offers -`capabilities.experimental.litheFunctions: { "versions": [1, 2, 3, 4] }` in -`initialize`, next to and independent of `litheBuild`. Host-owned sessions do +`capabilities.experimental.litheFunctions: { "versions": [0] }` in +`initialize`, next to and independent of `litheBuild`. The protocol has no +version of its own: it is the version of `backend`. Host-owned sessions do not offer or accept this protocol, retain no plugin-function registry, and reject register/unregister with `-32601`. A plugin backend that offers functions answers: ```json -"experimental": { "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } } +"experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } } ``` `functions` is optional and defaults to none. An answer with a version the @@ -2697,8 +2819,8 @@ backend that did not negotiate the protocol gets `-32601` for both. ### Invocation `lsp.functionRequest { sessionId, operationId?, function, params?, -timeoutMilliseconds?, context? }` (`context` from protocol version 3, see -[workspace contexts](#workspace-contexts-and-capability-discovery-protocol-version-3)) returns `{ operationId }` and sends: +timeoutMilliseconds?, context? }` (`context` is described under +[workspace contexts](#workspace-contexts-and-capability-discovery)) returns `{ operationId }` and sends: ```json { "method": "lithe/function/invoke", "params": { "functionID": "snapshot.refresh", "operationID": "…", "params": { … } } } @@ -2749,9 +2871,9 @@ A backend reports progress of a running call with the notification normalized report. A malformed report, or one for an operation that is not running on that session, is dropped. -### Host services (protocol version 2) +### Host services -A backend that negotiated version 2 may send these requests to the host. +A backend may send these requests to the host. Each service is granted by one manifest permission; Core receives the granted services as `lsp.startServer.hostServices` (from the launch plan's `backend.hostServices`) and checks every request before the host sees it. @@ -2763,20 +2885,17 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's | `lithe/host/credentials/delete` | `{ key }` | `null`, also when nothing was stored | `credentials.store` | - A `key` is 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`; a `secret` - is a string of at most 16 KiB. Unknown params are refused so a later - version can add them. + is a string of at most 16 KiB. - Credentials are private to the plugin ID: the host stores them in the platform's secret store (the Keychain on macOS) in a namespace of that ID, never in `${stateDir}` or the package. They survive disabling and updating the plugin and are deleted when it is uninstalled. -- Core refuses, without involving the host, in this order: a session that - negotiated version 1 or a method version 2 does not define (`-32601`, - `plugin.host.unavailable`), a service the plugin is not granted (`-32601` - in version 2, `-32010` from version 3, `plugin.host.permissionDenied`), - and malformed params (`-32602`, `plugin.host.invalidParams`; from - version 3 a field the method does not define is `-32018`, - `plugin.host.unsupportedOption`, see - [Extending version 3](#extending-version-3)). The code is in +- Core refuses, without involving the host, in this order: a method the + protocol does not define (`-32601`, `plugin.host.unavailable`), a service the + plugin is not granted (`-32010`, `plugin.host.permissionDenied`), and + malformed params (`-32602`, `plugin.host.invalidParams`; a field the method + does not define is `-32018`, `plugin.host.unsupportedOption`, see + [Extending the host services](#extending-the-host-services)). The code is in `error.data.code`. - An accepted request becomes the runtime event `hostRequest` with `method` and `result: { hostRequestID, service, params }`. The host answers it with @@ -2790,11 +2909,11 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's (`credentials`, `workspace`, or registered method-grant names); otherwise `lsp.startServer` is `invalid_request`. -### Workspace contexts and capability discovery (protocol version 3) +### Workspace contexts and capability discovery -Version 3 keeps every rule of version 2 and adds capability discovery, -subscription disposal, workspace contexts, a context on each call, typed host -failures with details, cancellation of host requests, and host +The requests below give a backend capability discovery, subscription +disposal, and workspace contexts. A context is named on each call, host +failures carry typed details, and the host can cancel requests and send host notifications. | Request | Params | Result | Granted by | @@ -2805,25 +2924,24 @@ notifications. | `lithe/host/workspace/getContext` | `{ context: { contextID, generation } }` | `` | `workspace.metadata` | | `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders: [] }` | `workspace.metadata` | -- Core answers `getCapabilities` itself. `services` maps each service the +- Core answers `getCapabilities` itself; `protocolVersion` is the negotiated `litheFunctions` version. `services` maps each service the session may use (the implicit `host`, `subscriptions`, and `commands`, and the granted ones) to `{ methods, notifications }`: the requests it may - send and the [host notifications](#host-notifications-protocol-version-3) + send and the [host notifications](#host-notifications) it may receive. It lists nothing the session may not use, and a host grants only the services it has handlers for, so a listed method never fails for want of one. `backendAuthority` is `"local"`: the backend runs on the machine that runs Lithe. `limits` gives `pendingHostRequests`, `functionResultBytes`, `hostRequestBytes`, `hostResultBytes`, `errorMessageBytes` (the cut of a host failure's message), `errorDetailsBytes`, and `notificationBytes`. A backend must - check `services` instead of assuming a method from the version. + check `services` before it calls a method. - A service entry may also carry `options`: what the host declared for that service. When `editor/registerActions` is granted, `services.editor.options` has `actionIcons` (the icon tokens the host draws) and `limits` gains `editorActionSourceBytes` (the largest document the host sends a collector). When `editor/registerProviders` is granted, `services.editor.options.providerTypes` lists the provider types the host - routes. A host that omits `actionIcons` accepts only the first editor-action - shape. + routes. - A context is one open workspace in one window: ```json @@ -2864,21 +2982,20 @@ notifications. `${stateDir}` is the plugin-wide state. Both survive disabling and updating the plugin and are deleted when it is uninstalled. - A page call may name its context: `lsp.functionRequest.context` - (`{ contextID, generation }`) reaches a version 3 backend as + (`{ contextID, generation }`) reaches the backend as `params.context` of `lithe/function/invoke`. A malformed reference is - `plugin.function.invalidParams`; a backend that negotiated version 1 or 2 - receives the call without it. The host fills it from the page's window. + `plugin.function.invalidParams`. The host fills it from the page's window. - A host failure may carry a code and details: `lsp.hostResponse.error: { message, code?, details? }`. Core passes these - codes to a version 3 backend, each with its own JSON-RPC error code so a - backend can branch on the number alone; any other code, no code, or a - version 2 backend gets `plugin.host.failed`. `details` is an object of at - most 4 KiB serialized (for example `{ currentVersion }` with - `versionConflict`, or `{ retryAfterMilliseconds }` with `rateLimited`); - it reaches a version 3 backend as `error.data.details` and is dropped - otherwise. Details that are not an object, or larger, are dropped too; - the failure itself still reaches the backend. `fixtures/backend/function-protocol.json` (`hostFailures`) - lists the mapping. + codes to the backend, each with its own JSON-RPC error code so a backend + can branch on the number alone; any other code, or no code, gives + `plugin.host.failed`. `details` is an object of at most 4 KiB serialized + (for example `{ currentVersion }` with `versionConflict`, or + `{ retryAfterMilliseconds }` with `rateLimited`); it reaches the backend as + `error.data.details`. Details that are not an object, or larger, are + dropped; the failure itself still reaches the backend. + `fixtures/backend/function-protocol.json` (`hostFailures`) lists the + mapping. | Code | JSON-RPC | Meaning | | --- | --- | --- | @@ -2908,13 +3025,12 @@ notifications. implementations define `-32099` to `-32000` as server errors. `-32000` to `-32009` stay free for backends' own failures, which many JSON-RPC libraries already use from `-32000`, so host codes start at `-32010` and - grow by one; a code is never reused for another meaning. Versions 1 and - 2 keep their numbers. + grow by one; a code is never reused for another meaning. - A backend may cancel its own pending host request with `$/cancelRequest`. Core answers it at once with `-32800` and `plugin.host.cancelled`, frees the slot, and publishes `hostRequestCancelled { hostRequestID }` so the host stops the work; a later `lsp.hostResponse` for that ID is - `invalid_request`. The 17th pending request of a version 3 session is + `invalid_request`. The 17th pending request of a session is `plugin.host.limitExceeded`. - A service that creates a subscription returns a `subscriptionID` of the key alphabet; its notifications name it. Ending the session, disabling the @@ -2923,13 +3039,12 @@ notifications. operation (`plugin.function.recursiveCall`), so a page call never waits on itself through the host. -### Host notifications (protocol version 3) +### Host notifications -The host tells a version 3 backend about changes with JSON-RPC +The host tells a backend about changes with JSON-RPC notifications; the backend answers none of them. The host sends them through Core with `lsp.hostNotify { sessionId, method, params }`, which returns -`{ delivered }`: `false` when the session negotiated an older version, which -then never sees them. +`{ delivered }`, `true` for an accepted notification. | Notification | Params | Granted by | | --- | --- | --- | @@ -2953,7 +3068,7 @@ then never sees them. stopped one is started for it only when it declares `onCommand:`, and receives it once ready. - `run/didExit` reports the end of a Run session the backend started with - [`run/start`](#run-and-debug-sessions-protocol-version-3). `exitCode` is the + [`run/start`](#run-and-debug-sessions). `exitCode` is the process's exit status and `signal` (1 to 127) the signal that ended it; both are absent when the host lost the process. It is sent once per run, only to a session that holds the `run.start` grant. @@ -2964,11 +3079,11 @@ then never sees them. `invalid_request`. `fixtures/backend/function-protocol.json` (`hostNotifications`) holds the cases. -### Extending version 3 +### Extending the host services -From version 3, host capabilities are added within the version, and a -backend discovers them with `getCapabilities` instead of a version number. A later host adds, without a -new version: +Host capabilities are added without a new version, and a backend discovers +them with `getCapabilities` instead of a version number. A later host adds, +without a new version: - a service with its methods and notifications, granted by a new manifest permission, or a method or notification of an existing service; @@ -2978,12 +3093,13 @@ new version: - a failure code, which takes the next unused JSON-RPC number after `-32021`; a number never changes meaning. -A new protocol version is needed only for a change an existing backend -would misread: removing or renaming a method, notification, or field, -changing a field's meaning or type, making an option required, changing a -code's number, or weakening an ordering or lifecycle guarantee above. +A change an existing backend would misread gives `backend` version 1, and the +protocol moves with it: removing or renaming a method, notification, or +field, changing a field's meaning or type, making an option required, +changing a code's number, or weakening an ordering or lifecycle guarantee +above. -So that a version 3 backend works with every host of version 3: +So that a backend works with every host: - It calls a method only when `getCapabilities.services` lists it; another is `-32601`, `plugin.host.unavailable`. @@ -3003,23 +3119,24 @@ So that a version 3 backend works with every host of version 3: `fixtures/backend/function-protocol.json` (`hostRequests`) holds the unsupported-option cases. -### Public document, file and window methods (protocol version 3) +### Public document, file and window methods These methods are implemented by the macOS host. Windows does not advertise `backend` support. `getCapabilities` lists only the intersection of the plugin's permission-derived grants and this host's registered method handlers. -New grants in `lsp.startServer.hostServices` use **full method names**, not service -names. The legacy `workspace` grant still admits only `listContexts`, `getContext` -and `getFolders`; it never grants file reads. The legacy `credentials` grant is -unchanged. Registering another handler cannot replace a method already registered. +Grants in `lsp.startServer.hostServices` for the methods below use **full +method names**, not service names. The `workspace` grant admits only +`listContexts`, `getContext` and `getFolders`; it never grants file reads. The +`credentials` grant admits the three credentials methods. Registering another +handler cannot replace a method already registered. All methods below require `context: {contextID, generation}`. Resource arguments use `{rootID, path}` from that plugin's `getFolders` response; paths use `/` and are relative to the root. A root ID is not a lifetime or an authorization token. Opening the same folder in two windows creates independent context-owned leases; closing one does not invalidate the other, and reopening works with generation 1 -in its new context. Remote contexts are explicitly refused by this first set of +in its new context. Remote contexts are explicitly refused by these methods. Unknown options fail before side effects with `unsupportedOption`. | Methods (prefix `lithe/host/`) | Permission | Params in addition to `context` | Result | @@ -3056,35 +3173,136 @@ that its web editor is already ready. Retry reads after readiness, not edits blindly. `editor.open/reveal` currently applies a range's start as a caret and reports `{kind:"caret",line,column}`, not a fabricated full selection. -Limits: snapshots and file reads expose at most 128 KiB of source text/bytes per -call; oversized snapshots return `limitExceeded`, file reads report truncation. -This leaves room for worst-case JSON escaping under the 1 MiB serialized host -request/result budget. Batches contain at most 1,000 edits/selections. Directory -pages default to 200 and cap at 1,000; search pages default to 100 and cap at 200, -with the search owner's overall 200-result cap. Explicit file reads use -component-wise `O_NOFOLLOW`; listings omit symlinks. Hidden-file rules affect -listing/search/watch, not authorization for explicit reads. Open/browser paths -retain the existing canonical containment checks and their documented -check-to-open race; native backends are not OS-sandboxed. +Limits: snapshots and file reads expose at most 128 KiB of source text/bytes per +call; oversized snapshots return `limitExceeded`, file reads report truncation. +This leaves room for worst-case JSON escaping under the 1 MiB serialized host +request/result budget. Batches contain at most 1,000 edits/selections. Directory +pages default to 200 and cap at 1,000; search pages default to 100 and cap at 200, +with the search owner's overall 200-result cap. Explicit file reads use +component-wise `O_NOFOLLOW`; listings omit symlinks. Hidden-file rules affect +listing/search/watch, not authorization for explicit reads. Open/browser paths +retain the existing canonical containment checks and their documented +check-to-open race; native backends are not OS-sandboxed. + +Machine host requests have a 30-second host deadline. User-choice messages wait +until the user answers, the request is cancelled, or the context/session closes. +Messages retain the per-plugin/window rate and queue limits (5/10 seconds, +3 pending per plugin, 10 per window, 2,000 text characters, 3 actions of 40 +characters). Session exit revokes grants and cancels requests/subscriptions; +old session callbacks cannot affect a replacement. Closing a context cancels its +requests with `contextExpired`. Notifications have a 128-entry per-session queue; +context revisions coalesce to the latest, and overflow ends the session instead +of allowing unbounded memory or silently losing commands. Notification/response +writes share the process write watchdog and stop cleanup with function requests. + +Cancellation or timeout **does not promise rollback**: an edit or external open +may already have committed. The SDK must not replay a mutation on these failures. +On `versionConflict`, reread and recompute; on `contextExpired`, resolve the +original target again, never substitute the focused window. `busy`/`rateLimited` +may be retried with bounds; `permissionDenied`/`readOnly` need user intervention. + +### Plugin-owned native terminals + +[`terminal-backend.plugin.json`](fixtures/backend/terminal-backend.plugin.json) is the +minimal manifest. `terminal.create` grants `lithe/host/terminal/create`, `show`, `close`, `reopen`, +`dispose`, and `onExit`. It requires `backend`; discover the individual +methods through `getCapabilities` before using them. macOS implements this +service. Windows has no handler and does not advertise its methods. The +plugin's web page calls its backend through `functions.invoke`; the backend +uses these host requests with the invocation's workspace context. There is +no direct web `terminal.*` bridge. + +| Method (prefix `lithe/host/terminal/`) | Params, in addition to `context` | Result | +| --- | --- | --- | +| `create` | `executable`, optional `arguments`, `title`, `cwd`, `environment`, `show`, `retainedContextBytes` | `{terminalID,generation:1}` | +| `show` | `terminalID` | `null` | +| `close` | `terminalID`, optional `retainedContextBytes` | `{retainedContextBytes}` after bounded native cleanup | +| `reopen` | `terminalID`, optional `show` | `{terminalID,generation,restoredContextBytes}` | +| `dispose` | `terminalID` | `null` after closing and forgetting the handle | +| `onExit` | `terminalID` | `{subscriptionID}` | + +`executable` is an absolute path or a bare command resolved by the native +terminal's PATH lookup; relative paths containing separators are refused. +`arguments` is an argv array, never shell text. `cwd` is a `{rootID,path}` +workspace directory resource (an empty path selects the folder root); without +it, the workspace root is used. The native adapter rejects a canonical cwd +outside that workspace. `environment` maps variable names to string overrides +or `null` removals. Process-ownership scope variables cannot be overridden. +`show` defaults to true; it selects the terminal in its own window without +raising a background window. With `show:false`, creation and reopening preserve +the current terminal selection and do not reveal the terminal panel or request +input focus; the native tab is mounted when the plugin explicitly calls `show`. +Unknown options fail before any side effect. + +Creation and reopening ask for execution consent with the program, argv and +working directory, all available in a scrollable, selectable native text view, +then revalidate context and cancellation before launching +through the existing PTY terminal module. The response waits for user choice +without a machine-operation deadline, but cancellation/session/context closure +still ends it. Native launch and process cleanup retain their own bounds. +The host never types a launch into an existing shell or reads other terminals. +There is no `sendText`, user-terminal enumeration, web emulator, or Docker +special case. First release is local authority only; a remote context fails +with `plugin.host.authorityUnsupported` instead of starting anything locally. + +Each handle belongs to one plugin backend session and one context generation. +A handle from another plugin, context or replacement backend is `notFound`. +At most eight handles per plugin are retained, including closed handles; +`dispose` releases one. Limits are 256 arguments, 16 KiB per argument or +environment value, 64 environment entries, 64 KiB combined launch data, and +128 UTF-8 bytes in a nonempty single-line title. `busy` refuses concurrent +close/reopen/dispose on the same handle. Reopening a running terminal is +`preconditionFailed`; showing a closed terminal asks the plugin to reopen it. + +#### Close, retained context and reopen + +`close` ends the process tree and removes the native tab, keeping the handle's +launch configuration and a plain-text suffix of its displayed terminal output. +`retainedContextBytes` is a nonnegative UTF-8 byte budget: default **16384**, +maximum **262144**, and **0** disables history. It can be chosen at create or +changed at close; over-limit values are refused, never silently expanded. +The result reports actual retained bytes, which can be smaller to avoid cutting +a Unicode scalar. The history is a rendering snapshot, not input events, raw +escape sequences, a resumable process image, or shell state. Secrets already +printed or echoed by a program can be part of that display; choose 0 when no +history should be retained. + +`reopen` uses the saved executable, argv, cwd and environment in a **new** PTY +process. It feeds retained plain text to the display before spawning, never to +stdin. Shell variables, unsubmitted input, process state and automatic command +replay are not restored. Natural exit and user tab close are also reopenable; +the snapshot is bounded by the same budget. It keeps `terminalID` and increments +`generation`; late exit callbacks of previous generations are ignored. These +handles/history exist only in memory, not across app/backend restart or +workspace close. No state is written to the app bundle or plugin package. + +Natural exit and user tab close run the same bounded native cleanup before +retaining the budgeted snapshot and releasing the complete native session and +render buffer. The handle is `busy` during cleanup; its exit notification is +sent after cleanup, so a subscriber can reopen immediately. A zero-byte budget +retains no display text on any close or exit path. + +`dispose` closes any live process and permanently forgets the launch settings +and history. Ending the backend session, disabling/updating the plugin or +closing/replacing its workspace does the same for only its owned terminals; +user-created terminals are unaffected. Closing a page alone does not close a +completed terminal creation. Cancellation before launch creates nothing; +cancellation or context loss during launch closes the newly created process. +Cancelled creation/reopen is not safe to retry automatically. -Machine host requests have a 30-second host deadline. User-choice messages wait -until the user answers, the request is cancelled, or the context/session closes. -Messages retain the per-plugin/window rate and queue limits (5/10 seconds, -3 pending per plugin, 10 per window, 2,000 text characters, 3 actions of 40 -characters). Session exit revokes grants and cancels requests/subscriptions; -old session callbacks cannot affect a replacement. Closing a context cancels its -requests with `contextExpired`. Notifications have a 128-entry per-session queue; -context revisions coalesce to the latest, and overflow ends the session instead -of allowing unbounded memory or silently losing commands. Notification/response -writes share the process write watchdog and stop cleanup with function requests. +`onExit` supports one live subscription per handle. Its event is +`{type:"exit",terminalID,generation,exitCode,reason}`, with an integer or null +exit code and `reason:"exited"|"closed"`. Each generation publishes one outcome. +Subscribing after exit delivers its recorded outcome once; the subscription +continues across reopen. Disposing it stops notifications and keeps the +terminal open. A disposed terminal has no further events. -Cancellation or timeout **does not promise rollback**: an edit or external open -may already have committed. The SDK must not replay a mutation on these failures. -On `versionConflict`, reread and recompute; on `contextExpired`, resolve the -original target again, never substitute the focused window. `busy`/`rateLimited` -may be retried with bounds; `permissionDenied`/`readOnly` need user intervention. +The executable and argv remain plugin-owned semantics. A container plugin can +launch the user's Docker CLI with an explicit engine and container ID; another +tool can launch its interactive client through exactly the same service. +Neither requires a fake Build/Run provider. -### Run and debug sessions (protocol version 3) +### Run and debug sessions A backend can start a session that the host presents as its own: a Run session in the Run tool window, or a debug session on one of the plugin's own adapters. @@ -3113,7 +3331,7 @@ context it started in has closed. normalized relative path of a file inside the plugin package. Arguments, the working directory (relative to the workspace root and an existing directory inside it) and the environment have the bounds of - [run preparation](#provider-run-preparation-buildproviders-4). An unknown + [run preparation](#provider-run-preparation). An unknown `process` field is refused. - `temporaryFiles` is at most four `{name, contents}` files of up to 4 MiB of text each. The host writes them into its own temporary directory before the @@ -3124,7 +3342,7 @@ context it started in has closed. know where the host keeps temporary files. - `debug` is `{configurationType, target}` and makes the run a debug session. `configurationType` is a public debug adapter alias, as in - [registry connection debugging](#registry-connection-debugging-buildproviders-11). + [registry connection debugging](#registry-connection-debugging). `target` is `{kind: "connection", port}`: the process listens for a debugger on `port` (1 to 65535; the backend puts it into the arguments), the host waits for it and attaches the adapter. Or `{kind: "launch"}`: the adapter starts the @@ -3136,7 +3354,7 @@ context it started in has closed. `context`, and the backend answers with a new `run/start`, so the backend prepares the restart. Without `rerun` the entry cannot be rerun. - The reply carries an opaque `runID`. The host sends - [`run/didExit`](#host-notifications-protocol-version-3) when the process ends. + [`run/didExit`](#host-notifications) when the process ends. `run/stop` stops a run by `runID` or a debug session by `sessionID` and answers `null`, also when it already ended. Cancelling the host request is the same as @@ -3174,7 +3392,7 @@ temporary directory and are covered by Core's own tests. The `languageServer` host service lets a plugin backend talk to the language servers its own plugin declares. It is granted by the `languageServer.request` -permission, only for a `languageServers` 3 server that lists `requestMethods` +permission, only for a language server that lists `requestMethods` or `forwardNotifications`, and each method is granted by its full name. `getCapabilities` lists the methods this host implements. Windows does not advertise `backend` support. @@ -3189,7 +3407,7 @@ plugin is `plugin.host.foreignContribution`. | `notify` | `method`, `params?` | `{delivered}`. `false` only for `workspace/didChangeConfiguration` sent before the server is initialized; the value is then sent with `initialized`. | | `setStatus` | `state` (`preparing`, `ready`, `degraded`, or `failed`), `message?`, `percentage?` (a whole number from 0 to 100) | `{}` | -- `request` and `notify` are checked by the [session policy](#plugin-owned-sessions-languageservers-3): +- `request` and `notify` are checked by the [session policy](#plugin-owned-sessions): `plugin.host.methodNotDeclared` for a method or command the server did not declare, `plugin.host.languageServerUnavailable` when the server is not running for this context, `plugin.host.languageServerError` when the server @@ -3262,14 +3480,12 @@ calls the backend; the function must already be registered on the session. the document closes, and it asks again when the document is reopened. Ending the session or closing the context withdraws the provider. -### Backend-only commands (`ui` 4) +### Backend-only commands -`requires.ui:4` permits `ui.commands` without `toolWindowID` and allows an empty -or omitted `toolWindows` array. Such a command requires `backend:2`, the existing -`ui.contribute` and `backend.launch` permissions, and its `onCommand:` event. -Versions 1-3 retain their window-required behavior. An old host rejects ui 4 at -installation, rather than accepting a command it cannot execute. See -`fixtures/backend/command-backend.plugin.json`. +A command in `ui.commands` may omit `toolWindowID`, and `toolWindows` may be +empty or omitted. A command without `toolWindowID` requires `backend`, the +`ui.contribute` and `backend.launch` permissions, and its `onCommand:` +event. See `fixtures/backend/command-backend.plugin.json`. The command appears through the ordinary workbench command contributions and sends `commands/didRun` without opening a dummy tool window. Commands arriving @@ -3280,7 +3496,7 @@ plugins. `didRun` remains an event, not an acknowledgement that the command's business action succeeded; a future result-bearing execute API must use the existing request lifecycle rather than infer success from notification delivery. -### Independent editor actions (protocol version 3) +### Independent editor actions Editor actions are a host presentation capability of an ordinary tool backend, not an LSP, Build, Run, Test or Debug provider. A play icon is presentation, not @@ -3288,7 +3504,7 @@ an execution kind. The host understands the bounded range/title/icon/invocation shape; HTTP/SQL parsing and execution remain private to the plugin. Native domain workflows still use their own typed contracts, never a private-function substitute. -A backend declares `backend: 2`, `documents.read` and `editor.actions`, discovers +A backend declares `backend`, `documents.read` and `editor.actions`, discovers `lithe/host/editor/registerActions` with `getCapabilities`, then registers per workspace context: @@ -3318,9 +3534,8 @@ workspace context: with workspace context and `{document:{documentID,version,path},text}`. Version is the same public buffer version as `documents/getSnapshot`, not a WebView revision. Source is the dirty live buffer, capped at the host's - `limits.editorActionSourceBytes` (1 MiB on macOS; 128 KiB when the host does - not report the limit). Remote workspaces and read-only preview surfaces are not - served by this first implementation. + `limits.editorActionSourceBytes` (1 MiB on macOS). Remote workspaces and + read-only preview surfaces are not served. - Collection is read-only by contract: no network request or business execution. Edits invalidate the old actions immediately and cancel older collection work; the host debounces fresh collection and rejects stale or withdrawn results. @@ -3332,14 +3547,12 @@ workspace context: cannot split surrogate pairs. Each range stays in the exact input document. `id` is 1 to 128 bytes without controls, unique per answer; `title` is 1 to 200 characters on one line. `icon` is a host-owned presentation token from - `services.editor.options.actionIcons` (`run` or `run-all` when the host does not - report the list). At most 1000 actions and 1 MiB serialized; malformed answers + `services.editor.options.actionIcons`. At most 1000 actions and 1 MiB serialized; malformed answers are refused whole. Same-line actions share a menu rather than silently replacing each other. - An action may carry `alternatives`: at most 4 `{id,title,icon,invoke}` entries with the same rules and IDs unique within the action, offered in the action's - menu after the action itself (for example Debug or Test beside Run). Hosts that - omit `actionIcons` refuse an answer with alternatives. + menu after the action itself (for example Debug or Test beside Run). - The macOS host draws `run`, `run-all`, `debug`, `test`, `test-passed`, `test-failed`, `test-skipped`, `navigate-super` and `navigate-implementations`. An icon names a picture, not an operation: a test icon does not make the @@ -3355,7 +3568,7 @@ workspace context: - A click result may carry `{reveal:{locations:[{uri,range}]}}`: 1 to 100 absolute URIs with zero-based LSP ranges. The host opens a single location and lists several in its navigation results. Other result members stay the - plugin's own. Hosts that omit `actionIcons` ignore the result. + plugin's own. - A click that runs or debugs code starts it through the host's run and debug methods (`lithe/host/run/start`, `lithe/host/debug/start`) once `getCapabilities` lists them, so the run appears in the host's Run and Debug @@ -3374,22 +3587,28 @@ workspace context: function; the host still never starts code because of an icon, so the plugin decides what the action does and still uses `lithe/host/run/start` or `lithe/host/debug/start` to run it. -- Registration/collection validation is shared Core command - `plugins.editorActions.decode`; `version: 2` with the host's icons and source - bound admits alternatives and click results (`kind: invokeResult`), and version 1 - keeps the first shape. Cases: `fixtures/editor/editor-actions-v2.json`. Public host - failures retain protocol 3's existing - distinct codes (`invalidParams`, `permissionDenied`, `versionConflict`, etc.). - No tool-private error or result is interpreted as a build/test result. -- Existing `commands/didRun {commandID,context?}` is unchanged. A command-palette - entry can read the document/selection and call the same plugin implementation. -- This is an additive host method within protocol 3, not a protocol-version bump. - Old hosts do not advertise it. A plugin can retain its existing command/window - entry point; a package requiring `editor.actions` is deliberately refused by an - older host that does not recognize that permission, rather than silently granted. - Grammar capability admission is separate. SDK/contract mirrors still need sync. - -### Editor language-feature providers (protocol version 3) +- Registration/collection validation is the shared Core command + `plugins.editorActions.decode`. Its request is + `{version, kind, icons, sourceBytes, text, value}`: `version` is required and + is `0` (Core accepts only `0`, otherwise `Unsupported editor action + validation version.`), `kind` is `registration`, `actions` or + `invokeResult`, and `kind: actions` also needs the host's `icons` and + `sourceBytes` (the `actionIcons` and source bound it reports). It admits + alternatives and click results (`kind: invokeResult`). Cases: + `fixtures/editor/editor-actions.json`. Public host failures use the + distinct codes (`invalidParams`, `permissionDenied`, `versionConflict`, + etc.). No tool-private error or result is interpreted as a build/test + result. +- `commands/didRun {commandID,context?}` is the command notification. A + command-palette entry can read the document/selection and call the same + plugin implementation. +- A host that does not list `lithe/host/editor/registerActions` in + `getCapabilities` presents no editor actions, so a plugin keeps its + command/window entry point. A package requiring `editor.actions` is refused by + a host that does not recognize that permission, rather than silently granted. + Grammar capability admission is separate. + +### Editor language-feature providers A provider adds a language feature the file's language server does not supply: a cross-file fact only the plugin knows, such as a key in a template @@ -3399,7 +3618,7 @@ language server. Where a language server answers, its answer comes first and plugin results only add to it; a plugin that repeats what the server already says shows the user the same thing twice. -A backend declares `backend: 2`, `documents.read` and `editor.providers`, +A backend declares `backend`, `documents.read` and `editor.providers`, finds `lithe/host/editor/registerProviders` and `services.editor.options.providerTypes` with `getCapabilities`, then registers per workspace context: @@ -3463,11 +3682,12 @@ per workspace context: - Validation is shared Core command `plugins.editorProviders.decode`. Cases: `fixtures/editor/editor-providers.json`. Example package: `fixtures/editor/editor-providers.plugin.json`. -- This is an additive host method within protocol 3, not a version bump. A - package requiring `editor.providers` is refused by an older host that does - not recognize the permission. +- A host that does not list `lithe/host/editor/registerProviders` in + `getCapabilities` presents no editor providers. A package requiring + `editor.providers` is refused by a host that does not recognize the + permission. -### Cross-plugin calls (protocol version 4) +### Cross-plugin calls A plugin function is private to its plugin unless the plugin exports it. An exported function can be called by another enabled plugin whose manifest @@ -3482,25 +3702,25 @@ on every platform that runs plugin backends. #### Declaring exports and calls ```json -"requires": { "pluginFunctions": 1, "backend": 2, "serverProcess": 1 }, +"requires": { "pluginFunctions": 0, "backend": 0 }, "pluginFunctions": { "exports": [{ "id": "document.format", "version": 1, "context": "workspace" }], "calls": [{ "id": "project.describe", "version": 2 }] } ``` -- `pluginFunctions` needs `pluginAPIVersion` 3 or later, a `backend`, and - `requires.pluginFunctions: 1`, `backend: 2` and `serverProcess: 1`. The - section holds at least one of `exports` and `calls`, each at most 32 - entries sorted by `id`, with no `id` twice. Anything else, including an - unknown field, is `plugin.manifest.invalid`. `requires` lists - `pluginFunctions` exactly when the section exists. +- `pluginFunctions` needs a `backend`, and `requires` lists + `pluginFunctions: 0` and `backend: 0`. The section holds at least one of + `exports` and `calls`, each at most 32 entries sorted by `id`, with no `id` + twice. Anything else, including an unknown field, is + `plugin.manifest.invalid`. `requires` lists `pluginFunctions` exactly when + the section exists. - An export is an object, never a bare function ID. `id` is a function ID as in [References and IDs](#references-and-ids). `version` is the exported contract version, an integer from 1 to 1000; an incompatible change to the params or result takes a new version. `context` is a closed - set; version 1 knows only `"workspace"`: the function runs only for a - local workspace context, on a call Core relays. A missing or unknown + set that holds only `"workspace"`: the function runs only for a local + workspace context, on a call Core relays. A missing or unknown `context` refuses the whole manifest with `plugin.manifest.invalid`, so a function whose context is unstated is never callable. - A call is `{ id, version }`. It names a function ID and the version the @@ -3515,7 +3735,7 @@ on every platform that runs plugin backends. `plugin.function.wrongPlugin`. - A plugin that exports needs no page, command or `onWorkspaceOpen` to start its backend: a call starts it. A plugin that only calls needs one of - those, as every [`backend` 2](#backend-2-workspace-contexts) does. + those, as every [`backend`](#backend-lifecycle-and-workspace-contexts) does. - `pluginFunctions` grants no manifest permission. The caller's `calls` and the provider's `exports` are the two sides' consent, and the host grants a plugin that declares `calls` the `functions` host service, @@ -3542,11 +3762,10 @@ on every platform that runs plugin backends. #### Discovery and invocation -A backend that negotiated protocol version 4, and whose plugin declares at -least one call, may send two requests. A backend on version 3 or earlier gets -`-32601`, `plugin.host.unavailable`; a plugin that declares no call gets -`-32010`, `plugin.host.permissionDenied`. `getCapabilities` lists the -methods the session may call under the `functions` service. +A backend whose plugin declares at least one call may send two requests; a +plugin that declares no call gets `-32010`, `plugin.host.permissionDenied`. +`getCapabilities` lists the methods the session may call under the `functions` +service. | Request | Params | Result | | --- | --- | --- | @@ -3563,7 +3782,7 @@ methods the session may call under the `functions` service. `plugin.function.notDeclared`. A listed provider's backend need not be running. - `invoke` calls one provider. `context` is required: a workspace context - the caller learned from the [workspace service](#workspace-contexts-and-capability-discovery-protocol-version-3), + the caller learned from the [workspace service](#workspace-contexts-and-capability-discovery), checked like every call context. `params` is a JSON object and defaults to `{}`. `timeoutMilliseconds` is a positive integer, defaults to 60 000 and is capped at 600 000. A field the method does not define is `-32018`, @@ -3647,8 +3866,7 @@ records the values, or calls after the call ends. one that never existed), the call is refused with `plugin.host.cancelled` and the message "The call this request belongs to is not running." - If it is omitted, the call counts as a child of every relayed call the - backend is serving at that moment. This is kept for compatibility with - backends written before the field; SDKs should always fill it in. + backend is serving at that moment; SDKs should always fill it in. Cancelling a call, its timing out or ending, or the end of its caller's session, cancels the calls made as its children, down the chain, and each @@ -3687,10 +3905,8 @@ as for any call (see [Invocation](#invocation)) and adds one top-level field: inside the caller's own `params` is ordinary data and is passed unchanged. - A call the host itself started has no `caller` field. Absent means the host. -- A provider that negotiated version 3 or earlier never receives the field. - A provider that relies on it must negotiate version 4; a provider that - keeps per-caller state (for example a slot per calling plugin) keys it by - this ID, which cannot be forged. +- A provider that keeps per-caller state (for example a slot per calling + plugin) keys it by this ID, which cannot be forged. #### Providers that are not running @@ -3717,7 +3933,7 @@ Disabling, enabling, replacing and uninstalling a plugin take effect for the relay at once and need no restart of Lithe or of any other plugin. The host sends the plugin and workspace-context snapshot with the Core command `lsp.configureFunctionRelay` -([Rust Core API](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md)), +([Rust Core API](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md)), and calls the new snapshot no longer allows end at once with the errors above. Installing or updating a plugin that has a backend is different on a host that @@ -3727,34 +3943,23 @@ restart of Lithe. ### Compatibility -- Packages of every API level are unchanged: private functions add no - manifest field. `pluginFunctions` is a new capability - (`requires.pluginFunctions: 1`), so only a plugin that exports or calls +- Private functions add no manifest field. `pluginFunctions` is a capability + (`requires.pluginFunctions: 0`), so only a plugin that exports or calls functions is refused by a host that cannot relay them. -- A backend written before this protocol does not answer `litheFunctions`. - It keeps serving its language and its `litheBuild` requests, receives no - function calls, and its function requests are answered with `-32601`. -- Up to version 3, every protocol version added host capabilities. From - version 3 a new host capability or optional field is added within the - version ([Extending version 3](#extending-version-3)); only a change an - existing backend would misread needs a new protocol version, offered next - to the old one. -- A backend that answers `version: 1` keeps working unchanged: version 2 - only adds host services. A backend that answers `version: 2` keeps - working unchanged: version 3 only adds methods, notifications, the call - context, and failure codes and details, all of which it never sees. A - backend that answers `version: 3` keeps working unchanged: version 4 only - adds the relay methods, which it must not call, and the `caller` field - of a relayed call, which it never receives. -- `requires.backend: 1` packages keep their meaning; a new behavior of the - backend capability is `backend` 2, never a silent change of version 1. -- `fixtures/backend/function-protocol.json` holds the golden cases, including the - host requests (`hostRequests`), call contexts (`invocationContexts`), the - caller of a relayed call (`invocationCallers`), host - failure codes (`hostFailures`), relay failure codes (`relayErrors`), and - host notifications (`hostNotifications`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, - deadline, late reply, revocation, and compatibility with a build-only - backend. +- A backend that does not answer `litheFunctions` (a build-only backend) keeps + serving its language and its `litheBuild` requests and receives no function + calls; its function requests are answered with `-32601`. +- A new host capability or optional field is added within the version + ([Extending the host services](#extending-the-host-services)); only a change + an existing backend would misread gives `backend` version 1, and the protocol + moves with it. +- `fixtures/backend/function-protocol.json` holds the golden cases: host + requests (`hostRequests`), call contexts (`invocationContexts`), the caller + of a relayed call (`invocationCallers`), host failure codes + (`hostFailures`), relay failure codes (`relayErrors`), and host + notifications (`hostNotifications`). Rust Core tests run them, and a + scripted backend covers routing, result, failure, cancellation, deadline, + late reply, revocation, and a build-only backend. ### Official backend SDK @@ -3792,11 +3997,11 @@ The Node.js and Java SDKs each have a sample build provider (`sdk/*/examples/sample-build`) that answers one end-to-end scenario driven through a real Core session; the Rust SDK's sample backend (`sdk/rust/examples/sample-backend`) answers a plugin backend scenario in which -the test plays the host: it serves the credential requests and the protocol 3 +the test plays the host: it serves the credential requests and the workspace, file watch and window requests, delivers a command and a context change, and checks that cancellation reaches the host. That repository runs them against the Lithe commit its contract copy pins, and weekly against the -latest `preview`, the branch Lithe changes merge into. +latest development branch of Lithe. No SDK adds a wire field: a backend written without one is equally valid. @@ -3809,13 +4014,12 @@ command payload is a Core `invalid_request` failure instead. | Code | Reported by | Meaning | | --- | --- | --- | | `plugin.manifest.invalid` | Core | Malformed manifest, unknown field, or broken cross reference | -| `plugin.schema.unsupported` | Core | `schemaVersion` is not `2` | +| `plugin.schema.unsupported` | Core | `schemaVersion` is not `0` | | `plugin.api.unsupported` | Core | `pluginAPIVersion`, or a capability version in `requires`, is not supported by this Core build or, when the host sent `hostCapabilities`, by the host | | `plugin.permission.unknown` | Core | Unknown permission | | `plugin.permission.undeclared` | Core | A provider or build system is declared without its permission | | `plugin.activation.invalid` | Core | Missing or orphan activation event | -| `plugin.runtime.unsupported` | Core | Unknown runtime dependency, or a launch runtime that cannot start an entry file | -| `plugin.runtime.undeclared` | Core | From `serverProcess` 4, a runtime tool that `requiresTools` does not list; `field` names where it is used | +| `plugin.runtime.undeclared` | Core | A tool that a launch, a runtime dependency or a debug adapter names and `requiresTools` does not list; `field` names where it is used | | `plugin.run.invalidStart` | Core | The params of `lithe/host/run/start` are malformed, name a tool the manifest does not require, or leave the workspace or package; `field` names the part | | `plugin.debug.invalidStart` | Core | The params of `lithe/host/debug/start` are malformed, or `configuration` holds a key the host reserves; `field` names the part | | `plugin.debug.unknownAdapter` | Core | `lithe/host/debug/start` names an adapter the plugin does not declare | @@ -3834,35 +4038,34 @@ command payload is a Core `invalid_request` failure instead. | `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.launch.notBackend` | Core | `decodeLaunch` named a language server that the plugin backend does not launch | -| `plugin.launch.invalidResult` | Core | A [backend-resolved launch](#backend-resolved-launch-serverprocess-3) answer is malformed, unknown in a field, or over a bound | +| `plugin.launch.invalidResult` | Core | A [backend-resolved launch](#backend-resolved-launch) answer is malformed, unknown in a field, or over a bound | | `plugin.launch.outsidePackage` | Core | The answer's packaged executable leaves the plugin package | | `plugin.launch.undeclaredTool` | Core | The answer's tool is not one of the plugin's tool definitions | | `plugin.launch.unwritablePath` | Core | The answer writes outside the state, workspace state, log, and temporary directories | | `plugin.grammar.invalid` | Core | A language's grammar file is not a TextMate grammar Core accepts; `field` names the failing part | | `plugin.grammar.unavailable` | Core | The plugin declares no grammar for the language | | `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.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/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.schemaUnsupported` | Core | The index `schemaVersion` is not `0` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | | `plugin.index.duplicate` | Core | An index lists the same plugin ID more than once | | `plugin.index.platformUnsupported` | Core | No version has an archive for this platform (availability, not a rejection) | -| `plugin.index.apiUnsupported` | Core | Only versions needing a newer plugin API, or a capability the host does not run, list this platform (availability, not a rejection) | +| `plugin.index.apiUnsupported` | Core | Only versions needing a plugin API or a capability version the host does not run list this platform (availability, not a rejection) | | `plugin.source.invalid` | Core | A marketplace source URL cannot be used | | `plugin.package.mismatch` | Core | A downloaded package's ID or version differs from its index entry | | `plugin.archive.checksumMismatch` | Host | A downloaded archive does not match the index SHA-256 | | `plugin.download.failed` | Host | An index or archive could not be downloaded | | `plugin.signature.missing`, `plugin.signature.invalid`, `plugin.signature.untrusted` | Host | Package signature problems | | `plugin.launch.missing` | Host | The packaged language server is missing or not executable | -| `plugin.runtime.missing` | Host | A declared runtime (for example Node.js) cannot be found, or is older than its `minimumVersion`, when the server starts | -| `plugin.legacyNative.updateRequired` | Host | An installed schema-1 native package must be replaced by its declarative version | +| `plugin.runtime.missing` | Host | A tool a launch needs cannot be found, or is older than its `minimumVersion`, when the server starts | | `plugin.web.unknownMethod` | Host | A plugin page called a bridge method the host does not implement | | `plugin.web.invalidRequest` | Host | A bridge call has malformed parameters | | `plugin.web.forbidden` | Host | A bridge call names an instance, run, or path outside the page's plugin or workspace, or the page is closed | | `plugin.function.invalidReference`, `plugin.function.invalidParams`, `plugin.function.unavailable`, `plugin.function.wrongPlugin`, `plugin.function.unknown` | Core | A [plugin-function](#plugin-functions) call is refused before it reaches the backend | | `plugin.function.failed`, `plugin.function.invalidResult`, `plugin.function.revoked` | Core | A plugin-function call ended without a usable result | | `plugin.function.invalidRegistration` | Core | A backend's register or unregister request was refused as a whole | -| `plugin.function.notDeclared`, `plugin.function.notExported`, `plugin.function.providerDisabled`, `plugin.function.versionMismatch` | Core | A [relayed call](#cross-plugin-calls-protocol-version-4) is refused: the caller did not declare it, the provider does not export (or has not registered) the function, the provider is not enabled, or the declared and exported versions differ | +| `plugin.function.notDeclared`, `plugin.function.notExported`, `plugin.function.providerDisabled`, `plugin.function.versionMismatch` | Core | A [relayed call](#cross-plugin-calls) is refused: the caller did not declare it, the provider does not export (or has not registered) the function, the provider is not enabled, or the declared and exported versions differ | | `plugin.function.callCycle`, `plugin.function.depthExceeded`, `plugin.function.tooManyCalls`, `plugin.function.payloadTooLarge` | Core | A relayed call is refused by a bound: the provider is already on the call chain, the chain is too long, the caller has too many relayed calls running, or the params or result are too large | | `plugin.function.providerUnavailable` | Core | The provider's backend did not start in time, exited, or withdrew the function while a relayed call waited for it | | `plugin.project.unavailable` | Core | The plugin declares no such project generator | @@ -3872,11 +4075,11 @@ command payload is a Core `invalid_request` failure instead. | `plugin.project.archiveInvalid` | Core | A downloaded archive is not a zip, has an entry outside the destination, a symbolic link, a file that already exists, or expands beyond the size limit | | `plugin.project.generationFailed` | Host | A command step failed or timed out, a download failed, or the project could not be moved into place | | `plugin.function.recursiveCall` | Core | A backend asked the host to dispatch a domain operation or function | -| `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | -| `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | -| `plugin.host.invalidParams` | Core | A host-service request has malformed params, or (before version 3) unknown ones | -| `plugin.host.unsupportedOption` | Core, Host | From version 3, a host-service request names a field the host does not implement; `details.option` names it | -| `plugin.host.failed` | Core | The host could not complete a host-service request, or (before version 3) too many were unanswered | +| `plugin.host.unavailable` | Core | A backend asked for a host method the protocol does not define | +| `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services) its plugin's permissions do not grant | +| `plugin.host.invalidParams` | Core | A host-service request has malformed params | +| `plugin.host.unsupportedOption` | Core, Host | A host-service request names a field the host does not implement; `details.option` names it | +| `plugin.host.failed` | Core | The host could not complete a host-service request | | `plugin.host.contextExpired` | Host | The request names a workspace context that closed or has a newer generation | | `plugin.host.authorityUnsupported` | Host | The context's authority (for example a remote workspace) does not implement the operation | | `plugin.host.versionConflict` | Host | A versioned write names an older version than the current one | @@ -3905,15 +4108,13 @@ this contract. Its adapter maps `languages[].extensions` to `languages[].fileExtensions`, `lsp.server`/`lsp.args` to `languageServers[]`, `lsp.requiredExecutables: ["node"]` to `runtimeDependencies`, and `runActions.manifestFiles` to `test.projectMarkers`. Composer script discovery -is Windows-only behavior outside v1. +is Windows-only behavior outside this contract. -## API level 2: first-stage host-owned workbench UI +## Host-owned workbench UI -`pluginAPIVersion: 2` adds optional `ui` contributions and the `ui.contribute` -permission. API level 1 language packages continue to work. UI-only packages may -omit `languages`; every package must still declare a language provider or a -nonempty UI catalog. See `fixtures/manifest/hello-lithe.plugin.json` and the UI cases in -`fixtures/manifest/invalid-manifests.json`. +`ui` contributions need `requires.ui: 0` and the `ui.contribute` permission. +UI-only packages may omit `languages`. See `fixtures/manifest/hello-lithe.plugin.json` +and the UI cases in `fixtures/manifest/invalid-manifests.json`. - `ui.toolWindows`: 1–64 entries with stable `id`, `title`, initial `placement` (`left`, `bottom` or `right`), integer `order` (default 0), and plain `text`. @@ -3925,8 +4126,8 @@ nonempty UI catalog. See `fixtures/manifest/hello-lithe.plugin.json` and the UI fields are rejected with `plugin.manifest.invalid`. - Titles are at most 256 UTF-8 bytes and window text at most 65536 UTF-8 bytes. Text is displayed literally; it is never evaluated as HTML or a script. - A window shows exactly one of `text` and, from `ui` 2 on, - [`web`](#web-content-ui-2). + A window shows exactly one of `text`, [`web`](#web-content) and + [`native`](#host-rendered-native-content). - Declare `onView:` and `onCommand:` for every window and command, plus `ui.contribute`. The existing permission and activation error codes apply. IDs and presentation order are normalized deterministically. @@ -3940,14 +4141,10 @@ nonempty UI catalog. See `fixtures/manifest/hello-lithe.plugin.json` and the UI Unknown and temporarily absent contributions never execute an action; placement/order preferences remain available if the plugin returns. -API level 2 is the first UI Contribution infrastructure stage. Its static text -windows and open-own-window commands are a supported subset, not the final public -extension API. The required window/text/command-target fields and rejection of -packages without language or UI contributions remain unchanged for v2 compatibility. The macOS host supports mouse docking through the existing shared dock state; that presentation behavior does not grant additional plugin execution capabilities. -### Web content (`ui` 2) +### Web content A plugin does not have to bring a UI: build systems get a host-drawn window (see [Presentation](#presentation)), and text windows need no code. A plugin @@ -3955,15 +4152,13 @@ that wants its own UI can show its own page in a tool window instead of `text`: ```json -"requires": { "ui": 2 }, +"requires": { "ui": 0 }, "permissions": ["ui.contribute", "ui.web"], "ui": { "toolWindows": [{ "id": "…", "title": "…", "placement": "right", "web": { "entry": "ui/index.html" } }] } ``` -- `web` needs `pluginAPIVersion` 3, `requires.ui: 2`, and the `ui.web` - permission, which the install review lists like every permission. - `requires.ui: 2` without a web window is `plugin.manifest.invalid`, so a - text-only plugin stays installable on hosts that run only `ui` 1. +- `web` needs `requires.ui: 0` and the `ui.web` permission, which the install + review lists like every permission. - `entry` is a package-relative `.html` path. `fixtures/ui/web-panel.plugin.json` is the example; the web cases of `fixtures/manifest/invalid-manifests.json` are the rejections. @@ -3991,9 +4186,7 @@ Hosts load a page under these rules: `--lithe-*` CSS colour and font variables, updated with the theme. Before the page's scripts run, the host defines `window.lithe`: -`lithe.version`, the bridge version (2 on current macOS hosts; version 2 -adds the file dialogs to version 1, so a page checks it before offering -them), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, +`lithe.version`, the bridge version (`0`), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, `theme`), `lithe.request(method, params)`, which returns a promise of the result or rejects with `{ code, message }`, and `lithe.on(event, listener)`, which returns an unsubscribe function. A page reaches only its own plugin's @@ -4016,8 +4209,8 @@ their output appears in the host's Build output window. | `functions.list` | `{ instance? }` | `{ functions }`: sorted IDs of the functions the instance's provider, or without `instance` the plugin's [backend](#plugin-backend), currently offers; starts that process | | `functions.invoke` | `{ instance?, function, params?, call? }`, without `instance` a call to the plugin's backend; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | | `functions.cancel` | `{ call }` | `{}`; a call still awaiting consent never starts, a call waiting for its provider to start ends at once while the start goes on, and a call that already ended is ignored | -| `dialog.openFile` (bridge 2) | `{ title?, extensions? }` | `{ path }`: the absolute path of the file the user chose, or `null` when the dialog was cancelled | -| `dialog.saveFile` (bridge 2) | `{ title?, suggestedName?, extensions? }` | `{ path }` as for `dialog.openFile`; the host has asked before replacing an existing file, and nothing is written | +| `dialog.openFile` | `{ title?, extensions? }` | `{ path }`: the absolute path of the file the user chose, or `null` when the dialog was cancelled | +| `dialog.saveFile` | `{ title?, suggestedName?, extensions? }` | `{ path }` as for `dialog.openFile`; the host has asked before replacing an existing file, and nothing is written | | Event | Payload | | --- | --- | @@ -4050,78 +4243,36 @@ consent, and closing it cancels them. Reusing a `call` ID is consent as the host's provider requests: once given for a plugin and workspace, it holds until the project is closed. Calls to the plugin's backend ask for none; the install review covered it. -macOS implements web content with WKWebView and advertises `ui: [1, 2, 3]` -(including the native controls described below). -Windows implements web content with WebView2 under a plugin-private -`lithe-plugin://` origin and also advertises `ui: [1, 2]`; its bridge v0 -answers `ui.getState`, `ui.setState`, and `workspace.openFile`, and returns -`plugin.web.unknownMethod` for `build.*` and `functions.*` until the Windows -build-system capability lands. Windows does not advertise `ui` 3. - -### Future public extension direction (not implemented in API level 2) - -- Plugins may bring their own UI as [web content](#web-content-ui-2) inside - host-owned windows. Native trees/lists and panel actions now use the separately versioned - [native schema](#host-rendered-native-content-ui-3-native-schema-1). - Forms and broader component composition remain follow-up work. Built-in or trusted native modules may - register Native View/Renderer Factories under the same host window - management. This does not restore arbitrary dynamic third-party Swift - Bundle loading; ABI, signing and isolation need separate design. -- Actions declare handlers and availability/context conditions independently of - Tool Windows. Commands, menus/context menus, toolbars, status bars and settings - contributions must not require dummy windows. Opening a window is one action kind. -- A new, explicitly versioned Extension Contract will cover Language/LSP, - BuildSystem/Task, RunConfiguration/Test, DebugAdapter and ServiceProvider, - including pure Build/Debug/Services packages with no UI or language declaration. - Exact version numbers, handler transport and fields are not published here. -- Built-in and plugin providers should use the same typed domain registries. - Providers supply behavior/processes/data; hosts own scheduling, permission, - presentation and lifecycle. Reuse existing language, build, run/test and DAP - backends instead of duplicating them or branching by plugin name in composition. -- Services will accept general resources with provider/descriptor/instance/status/ - actions/logs semantics. Disabling unregisters contributions and releases - plugin-owned resources; it must not implicitly stop independently running - external services. An explicit stop action requires its own authorization. -- API level 2 remains supported unchanged. UI Schema evolves independently; - domain extension points need a new contract version and compatibility fixtures. - Windows implements its own host adapter over shared data, not SwiftUI types. - -Planned end-to-end samples are a native Tree with an independent refresh command, -Build Task in existing Build, ServiceProvider in Services, and third-party DAP in -existing Debug UI. Each must verify disable/re-enable, resource ownership and -restoration; view-bearing samples also verify layout persistence. These samples -are future acceptance criteria, not claims about current runtime support. - -The design source is the [proposed public Extension Contract and Provider API](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). -Current runtime behavior is recorded in the [first-stage workbench UI Note](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). +macOS implements web content with WKWebView, including the native controls +described below. Windows implements web content with WebView2 under a +plugin-private `lithe-plugin://` origin; its bridge answers `ui.getState`, +`ui.setState`, and `workspace.openFile`, and returns `plugin.web.unknownMethod` +for `build.*` and `functions.*` until the Windows build-system capability +lands. ### Current Windows availability -Windows advertises `ui` 1 and 2: it renders host tool windows, commands and -package web content. It does not advertise `ui` 3 (native controls) or 4 -(backend-only commands), so Core refuses packages that require them there. -Windows installs declarative packages from disk only and has no marketplace -browser yet. Core's `plugins.marketplace.validateIndex` marks level 2 versions -without `requires` available only on `darwin-*` platforms. -### Existing community store and Lua template +Windows renders host tool windows, commands and package web content, but not +native controls or backend-only commands. Under the +[declaration rule](#versioning) it therefore does not declare `ui`, and Core +refuses packages that require it there. Windows installs declarative packages +from disk only and has no marketplace browser yet. + +### Existing community store The community index is [Lithe Store](https://github.com/Lithe-IDEA/Lithe-store). -The [Lua plugin template](https://github.com/Lithe-IDEA/lithe-plugin-lua) uses -schema 2 / API level 1 for language-server and run providers; those declarations -remain valid. To add a host-rendered view to that template: - -1. Set `pluginAPIVersion` to 2. Keep the existing language and execution fields. -2. Add `ui.contribute` to the existing permissions and the new `onView` / - `onCommand` events to the existing activation events. -3. Add `ui.toolWindows` and `ui.commands`; prefix every ID with the plugin ID, +To add a host-rendered view to a community package: + +1. Add `ui` to `requires`, `ui.contribute` to the permissions, and the new + `onView` / `onCommand` events to the activation events. +2. Add `ui.toolWindows` and `ui.commands`; prefix every ID with the plugin ID, for example `io.github.lithe-idea.lua-support.tools`. -4. Keep `vendor.signatureRequirement: "none"` for community packages. The +3. Keep `vendor.signatureRequirement: "none"` for community packages. The existing package checksum, user install review and content digest still apply; UI does not bypass them. -5. Set the new Store version entry's `pluginAPIVersion` to 2. The index schema, - release archive format and SHA-256 rules stay unchanged. Only list `darwin-*` - platforms: `plugins.marketplace.validateIndex` marks a level 2 version without - `requires` unsupported on every other platform. +4. List the platforms whose hosts declare every capability the package + requires; the index schema, release archive format and SHA-256 rules do not + change. `fixtures/manifest/hello-lithe.plugin.json` is a contract fixture in the reserved test namespace, not a ready-to-publish community package. Change its ID and all @@ -4129,7 +4280,7 @@ references to your own namespace before using it in Lithe Store. ### UI icons (macOS host) -API level 2 tool windows and commands accept an optional `icon`. Prefer a stable +Tool windows and commands accept an optional `icon`. Prefer a stable host icon ID; it resolves through Lithe's existing icon library, not SF Symbols: ```json @@ -4185,8 +4336,8 @@ runtime disk cache or app-bundle write is created. Installed icon bytes follow the existing package signature/digest, update and rollback lifecycle. macOS 宿主布局菜单允许把已注册工具窗口移到左侧、右侧或底部,并保存入口 -顺序与显隐;`ui.toolWindows[].placement` 仍只声明首次使用的 `bottom`/`right` -位置,未新增插件契约值。项目、提交和搜索采用相同的工具窗口注册路径, +顺序与显隐;`ui.toolWindows[].placement` 只声明首次使用的 `bottom`/`right` +位置。项目、提交和搜索采用相同的工具窗口注册路径, 原生业务内容保持宿主所有;左侧恢复插件窗口仍按 `onView` 激活所属模块。 macOS 动作的可见、可用、勾选和激活中状态由宿主计算,不接受清单中的可执行 @@ -4194,117 +4345,13 @@ macOS 动作的可见、可用、勾选和激活中状态由宿主计算,不 仍可取消显示。多个窗口同时请求同一插件模块时只构造一次;禁用或关闭会取消 并清理在途激活,旧的菜单或命令引用不能重新调用已禁用的贡献。 -#### Shared executable candidates (`executableResolution` 1/2) - -Declare executable sources in `invocation.executables`, in preference order. -Array order is the recommended form; no custom path setting is required for a -plugin that only needs automatic discovery. Existing `projectFile`, `environment`, -`command` and `runtime` sources retain their v1 behavior. The shared -`executableResolution: 1` capability adds `setting` and ordering metadata: - -```json -"executables": [ - { "id": "selected", "kind": "setting", "settingID": "toolPath" }, - { "id": "wrapper", "kind": "projectFile", "path": "mvnw" }, - { "id": "system", "kind": "command", "command": "mvn" } -] -``` - -A `setting` references a declared **global file** setting with `effect: "reload"` -or `"nextRun"` (`settings: 2`). Core receives raw `request.settings`. An absent -value uses the declared default; an explicit empty string emits a setting candidate -with `path: null`, which skips. A nonempty value must satisfy the setting rules, -be absolute, and have no surrounding whitespace or controls. Invalid stored -values reject the invocation instead of invoking the normal settings display -resolver's fallback-to-default behavior. Project overrides never select tools. -The host checks file permissions and versions: a configured failure is terminal, -including a failure to probe, and must not fall back to subsequent candidates or -a Provider's own lookup. Paths with spaces remain single paths. The selected -setting's directory precedes PATH. Runtime candidates construct their environment -before version probing and reuse it for execution; a rejected candidate's -environment does not leak into the next choice. Provider hints and task/test -execution use this same plan/selection mechanism. If resolution fails before a -configured candidate is reached (for example, an earlier Runtime is missing), -any nonempty setting in the tool or Runtime candidate sets still forbids the -Provider's independent lookup. All-empty settings retain automatic-discovery -fallback. This check applies after failure, so an earlier successful candidate -is unaffected by a later unused setting. - -Optional metadata applies within **one candidate set owned by one plugin**: - -- `id` is a unique stable identifier; anonymous candidates cannot be anchors. -- `priority` is a signed 32-bit integer; higher wins, absent means 0. Ties retain - original array order. -- `insertBefore` or `insertAfter` names an ID in this set. One candidate cannot - combine either with `priority`, or declare both insertion fields. Constraints - mean relative order, not immediate adjacency. -- Normalize the complete set into a directed graph before platform filtering. - Repeatedly select a zero-indegree node by descending priority, then original - index; emit it and remove its edges. Explicit edges take precedence over - priority. Duplicate/invalid IDs, unknown anchors, cycles/self-edges and - conflicting metadata are manifest errors. Filter platforms only after sorting, - so anchors remain valid even if they do not apply to the current platform. - -`plugins::executables` owns the reusable source types and deterministic order; -`ExecutableCandidateResolver` receives file-system, Setting (already validated by -Core), Environment, Runtime and command-discovery capabilities from the host. -Results distinguish `skip`, `resolved` and `terminalFailure`; plugins declare data -and cannot inject host native code. Each operation uses a value snapshot for its -settings/environment and ordered plan. Passive import does not resolve/probe -runtimes or execute project wrappers. A project candidate remains project code -and is only executed through the consumer's existing trust/user-action workflow. - -This PR connects Build Systems and their custom Runtime Dependencies. Own Build -Providers already adapt command/runtime launches through BuildToolResolver and -receive the selected build tool as `tool.executable`; their ServerProcess protocol -is unchanged. Language Servers, Execution Providers, and other ServerProcess -launch forms can later adapt into this same resolver without changing their -manifest structure all at once: preserve package-realpath/executable checks, -per-operation consent (there is no workspace trust state yet), auto-start -restrictions, runtime ownership and cancellation. -They do **not** advertise support for new candidate declarations until their -adapter is implemented; this capability currently authorizes the Build candidate -positions only. Windows does not advertise it yet. - -Compatibility: Cargo 0.1.1 already shipped as a preview with `buildSystems: 2` -and `invocation.executableSetting`. That field is deprecated and supported only -as a v2 input adapter: prepend an equivalent setting candidate to the normal -list; never emit `configuredExecutable` or use a separate resolution branch. -New plugins use the fifth candidate and `executableResolution: 1` (Build v1 -remains sufficient). Legacy manifests do not need the new capability; hosts -without it reject new-style plugins before decoding unknown fields. Published -0.1.0/0.1.1 archives remain immutable; migration is a new plugin version. - -See [`build-tool-path.plugin.json`](fixtures/build-systems/build-tool-path.plugin.json). - -With `executableResolution: 2`, a candidate may use -`{ "kind": "toolchain", "toolchainID": "org.example.compiler:compiler" }`. -Core accepts the same short/qualified ID grammar as tool definitions and adds the -normalized instance `root`. The macOS adapter resolves installed definitions -against fresh native evidence and directory pins. Existing host registry IDs -remain only for their unmigrated consumers. Unavailable selections are terminal, -including when a prerequisite runtime fails first; they cannot silently fall -back to Provider discovery. -A host-managed `node` or `go` dependency may select one such candidate, -while retaining Core's version probe. Any other dependency that declares no -`executables` is already this candidate for the definition with its own ID, and -that definition checks `minimumVersion`. Maven declares its namespace-owned tool -and uses the short `java` definition for its Java prerequisite; no Maven-specific -JDK registry entry is added. Its backend bootstrap remains a separate consumer. -Passive import performs no version or runtime probes; a definition that requires -verification is not reported as an already verified import executable. -A toolchain that resolves to a project file remains project code, -including through a symlink, and native configuration requires explicit Read -before running it. See [`build-toolchains.plugin.json`](fixtures/build-systems/build-toolchains.plugin.json). - +### Host-rendered native content -### Host-rendered native content (`ui` 3, native schema 1) - -macOS supports `requires.ui: 3` for a tool window whose sole body is `native`. -Text and Web windows retain their existing behavior. Windows does not advertise -ui 3 and rejects these packages before installing them. The independently -versioned [native schema](native-ui.schema.json) has no style, script, HTML or -host-action fields. The host renders every control with its shared theme. +A tool window whose sole body is `native` is rendered by the host from a +declarative document; the manifest requires `ui: 0`. The independently +versioned [native schema](native-ui.schema.json) (`schemaVersion` 0) has no +style, script, HTML or host-action fields. The host renders every control with +its shared theme. See [native-panel.plugin.json](fixtures/ui/native-panel.plugin.json) and the runnable [Node SDK Sample Explorer](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/examples/sample-build/plugin.json). @@ -4349,9 +4396,9 @@ content width. Reflow preserves the same controls, local values and focus. Buttons reference an existing non-selection action through `action`, have stable `id`, literal `label`, optional `primary`, `enabled` and `info`. Text actions reuse the host's shared natural-width button style and 6pt action -spacing; a narrow action group falls back to vertical stacking. Deprecated -`buttons[].width` (`content`/`fill`) and `buttons[].span` are accepted for older -experimental packages, but do not change button width or action-group layout. +spacing; a narrow action group falls back to vertical stacking. +`buttons[].width` (`content`/`fill`) and `buttons[].span` are accepted but do +not change button width or action-group layout. Grid spans continue to apply to value controls. The declaration cannot override button colors, fonts, chrome, padding or fixed/max widths. @@ -4364,13 +4411,13 @@ Disabled fields still contribute their values; they cannot edit or commit. A pending call disables all fields. Values use the existing 64 KiB per-plugin, per-window, per-workspace state store; removed fields, invalid types and removed options are discarded when restoring. Provider replies never overwrite local -fields. Form declarations and choices are static in this version. +fields. Form declarations and choices are static. See [native-form.plugin.json](fixtures/ui/native-form.plugin.json) and -[host UI acceptance fixture](https://github.com/1lck/Lithe-IDEA/blob/preview/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. +[host UI acceptance fixture](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. An optional `source` names this plugin's `buildSystemID` and a function returning -`{ nodes: [...] }`. This phase uses the existing provider transport; it adds no -new backend, provider registry, or process protocol. There must be exactly one +`{ nodes: [...] }`. The source uses the existing provider transport; it adds no +backend, provider registry, or process protocol. There must be exactly one detected instance of that build system in the workspace; zero or multiple matches produce an error instead of silently targeting a different root. Static panels omit `source` and declare empty actions and placements. @@ -4388,7 +4435,7 @@ An optional `icon: {"host":"search"}` reuses the host icon vocabulary (including uses the refresh icon and invoke uses the generic run icon. The literal title is the tooltip and accessibility name. A context-menu action selects its clicked row before running; selecting a row alone never starts a provider function. These are panel-local actions; -this phase does not change API-v2 commands into arbitrary function handlers. +they do not turn `ui.commands` into arbitrary function handlers. The host allows one operation per panel, disables actions while busy, and shows loading, empty, failure and success feedback. A source reply is at most @@ -4402,154 +4449,6 @@ and workspace close synchronously invalidate it through the existing shared plugin-page lifecycle; late replies cannot publish. All state is outside the installed package and application bundle. -Schema 1 loads the complete node set on refresh. Forms, lazy child data sources, -multi-instance selectors, global function commands, native renderer factories -and Windows rendering remain follow-up work under #1209; this subset does not -claim completion of that whole issue. - -### Opaque semantic test selectors (buildProviders 9) - -Provider test declarations may advertise `selectorKinds`, up to 16 distinct public -namespace identifiers (120 bytes each). These require `buildProviders: 9`. A test -request may then carry `selectorKind` and `selectors` instead of `suite`/`case`. -The namespace must be declared; at most 64 nonempty, control-free UTF-8 values of -4096 bytes each are allowed. An empty list explicitly selects all tests within the -request's normal project/workspace scope. Values remain opaque to the host and -Core: only the owning language engine supplies semantic identities and the build -backend translates them into its upstream runner's selection syntax. Providers -must reject identities they cannot represent exactly, rather than widen execution. - -The wire session must explicitly negotiate version 9. Core and both SDKs reject -these fields on older versions before dispatch; ignoring a filter is unsafe even -when version 8 inline reports are supported. The operation identity, source/model -and configuration validity, cancellation, progress, and inline report rules still -apply. The shared example is `fixtures/build-providers/build-provider-selectors.plugin.json`. - -### Declarative project preparation (buildSystems 4) - -A project task may declare `preparation: { "purpose": "run", "sourceRoles": -["source", "generatedSource"] }` with `requires.buildSystems: 4`. Purpose is -an opaque workflow identifier; `run` identifies preparation before resolving a -language launch. Roles are the canonical model content-root kinds. A provider -can declare a different task for `testSource`/`generatedTestSource`, with any -private task IDs. Hosts never prescribe task names or infer a role from a file -extension, build descriptor, class name or missing model entry. - -Bindings are unique by purpose/role within each build system. Preparation -requires a project-scoped task, task execution, no argument prompt, and no -`fromModel`. Empty, overlapping, duplicate or unknown roles are rejected. -Hosts select the deepest owning project and its deepest content root from the -current model, then require exactly one declared matching task. Missing or -ambiguous declarations fail; they do not fall back to a conventional task ID. -The selected ID travels through the existing execute plan and backend method. -An older host rejects the capability before invocation. No new RPC version is -needed, so buildProviders 8–10 are unchanged. Shared fixture: -`fixtures/build-providers/build-provider-preparation.plugin.json`. - -The build backend owns filtering, custom resource directories, profiles and -reactor semantics. Hosts await successful completion, retain one operation -identity through cancellation/configuration changes, and only then resolve and -launch Java. Per-run option/tool overrides are ephemeral host inputs to the -existing validated plan; they never mutate saved build defaults or claim that -the persistent model reflects a different configuration. - -### Plugin-owned native terminals (function protocol 3) - -[`terminal-backend.plugin.json`](fixtures/backend/terminal-backend.plugin.json) is the -minimal manifest. `terminal.create` grants `lithe/host/terminal/create`, `show`, `close`, `reopen`, -`dispose`, and `onExit`. It requires `backend:2`; discover the individual -methods through `getCapabilities` before using them. macOS implements this -service. Windows has no handler and does not advertise its methods. The -plugin's web page calls its backend through `functions.invoke`; the backend -uses these host requests with the invocation's workspace context. There is -no direct web `terminal.*` bridge and no new protocol version. - -| Method (prefix `lithe/host/terminal/`) | Params, in addition to `context` | Result | -| --- | --- | --- | -| `create` | `executable`, optional `arguments`, `title`, `cwd`, `environment`, `show`, `retainedContextBytes` | `{terminalID,generation:1}` | -| `show` | `terminalID` | `null` | -| `close` | `terminalID`, optional `retainedContextBytes` | `{retainedContextBytes}` after bounded native cleanup | -| `reopen` | `terminalID`, optional `show` | `{terminalID,generation,restoredContextBytes}` | -| `dispose` | `terminalID` | `null` after closing and forgetting the handle | -| `onExit` | `terminalID` | `{subscriptionID}` | - -`executable` is an absolute path or a bare command resolved by the native -terminal's PATH lookup; relative paths containing separators are refused. -`arguments` is an argv array, never shell text. `cwd` is a `{rootID,path}` -workspace directory resource (an empty path selects the folder root); without -it, the workspace root is used. The native adapter rejects a canonical cwd -outside that workspace. `environment` maps variable names to string overrides -or `null` removals. Process-ownership scope variables cannot be overridden. -`show` defaults to true; it selects the terminal in its own window without -raising a background window. With `show:false`, creation and reopening preserve -the current terminal selection and do not reveal the terminal panel or request -input focus; the native tab is mounted when the plugin explicitly calls `show`. -Unknown options fail before any side effect. - -Creation and reopening ask for execution consent with the program, argv and -working directory, all available in a scrollable, selectable native text view, -then revalidate context and cancellation before launching -through the existing PTY terminal module. The response waits for user choice -without a machine-operation deadline, but cancellation/session/context closure -still ends it. Native launch and process cleanup retain their own bounds. -The host never types a launch into an existing shell or reads other terminals. -There is no `sendText`, user-terminal enumeration, web emulator, or Docker -special case. First release is local authority only; a remote context fails -with `plugin.host.authorityUnsupported` instead of starting anything locally. - -Each handle belongs to one plugin backend session and one context generation. -A handle from another plugin, context or replacement backend is `notFound`. -At most eight handles per plugin are retained, including closed handles; -`dispose` releases one. Limits are 256 arguments, 16 KiB per argument or -environment value, 64 environment entries, 64 KiB combined launch data, and -128 UTF-8 bytes in a nonempty single-line title. `busy` refuses concurrent -close/reopen/dispose on the same handle. Reopening a running terminal is -`preconditionFailed`; showing a closed terminal asks the plugin to reopen it. - -#### Close, retained context and reopen - -`close` ends the process tree and removes the native tab, keeping the handle's -launch configuration and a plain-text suffix of its displayed terminal output. -`retainedContextBytes` is a nonnegative UTF-8 byte budget: default **16384**, -maximum **262144**, and **0** disables history. It can be chosen at create or -changed at close; over-limit values are refused, never silently expanded. -The result reports actual retained bytes, which can be smaller to avoid cutting -a Unicode scalar. The history is a rendering snapshot, not input events, raw -escape sequences, a resumable process image, or shell state. Secrets already -printed or echoed by a program can be part of that display; choose 0 when no -history should be retained. - -`reopen` uses the saved executable, argv, cwd and environment in a **new** PTY -process. It feeds retained plain text to the display before spawning, never to -stdin. Shell variables, unsubmitted input, process state and automatic command -replay are not restored. Natural exit and user tab close are also reopenable; -the snapshot is bounded by the same budget. It keeps `terminalID` and increments -`generation`; late exit callbacks of previous generations are ignored. These -handles/history exist only in memory, not across app/backend restart or -workspace close. No state is written to the app bundle or plugin package. - -Natural exit and user tab close run the same bounded native cleanup before -retaining the budgeted snapshot and releasing the complete native session and -render buffer. The handle is `busy` during cleanup; its exit notification is -sent after cleanup, so a subscriber can reopen immediately. A zero-byte budget -retains no display text on any close or exit path. - -`dispose` closes any live process and permanently forgets the launch settings -and history. Ending the backend session, disabling/updating the plugin or -closing/replacing its workspace does the same for only its owned terminals; -user-created terminals are unaffected. Closing a page alone does not close a -completed terminal creation. Cancellation before launch creates nothing; -cancellation or context loss during launch closes the newly created process. -Cancelled creation/reopen is not safe to retry automatically. - -`onExit` supports one live subscription per handle. Its event is -`{type:"exit",terminalID,generation,exitCode,reason}`, with an integer or null -exit code and `reason:"exited"|"closed"`. Each generation publishes one outcome. -Subscribing after exit delivers its recorded outcome once; the subscription -continues across reopen. Disposing it stops notifications and keeps the -terminal open. A disposed terminal has no further events. - -The executable and argv remain plugin-owned semantics. A container plugin can -launch the user's Docker CLI with an explicit engine and container ID; another -tool can launch its interactive client through exactly the same service. -Neither requires a fake Build/Run provider. +A refresh loads the complete node set. Lazy child data sources, multi-instance +selectors, global function commands and native renderer factories are not part +of the native schema. diff --git a/shared/contracts/plugins/v1/fixtures/backend/backend-workspace.plugin.json b/shared/contracts/plugins/v1/fixtures/backend/backend-workspace.plugin.json index 8799688b..7ae27413 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/backend-workspace.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/backend/backend-workspace.plugin.json @@ -1,12 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.workspacebackend", "displayName": "Workspace Backend", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "serverProcess": 1 + "backend": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/backend/backend.plugin.json b/shared/contracts/plugins/v1/fixtures/backend/backend.plugin.json index b5bd06b1..20f9449b 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/backend.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/backend/backend.plugin.json @@ -1,13 +1,12 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.backend", "displayName": "Backend", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "backend": 1, - "serverProcess": 1, - "ui": 2 + "backend": 0, + "ui": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/backend/command-backend.plugin.json b/shared/contracts/plugins/v1/fixtures/backend/command-backend.plugin.json index 31fdec65..13afeb9d 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/command-backend.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/backend/command-backend.plugin.json @@ -1,13 +1,12 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.workspacebackend", "displayName": "Workspace Backend", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "serverProcess": 1, - "ui": 4 + "backend": 0, + "ui": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/backend/function-protocol.json b/shared/contracts/plugins/v1/fixtures/backend/function-protocol.json index 65dd3183..e0cb1d53 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/backend/function-protocol.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "protocol": "litheFunctions", - "offer": { "versions": [1, 2, 3, 4] }, + "offer": { "versions": [0] }, "negotiation": [ { "name": "no answer leaves the protocol off", @@ -12,15 +12,15 @@ "name": "an answer next to litheBuild is independent of it", "capabilities": { "experimental": { - "litheBuild": { "version": 1 }, - "litheFunctions": { "version": 1, "functions": [{ "id": "snapshot.refresh" }] } + "litheBuild": { "version": 0 }, + "litheFunctions": { "version": 0, "functions": [{ "id": "snapshot.refresh" }] } } }, "expected": { "functions": ["snapshot.refresh"] } }, { "name": "a version without functions starts with an empty registry", - "capabilities": { "experimental": { "litheFunctions": { "version": 1 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 0 } } }, "expected": { "functions": [] } }, { @@ -28,7 +28,7 @@ "capabilities": { "experimental": { "litheFunctions": { - "version": 1, + "version": 0, "future": true, "functions": [{ "id": "install.prepare", "title": "Prepare" }, { "id": "install.finish" }] } @@ -37,35 +37,25 @@ "expected": { "functions": ["install.finish", "install.prepare"] } }, { - "name": "version 2 negotiates the same registry and adds host services", - "capabilities": { "experimental": { "litheFunctions": { "version": 2, "functions": [{ "id": "query.run" }] } } }, - "expected": { "functions": ["query.run"], "version": 2 } - }, - { - "name": "version 3 negotiates the same registry and adds contexts and capability discovery", - "capabilities": { "experimental": { "litheFunctions": { "version": 3, "functions": [{ "id": "query.run" }] } } }, - "expected": { "functions": ["query.run"], "version": 3 } - }, - { - "name": "version 4 negotiates the same registry and adds the cross-plugin relay", - "capabilities": { "experimental": { "litheFunctions": { "version": 4, "functions": [{ "id": "query.run" }] } } }, - "expected": { "functions": ["query.run"], "version": 4 } + "name": "the negotiated version is reported next to the registry", + "capabilities": { "experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "query.run" }] } } }, + "expected": { "functions": ["query.run"], "version": 0 } }, { "name": "a version the host never offered is not negotiated", - "capabilities": { "experimental": { "litheFunctions": { "version": 5 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 1 } } }, "expected": { "functions": null } }, { "name": "a version list is the offer shape, not an answer", - "capabilities": { "experimental": { "litheFunctions": { "versions": [1] } } }, + "capabilities": { "experimental": { "litheFunctions": { "versions": [0] } } }, "expected": { "functions": null } }, { "name": "a malformed function ID rejects the whole answer", "capabilities": { "experimental": { - "litheFunctions": { "version": 1, "functions": [{ "id": "ok" }, { "id": "Not-Valid" }] } + "litheFunctions": { "version": 0, "functions": [{ "id": "ok" }, { "id": "Not-Valid" }] } } }, "expected": { "functions": null } @@ -74,7 +64,7 @@ "name": "a duplicate function ID rejects the whole answer", "capabilities": { "experimental": { - "litheFunctions": { "version": 1, "functions": [{ "id": "refresh" }, { "id": "refresh" }] } + "litheFunctions": { "version": 0, "functions": [{ "id": "refresh" }, { "id": "refresh" }] } } }, "expected": { "functions": null } @@ -271,11 +261,6 @@ "method": "lithe/build/model", "expected": { "code": "plugin.function.recursiveCall" } }, - { - "name": "version 1 offers no host capability", - "method": "lithe/host/process.run", - "expected": { "code": "plugin.host.unavailable" } - }, { "name": "other methods keep the ordinary JSON-RPC answer", "method": "workspace/applyEdit", @@ -283,20 +268,12 @@ } ], "hostRequests": [ - {"name": "workspace metadata never authorizes file reads", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/readFile", "params": {"context": {"contextID": "w1", "generation": 1}}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "editor read method grant never authorizes edits", "version": 3, "hostServices": ["lithe/host/editor/getSelections"], "method": "lithe/host/editor/applyEdits", "params": {}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "only the explicit file method is forwarded", "version": 3, "hostServices": ["lithe/host/workspace/readFile"], "method": "lithe/host/workspace/readFile", "params": {"context": {"contextID": "w1", "generation": 1}, "resource": {"rootID": "r1", "path": "hello.txt"}}, "expected": {"forward": "workspace"}}, - { - "name": "version 1 sessions get no host service even when granted", - "version": 1, - "hostServices": ["credentials"], - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" }, - "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } - }, + {"name": "workspace metadata never authorizes file reads", "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/readFile", "params": {"context": {"contextID": "w1", "generation": 1}}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "editor read method grant never authorizes edits", "version": 0, "hostServices": ["lithe/host/editor/getSelections"], "method": "lithe/host/editor/applyEdits", "params": {}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "only the explicit file method is forwarded", "version": 0, "hostServices": ["lithe/host/workspace/readFile"], "method": "lithe/host/workspace/readFile", "params": {"context": {"contextID": "w1", "generation": 1}, "resource": {"rootID": "r1", "path": "hello.txt"}}, "expected": {"forward": "workspace"}}, { "name": "a granted credential read is forwarded to the host", - "version": 2, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/get", "params": { "key": "connection.prod" }, @@ -304,7 +281,7 @@ }, { "name": "a credential write carries the secret", - "version": 2, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/set", "params": { "key": "connection.prod:password", "secret": "s3cret" }, @@ -312,7 +289,7 @@ }, { "name": "a credential delete names only the key", - "version": 2, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/delete", "params": { "key": "connection.prod" }, @@ -320,23 +297,15 @@ }, { "name": "a service the plugin's permissions do not grant is refused", - "version": 2, + "version": 0, "hostServices": [], "method": "lithe/host/credentials/get", "params": { "key": "connection.prod" }, - "expected": { "rpcCode": -32601, "code": "plugin.host.permissionDenied" } - }, - { - "name": "a host method version 2 does not define is unavailable", - "version": 2, - "hostServices": ["credentials"], - "method": "lithe/host/process.run", - "params": {}, - "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + "expected": { "rpcCode": -32010, "code": "plugin.host.permissionDenied" } }, { "name": "a key outside the alphabet is invalid", - "version": 2, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/get", "params": { "key": "../other-plugin" }, @@ -344,7 +313,7 @@ }, { "name": "an empty key is invalid", - "version": 2, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/delete", "params": { "key": "" }, @@ -352,23 +321,15 @@ }, { "name": "a write without a string secret is invalid", - "version": 2, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/set", "params": { "key": "connection.prod", "secret": 42 }, "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } }, { - "name": "version 2 answers an unknown field as invalid params", - "version": 2, - "hostServices": ["credentials"], - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod", "scope": "global" }, - "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } - }, - { - "name": "from version 3 an unknown field is an option this host does not support", - "version": 3, + "name": "an unknown field is an option this host does not support", + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/credentials/get", "params": { "key": "connection.prod", "scope": "global" }, @@ -376,28 +337,21 @@ }, { "name": "a context method names the option it does not support", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getFolders", "params": { "context": { "contextID": "w-3f2a", "generation": 1 }, "includeHidden": true }, "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "includeHidden" } } }, - { - "name": "capability discovery needs version 3", - "version": 2, - "hostServices": ["credentials"], - "method": "lithe/host/getCapabilities", - "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } - }, { "name": "Core answers capability discovery with only the session's grants", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/getCapabilities", "params": {}, "expected": { "answer": { - "protocolVersion": 3, + "protocolVersion": 0, "backendAuthority": "local", "services": { "commands": { "methods": [], "notifications": ["lithe/host/commands/didRun"] }, @@ -429,7 +383,7 @@ }, { "name": "capability discovery takes no options yet", - "version": 3, + "version": 0, "hostServices": [], "method": "lithe/host/getCapabilities", "params": { "services": ["credentials"] }, @@ -437,30 +391,22 @@ }, { "name": "listing workspace contexts is forwarded when workspace.metadata is granted", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/listContexts", "expected": { "forward": "workspace" } }, { "name": "workspace contexts need the workspace.metadata grant", - "version": 3, + "version": 0, "hostServices": ["credentials"], "method": "lithe/host/workspace/listContexts", "params": {}, "expected": { "rpcCode": -32010, "code": "plugin.host.permissionDenied" } }, - { - "name": "workspace contexts need version 3 even when granted", - "version": 2, - "hostServices": ["workspace"], - "method": "lithe/host/workspace/listContexts", - "params": {}, - "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } - }, { "name": "a context's folders are asked for by context ID and generation", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getFolders", "params": { "context": { "contextID": "w-3f2a", "generation": 2 } }, @@ -468,7 +414,7 @@ }, { "name": "one context is asked for by context ID and generation", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getContext", "params": { "context": { "contextID": "w-3f2a", "generation": 2 } }, @@ -476,7 +422,7 @@ }, { "name": "a context without a generation is invalid", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getFolders", "params": { "context": { "contextID": "w-3f2a" } }, @@ -484,7 +430,7 @@ }, { "name": "a generation is positive", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getContext", "params": { "context": { "contextID": "w-3f2a", "generation": 0 } }, @@ -492,7 +438,7 @@ }, { "name": "a context ID outside the alphabet is invalid", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getContext", "params": { "context": { "contextID": "../w", "generation": 1 } }, @@ -500,7 +446,7 @@ }, { "name": "a context reference has no other fields", - "version": 3, + "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/getContext", "params": { "context": { "contextID": "w-3f2a", "generation": 1, "path": "/tmp" } }, @@ -508,7 +454,7 @@ }, { "name": "a subscription is disposed by its ID without any grant", - "version": 3, + "version": 0, "hostServices": [], "method": "lithe/host/subscriptions/dispose", "params": { "subscriptionID": "watch-1" }, @@ -516,95 +462,82 @@ }, { "name": "disposing needs a subscription ID", - "version": 3, + "version": 0, "hostServices": [], "method": "lithe/host/subscriptions/dispose", "params": {}, "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } }, - { - "name": "credentials keep their version 2 rules in version 3", - "version": 3, - "hostServices": ["credentials"], - "method": "lithe/host/credentials/get", - "params": { "key": "connection.prod" }, - "expected": { "forward": "credentials" } - }, { "name": "a method no version defines is unavailable", - "version": 3, + "version": 0, "hostServices": ["credentials", "workspace"], "method": "lithe/host/process/run", "params": {}, "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } }, - {"name": "editor actions are a discoverable independent host service", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/editor/registerActions"], "params": {"context": {"contextID": "w1", "generation": 1}, "id": "actions", "function": "actions.collect", "selector": {"fileExtensions": ["http"]}}, "expected": {"forward": "editor"}}, - {"name": "document reads do not grant editor action contribution", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/documents/getSnapshot"], "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, - {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}}, - {"name": "terminal create is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "executable": "example-tool"}, "expected": {"forward": "terminal"}}, - {"name": "terminal show is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/show"], "method": "lithe/host/terminal/show", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, - {"name": "terminal close is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/close"], "method": "lithe/host/terminal/close", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, - {"name": "terminal onExit is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/onExit"], "method": "lithe/host/terminal/onExit", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, - {"name": "terminal aggregate grant cannot authorize terminal creation", "version": 3, "hostServices": ["terminal"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, - {"name": "an older protocol cannot create a native terminal", "version": 2, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}}, - {"name": "terminal reopen is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/terminal/reopen"], "method": "lithe/host/terminal/reopen", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, - {"name": "terminal dispose is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/terminal/dispose"], "method": "lithe/host/terminal/dispose", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, - { "name": "a run start is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/start", "params": { "context": { "contextID": "w-run", "generation": 1 }, "label": "Run app" }, "expected": { "forward": "run" } }, - { "name": "a run stop is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/run/stop"], "method": "lithe/host/run/stop", "params": { "context": { "contextID": "w-run", "generation": 1 }, "runID": "r1" }, "expected": { "forward": "run" } }, - { "name": "a run start needs its own grant", "version": 3, "hostServices": ["lithe/host/run/stop"], "method": "lithe/host/run/start", "params": { "context": { "contextID": "w-run", "generation": 1 } }, "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, - { "name": "a run start takes an object", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/start", "params": [], "expected": { "code": "plugin.host.invalidParams", "rpcCode": -32602 } }, - { "name": "an older protocol cannot start a run", "version": 2, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/start", "params": {}, "expected": { "code": "plugin.host.unavailable", "rpcCode": -32601 } }, - { "name": "a debug start is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/debug/start"], "method": "lithe/host/debug/start", "params": { "context": { "contextID": "w-run", "generation": 1 }, "label": "Debug app" }, "expected": { "forward": "debug" } }, - { "name": "a debug start is not covered by the run grant", "version": 3, "hostServices": ["lithe/host/run/start", "lithe/host/run/stop"], "method": "lithe/host/debug/start", "params": { "context": { "contextID": "w-run", "generation": 1 } }, "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, - {"name": "a language server request is forwarded when languageServer.request is granted", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "params": {"verbose": true}}, "expected": {"forward": "languageServer"}}, - {"name": "a language server request may bound its own timeout", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "rust-analyzer", "method": "rust-analyzer/expandMacro", "params": {}, "timeoutMilliseconds": 300000}, "expected": {"forward": "languageServer"}}, - {"name": "a language server request may run a declared command", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "workspace/executeCommand", "params": {"command": "metals.build-import"}}, "expected": {"forward": "languageServer"}}, - {"name": "a language server notification is forwarded", "version": 3, "hostServices": ["lithe/host/languageServer/notify"], "method": "lithe/host/languageServer/notify", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "workspace/didChangeConfiguration", "params": {"settings": {}}}, "expected": {"forward": "languageServer"}}, - {"name": "a language server status is forwarded", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "preparing", "message": "Importing build", "percentage": 40}, "expected": {"forward": "languageServer"}}, - {"name": "a language server status may omit the message and the percentage", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "rust-analyzer", "state": "ready"}, "expected": {"forward": "languageServer"}}, - {"name": "a language server request needs the languageServer.request grant", "version": 3, "hostServices": [], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "another service's grant never authorizes a language server request", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "the aggregate service name never authorizes a language server request", "version": 3, "hostServices": ["languageServer"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "a notify grant never authorizes a language server request", "version": 3, "hostServices": ["lithe/host/languageServer/notify"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "a language server request is unavailable before protocol 3", "version": 2, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32601, "code": "plugin.host.unavailable"}}, - {"name": "a language server request needs a context", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server request names a server", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "method": "metals/doctorRun"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server request names a method", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server request refuses an empty method", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": ""}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server request timeout is positive", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "timeoutMilliseconds": 0}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server request timeout has an upper bound", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "timeoutMilliseconds": 300001}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server request names the option it does not support", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "workspaceFolder": "/tmp"}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "workspaceFolder"}}}, - {"name": "a language server notification takes no timeout", "version": 3, "hostServices": ["lithe/host/languageServer/notify"], "method": "lithe/host/languageServer/notify", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "workspace/didChangeConfiguration", "timeoutMilliseconds": 5}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "timeoutMilliseconds"}}}, - {"name": "a language server status state is a closed set", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "importing"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server status percentage stops at one hundred", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "preparing", "percentage": 101}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server status percentage is a whole number", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "preparing", "percentage": 12.5}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server status message is text", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "failed", "message": {"text": "x"}}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a language server status has no method field", "version": 3, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "ready", "method": "x"}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "method"}}}, - {"name": "a content provider is registered with the documents.contentProvider grant", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "documents.read"}, "expected": {"forward": "documents"}}, - {"name": "the aggregate documents name never authorizes a content provider", "version": 3, "hostServices": ["documents"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "documents.read"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "a content provider needs its own grant", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "documents.read"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "a content provider cannot serve a scheme the host owns", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "file", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a content provider cannot serve the lithe scheme", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "lithe", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a content scheme is lowercase", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "Metals", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a content scheme starts with a letter", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "1metals", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a content provider names a plugin function", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "not a function"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a content provider needs a context", "version": 3, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"scheme": "metals-library", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a declared caller lists the providers of a function", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/providers", "params": {"functionID": "languageImport.settings"}, "expected": {"relay": true}}, - {"name": "a declared caller invokes another plugin's function through the relay", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": {"project": "app"}, "timeoutMilliseconds": 5000}, "expected": {"relay": true}}, - {"name": "a nested invocation names the relayed call it belongs to", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": {"project": "app"}, "timeoutMilliseconds": 5000, "parentOperationID": "17"}, "expected": {"relay": true}}, - {"name": "an invocation's parent operation ID is a string", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": {"project": "app"}, "timeoutMilliseconds": 5000, "parentOperationID": 17}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "the relay needs protocol version 4", "version": 3, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}}, "expected": {"rpcCode": -32601, "code": "plugin.host.unavailable"}}, - {"name": "the relay needs the manifest's pluginFunctions calls", "version": 4, "hostServices": [], "method": "lithe/host/functions/providers", "params": {"functionID": "languageImport.settings"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, - {"name": "an invocation names the workspace context it runs in", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "an invocation's function ID is well formed", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "Not-Valid", "context": {"contextID": "w1", "generation": 1}}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "an invocation's params are an object", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": [1]}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "an invocation's timeout is positive", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "timeoutMilliseconds": 0}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, - {"name": "a caller cannot choose who the callee thinks called", "version": 4, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "caller": {"pluginID": "dev.lithe.fixture.other"}}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "caller"}}} + {"name": "editor actions are a discoverable independent host service", "method": "lithe/host/editor/registerActions", "version": 0, "hostServices": ["lithe/host/editor/registerActions"], "params": {"context": {"contextID": "w1", "generation": 1}, "id": "actions", "function": "actions.collect", "selector": {"fileExtensions": ["http"]}}, "expected": {"forward": "editor"}}, + {"name": "document reads do not grant editor action contribution", "method": "lithe/host/editor/registerActions", "version": 0, "hostServices": ["lithe/host/documents/getSnapshot"], "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, + {"name": "terminal create is an individually granted method", "version": 0, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "executable": "example-tool"}, "expected": {"forward": "terminal"}}, + {"name": "terminal show is an individually granted method", "version": 0, "hostServices": ["lithe/host/terminal/show"], "method": "lithe/host/terminal/show", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal close is an individually granted method", "version": 0, "hostServices": ["lithe/host/terminal/close"], "method": "lithe/host/terminal/close", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal onExit is an individually granted method", "version": 0, "hostServices": ["lithe/host/terminal/onExit"], "method": "lithe/host/terminal/onExit", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal aggregate grant cannot authorize terminal creation", "version": 0, "hostServices": ["terminal"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, + {"name": "terminal reopen is granted", "version": 0, "hostServices": ["lithe/host/terminal/reopen"], "method": "lithe/host/terminal/reopen", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal dispose is granted", "version": 0, "hostServices": ["lithe/host/terminal/dispose"], "method": "lithe/host/terminal/dispose", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + { "name": "a run start is granted", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/start", "params": { "context": { "contextID": "w-run", "generation": 1 }, "label": "Run app" }, "expected": { "forward": "run" } }, + { "name": "a run stop is granted", "version": 0, "hostServices": ["lithe/host/run/stop"], "method": "lithe/host/run/stop", "params": { "context": { "contextID": "w-run", "generation": 1 }, "runID": "r1" }, "expected": { "forward": "run" } }, + { "name": "a run start needs its own grant", "version": 0, "hostServices": ["lithe/host/run/stop"], "method": "lithe/host/run/start", "params": { "context": { "contextID": "w-run", "generation": 1 } }, "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, + { "name": "a run start takes an object", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/start", "params": [], "expected": { "code": "plugin.host.invalidParams", "rpcCode": -32602 } }, + { "name": "a debug start is granted", "version": 0, "hostServices": ["lithe/host/debug/start"], "method": "lithe/host/debug/start", "params": { "context": { "contextID": "w-run", "generation": 1 }, "label": "Debug app" }, "expected": { "forward": "debug" } }, + { "name": "a debug start is not covered by the run grant", "version": 0, "hostServices": ["lithe/host/run/start", "lithe/host/run/stop"], "method": "lithe/host/debug/start", "params": { "context": { "contextID": "w-run", "generation": 1 } }, "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, + {"name": "a language server request is forwarded when languageServer.request is granted", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "params": {"verbose": true}}, "expected": {"forward": "languageServer"}}, + {"name": "a language server request may bound its own timeout", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "rust-analyzer", "method": "rust-analyzer/expandMacro", "params": {}, "timeoutMilliseconds": 300000}, "expected": {"forward": "languageServer"}}, + {"name": "a language server request may run a declared command", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "workspace/executeCommand", "params": {"command": "metals.build-import"}}, "expected": {"forward": "languageServer"}}, + {"name": "a language server notification is forwarded", "version": 0, "hostServices": ["lithe/host/languageServer/notify"], "method": "lithe/host/languageServer/notify", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "workspace/didChangeConfiguration", "params": {"settings": {}}}, "expected": {"forward": "languageServer"}}, + {"name": "a language server status is forwarded", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "preparing", "message": "Importing build", "percentage": 40}, "expected": {"forward": "languageServer"}}, + {"name": "a language server status may omit the message and the percentage", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "rust-analyzer", "state": "ready"}, "expected": {"forward": "languageServer"}}, + {"name": "a language server request needs the languageServer.request grant", "version": 0, "hostServices": [], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "another service's grant never authorizes a language server request", "version": 0, "hostServices": ["workspace"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "the aggregate service name never authorizes a language server request", "version": 0, "hostServices": ["languageServer"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "a notify grant never authorizes a language server request", "version": 0, "hostServices": ["lithe/host/languageServer/notify"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "a language server request needs a context", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"serverID": "metals", "method": "metals/doctorRun"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server request names a server", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "method": "metals/doctorRun"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server request names a method", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server request refuses an empty method", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": ""}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server request timeout is positive", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "timeoutMilliseconds": 0}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server request timeout has an upper bound", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "timeoutMilliseconds": 300001}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server request names the option it does not support", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/request", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/doctorRun", "workspaceFolder": "/tmp"}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "workspaceFolder"}}}, + {"name": "a language server notification takes no timeout", "version": 0, "hostServices": ["lithe/host/languageServer/notify"], "method": "lithe/host/languageServer/notify", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "workspace/didChangeConfiguration", "timeoutMilliseconds": 5}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "timeoutMilliseconds"}}}, + {"name": "a language server status state is a closed set", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "importing"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server status percentage stops at one hundred", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "preparing", "percentage": 101}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server status percentage is a whole number", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "preparing", "percentage": 12.5}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server status message is text", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "failed", "message": {"text": "x"}}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a language server status has no method field", "version": 0, "hostServices": ["lithe/host/languageServer/setStatus"], "method": "lithe/host/languageServer/setStatus", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "ready", "method": "x"}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "method"}}}, + {"name": "a content provider is registered with the documents.contentProvider grant", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "documents.read"}, "expected": {"forward": "documents"}}, + {"name": "the aggregate documents name never authorizes a content provider", "version": 0, "hostServices": ["documents"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "documents.read"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "a content provider needs its own grant", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "documents.read"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "a content provider cannot serve a scheme the host owns", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "file", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a content provider cannot serve the lithe scheme", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "lithe", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a content scheme is lowercase", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "Metals", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a content scheme starts with a letter", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "1metals", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a content provider names a plugin function", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"context": {"contextID": "w1", "generation": 1}, "scheme": "metals-library", "function": "not a function"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a content provider needs a context", "version": 0, "hostServices": ["lithe/host/documents/registerContentProvider"], "method": "lithe/host/documents/registerContentProvider", "params": {"scheme": "metals-library", "function": "documents.read"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a declared caller lists the providers of a function", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/providers", "params": {"functionID": "languageImport.settings"}, "expected": {"relay": true}}, + {"name": "a declared caller invokes another plugin's function through the relay", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": {"project": "app"}, "timeoutMilliseconds": 5000}, "expected": {"relay": true}}, + {"name": "a nested invocation names the relayed call it belongs to", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": {"project": "app"}, "timeoutMilliseconds": 5000, "parentOperationID": "17"}, "expected": {"relay": true}}, + {"name": "an invocation's parent operation ID is a string", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": {"project": "app"}, "timeoutMilliseconds": 5000, "parentOperationID": 17}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "the relay needs the manifest's pluginFunctions calls", "version": 0, "hostServices": [], "method": "lithe/host/functions/providers", "params": {"functionID": "languageImport.settings"}, "expected": {"rpcCode": -32010, "code": "plugin.host.permissionDenied"}}, + {"name": "an invocation names the workspace context it runs in", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings"}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "an invocation's function ID is well formed", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "Not-Valid", "context": {"contextID": "w1", "generation": 1}}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "an invocation's params are an object", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "params": [1]}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "an invocation's timeout is positive", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "timeoutMilliseconds": 0}, "expected": {"rpcCode": -32602, "code": "plugin.host.invalidParams"}}, + {"name": "a caller cannot choose who the callee thinks called", "version": 0, "hostServices": ["functions"], "method": "lithe/host/functions/invoke", "params": {"pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", "context": {"contextID": "w1", "generation": 1}, "caller": {"pluginID": "dev.lithe.fixture.other"}}, "expected": {"rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": {"option": "caller"}}} ], "invocationCallers": [ { - "name": "a version 4 callee receives the plugin that called it", - "version": 4, + "name": "a callee receives the plugin that called it", + "version": 0, "params": { "functionID": "languageImport.settings", "operationID": "7", "params": {} }, "caller": "dev.lithe.fixture.python", "expected": { @@ -616,22 +549,15 @@ } } }, - { - "name": "an older callee never receives a caller", - "version": 3, - "params": { "functionID": "languageImport.settings", "operationID": "7", "params": {} }, - "caller": "dev.lithe.fixture.python", - "expected": { "params": { "functionID": "languageImport.settings", "operationID": "7", "params": {} } } - }, { "name": "a call the host started has no caller, which means the host", - "version": 4, + "version": 0, "params": { "functionID": "languageImport.settings", "operationID": "7", "params": {} }, "expected": { "params": { "functionID": "languageImport.settings", "operationID": "7", "params": {} } } }, { "name": "Core's caller replaces any field the params already carried", - "version": 4, + "version": 0, "params": { "functionID": "languageImport.settings", "operationID": "7", "params": {}, "caller": { "pluginID": "dev.lithe.fixture.other" } }, "caller": "dev.lithe.fixture.python", "expected": { @@ -663,30 +589,23 @@ ], "invocationContexts": [ { - "name": "a version 3 backend receives the call's context", - "version": 3, + "name": "a backend receives the call's context", + "version": 0, "params": { "functionID": "query.run", "operationID": "7", "params": {} }, "context": { "contextID": "w-3f2a", "generation": 2 }, "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {}, "context": { "contextID": "w-3f2a", "generation": 2 } } } }, - { - "name": "an older backend receives the call unchanged", - "version": 2, - "params": { "functionID": "query.run", "operationID": "7", "params": {} }, - "context": { "contextID": "w-3f2a", "generation": 2 }, - "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {} } } - }, { "name": "a call without a context is unchanged", - "version": 3, + "version": 0, "params": { "functionID": "query.run", "operationID": "7", "params": {} }, "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {} } } }, { "name": "a malformed context never leaves Core", - "version": 3, + "version": 0, "params": { "functionID": "query.run", "operationID": "7", "params": {} }, "context": { "contextID": "w-3f2a", "generation": -1 }, "expected": { "code": "plugin.function.invalidParams" } @@ -718,34 +637,32 @@ {"name": "the server belongs to another plugin", "code": "plugin.host.foreignContribution", "expected": {"code": "plugin.host.foreignContribution", "rpcCode": -32026}} ], "hostNotifications": [ - { "name": "changed contexts reach a workspace session", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": true } }, - { "name": "a version 2 backend is skipped", "version": 2, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": false } }, - { "name": "changed contexts need the workspace grant", "version": 3, "hostServices": [], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "code": "plugin.host.permissionDenied" } }, - { "name": "a revision is positive", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, - { "name": "a subscription event", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": { "kind": "changed" } }, "expected": { "delivered": true } }, - { "name": "a subscription event is an object", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": "changed" }, "expected": { "code": "plugin.host.invalidParams" } }, - { "name": "a command run in a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "context": { "contextID": "w1", "generation": 2 } }, "expected": { "delivered": true } }, - { "name": "a command without a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh" }, "expected": { "delivered": true } }, - { "name": "extra command params are refused", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "path": "/etc" }, "expected": { "code": "plugin.host.invalidParams" } }, - { "name": "the host cannot call the backend's own protocol", "version": 3, "hostServices": [], "method": "lithe/functions/invoke", "params": {}, "expected": { "code": "plugin.host.unavailable" } }, - { "name": "a run exit with a status", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "exitCode": 0 }, "expected": { "delivered": true } }, - { "name": "a run exit by signal", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "signal": 9 }, "expected": { "delivered": true } }, - { "name": "a run exit the host lost track of", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1" }, "expected": { "delivered": true } }, - { "name": "a run exit needs the run grant", "version": 3, "hostServices": ["lithe/host/debug/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "exitCode": 0 }, "expected": { "code": "plugin.host.permissionDenied" } }, - { "name": "a run exit status is a 32-bit integer", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "exitCode": 4294967296 }, "expected": { "code": "plugin.host.invalidParams" } }, - { "name": "a run exit signal is a signal number", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "signal": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, - { "name": "a run exit names its run", "version": 3, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "exitCode": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, - {"name": "a language server changed state", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "running", "generation": 3}, "expected": {"delivered": true}}, - {"name": "a language server state is a closed set", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "ready", "generation": 3}, "expected": {"code": "plugin.host.invalidParams"}}, - {"name": "a language server state names its generation", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "stopped"}, "expected": {"code": "plugin.host.invalidParams"}}, - {"name": "a language server state generation is positive", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "crashed", "generation": 0}, "expected": {"code": "plugin.host.invalidParams"}}, - {"name": "a language server state needs the languageServer.request grant", "version": 3, "hostServices": [], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "running", "generation": 1}, "expected": {"code": "plugin.host.permissionDenied"}}, - {"name": "a version 2 backend skips language server state", "version": 2, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "running", "generation": 1}, "expected": {"delivered": false}}, - {"name": "a forwarded server notification carries its params", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "params": {"text": "Importing"}}, "expected": {"delivered": true}}, - {"name": "an oversized server notification is marked truncated without params", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "truncated": true}, "expected": {"delivered": true}}, - {"name": "a truncated server notification drops its params", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "params": {}, "truncated": true}, "expected": {"code": "plugin.host.invalidParams"}}, - {"name": "a server notification names its method", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2}, "expected": {"code": "plugin.host.invalidParams"}}, - {"name": "a server notification names its generation", "version": 3, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/status", "params": {}}, "expected": {"code": "plugin.host.invalidParams"}}, - {"name": "a server notification needs the languageServer.request grant", "version": 3, "hostServices": [], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "params": {}}, "expected": {"code": "plugin.host.permissionDenied"}} + { "name": "changed contexts reach a workspace session", "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": true } }, + { "name": "changed contexts need the workspace grant", "version": 0, "hostServices": [], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "code": "plugin.host.permissionDenied" } }, + { "name": "a revision is positive", "version": 0, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a subscription event", "version": 0, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": { "kind": "changed" } }, "expected": { "delivered": true } }, + { "name": "a subscription event is an object", "version": 0, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": "changed" }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a command run in a window", "version": 0, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "context": { "contextID": "w1", "generation": 2 } }, "expected": { "delivered": true } }, + { "name": "a command without a window", "version": 0, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh" }, "expected": { "delivered": true } }, + { "name": "extra command params are refused", "version": 0, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "path": "/etc" }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "the host cannot call the backend's own protocol", "version": 0, "hostServices": [], "method": "lithe/functions/invoke", "params": {}, "expected": { "code": "plugin.host.unavailable" } }, + { "name": "a run exit with a status", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "exitCode": 0 }, "expected": { "delivered": true } }, + { "name": "a run exit by signal", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "signal": 9 }, "expected": { "delivered": true } }, + { "name": "a run exit the host lost track of", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1" }, "expected": { "delivered": true } }, + { "name": "a run exit needs the run grant", "version": 0, "hostServices": ["lithe/host/debug/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "exitCode": 0 }, "expected": { "code": "plugin.host.permissionDenied" } }, + { "name": "a run exit status is a 32-bit integer", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "exitCode": 4294967296 }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a run exit signal is a signal number", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "runID": "r1", "signal": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a run exit names its run", "version": 0, "hostServices": ["lithe/host/run/start"], "method": "lithe/host/run/didExit", "params": { "exitCode": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, + {"name": "a language server changed state", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "running", "generation": 3}, "expected": {"delivered": true}}, + {"name": "a language server state is a closed set", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "ready", "generation": 3}, "expected": {"code": "plugin.host.invalidParams"}}, + {"name": "a language server state names its generation", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "stopped"}, "expected": {"code": "plugin.host.invalidParams"}}, + {"name": "a language server state generation is positive", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "crashed", "generation": 0}, "expected": {"code": "plugin.host.invalidParams"}}, + {"name": "a language server state needs the languageServer.request grant", "version": 0, "hostServices": [], "method": "lithe/host/languageServer/didChangeState", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "state": "running", "generation": 1}, "expected": {"code": "plugin.host.permissionDenied"}}, + {"name": "a forwarded server notification carries its params", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "params": {"text": "Importing"}}, "expected": {"delivered": true}}, + {"name": "an oversized server notification is marked truncated without params", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "truncated": true}, "expected": {"delivered": true}}, + {"name": "a truncated server notification drops its params", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "params": {}, "truncated": true}, "expected": {"code": "plugin.host.invalidParams"}}, + {"name": "a server notification names its method", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2}, "expected": {"code": "plugin.host.invalidParams"}}, + {"name": "a server notification names its generation", "version": 0, "hostServices": ["lithe/host/languageServer/request"], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "method": "metals/status", "params": {}}, "expected": {"code": "plugin.host.invalidParams"}}, + {"name": "a server notification needs the languageServer.request grant", "version": 0, "hostServices": [], "method": "lithe/host/languageServer/didReceiveNotification", "params": {"context": {"contextID": "w1", "generation": 1}, "serverID": "metals", "generation": 2, "method": "metals/status", "params": {}}, "expected": {"code": "plugin.host.permissionDenied"}} ] } diff --git a/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-caller.plugin.json b/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-caller.plugin.json index 301571b7..4671c4de 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-caller.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-caller.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.functioncaller", "displayName": "Plugin function caller fixture", "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": {"pluginFunctions": 1, "backend": 2, "serverProcess": 1}, + "pluginAPIVersion": 0, + "requires": {"pluginFunctions": 0, "backend": 0}, "vendor": {"id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"}, "permissions": ["backend.launch"], "activationEvents": ["onWorkspaceOpen"], diff --git a/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-exporter.plugin.json b/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-exporter.plugin.json index dedf1e4c..75675959 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-exporter.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-exporter.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.functionexporter", "displayName": "Plugin function exporter fixture", "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": {"pluginFunctions": 1, "backend": 2, "serverProcess": 1}, + "pluginAPIVersion": 0, + "requires": {"pluginFunctions": 0, "backend": 0}, "vendor": {"id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"}, "permissions": ["backend.launch"], "pluginFunctions": { diff --git a/shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json b/shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json index e606b4ca..5e900ff2 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json +++ b/shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json @@ -1,7 +1,7 @@ { - "version": 1, - "offer": { "litheLogging": { "versions": [1] } }, - "answer": { "litheLogging": { "version": 1 } }, + "version": 0, + "offer": { "litheLogging": { "versions": [0] } }, + "answer": { "litheLogging": { "version": 0 } }, "notifications": [ { "kind": "operationFinished", "operation": "database.call", "durationMs": 18, "operationId": "7" }, { "kind": "operationFailed", "operation": "database.call", "durationMs": 20, "code": "database.unavailable", "operationId": "8" }, diff --git a/shared/contracts/plugins/v1/fixtures/backend/terminal-backend.plugin.json b/shared/contracts/plugins/v1/fixtures/backend/terminal-backend.plugin.json index 18c20e00..4a8fe98d 100644 --- a/shared/contracts/plugins/v1/fixtures/backend/terminal-backend.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/backend/terminal-backend.plugin.json @@ -1,12 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.terminalbackend", "displayName": "Native Terminal Backend", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "serverProcess": 1 + "backend": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json index c1944cdd..ad8dc556 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json @@ -1,6 +1,6 @@ { "format": "litheBuildConfiguration", - "version": 1, + "version": 0, "sources": [ { "id": "user", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json index e3beac1d..7aed5a1a 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json @@ -1,6 +1,6 @@ { "format": "litheBuildRunOptions", - "version": 1, + "version": 0, "launchOptions": { "execution": "service", "runtimeArguments": [ diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json index 0faea862..a33a5c2d 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json @@ -1,5 +1,5 @@ { - "version": 2, + "version": 0, "request": { "method": "lithe/build/execute", "params": { diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json index ea4d15d3..946b5e4d 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-configuration", "displayName": "Native Configuration Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 7, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json index e6d09985..80b3c59c 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json @@ -1,16 +1,16 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-debug-preparation", "displayName": "Native Debug Preparation Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 5, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1, - "debugAdapters": 2 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0, + "debugAdapters": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-execution.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-execution.plugin.json index 725065e3..88c0c771 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-execution.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-execution.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-execution", "displayName": "Build Provider Execution Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 2, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-language-server.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-language-server.plugin.json index 39967483..92098928 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-language-server.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-language-server.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-language-server", "displayName": "Build Provider With Language Server Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildProviders": 0, "buildSystems": 0, "languageServers": 0, "languages": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json index 7da03c34..0cfbb1de 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-preparation", "displayName": "Declarative Preparation Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 2, - "buildSystems": 4, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-reports.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-reports.plugin.json index 4b7fc7db..81ee2867 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-reports.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-reports.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-reports", "displayName": "Build Provider Tests Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 8, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-options.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-options.plugin.json index 12b980f5..4f89667b 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-options.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-options.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-run-options", "displayName": "Provider Run Options Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 10, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json index eb5422c6..068acc08 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-run-preparation", "displayName": "Provider Run Preparation Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 4, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-selectors.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-selectors.plugin.json index e36cd3b4..ca73e97e 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-selectors.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-selectors.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-selectors", "displayName": "Build Provider Tests Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 9, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-tests.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-tests.plugin.json index 3749a7a3..ab5b338e 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-tests.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-tests.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider-tests", "displayName": "Build Provider Tests Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 3, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider.plugin.json index 512f7010..bc56d822 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-provider.plugin.json @@ -1,10 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-provider", "displayName": "Build Provider Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1, "settings": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildProviders": 0, "buildSystems": 0, "settings": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json index b8644d81..74a7571e 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json @@ -1,6 +1,6 @@ { "format": "litheBuildRunOptions", - "version": 1, + "version": 0, "launchOptions": { "execution": "service", "runtimeArguments": [ diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-test-discovery.json b/shared/contracts/plugins/v1/fixtures/build-providers/build-test-discovery.json index 32b0496d..52ed9052 100644 --- a/shared/contracts/plugins/v1/fixtures/build-providers/build-test-discovery.json +++ b/shared/contracts/plugins/v1/fixtures/build-providers/build-test-discovery.json @@ -1,7 +1,7 @@ { "result": { "format": "litheTestDiscovery", - "version": 1, + "version": 0, "tests": [ { "suite": "ExampleSuite", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-command-output-file.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-command-output-file.plugin.json index 7d5c974c..1a3b8f9b 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-command-output-file.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-command-output-file.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-command-output-file", "displayName": "Command Output File Build Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildSystems": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildSystems": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-command-stdout.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-command-stdout.plugin.json index 8ed8855e..ac3d721c 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-command-stdout.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-command-stdout.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-command-stdout", "displayName": "Command Stdout Build Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildSystems": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildSystems": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json index 9aa8cf68..f2a3d5c2 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json @@ -1,11 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-instance-options", "displayName": "Instance Options Build Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildSystems": 3 + "buildSystems": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-model-consumer.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-model-consumer.plugin.json index 57fc3c55..9ada7c94 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-model-consumer.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-model-consumer.plugin.json @@ -1,16 +1,16 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.model-consumer", "displayName": "Model Consumer Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 6, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1, - "buildModelInputs": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0, + "buildModelInputs": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-model-producer.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-model-producer.plugin.json index 92055e41..118cdfa9 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-model-producer.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-model-producer.plugin.json @@ -1,16 +1,16 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.model-producer", "displayName": "Model Producer Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1, - "settings": 1, - "buildModelInputs": 1 + "buildProviders": 0, + "buildSystems": 0, + "settings": 0, + "buildModelInputs": 0 }, + "requiresTools": ["java"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json index c66169dd..1eedda79 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json @@ -1634,7 +1634,7 @@ "root": ".", "result": { "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [ { "path": ".", @@ -1798,7 +1798,7 @@ "project": "app", "result": { "format": "litheDependencies", - "version": 1, + "version": 0, "dependencies": [ { "name": "spring-web", @@ -1850,7 +1850,7 @@ "root": ".", "result": { "format": "litheBuildModel", - "version": 1, + "version": 0, "projects": [ { "path": ".", @@ -1892,7 +1892,7 @@ "status": "rejected", "error": { "code": "plugin.build.invalidOutput", - "message": "Expected litheDependencies version 1." + "message": "Expected litheDependencies version 0." } } }, diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json index 78441771..6521b0e7 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-static-file", "displayName": "Static File Build Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildSystems": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildSystems": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json index 3115aac8..fb23779c 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json @@ -1,13 +1,12 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-tool-path", "displayName": "Build path fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildSystems": 1, - "settings": 2, - "executableResolution": 1 + "buildSystems": 0, + "settings": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json b/shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json index 386c84a1..b18f6fea 100644 --- a/shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json @@ -1,12 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.build-toolchains", "displayName": "Canonical Toolchain Build Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildSystems": 3, - "executableResolution": 2 + "buildSystems": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json index 0c8b0449..4535ec3e 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.debug-adapter-features", "displayName": "Debug Adapter Features Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, - "requires": { "backend": 2, "debugAdapters": 3, "serverProcess": 1 }, + "pluginAPIVersion": 0, + "requires": { "backend": 0, "debugAdapters": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json index aa5a9c3a..1f842865 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.debug-adapter-language-server", "displayName": "Debug Adapter In Language Server Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, - "requires": { "debugAdapters": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, + "pluginAPIVersion": 0, + "requires": { "debugAdapters": 0, "languageServers": 0, "languages": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-rejections.json b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-rejections.json index 19f09f99..a6d4f6b3 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-rejections.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-rejections.json @@ -20,10 +20,9 @@ }, "context": { "manifestMetadata": { - "pluginAPIVersion": 4, + "pluginAPIVersion": 0, "requires": { - "debugAdapters": 1, - "serverProcess": 1 + "debugAdapters": 0 }, "permissions": [ "debug.launch" @@ -31,15 +30,10 @@ }, "hostCapabilities": { "debugAdapters": [ - 1 - ], - "serverProcess": [ - 1, - 2 + 0 ], "settings": [ - 1, - 2 + 0 ] }, "registration": { @@ -94,9 +88,9 @@ "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].capabilities.stepBack"} }, { - "id": "old-api-debug", + "id": "debug-adapters-version-1", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/context/manifestMetadata/pluginAPIVersion", "value": 3}], + "edits": [{"op": "set", "path": "/context/manifestMetadata/requires/debugAdapters", "value": 1}], "expected": {"code": "plugin.api.unsupported", "field": "requires.debugAdapters"} }, { @@ -111,18 +105,6 @@ "edits": [{"op": "remove", "path": "/context/manifestMetadata/requires/debugAdapters"}], "expected": {"code": "plugin.manifest.invalid", "field": "requires"} }, - { - "id": "missing-process-requirement", - "layer": "coreManifest", - "edits": [{"op": "remove", "path": "/context/manifestMetadata/requires/serverProcess"}], - "expected": {"code": "plugin.manifest.invalid", "field": "requires"} - }, - { - "id": "unused-process-v2", - "layer": "coreManifest", - "edits": [{"op": "set", "path": "/context/manifestMetadata/requires/serverProcess", "value": 2}], - "expected": {"code": "plugin.manifest.invalid", "field": "requires.serverProcess"} - }, { "id": "duplicate-adapter-id", "layer": "coreManifest", @@ -148,15 +130,15 @@ "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach"} }, { - "id": "selected-runtime-v1", + "id": "selected-runtime-not-in-requires-tools", "layer": "coreManifest", "edits": [{"op": "set", "path": "/block/debugAdapters/0/launch/process", "value": {"launch": {"kind": "runSelectedRuntime", "tool": "python"}}}], - "expected": {"code": "plugin.manifest.invalid", "field": "requires.serverProcess"} + "expected": {"code": "plugin.runtime.undeclared", "field": "debugAdapters[0].launch.process.launch.tool"} }, { "id": "stale-selected-runtime", "layer": "corePlan", - "edits": [{"op": "set", "path": "/block/debugAdapters/0/launch/process", "value": {"launch": {"kind": "runSelectedRuntime", "tool": "python"}}}, {"op": "set", "path": "/context/manifestMetadata/requires/serverProcess", "value": 2}, {"op": "set", "path": "/context/runtimeSelection/currentGeneration", "value": 2}], + "edits": [{"op": "set", "path": "/block/debugAdapters/0/launch/process", "value": {"launch": {"kind": "runSelectedRuntime", "tool": "python"}}}, {"op": "set", "path": "/context/manifestMetadata/requiresTools", "value": ["python"]}, {"op": "set", "path": "/context/runtimeSelection/currentGeneration", "value": 2}], "expected": {"code": "debug.runtime.stale", "field": "runtimeSelection"} }, { @@ -216,13 +198,13 @@ { "id": "executable-without-binding", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable"} }, { "id": "executable-binding-without-lookup", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable"} }, { @@ -234,49 +216,49 @@ { "id": "executable-setting-not-file", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "boolean", "title": "Program", "default": false}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 1}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "boolean", "title": "Program", "default": false}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable.setting"} }, { "id": "executable-candidate-outside-roots", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["/usr/local/bin/app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["/usr/local/bin/app"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable.candidates[0]"} }, { "id": "executable-candidate-parent-directory", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/../app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/../app"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable.candidates[0]"} }, { "id": "executable-candidate-unknown-variable", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/${env:HOME}"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/${env:HOME}"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable.candidates[0]"} }, { "id": "executable-duplicate-candidates", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/app", "${workspaceFolder}/app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/app", "${workspaceFolder}/app"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable.candidates"} }, { "id": "executable-too-many-candidates", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/c0", "${workspaceFolder}/c1", "${workspaceFolder}/c2", "${workspaceFolder}/c3", "${workspaceFolder}/c4", "${workspaceFolder}/c5", "${workspaceFolder}/c6", "${workspaceFolder}/c7", "${workspaceFolder}/c8"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/c0", "${workspaceFolder}/c1", "${workspaceFolder}/c2", "${workspaceFolder}/c3", "${workspaceFolder}/c4", "${workspaceFolder}/c5", "${workspaceFolder}/c6", "${workspaceFolder}/c7", "${workspaceFolder}/c8"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.executable.candidates"} }, { "id": "executable-on-attach", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["launch", "attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["launch", "attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.executable"} }, { "id": "missing-target-executable", "layer": "corePlan", - "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 2}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], + "edits": [{"op": "set", "path": "/block/settings", "value": [{"id": "program", "type": "file", "title": "Program", "default": "", "scope": "project", "effect": "nextRun"}]}, {"op": "set", "path": "/context/manifestMetadata/requires/settings", "value": 0}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"program": "target.executable"}, "executable": {"setting": "program", "candidates": ["${workspaceFolder}/build/app"]}}}}], "expected": {"code": "debug.target.invalid", "field": "target.executable"} }, { @@ -286,10 +268,10 @@ "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.parametersSchema"} }, { - "id": "api-level-below-4", + "id": "requires-absent", "layer": "coreManifest", - "edits": [{"op": "set", "path": "/context/manifestMetadata/pluginAPIVersion", "value": 2}, {"op": "remove", "path": "/context/manifestMetadata/requires"}], - "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters"} + "edits": [{"op": "remove", "path": "/context/manifestMetadata/requires"}], + "expected": {"code": "plugin.manifest.invalid", "field": "requires"} }, { "id": "missing-adapter-executable", @@ -330,7 +312,7 @@ { "id": "runtime-adapter-entry-missing", "layer": "coreInstall", - "edits": [{"op": "set", "path": "/block/debugAdapters/0/launch/process/launch", "value": {"kind": "runtime", "tool": "java", "entry": "lib/adapter.jar"}}, {"op": "set", "path": "/context/packageResources", "value": []}], + "edits": [{"op": "set", "path": "/block/debugAdapters/0/launch/process/launch", "value": {"kind": "runtime", "tool": "java", "entry": "lib/adapter.jar"}}, {"op": "set", "path": "/context/manifestMetadata/requiresTools", "value": ["java"]}, {"op": "set", "path": "/context/packageResources", "value": []}], "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].launch.process.launch.entry"} }, { diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json index fa9f8689..67085f84 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.debug-adapter-remote-attach", "displayName": "Debug Adapter Remote Attach Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, - "requires": { "debugAdapters": 1, "serverProcess": 1 }, + "pluginAPIVersion": 0, + "requires": { "debugAdapters": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json index 568cfa89..4b6eb2c9 100644 --- a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.debug-adapter", "displayName": "Debug Adapter Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, - "requires": { "debugAdapters": 1, "serverProcess": 1, "settings": 2 }, + "pluginAPIVersion": 0, + "requires": { "debugAdapters": 0, "settings": 0 }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-actions-v2.json b/shared/contracts/plugins/v1/fixtures/editor/editor-actions.json similarity index 89% rename from shared/contracts/plugins/v1/fixtures/editor/editor-actions-v2.json rename to shared/contracts/plugins/v1/fixtures/editor/editor-actions.json index f0083869..88550cca 100644 --- a/shared/contracts/plugins/v1/fixtures/editor/editor-actions-v2.json +++ b/shared/contracts/plugins/v1/fixtures/editor/editor-actions.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "description": "Version 2 editor action validation. `host` is what the host declared in getCapabilities (`services.editor.options.actionIcons` and `limits.editorActionSourceBytes`); `kind` is `actions` for a collector result (one-based UTF-16 positions in `text`) or `invokeResult` for the result of a clicked action or alternative. Accepted cases list the normalized value; rejected cases reject the whole result.", + "description": "Editor action validation through `plugins.editorActions.decode`. Every case is sent as the request `{version, kind, icons, sourceBytes, text, value}`: `version` is required and is `0` (Core accepts only `0`, otherwise `Unsupported editor action validation version.`); `icons` and `sourceBytes` are the `host` members below, which the host declared in getCapabilities (`services.editor.options.actionIcons` and `limits.editorActionSourceBytes`); `kind` is `actions` for a collector result (one-based UTF-16 positions in `text`) or `invokeResult` for the result of a clicked action or alternative. Accepted cases list the normalized value; rejected cases reject the whole result.", "host": { "actionIcons": ["run", "run-all", "debug", "test", "test-passed", "test-failed", "test-skipped", "navigate-super", "navigate-implementations"], "actionSourceBytes": 1048576 diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-actions.plugin.json b/shared/contracts/plugins/v1/fixtures/editor/editor-actions.plugin.json index 2c499d2f..466c436d 100644 --- a/shared/contracts/plugins/v1/fixtures/editor/editor-actions.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/editor/editor-actions.plugin.json @@ -1,12 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.editoractions", "displayName": "Editor Actions", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "serverProcess": 1 + "backend": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-providers.plugin.json b/shared/contracts/plugins/v1/fixtures/editor/editor-providers.plugin.json index 0a3dd906..91a68cbf 100644 --- a/shared/contracts/plugins/v1/fixtures/editor/editor-providers.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/editor/editor-providers.plugin.json @@ -1,12 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.editorproviders", "displayName": "Editor Providers", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "serverProcess": 1 + "backend": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json index 0cafa02b..dcf6e4fb 100644 --- a/shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.import", "displayName": "Shortcut import fixture", "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": {"keymapImportProviders": 1, "backend": 2, "serverProcess": 1}, + "pluginAPIVersion": 0, + "requires": {"keymapImportProviders": 0, "backend": 0}, "vendor": {"id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"}, "permissions": ["backend.launch", "keymaps.provide", "keymaps.readSelectedSources"], "keymapImportProviders": [ diff --git a/shared/contracts/plugins/v1/fixtures/languages/command-plans.json b/shared/contracts/plugins/v1/fixtures/languages/command-plans.json index 9200afed..54cca62c 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/command-plans.json +++ b/shared/contracts/plugins/v1/fixtures/languages/command-plans.json @@ -194,7 +194,7 @@ } }, { - "name": "node launch discovers the runtime, carries its version probe, and expands host directories", + "name": "node launch names the tool, its entry and the expanded runtime arguments", "plugin": "node-runtime.plugin.json", "languageID": "fixture", "operation": "languageServerLaunchPlan", @@ -209,51 +209,27 @@ "plan": { "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { - "kind": "runtime", + "kind": "tool", "tool": "node", - "entry": "language-server/lib/server.js", - "minimumVersion": "18.17", - "versionArguments": [ - "--version" - ] + "entry": "/plugins/node/versions/1.0.0/language-server/lib/server.js", + "runtimeArguments": ["--max-old-space-size=2048", "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js"], + "minimumVersion": "18.17" }, - "arguments": [ - "--max-old-space-size=2048", - "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js", - "/plugins/node/versions/1.0.0/language-server/lib/server.js", - "--stdio", - "--log-level=info", - "--storage", - "/state/node/storage", - "--workspace-cache", - "/state/node/workspaces/abc" - ], + "arguments": ["--stdio", "--log-level=info", "--storage", "/state/node/storage", "--workspace-cache", "/state/node/workspaces/abc"], "validationArguments": [], - "runtimeRequirements": [ - { - "tool": "node", - "minimumVersion": "18.17" - } - ], - "initializationOptions": { - "stubs": [ - "/plugins/node/versions/1.0.0/language-server/stubs/x86_64" - ], - "telemetry": false - }, - "requiredDirectories": [ - "state", - "workspaceState" - ], - "requiredPackagePaths": [ - "language-server/lib/preload.js", - "language-server/lib/server.js", - "language-server/stubs/x86_64" - ], - "settings": { - "heapMB": 2048, - "logLevel": "info", - "telemetry": false + "runtimeRequirements": [{ "kind": "tool", "tool": "node", "minimumVersion": "18.17" }], + "initializationOptions": { "stubs": ["/plugins/node/versions/1.0.0/language-server/stubs/x86_64"], "telemetry": false }, + "requiredDirectories": ["state", "workspaceState"], + "requiredPackagePaths": ["language-server/lib/preload.js", "language-server/lib/server.js", "language-server/stubs/x86_64"], + "settings": { "heapMB": 2048, "logLevel": "info", "telemetry": false }, + "sessionPolicy": { + "serverID": "fixture", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] } } } @@ -268,62 +244,34 @@ "stateDirectory": "/state/node", "workspaceStateDirectory": "/state/node/workspaces/abc", "architecture": "x86_64", - "settings": { - "heapMB": 4096, - "logLevel": "debug", - "telemetry": true - } + "settings": { "heapMB": 4096, "logLevel": "debug", "telemetry": true } }, "expected": { "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { - "kind": "runtime", + "kind": "tool", "tool": "node", - "entry": "language-server/lib/server.js", - "minimumVersion": "18.17", - "versionArguments": [ - "--version" - ] + "entry": "/plugins/node/versions/1.0.0/language-server/lib/server.js", + "runtimeArguments": ["--max-old-space-size=4096", "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js"], + "minimumVersion": "18.17" }, - "arguments": [ - "--max-old-space-size=4096", - "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js", - "/plugins/node/versions/1.0.0/language-server/lib/server.js", - "--stdio", - "--log-level=debug", - "--storage", - "/state/node/storage", - "--workspace-cache", - "/state/node/workspaces/abc" - ], + "arguments": ["--stdio", "--log-level=debug", "--storage", "/state/node/storage", "--workspace-cache", "/state/node/workspaces/abc"], "validationArguments": [], - "runtimeRequirements": [ - { - "tool": "node", - "minimumVersion": "18.17" - } - ], - "initializationOptions": { - "stubs": [ - "/plugins/node/versions/1.0.0/language-server/stubs/x86_64" - ], - "telemetry": true - }, - "requiredDirectories": [ - "state", - "workspaceState" - ], - "requiredPackagePaths": [ - "language-server/lib/preload.js", - "language-server/lib/server.js", - "language-server/stubs/x86_64" - ], - "settings": { - "heapMB": 4096, - "logLevel": "debug", - "telemetry": true + "runtimeRequirements": [{ "kind": "tool", "tool": "node", "minimumVersion": "18.17" }], + "initializationOptions": { "stubs": ["/plugins/node/versions/1.0.0/language-server/stubs/x86_64"], "telemetry": true }, + "requiredDirectories": ["state", "workspaceState"], + "requiredPackagePaths": ["language-server/lib/preload.js", "language-server/lib/server.js", "language-server/stubs/x86_64"], + "settings": { "heapMB": 4096, "logLevel": "debug", "telemetry": true }, + "sessionPolicy": { + "serverID": "fixture", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] } } } @@ -338,63 +286,34 @@ "stateDirectory": "/state/node", "workspaceStateDirectory": "/state/node/workspaces/abc", "architecture": "x86_64", - "settings": { - "heapMB": 99999, - "logLevel": "verbose", - "telemetry": "yes", - "unknown": 1 - } + "settings": { "heapMB": 99999, "logLevel": "verbose", "telemetry": "yes", "unknown": 1 } }, "expected": { "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { - "kind": "runtime", + "kind": "tool", "tool": "node", - "entry": "language-server/lib/server.js", - "minimumVersion": "18.17", - "versionArguments": [ - "--version" - ] + "entry": "/plugins/node/versions/1.0.0/language-server/lib/server.js", + "runtimeArguments": ["--max-old-space-size=2048", "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js"], + "minimumVersion": "18.17" }, - "arguments": [ - "--max-old-space-size=2048", - "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js", - "/plugins/node/versions/1.0.0/language-server/lib/server.js", - "--stdio", - "--log-level=info", - "--storage", - "/state/node/storage", - "--workspace-cache", - "/state/node/workspaces/abc" - ], + "arguments": ["--stdio", "--log-level=info", "--storage", "/state/node/storage", "--workspace-cache", "/state/node/workspaces/abc"], "validationArguments": [], - "runtimeRequirements": [ - { - "tool": "node", - "minimumVersion": "18.17" - } - ], - "initializationOptions": { - "stubs": [ - "/plugins/node/versions/1.0.0/language-server/stubs/x86_64" - ], - "telemetry": false - }, - "requiredDirectories": [ - "state", - "workspaceState" - ], - "requiredPackagePaths": [ - "language-server/lib/preload.js", - "language-server/lib/server.js", - "language-server/stubs/x86_64" - ], - "settings": { - "heapMB": 2048, - "logLevel": "info", - "telemetry": false + "runtimeRequirements": [{ "kind": "tool", "tool": "node", "minimumVersion": "18.17" }], + "initializationOptions": { "stubs": ["/plugins/node/versions/1.0.0/language-server/stubs/x86_64"], "telemetry": false }, + "requiredDirectories": ["state", "workspaceState"], + "requiredPackagePaths": ["language-server/lib/preload.js", "language-server/lib/server.js", "language-server/stubs/x86_64"], + "settings": { "heapMB": 2048, "logLevel": "info", "telemetry": false }, + "sessionPolicy": { + "serverID": "fixture", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] } } } @@ -409,66 +328,40 @@ "stateDirectory": "/state/node", "workspaceStateDirectory": "/state/node/workspaces/abc", "architecture": "x86_64", - "settings": { - "heapMB": 1024 - } + "settings": { "heapMB": 1024 } }, "expected": { "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { - "kind": "runtime", + "kind": "tool", "tool": "node", - "entry": "language-server/lib/server.js", - "minimumVersion": "18.17", - "versionArguments": [ - "--version" - ] + "entry": "/plugins/node/versions/1.0.0/language-server/lib/server.js", + "runtimeArguments": ["--max-old-space-size=1024", "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js"], + "minimumVersion": "18.17" }, - "arguments": [ - "--max-old-space-size=1024", - "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js", - "/plugins/node/versions/1.0.0/language-server/lib/server.js", - "--stdio", - "--log-level=info", - "--storage", - "/state/node/storage", - "--workspace-cache", - "/state/node/workspaces/abc" - ], + "arguments": ["--stdio", "--log-level=info", "--storage", "/state/node/storage", "--workspace-cache", "/state/node/workspaces/abc"], "validationArguments": [], - "runtimeRequirements": [ - { - "tool": "node", - "minimumVersion": "18.17" - } - ], - "initializationOptions": { - "stubs": [ - "/plugins/node/versions/1.0.0/language-server/stubs/x86_64" - ], - "telemetry": false - }, - "requiredDirectories": [ - "state", - "workspaceState" - ], - "requiredPackagePaths": [ - "language-server/lib/preload.js", - "language-server/lib/server.js", - "language-server/stubs/x86_64" - ], - "settings": { - "heapMB": 1024, - "logLevel": "info", - "telemetry": false + "runtimeRequirements": [{ "kind": "tool", "tool": "node", "minimumVersion": "18.17" }], + "initializationOptions": { "stubs": ["/plugins/node/versions/1.0.0/language-server/stubs/x86_64"], "telemetry": false }, + "requiredDirectories": ["state", "workspaceState"], + "requiredPackagePaths": ["language-server/lib/preload.js", "language-server/lib/server.js", "language-server/stubs/x86_64"], + "settings": { "heapMB": 1024, "logLevel": "info", "telemetry": false }, + "sessionPolicy": { + "serverID": "fixture", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] } } } }, { - "name": "php launch keeps API 1 arguments literal and names the packaged executable", + "name": "php launch names the packaged executable", "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "languageServerLaunchPlan", @@ -482,23 +375,21 @@ "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.language-packaged-server", "version": "0.1.0" }, - "launch": { - "kind": "packaged", - "path": "language-server/bin/intelephense" - }, - "arguments": [ - "--stdio" - ], + "launch": { "kind": "packaged", "path": "language-server/bin/intelephense" }, + "arguments": ["--stdio"], "validationArguments": [], - "runtimeRequirements": [ - { - "tool": "node" - } - ], + "runtimeRequirements": [{ "kind": "tool", "tool": "node" }], "requiredDirectories": [], - "requiredPackagePaths": [ - "language-server/bin/intelephense" - ] + "requiredPackagePaths": ["language-server/bin/intelephense"], + "sessionPolicy": { + "serverID": "php", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] + } } } }, @@ -517,23 +408,21 @@ "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.language-system-command", "version": "0.1.0" }, - "launch": { - "kind": "systemCommand", - "commands": [ - "gopls" - ] - }, + "launch": { "kind": "systemCommand", "commands": ["gopls"] }, "arguments": [], - "validationArguments": [ - "version" - ], - "runtimeRequirements": [ - { - "tool": "go" - } - ], + "validationArguments": ["version"], + "runtimeRequirements": [{ "kind": "tool", "tool": "go" }], "requiredDirectories": [], - "requiredPackagePaths": [] + "requiredPackagePaths": [], + "sessionPolicy": { + "serverID": "go", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] + } } } }, @@ -552,24 +441,21 @@ "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.build-provider-language-server", "version": "0.1.0" }, - "launch": { - "kind": "systemCommand", - "commands": [ - "elixir-ls" - ] - }, - "arguments": [ - "--stdio" - ], + "launch": { "kind": "systemCommand", "commands": ["elixir-ls"] }, + "arguments": ["--stdio"], "validationArguments": [], "runtimeRequirements": [], "requiredDirectories": [], "requiredPackagePaths": [], - "buildProvider": { - "buildSystemID": "mix", - "executesProjectCode": true, - "mayUseNetwork": false, - "languageServer": "elixir" + "buildProvider": { "buildSystemID": "mix", "executesProjectCode": true, "mayUseNetwork": false, "languageServer": "elixir" }, + "sessionPolicy": { + "serverID": "elixir", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] } } } @@ -589,19 +475,21 @@ "status": "ok", "plan": { "owner": { "pluginID": "dev.lithe.fixture.build-provider-language-server", "version": "0.1.0" }, - "launch": { - "kind": "systemCommand", - "commands": [ - "elixir-ls" - ] - }, - "arguments": [ - "--stdio" - ], + "launch": { "kind": "systemCommand", "commands": ["elixir-ls"] }, + "arguments": ["--stdio"], "validationArguments": [], "runtimeRequirements": [], "requiredDirectories": [], - "requiredPackagePaths": [] + "requiredPackagePaths": [], + "sessionPolicy": { + "serverID": "elixir", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] + } } } }, @@ -657,53 +545,31 @@ "plan": { "owner": { "pluginID": "dev.lithe.fixture.node-runtime", "version": "1.0.0" }, "launch": { - "kind": "runtime", + "kind": "tool", "tool": "node", - "entry": "language-server/lib/server.js", - "minimumVersion": "18.17", - "versionArguments": [ - "--version" - ] + "entry": "/plugins/node/versions/1.0.0/language-server/lib/server.js", + "runtimeArguments": ["--max-old-space-size=2048", "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js"], + "minimumVersion": "18.17" }, - "arguments": [ - "--max-old-space-size=2048", - "--require=/plugins/node/versions/1.0.0/language-server/lib/preload.js", - "/plugins/node/versions/1.0.0/language-server/lib/server.js", - "--stdio", - "--log=/logs/sessions/current/plugins/io.example/files/server.log" - ], + "arguments": ["--stdio", "--log=/logs/sessions/current/plugins/io.example/files/server.log"], "validationArguments": [], - "runtimeRequirements": [ - { - "tool": "node", - "minimumVersion": "18.17" - } - ], - "initializationOptions": { - "stubs": [ - "/plugins/node/versions/1.0.0/language-server/stubs/x86_64" - ], - "telemetry": false - }, - "requiredDirectories": [ - "log" - ], - "requiredPackagePaths": [ - "language-server/lib/preload.js", - "language-server/lib/server.js", - "language-server/stubs/x86_64" - ], - "settings": { - "heapMB": 2048, - "logLevel": "info", - "telemetry": false + "runtimeRequirements": [{ "kind": "tool", "tool": "node", "minimumVersion": "18.17" }], + "initializationOptions": { "stubs": ["/plugins/node/versions/1.0.0/language-server/stubs/x86_64"], "telemetry": false }, + "requiredDirectories": ["log"], + "requiredPackagePaths": ["language-server/lib/preload.js", "language-server/lib/server.js", "language-server/stubs/x86_64"], + "settings": { "heapMB": 2048, "logLevel": "info", "telemetry": false }, + "sessionPolicy": { + "serverID": "fixture", + "requestMethods": [], + "commands": [], + "serializedCommands": [], + "forwardNotifications": [], + "remote": "supported", + "gates": [] } } }, - "manifestArguments": [ - "--stdio", - "--log=${logDir}/server.log" - ] + "manifestArguments": ["--stdio", "--log=${logDir}/server.log"] } ] } diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-grammar.plugin.json b/shared/contracts/plugins/v1/fixtures/languages/language-grammar.plugin.json index cc92d19f..cf14f9ad 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/language-grammar.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/languages/language-grammar.plugin.json @@ -1,11 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.httpgrammar", "displayName": "HTTP Request Syntax", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "languages": 3 + "languages": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-packaged-server.plugin.json b/shared/contracts/plugins/v1/fixtures/languages/language-packaged-server.plugin.json index 30aacbd2..025017b9 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/language-packaged-server.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/languages/language-packaged-server.plugin.json @@ -1,9 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.language-packaged-server", "displayName": "Packaged Language Server Fixture", "version": "0.1.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": { + "executionProviders": 0, + "languageServers": 0, + "languages": 0 + }, + "requiresTools": ["node"], "vendor": { "id": "dev.lithe", "displayName": "Lithe", diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-server-backend-launch.plugin.json b/shared/contracts/plugins/v1/fixtures/languages/language-server-backend-launch.plugin.json index 2bc42b5d..5636066e 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/language-server-backend-launch.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/languages/language-server-backend-launch.plugin.json @@ -1,16 +1,14 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.language-server-backend-launch", "displayName": "Backend Launched Language Server Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "executableResolution": 2, - "languageServers": 3, - "languages": 1, - "serverProcess": 3, - "toolDefinitions": 1 + "backend": 0, + "languageServers": 0, + "languages": 0, + "toolDefinitions": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json b/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json index 14a58898..49d5d17f 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json +++ b/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "description": "Golden launch of a language server whose backend resolves the command line (serverProcess 3). `languageServerLaunchPlan` is the first step: Core names the function to call and its params. `decodeLaunch` is the second step: Core bounds the function's `result` and returns the plan the host starts. A case runs against `plugin` and `languageID` unless it names its own, with `request` changed by `requestEdits` (JSON Pointer `set` or `remove`). `expected` is the command's data object, or `{status: \"rejected\", code}` for the stable rejection code. `resolveParams.platform.os` is the host's operating system and is not compared.", + "description": "Golden launch of a language server whose backend resolves the command line (a `backend` launch). `languageServerLaunchPlan` is the first step: Core names the function to call and its params. `decodeLaunch` is the second step: Core bounds the function's `result` and returns the plan the host starts. A case runs against `plugin` and `languageID` unless it names its own, with `request` changed by `requestEdits` (JSON Pointer `set` or `remove`). `expected` is the command's data object, or `{status: \"rejected\", code}` for the stable rejection code. `resolveParams.platform.os` is the host's operating system and is not compared.", "plugin": "language-server-backend-launch.plugin.json", "languageID": "scala", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-system-command.plugin.json b/shared/contracts/plugins/v1/fixtures/languages/language-system-command.plugin.json index 299f341b..cb7f00d7 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/language-system-command.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/languages/language-system-command.plugin.json @@ -1,9 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.language-system-command", "displayName": "System Command Language Server Fixture", "version": "0.1.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": { + "executionProviders": 0, + "languageServers": 0, + "languages": 0 + }, + "requiresTools": ["go"], "vendor": { "id": "dev.lithe", "displayName": "Lithe", diff --git a/shared/contracts/plugins/v1/fixtures/languages/node-runtime.plugin.json b/shared/contracts/plugins/v1/fixtures/languages/node-runtime.plugin.json index 7914e4b9..f30e9aae 100644 --- a/shared/contracts/plugins/v1/fixtures/languages/node-runtime.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/languages/node-runtime.plugin.json @@ -1,9 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.node-runtime", "displayName": "Node Runtime Fixture", "version": "1.0.0", - "pluginAPIVersion": 2, + "pluginAPIVersion": 0, + "requires": { + "languageServers": 0, + "languages": 0, + "settings": 0 + }, + "requiresTools": ["node"], "vendor": { "id": "dev.lithe", "displayName": "Lithe", diff --git a/shared/contracts/plugins/v1/fixtures/languages/runtime-versions.json b/shared/contracts/plugins/v1/fixtures/languages/runtime-versions.json deleted file mode 100644 index 327882e7..00000000 --- a/shared/contracts/plugins/v1/fixtures/languages/runtime-versions.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "schemaVersion": 1, - "description": "Golden outcomes of plugins.declarative.checkRuntimeVersion. `request` is the command payload; `expected` is the returned `check` object, or a rejection code.", - "cases": [ - { "name": "node version with a leading v", "request": { "tool": "node", "minimumVersion": "18.17", "output": "v20.11.0" }, "expected": { "satisfied": true, "version": "20.11.0" } }, - { "name": "node minor below the requirement", "request": { "tool": "node", "minimumVersion": "18.17", "output": "v18.16.1" }, "expected": { "satisfied": false, "version": "18.16.1" } }, - { "name": "node meets a major-only requirement", "request": { "tool": "node", "minimumVersion": "20", "output": "v20.0.0" }, "expected": { "satisfied": true, "version": "20.0.0" } }, - { "name": "a newer node major satisfies the requirement", "request": { "tool": "node", "minimumVersion": "18", "output": "v22.3.0" }, "expected": { "satisfied": true, "version": "22.3.0" } }, - { "name": "the first line with a digit carries the version", "request": { "tool": "node", "minimumVersion": "20", "output": "warning: using a custom runtime\nv18.20.4\nv22.0.0" }, "expected": { "satisfied": false, "version": "18.20.4" } }, - { "name": "requirement names more components than the runtime reports", "request": { "tool": "node", "minimumVersion": "21.0.1", "output": "v21" }, "expected": { "satisfied": false, "version": "21" } }, - { "name": "output without a version", "request": { "tool": "node", "minimumVersion": "18", "output": "node: command not found" }, "expected": { "satisfied": false } }, - { "name": "a tool that cannot start an entry file", "request": { "tool": "go", "minimumVersion": "1.22", "output": "go version go1.22.3 darwin/arm64" }, "expectedCode": "plugin.runtime.unsupported" }, - { "name": "a tool definition checks its own version", "request": { "tool": "fixture-sdk", "minimumVersion": "21", "output": "fixture-sdk 21.0.4" }, "expectedCode": "plugin.runtime.unsupported" }, - { "name": "malformed requirement", "request": { "tool": "node", "minimumVersion": "21+", "output": "v21.0.0" }, "expectedCode": "plugin.manifest.invalid" } - ] -} diff --git a/shared/contracts/plugins/v1/fixtures/manifest/hello-lithe.plugin.json b/shared/contracts/plugins/v1/fixtures/manifest/hello-lithe.plugin.json index e31ca05f..d9810e76 100644 --- a/shared/contracts/plugins/v1/fixtures/manifest/hello-lithe.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/manifest/hello-lithe.plugin.json @@ -1,9 +1,12 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.hello", "displayName": "Hello Lithe", "version": "1.0.0", - "pluginAPIVersion": 2, + "pluginAPIVersion": 0, + "requires": { + "ui": 0 + }, "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json index 6188e227..fe63bf13 100644 --- a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json @@ -1,19 +1,27 @@ { "schemaVersion": 1, - "description": "Each case edits a valid fixture manifest and states the stable rejection code every host must report. Edits use JSON Pointer paths; `set` replaces or adds a value and `remove` deletes it.", + "description": "Each case edits a valid fixture manifest and states the stable rejection code every host must report. Edits use JSON Pointer paths; `set` replaces or adds a value and `remove` deletes it. When a case has `expectedField`, the reported error also names that manifest field.", "cases": [ { - "name": "legacy native manifest schema", + "name": "manifest schemaVersion 1 is not accepted", "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/schemaVersion", "value": 1 }], "expectedCode": "plugin.schema.unsupported" }, + { + "name": "manifest schemaVersion 2 is not accepted", + "base": "language-packaged-server.plugin.json", + "edits": [{ "op": "set", "path": "/schemaVersion", "value": 2 }], + "expectedCode": "plugin.schema.unsupported" + }, { "name": "future plugin API", "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/pluginAPIVersion", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, + {"name": "pluginAPIVersion 4 is not accepted", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/pluginAPIVersion", "value": 4}], "expectedCode": "plugin.api.unsupported", "expectedField": "pluginAPIVersion"}, + {"name": "pluginAPIVersion 1 is not accepted", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/pluginAPIVersion", "value": 1}], "expectedCode": "plugin.api.unsupported", "expectedField": "pluginAPIVersion"}, { "name": "unknown top-level field", "base": "language-packaged-server.plugin.json", @@ -45,10 +53,10 @@ "expectedCode": "plugin.manifest.invalid" }, { - "name": "unsupported runtime dependency", + "name": "runtime dependency not listed in requiresTools", "base": "language-packaged-server.plugin.json", - "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "Python 3" }] }], - "expectedCode": "plugin.runtime.unsupported" + "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "python" }] }], + "expectedCode": "plugin.runtime.undeclared" }, { "name": "unknown permission", @@ -104,36 +112,12 @@ "edits": [{ "op": "set", "path": "/executionProviders/0/run/executable", "value": { "command": "/usr/bin/php" } }], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "runtime launch in an API 1 manifest", - "base": "language-packaged-server.plugin.json", - "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "runtime", "tool": "node", "entry": "language-server/lib/intelephense.js" } }, { "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [] }], - "expectedCode": "plugin.manifest.invalid" - }, - { - "name": "initialization options in an API 1 manifest", - "base": "language-packaged-server.plugin.json", - "edits": [{ "op": "set", "path": "/languageServers/0/initializationOptions", "value": { "storagePath": "cache" } }], - "expectedCode": "plugin.manifest.invalid" - }, - { - "name": "host integration in an API 1 manifest", - "base": "language-packaged-server.plugin.json", - "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration", "value": { "id": "jdt" } }], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "runtime dependency states a minimum version", "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "node", "minimumVersion": "18" }] }], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "runtime launch with a tool that cannot start an entry", - "base": "node-runtime.plugin.json", - "edits": [{ "op": "set", "path": "/languageServers/0/launch/tool", "value": "go" }], - "expectedCode": "plugin.runtime.unsupported" - }, { "name": "runtime entry escapes the package", "base": "node-runtime.plugin.json", @@ -212,7 +196,6 @@ "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration", "value": { "id": "roslyn" } }], "expectedCode": "plugin.integration.unsupported" }, - {"name": "settings on API level 1", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, {"name": "launch template names an undeclared setting", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/arguments/1", "value": "--log-level=${setting:verbosity}"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting shapes a package path", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/launch/runtimeArguments/1", "value": "--require=${pluginDir}/language-server/${setting:logLevel}.js"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting identifier with a separator", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/0/id", "value": "heap-mb"}], "expectedCode": "plugin.manifest.invalid"}, @@ -225,18 +208,6 @@ {"name": "enum option value could inject an argument", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/1/options/0/value", "value": "error --inspect"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "boolean default is text", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/2/default", "value": "false"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "setting with an unknown field", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/2/scope", "value": "workspace"}], "expectedCode": "plugin.manifest.invalid"}, - { - "name": "UI requires API level 2", - "base": "hello-lithe.plugin.json", - "edits": [ - { - "op": "set", - "path": "/pluginAPIVersion", - "value": 1 - } - ], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "UI permission is required", "base": "hello-lithe.plugin.json", @@ -364,15 +335,6 @@ ], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "build systems need plugin API 3", - "base": "build-static-file.plugin.json", - "edits": [ - { "op": "set", "path": "/pluginAPIVersion", "value": 2 }, - { "op": "remove", "path": "/requires" } - ], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "build systems need the build permission", "base": "build-static-file.plugin.json", @@ -653,10 +615,10 @@ "expectedCode": "plugin.manifest.invalid" }, { - "name": "provider runtime the host cannot start", + "name": "provider runtime tool not listed in requiresTools", "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/tool", "value": "go" }], - "expectedCode": "plugin.runtime.unsupported" + "expectedCode": "plugin.runtime.undeclared" }, { "name": "provider argument names an undeclared setting", @@ -671,17 +633,11 @@ "expectedCode": "plugin.manifest.invalid" }, { - "name": "plugin API 3 without requires", + "name": "manifest without requires", "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/requires" }], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "requires below plugin API 3", - "base": "language-packaged-server.plugin.json", - "edits": [{ "op": "set", "path": "/requires", "value": { "languageServers": 1, "languages": 1, "serverProcess": 1 } }], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "empty requires", "base": "build-static-file.plugin.json", @@ -689,28 +645,28 @@ "expectedCode": "plugin.manifest.invalid" }, { - "name": "capability version is not a positive integer", + "name": "capability version is not a non-negative integer", "base": "build-static-file.plugin.json", - "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 0 }], + "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": -1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "malformed capability ID", "base": "build-static-file.plugin.json", - "edits": [{ "op": "set", "path": "/requires/build-systems", "value": 1 }], + "edits": [{ "op": "set", "path": "/requires/build-systems", "value": 0 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "future capability", "base": "build-static-file.plugin.json", - "edits": [{ "op": "set", "path": "/requires/debuggers", "value": 1 }], + "edits": [{ "op": "set", "path": "/requires/debuggers", "value": 0 }], "expectedCode": "plugin.api.unsupported" }, { "name": "future capability is reported before its unknown section", "base": "build-static-file.plugin.json", "edits": [ - { "op": "set", "path": "/requires/debuggers", "value": 1 }, + { "op": "set", "path": "/requires/debuggers", "value": 0 }, { "op": "set", "path": "/debuggers", "value": [{ "id": "lldb" }] } ], "expectedCode": "plugin.api.unsupported" @@ -721,6 +677,7 @@ "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 5 }], "expectedCode": "plugin.api.unsupported" }, + {"name": "capability version 1 is not defined", "base": "build-static-file.plugin.json", "edits": [{"op": "set", "path": "/requires/buildSystems", "value": 1}], "expectedCode": "plugin.api.unsupported", "expectedField": "requires.buildSystems"}, { "name": "used capability missing from requires", "base": "build-provider-language-server.plugin.json", @@ -730,9 +687,11 @@ { "name": "requires lists an unused capability", "base": "build-static-file.plugin.json", - "edits": [{ "op": "set", "path": "/requires/ui", "value": 1 }], + "edits": [{ "op": "set", "path": "/requires/ui", "value": 0 }], "expectedCode": "plugin.manifest.invalid" }, + {"name": "serverProcess is not a capability", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/requires/serverProcess", "value": 0}], "expectedCode": "plugin.api.unsupported", "expectedField": "requires.serverProcess"}, + {"name": "executableResolution is not a capability", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/requires/executableResolution", "value": 0}], "expectedCode": "plugin.api.unsupported", "expectedField": "requires.executableResolution"}, { "name": "own provider without buildProviders capability", "base": "build-provider.plugin.json", @@ -748,13 +707,7 @@ { "name": "template build lists unused buildProviders capability", "base": "build-static-file.plugin.json", - "edits": [{ "op": "set", "path": "/requires/buildProviders", "value": 1 }], - "expectedCode": "plugin.manifest.invalid" - }, - { - "name": "own provider process without serverProcess", - "base": "build-provider.plugin.json", - "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], + "edits": [{ "op": "set", "path": "/requires/buildProviders", "value": 0 }], "expectedCode": "plugin.manifest.invalid" }, { @@ -763,18 +716,6 @@ "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/text" }], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "UI web content needs ui 2", - "base": "web-panel.plugin.json", - "edits": [{ "op": "set", "path": "/requires/ui", "value": 1 }], - "expectedCode": "plugin.manifest.invalid" - }, - { - "name": "UI web content needs plugin API 3", - "base": "web-panel.plugin.json", - "edits": [{ "op": "set", "path": "/pluginAPIVersion", "value": 2 }, { "op": "remove", "path": "/requires" }], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "UI web content needs its permission", "base": "web-panel.plugin.json", @@ -805,12 +746,6 @@ "edits": [{ "op": "set", "path": "/ui/toolWindows/0/web/allowNetwork", "value": true }], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "ui 2 without web content", - "base": "web-panel.plugin.json", - "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/web" }, { "op": "set", "path": "/ui/toolWindows/0/text", "value": "Plain" }, { "op": "set", "path": "/permissions", "value": ["ui.contribute"] }], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "build system icon leaves the package", "base": "build-command-output-file.plugin.json", @@ -865,12 +800,6 @@ "edits": [{ "op": "set", "path": "/requires/backend", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, - { - "name": "backend needs serverProcess", - "base": "backend.plugin.json", - "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "backend needs the backend.launch permission", "base": "backend.plugin.json", @@ -880,7 +809,7 @@ { "name": "backend serves only web pages", "base": "backend.plugin.json", - "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/web" }, { "op": "set", "path": "/ui/toolWindows/0/text", "value": "No page" }, { "op": "set", "path": "/requires/ui", "value": 1 }, { "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute"] }], + "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/web" }, { "op": "set", "path": "/ui/toolWindows/0/text", "value": "No page" }, { "op": "set", "path": "/requires/ui", "value": 0 }, { "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute"] }], "expectedCode": "plugin.manifest.invalid" }, { @@ -904,67 +833,45 @@ { "name": "credentials.store without a backend", "base": "backend.plugin.json", - "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 0} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], "expectedCode": "plugin.manifest.invalid" }, { - "name": "backend 2 still needs a way to start", + "name": "backend still needs a way to start", "base": "backend-workspace.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, - { - "name": "onWorkspaceOpen needs backend 2", - "base": "backend.plugin.json", - "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onView:dev.lithe.fixture.backend.window", "onWorkspaceOpen"] }], - "expectedCode": "plugin.activation.invalid" - }, - { - "name": "workspace.metadata needs backend 2", - "base": "backend.plugin.json", - "edits": [{ "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute", "ui.web", "workspace.metadata"] }], - "expectedCode": "plugin.manifest.invalid" - }, { "name": "workspace.metadata without a backend", "base": "backend.plugin.json", - "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["ui.contribute", "ui.web", "workspace.metadata"] }], + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 0} }, { "op": "set", "path": "/permissions", "value": ["ui.contribute", "ui.web", "workspace.metadata"] }], "expectedCode": "plugin.manifest.invalid" }, { - "name": "backend 2 does not start on every launch", + "name": "backend does not start on every launch", "base": "backend-workspace.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onStartupFinished", "onWorkspaceOpen"] }], "expectedCode": "plugin.activation.invalid" }, { - "name": "backend 2 is still one process for every workspace", + "name": "backend is one process for every workspace", "base": "backend-workspace.plugin.json", "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], "expectedCode": "plugin.manifest.invalid" }, - {"name": "a grammar needs languages 3", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/requires/languages", "value": 1}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "languages 2, the withdrawn Monarch grammar, is unsupported", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/requires/languages", "value": 2}], "expectedCode": "plugin.api.unsupported"}, - {"name": "languages 3 is used by a grammar or configuration", "base": "language-grammar.plugin.json", "edits": [{"op": "remove", "path": "/languages/0/grammar"}, {"op": "remove", "path": "/languages/0/configuration"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a grammar names a known engine", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/kind", "value": "monarch"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a grammar file stays in the package", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/path", "value": "../http.tmLanguage.json"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a runtime tool must be listed in requiresTools", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "set", "path": "/requiresTools", "value": ["other-tool"]}], "expectedCode": "plugin.runtime.undeclared"}, - {"name": "requiresTools needs serverProcess 4", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "set", "path": "/requires/serverProcess", "value": 1}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "serverProcess 4 lists its tools", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "remove", "path": "/requiresTools"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a runtime tool needs a requiresTools list", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "remove", "path": "/requiresTools"}], "expectedCode": "plugin.runtime.undeclared"}, {"name": "a required tool is a tool-definition ID", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "set", "path": "/requiresTools", "value": ["Interpreter"]}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "adapter features need debugAdapters 3", "base": "debug-adapter-features.plugin.json", "edits": [{"op": "set", "path": "/requires/debugAdapters", "value": 2}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a step filter argument is a DAP name", "base": "debug-adapter-features.plugin.json", "edits": [{"op": "set", "path": "/debugAdapters/0/features/stepFilters/argument", "value": "step filters"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a hot code replace request is a DAP name", "base": "debug-adapter-features.plugin.json", "edits": [{"op": "set", "path": "/debugAdapters/0/features/hotCodeReplace/request", "value": "1reload"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "an output link pattern must compile", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "set", "path": "/outputLinkMatchers/0/pattern", "value": "File ("}], "expectedCode": "plugin.manifest.invalid"}, {"name": "an output link without a resolver captures a path", "base": "tool-reference-launch.plugin.json", "edits": [{"op": "set", "path": "/outputLinkMatchers/0/pattern", "value": "File (?P.+)"}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "run.start needs backend 2", "base": "backend.plugin.json", "edits": [{"op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "run.start", "ui.contribute", "ui.web"]}], "expectedCode": "plugin.manifest.invalid"}, {"name": "debug.start needs a declared adapter", "base": "backend-workspace.plugin.json", "edits": [{"op": "set", "path": "/permissions", "value": ["backend.launch", "debug.start"]}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "session fields need languageServers 3", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/requires/languageServers", "value": 2}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "a backend launch needs serverProcess 3", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/requires/serverProcess", "value": 2}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "serverProcess 3 needs pluginAPIVersion 4", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/pluginAPIVersion", "value": 3}], "expectedCode": "plugin.api.unsupported"}, {"name": "a backend launch needs a backend", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "remove", "path": "/backend"}, {"op": "remove", "path": "/requires/backend"}, {"op": "set", "path": "/permissions", "value": ["languageServer.launch", "languageServer.request"]}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "a backend launch needs backend 2", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/requires/backend", "value": 1}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a backend launch takes its arguments from the answer", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/arguments", "value": ["--stdio"]}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a backend launch function is a function ID", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/launch/function", "value": "Resolve Launch"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a backend launch timeout stays in range", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/launch/timeoutMilliseconds", "value": 100}], "expectedCode": "plugin.manifest.invalid"}, @@ -978,7 +885,6 @@ {"name": "state retention keeps a workspace for a day at least", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/state/retention/maxAgeDays", "value": 0}], "expectedCode": "plugin.manifest.invalid"}, {"name": "state belongs to a known scope", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/state/scope", "value": "user"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "remote support is supported or unsupported", "base": "language-server-backend-launch.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/remote", "value": "partial"}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "a content provider needs backend 2", "base": "backend.plugin.json", "edits": [{"op": "set", "path": "/permissions/1", "value": "documents.contentProvider"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "an export states its calling context", "base": "plugin-functions-exporter.plugin.json", "edits": [{"op": "remove", "path": "/pluginFunctions/exports/0/context"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "an export names a context Lithe knows", "base": "plugin-functions-exporter.plugin.json", "edits": [{"op": "set", "path": "/pluginFunctions/exports/0/context", "value": "remote"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "an export is an object, not a bare function ID", "base": "plugin-functions-exporter.plugin.json", "edits": [{"op": "set", "path": "/pluginFunctions/exports/0", "value": "document.format"}], "expectedCode": "plugin.manifest.invalid"}, @@ -987,13 +893,10 @@ {"name": "an exported function ID is lowerCamelCase segments", "base": "plugin-functions-exporter.plugin.json", "edits": [{"op": "set", "path": "/pluginFunctions/exports/0/id", "value": "Document.Format"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a call states the contract version it was written for", "base": "plugin-functions-caller.plugin.json", "edits": [{"op": "remove", "path": "/pluginFunctions/calls/0/version"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a call never names the provider plugin", "base": "plugin-functions-caller.plugin.json", "edits": [{"op": "set", "path": "/pluginFunctions/calls/0/pluginID", "value": "dev.lithe.fixture.functionexporter"}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "exported and called functions need backend 2", "base": "plugin-functions-exporter.plugin.json", "edits": [{"op": "set", "path": "/requires/backend", "value": 1}], "expectedCode": "plugin.manifest.invalid"}, {"name": "pluginFunctions lists an export or a call", "base": "plugin-functions-exporter.plugin.json", "edits": [{"op": "set", "path": "/pluginFunctions", "value": {}}], "expectedCode": "plugin.manifest.invalid"}, {"name": "pluginFunctions is listed in requires", "base": "plugin-functions-caller.plugin.json", "edits": [{"op": "remove", "path": "/requires/pluginFunctions"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "requires lists pluginFunctions only when the manifest uses it", "base": "plugin-functions-caller.plugin.json", "edits": [{"op": "remove", "path": "/pluginFunctions"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "editor providers read the documents they answer for", "base": "editor-providers.plugin.json", "edits": [{"op": "set", "path": "/permissions", "value": ["backend.launch", "editor.providers", "workspace.metadata"]}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "a tool field needs projectGenerators 2", "base": "project-generator.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/1", "value": {"id": "sdk", "type": "tool", "title": "SDK", "tool": "python"}}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "projectGenerators 2 has no runtime field", "base": "project-generator.plugin.json", "edits": [{"op": "set", "path": "/requires/projectGenerators", "value": 2}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a tool field names a tool definition ID", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/tool", "value": "Python Interpreter"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a tool field names a tool", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "remove", "path": "/projectGenerators/0/fields/0/tool"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a tool field minimum version is dotted numbers", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/minimumVersion", "value": "latest"}], "expectedCode": "plugin.manifest.invalid"}, diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json index c4f5960b..728235d7 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json @@ -1,17 +1,17 @@ { "schemaVersion": 1, - "description": "Expected plugins.marketplace.validateIndex outcomes for index.json. `plugins` lists every accepted ID in output order with its availability; `rejected` lists refused entries in index order. `hostCapabilities`, when present, is sent with the request.", + "description": "Expected plugins.marketplace.validateIndex outcomes for index.json. `plugins` lists every accepted ID in output order with its availability; `rejected` lists refused entries in index order. `hostCapabilities`, when present, is sent with the request; a request without it offers no capability, so every version on a listed platform is unavailable with plugin.index.apiUnsupported.", "cases": [ { - "name": "official source on Apple silicon", + "name": "official source on Apple silicon without a host capability table", "sourceKind": "official", "platform": "darwin-arm64", "expected": { "plugins": [ - { "id": "dev.lithe.fixture.language-packaged-server", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, + { "id": "dev.lithe.fixture.language-packaged-server", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.elixir-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.future-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "linux-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, - { "id": "io.example.zig-support", "latestVersion": "2.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "available", "version": "2.0.0", "downloadURL": "https://github.com/example/lithe-zig/releases/download/v2.0.0/zig-support-2.0.0-darwin-arm64.zip" } } + { "id": "io.example.zig-support", "latestVersion": "2.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } } ], "rejected": [ { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } @@ -38,9 +38,10 @@ "name": "Windows picks its own archive", "sourceKind": "thirdParty", "platform": "windows-x64", + "hostCapabilities": { "languages": [0], "languageServers": [0] }, "expected": { "plugins": [ - { "id": "io.example.elixir-tools", "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, + { "id": "io.example.elixir-tools", "availability": { "status": "available", "version": "1.0.0", "sha256": "8888888888888888888888888888888888888888888888888888888888888888" } }, { "id": "io.example.future-tools", "availability": { "status": "unsupported", "code": "plugin.index.platformUnsupported" } }, { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], @@ -54,10 +55,10 @@ "name": "host that runs language servers but not build systems", "sourceKind": "thirdParty", "platform": "darwin-arm64", - "hostCapabilities": { "languages": [1], "languageServers": [1], "serverProcess": [1] }, + "hostCapabilities": { "languages": [0], "languageServers": [0] }, "expected": { "plugins": [ - { "id": "io.example.elixir-tools", "latestVersion": "3.0.0", "availability": { "status": "available", "version": "1.0.0", "pluginAPIVersion": 3, "sha256": "8888888888888888888888888888888888888888888888888888888888888888" } }, + { "id": "io.example.elixir-tools", "latestVersion": "3.0.0", "availability": { "status": "available", "version": "1.0.0", "pluginAPIVersion": 0, "sha256": "8888888888888888888888888888888888888888888888888888888888888888" } }, { "id": "io.example.future-tools", "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], @@ -71,7 +72,7 @@ "name": "host with build systems but no build providers keeps the language-only version", "sourceKind": "thirdParty", "platform": "darwin-arm64", - "hostCapabilities": { "buildSystems": [1], "languages": [1], "languageServers": [1], "serverProcess": [1] }, + "hostCapabilities": { "buildSystems": [0], "languages": [0], "languageServers": [0] }, "expected": { "plugins": [ { "id": "io.example.elixir-tools", "availability": { "status": "available", "version": "1.0.0" } }, @@ -84,11 +85,28 @@ ] } }, + { + "name": "host without language servers is offered no language plugin", + "sourceKind": "thirdParty", + "platform": "darwin-arm64", + "hostCapabilities": { "languages": [0] }, + "expected": { + "plugins": [ + { "id": "io.example.elixir-tools", "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, + { "id": "io.example.future-tools", "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, + { "id": "io.example.zig-support", "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } } + ], + "rejected": [ + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, + { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } + ] + } + }, { "name": "a capability Core cannot validate is never offered", "sourceKind": "thirdParty", "platform": "windows-x64", - "hostCapabilities": { "buildProviders": [1], "buildSystems": [1], "debuggers": [1], "languages": [1], "languageServers": [1], "serverProcess": [1] }, + "hostCapabilities": { "buildProviders": [0], "buildSystems": [0], "debuggers": [0], "languages": [0], "languageServers": [0] }, "expected": { "plugins": [ { "id": "io.example.elixir-tools", "availability": { "status": "available", "version": "2.0.0", "sha256": "9999999999999999999999999999999999999999999999999999999999999999" } }, diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index.json b/shared/contracts/plugins/v1/fixtures/marketplace/index.json index 640b5ec3..36cdb42e 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "schemaVersion": 0, "plugins": [ { "id": "io.example.zig-support", @@ -15,7 +15,8 @@ "versions": [ { "version": "1.9.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, "releaseDate": "2026-08-01", "downloads": [ { @@ -27,7 +28,8 @@ }, { "version": "2.0.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, "releaseDate": "2026-09-20", "downloads": [ { @@ -56,6 +58,7 @@ { "version": "1.1.0", "pluginAPIVersion": 99, + "requires": { "languageServers": 0, "languages": 0 }, "releaseDate": "2026-10-10", "downloads": [ { @@ -67,7 +70,8 @@ }, { "version": "1.0.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, "releaseDate": "2026-10-01", "downloads": [ { @@ -90,6 +94,7 @@ { "version": "3.0.0", "pluginAPIVersion": 99, + "requires": { "languages": 0 }, "releaseDate": "2026-10-05", "downloads": [ { @@ -111,8 +116,8 @@ "versions": [ { "version": "3.0.0", - "pluginAPIVersion": 3, - "requires": { "debuggers": 1, "languages": 1 }, + "pluginAPIVersion": 0, + "requires": { "debuggers": 0, "languages": 0 }, "releaseDate": "2026-10-15", "downloads": [ { @@ -124,8 +129,8 @@ }, { "version": "2.0.0", - "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, + "pluginAPIVersion": 0, + "requires": { "buildProviders": 0, "buildSystems": 0, "languageServers": 0, "languages": 0 }, "releaseDate": "2026-10-12", "downloads": [ { @@ -137,8 +142,8 @@ }, { "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": { "languageServers": 1, "languages": 1, "serverProcess": 1 }, + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, "releaseDate": "2026-10-02", "downloads": [ { @@ -160,7 +165,8 @@ "versions": [ { "version": "9.0.0", - "pluginAPIVersion": 1, + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, "releaseDate": "2026-10-11", "downloads": [ { diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json index 06630675..e28edafd 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json @@ -274,7 +274,7 @@ "base": "build-static-file.plugin.json", "hostCapabilities": { "buildSystems": [ - 1 + 0 ] }, "expected": { @@ -286,19 +286,16 @@ "base": "build-provider.plugin.json", "hostCapabilities": { "buildSystems": [ - 1 + 0 ], "languages": [ - 1 + 0 ], "languageServers": [ - 1 - ], - "serverProcess": [ - 1 + 0 ], "settings": [ - 1 + 0 ] }, "expectedCode": "plugin.api.unsupported", @@ -309,22 +306,19 @@ "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ - 2 + 1 ], "buildSystems": [ - 1 + 0 ], "languages": [ - 1 + 0 ], "languageServers": [ - 1 - ], - "serverProcess": [ - 1 + 0 ], "settings": [ - 1 + 0 ] }, "expectedCode": "plugin.api.unsupported", @@ -342,19 +336,16 @@ "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildSystems": [ - 1 + 0 ], "languages": [ - 1 + 0 ], "languageServers": [ - 1 - ], - "serverProcess": [ - 1 + 0 ], "settings": [ - 1 + 0 ] }, "expectedCode": "plugin.api.unsupported", @@ -365,22 +356,19 @@ "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [ - 2 + 1 ], "buildSystems": [ - 1 + 0 ], "languages": [ - 1 + 0 ], "languageServers": [ - 1 - ], - "serverProcess": [ - 1 + 0 ], "settings": [ - 1 + 0 ] }, "expectedCode": "plugin.api.unsupported", @@ -398,16 +386,13 @@ "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ - 1 + 0 ], "buildSystems": [ - 1 - ], - "serverProcess": [ - 1 + 0 ], "settings": [ - 1 + 0 ] }, "expected": { @@ -417,13 +402,13 @@ { "name": "host without build systems refuses a build plugin", "base": "build-provider-language-server.plugin.json", - "hostCapabilities": { "languages": [1], "languageServers": [1], "serverProcess": [1] }, + "hostCapabilities": { "languages": [0], "languageServers": [0] }, "expectedCode": "plugin.api.unsupported" }, { "name": "host with every required capability reviews a build plugin", "base": "build-provider-language-server.plugin.json", - "hostCapabilities": { "buildProviders": [1], "buildSystems": [1], "languages": [1], "languageServers": [1], "serverProcess": [1] }, + "hostCapabilities": { "buildProviders": [0], "buildSystems": [0], "languages": [0], "languageServers": [0] }, "expected": { "id": "dev.lithe.fixture.build-provider-language-server", "permissions": ["languageServer.launch", "process.build"], diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/invalid-index-entries.json b/shared/contracts/plugins/v1/fixtures/marketplace/invalid-index-entries.json index 8d183cfd..07dd0f8f 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/invalid-index-entries.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/invalid-index-entries.json @@ -23,8 +23,10 @@ { "name": "tag with whitespace", "edits": [{ "op": "set", "path": "/tags", "value": ["two words"] }], "expectedCode": "plugin.index.invalid" }, { "name": "localized description with a malformed language tag", "edits": [{ "op": "set", "path": "/localizedDescriptions", "value": { "Chinese": "Zig 语言服务器集成。" } }], "expectedCode": "plugin.index.invalid" }, { "name": "empty localized description", "edits": [{ "op": "set", "path": "/localizedDescriptions", "value": { "zh-Hans": " " } }], "expectedCode": "plugin.index.invalid" }, - { "name": "requires below plugin API 3", "edits": [{ "op": "set", "path": "/versions/0/requires", "value": { "languages": 1 } }], "expectedCode": "plugin.index.invalid" }, - { "name": "plugin API 3 version without requires", "edits": [{ "op": "set", "path": "/versions/0/pluginAPIVersion", "value": 3 }], "expectedCode": "plugin.index.invalid" }, - { "name": "capability version is not a positive integer", "edits": [{ "op": "set", "path": "/versions/0/pluginAPIVersion", "value": 3 }, { "op": "set", "path": "/versions/0/requires", "value": { "languages": 0 } }], "expectedCode": "plugin.index.invalid" } + {"name": "version without requires", "edits": [{"op": "remove", "path": "/versions/0/requires"}], "expectedCode": "plugin.index.invalid"}, + {"name": "empty requires", "edits": [{"op": "set", "path": "/versions/0/requires", "value": {}}], "expectedCode": "plugin.index.invalid"}, + {"name": "capability version is not a non-negative integer", "edits": [{"op": "set", "path": "/versions/0/requires", "value": {"languages": -1}}], "expectedCode": "plugin.index.invalid"}, + {"name": "malformed capability ID in requires", "edits": [{"op": "set", "path": "/versions/0/requires", "value": {"Language Servers": 0}}], "expectedCode": "plugin.index.invalid"}, + {"name": "plugin API version is not a non-negative integer", "edits": [{"op": "set", "path": "/versions/0/pluginAPIVersion", "value": -1}], "expectedCode": "plugin.index.invalid"} ] } diff --git a/shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json b/shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json index 220924b2..50b5fb65 100644 --- a/shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json @@ -1,10 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.publisher-bundled", "displayName": "Publisher and bundled trust fixture", "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "packageTrust": 1}, + "pluginAPIVersion": 0, + "requires": {"languages": 0, "languageServers": 0, "packageTrust": 0}, + "requiresTools": ["java"], "vendor": { "id": "dev.lithe", "displayName": "Lithe", diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json b/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json index 4994323f..c3caba35 100644 --- a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.project-generator-tool-field", "displayName": "Project Generator Tool Field Fixture", "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": {"projectGenerators": 2}, + "pluginAPIVersion": 0, + "requires": {"projectGenerators": 0}, "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, "permissions": ["project.generate"], "activationEvents": [], diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json b/shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json index 2add8763..767a0750 100644 --- a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json @@ -1,10 +1,10 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.project-generator", "displayName": "Project Generator Fixture", "version": "1.0.0", - "pluginAPIVersion": 3, - "requires": {"projectGenerators": 1}, + "pluginAPIVersion": 0, + "requires": {"projectGenerators": 0}, "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, "permissions": ["project.generate"], "activationEvents": [], @@ -16,7 +16,7 @@ "fields": [ {"id": "buildSystem", "type": "enum", "title": "Build system", "presentation": "segmented", "options": [{"value": "none", "title": "None"}, {"value": "maven", "title": "Maven"}]}, - {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"}, + {"id": "jdk", "type": "tool", "title": "JDK", "tool": "java", "minimumVersion": "17"}, {"id": "sample", "type": "boolean", "title": "Add sample code", "default": true}, {"id": "groupId", "type": "text", "title": "Group", "advanced": true, "default": "com.example", "pattern": "[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*", @@ -42,7 +42,7 @@ "displayName": "Archetype", "section": "generator", "fields": [ - {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java"}, + {"id": "jdk", "type": "tool", "title": "JDK", "tool": "java"}, {"id": "artifactId", "type": "text", "title": "Artifact", "default": "${project.name|lower}", "pattern": "[a-z0-9-]+"}, {"id": "version", "type": "text", "title": "Archetype version", "optional": true, "pattern": "[0-9.]+"} diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json b/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json index 67324d9e..2468f108 100644 --- a/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json +++ b/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json @@ -58,11 +58,14 @@ "edited": [ "groupId" ], - "runtimes": { - "jdk": { - "home": "/opt/jdk-21", - "version": "21.0.4" - } + "tools": { + "jdk": [ + { + "home": "/opt/jdk-21", + "executable": "/opt/jdk-21/bin/java", + "version": "21.0.4" + } + ] } }, "expected": { @@ -80,7 +83,15 @@ { "id": "jdk", "visible": true, - "value": "/opt/jdk-21" + "value": "/opt/jdk-21", + "installations": [ + { + "home": "/opt/jdk-21", + "executable": "/opt/jdk-21/bin/java", + "version": "21.0.4", + "meetsMinimum": true + } + ] }, { "id": "sample", @@ -108,11 +119,14 @@ "generatorID": "archetype", "form": { "projectName": "My-App", - "runtimes": { - "jdk": { - "home": "/opt/jdk-21", - "version": "21.0.4" - } + "tools": { + "jdk": [ + { + "home": "/opt/jdk-21", + "executable": "/opt/jdk-21/bin/java", + "version": "21.0.4" + } + ] } }, "expected": { @@ -125,7 +139,15 @@ { "id": "jdk", "visible": true, - "value": "/opt/jdk-21" + "value": "/opt/jdk-21", + "installations": [ + { + "home": "/opt/jdk-21", + "executable": "/opt/jdk-21/bin/java", + "version": "21.0.4", + "meetsMinimum": true + } + ] }, { "id": "artifactId", @@ -152,11 +174,14 @@ "values": { "buildSystem": "maven" }, - "runtimes": { - "jdk": { - "home": "/opt/jdk-21", - "version": "21.0.4" - } + "tools": { + "jdk": [ + { + "home": "/opt/jdk-21", + "executable": "/opt/jdk-21/bin/java", + "version": "21.0.4" + } + ] } }, "expected": { @@ -193,37 +218,32 @@ "openFiles": [ "src/com/example/Main.java" ], - "runtimes": [ - { - "field": "jdk", - "runtime": "java", - "home": "/opt/jdk-21", - "version": "21.0.4" - } - ], "toolPins": [], "usesNetwork": false } } }, { - "name": "plan rejects an old runtime", + "name": "plan rejects an old tool", "operation": "generationPlan", "generatorID": "app", "form": { "projectName": "demo", - "runtimes": { - "jdk": { - "home": "/opt/jdk-11", - "version": "11.0.2" - } + "tools": { + "jdk": [ + { + "home": "/opt/jdk-11", + "executable": "/opt/jdk-11/bin/java", + "version": "11.0.2" + } + ] } }, "expected": { "status": "rejected", "error": { "code": "plugin.project.invalidInput", - "message": "The value of jdk is not accepted (runtimeTooOld).", + "message": "The value of jdk is not accepted (toolTooOld).", "field": "values.jdk" } } @@ -240,11 +260,14 @@ "edited": [ "version" ], - "runtimes": { - "jdk": { - "home": "/opt/jdk-21", - "version": "21.0.4" - } + "tools": { + "jdk": [ + { + "home": "/opt/jdk-21", + "executable": "/opt/jdk-21/bin/java", + "version": "21.0.4" + } + ] } }, "expected": { @@ -270,14 +293,6 @@ } ], "openFiles": [], - "runtimes": [ - { - "field": "jdk", - "runtime": "java", - "home": "/opt/jdk-21", - "version": "21.0.4" - } - ], "toolPins": [], "usesNetwork": true } @@ -309,7 +324,6 @@ } ], "openFiles": [], - "runtimes": [], "toolPins": [], "usesNetwork": true } @@ -500,7 +514,6 @@ } ], "openFiles": [], - "runtimes": [], "toolPins": [ { "field": "python", @@ -548,7 +561,6 @@ "projectName": "demo", "steps": [], "openFiles": [], - "runtimes": [], "toolPins": [], "usesNetwork": false } @@ -583,7 +595,6 @@ } ], "openFiles": [], - "runtimes": [], "toolPins": [ { "field": "node", @@ -678,7 +689,7 @@ }, "expected": { "status": "ok", - "plan": {"projectName": "demo", "steps": [], "openFiles": [], "runtimes": [], "toolPins": [], "usesNetwork": false} + "plan": {"projectName": "demo", "steps": [], "openFiles": [], "toolPins": [], "usesNetwork": false} } }, { diff --git a/shared/contracts/plugins/v1/fixtures/run/tool-reference-launch.plugin.json b/shared/contracts/plugins/v1/fixtures/run/tool-reference-launch.plugin.json index a713a79b..f70a89e8 100644 --- a/shared/contracts/plugins/v1/fixtures/run/tool-reference-launch.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/run/tool-reference-launch.plugin.json @@ -1,18 +1,16 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.tool-reference-launch", "displayName": "Tool Reference Launch Fixture", "version": "0.1.0", - "pluginAPIVersion": 4, + "pluginAPIVersion": 0, "requires": { - "backend": 2, - "debugAdapters": 1, - "executableResolution": 2, - "languageServers": 1, - "languages": 1, - "outputLinkMatchers": 1, - "serverProcess": 4, - "toolDefinitions": 1 + "backend": 0, + "debugAdapters": 0, + "languageServers": 0, + "languages": 0, + "outputLinkMatchers": 0, + "toolDefinitions": 0 }, "requiresTools": ["interpreter"], "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/settings/settings-v2.plugin.json b/shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json similarity index 92% rename from shared/contracts/plugins/v1/fixtures/settings/settings-v2.plugin.json rename to shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json index e1378c02..38ae08b2 100644 --- a/shared/contracts/plugins/v1/fixtures/settings/settings-v2.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json @@ -1,16 +1,15 @@ { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json b/shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json index 532821f8..1ef77902 100644 --- a/shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json @@ -1,12 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.java-support", "displayName": "Tool definition fixture", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "toolDefinitions": 1, - "executableResolution": 2 + "toolDefinitions": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json index ee9bb198..e32b7f1e 100644 --- a/shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json @@ -1,13 +1,12 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.mise", "displayName": "Toolchain provider fixture", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "toolchainProviders": 1, - "backend": 2, - "serverProcess": 1 + "toolchainProviders": 0, + "backend": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/ui/native-controls.plugin.json b/shared/contracts/plugins/v1/fixtures/ui/native-controls.plugin.json index 49b82e20..89f77815 100644 --- a/shared/contracts/plugins/v1/fixtures/ui/native-controls.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/ui/native-controls.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.native-controls", "displayName": "Native public controls fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1, - "ui": 3 + "buildProviders": 0, + "buildSystems": 0, + "ui": 0 }, + "requiresTools": ["node"], "vendor": { "id": "org.example", "displayName": "Example", @@ -94,7 +94,7 @@ "title": "Public controls", "placement": "bottom", "native": { - "schemaVersion": 1, + "schemaVersion": 0, "id": "form", "kind": "form", "nodes": [], diff --git a/shared/contracts/plugins/v1/fixtures/ui/native-form.plugin.json b/shared/contracts/plugins/v1/fixtures/ui/native-form.plugin.json index 71dd6403..88c77e83 100644 --- a/shared/contracts/plugins/v1/fixtures/ui/native-form.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/ui/native-form.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.native-form", "displayName": "Native form fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1, - "ui": 3 + "buildProviders": 0, + "buildSystems": 0, + "ui": 0 }, + "requiresTools": ["node"], "vendor": { "id": "org.example", "displayName": "Example", @@ -94,7 +94,7 @@ "title": "Native form", "placement": "bottom", "native": { - "schemaVersion": 1, + "schemaVersion": 0, "id": "form", "kind": "form", "nodes": [], diff --git a/shared/contracts/plugins/v1/fixtures/ui/native-panel.plugin.json b/shared/contracts/plugins/v1/fixtures/ui/native-panel.plugin.json index 41030102..295707d5 100644 --- a/shared/contracts/plugins/v1/fixtures/ui/native-panel.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/ui/native-panel.plugin.json @@ -1,15 +1,15 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.native-panel", "displayName": "Sample Build", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1, - "ui": 3 + "buildProviders": 0, + "buildSystems": 0, + "ui": 0 }, + "requiresTools": ["node"], "vendor": { "id": "org.example", "displayName": "Example", @@ -94,7 +94,7 @@ "title": "Sample Explorer", "placement": "right", "native": { - "schemaVersion": 1, + "schemaVersion": 0, "id": "explorer", "kind": "tree", "nodes": [], diff --git a/shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json b/shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json index 761f8bd6..a7b74b08 100644 --- a/shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json @@ -1,11 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.web-panel", "displayName": "Web Panel", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "ui": 2 + "ui": 0 }, "vendor": { "id": "org.example", diff --git a/shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json index b78094be..c8f3a667 100644 --- a/shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json @@ -1,10 +1,11 @@ { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.vcs-provider", "displayName": "VCS Provider Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "pluginAPIVersion": 0, + "requires": { "vcsProviders": 0 }, + "requiresTools": ["node"], "vendor": { "id": "org.example", "displayName": "Example", diff --git a/shared/contracts/plugins/v1/native-ui.schema.json b/shared/contracts/plugins/v1/native-ui.schema.json index 5e857149..50ba63da 100644 --- a/shared/contracts/plugins/v1/native-ui.schema.json +++ b/shared/contracts/plugins/v1/native-ui.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/native-ui.schema.json", - "title": "Host-rendered native plugin UI schema 1", + "title": "Host-rendered native plugin UI schema 0 (plugin contract v1)", "type": "object", "additionalProperties": false, "required": [ @@ -15,7 +15,7 @@ ], "properties": { "schemaVersion": { - "const": 1 + "const": 0 }, "id": { "type": "string", diff --git a/shared/contracts/plugins/v1/plugin-index.schema.json b/shared/contracts/plugins/v1/plugin-index.schema.json index 26ae06a5..177b7c8d 100644 --- a/shared/contracts/plugins/v1/plugin-index.schema.json +++ b/shared/contracts/plugins/v1/plugin-index.schema.json @@ -2,12 +2,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-index.schema.json", "title": "Lithe plugin marketplace index (plugin contract v1)", - "description": "Shape of a marketplace source index. Semantic rules that JSON Schema cannot express (download URLs inside the entry's own repository, duplicate IDs and versions, the official namespace, platform and API filtering) are defined in README.md and enforced by plugins.marketplace.validateIndex.", + "description": "Shape of a marketplace source index file, lithe-plugin-index.json. Semantic rules that JSON Schema cannot express (download URLs inside the entry's own repository, duplicate IDs and versions, the official namespace, platform and API filtering) are defined in README.md and enforced by plugins.marketplace.validateIndex.", "type": "object", "additionalProperties": false, "required": ["schemaVersion", "plugins"], "properties": { - "schemaVersion": { "const": 1 }, + "schemaVersion": { "const": 0 }, "plugins": { "type": "array", "items": { "$ref": "#/$defs/plugin" } } }, "$defs": { @@ -38,28 +38,20 @@ "version": { "type": "object", "additionalProperties": false, - "required": ["version", "pluginAPIVersion", "releaseDate", "downloads"], - "if": { "properties": { "pluginAPIVersion": { "const": 3 } } }, - "then": { "required": ["requires"] }, + "required": ["version", "pluginAPIVersion", "requires", "releaseDate", "downloads"], "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, - "pluginAPIVersion": { "type": "integer", "minimum": 1 }, + "pluginAPIVersion": { "description": "The package manifest's pluginAPIVersion. Hosts run version 0; any other value is reported as unavailable, not as an invalid index.", "type": "integer", "minimum": 0 }, "requires": { - "description": "API level 3: the package manifest's requires, including buildProviders: 1 when any buildSystems[].provider is present (own or shared language server), so hosts can tell before downloading whether they run every capability it needs. Capability IDs this contract does not define are allowed; such a version is never available.", + "description": "The package manifest's requires, including buildProviders when any buildSystems[].provider is present (own or shared language server), so hosts can tell before downloading whether they run every capability it needs. Capability IDs this contract does not define are allowed; such a version is never available.", "type": "object", "minProperties": 1, "propertyNames": { "pattern": "^[a-z][A-Za-z0-9]{0,63}$" }, - "additionalProperties": { "type": "integer", "minimum": 1 } + "additionalProperties": { "type": "integer", "minimum": 0 } }, "releaseDate": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, "downloads": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/download" } } - }, - "allOf": [ - { - "if": { "properties": { "pluginAPIVersion": { "maximum": 2 } } }, - "then": { "not": { "required": ["requires"] } } - } - ] + } }, "download": { "type": "object", diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 58bc910a..6d63ba77 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -5,63 +5,45 @@ "description": "Shape of plugin.json for a declarative plugin. Semantic rules that JSON Schema cannot express (cross references, permissions, activation events, template variables) are defined in README.md and enforced by plugins.declarative.validate.", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "id", "displayName", "version", "pluginAPIVersion", "vendor"], - "if": { "required": ["pluginAPIVersion"], "properties": { "pluginAPIVersion": { "minimum": 3 } } }, - "then": { "required": ["requires"] }, - "else": { "not": { "required": ["requires"] } }, + "required": ["schemaVersion", "id", "displayName", "version", "pluginAPIVersion", "requires", "vendor"], "allOf": [ - { - "if": { "required": ["pluginAPIVersion"], "properties": { "pluginAPIVersion": { "maximum": 3 } } }, - "then": { "properties": { "debugAdapters": { "maxItems": 0 } } } - }, - { - "if": { "required": ["pluginAPIVersion"], "properties": { "pluginAPIVersion": { "maximum": 1 } } }, - "then": { "properties": { "settings": { "maxItems": 0 } } } - }, - {"if": {"required": ["requiresTools"]}, "then": {"properties": {"pluginAPIVersion": {"minimum": 4}, "requires": {"required": ["serverProcess"], "properties": {"serverProcess": {"const": 4}}}}}}, - {"if": {"required": ["outputLinkMatchers"]}, "then": {"properties": {"pluginAPIVersion": {"minimum": 4}, "requires": {"required": ["outputLinkMatchers"]}}}}, - {"if": {"required": ["requires"], "properties": {"requires": {"required": ["debugAdapters"], "properties": {"debugAdapters": {"const": 3}}}}}, "else": {"properties": {"debugAdapters": {"items": {"properties": {"features": false, "defaults": false}}}}}}, - {"if": {"required": ["toolDefinitions"]}, "then": {"properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolDefinitions", "executableResolution"], "properties": {"executableResolution": {"const": 2}}}}}}, - {"if": {"required": ["toolchainProviders"]}, "then": {"required": ["backend"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolchainProviders", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}, "permissions": {"allOf": [{"contains": {"const": "toolchains.provide"}}, {"contains": {"const": "backend.launch"}}]}}}}, - {"if": {"required": ["pluginFunctions"]}, "then": {"required": ["backend"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["pluginFunctions", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}}}}, - {"if": {"required": ["keymapImportProviders"]}, "then": {"required": ["backend", "permissions"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["keymapImportProviders", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}, "permissions": {"allOf": [{"contains": {"const": "keymaps.provide"}}, {"contains": {"const": "keymaps.readSelectedSources"}}, {"contains": {"const": "backend.launch"}}]}}}}, - {"if": {"required": ["requires"], "properties": {"requires": {"required": ["settings"], "properties": {"settings": {"const": 2}}}}}, "else": {"properties": {"settings": {"items": {"$ref": "#/$defs/settingVersion1"}}, "settingActions": {"maxItems": 0}}}}, - {"if": {"required": ["requires"], "properties": {"requires": {"required": ["languageServers"], "properties": {"languageServers": {"minimum": 2}}}}}, "else": {"properties": {"languageServers": {"items": {"properties": {"workspaceConfiguration": false}}}}}}, - {"if": {"required": ["requires"], "properties": {"requires": {"required": ["languageServers"], "properties": {"languageServers": {"const": 3}}}}}, "else": {"properties": {"languageServers": {"items": {"properties": {"requestMethods": false, "commands": false, "serializedCommands": false, "forwardNotifications": false, "state": false, "remote": false}}}}}}, - {"if": {"required": ["languageServers"], "properties": {"languageServers": {"contains": {"required": ["launch"], "properties": {"launch": {"required": ["kind"], "properties": {"kind": {"const": "backend"}}}}}}}}, "then": {"required": ["backend"], "properties": {"pluginAPIVersion": {"minimum": 4}, "requires": {"required": ["backend", "serverProcess"], "properties": {"backend": {"const": 2}, "serverProcess": {"const": 3}}}}}} + {"if": {"required": ["outputLinkMatchers"]}, "then": {"properties": {"requires": {"required": ["outputLinkMatchers"]}}}}, + {"if": {"required": ["toolDefinitions"]}, "then": {"properties": {"requires": {"required": ["toolDefinitions"]}}}}, + {"if": {"required": ["toolchainProviders"]}, "then": {"required": ["backend"], "properties": {"requires": {"required": ["toolchainProviders", "backend"]}, "permissions": {"allOf": [{"contains": {"const": "toolchains.provide"}}, {"contains": {"const": "backend.launch"}}]}}}}, + {"if": {"required": ["pluginFunctions"]}, "then": {"required": ["backend"], "properties": {"requires": {"required": ["pluginFunctions", "backend"]}}}}, + {"if": {"required": ["keymapImportProviders"]}, "then": {"required": ["backend", "permissions"], "properties": {"requires": {"required": ["keymapImportProviders", "backend"]}, "permissions": {"allOf": [{"contains": {"const": "keymaps.provide"}}, {"contains": {"const": "keymaps.readSelectedSources"}}, {"contains": {"const": "backend.launch"}}]}}}}, + {"if": {"required": ["languageServers"], "properties": {"languageServers": {"contains": {"required": ["launch"], "properties": {"launch": {"required": ["kind"], "properties": {"kind": {"const": "backend"}}}}}}}}, "then": {"required": ["backend"], "properties": {"requires": {"required": ["backend"]}}}} ], "properties": { - "schemaVersion": { "const": 2 }, + "schemaVersion": { "const": 0 }, "id": { "type": "string", "pattern": "^[a-z0-9.-]+$" }, "displayName": { "type": "string", "minLength": 1 }, "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, - "pluginAPIVersion": { "type": "integer", "minimum": 1 }, + "pluginAPIVersion": { "description": "Plugin API version of the manifest format. Always 0 in this contract; a host refuses any other value with plugin.api.unsupported.", "const": 0 }, "requires": { - "description": "API level 3: every capability the manifest uses, each with the version of that capability's contract it was written against. Required at level 3 and refused below it. Listing a capability the manifest does not use, or leaving out one it uses, is refused.", + "description": "Required: every capability the manifest uses, each with the version of that capability's contract it was written against. Every capability defines only version 0 today. Listing a capability the manifest does not use, or leaving out one it uses, is refused.", "type": "object", "minProperties": 1, "additionalProperties": false, "properties": { - "backend": { "description": "Required exactly when backend is present: the plugin's own process. Version 1 serves only its web pages. Version 2 may also start on onWorkspaceOpen or a plugin command, receives workspace contexts through plugin-function protocol 3, and may be granted workspace.metadata.", "enum": [1, 2] }, - "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders 6. Version 1 producers cannot also consume inputs.", "enum": [1]}, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs; version 7 non-sensitive native configuration sources; version 8 inline provider test reports; version 9 opaque semantic test selectors; version 10 typed run options and service/task preparation; version 11 registry-selected adapter and host-owned connection target for service debugging.", "enum": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }, - "buildSystems": { "description": "Declarative build detection, models, and command plans; v2 adds the executable setting adapter, v3 instance-scoped options, v4 declarative preparation roles; does not imply buildProviders support.", "enum": [1, 2, 3, 4] }, - "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. Version 3 adds debugAdapters[].features and defaults, and is required exactly when one is used. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2, 3] }, - "executableResolution": { "enum": [1, 2], "description": "Shared executable candidate ordering and strict selection." }, - "executionProviders": { "enum": [1] }, - "languageServers": { "description": "Required exactly when languageServers is non-empty. Version 2 adds languageServers[].workspaceConfiguration. Version 3 adds the session fields requestMethods, commands, serializedCommands, forwardNotifications, state, and remote.", "enum": [1, 2, 3] }, - "languages": { "description": "Version 3 adds languages[].grammar, a TextMate grammar, and languages[].configuration, and is required exactly when one of them is used. Version 2, a Monaco Monarch grammar, is withdrawn.", "enum": [1, 3] }, - "outputLinkMatchers": { "description": "Required exactly when outputLinkMatchers is non-empty.", "const": 1 }, - "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, - "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps. Version 2 adds the tool field and retires the runtime field.", "enum": [1, 2] }, - "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Versions are cumulative: declare the lowest version that covers every launch the manifest uses. Version 2 adds runSelectedRuntime for a debug adapter (pluginAPIVersion 4). Version 3 adds the backend-resolved launch of a language server (pluginAPIVersion 4, backend 2). Version 4 names runtimes by tool-definition ID and is declared exactly when requiresTools is present.", "enum": [1, 2, 3, 4] }, - "settings": { "description": "Required exactly when settings or settingActions is non-empty. Version 1 is the API level 2 section: integer, enum, and boolean options. Version 2 adds scope, effect, page, section, the multiSelect, text, file, and directory types, and settingActions.", "enum": [1, 2] }, - "toolDefinitions": { "const": 1 }, - "toolchainProviders": { "const": 1 }, - "keymapImportProviders": { "const": 1 }, - "pluginFunctions": { "description": "Required exactly when pluginFunctions is present: functions this plugin's backend exports to other plugins, and exported functions it calls. Needs backend 2 (litheFunctions protocol 4).", "const": 1 }, - "ui": { "description": "Version 2 adds web content; version 3 adds native controls; version 4 adds backend-only commands.", "enum": [1, 2, 3, 4] }, - "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] } + "backend": { "description": "Required exactly when backend is present: the plugin's own process. It serves its web pages, may also start on onWorkspaceOpen or a plugin command, receives workspace contexts through the litheFunctions protocol, and may be granted workspace.metadata.", "enum": [0] }, + "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders. A producer cannot also consume inputs.", "enum": [0]}, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration (task execution, test execution and discovery, run preparation, native debug preparation, optional read-only model inputs, non-sensitive native configuration sources, inline test reports, opaque semantic test selectors, typed run options and service/task preparation, registry-selected adapters and host-owned connection targets for service debugging), independent of declarative buildSystems.", "enum": [0] }, + "buildSystems": { "description": "Declarative build detection, models, and command plans, including the executable setting adapter, instance-scoped options, and declarative preparation roles; does not imply buildProviders support.", "enum": [0] }, + "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Covers bounded target argument and environment bindings and debugAdapters[].features and defaults. The DAP session itself is negotiated by the adapter in initialize.", "enum": [0] }, + "executionProviders": { "enum": [0] }, + "languageServers": { "description": "Required exactly when languageServers is non-empty. Covers languageServers[].workspaceConfiguration and the session fields requestMethods, commands, serializedCommands, forwardNotifications, state, and remote.", "enum": [0] }, + "languages": { "description": "Required exactly when languages is non-empty. Covers languages[].grammar, a TextMate grammar, and languages[].configuration.", "enum": [0] }, + "outputLinkMatchers": { "description": "Required exactly when outputLinkMatchers is non-empty.", "enum": [0] }, + "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [0] }, + "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps, with tool fields that pick tool installations.", "enum": [0] }, + "settings": { "description": "Required exactly when settings or settingActions is non-empty. Covers integer, enum, boolean, multiSelect, text, file, and directory options, scope, effect, page, section, and settingActions.", "enum": [0] }, + "toolDefinitions": { "enum": [0] }, + "toolchainProviders": { "enum": [0] }, + "keymapImportProviders": { "enum": [0] }, + "pluginFunctions": { "description": "Required exactly when pluginFunctions is present: functions this plugin's backend exports to other plugins, and exported functions it calls. Needs backend.", "enum": [0] }, + "ui": { "description": "Required exactly when ui is present. Covers tool windows with text, web content, and native controls, and backend-only commands.", "enum": [0] }, + "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md).", "enum": [0] } } }, "vendor": { @@ -73,7 +55,7 @@ "displayName": { "type": "string", "minLength": 1 }, "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] }, "bundledSignatureRequirement": { - "description": "packageTrust 1: host-pinned official packages may trust the host's sealed bundled copy; all other origins require publisherPackage.", + "description": "requires.packageTrust: host-pinned official packages may trust the host's sealed bundled copy; all other origins require publisherPackage.", "const": "sameTeamAsHost" } } @@ -105,13 +87,13 @@ "type": "array", "items": { "type": "string", - "description": "onWorkspaceOpen names no ID and needs requires.backend 2; it starts the backend once a workspace is open.", + "description": "onWorkspaceOpen names no ID and needs requires.backend; it starts the backend once a workspace is open.", "pattern": "^((onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+|onWorkspaceOpen)$" } }, "toolDefinitions": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/toolchainToolDefinition" } }, "requiresTools": { - "description": "requires.serverProcess 4: every tool definition the plugin starts or names, by ID. A runtime tool of a launch, a runtime dependency, a runSelectedRuntime tool, or the tool of a lithe/host/run/start must be listed here. An ID is short or :; a short ID defined by more than one enabled plugin belongs to none of them.", + "description": "Every tool definition the plugin starts or names, by ID. A runtime tool of a launch, a runtime dependency, a runSelectedRuntime tool, or the tool of a lithe/host/run/start must be listed here. An ID is short or :; a short ID defined by more than one enabled plugin belongs to none of them.", "type": "array", "minItems": 1, "maxItems": 32, @@ -119,7 +101,7 @@ "items": { "$ref": "#/$defs/toolchainToolID" } }, "outputLinkMatchers": { - "description": "requires.outputLinkMatchers 1: patterns that turn Run and Test console text into links.", + "description": "requires.outputLinkMatchers: patterns that turn Run and Test console text into links.", "type": "array", "minItems": 1, "maxItems": 16, @@ -142,29 +124,29 @@ "items": { "$ref": "#/$defs/executionProvider" } }, "buildSystems": { - "description": "pluginAPIVersion 3: build systems Core detects, models, and plans commands for. Requires process.build and one onBuildSystem: activation event per build system.", + "description": "requires.buildSystems: build systems Core detects, models, and plans commands for. Requires process.build and one onBuildSystem: activation event per build system.", "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, "backend": { - "description": "pluginAPIVersion 3, requires.backend 1 or 2: the plugin's own LSP base-protocol process. The host keeps one process per plugin on the machine that runs Lithe, independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Version 1 starts on the first plugin-function call from one of the plugin's web pages and requires a web tool window. Version 2 also starts on onWorkspaceOpen or one of the plugin's commands and requires one of the three. Requires backend.launch; credentials.store lets it use the host's private credential store; workspace.metadata (version 2) lets it list workspace contexts.", + "description": "requires.backend: the plugin's own LSP base-protocol process. The host keeps one process per plugin on the machine that runs Lithe, independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. It starts on the first plugin-function call from one of the plugin's web pages, on onWorkspaceOpen, or on one of the plugin's commands, and the manifest declares at least one of those. Requires backend.launch; credentials.store lets it use the host's private credential store; workspace.metadata lets it list workspace contexts.", "$ref": "#/$defs/backend" }, "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.", + "description": "requires.vcsProviders: 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" } }, "debugAdapters": { - "description": "pluginAPIVersion 4, requires.debugAdapters 1: debug adapters the plugin implements, each started as its own DAP process, or hosted by the plugin's own language server (transport tcpLanguageServer). Requires debug.launch (and debug.attach when an adapter serves attach) and one onDebug: activation event per adapter.", + "description": "requires.debugAdapters: debug adapters the plugin implements, each started as its own DAP process, or hosted by the plugin's own language server (transport tcpLanguageServer). Requires debug.launch (and debug.attach when an adapter serves attach) and one onDebug: activation event per adapter.", "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/debugAdapter" } }, "projectGenerators": { - "description": "pluginAPIVersion 3 with requires.projectGenerators: entries of the New Project window. Requires the project.generate permission.", + "description": "requires.projectGenerators: entries of the New Project window. Requires the project.generate permission.", "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/projectGenerator" } @@ -172,7 +154,7 @@ "ui": { "type": "object", "additionalProperties": false, - "description": "ui 4 permits backend commands without toolWindowID or tool windows; older ui versions require a tool window.", + "description": "requires.ui: tool windows and commands. A command may be backend-only, without toolWindowID or tool windows.", "properties": { "toolWindows": { "type": "array", @@ -217,7 +199,7 @@ }, "native": { "$ref": "native-ui.schema.json" }, "web": { - "description": "ui 2: the plugin's own page, served from the package into a sandboxed host web view. Needs the ui.web permission.", + "description": "The plugin's own page, served from the package into a sandboxed host web view. Needs the ui.web permission.", "type": "object", "additionalProperties": false, "required": ["entry"], @@ -284,13 +266,13 @@ } }, "settings": { - "description": "pluginAPIVersion 2: user-adjustable options with unique IDs. Their shape follows requires.settings, which is 1 below level 3.", + "description": "requires.settings: user-adjustable options with unique IDs.", "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/setting" } }, "settingActions": { - "description": "settings 2: buttons on settings pages that invoke the plugin's own functions, with unique IDs.", + "description": "requires.settings: buttons on settings pages that invoke the plugin's own functions, with unique IDs.", "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/settingAction" } @@ -465,8 +447,8 @@ { "required": ["address"] } ] }, - "debugServerProcessV1": { - "$ref": "#/$defs/serverProcess", + "debugServerProcess": { + "$ref": "#/$defs/processLaunch", "description": "Reuse existing launch fields and validation. LSP initializationOptions have no meaning for a DAP transport and are excluded.", "propertyNames": { "enum": [ @@ -479,7 +461,7 @@ "unevaluatedProperties": false }, "debugRunSelectedRuntimeProcess": { - "description": "Proposed serverProcess 2 branch. Resolve through Run's selected runtime for this target, not a plugin dependency or a separate Debug runtime preference.", + "description": "Resolve through Run's selected runtime for this target, not a plugin dependency or a separate Debug runtime preference.", "type": "object", "additionalProperties": false, "required": [ @@ -498,23 +480,23 @@ "const": "runSelectedRuntime" }, "tool": { - "description": "serverProcess 2 accepts python, java and node; serverProcess 4 accepts the ID of a tool definition listed in requiresTools.", + "description": "The ID of a tool definition listed in requiresTools.", "$ref": "#/$defs/toolchainToolID" } } }, "arguments": { - "$ref": "#/$defs/serverProcess/properties/arguments" + "$ref": "#/$defs/processLaunch/properties/arguments" }, "validationArguments": { - "$ref": "#/$defs/serverProcess/properties/validationArguments" + "$ref": "#/$defs/processLaunch/properties/validationArguments" } } }, "debugProcess": { "oneOf": [ { - "$ref": "#/$defs/debugServerProcessV1" + "$ref": "#/$defs/debugServerProcess" }, { "$ref": "#/$defs/debugRunSelectedRuntimeProcess" @@ -879,11 +861,11 @@ "$ref": "#/$defs/debugLaunch" }, "features": { - "description": "debugAdapters 3: adapter-specific DAP extensions the adapter implements.", + "description": "Adapter-specific DAP extensions the adapter implements.", "$ref": "#/$defs/debugAdapterFeatures" }, "defaults": { - "description": "debugAdapters 3: values the adapter starts with when the user configured none.", + "description": "Values the adapter starts with when the user configured none.", "$ref": "#/$defs/debugAdapterDefaults" } } @@ -957,7 +939,7 @@ }, "scope": { "description": "The console the matcher applies to; both when absent.", "enum": ["run", "test"] }, "resolve": { - "description": "A function of the plugin's backend 2 process that turns the named groups into a location.", + "description": "A function of the plugin's backend process that turns the named groups into a location.", "type": "object", "additionalProperties": false, "required": ["function"], @@ -995,12 +977,12 @@ "required": ["id", "type", "title"], "properties": { "id": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" }, - "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime", "tool"], "description": "runtime needs requires.projectGenerators 1; tool needs requires.projectGenerators 2." }, + "type": { "enum": ["text", "enum", "boolean", "multiSelect", "tool"] }, "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "minLength": 1, "maxLength": 480 }, "advanced": { "type": "boolean" }, "visibleWhen": { "$ref": "#/$defs/generatorConditions" }, - "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime and tool." }, + "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for tool." }, "options": { "type": "array", "items": { @@ -1019,7 +1001,6 @@ "maxLength": { "type": "integer", "minimum": 1, "maximum": 1024 }, "placeholder": { "type": "string", "minLength": 1, "maxLength": 120 }, "optional": { "type": "boolean" }, - "runtime": { "enum": ["java"] }, "tool": { "description": "tool: a tool definition ID, a short ID or :; a short ID that several enabled plugins define belongs to none of them.", "type": "string", @@ -1027,7 +1008,7 @@ "pattern": "^([a-z0-9]+([.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$" }, "pinAs": { "description": "tool: project saves the selection as the created project's own tool choice.", "enum": ["project"] }, - "minimumVersion": { "type": "string", "description": "runtime and tool: one to three dotted numbers." }, + "minimumVersion": { "type": "string", "description": "tool: one to three dotted numbers." }, "maximumVersion": { "type": "string", "description": "text and enum: the highest version the value may name, a template over earlier fields such as ${field.jdk.major}." } } }, @@ -1231,7 +1212,7 @@ "required": ["id", "type", "title", "default"], "properties": { "id": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$", "maxLength": 120 }, - "type": { "description": "settings 2 adds multiSelect, text, file, and directory.", "enum": ["integer", "enum", "boolean", "multiSelect", "text", "file", "directory"] }, + "type": { "enum": ["integer", "enum", "boolean", "multiSelect", "text", "file", "directory"] }, "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "maxLength": 480 }, "default": { "type": ["integer", "string", "boolean", "array"] }, @@ -1253,13 +1234,13 @@ } } }, - "scope": { "description": "settings 2. global (default): one value for every project. project: the open project may override it.", "enum": ["global", "project"] }, - "effect": { "description": "settings 2. When a change takes effect: restart (default), immediate (pushed through workspaceConfiguration, which must use the setting), reload, or nextRun.", "enum": ["restart", "immediate", "reload", "nextRun"] }, + "scope": { "description": "global (default): one value for every project. project: the open project may override it.", "enum": ["global", "project"] }, + "effect": { "description": "When a change takes effect: restart (default), immediate (pushed through workspaceConfiguration, which must use the setting), reload, or nextRun.", "enum": ["restart", "immediate", "reload", "nextRun"] }, "page": { "$ref": "#/$defs/settingPage" }, - "section": { "description": "settings 2: heading the setting is grouped under on its page.", "type": "string", "minLength": 1, "maxLength": 120 }, - "pattern": { "description": "settings 2, text only: regular expression the whole value must match; at most 256 bytes.", "type": "string", "minLength": 1, "maxLength": 256 }, - "maxLength": { "description": "settings 2, text only: longest value in Unicode scalars; 256 when absent.", "type": "integer", "minimum": 1, "maximum": 1024 }, - "placeholder": { "description": "settings 2, text, file, and directory only: hint shown in an empty field.", "type": "string", "minLength": 1, "maxLength": 120 } + "section": { "description": "Heading the setting is grouped under on its page.", "type": "string", "minLength": 1, "maxLength": 120 }, + "pattern": { "description": "text only: regular expression the whole value must match; at most 256 bytes.", "type": "string", "minLength": 1, "maxLength": 256 }, + "maxLength": { "description": "text only: longest value in Unicode scalars; 256 when absent.", "type": "integer", "minimum": 1, "maximum": 1024 }, + "placeholder": { "description": "text, file, and directory only: hint shown in an empty field.", "type": "string", "minLength": 1, "maxLength": 120 } }, "allOf": [ { @@ -1312,19 +1293,6 @@ } ] }, - "settingVersion1": { - "description": "A setting of settings 1, which every manifest below API level 3 uses: no settings 2 type or field.", - "properties": { - "type": { "enum": ["integer", "enum", "boolean"] }, - "scope": false, - "effect": false, - "page": false, - "section": false, - "pattern": false, - "maxLength": false, - "placeholder": false - } - }, "settingToken": { "description": "Letters, digits, '.', '_', and '-': an option value, or the language or build system ID a settings page names.", "type": "string", @@ -1332,7 +1300,7 @@ "maxLength": 120 }, "settingPage": { - "description": "settings 2: the settings page that shows the entry besides the plugin's own page, named by exactly one language or build system ID. The language or build system may come from another plugin.", + "description": "The settings page that shows the entry besides the plugin's own page, named by exactly one language or build system ID. The language or build system may come from another plugin.", "type": "object", "additionalProperties": false, "minProperties": 1, @@ -1343,7 +1311,7 @@ } }, "settingAction": { - "description": "settings 2: a button the host draws on the plugin's settings page and on page. It invokes function, one of the plugin's own plugin functions, with empty params.", + "description": "A button the host draws on the plugin's settings page and on page. It invokes function, one of the plugin's own plugin functions, with empty params.", "type": "object", "additionalProperties": false, "required": ["id", "title", "function"], @@ -1381,17 +1349,17 @@ } }, "grammar": { - "description": "languages 3: syntax highlighting from a package file, applied by the editor without any process.", + "description": "Syntax highlighting from a package file, applied by the editor without any process.", "type": "object", "additionalProperties": false, "required": ["kind", "path"], "properties": { - "kind": { "description": "Highlighting engine of the file. Version 3 defines textmate: a TextMate grammar in the JSON form VS Code reads (.tmLanguage.json).", "enum": ["textmate"] }, + "kind": { "description": "Highlighting engine of the file. Only textmate is defined: a TextMate grammar in the JSON form VS Code reads (.tmLanguage.json).", "enum": ["textmate"] }, "path": { "description": "Package-relative grammar file inside the package.", "type": "string", "minLength": 1 } } }, "configuration": { - "description": "languages 3: comment and bracket behavior of the editor.", + "description": "Comment and bracket behavior of the editor.", "type": "object", "additionalProperties": false, "properties": { @@ -1419,7 +1387,7 @@ }, "languageServer": { "type": "object", - "$ref": "#/$defs/serverProcess", + "$ref": "#/$defs/processLaunch", "unevaluatedProperties": false, "required": ["languageID"], "allOf": [ @@ -1432,39 +1400,39 @@ "languageID": { "type": "string" }, "languageIdentifier": { "type": "string", "minLength": 1 }, "workspaceConfiguration": { - "description": "languageServers 2: what the server reads through workspace/configuration and receives again in workspace/didChangeConfiguration. String leaves may be ${setting:} templates, including settings whose effect is immediate, and no other variable.", + "description": "What the server reads through workspace/configuration and receives again in workspace/didChangeConfiguration. String leaves may be ${setting:} templates, including settings whose effect is immediate, and no other variable.", "type": "object" }, "requestMethods": { - "description": "languageServers 3: LSP methods the plugin backend may send with languageServer/request or languageServer/notify. The methods the host sends itself are refused. Requires the languageServer.request permission.", + "description": "LSP methods the plugin backend may send with languageServer/request or languageServer/notify. The methods the host sends itself are refused. Requires the languageServer.request permission.", "type": "array", "maxItems": 64, "uniqueItems": true, "items": { "$ref": "#/$defs/protocolName" } }, "commands": { - "description": "languageServers 3: workspace/executeCommand commands the backend may run. Declared exactly when requestMethods lists workspace/executeCommand.", + "description": "Workspace/executeCommand commands the backend may run. Declared exactly when requestMethods lists workspace/executeCommand.", "type": "array", "maxItems": 128, "uniqueItems": true, "items": { "$ref": "#/$defs/protocolName" } }, "serializedCommands": { - "description": "languageServers 3: commands that run one at a time; a later request for a running command joins it. Each is also listed in commands.", + "description": "Commands that run one at a time; a later request for a running command joins it. Each is also listed in commands.", "type": "array", "maxItems": 128, "uniqueItems": true, "items": { "$ref": "#/$defs/protocolName" } }, "forwardNotifications": { - "description": "languageServers 3: server notifications the host forwards to the backend. Requires the languageServer.request permission.", + "description": "Server notifications the host forwards to the backend. Requires the languageServer.request permission.", "type": "array", "maxItems": 32, "uniqueItems": true, "items": { "$ref": "#/$defs/protocolName" } }, "state": { - "description": "languageServers 3: how the host keeps the server's per-workspace state directories.", + "description": "How the host keeps the server's per-workspace state directories.", "type": "object", "additionalProperties": false, "required": ["scope", "retention"], @@ -1482,11 +1450,11 @@ } }, "remote": { - "description": "languageServers 3: whether the server works on a remote workspace. Supported when absent.", + "description": "Whether the server works on a remote workspace. Supported when absent.", "enum": ["supported", "unsupported"] }, "hostIntegration": { - "description": "pluginAPIVersion 2: a named host integration and the package resources it reads.", + "description": "A named host integration and the package resources it reads.", "type": "object", "additionalProperties": false, "required": ["id"], @@ -1511,8 +1479,8 @@ } } }, - "serverProcess": { - "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.", + "processLaunch": { + "description": "The launch fields a language server, a build provider, and a VCS provider share; all are LSP base-protocol processes and pass the same validation.", "type": "object", "required": ["launch"], "properties": { @@ -1541,13 +1509,13 @@ } }, { - "description": "pluginAPIVersion 2: a packaged entry file started by a host-discovered runtime.", + "description": "A packaged entry file started by a tool, either a runtime the plugin ships or a tool definition the host resolves.", "type": "object", "additionalProperties": false, "required": ["kind", "tool", "entry"], "properties": { "kind": { "const": "runtime" }, - "tool": { "description": "serverProcess 1 and 2 accept java and node; serverProcess 4 accepts the ID of a tool definition listed in requiresTools.", "$ref": "#/$defs/toolchainToolID" }, + "tool": { "description": "The ID of a tool definition listed in requiresTools.", "$ref": "#/$defs/toolchainToolID" }, "minimumVersion": { "$ref": "#/$defs/minimumVersion" }, "packagedRuntime": { "type": "string", "minLength": 1 }, "runtimeArguments": { "type": "array", "items": { "type": "string" } }, @@ -1555,7 +1523,7 @@ } }, { - "description": "serverProcess 3, language servers only: the plugin backend answers the command line each time the server starts. Core refuses it in any other process.", + "description": "Language servers only: the plugin backend answers the command line each time the server starts. Core refuses it in any other process.", "type": "object", "additionalProperties": false, "required": ["kind", "function"], @@ -1567,7 +1535,7 @@ } ] }, - "arguments": { "description": "API 2 launch templates include ${logDir}, supplied explicitly as absolute logDirectory by the host for the current session plugin files directory; never inferred from installation paths.", "type": "array", "items": { "type": "string" } }, + "arguments": { "description": "Launch templates include ${logDir}, supplied explicitly as absolute logDirectory by the host for the current session plugin files directory; never inferred from installation paths.", "type": "array", "items": { "type": "string" } }, "validationArguments": { "type": "array", "items": { "type": "string" } }, "runtimeDependencies": { "type": "array", @@ -1575,17 +1543,17 @@ "type": "object", "additionalProperties": false, "required": ["tool"], - "properties": { "tool": { "description": "serverProcess 1 and 2 accept go, java and node; serverProcess 4 accepts the ID of a tool definition listed in requiresTools.", "$ref": "#/$defs/toolchainToolID" } } + "properties": { "tool": { "description": "The ID of a tool definition listed in requiresTools.", "$ref": "#/$defs/toolchainToolID" } } } }, "initializationOptions": { - "description": "pluginAPIVersion 2: LSP initializationOptions; string values are launch templates.", + "description": "LSP initializationOptions; string values are launch templates.", "type": "object" } } }, "buildProvider": { - "description": "pluginAPIVersion 3: the LSP base-protocol process that answers lithe/build/model and lithe/build/dependencies. It either names a language server of the same plugin or declares its own server process.", + "description": "The LSP base-protocol process that answers lithe/build/model and lithe/build/dependencies. It either names a language server of the same plugin or declares its own server process.", "oneOf": [ { "type": "object", @@ -1603,7 +1571,7 @@ }, { "type": "object", - "$ref": "#/$defs/serverProcess", + "$ref": "#/$defs/processLaunch", "unevaluatedProperties": false, "required": ["executesProjectCode", "mayUseNetwork"], "properties": { @@ -1614,15 +1582,15 @@ ] }, "backend": { - "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because one backend process serves every workspace; version 2 receives each workspace's state directory in its context instead.", + "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because one backend process serves every workspace; each workspace's state directory arrives in its context instead.", "type": "object", - "$ref": "#/$defs/serverProcess", + "$ref": "#/$defs/processLaunch", "unevaluatedProperties": false }, "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.", + "description": "A VCS provider process (https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/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", + "$ref": "#/$defs/processLaunch", "unevaluatedProperties": false, "required": ["id", "displayName", "detection", "mayUseNetwork"], "properties": { @@ -1680,7 +1648,7 @@ } }, "minimumVersion": { - "description": "pluginAPIVersion 2: one to three dot-separated numbers, for example 21 or 18.17.", + "description": "One to three dot-separated numbers, for example 21 or 18.17.", "type": "string", "pattern": "^[0-9]{1,9}(\\.[0-9]{1,9}){0,2}$" }, @@ -2593,7 +2561,7 @@ "invocation" ], "properties": { - "configuration": {"type": "object", "additionalProperties": false, "required": ["source"], "properties": {"source": {"const": "provider"}}, "description": "Format-independent native configuration facts and provider-owned staged editing; requires buildProviders 7 and an owned provider."}, + "configuration": {"type": "object", "additionalProperties": false, "required": ["source"], "properties": {"source": {"const": "provider"}}, "description": "Format-independent native configuration facts and provider-owned staged editing; requires buildProviders and an owned provider."}, "modelSharing": {"type": "object", "additionalProperties": false, "properties": {"options": {"type": "array", "maxItems": 32, "uniqueItems": true, "items": {"type": "string"}}}, "description": "Opt into publishing normalized projects, targets and content roots, plus only these explicitly shared ordinary build option values. Never tasks, run plans, settings or optionValues."}, "modelInputs": {"type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["pluginID", "buildSystemID"], "properties": {"pluginID": {"type": "string"}, "buildSystemID": {"type": "string"}}}, "description": "Exact optional producer slots, sorted by Core. Missing or disabled producers are unavailable and never installed or activated automatically."}, "id": { @@ -2649,7 +2617,7 @@ "executableSetting": { "deprecated": true, "type": "string", - "description": "buildSystems 2: global file setting with effect reload or nextRun; empty uses discovery, nonempty must be used without fallback.", + "description": "Global file setting with effect reload or nextRun; empty uses discovery, nonempty must be used without fallback.", "pattern": "^[a-z][a-zA-Z0-9]*$", "maxLength": 120 }, @@ -2705,7 +2673,7 @@ "$ref": "#/$defs/buildVersionProbe" } }, - "description": "java, node, and go use host version probes. With executableResolution 2 they may select one toolchain candidate; otherwise they take only id and minimumVersion. Other runtimes declare their executables and version probe." + "description": "java, node, and go use host version probes. They may select one toolchain candidate with executables, or take only id and minimumVersion. Other runtimes declare their executables and version probe." } }, "version": { @@ -2757,7 +2725,7 @@ }, "default": {}, "scope": { - "description": "Instance scope requires buildSystems 3; omitted means workspace. Hosts persist each detected root independently.", + "description": "Instance scope; omitted means workspace. Hosts persist each detected root independently.", "enum": ["workspace", "instance"] }, "choices": { @@ -2905,7 +2873,7 @@ } }, { - "description": "pluginAPIVersion 3: the build provider answers lithe/build/model with a litheBuildModel document.", + "description": "The build provider answers lithe/build/model with a litheBuildModel document.", "type": "object", "additionalProperties": false, "required": [ @@ -2970,11 +2938,11 @@ } }, "source": { - "description": "Omitted expands argv in Core; provider sends lithe/build/execute. Provider tasks require buildProviders 2, empty arguments, and task execution.", + "description": "Omitted expands argv in Core; provider sends lithe/build/execute. Provider tasks require empty arguments, and task execution.", "enum": ["provider"] }, "preparation": { - "description": "buildSystems 4: automatic project preparation selected by workflow purpose and canonical model source role. Bindings must be unique within one build system; no prompted or fromModel tasks.", + "description": "Automatic project preparation selected by workflow purpose and canonical model source role. Bindings must be unique within one build system; no prompted or fromModel tasks.", "type": "object", "additionalProperties": false, "required": ["purpose", "sourceRoles"], @@ -3017,7 +2985,7 @@ ], "properties": { "debug": { - "description": "Same-package native launch adapter (v5), or a public registry configurationType for connection attach (v11 service runs).", + "description": "Same-package native launch adapter, or a public registry configurationType for connection attach (service runs).", "type": "object", "additionalProperties": false, "oneOf": [{"required": ["adapter"]}, {"required": ["configurationType"]}], "properties": { @@ -3026,7 +2994,7 @@ } }, "source": { - "description": "Provider-owned application preparation (buildProviders 4) or service/task preparation (buildProviders 10), fromTargets only, without host command templates.", + "description": "Provider-owned application or service/task preparation, fromTargets only, without host command templates.", "enum": ["provider"] }, "id": { @@ -3129,7 +3097,7 @@ "properties": { "selectorKinds": { "type": "array", "maxItems": 16, "uniqueItems": true, - "description": "Public opaque semantic selector namespaces; provider tests and buildProviders 9 required.", + "description": "Public opaque semantic selector namespaces; provider tests required.", "items": {"type": "string", "maxLength": 120, "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"} }, "discovery": { @@ -3137,7 +3105,7 @@ "description": "Opt in to lithe/build/discoverTests; requires provider test source and suite/case filters." }, "source": { - "description": "Omitted expands argv; provider sends lithe/build/test, requires buildProviders 3, empty arguments, and plugin-owned report decoding.", + "description": "Omitted expands argv; provider sends lithe/build/test, requires empty arguments, and plugin-owned report decoding.", "enum": ["provider"] }, "scopes": { @@ -3367,7 +3335,7 @@ } }, { - "description": "pluginAPIVersion 3: the build provider answers lithe/build/dependencies with a litheDependencies document.", + "description": "The build provider answers lithe/build/dependencies with a litheDependencies document.", "type": "object", "additionalProperties": false, "required": [ diff --git a/shared/contracts/plugins/v1/toolchains/README.md b/shared/contracts/plugins/v1/toolchains/README.md index 80654a29..dc258b6e 100644 --- a/shared/contracts/plugins/v1/toolchains/README.md +++ b/shared/contracts/plugins/v1/toolchains/README.md @@ -8,10 +8,10 @@ reuses each enabled provider's plugin backend, sends `toolchains/query` through `lsp.toolchainRequest`, passes every raw reply with native facts to `decodeReply`, runs the definition's version probe on executables Core returns from `candidates`, and shows the result. Any change to a watched input reloads the -whole snapshot. Nothing is saved by these pages. The pinned Stage D integration -adds a separate fresh-resolution adapter for macOS Build and Provider Run; -settings detections are never used as launch permits. Existing Java and -language-server consumers still use their previous adapters. Windows has no +whole snapshot. Nothing is saved by these pages. A separate fresh-resolution +adapter serves macOS Build and Provider Run; settings detections are never used +as launch permits. Java and language-server consumers resolve their tools with +their own adapters. Windows has no toolchain adapter and does not advertise `toolDefinitions` or `toolchainProviders`, so Core refuses packages that require them there. Consumer migration, the native query lifecycle and cache, pin saving and @@ -20,15 +20,14 @@ version-manager plugins are not specified yet. The manifest shape is ## Manifest admission and registry -`toolDefinitions[]` needs `requires.toolDefinitions: 1` and -`requires.executableResolution: 2`. `toolchainProviders[]` needs -`requires.toolchainProviders: 1`, `backend: 2`, `serverProcess: 1`, and permissions +`toolDefinitions[]` needs `requires.toolDefinitions: 0`. `toolchainProviders[]` +needs `requires.toolchainProviders: 0`, `backend: 0`, and permissions `backend.launch` / `toolchains.provide`. Capability lists are exact: missing or unused capabilities reject the manifest. A definition/provider alone is a valid -contribution. Old executable candidate sets retain their strict source types. -Build consumers may also require executableResolution 2 for their existing -`toolchain` candidate without contributing a definition; they do not gain -definition-owned candidate fields. +contribution. Executable candidate sets keep their strict source types; the +`toolchain` candidate of a build consumer needs no definition, and a consumer +does not gain definition-owned candidate fields (see +[Executable resolution](../README.md#executable-resolution)). Definitions own IDs, labels/icons, `executableLayouts`, ordered `executables`, `version`, `homeLayouts`, `discovery`, `homeVariable`, `commands`, `wrappers`, @@ -44,10 +43,10 @@ unique patterns; an absent one makes an installation-only provider, whose replies may not contain statements. Providers share the plugin's backend; admission permits a host-query trigger without a page or startup event, and the macOS host starts the backend on the first query. Bootstrap must be `packaged`, or -`runtime` with `packagedRuntime`, without discovered runtime dependencies. Existing -runtime launch tool restrictions remain for `serverProcess` 1 and 2, which name -a closed set of runtimes. From `serverProcess` 4 a plugin names any tool definition -by ID instead; see [`instantiateLaunch`](#launching-a-tool-definition). +`runtime` with `packagedRuntime`, without discovered runtime dependencies. A +launch names a tool definition by ID, and `requiresTools` lists it (see +[Tool references](../README.md#tool-references)); see +[`instantiateLaunch`](#launching-a-tool-definition). `toolchains.provide` may influence which program a defined tool launches. It does not grant arbitrary command replacement, installation, network or workspace-code @@ -139,7 +138,8 @@ are 128 plugins, 512 aggregate definitions and 256 aggregate providers. ## Provider backend negotiation A provider answers over its plugin backend's JSON-RPC connection, the same -`serverProcess` transport other backend requests use. The backend need not +transport other backend requests use ([Process start](../README.md#process-start)). +The backend need not declare a language, language server, Build provider or callable function. The host starts the backend with `lsp.startServer` and passes the manifest's @@ -148,17 +148,19 @@ at least one provider is offered the protocol, in the client capabilities of `initialize`: ```json -{"experimental": {"litheToolchains": {"versions": [1], "providers": ["first", "second"]}}} +{"experimental": {"litheToolchains": {"versions": [0], "providers": ["first", "second"]}}} ``` `providers` lists the declared IDs in sorted order. The backend selects in the server capabilities of its `initialize` result: ```json -{"experimental": {"litheToolchains": {"version": 1, "providers": ["first"]}}} +{"experimental": {"litheToolchains": {"version": 0, "providers": ["first"]}}} ``` -`version` must be an offered version and `providers` a non-empty list of +The protocol has no version of its own: its version is that of the +`toolchainProviders` capability, `0`. `version` must be an offered version and +`providers` a non-empty list of offered IDs without repeats; other fields are ignored. Any other answer, including an unoffered version, an undeclared, repeated or non-string ID, or an empty list, enables no provider at all rather than a partial set, and no query is sent. On @@ -174,7 +176,7 @@ the session's request timeout applies. Completion carries `result.value: {id, response}`: the wire ID and the complete, unvalidated response, error replies included. The host passes it with the same `params` to `decodeReply`. Cancellation and late results follow -[`lsp.toolchainRequest`](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md). +[`lsp.toolchainRequest`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md). ## Provider reply validation @@ -272,7 +274,7 @@ rejected, so a cancelled or refused query never becomes a missing provider. ## Pins and snapshot resolution `resolvePins` validates an inert local document `{version:1,toolchains,pins?}`. -`toolchains` retains legacy entries; `pins` maps directory keys to tool maps of +`toolchains` holds per-tool entries; `pins` maps directory keys to tool maps of `{home,version,provider}`. It returns `{pin,sourceDirectory}`, both `null` if absent. Nearest exact ancestor wins; sibling prefixes do not match. Dormant pins survive missing definitions. No lookup grants execution authority or writes a file. @@ -282,7 +284,7 @@ Saving, conflict protection and `.lithe/.gitignore` updates are not implemented. platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, `filesystemCaseSensitive`, `local`, `minimumVersion`, `probes`, `queries`, `systemInstallations`, `globalDefault`, `environment`, `commandHomes`, -`projectCodeAllowed`, `providerChoices` and `fixed`. `local` defaults to an empty v1 +`projectCodeAllowed`, `providerChoices` and `fixed`. `local` defaults to an empty document. Probes are host observations `{executable,output,exitCode}` keyed by the canonical executable a selection reports; system installations are `{tool,home}`. Shell environment home variables and `commandHomes` come from the native login-shell @@ -415,10 +417,10 @@ total time, and reports skipped work as diagnostics rather than a missing tool. Production-shape examples are [tool-definition.plugin.json](../fixtures/toolchains/tool-definition.plugin.json) and [toolchain-provider.plugin.json](../fixtures/toolchains/toolchain-provider.plugin.json). Core acceptance vectors are in -[toolchain_definitions.rs](https://github.com/1lck/Lithe-IDEA/blob/preview/rust/lithe-core/src/tests/toolchain_definitions.rs), +[toolchain_definitions.rs](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_definitions.rs), with sibling pins/query/resolution suites. The pin document, query exchange and one error reply per stable code that those suites share are inline in -[toolchain_vectors.rs](https://github.com/1lck/Lithe-IDEA/blob/preview/rust/lithe-core/src/tests/toolchain_vectors.rs). +[toolchain_vectors.rs](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_vectors.rs). Passing them is not native product verification. Contract changes must be mirrored after merge with -`node scripts/contract.mjs sync --revision ` in +`node scripts/contract.mjs sync --revision ` in [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs). diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2256ec52..9cd32180 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -60,8 +60,8 @@ See [source plan fixtures](../fixtures/keymap/source-plans-v1.json). For the provider foundation, `lsp.startServer` optionally accepts `keymapImportProviders`, an array of at most eight declared plugin-local IDs. Only a plugin-owned session with valid IDs offers -`experimental.litheKeymapImport: {versions: [1], providers}`. A backend may -select a non-empty, duplicate-free subset using `{version: 1, providers}`; +`experimental.litheKeymapImport: {versions: [0], providers}`. A backend may +select a non-empty, duplicate-free subset using `{version: 0, providers}`; Core emits `keymapImportProviders` with `result: {providers}` only for a valid selection. Invalid IDs, unknown selections and unsupported versions enable no providers. This negotiation alone does not implement the product workflow. See the @@ -1573,7 +1573,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `diagnostics.configureCoreEvents` | Set the DEBUG admission gate for Core log facts from host detailed-diagnostics state | | `diagnostics.redactText` | Redact credentials, tokens, and home-directory paths from diagnostic-bundle text | | `diagnostics.buildManifest` | Shape a deterministic diagnostic bundle manifest from host-gathered environment and file facts | -| `plugins.declarative.validate` | Validate and normalize a declarative plugin manifest (plugin contract v1) | +| `plugins.declarative.validate` | Validate and normalize a declarative plugin manifest | | `plugins.declarative.runPlan` | Expand a declarative plugin's run template for one workspace file | | `plugins.declarative.discoverTests` | List a declarative plugin's workspace and file test items | | `plugins.declarative.testPlan` | Expand a declarative plugin's test template for a workspace, file, or test case | @@ -1588,7 +1588,6 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans | | `plugins.declarative.editSettings` | Validate one atomic setting edit batch and return replacement text while preserving unrelated plugins and dormant values | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | -| `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | | `toolchains.registry` | Revalidate complete enabled plugin manifests, `{plugins:[{manifest}]}`, into a deterministic tool-definition/provider registry; the host supplies no origin evidence. A short tool ID defined by several enabled plugins belongs to none of them: none of those definitions is registered, the rest register as usual, and `conflicts: [{tool, pluginIDs}]` (sorted by tool, `pluginIDs` sorted) reports each contested tool. A repeated plugin ID rejects the batch. No native host capability claim | | `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | | `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | @@ -1604,7 +1603,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `toolchains.instantiateLaunch` | Expand an enabled tool definition's `launch` template for a selected executable, entry, runtime arguments and arguments; a part the template cannot carry is `plugin.toolchain.launchMismatch`; performs no I/O | | `toolchains.discoveryPlan` | Return platform-filtered definition-owned scan declarations, layouts, probes, PATH entries, login-shell home variables and budgets; never scans native directories | | `toolchains.candidates` | List one tool's login-shell homes and host-scanned installations with checked executables, versions or skip reasons, and the automatic-layer choice; never starts a probe | -| `plugins.declarative.decodeGrammar` | Validate independent static editor resources (`languages` 3) | +| `plugins.declarative.decodeGrammar` | Validate independent static editor resources | | `plugins.editorActions.decode` | Validate independent action registration, collection or click results | | `plugins.hostApi.describe` | Describe backend host methods and their required grants from Core's method table | | `plugins.hostApi.authorize` | Check a method, service and current session grants using Core's capability rules; the host still checks session and resource ownership | @@ -1620,7 +1619,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | | `build.resolveModelInputs` | Validate enabled producer facts and project a bounded public model snapshot for a declared consumer | | `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | -| `build.configurationPlan` | Plan a read of the provider's non-sensitive native configuration (v7) | +| `build.configurationPlan` | Plan a read of the provider's non-sensitive native configuration | | `build.decodeConfiguration` | Validate native source identities, raw/effective values and XML bindings | | `build.configurationEditPlan` | Plans format-independent provider editing into private host staging | | `build.decodeConfigurationEdit` | Validates the receipt without native document bytes | @@ -1631,7 +1630,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | | `build.prepareRunPlan` | Plan Provider-owned application/service/task preparation from a model target | | `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | -| `build.prepareDebugPlan` | Plan same-plugin native debug preparation (Provider v5) | +| `build.prepareDebugPlan` | Plan same-plugin native debug preparation | | `build.decodeDebugPreparation` | Validate an existing instance-contained native debug program and bounded process facts | | `build.dependencyPlan` | Expand the command that prints a project's dependency tree, or the request a build provider answers | | `build.decodeModel` | Decode model output or a provider result into projects, targets, tasks, content roots, option values, and run entries | @@ -3900,38 +3899,41 @@ and virtual-location representation as ordinary navigation. `lsp.buildRequest` accepts `{ sessionId, operationId?, method, params, timeoutMilliseconds? }` and returns `{ operationId }`. `method` and `params` are the `provider` field of a `build.modelPlan`, `build.dependencyPlan` or -provider-sourced `build.taskPlan`/`build.testPlan`, `build.testDiscoveryPlan`, or -`build.prepareRunPlan` result. Version 1 accepts -`lithe/build/model` and `lithe/build/dependencies`; version 2 adds -`lithe/build/execute`, version 3 adds `lithe/build/test` with the same bounded -execution lifecycle and `lithe/build/discoverTests` with the raw result lifecycle. -Version 4 adds `lithe/build/prepareRun`, with execution progress but a raw launch -plan passed to `build.decodeRunPreparation` before native process startup. Version 5 adds `lithe/build/prepareDebug`: raw native executable facts pass through `build.decodeDebugPreparation` and the existing DebugAdapter Registry, not private functions or a second DAP engine. -Version 10 adds typed `launchOptions` for this run method and host-owned service/task -preparation. These options require explicit version-10 negotiation before a -request is written. The decoder accepts a workspace cwd outside the instance +provider-sourced `build.taskPlan`/`build.testPlan`, `build.testDiscoveryPlan`, +`build.prepareRunPlan`, `build.prepareDebugPlan`, `build.configurationPlan` or +`build.configurationEditPlan` result. It accepts `lithe/build/model` and +`lithe/build/dependencies`; `lithe/build/execute` and `lithe/build/test` with the +same bounded execution lifecycle; `lithe/build/discoverTests` with the raw result +lifecycle; `lithe/build/configuration` and `lithe/build/editConfiguration`; and +`lithe/build/prepareRun`, with execution progress but a raw launch +plan passed to `build.decodeRunPreparation` before native process startup. +`lithe/build/prepareDebug` also reports progress: raw native executable facts +pass through `build.decodeDebugPreparation` and the existing DebugAdapter +Registry, not private functions or a second DAP engine. +Typed `launchOptions` belong to the run method alone, together with host-owned +service/task preparation; any other method carrying them is `invalid_request`. +The decoder accepts a workspace cwd outside the instance only when it matches the same host-validated explicit option. Native debug -preparation retains its existing application contract. -Other methods are `invalid_request`; tasks on version 1 -and tests on versions 1/2 or run preparation on versions 1/2/3 are `not_supported`. +preparation takes no launch options. +Other methods are `invalid_request`. Every session offers -`capabilities.experimental.litheBuild: { versions: [4, 3, 2, 1] }` in `initialize`; a -session whose process did not answer one of those versions rejects the request -with `not_supported`, so a shared provider that does not speak the protocol -still serves its language. `timeoutMilliseconds` replaces the session's request +`capabilities.experimental.litheBuild: { versions: [0] }` in `initialize`; a +session whose process did not answer a version the host offered rejects the +request with `not_supported`, so a shared provider that does not speak the +protocol still serves its language. `timeoutMilliseconds` replaces the session's request timeout for this request and is capped at one hour. The `requestCompleted` event carries the provider's reply unchanged as `result.value`, which the host passes to `build.decodeModel` or `build.decodeDependencies` as `result`; cancellation and late results follow `lsp.cancelOperation`. Execution replies are validated as `{exitCode: Int32, output: string}` and progress is mapped from a Core-owned wire request ID to `requestProgress` for the still-pending -operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution-buildproviders-2). +operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution). `lsp.toolchainRequest` accepts `{ sessionId, operationId?, params, timeoutMilliseconds? }` and returns `{ operationId }`. `lsp.startServer` takes `toolchainProviders`, the manifest's provider IDs; only a plugin-owned session -with some offers `capabilities.experimental.litheToolchains: { versions: [1], -providers }`. A backend that answers `{ version: 1, providers }` with a non-empty, +with some offers `capabilities.experimental.litheToolchains: { versions: [0], +providers }`. A backend that answers `{ version: 0, providers }` with a non-empty, duplicate-free subset of the offer emits a `toolchainProviders` runtime event with `result: { providers }`; any other answer enables none. A request whose `params.providerID` was not negotiated is `not_supported` with details @@ -3947,7 +3949,7 @@ tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. operation name, never a method; Core checks `arguments`, adds the absolute `root` and `tool` paths, and writes the operation's `lithe/vcs/*` request. Only plugin-owned sessions offer `capabilities.experimental.litheVcs: -{ versions: [1] }`; a session whose process did not answer with a VCS ID and +{ versions: [0] }`; a session whose process did not answer with a VCS ID and every required capability, or answered another VCS than `vcs`, refuses the request with `not_supported` and details `vcs.provider.unavailable`, and an operation whose capability was not negotiated is `not_supported` with details @@ -3972,8 +3974,8 @@ sessions refuse it with `invalid_request`. functionID }, params?, timeoutMilliseconds? }` and returns `{ operationId }`. It sends `lithe/function/invoke` to a session started with `lsp.startServer.pluginId` equal to `function.pluginID` that negotiated -`capabilities.experimental.litheFunctions` version 1 or 2 and registered -the function. `params` must be a JSON object and defaults to `{}`. Refusals are +`capabilities.experimental.litheFunctions` and registered the function. +`params` must be a JSON object and defaults to `{}`. Refusals are `invalid_request`, `not_supported`, or `permission_denied` with a `plugin.function.*` code in `details`, in the order the [plugin contract](plugins/v1/README.md#plugin-functions) defines. The @@ -3997,13 +3999,17 @@ with `result: { functions, removed: [] }` after successful `initialize` negotiation, including an empty negotiated registry. Subsequent register and unregister requests publish `functionsChanged` with `result: { functions, removed }`; the current registry -is `pluginFunctions` in the session's client state. Sessions without +is `pluginFunctions` in the session's client state. A plugin-owned session offers +`capabilities.experimental.litheFunctions: { versions: [0] }`; a reply negotiates +the protocol only when it names a version the host offered. Sessions without `lsp.startServer.pluginId` neither offer nor accept `litheFunctions`, retain no registry, and publish no function-discovery events. -A session that negotiated `litheFunctions` version 2 may send the -[host-service requests](plugins/v1/README.md#host-services-protocol-version-2) -its `hostServices` grant. Core checks method, grant, and params, then +A session that negotiated `litheFunctions` may send the +[host-service requests](plugins/v1/README.md#host-services) +its `hostServices` grant. A session that did not negotiate it gets no host +services: its requests are answered `plugin.host.unavailable` (-32601) and its +notifications are dropped. Core checks method, grant, and params, then publishes the runtime event `hostRequest` with `method` and `result: { hostRequestID, service, params }`; refusals go back to the backend without an event. `lsp.hostResponse` accepts `{ sessionId, hostRequestID, result? }` or @@ -4011,26 +4017,25 @@ event. `lsp.hostResponse` accepts `{ sessionId, hostRequestID, result? }` or `result` (default `null`) or `-32603` with `data.code: "plugin.host.failed"` to the backend. An unknown or already answered `hostRequestID` is `invalid_request`. At most 16 host requests are unanswered per session; more -are refused with `plugin.host.failed`. From version 3 the error may also be +are refused with `plugin.host.failed`. The error may also be `{ message, code?, details? }`: a host code reaches the backend with its own JSON-RPC number, and `details`, an object of at most 4 KiB, as `error.data.details` -([failure codes](plugins/v1/README.md#workspace-contexts-and-capability-discovery-protocol-version-3)). +([failure codes](plugins/v1/README.md#workspace-contexts-and-capability-discovery)). -The macOS public document/file/window adapters use method-level grants for new -permissions. Core intersects grants with its method table for capability discovery; -macOS first intersects them with actual registered handlers. Legacy `workspace` +The macOS public document/file/window adapters use method-level grants. Core +intersects grants with its method table for capability discovery; macOS first +intersects them with actual registered handlers. The `workspace` service grant never authorizes file methods. Host request/result JSON is bounded to 1 MiB; -oversized v3 results return `plugin.host.limitExceeded`. See the plugin contract's +oversized results return `plugin.host.limitExceeded`. See the plugin contract's public document, file and window method table for exact shapes and cancellation -semantics. The underlying `PluginFunctionRequest` context and legacy SDK wire -shapes remain compatible. +semantics. `lsp.hostNotify` accepts `{ sessionId, method, params }` for a ready plugin-owned session and returns `{ delivered }`. Core sends the -[host notification](plugins/v1/README.md#host-notifications-protocol-version-3) -when the session negotiated version 3 and is granted its service, and returns -`delivered: false` without sending for an older version. Another method, an +[host notification](plugins/v1/README.md#host-notifications) +when the session negotiated `litheFunctions` and is granted its service, and +returns `delivered: false` without sending when it did not. Another method, an ungranted service, malformed or oversized params, a session that is not plugin-owned, or one that is not ready is `invalid_request` with the `plugin.host.*` code in `details`. @@ -4072,11 +4077,12 @@ with `result: { serverID, method, params? }` for the host to relay as `remote: "unsupported"` fails with `not_supported` when the workspace is remote, before any process is started. -A session that negotiated `litheFunctions` version 4 and holds the `functions` +A session that negotiated `litheFunctions` and holds the `functions` grant may call another plugin's exported function through the -[plugin-to-plugin relay](plugins/v1/README.md#cross-plugin-calls-protocol-version-4). +[plugin-to-plugin relay](plugins/v1/README.md#cross-plugin-calls). Core runs the whole relay: it checks the declarations, keeps the call chain, -writes the forwarded request, and answers the caller. `lsp.configureFunctionRelay` +writes the forwarded request with the `caller` the callee receives, and answers +the caller. `lsp.configureFunctionRelay` accepts the host's complete view and returns `{}`; every call replaces the previous view, and a call in flight that the new view forbids ends with an explicit `plugin.function.*` error. @@ -4679,7 +4685,7 @@ configuration retains that execution kind when reopened. Process commands, toolc bindings and cached debug adapters are refused for `build.provider`. Generic args, environment and cwd overrides are user choices forwarded through typed launch options; explicit args replace older reference userArguments, including an empty array. -An implicit selector-root cwd remains omitted from launch options for v4 compatibility. Current model membership is revalidated by the +An implicit selector-root cwd remains omitted from launch options. Current model membership is revalidated by the native build workflow before every preparation. No generated document is needed: `runConfig.inspect` recognizes explicit saved team/local references and `runConfig.resolve` merges them against an empty generated layer. A workspace without generated data @@ -4727,7 +4733,7 @@ Other extension namespaces and the selector remain intact. Re-saving the same selector preserves its working-directory override and portable choices. The production macOS store, Windows editor and preference copies carry the typed snapshot unchanged. See `fixtures/run-configuration/build-provider-options.json`; no new Provider RPC or -SDK protocol version is needed, because preparation reuses accepted v10 launch options. Editor argument text uses quoting/escaping without expansion or execution; empty quoted tokens remain empty argv items. `fixtures/run-configuration/argument-text.json` is shared by Core and both editors. +SDK protocol change is needed, because preparation reuses the accepted launch options. Editor argument text uses quoting/escaping without expansion or execution; empty quoted tokens remain empty argv items. `fixtures/run-configuration/argument-text.json` is shared by Core and both editors. `runConfig.providerLaunchInputs` takes `{root,configurationId,providerRoot,localDocument?}` and requires a complete `build.provider` selector. Unmigrated tool-specific records are rejected; only an isolated host import may translate their retired format. It resolves the same document layers for one preparation. It returns bounded typed @@ -5381,9 +5387,12 @@ relevant facts with every call. and returns `{ "status": "ok", "plugin": object }` with the normalized manifest (names lowercased and sorted, argument order preserved), or `{ "status": "rejected", "error": { "code", "message", "field"? } }` with a -stable `plugin.*` code. When the host sends the capability versions it runs, -a level 3 manifest that requires another one is `plugin.api.unsupported`; -levels 1 and 2 are unaffected. See Capability versions in +stable `plugin.*` code. Every manifest declares `requires`, and a `requires` +entry the Core build does not know, or whose version it does not support, is +`plugin.api.unsupported`. When the host sends the capability versions it runs, +a manifest that requires one the host lacks is `plugin.api.unsupported` too. +Launches and executable resolution belong to the base contract, not to a +capability, and are never listed in `requires`. See Capability versions in [`plugins/v1`](plugins/v1/README.md). `plugins.declarative.runPlan`, `plugins.declarative.discoverTests`, and @@ -5415,7 +5424,7 @@ acceptable value falls back to the next layer and then the declared default. Suc `runtimeRequirements`, `requiredDirectories`, `requiredPackagePaths`, and `initializationOptions`, `hostIntegration`, `settings` (the effective values), and `workspaceConfiguration` (the server's expanded -`workspaceConfiguration`, `languageServers` 2) when the plugin declares them. A `buildSystemID` plan adds +`workspaceConfiguration`) when the plugin declares them. A `buildSystemID` plan adds `buildProvider: { buildSystemID, executesProjectCode, mayUseNetwork, languageServer? }`; with `languageServer` the plan is that language server's and the host reuses its session. An unknown @@ -5424,13 +5433,12 @@ architecture or an empty directory is refused with build system, and a build system without a provider with `plugin.launch.unavailable`. -A backend plan additionally returns `backend.hostServices` (and `version` -for backend capability version 2 and later). These are the declared grants; +A backend plan additionally returns `backend.hostServices`. These are the declared grants; the host must provide only services it actually implements and must verify the package and approved programs before starting the plan. A VCS or debug-adapter plan likewise includes its corresponding typed descriptor. -A language server declared with `launch.kind: "backend"` (`serverProcess` 3) has +A language server declared with `launch.kind: "backend"` has no command line in its manifest; the plugin's backend chooses it. The plan this command returns is then only the first step: `launch` is `{ kind: "backend", function, timeoutMilliseconds }` (30000 when absent) and `resolveParams` is the @@ -5439,8 +5447,7 @@ arch }, stateDirectory, workspaceStateDirectory, logDirectory, temporaryDirectory, settings }`; the host adds `context` and, when the plugin declares tool definitions, `tools`. The request must carry all four of `stateDirectory`, `workspaceStateDirectory`, `logDirectory`, and -`temporaryDirectory`, and the plan lists them in `requiredDirectories`. A server -below `languageServers` 3 has no `sessionPolicy`; a version 3 plan carries it +`temporaryDirectory`, and the plan lists them in `requiredDirectories`. Every language-server plan carries a `sessionPolicy` (`serverID`, `requestMethods`, `commands`, `serializedCommands`, `forwardNotifications`, `remote`, `gates`, `state`) for the engine to enforce. @@ -5475,7 +5482,7 @@ function returned for a document of its content scheme, and returns than 1 MiB (1,048,576 UTF-8 bytes). The host opens `content` as a read-only document and never interprets it. -A `debugAdapterID` selects a `debugAdapters[]` entry with a `serverProcess` +A `debugAdapterID` selects a `debugAdapters[]` entry with an adapter process or a `tcpLanguageServer` transport (naming it together with another selector is the same `invalid_request` failure); the plan adds `debugAdapter: { id, providerID, displayName, adapterID, requests, selectors, configurationTypes, @@ -5486,7 +5493,7 @@ plan of the plugin language server that `endpoint.languageID` names, so the host starts that server as for editing, then sends `workspace/executeCommand` with `endpoint.command` and connects to the loopback port the server answers with (an integer or a numeric string from 1 to 65535). When that server's -launch is resolved by the plugin backend (`serverProcess` 3), the plan names +launch is resolved by the plugin backend, the plan names only `launch: { kind: "backend", function, timeoutMilliseconds }` with empty `requiredDirectories` and no `resolveParams`: the server's own session asks the backend with the directories of its start, so resolving the adapter needs @@ -5655,12 +5662,6 @@ successfully started session merely because other plugins failed discovery. This is metadata selection, not manifest validation, approval or admission. Shared cases are in `shared/fixtures/plugins/installed-versions.json`. -`plugins.declarative.checkRuntimeVersion` accepts -`{ tool, minimumVersion, output }`, where `output` is what the runtime printed -for the plan's `launch.versionArguments`, and returns -`{ "status": "ok", "check": { "satisfied": bool, "version"?: string } }`. -`version` is absent when the output contains none. It takes no manifest. - `plugins.declarative.decodeGrammar` accepts `{ manifest, languageID, grammar }`, where `grammar` is the text the host read from the language's `grammar.path` inside the installed package, and returns `language: { id, fileExtensions, @@ -5687,13 +5688,15 @@ parameter, current-session, workspace-context, or resource-ownership checks. Hos deny access if the Core call fails. Shared cases are in `shared/fixtures/plugins/host-api-v1.json`. -`plugins.editorActions.decode` accepts `{kind:"registration",value}` or -`{kind:"actions",value,text}` and returns `{registration}` or `{actions}`. -Core validates literal selectors, one-based UTF-16 ranges and bounded opaque -function bindings. Version 1 (the default) admits the icons `run` and -`run-all` and a 128 KiB source. `version: 2` with the host's `icons` and -`sourceBytes` also admits `alternatives` and `{kind:"invokeResult",value}`, -which returns `{}` or `{reveal:{locations}}` for a clicked action. Hosts enforce +`plugins.editorActions.decode` accepts `{version,kind:"registration",value}` or +`{version,kind:"actions",value,text}` and returns `{registration}` or `{actions}`. +`version` is required; this Core accepts `0` and rejects any other request +version. Core validates literal selectors, one-based UTF-16 ranges and bounded opaque +function bindings. For `kind: "actions"` the request must carry the host's +`icons` and `sourceBytes`, which bound the icon names and the document size; +an action may carry `alternatives` that use the same icons. +`{version,kind:"invokeResult",value}` needs no `icons` or `sourceBytes` and returns `{}` or +`{reveal:{locations}}` for a clicked action. Hosts enforce permission, live document versions and session ownership; HTTP/SQL business semantics stay with plugins. See the plugin contract's independent editor actions section. @@ -5733,7 +5736,7 @@ working directory must be a relative path of an existing directory inside Core writes nothing, the host writes `temporaryFiles`. A start Core refuses is `{ status: "rejected", error }` with `plugin.run.invalidStart` and the failing `field`. See the plugin contract's -[run and debug sessions](plugins/v1/README.md#run-and-debug-sessions-protocol-version-3). +[run and debug sessions](plugins/v1/README.md#run-and-debug-sessions). `debug.decodeBackendConfiguration` accepts `{ manifest, params }` with the params of `lithe/host/debug/start` and returns `{ status: "ok", start }` with @@ -5749,15 +5752,15 @@ sources: [{ pluginID, matchers }], lines }` and returns `{ links }`; the matchers of each enabled plugin as `plugins.declarative.validate` returned them. A request Core cannot accept is `invalid_request`, not a policy outcome. See the plugin contract's -[output link matchers](plugins/v1/README.md#output-link-matchers-outputlinkmatchers-1). +[output link matchers](plugins/v1/README.md#output-link-matchers). The `plugins.marketplace.*` commands are equally pure: hosts fetch indexes and archives and pass the parsed JSON. `validateIndex` accepts `{ index, sourceKind: "official" | "thirdParty", platform, hostCapabilities? }` and returns `{ status: "ok", index: { plugins, rejected } }` with per-entry -`availability`; a level 3 version is available only when every capability it -requires is in `hostCapabilities`; `resolveSource` accepts `{ url }` and returns +`availability`; a listed version is available only when its `pluginAPIVersion` +is one Core supports and every capability it requires is in `hostCapabilities`; `resolveSource` accepts `{ url }` and returns `{ status: "ok", source: { indexURL, repository, displayName } }`; `installReview` accepts `{ manifest, expectedID?, expectedVersion?, approvedPrograms?, hostCapabilities? }` and returns `{ status: "ok", review: { id, version, @@ -5767,19 +5770,20 @@ permissions, programs, newPrograms } }`. Content problems use the same ### Build systems -The `build.*` commands implement API level 3 `buildSystems` of +The `build.*` commands implement the `buildSystems` capability of [`plugins/v1`](plugins/v1/README.md). Every build tool shares them, so a host has one build workflow and never branches on a build-system ID: Core plans each process, the host runs it with the discovered executable, and Core decodes what it printed or wrote. -A build system with a `provider` requires `buildProviders: 1`, version 2 when -a task uses `source: "provider"`, version 3 when tests do, or version 4 for -provider application run preparation, version 5 for native debug preparation, and -version 6 for optional read-only model inputs. Core validates and plans all six versions; -a host advertises each only after implementing its lifecycle and requests. -macOS advertises all six and routes provider tasks, tests and run preparation through -the existing workspace session. Prepared applications use the existing native Run host. Windows does not yet advertise Provider lifecycle support. +A build system with a `provider` requires `buildProviders: 0`. That capability +covers provider tasks, tests, run preparation, native debug preparation, +optional native configuration, and optional read-only model inputs. Core +validates and plans every one of them; a host advertises the capability only +after implementing its lifecycle and requests. macOS advertises it and routes +provider tasks, tests and run preparation through the existing workspace +session. Prepared applications use the existing native Run host. Windows does +not yet advertise Provider lifecycle support. Except `build.detect`, `build.projectOptions` and `build.resolveStoredOptions`, every command accepts `{ manifest, buildSystemID, request }`, revalidates the manifest, and answers @@ -5794,8 +5798,8 @@ mechanism before platform filtering. A `setting` candidate emits `{kind: "setting", settingID, path: absolutePath | null}` in the same `executables` list; null skips, invalid stored values reject the plan, and native validation failure is terminal. There is no separate `configuredExecutable` result. -See the plugin contract's **Shared executable candidates** section for ordering, -capability negotiation and the compatibility adapter for published v2 previews. +See the plugin contract's **Shared executable candidates** section for ordering +and strict selection. Host-managed runtime probes use `java -version` (including Java 8), `node --version`, and `go version`. Hosts execute the returned arguments and @@ -5817,20 +5821,20 @@ and the directories are absolute host paths used only as substitution text. | `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1 and systems; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | -| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/plugins/v1/README.md#optional-read-only-model-inputs-buildmodelinputs-1) | +| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/plugins/v1/README.md#optional-read-only-model-inputs) | | `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, scope?, value, source, choices, allowCustom }], dropped, fingerprint }` | -| `build.configurationPlan` | plan fields and optional host-selected `tool` | `plan.provider` for `lithe/build/configuration` (v7); no mutation, project or output-file parameters | +| `build.configurationPlan` | plan fields and optional host-selected `tool` | `plan.provider` for `lithe/build/configuration`; no mutation, project or output-file parameters | | `build.decodeConfiguration` | `{ result }` | `configuration`: bounded `litheBuildConfiguration` v1 native sources/fields; see the plugin contract | | `build.configurationEditPlan` | `{ root, options?, workspaceStateDirectory, outputFile, snapshot, edits: { sourceID, revision, values }, tool? }` | Provider request `lithe/build/editConfiguration`; existing private staging only, no document bytes | | `build.decodeConfigurationEdit` | `{ snapshot, edits, result }` | `edit: { sourceID, revision, changed }`; validates bounded receipt and exact draft/source identity; native adapter verifies staged hash and publishes atomically | | `build.modelPlan`, `build.dependencyPlan` | plan fields plus `project?` (dependencies), `outputFile?`, and for provider sources `refresh?` (boolean) and `tool?: { executable }` (absolute path) | `plan`: `{ buildSystemID, root, arguments, environment, workingDirectory, execution, optionFingerprint, output, decoder, executesProjectCode, mayUseNetwork }`; for a `provider` source `{ buildSystemID, root, provider: { method, params }, decoder, executesProjectCode, mayUseNetwork, optionFingerprint }`, where `method` is `lithe/build/model` or `lithe/build/dependencies` and `params` is `{ buildSystemID, root, options, optionArguments, environment, project?, refresh?, tool? }`; `refresh` appears only when `true` and `tool` only when the host sent it | | `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | command `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint`; a provider task instead returns `provider: {method: "lithe/build/execute", params}` with effective options, selected task/entities, user arguments and effect metadata | -| `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | command plan or `provider: {method: "lithe/build/test", params}` (buildProviders 3), plus `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or command-only `{ decoder: "hostIntegration" }`. Command filters that templates cannot place, such as a case without its suite when the tool only addresses `Suite#case`, are `plugin.build.invalidSelection`; Provider filters remain separate typed values and must be declared supported | -| `build.testDiscoveryPlan` | common plan fields plus `refresh?`, `tool?`; no project/output file | `plan.provider` for `lithe/build/discoverTests`, only with `test.discovery: true` (Provider v3), using the same effective options and effect metadata | +| `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | command plan or `provider: {method: "lithe/build/test", params}`, plus `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or command-only `{ decoder: "hostIntegration" }`. Command filters that templates cannot place, such as a case without its suite when the tool only addresses `Suite#case`, are `plugin.build.invalidSelection`; Provider filters remain separate typed values and must be declared supported | +| `build.testDiscoveryPlan` | common plan fields plus `refresh?`, `tool?`; no project/output file | `plan.provider` for `lithe/build/discoverTests`, only with `test.discovery: true`, using the same effective options and effect metadata | | `build.decodeTestDiscovery` | common plan fields plus canonical `result` | `discovery.tests` with validated `{id, suite, case, project?, target?}` selectors; bounded at 10,000 items/32 MiB, deterministic and exactly runnable; malformed replies reject in full | -| `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`, `launchOptions?`, `workspaceRoot?` (required for an explicit launch cwd); no output file | `plan.provider` for `lithe/build/prepareRun` (Provider v4), selected declaration/tags/paths/options validated; typed launch options and service/task execution require v10 | +| `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`, `launchOptions?`, `workspaceRoot?` (required for an explicit launch cwd); no output file | `plan.provider` for `lithe/build/prepareRun`, selected declaration/tags/paths/options validated; typed launch options must match the run's declared execution kind, and a service or task run requires them | | `build.decodeRunPreparation` | `workspaceRoot`, instance `root`, raw `result`, same optional `launchOptions` | `launch` with portable `{executable:{kind,value},arguments,workingDirectory,environment}`; bounded strings and workspace directory containment, `plugin.build.invalidOutput` on invalid result | -| `build.prepareDebugPlan` | same selected fields as `build.prepareRunPlan`; run declares same-plugin `debug.adapter` | `plan.provider` for `lithe/build/prepareDebug` (v5); no cross-package reference | +| `build.prepareDebugPlan` | same selected fields as `build.prepareRunPlan`; run declares same-plugin `debug.adapter` | `plan.provider` for `lithe/build/prepareDebug`; no cross-package reference, and launch options are refused: they apply only to running a target | | `build.decodeDebugPreparation` | `workspaceRoot`, instance `root`, selected `run`, raw `result` | `preparation` with declaration-owned `adapter`, normalized `root`, `program`, `arguments`, `workingDirectory`, `environment`; program is an existing regular file contained in the instance after canonicalization | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | | `build.decodeTestResults` | `{ workspaceRoot, root, project?, projects?, output, notBeforeMillis?, suite? }`; without `project` the reports of the root and every listed model project are read | `results`: `{ tests: [{ suite, case, status, message?, durationMillis?, path?, line?, invocations }], summary: { total, passed, failed, errors, skipped }, success }`; repeated invocations of one case merge into the most severe status | @@ -5839,8 +5843,8 @@ and the directories are absolute host paths used only as substitution text. | `build.syncFingerprint` | `{ workspaceRoot, root, projects, options? }` | `sync`: `{ fingerprint, watch }` | | `build.languageImport` | `{ workspaceRoot, root, options?, executablePath? }` | `languageImport`: `null` without a `languageImport` declaration; `{ kind: "maven", mavenContext: { version, reactorPath, profiles, settingsPath?, localRepositoryPath?, mavenExecutablePath? } }` for `lsp.startServer.mavenContext` and `lsp.updateMavenConfiguration`; or `{ kind: "gradle", javaGradle: { wrapperPresent, offline, userHome? } }` for `lsp.startServer.javaGradle`, where `wrapperPresent` reports `gradle/wrapper/gradle-wrapper.properties` under `root` | -`executableResolution: 2` adds `{ kind: "toolchain", toolchainID, root }` invocation -candidates. Hosts resolve them through the same canonical project/subproject +Invocation candidates also include `{ kind: "toolchain", toolchainID, root }`. +Hosts resolve them through the same canonical project/subproject selection used by Run, preserving terminal failure and project-code identity. The optional plan hint `tool.environment` is a bounded string map (64 entries, valid environment names, 32768 bytes per value, no NUL). It merges into Provider @@ -5860,7 +5864,7 @@ holds the golden plans and decoded outputs. ### VCS providers -The `vcs.*` commands implement API level 3 `vcsProviders` of +The `vcs.*` commands implement the `vcsProviders` capability of [`plugins/v1`](plugins/v1/README.md) and the [VCS provider contract](vcs-provider-v1.md). Results use the `plugins.declarative.*` envelope; a malformed payload, a relative @@ -5887,7 +5891,7 @@ answers. Results use the `plugins.declarative.*` envelope: `catalog`, `form`, `plan`, `name`, and `extracted`. Commands that take a manifest revalidate it and answer `plugin.project.unavailable` for an undeclared generator. -A `projectGenerators` 2 generator picks tool installations with `tool` fields. +A project generator picks tool installations with `tool` fields. The host lists the installations of each field's tool and sends them as the form's `tools`; Core chooses among them, checks `minimumVersion`, expands `${field..executable}`, and returns `toolPins` in the plan. Core lists no @@ -6587,7 +6591,7 @@ writes and honor the decoding-loss flag. See ### Plugin web pages -A host that shows a plugin's web tool window (`ui` 2) keeps the web view, the +A host that shows a plugin's web tool window keeps the web view, the package file reads, page state storage, file dialogs and the calls a page makes; Core decides what the page is served and which bridge requests it may make, and frames every answer, so all clients behave alike. Pages are @@ -6596,7 +6600,7 @@ registered in Core for their lifetime: - `plugins.web.open` takes `{ manifest, windowID, platform, locale, theme: { theme: "light"|"dark", variables: { "--lithe-*": value } }, buildSystems? }` and returns `{ page, pluginID, windowID, scheme: "lithe-plugin", url, - bridgeVersion: 2, fileLimit, stateLimit }`. `url` is + bridgeVersion: 0, fileLimit, stateLimit }`. `url` is `lithe-plugin:///`. A window that is not one of the manifest's web windows is `invalid_request`. At most 512 pages are open. - `plugins.web.close` takes `{ page }` and returns `{ cancelCalls }`, the @@ -6631,7 +6635,7 @@ registered in Core for their lifetime: ### Native plugin UI data `plugins.nativeUI.validateData` accepts `{ ui, data }`, where `ui` is the -validated native schema-1 declaration and `data` is a provider's `{ nodes }` +validated native declaration (`schemaVersion` 0) and `data` is a provider's `{ nodes }` reply. It returns `{ status: "ok", nodes }` or `{ status: "rejected", error: { code: "plugin.ui.invalidData", message } }`. Malformed envelopes remain Core `invalid_request` failures. The operation uses @@ -6641,8 +6645,8 @@ controls (including search/editor/segmented/stepper), typed scalar defaults, cho info, button action references/width policies and onCommit references; form source replies require empty nodes. Hosts inject this typed operation into their presentation feature; views never call the C ABI. Windows can reuse validation -when it later adds a native renderer; Core support does not advertise ui 3 on -that platform. +when it later adds a native renderer; Core support does not make Windows +advertise native plugin content. ### Agent skill management @@ -7220,8 +7224,8 @@ discovery deadline fires. `exit` applies only to an adapter that was reached. Core refusing the debug session is a host outcome and is not recorded. A declarative language server is resolved by the host and supervised by Core. -The host records failures before launch, including plugin API 1 servers that -launch as host-owned processes. Otherwise it passes +The host records failures before launch, including servers that launch as +host-owned processes. Otherwise it passes `startupOperation:{pluginId,operationId,elapsedMs}` on `lsp.startServer`. Core accepts the handoff only for the language server role, and only when the request's `pluginId` is absent or equals the handoff's `pluginId`; it ignores the @@ -7254,18 +7258,19 @@ Missing, unusable, or relative values fail with `plugin.launch.invalidRequest` when needed; `requiredDirectories` adds `log` when used. The host creates the provided writable directory before launch, outside all bundles and installation resources. Validation probes cannot reference `${logDir}`. See the synchronized -[plugin contract](plugins/v1/README.md). Existing API 1 literal arguments remain -literal. Wire examples are exercised by +[plugin contract](plugins/v1/README.md). Literal arguments that do not use +`${logDir}` remain literal. Wire examples are exercised by `shared/fixtures/diagnostics/engine-logging-v2.json` and Core tests. -Plugin-owned sessions offer `litheLogging` 1 independently of other protocols. +Plugin-owned sessions offer `litheLogging: { versions: [0] }` independently of +other protocols; a backend that answers `{ "version": 0 }` negotiates it. The negotiated `lithe/log` notification is validated in Core and enters the same bounded `diagnostics.drainCoreEvents` queue, with the installed plugin identity selecting `plugins//host`. It never copies notification JSON into legacy runtime detail. The four facts map to catalog INFO/ERROR/WARN/WARN, and a live wire request ID resolves to its original host `op`; no user content or backend-selected channel/level/message is accepted. See the -[plugin logging contract](plugins/v1/README.md#structured-plugin-diagnostics-logging-protocol-1) +[plugin logging contract](plugins/v1/README.md#structured-plugin-diagnostics) and `plugins/v1/fixtures/backend/plugin-logging.json`. Explicit upstream LSP message types retain their severity through separate catalog events; unclassified stderr is DEBUG and actual lifecycle failure is ERROR. @@ -7495,7 +7500,7 @@ This is an internal desktop command, not a new public plugin host method. ### Dynamic plugin settings surfaces -Internal desktop commands reuse the validated settings v2 manifest. They do not +Internal desktop commands reuse the validated settings manifest. They do not add plugin RPCs or admit capabilities. `plugins.settings.catalog` accepts up to 64 `{manifest, owner: {pluginID, version}, enabled}` records. It validates all identities, rejects duplicates and returns deterministic `pages` (up to 512), diff --git a/shared/contracts/vcs-provider-v1.md b/shared/contracts/vcs-provider-v1.md index 29fbca2f..87c81de2 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/shared/contracts/vcs-provider-v1.md @@ -1,8 +1,8 @@ -# VCS provider contract v1 +# VCS provider contract > **Status: Core implemented; macOS hosts providers.** Core accepts the > `vcsProviders` manifest capability and the commands below. macOS advertises -> `vcsProviders` 1 and starts providers for the roots it detects. Windows does +> `vcsProviders` 0 and starts providers for the roots it detects. Windows does > not advertise the capability. > The decision record is > [Unified VCS model and providers](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); @@ -35,12 +35,13 @@ not modeled here. fragment before they leave it. - Secrets never appear in arguments, results, console events, or logs. -## Manifest (plugin API level 3) +## Manifest ```json { - "pluginAPIVersion": 3, - "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "pluginAPIVersion": 0, + "requires": { "vcsProviders": 0 }, + "requiresTools": ["node"], "permissions": ["languageServer.launch", "process.vcs"], "activationEvents": ["onVcsRoot:svn"], "vcsProviders": [ @@ -65,14 +66,15 @@ not modeled here. | `detection.markers` | 1-8 entries directly inside a root; `type` is `directory` or `file`. | | `metadataPaths` | Up to 16 root-relative paths the host watches to refresh status. | | `tool` | Optional. Bare command names the host resolves like a `systemCommand` launch, and an optional probe. | -| `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | The shared server-process fields. | +| `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | The process-start fields every plugin process shares; see the base contract. | | `mayUseNetwork` | Shown in the install review. | A plugin declares at most 8 providers. A non-empty `vcsProviders` requires the `languageServer.launch` and `process.vcs` permissions, the activation event -`onVcsRoot:` for every provider, and `requires.vcsProviders` and -`requires.serverProcess`. Capabilities are not declared in the manifest: they -belong to the running process and are negotiated. +`onVcsRoot:` for every provider, and `requires.vcsProviders`. Starting the +process follows the base contract's process rules, which are not a capability +and are never listed in `requires`. VCS capabilities are not declared in the +manifest: they belong to the running process and are negotiated. The install review lists one program per provider, of kind `packagedVcsProvider`, `systemVcsProvider`, or `runtimeVcsProvider`, with @@ -220,7 +222,7 @@ The provider receives, for example: ### `vcs.decodeReply` ```json -{ "operation": "status", "capabilities": { "status": 1, "…": 1 }, "request": { }, "reply": { } } +{ "operation": "status", "capabilities": { "status": 0, "…": 0 }, "request": { }, "reply": { } } ``` `capabilities` is what the session reported in its `vcsProvider` event, @@ -230,16 +232,18 @@ completed request. The result is `{ status: "ok", result }` or ## Negotiation -The host offers `capabilities.experimental.litheVcs: { "versions": [1] }` in -`initialize`, to plugin-owned sessions only. The provider answers: +The host offers `capabilities.experimental.litheVcs: { "versions": [0] }` in +`initialize`, to plugin-owned sessions only. The protocol version is the +`vcsProviders` capability version. The provider answers: ```json -{ "litheVcs": { "version": 1, "vcs": "svn", "capabilities": { "status": 1, "baseContent": 1, "checkin": 1, "rollback": 1, "update": 1 } } } +{ "litheVcs": { "version": 0, "vcs": "svn", "capabilities": { "status": 0, "baseContent": 0, "checkin": 0, "rollback": 0, "update": 0 } } } ``` A session is a provider only when the answer names an offered version, a valid -VCS ID, and every required capability. Unknown capabilities, and known ones at -another version, are ignored. Core then publishes a runtime event of kind +VCS ID, and every required capability. Every capability is named at version 0; +an unknown capability, or a known one named at any other version, is ignored. +Core then publishes a runtime event of kind `vcsProvider` with `result: { vcs, capabilities }`; the host compares `vcs` with the manifest entry it launched and stops a session that answered another one. A session without a valid answer receives no `lithe/vcs/*` request. @@ -262,9 +266,9 @@ one. A session without a valid answer receives no `lithe/vcs/*` request. UI behavior is chosen from capabilities only, never from `vcs`. A provider without `push` commits directly to the server; the host labels the action -accordingly and does not offer "Commit and Push". Version 1 has no operation -for `push`: a provider that negotiates it tells the host the VCS has a -publishing step, which a later version will model. +accordingly and does not offer "Commit and Push". The contract has no operation +for `push`: a provider that negotiates it only tells the host the VCS has a +publishing step. ## Replies @@ -296,8 +300,9 @@ without telling the VCS), `renamed`, `copied`, `replaced`, `typeChanged`, Only `renamed` and `copied` may name an `oldPath`. `flags` refine a status: `external`, `locked`, `lockedByOther`, -`propertiesModified`, `submodule`, `treeConflict`. Core drops unknown flags so -a newer provider can add optional data within version 1, and sorts the rest. +`propertiesModified`, `submodule`, `treeConflict`. Core drops unknown flags, so +a provider can add optional data without breaking a host that does not know it, +and sorts the rest. A provider that negotiated `staging` gives every change `staging: "staged" | "unstaged" | "partial"`; any other provider gives none. @@ -323,7 +328,7 @@ missing required field, or a size limit exceeded makes the reply invalid. ## Provider messages -| Direction | Method | Version 1 behavior | +| Direction | Method | Behavior | | --- | --- | --- | | provider → host | `lithe/vcs/execution` | Notification of a command the provider ran: `{ command: [executable, ...arguments], exitCode: number \| null, durationMilliseconds, stderr }`, with `stderr` cut to 16 KiB and no stdout. Core passes it on as a session `log` event (`message` is the method, `detail` the params as JSON); routing it to the project console is host work (P3). Arguments are shown as sent, so a provider never puts a credential in them. | | provider → host | `lithe/vcs/authenticate` | Not accepted yet: Core answers `-32601`. A provider runs its client non-interactively and fails with `vcs.authentication.failed`. | diff --git a/shared/fixtures/keymap/provider-negotiation-v1.json b/shared/fixtures/keymap/provider-negotiation-v1.json index 57c974d7..1dc88cc1 100644 --- a/shared/fixtures/keymap/provider-negotiation-v1.json +++ b/shared/fixtures/keymap/provider-negotiation-v1.json @@ -1,14 +1,14 @@ { "version": 1, "cases": [ - {"name": "one selected provider", "offered": ["first", "second"], "answer": {"version": 1, "providers": ["second"]}, "expected": ["second"]}, - {"name": "selection is ordered deterministically", "offered": ["first", "second"], "answer": {"version": 1, "providers": ["second", "first"]}, "expected": ["first", "second"]}, - {"name": "future optional answer fields do not prevent negotiation", "offered": ["first"], "answer": {"version": 1, "providers": ["first"], "future": true}, "expected": ["first"]}, - {"name": "unsupported version", "offered": ["first"], "answer": {"version": 2, "providers": ["first"]}, "expected": null}, - {"name": "unknown provider", "offered": ["first"], "answer": {"version": 1, "providers": ["first", "other"]}, "expected": null}, - {"name": "duplicate provider", "offered": ["first"], "answer": {"version": 1, "providers": ["first", "first"]}, "expected": null}, - {"name": "empty selection", "offered": ["first"], "answer": {"version": 1, "providers": []}, "expected": null}, - {"name": "non-string provider", "offered": ["first"], "answer": {"version": 1, "providers": [3]}, "expected": null}, - {"name": "no providers offered", "offered": [], "answer": {"version": 1, "providers": ["first"]}, "expected": null} + {"name": "one selected provider", "offered": ["first", "second"], "answer": {"version": 0, "providers": ["second"]}, "expected": ["second"]}, + {"name": "selection is ordered deterministically", "offered": ["first", "second"], "answer": {"version": 0, "providers": ["second", "first"]}, "expected": ["first", "second"]}, + {"name": "future optional answer fields do not prevent negotiation", "offered": ["first"], "answer": {"version": 0, "providers": ["first"], "future": true}, "expected": ["first"]}, + {"name": "unsupported version", "offered": ["first"], "answer": {"version": 1, "providers": ["first"]}, "expected": null}, + {"name": "unknown provider", "offered": ["first"], "answer": {"version": 0, "providers": ["first", "other"]}, "expected": null}, + {"name": "duplicate provider", "offered": ["first"], "answer": {"version": 0, "providers": ["first", "first"]}, "expected": null}, + {"name": "empty selection", "offered": ["first"], "answer": {"version": 0, "providers": []}, "expected": null}, + {"name": "non-string provider", "offered": ["first"], "answer": {"version": 0, "providers": [3]}, "expected": null}, + {"name": "no providers offered", "offered": [], "answer": {"version": 0, "providers": ["first"]}, "expected": null} ] } diff --git a/shared/fixtures/plugins/language-support-v1.json b/shared/fixtures/plugins/language-support-v1.json deleted file mode 100644 index 891ff0fa..00000000 --- a/shared/fixtures/plugins/language-support-v1.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "schemaVersion": 1, - "hostVersion": "0.3.0", - "pluginAPIVersion": 1, - "plugins": [ - { - "id": "dev.lithe.fixture.go-support", - "displayName": "Go Support Fixture", - "version": "0.3.0", - "apiVersion": 1, - "hostCompatibility": { - "minimum": "0.3.0", - "maximumExclusive": "0.4.0" - }, - "vendor": { - "id": "dev.lithe", - "displayName": "Lithe", - "signatureRequirement": "sameTeamAsHost" - }, - "entrypoint": { - "kind": "nativeBundle", - "bundleIdentifier": "dev.lithe.fixture.go-support.bundle", - "principalClass": "FixtureGoSupportEntrypoint", - "bundlePath": "FixtureGoSupport.bundle" - }, - "moduleIDs": [ - "dev.lithe.fixture.go.execution", - "dev.lithe.fixture.go.language-server" - ], - "languageSupports": [ - { - "id": "go", - "displayName": "Go", - "fileExtensions": ["go"], - "fileNames": [], - "projectFileNames": ["go.mod", "go.work"], - "languageServerModuleID": "dev.lithe.fixture.go.language-server", - "executionModuleID": "dev.lithe.fixture.go.execution", - "testingModuleID": "dev.lithe.fixture.go.execution" - } - ] - } - ] -} diff --git a/shared/fixtures/plugins/official-v1.json b/shared/fixtures/plugins/official-v1.json deleted file mode 100644 index 3d0e31d9..00000000 --- a/shared/fixtures/plugins/official-v1.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "schemaVersion": 1, - "hostVersion": "0.3.0", - "pluginAPIVersion": 1, - "plugins": [] -} diff --git a/shared/fixtures/plugins/settings-actions.json b/shared/fixtures/plugins/settings-actions.json index e192c996..985a20ab 100644 --- a/shared/fixtures/plugins/settings-actions.json +++ b/shared/fixtures/plugins/settings-actions.json @@ -5,7 +5,7 @@ "name": "confirmation-before-invocation", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "actionID": "rebuildPlt", @@ -14,7 +14,7 @@ "enabled": true }, "plan": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "actionID": "rebuildPlt", "title": "Rebuild PLT", "kind": "confirmationRequired", @@ -25,7 +25,7 @@ "name": "confirmed-action-has-empty-params", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "actionID": "rebuildPlt", @@ -34,7 +34,7 @@ "enabled": true }, "plan": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "actionID": "rebuildPlt", "title": "Rebuild PLT", "kind": "invoke", @@ -47,7 +47,7 @@ "name": "project-required", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "actionID": "rebuildPlt", @@ -56,7 +56,7 @@ "enabled": true }, "plan": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "actionID": "rebuildPlt", "title": "Rebuild PLT", "kind": "unavailable", @@ -68,7 +68,7 @@ "name": "unknown-action", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "actionID": "missing", @@ -82,7 +82,7 @@ "name": "wrong-installed-owner", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "9.0.0" }, "actionID": "rebuildPlt", @@ -96,7 +96,7 @@ "name": "action-without-confirmation", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "actionID": "rebuildPlt", @@ -106,7 +106,7 @@ }, "confirmation": false, "plan": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "actionID": "rebuildPlt", "title": "Rebuild PLT", "kind": "invoke", @@ -119,7 +119,7 @@ "name": "disabled-plugin-is-not-invoked", "input": { "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "actionID": "rebuildPlt", @@ -129,7 +129,7 @@ }, "plan": { "kind": "unavailable", - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "actionID": "rebuildPlt", "title": "Rebuild PLT", "reason": "pluginDisabled", diff --git a/shared/fixtures/plugins/settings-documents.json b/shared/fixtures/plugins/settings-documents.json index c4ee308f..d9085ba7 100644 --- a/shared/fixtures/plugins/settings-documents.json +++ b/shared/fixtures/plugins/settings-documents.json @@ -1,16 +1,15 @@ { "schemaVersion": 1, "manifest": { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.example.settings", "displayName": "Settings Fixture", "version": "1.0.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "settings": 2, - "languages": 1, - "serverProcess": 1, - "languageServers": 1 + "settings": 0, + "languages": 0, + "languageServers": 0 }, "vendor": { "id": "dev.example", diff --git a/shared/fixtures/plugins/settings-surfaces.json b/shared/fixtures/plugins/settings-surfaces.json index e6107262..c7297914 100644 --- a/shared/fixtures/plugins/settings-surfaces.json +++ b/shared/fixtures/plugins/settings-surfaces.json @@ -7,18 +7,17 @@ "plugins": [ { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -197,7 +196,7 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "enabled": true @@ -214,18 +213,17 @@ "plugins": [ { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -404,7 +402,7 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "enabled": false @@ -422,18 +420,17 @@ "plugins": [ { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -612,25 +609,24 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "enabled": true }, { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -809,7 +805,7 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "enabled": true @@ -985,18 +981,17 @@ "plugins": [ { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -1175,7 +1170,7 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "9.0.0" }, "enabled": true @@ -1191,18 +1186,17 @@ "plugins": [ { "manifest": { - "schemaVersion": 2, + "schemaVersion": 0, "id": "dev.lithe.fixture.other-settings", - "displayName": "Settings v2 Fixture", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -1388,18 +1382,17 @@ }, { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "buildProviders": 1, - "buildSystems": 1, - "languageServers": 2, - "languages": 1, - "serverProcess": 1, - "settings": 2 + "buildProviders": 0, + "buildSystems": 0, + "languageServers": 0, + "languages": 0, + "settings": 0 }, "vendor": { "id": "org.example", @@ -1578,7 +1571,7 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "enabled": true @@ -1590,7 +1583,7 @@ "pageOwners": { "language:elixir": [ "dev.lithe.fixture.other-settings", - "dev.lithe.fixture.settings-v2" + "dev.lithe.fixture.settings" ] } }, @@ -1601,16 +1594,15 @@ "plugins": [ { "manifest": { - "schemaVersion": 2, - "id": "dev.lithe.fixture.settings-v2", - "displayName": "Settings v2 Fixture", + "schemaVersion": 0, + "id": "dev.lithe.fixture.settings", + "displayName": "Settings Fixture", "version": "0.1.0", - "pluginAPIVersion": 3, + "pluginAPIVersion": 0, "requires": { - "languages": 1, - "settings": 2, - "languageServers": 1, - "serverProcess": 1 + "languages": 0, + "settings": 0, + "languageServers": 0 }, "vendor": { "id": "org.example", @@ -1663,7 +1655,7 @@ ] }, "owner": { - "pluginID": "dev.lithe.fixture.settings-v2", + "pluginID": "dev.lithe.fixture.settings", "version": "0.1.0" }, "enabled": true diff --git a/shared/fixtures/vcs/model-v1.json b/shared/fixtures/vcs/model-v1.json index afe26850..b6d3600d 100644 --- a/shared/fixtures/vcs/model-v1.json +++ b/shared/fixtures/vcs/model-v1.json @@ -34,17 +34,17 @@ } ], "capabilities": { - "annotate": 1, - "baseContent": 1, - "checkin": 1, - "cleanup": 1, - "conflicts": 1, - "fileHistory": 1, - "fileOperations": 1, - "history": 1, - "rollback": 1, - "status": 1, - "update": 1 + "annotate": 0, + "baseContent": 0, + "checkin": 0, + "cleanup": 0, + "conflicts": 0, + "fileHistory": 0, + "fileOperations": 0, + "history": 0, + "rollback": 0, + "status": 0, + "update": 0 } } ], @@ -318,11 +318,11 @@ { "name": "provider with a staging dimension", "capabilities": { - "baseContent": 1, - "checkin": 1, - "rollback": 1, - "status": 1, - "staging": 1 + "baseContent": 0, + "checkin": 0, + "rollback": 0, + "status": 0, + "staging": 0 }, "snapshot": { "head": { @@ -372,17 +372,17 @@ { "name": "svn root without staging, with Subversion-only flags", "capabilities": { - "annotate": 1, - "baseContent": 1, - "checkin": 1, - "cleanup": 1, - "conflicts": 1, - "fileHistory": 1, - "fileOperations": 1, - "history": 1, - "rollback": 1, - "status": 1, - "update": 1 + "annotate": 0, + "baseContent": 0, + "checkin": 0, + "cleanup": 0, + "conflicts": 0, + "fileHistory": 0, + "fileOperations": 0, + "history": 0, + "rollback": 0, + "status": 0, + "update": 0 }, "snapshot": { "head": { diff --git a/shared/logging/README.md b/shared/logging/README.md index 0ed1d3b5..73c37985 100644 --- a/shared/logging/README.md +++ b/shared/logging/README.md @@ -88,7 +88,7 @@ Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落 - 启动者显式声明 role、真实 `pluginId` 和 VCS `logSubject`;宿主不解析 providerID。 - stderr 读取器限制每行 4 KiB、每秒 200 行,之后再走 `text` 的 1 KiB 脱敏规范化。与 v1 相比唯一的兼容变化是从任意 trim 的读取块改为逐行文本:保留行内空格,移除 LF 和末尾 CR,并应用已声明的长度上限和限流。 - 上游明确给出的严重度通过独立的目录事件保留:`window/logMessage` 和 `window/showMessage` 的 type 1/2/3/4 分别为 ERROR/WARN/INFO/DEBUG,未知类型为 DEBUG。未分类的 stderr 是 DEBUG 排障证据,流名称不表示警告;实际的进程或协议失败由 `*.session.failed` 记录 ERROR,正常关闭仍是生命周期状态。 -- 插件自身使用独立协商的 `litheLogging` 1 和 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额和 fixture 以 [插件契约](../contracts/plugins/v1/README.md#structured-plugin-diagnostics-logging-protocol-1) 为准。 +- 插件自身使用独立协商的 `litheLogging` 0 和 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额和 fixture 以 [插件契约](../contracts/plugins/v1/README.md#structured-plugin-diagnostics) 为准。 - 声明式贡献没有插件进程,不能发 `lithe/log`。实际执行它的一方通过 `diagnostics.recordPluginOperation` 代记每次尝试的一条终态,事件和通道与 SDK 插件相同:`plugins.operation.finished` 或 `plugins.operation.failed`,写入 `plugins/{plugin}/host`。平台层只上报观察到的阶段和耗时,级别和 `host.*` 失败类别只由 Core 按阶段决定;取消、停止和过期结果不记录。宿主当前代记下表四种贡献,唯一来源是 `rust/lithe-core/src/diagnostics/plugin_operations.rs` 的 `CONTRIBUTIONS`;输入、阶段到类别的对应和 -32064 见 [Rust Core API](../contracts/rust-core-api.md#engine-logging-v2-handoff-pr3)。 | operation | 声明来源 | 记录方 | 可能的失败阶段 | From ca610c24d2d313ba27ffb6c14231a66b3e8d2739 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 11 Nov 2026 19:38:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20shared/=20=E5=8F=AA=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=8F=92=E4=BB=B6=EF=BC=8CCore=20=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E7=9A=84=E5=A5=91=E7=BA=A6=E3=80=81fixture=20=E5=92=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E7=9B=AE=E5=BD=95=E7=A7=BB=E5=88=B0=20rust/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/fixtures → rust/fixtures,shared/logging → rust/logging, shared/contracts 下的非插件契约 → rust/contracts;四个 crate 的 include_str!、测试路径和文档引用一起改。 - 插件契约 shared/contracts/plugins/v1 → shared/plugins/contract/v1。 - macos/Tests/LitheTests/Fixtures/GitGraph 的 issue410 对照数据移到 rust/fixtures/git/graph-issue410,删除 macos/ 目录。 - 拆仓时没跟过来的 Lithe-IDEA 文件(根 README、i18n README、Agent Note、 native-ui-lab)改为指向 Lithe-IDEA preview 的绝对链接。 --- .../contracts/agent-turn-policy.json | 0 {shared => rust}/contracts/ai-commit.md | 4 +- .../contracts/ai-generation-requests.md | 4 +- .../contracts/application-boundary.md | 14 +- .../build-local-options-v1.schema.json | 0 .../build-project-options-v1.schema.json | 0 .../editor-syntax-theme-v1.schema.json | 0 .../contracts/git-commit-message-context.md | 0 .../contracts/git-patch-exchange.md | 0 .../contracts/git-rebase-session.md | 2 +- .../contracts/git-repository-setup.md | 0 {shared => rust}/contracts/github.md | 6 +- {shared => rust}/contracts/ide-api/v1.md | 6 +- .../maven-launch-context-v1.schema.json | 0 ...aven-portable-configuration-v1.schema.json | 0 .../contracts/mcp-registry-page.json | 0 .../contracts/project-manifest-v1.schema.json | 0 .../contracts/remote-workspace-protocol-v1.md | 0 .../run-configuration-v1.schema.json | 0 .../run-configuration-v2.schema.json | 0 {shared => rust}/contracts/rust-core-api.md | 370 +++++++++--------- .../contracts/terminal-profiles.md | 0 ...lchain-consumer-preferences-v1.schema.json | 0 .../contracts/toolchain-local-v1.schema.json | 0 .../toolchain-requirements-v1.schema.json | 0 {shared => rust}/contracts/update-v1.md | 0 .../contracts/update-v1.schema.json | 0 {shared => rust}/contracts/vcs-provider-v1.md | 8 +- .../workbench-background-v1.schema.json | 0 .../fixtures/agent/acp-events-v1.json | 0 .../fixtures/agent/agent-instructions-v1.json | 0 .../fixtures/agent/agent-management-v1.json | 0 .../agent/attachment-retention-v1.json | 0 .../agent/claude-desktop-cards-v1.json | 2 +- .../fixtures/agent/edit-review-v1.json | 0 .../fixtures/agent/history-activity-v1.json | 0 .../agent/hook-draft-validation-v1.json | 0 .../fixtures/agent/hooks-management-v1.json | 0 .../agent/model-call-statistics-v1.json | 0 .../agent/next-turn-configuration-v1.json | 0 .../agent/shared-instructions-v1.json | 0 .../fixtures/agent/skills-management-v1.json | 0 .../fixtures/ai/cli-configuration-v1.json | 0 .../fixtures/ai/commit-generation-v1.json | 0 .../fixtures/ai/inline-edit-v1.json | 0 .../ai/pull-request-description-v1.json | 0 .../build-project-options/local-edits.json | 0 .../shared-defaults.json | 0 .../build-project-options/validation.json | 0 .../build-provider/test-results-v1.json | 0 .../fixtures/build/native-tool-context.json | 0 .../fixtures/build/test-outcome-merge-v1.json | 0 .../fixtures/build/test-run-outcome-v1.json | 0 .../fixtures/community/discourse-auth-v1.json | 0 .../fixtures/core/cancellation.json | 0 .../fixtures/debug/adapter-endpoints-v1.json | 0 .../fixtures/debug/adapter-selection-v1.json | 0 .../fixtures/debug/breakpoint-binding-v1.json | 0 .../debug/breakpoint-relocation-v1.json | 0 .../fixtures/debug/dap-session-v1.json | 0 .../fixtures/debug/disassembly-paging-v1.json | 0 .../fixtures/debug/disconnect-policy-v1.json | 0 .../fixtures/debug/exception-info-v1.json | 0 .../fixtures/debug/hot-code-replace-v1.json | 0 .../fixtures/debug/launch-event-order.json | 0 .../fixtures/debug/node-source-map-v1.json | 0 .../debug/projection-v2-examples.json | 0 .../fixtures/debug/run-in-terminal-v1.json | 0 .../fixtures/debug/stale-reference-v1.json | 0 .../fixtures/debug/start-debugging-v1.json | 0 .../fixtures/debug/stepping-filters-v1.json | 0 .../debug/transport-lifecycle-v1.json | 0 .../fixtures/debug/v1-baseline.json | 34 +- .../fixtures/debug/variable-page-plan-v1.json | 0 .../fixtures/debug/variable-paging-v1.json | 0 .../diagnostics/build-manifest-v1.json | 0 .../diagnostics/engine-logging-v2.json | 0 .../fixtures/diagnostics/logging-ui-v1.json | 0 .../fixtures/diagnostics/logging-v1.json | 0 .../fixtures/diagnostics/logging-v2.json | 0 .../fixtures/diagnostics/redact-text-v1.json | 0 .../fixtures/diff/compare-texts-v1.json | 0 .../encoding-explicit-reopen-v1.json | 0 .../fixtures/documents/lifecycle-v1.json | 0 .../fixtures/documents/save-batch-v1.json | 0 .../fixtures/editor-themes/lithe-v1.json | 0 .../editor/basic-file-highlighting-v1.json | 0 .../fixtures/editor/diff-review-v1.json | 0 .../fixtures/editor/file-encoding-v1.json | 0 .../fixtures/editor/line-edit-v1.json | 0 .../fixtures/editor/markdown-html-v1.json | 0 .../fixtures/editor/markdown-render-v1.json | 0 .../editor/markdown-rich-content-v1.json | 0 .../editor/navigation-history-v1.json | 0 .../fixtures/editor/navigation-input-v1.json | 0 .../fixtures/editor/svg-preview-v1.json | 0 .../fixtures/editor/text-content-v1.json | 0 .../fixtures/editor/text-edit-plan-v1.json | 0 .../fixtures/editor/workbench-state-v1.json | 0 .../execution/framework-identity.json | 0 .../fixtures/execution/gradle.json | 0 .../execution/launch-environment-v1.json | 0 .../maven-java-main-source-sets-v1.json | 0 .../execution/maven-runtime-versions.json | 0 .../fixtures/execution/maven.json | 0 .../fixtures/execution/search-path-v1.json | 0 .../standalone-java-compile-run-v1.json | 0 .../git/authentication-failure-v1.json | 0 .../fixtures/git/branch-name-preview-v1.json | 0 .../fixtures/git/change-targets-v1.json | 0 .../fixtures/git/changelists-reducer-v1.json | 0 .../fixtures/git/checkout-metadata-v1.json | 0 .../git/command-error-response-v1.json | 0 .../fixtures/git/command-response-v1.json | 0 .../git/commit-lookup-response-v1.json | 0 .../git/commit-message-context-v1.json | 0 .../git/commit-selection-plan-v1.json | 0 .../fixtures/git/comparison-untracked-v1.json | 0 .../fixtures/git/console-command-v1.json | 0 .../fixtures/git/console-lifecycle-v1.json | 0 .../fixtures/git/console-presentation-v1.json | 0 .../fixtures/git/diff-blocks-v1.json | 0 {shared => rust}/fixtures/git/diff.json | 0 .../fixtures/git/execution-events-v1.json | 0 .../fixtures/git/execution-policy-v1.json | 0 .../fixtures/git/fetch-behavior-v1.json | 0 .../fixtures/git/fetch-plan-v1.json | 0 .../fixtures/git/fetch-skip-boolean-v1.json | 0 .../fixtures/git/graph-idea/NOTICE.txt | 0 .../elementGenerator/longEdges_in.txt | 0 .../elementGenerator/longEdges_out.txt | 0 .../elementGenerator/manyNodes_in.txt | 0 .../elementGenerator/manyNodes_out.txt | 0 .../elementGenerator/oneNode_in.txt | 0 .../elementGenerator/oneNode_out.txt | 0 .../elementGenerator/oneUpOneDown1_in.txt | 0 .../elementGenerator/oneUpOneDown1_out.txt | 0 .../elementGenerator/oneUpOneDown2_in.txt | 0 .../elementGenerator/oneUpOneDown2_out.txt | 0 .../graph-idea/layoutBuilder/manyNodes_in.txt | 0 .../layoutBuilder/manyNodes_out.txt | 0 .../layoutBuilder/notFullGraph_in.txt | 0 .../layoutBuilder/notFullGraph_out.txt | 0 .../layoutBuilder/oneNodeNotFullGraph_in.txt | 0 .../layoutBuilder/oneNodeNotFullGraph_out.txt | 0 .../graph-idea/layoutBuilder/oneNode_in.txt | 0 .../graph-idea/layoutBuilder/oneNode_out.txt | 0 .../graph-issue410}/issue410-context-idea.txt | 0 .../git/graph-issue410}/issue410-context.tsv | 0 .../graph-issue410}/issue410-date-context.tsv | 0 .../graph-issue410}/issue410-date-history.tsv | 0 .../graph-issue410}/issue410-date-idea.txt | 0 .../git/graph-issue410}/issue410-history.tsv | 0 .../git/graph-issue410}/issue410-idea.txt | 0 .../fixtures/git/graph-projection-v1.json | 0 {shared => rust}/fixtures/git/graph.json | 0 .../git/history-action-capabilities-v1.json | 0 .../fixtures/git/history-date-query-v1.json | 0 .../fixtures/git/history-locate-v1.json | 0 .../git/history-page-date-request-v1.json | 0 .../git/history-page-response-v1.json | 0 .../fixtures/git/history-response-v1.json | 0 .../fixtures/git/history-rewrite-v1.json | 0 .../fixtures/git/history-window-v1.json | 0 .../git/local-changelist-scope-v1.json | 0 .../git/metadata-classification-v1.json | 0 .../fixtures/git/parsed-diff-v1.json | 0 .../fixtures/git/patch-exchange-v1.json | 0 .../fixtures/git/push-preview-v1.json | 0 .../fixtures/git/rebase-plan-preview-v1.json | 0 .../fixtures/git/rebase-session-v1.json | 0 .../fixtures/git/reference-tree-v1.json | 0 .../fixtures/git/references-response-v1.json | 0 .../fixtures/git/remote-checkout-v1.json | 0 .../fixtures/git/remote-tag-catalog-v1.json | 0 .../fixtures/git/remote-url-v1.json | 0 .../fixtures/git/repository-colors-v1.json | 0 .../fixtures/git/repository-setup-v1.json | 0 .../fixtures/git/rollback-plan-v1.json | 0 .../fixtures/git/saved-stash-v1.json | 0 {shared => rust}/fixtures/git/shelf-v1.json | 0 {shared => rust}/fixtures/git/tag-names.json | 0 .../fixtures/git/tag-write-v1.json | 0 .../fixtures/git/update-plan-v1.json | 0 .../fixtures/git/windows-paths.json | 0 .../fixtures/git/workspace-commit-v1.json | 0 .../git/workspace-commit-workflow-v1.json | 0 .../fixtures/git/worktree-creation-v1.json | 0 .../fixtures/git/worktrees-v1.json | 0 {shared => rust}/fixtures/git/write.json | 0 .../device-authorization-transition-v1.json | 0 .../fixtures/github/pr-checkout-plan-v1.json | 0 .../fixtures/github/pull-request-v1.json | 0 {shared => rust}/fixtures/history/basic.json | 0 .../fixtures/history/event-plan-v1.json | 0 .../fixtures/history/relocation-v1.json | 0 {shared => rust}/fixtures/i18n/format-v1.json | 0 .../fixtures/i18n/native-abi-v1.json | 0 {shared => rust}/fixtures/ide-api/calls.json | 0 .../fixtures/ide-api/host-tool-exposure.json | 0 .../fixtures/ide-api/mcp-protocol.json | 0 {shared => rust}/fixtures/java/basic.json | 0 .../fixtures/java/run-markers-v1.json | 0 .../fixtures/keymap/binding-conflicts-v1.json | 0 .../fixtures/keymap/context-v1.json | 0 .../fixtures/keymap/import-plan-v1.json | 0 .../fixtures/keymap/import-provider-v1.json | 0 .../fixtures/keymap/modifier-gesture-v1.json | 0 .../keymap/provider-negotiation-v1.json | 0 .../fixtures/keymap/source-plans-v1.json | 0 .../fixtures/lsp/activation-policy-v1.json | 0 .../fixtures/lsp/completion-merge-v1.json | 0 .../fixtures/lsp/completion-refresh-v1.json | 0 .../lsp/diagnostic-code-actions-v1.json | 0 .../fixtures/lsp/document-identity-v1.json | 0 .../fixtures/lsp/document-save-v1.json | 0 .../fixtures/lsp/document-symbols-v1.json | 0 .../fixtures/lsp/document-uri-v1.json | 0 .../fixtures/lsp/java-build-report-v1.json | 0 .../fixtures/lsp/java-completion-v1.json | 0 .../lsp/java-launch-preparation-v1.json | 0 .../fixtures/lsp/java-navigation-v1.json | 0 .../lsp/java-project-runtimes-v1.json | 0 .../lsp/java-workspace-policy-v1.json | 6 +- .../fixtures/lsp/jdt-cache-retention-v1.json | 0 .../fixtures/lsp/jdt-direct-launch-v1.json | 0 .../fixtures/lsp/jdt-readiness-v1.json | 0 .../lsp/jdt-workspace-fingerprint-v1.json | 0 .../fixtures/lsp/jdt-workspace-key-v1.json | 0 .../fixtures/lsp/maven-profile-events-v1.json | 0 .../fixtures/lsp/navigation-target-v1.json | 0 .../fixtures/lsp/problems-query-v1.json | 0 .../fixtures/lsp/project-preparation-v1.json | 0 .../fixtures/lsp/semantic-tokens-v1.json | 0 .../fixtures/lsp/text-edits-v1.json | 0 .../fixtures/lsp/workspace-edit-plan-v1.json | 0 {shared => rust}/fixtures/maven/basic.json | 0 .../fixtures/maven/dependency-tree-v2.json | 0 .../fixtures/maven/diagnostics.json | 0 .../fixtures/maven/launch-plan-v1.json | 0 .../fixtures/maven/platform-contract-v1.json | 0 .../fixtures/maven/source-roots-v1.json | 0 .../fixtures/maven/test-results-v1.json | 0 .../fixtures/maven/testcase-durations-v1.json | 0 .../fixtures/mcp/featured-setup-v1.json | 0 {shared => rust}/fixtures/mcp/library-v1.json | 0 {shared => rust}/fixtures/mcp/tools-v1.json | 0 .../fixtures/modules/built-in-v1.json | 0 {shared => rust}/fixtures/mybatis/basic.json | 0 .../fixtures/network/proxy-policy-v1.json | 0 .../fixtures/phpunit-project/.gitignore | 0 .../fixtures/phpunit-project/composer.json | 0 .../fixtures/phpunit-project/phpunit.xml | 0 .../fixtures/phpunit-project/src/Store.php | 0 .../phpunit-project/tests/StoreTest.php | 0 .../fixtures/plugins/host-api-v1.json | 0 .../fixtures/plugins/installed-versions.json | 0 .../fixtures/plugins/publisher-signature.json | 0 .../fixtures/plugins/settings-actions.json | 0 .../fixtures/plugins/settings-documents.json | 0 .../plugins/settings-function-provider.json | 0 .../fixtures/plugins/settings-surfaces.json | 0 .../process-lifecycle/exit-plan-v1.json | 0 .../process-lifecycle/inventory-v1.json | 0 .../fixtures/process-lifecycle/settle-v1.json | 0 .../lithe-spring-boot-git-graph/.classpath | 0 .../lithe-spring-boot-git-graph/.project | 0 .../org.eclipse.core.resources.prefs | 0 .../.settings/org.eclipse.jdt.apt.core.prefs | 0 .../.settings/org.eclipse.jdt.core.prefs | 0 .../.settings/org.eclipse.m2e.core.prefs | 0 .../lithe-spring-boot-git-graph/README.md | 0 .../history/OrderController-feature.java | 0 .../history/UserService-feature.java | 0 .../lithe-spring-boot-git-graph/pom.xml | 0 .../com/example/demo/DemoApplication.java | 0 .../com/example/demo/user/UserController.java | 0 .../com/example/demo/user/UserRepository.java | 0 .../com/example/demo/user/UserService.java | 0 .../src/main/resources/application.yml | 0 .../run-configuration/argument-text.json | 0 .../automatic-java-selection.json | 0 .../fixtures/run-configuration/basic.json | 0 .../build-provider-options.json | 0 .../build-provider-stable-identities.json | 0 .../run-configuration/build-provider.json | 0 .../configuration-toolchain-selection.json | 0 .../run-configuration/context-selection.json | 0 .../run-configuration/editor-save.json | 0 .../run-configuration/hybrid-spring-vue.json | 0 .../run-configuration/invalid-version.json | 0 .../maven-module-ownership.json | 0 .../run-configuration/multi-module.json | 0 .../fixtures/run-configuration/no-entry.json | 0 .../project-environment.json | 0 .../project-toolchain-selection.json | 0 .../provider-prepared-java.json | 0 .../provider-project-ownership.json | 0 .../run-configuration/single-module.json | 0 .../subproject-toolchains.json | 0 .../run-configuration/toolchain-mismatch.json | 0 .../fixtures/search/actions-v1.json | 0 .../fixtures/search/advanced.json | 0 {shared => rust}/fixtures/search/basic.json | 0 .../fixtures/search/exclusions.json | 0 .../fixtures/search/name-matching.json | 0 .../fixtures/search/path-fallback.json | 0 .../fixtures/search/rank-names-v1.json | 0 .../search/search-everywhere-context-v1.json | 0 .../fixtures/search/text-snapshot-v1.json | 0 .../fixtures/search/unicode-contiguous.json | 0 .../settings/workbench-background-v1.json | 0 {shared => rust}/fixtures/spring/basic.json | 0 .../fixtures/terminal/link-target-v1.json | 0 .../fixtures/terminal/shell-selection-v1.json | 0 .../fixtures/toolchain-pin-edit.json | 0 .../fixtures/toolchain-pin-storage.json | 0 .../toolchains/consumer-preferences.json | 0 .../fixtures/toolchains/rpc-id-cases.json | 0 .../fixtures/updates/build-time-v1.json | 0 .../updates/channel-selection-v1.json | 0 .../fixtures/updates/update-v1.json | 0 {shared => rust}/fixtures/vcs/model-v1.json | 2 +- .../fixtures/workspace/change-filter-v1.json | 0 .../workspace/directory-actions-v1.json | 0 .../workspace/directory-rules-v1.json | 0 .../fixtures/workspace/entry-order-v1.json | 0 .../workspace/file-copy-names-v1.json | 0 .../workspace/file-name-order-v1.json | 0 .../workspace/indexing-policy-v1.json | 0 .../workspace/recent-projects-v1.json | 0 .../fixtures/workspace/repositories-v1.json | 0 .../workspace/repository-visibility-v1.json | 0 .../fixtures/workspace/visibility-v1.json | 0 .../src/attachment_retention.rs | 2 +- rust/lithe-agent-host/src/claude_desktop.rs | 4 +- .../src/codex_history/tests.rs | 2 +- rust/lithe-agent-host/src/edit_review.rs | 2 +- .../lithe-agent-host/src/edit_review/tests.rs | 4 +- rust/lithe-agent-host/src/hooks/tests.rs | 2 +- rust/lithe-agent-host/src/lib.rs | 2 +- .../src/model_call_statistics.rs | 4 +- .../src/model_proxy/websocket/tests.rs | 2 +- rust/lithe-agent-host/src/next_turn_config.rs | 4 +- rust/lithe-agent-host/src/prompt_retry.rs | 2 +- rust/lithe-agent-host/src/session_defaults.rs | 2 +- rust/lithe-agent-host/src/skills/tests.rs | 2 +- rust/lithe-agent-host/src/subagents.rs | 2 +- rust/lithe-agent-host/src/tests.rs | 6 +- rust/lithe-core-rules/src/visibility.rs | 2 +- .../tests/modifier_gesture_fixture.rs | 2 +- .../tests/visibility_fixture.rs | 2 +- .../tests/workbench_state_fixture.rs | 2 +- rust/lithe-core/src/action_search/mod.rs | 2 +- rust/lithe-core/src/ai/request_tests.rs | 4 +- rust/lithe-core/src/ai/tests.rs | 4 +- rust/lithe-core/src/build/test_outcome.rs | 2 +- .../src/build/test_outcome_merge.rs | 2 +- .../src/debug/disassembly_paging.rs | 2 +- rust/lithe-core/src/debug/endpoint.rs | 2 +- rust/lithe-core/src/debug/engine.rs | 24 +- rust/lithe-core/src/debug/projection_v2.rs | 2 +- .../src/debug/transport_lifecycle.rs | 2 +- rust/lithe-core/src/debug/variable_paging.rs | 2 +- .../src/diagnostics/catalog_generated.rs | 2 +- rust/lithe-core/src/diagnostics/collection.rs | 2 +- rust/lithe-core/src/diagnostics/logging.rs | 4 +- .../src/diagnostics/plugin_operations.rs | 2 +- rust/lithe-core/src/diagnostics/record.rs | 2 +- rust/lithe-core/src/diff/tests.rs | 2 +- rust/lithe-core/src/editor/tests.rs | 8 +- .../lithe-core/src/execution/argument_text.rs | 4 +- .../src/execution/context_selection.rs | 2 +- rust/lithe-core/src/execution/environment.rs | 4 +- .../src/git/authentication_failure.rs | 2 +- rust/lithe-core/src/git/change_targets.rs | 2 +- rust/lithe-core/src/git/changelists.rs | 2 +- rust/lithe-core/src/git/commit_selection.rs | 2 +- rust/lithe-core/src/git/configuration.rs | 2 +- rust/lithe-core/src/git/console/tests.rs | 2 +- rust/lithe-core/src/git/execution_events.rs | 2 +- rust/lithe-core/src/git/execution_policy.rs | 4 +- rust/lithe-core/src/git/fetch.rs | 2 +- rust/lithe-core/src/git/graph_tests.rs | 10 +- rust/lithe-core/src/git/history.rs | 4 +- rust/lithe-core/src/git/history_window.rs | 2 +- rust/lithe-core/src/git/mod.rs | 20 +- rust/lithe-core/src/git/progress.rs | 2 +- rust/lithe-core/src/git/rebase_session.rs | 4 +- rust/lithe-core/src/git/reference_tree.rs | 2 +- rust/lithe-core/src/git/update_plan.rs | 2 +- .../src/git/workspace_commit/tests.rs | 4 +- rust/lithe-core/src/github/mod.rs | 2 +- rust/lithe-core/src/lsp/document_identity.rs | 2 +- rust/lithe-core/src/lsp/host_policy/tests.rs | 8 +- .../src/lsp/interface/build_provider.rs | 4 +- rust/lithe-core/src/lsp/interface/client.rs | 4 +- rust/lithe-core/src/lsp/interface/engine.rs | 10 +- .../lsp/interface/engine_function_provider.rs | 2 +- .../src/lsp/interface/function_relay.rs | 2 +- .../src/lsp/interface/keymap_provider.rs | 2 +- .../src/lsp/interface/plugin_function.rs | 6 +- .../src/lsp/interface/plugin_logging.rs | 2 +- .../src/lsp/interface/toolchain_provider.rs | 2 +- .../src/lsp/interface/vcs_provider.rs | 2 +- rust/lithe-core/src/lsp/navigation_target.rs | 2 +- rust/lithe-core/src/lsp/tests.rs | 10 +- .../lithe-core/src/lsp/workspace_edit_plan.rs | 2 +- rust/lithe-core/src/mcp/library_tests.rs | 4 +- rust/lithe-core/src/mcp/registry.rs | 2 +- rust/lithe-core/src/network/tests.rs | 2 +- rust/lithe-core/src/plugins/declarative.rs | 2 +- rust/lithe-core/src/plugins/editor_actions.rs | 2 +- .../src/plugins/editor_providers.rs | 2 +- rust/lithe-core/src/plugins/host_api.rs | 2 +- rust/lithe-core/src/plugins/installed.rs | 2 +- rust/lithe-core/src/plugins/keymap_import.rs | 14 +- rust/lithe-core/src/plugins/marketplace.rs | 2 +- .../src/plugins/settings_document_tests.rs | 18 +- .../src/plugins/web_content_tests.rs | 2 +- rust/lithe-core/src/process/inventory.rs | 4 +- rust/lithe-core/src/process/lifecycle.rs | 6 +- .../src/project/directory_actions.rs | 2 +- .../lithe-core/src/project/file_name_order.rs | 2 +- rust/lithe-core/src/project/files.rs | 4 +- rust/lithe-core/src/project/indexing.rs | 4 +- rust/lithe-core/src/project/markdown.rs | 4 +- rust/lithe-core/src/project/name_matching.rs | 2 +- rust/lithe-core/src/project/search_index.rs | 2 +- .../src/remote/sessions_integration_tests.rs | 4 +- rust/lithe-core/src/runtime/ffi.rs | 4 +- rust/lithe-core/src/tests/agent.rs | 12 +- .../src/tests/build_configuration.rs | 4 +- .../src/tests/build_project_options.rs | 8 +- .../src/tests/build_runtime_versions.rs | 2 +- rust/lithe-core/src/tests/build_systems.rs | 2 +- .../src/tests/declarative_plugins.rs | 2 +- .../lithe-core/src/tests/directory_actions.rs | 4 +- rust/lithe-core/src/tests/file_name_order.rs | 4 +- rust/lithe-core/src/tests/file_operations.rs | 2 +- rust/lithe-core/src/tests/git.rs | 20 +- rust/lithe-core/src/tests/git_diff_blocks.rs | 2 +- rust/lithe-core/src/tests/git_fetch.rs | 2 +- .../src/tests/git_history_rewrite.rs | 4 +- .../src/tests/git_patch_exchange.rs | 12 +- .../src/tests/git_remote_checkout.rs | 2 +- .../src/tests/git_repository_discovery.rs | 2 +- .../src/tests/git_workspace_commit.rs | 2 +- rust/lithe-core/src/tests/github.rs | 4 +- rust/lithe-core/src/tests/history_events.rs | 2 +- .../src/tests/language_server_sessions.rs | 4 +- .../src/tests/marketplace_plugins.rs | 2 +- .../src/tests/plugin_editor_resources.rs | 2 +- rust/lithe-core/src/tests/plugin_run_debug.rs | 2 +- rust/lithe-core/src/tests/project.rs | 22 +- .../src/tests/project_generators.rs | 12 +- rust/lithe-core/src/tests/protocol.rs | 22 +- rust/lithe-core/src/tests/rank_names.rs | 4 +- .../lithe-core/src/tests/run_configuration.rs | 16 +- rust/lithe-core/src/tests/support.rs | 2 +- .../src/tests/toolchain_definitions.rs | 8 +- rust/lithe-core/src/tests/toolchain_pins.rs | 2 +- .../lithe-core/src/tests/toolchain_queries.rs | 6 +- .../src/tests/toolchain_resolution.rs | 4 +- .../lithe-core/src/tests/toolchain_vectors.rs | 2 +- rust/lithe-core/src/tests/vcs.rs | 6 +- rust/lithe-core/src/toolchain/pin_sources.rs | 2 +- rust/lithe-core/src/toolchain/preferences.rs | 2 +- rust/lithe-ide-host/src/catalog.rs | 4 +- rust/lithe-ide-host/src/lib.rs | 2 +- {shared => rust}/logging/README.md | 6 +- .../logging/event-catalog.schema.json | 0 {shared => rust}/logging/events/agent.json | 0 {shared => rust}/logging/events/app.json | 0 {shared => rust}/logging/events/debug.json | 0 {shared => rust}/logging/events/editor.json | 0 {shared => rust}/logging/events/lsp.json | 0 {shared => rust}/logging/events/perf.json | 0 {shared => rust}/logging/events/plugins.json | 0 {shared => rust}/logging/events/remote.json | 0 {shared => rust}/logging/events/run.json | 0 {shared => rust}/logging/events/terminal.json | 0 {shared => rust}/logging/events/update.json | 0 {shared => rust}/logging/events/vcs.json | 0 .../plugins => plugins/contract}/v1/README.md | 18 +- .../backend/backend-workspace.plugin.json | 0 .../v1/fixtures/backend/backend.plugin.json | 0 .../backend/command-backend.plugin.json | 0 .../fixtures/backend/function-protocol.json | 0 .../plugin-functions-caller.plugin.json | 0 .../plugin-functions-exporter.plugin.json | 0 .../v1/fixtures/backend/plugin-logging.json | 0 .../backend/terminal-backend.plugin.json | 0 .../build-providers/build-configuration.json | 0 .../build-connection-debug-options.json | 0 .../build-debug-preparation.json | 0 .../build-execution-protocol.json | 0 .../build-provider-configuration.plugin.json | 0 ...ild-provider-debug-preparation.plugin.json | 0 .../build-provider-execution.plugin.json | 0 ...build-provider-language-server.plugin.json | 0 .../build-provider-preparation.plugin.json | 0 .../build-provider-reports.plugin.json | 0 .../build-provider-run-options.plugin.json | 0 ...build-provider-run-preparation.plugin.json | 0 .../build-provider-selectors.plugin.json | 0 .../build-provider-tests.plugin.json | 0 .../build-provider.plugin.json | 0 .../build-providers/build-run-options.json | 0 .../build-run-preparation.json | 0 .../build-providers/build-test-discovery.json | 0 .../build-providers/build-test-durations.json | 0 .../build-providers/build-test-protocol.json | 0 .../build-command-output-file.plugin.json | 0 .../build-command-stdout.plugin.json | 0 .../build-instance-options.plugin.json | 0 .../build-model-consumer.plugin.json | 0 .../build-model-producer.plugin.json | 0 .../fixtures/build-systems/build-plans.json | 0 .../build-static-file.plugin.json | 0 .../build-systems/build-tool-path.plugin.json | 0 .../build-toolchains.plugin.json | 0 .../debug-adapter-features.plugin.json | 0 .../debug-adapter-language-server.plugin.json | 0 .../debug-adapter-rejections.json | 0 .../debug-adapter-remote-attach.plugin.json | 0 .../debug-adapters/debug-adapter.plugin.json | 0 .../fixtures/debug-adapters/debug-start.json | 0 .../v1/fixtures/editor/editor-actions.json | 0 .../editor/editor-actions.plugin.json | 0 .../v1/fixtures/editor/editor-providers.json | 0 .../editor/editor-providers.plugin.json | 0 .../keymap-import-provider.plugin.json | 0 .../v1/fixtures/languages/command-plans.json | 0 .../languages/grammars/http.tmLanguage.json | 0 .../languages/language-grammar.plugin.json | 0 .../language-packaged-server.plugin.json | 0 ...language-server-backend-launch.plugin.json | 0 .../language-server-launch-resolution.json | 0 .../language-system-command.plugin.json | 0 .../languages/node-runtime.plugin.json | 0 .../fixtures/manifest/hello-lithe.plugin.json | 0 .../fixtures/manifest/invalid-manifests.json | 0 .../v1/fixtures/marketplace/index-cases.json | 0 .../v1/fixtures/marketplace/index.json | 0 .../fixtures/marketplace/install-reviews.json | 0 .../marketplace/invalid-index-entries.json | 0 .../v1/fixtures/marketplace/source-urls.json | 0 .../publisher-bundled.plugin.json | 0 .../package-trust/signature-vectors.json | 0 .../project-generator-tool-field.plugin.json | 0 .../project-generator.plugin.json | 0 .../project-generators/project-plans.json | 0 .../templates/.gitattributes | 0 .../templates/app/README.md | 0 .../templates/app/dot-gitignore | 0 .../templates/main/Main.java | 0 .../templates/tool-field/README.md | 0 .../v1/fixtures/run/output-link-matchers.json | 0 .../contract}/v1/fixtures/run/run-start.json | 0 .../run/tool-reference-launch.plugin.json | 0 .../v1/fixtures/settings/settings.plugin.json | 0 .../toolchains/tool-definition.plugin.json | 0 .../toolchains/toolchain-provider.plugin.json | 0 .../fixtures/ui/native-controls.plugin.json | 0 .../v1/fixtures/ui/native-form.plugin.json | 0 .../v1/fixtures/ui/native-panel.plugin.json | 0 .../v1/fixtures/ui/web-panel.plugin.json | 0 .../v1/fixtures/vcs/vcs-provider.plugin.json | 0 .../contract}/v1/native-ui.schema.json | 0 .../contract}/v1/plugin-index.schema.json | 0 .../contract}/v1/plugin-manifest.schema.json | 4 +- .../contract}/v1/toolchains/README.md | 2 +- 573 files changed, 530 insertions(+), 530 deletions(-) rename {shared => rust}/contracts/agent-turn-policy.json (100%) rename {shared => rust}/contracts/ai-commit.md (97%) rename {shared => rust}/contracts/ai-generation-requests.md (97%) rename {shared => rust}/contracts/application-boundary.md (98%) rename {shared => rust}/contracts/build-local-options-v1.schema.json (100%) rename {shared => rust}/contracts/build-project-options-v1.schema.json (100%) rename {shared => rust}/contracts/editor-syntax-theme-v1.schema.json (100%) rename {shared => rust}/contracts/git-commit-message-context.md (100%) rename {shared => rust}/contracts/git-patch-exchange.md (100%) rename {shared => rust}/contracts/git-rebase-session.md (99%) rename {shared => rust}/contracts/git-repository-setup.md (100%) rename {shared => rust}/contracts/github.md (97%) rename {shared => rust}/contracts/ide-api/v1.md (98%) rename {shared => rust}/contracts/maven-launch-context-v1.schema.json (100%) rename {shared => rust}/contracts/maven-portable-configuration-v1.schema.json (100%) rename {shared => rust}/contracts/mcp-registry-page.json (100%) rename {shared => rust}/contracts/project-manifest-v1.schema.json (100%) rename {shared => rust}/contracts/remote-workspace-protocol-v1.md (100%) rename {shared => rust}/contracts/run-configuration-v1.schema.json (100%) rename {shared => rust}/contracts/run-configuration-v2.schema.json (100%) rename {shared => rust}/contracts/rust-core-api.md (97%) rename {shared => rust}/contracts/terminal-profiles.md (100%) rename {shared => rust}/contracts/toolchain-consumer-preferences-v1.schema.json (100%) rename {shared => rust}/contracts/toolchain-local-v1.schema.json (100%) rename {shared => rust}/contracts/toolchain-requirements-v1.schema.json (100%) rename {shared => rust}/contracts/update-v1.md (100%) rename {shared => rust}/contracts/update-v1.schema.json (100%) rename {shared => rust}/contracts/vcs-provider-v1.md (97%) rename {shared => rust}/contracts/workbench-background-v1.schema.json (100%) rename {shared => rust}/fixtures/agent/acp-events-v1.json (100%) rename {shared => rust}/fixtures/agent/agent-instructions-v1.json (100%) rename {shared => rust}/fixtures/agent/agent-management-v1.json (100%) rename {shared => rust}/fixtures/agent/attachment-retention-v1.json (100%) rename {shared => rust}/fixtures/agent/claude-desktop-cards-v1.json (99%) rename {shared => rust}/fixtures/agent/edit-review-v1.json (100%) rename {shared => rust}/fixtures/agent/history-activity-v1.json (100%) rename {shared => rust}/fixtures/agent/hook-draft-validation-v1.json (100%) rename {shared => rust}/fixtures/agent/hooks-management-v1.json (100%) rename {shared => rust}/fixtures/agent/model-call-statistics-v1.json (100%) rename {shared => rust}/fixtures/agent/next-turn-configuration-v1.json (100%) rename {shared => rust}/fixtures/agent/shared-instructions-v1.json (100%) rename {shared => rust}/fixtures/agent/skills-management-v1.json (100%) rename {shared => rust}/fixtures/ai/cli-configuration-v1.json (100%) rename {shared => rust}/fixtures/ai/commit-generation-v1.json (100%) rename {shared => rust}/fixtures/ai/inline-edit-v1.json (100%) rename {shared => rust}/fixtures/ai/pull-request-description-v1.json (100%) rename {shared => rust}/fixtures/build-project-options/local-edits.json (100%) rename {shared => rust}/fixtures/build-project-options/shared-defaults.json (100%) rename {shared => rust}/fixtures/build-project-options/validation.json (100%) rename {shared => rust}/fixtures/build-provider/test-results-v1.json (100%) rename {shared => rust}/fixtures/build/native-tool-context.json (100%) rename {shared => rust}/fixtures/build/test-outcome-merge-v1.json (100%) rename {shared => rust}/fixtures/build/test-run-outcome-v1.json (100%) rename {shared => rust}/fixtures/community/discourse-auth-v1.json (100%) rename {shared => rust}/fixtures/core/cancellation.json (100%) rename {shared => rust}/fixtures/debug/adapter-endpoints-v1.json (100%) rename {shared => rust}/fixtures/debug/adapter-selection-v1.json (100%) rename {shared => rust}/fixtures/debug/breakpoint-binding-v1.json (100%) rename {shared => rust}/fixtures/debug/breakpoint-relocation-v1.json (100%) rename {shared => rust}/fixtures/debug/dap-session-v1.json (100%) rename {shared => rust}/fixtures/debug/disassembly-paging-v1.json (100%) rename {shared => rust}/fixtures/debug/disconnect-policy-v1.json (100%) rename {shared => rust}/fixtures/debug/exception-info-v1.json (100%) rename {shared => rust}/fixtures/debug/hot-code-replace-v1.json (100%) rename {shared => rust}/fixtures/debug/launch-event-order.json (100%) rename {shared => rust}/fixtures/debug/node-source-map-v1.json (100%) rename {shared => rust}/fixtures/debug/projection-v2-examples.json (100%) rename {shared => rust}/fixtures/debug/run-in-terminal-v1.json (100%) rename {shared => rust}/fixtures/debug/stale-reference-v1.json (100%) rename {shared => rust}/fixtures/debug/start-debugging-v1.json (100%) rename {shared => rust}/fixtures/debug/stepping-filters-v1.json (100%) rename {shared => rust}/fixtures/debug/transport-lifecycle-v1.json (100%) rename {shared => rust}/fixtures/debug/v1-baseline.json (94%) rename {shared => rust}/fixtures/debug/variable-page-plan-v1.json (100%) rename {shared => rust}/fixtures/debug/variable-paging-v1.json (100%) rename {shared => rust}/fixtures/diagnostics/build-manifest-v1.json (100%) rename {shared => rust}/fixtures/diagnostics/engine-logging-v2.json (100%) rename {shared => rust}/fixtures/diagnostics/logging-ui-v1.json (100%) rename {shared => rust}/fixtures/diagnostics/logging-v1.json (100%) rename {shared => rust}/fixtures/diagnostics/logging-v2.json (100%) rename {shared => rust}/fixtures/diagnostics/redact-text-v1.json (100%) rename {shared => rust}/fixtures/diff/compare-texts-v1.json (100%) rename {shared => rust}/fixtures/documents/encoding-explicit-reopen-v1.json (100%) rename {shared => rust}/fixtures/documents/lifecycle-v1.json (100%) rename {shared => rust}/fixtures/documents/save-batch-v1.json (100%) rename {shared => rust}/fixtures/editor-themes/lithe-v1.json (100%) rename {shared => rust}/fixtures/editor/basic-file-highlighting-v1.json (100%) rename {shared => rust}/fixtures/editor/diff-review-v1.json (100%) rename {shared => rust}/fixtures/editor/file-encoding-v1.json (100%) rename {shared => rust}/fixtures/editor/line-edit-v1.json (100%) rename {shared => rust}/fixtures/editor/markdown-html-v1.json (100%) rename {shared => rust}/fixtures/editor/markdown-render-v1.json (100%) rename {shared => rust}/fixtures/editor/markdown-rich-content-v1.json (100%) rename {shared => rust}/fixtures/editor/navigation-history-v1.json (100%) rename {shared => rust}/fixtures/editor/navigation-input-v1.json (100%) rename {shared => rust}/fixtures/editor/svg-preview-v1.json (100%) rename {shared => rust}/fixtures/editor/text-content-v1.json (100%) rename {shared => rust}/fixtures/editor/text-edit-plan-v1.json (100%) rename {shared => rust}/fixtures/editor/workbench-state-v1.json (100%) rename {shared => rust}/fixtures/execution/framework-identity.json (100%) rename {shared => rust}/fixtures/execution/gradle.json (100%) rename {shared => rust}/fixtures/execution/launch-environment-v1.json (100%) rename {shared => rust}/fixtures/execution/maven-java-main-source-sets-v1.json (100%) rename {shared => rust}/fixtures/execution/maven-runtime-versions.json (100%) rename {shared => rust}/fixtures/execution/maven.json (100%) rename {shared => rust}/fixtures/execution/search-path-v1.json (100%) rename {shared => rust}/fixtures/execution/standalone-java-compile-run-v1.json (100%) rename {shared => rust}/fixtures/git/authentication-failure-v1.json (100%) rename {shared => rust}/fixtures/git/branch-name-preview-v1.json (100%) rename {shared => rust}/fixtures/git/change-targets-v1.json (100%) rename {shared => rust}/fixtures/git/changelists-reducer-v1.json (100%) rename {shared => rust}/fixtures/git/checkout-metadata-v1.json (100%) rename {shared => rust}/fixtures/git/command-error-response-v1.json (100%) rename {shared => rust}/fixtures/git/command-response-v1.json (100%) rename {shared => rust}/fixtures/git/commit-lookup-response-v1.json (100%) rename {shared => rust}/fixtures/git/commit-message-context-v1.json (100%) rename {shared => rust}/fixtures/git/commit-selection-plan-v1.json (100%) rename {shared => rust}/fixtures/git/comparison-untracked-v1.json (100%) rename {shared => rust}/fixtures/git/console-command-v1.json (100%) rename {shared => rust}/fixtures/git/console-lifecycle-v1.json (100%) rename {shared => rust}/fixtures/git/console-presentation-v1.json (100%) rename {shared => rust}/fixtures/git/diff-blocks-v1.json (100%) rename {shared => rust}/fixtures/git/diff.json (100%) rename {shared => rust}/fixtures/git/execution-events-v1.json (100%) rename {shared => rust}/fixtures/git/execution-policy-v1.json (100%) rename {shared => rust}/fixtures/git/fetch-behavior-v1.json (100%) rename {shared => rust}/fixtures/git/fetch-plan-v1.json (100%) rename {shared => rust}/fixtures/git/fetch-skip-boolean-v1.json (100%) rename {shared => rust}/fixtures/git/graph-idea/NOTICE.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/longEdges_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/longEdges_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/manyNodes_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/manyNodes_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/oneNode_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/oneNode_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/manyNodes_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/manyNodes_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/notFullGraph_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/notFullGraph_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_out.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/oneNode_in.txt (100%) rename {shared => rust}/fixtures/git/graph-idea/layoutBuilder/oneNode_out.txt (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-context-idea.txt (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-context.tsv (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-date-context.tsv (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-date-history.tsv (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-date-idea.txt (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-history.tsv (100%) rename {macos/Tests/LitheTests/Fixtures/GitGraph => rust/fixtures/git/graph-issue410}/issue410-idea.txt (100%) rename {shared => rust}/fixtures/git/graph-projection-v1.json (100%) rename {shared => rust}/fixtures/git/graph.json (100%) rename {shared => rust}/fixtures/git/history-action-capabilities-v1.json (100%) rename {shared => rust}/fixtures/git/history-date-query-v1.json (100%) rename {shared => rust}/fixtures/git/history-locate-v1.json (100%) rename {shared => rust}/fixtures/git/history-page-date-request-v1.json (100%) rename {shared => rust}/fixtures/git/history-page-response-v1.json (100%) rename {shared => rust}/fixtures/git/history-response-v1.json (100%) rename {shared => rust}/fixtures/git/history-rewrite-v1.json (100%) rename {shared => rust}/fixtures/git/history-window-v1.json (100%) rename {shared => rust}/fixtures/git/local-changelist-scope-v1.json (100%) rename {shared => rust}/fixtures/git/metadata-classification-v1.json (100%) rename {shared => rust}/fixtures/git/parsed-diff-v1.json (100%) rename {shared => rust}/fixtures/git/patch-exchange-v1.json (100%) rename {shared => rust}/fixtures/git/push-preview-v1.json (100%) rename {shared => rust}/fixtures/git/rebase-plan-preview-v1.json (100%) rename {shared => rust}/fixtures/git/rebase-session-v1.json (100%) rename {shared => rust}/fixtures/git/reference-tree-v1.json (100%) rename {shared => rust}/fixtures/git/references-response-v1.json (100%) rename {shared => rust}/fixtures/git/remote-checkout-v1.json (100%) rename {shared => rust}/fixtures/git/remote-tag-catalog-v1.json (100%) rename {shared => rust}/fixtures/git/remote-url-v1.json (100%) rename {shared => rust}/fixtures/git/repository-colors-v1.json (100%) rename {shared => rust}/fixtures/git/repository-setup-v1.json (100%) rename {shared => rust}/fixtures/git/rollback-plan-v1.json (100%) rename {shared => rust}/fixtures/git/saved-stash-v1.json (100%) rename {shared => rust}/fixtures/git/shelf-v1.json (100%) rename {shared => rust}/fixtures/git/tag-names.json (100%) rename {shared => rust}/fixtures/git/tag-write-v1.json (100%) rename {shared => rust}/fixtures/git/update-plan-v1.json (100%) rename {shared => rust}/fixtures/git/windows-paths.json (100%) rename {shared => rust}/fixtures/git/workspace-commit-v1.json (100%) rename {shared => rust}/fixtures/git/workspace-commit-workflow-v1.json (100%) rename {shared => rust}/fixtures/git/worktree-creation-v1.json (100%) rename {shared => rust}/fixtures/git/worktrees-v1.json (100%) rename {shared => rust}/fixtures/git/write.json (100%) rename {shared => rust}/fixtures/github/device-authorization-transition-v1.json (100%) rename {shared => rust}/fixtures/github/pr-checkout-plan-v1.json (100%) rename {shared => rust}/fixtures/github/pull-request-v1.json (100%) rename {shared => rust}/fixtures/history/basic.json (100%) rename {shared => rust}/fixtures/history/event-plan-v1.json (100%) rename {shared => rust}/fixtures/history/relocation-v1.json (100%) rename {shared => rust}/fixtures/i18n/format-v1.json (100%) rename {shared => rust}/fixtures/i18n/native-abi-v1.json (100%) rename {shared => rust}/fixtures/ide-api/calls.json (100%) rename {shared => rust}/fixtures/ide-api/host-tool-exposure.json (100%) rename {shared => rust}/fixtures/ide-api/mcp-protocol.json (100%) rename {shared => rust}/fixtures/java/basic.json (100%) rename {shared => rust}/fixtures/java/run-markers-v1.json (100%) rename {shared => rust}/fixtures/keymap/binding-conflicts-v1.json (100%) rename {shared => rust}/fixtures/keymap/context-v1.json (100%) rename {shared => rust}/fixtures/keymap/import-plan-v1.json (100%) rename {shared => rust}/fixtures/keymap/import-provider-v1.json (100%) rename {shared => rust}/fixtures/keymap/modifier-gesture-v1.json (100%) rename {shared => rust}/fixtures/keymap/provider-negotiation-v1.json (100%) rename {shared => rust}/fixtures/keymap/source-plans-v1.json (100%) rename {shared => rust}/fixtures/lsp/activation-policy-v1.json (100%) rename {shared => rust}/fixtures/lsp/completion-merge-v1.json (100%) rename {shared => rust}/fixtures/lsp/completion-refresh-v1.json (100%) rename {shared => rust}/fixtures/lsp/diagnostic-code-actions-v1.json (100%) rename {shared => rust}/fixtures/lsp/document-identity-v1.json (100%) rename {shared => rust}/fixtures/lsp/document-save-v1.json (100%) rename {shared => rust}/fixtures/lsp/document-symbols-v1.json (100%) rename {shared => rust}/fixtures/lsp/document-uri-v1.json (100%) rename {shared => rust}/fixtures/lsp/java-build-report-v1.json (100%) rename {shared => rust}/fixtures/lsp/java-completion-v1.json (100%) rename {shared => rust}/fixtures/lsp/java-launch-preparation-v1.json (100%) rename {shared => rust}/fixtures/lsp/java-navigation-v1.json (100%) rename {shared => rust}/fixtures/lsp/java-project-runtimes-v1.json (100%) rename {shared => rust}/fixtures/lsp/java-workspace-policy-v1.json (89%) rename {shared => rust}/fixtures/lsp/jdt-cache-retention-v1.json (100%) rename {shared => rust}/fixtures/lsp/jdt-direct-launch-v1.json (100%) rename {shared => rust}/fixtures/lsp/jdt-readiness-v1.json (100%) rename {shared => rust}/fixtures/lsp/jdt-workspace-fingerprint-v1.json (100%) rename {shared => rust}/fixtures/lsp/jdt-workspace-key-v1.json (100%) rename {shared => rust}/fixtures/lsp/maven-profile-events-v1.json (100%) rename {shared => rust}/fixtures/lsp/navigation-target-v1.json (100%) rename {shared => rust}/fixtures/lsp/problems-query-v1.json (100%) rename {shared => rust}/fixtures/lsp/project-preparation-v1.json (100%) rename {shared => rust}/fixtures/lsp/semantic-tokens-v1.json (100%) rename {shared => rust}/fixtures/lsp/text-edits-v1.json (100%) rename {shared => rust}/fixtures/lsp/workspace-edit-plan-v1.json (100%) rename {shared => rust}/fixtures/maven/basic.json (100%) rename {shared => rust}/fixtures/maven/dependency-tree-v2.json (100%) rename {shared => rust}/fixtures/maven/diagnostics.json (100%) rename {shared => rust}/fixtures/maven/launch-plan-v1.json (100%) rename {shared => rust}/fixtures/maven/platform-contract-v1.json (100%) rename {shared => rust}/fixtures/maven/source-roots-v1.json (100%) rename {shared => rust}/fixtures/maven/test-results-v1.json (100%) rename {shared => rust}/fixtures/maven/testcase-durations-v1.json (100%) rename {shared => rust}/fixtures/mcp/featured-setup-v1.json (100%) rename {shared => rust}/fixtures/mcp/library-v1.json (100%) rename {shared => rust}/fixtures/mcp/tools-v1.json (100%) rename {shared => rust}/fixtures/modules/built-in-v1.json (100%) rename {shared => rust}/fixtures/mybatis/basic.json (100%) rename {shared => rust}/fixtures/network/proxy-policy-v1.json (100%) rename {shared => rust}/fixtures/phpunit-project/.gitignore (100%) rename {shared => rust}/fixtures/phpunit-project/composer.json (100%) rename {shared => rust}/fixtures/phpunit-project/phpunit.xml (100%) rename {shared => rust}/fixtures/phpunit-project/src/Store.php (100%) rename {shared => rust}/fixtures/phpunit-project/tests/StoreTest.php (100%) rename {shared => rust}/fixtures/plugins/host-api-v1.json (100%) rename {shared => rust}/fixtures/plugins/installed-versions.json (100%) rename {shared => rust}/fixtures/plugins/publisher-signature.json (100%) rename {shared => rust}/fixtures/plugins/settings-actions.json (100%) rename {shared => rust}/fixtures/plugins/settings-documents.json (100%) rename {shared => rust}/fixtures/plugins/settings-function-provider.json (100%) rename {shared => rust}/fixtures/plugins/settings-surfaces.json (100%) rename {shared => rust}/fixtures/process-lifecycle/exit-plan-v1.json (100%) rename {shared => rust}/fixtures/process-lifecycle/inventory-v1.json (100%) rename {shared => rust}/fixtures/process-lifecycle/settle-v1.json (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/.classpath (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/.project (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.core.resources.prefs (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.apt.core.prefs (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.core.prefs (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.m2e.core.prefs (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/README.md (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/history/OrderController-feature.java (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/history/UserService-feature.java (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/pom.xml (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/DemoApplication.java (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserController.java (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserRepository.java (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserService.java (100%) rename {shared => rust}/fixtures/projects/lithe-spring-boot-git-graph/src/main/resources/application.yml (100%) rename {shared => rust}/fixtures/run-configuration/argument-text.json (100%) rename {shared => rust}/fixtures/run-configuration/automatic-java-selection.json (100%) rename {shared => rust}/fixtures/run-configuration/basic.json (100%) rename {shared => rust}/fixtures/run-configuration/build-provider-options.json (100%) rename {shared => rust}/fixtures/run-configuration/build-provider-stable-identities.json (100%) rename {shared => rust}/fixtures/run-configuration/build-provider.json (100%) rename {shared => rust}/fixtures/run-configuration/configuration-toolchain-selection.json (100%) rename {shared => rust}/fixtures/run-configuration/context-selection.json (100%) rename {shared => rust}/fixtures/run-configuration/editor-save.json (100%) rename {shared => rust}/fixtures/run-configuration/hybrid-spring-vue.json (100%) rename {shared => rust}/fixtures/run-configuration/invalid-version.json (100%) rename {shared => rust}/fixtures/run-configuration/maven-module-ownership.json (100%) rename {shared => rust}/fixtures/run-configuration/multi-module.json (100%) rename {shared => rust}/fixtures/run-configuration/no-entry.json (100%) rename {shared => rust}/fixtures/run-configuration/project-environment.json (100%) rename {shared => rust}/fixtures/run-configuration/project-toolchain-selection.json (100%) rename {shared => rust}/fixtures/run-configuration/provider-prepared-java.json (100%) rename {shared => rust}/fixtures/run-configuration/provider-project-ownership.json (100%) rename {shared => rust}/fixtures/run-configuration/single-module.json (100%) rename {shared => rust}/fixtures/run-configuration/subproject-toolchains.json (100%) rename {shared => rust}/fixtures/run-configuration/toolchain-mismatch.json (100%) rename {shared => rust}/fixtures/search/actions-v1.json (100%) rename {shared => rust}/fixtures/search/advanced.json (100%) rename {shared => rust}/fixtures/search/basic.json (100%) rename {shared => rust}/fixtures/search/exclusions.json (100%) rename {shared => rust}/fixtures/search/name-matching.json (100%) rename {shared => rust}/fixtures/search/path-fallback.json (100%) rename {shared => rust}/fixtures/search/rank-names-v1.json (100%) rename {shared => rust}/fixtures/search/search-everywhere-context-v1.json (100%) rename {shared => rust}/fixtures/search/text-snapshot-v1.json (100%) rename {shared => rust}/fixtures/search/unicode-contiguous.json (100%) rename {shared => rust}/fixtures/settings/workbench-background-v1.json (100%) rename {shared => rust}/fixtures/spring/basic.json (100%) rename {shared => rust}/fixtures/terminal/link-target-v1.json (100%) rename {shared => rust}/fixtures/terminal/shell-selection-v1.json (100%) rename {shared => rust}/fixtures/toolchain-pin-edit.json (100%) rename {shared => rust}/fixtures/toolchain-pin-storage.json (100%) rename {shared => rust}/fixtures/toolchains/consumer-preferences.json (100%) rename {shared => rust}/fixtures/toolchains/rpc-id-cases.json (100%) rename {shared => rust}/fixtures/updates/build-time-v1.json (100%) rename {shared => rust}/fixtures/updates/channel-selection-v1.json (100%) rename {shared => rust}/fixtures/updates/update-v1.json (100%) rename {shared => rust}/fixtures/vcs/model-v1.json (99%) rename {shared => rust}/fixtures/workspace/change-filter-v1.json (100%) rename {shared => rust}/fixtures/workspace/directory-actions-v1.json (100%) rename {shared => rust}/fixtures/workspace/directory-rules-v1.json (100%) rename {shared => rust}/fixtures/workspace/entry-order-v1.json (100%) rename {shared => rust}/fixtures/workspace/file-copy-names-v1.json (100%) rename {shared => rust}/fixtures/workspace/file-name-order-v1.json (100%) rename {shared => rust}/fixtures/workspace/indexing-policy-v1.json (100%) rename {shared => rust}/fixtures/workspace/recent-projects-v1.json (100%) rename {shared => rust}/fixtures/workspace/repositories-v1.json (100%) rename {shared => rust}/fixtures/workspace/repository-visibility-v1.json (100%) rename {shared => rust}/fixtures/workspace/visibility-v1.json (100%) rename {shared => rust}/logging/README.md (97%) rename {shared => rust}/logging/event-catalog.schema.json (100%) rename {shared => rust}/logging/events/agent.json (100%) rename {shared => rust}/logging/events/app.json (100%) rename {shared => rust}/logging/events/debug.json (100%) rename {shared => rust}/logging/events/editor.json (100%) rename {shared => rust}/logging/events/lsp.json (100%) rename {shared => rust}/logging/events/perf.json (100%) rename {shared => rust}/logging/events/plugins.json (100%) rename {shared => rust}/logging/events/remote.json (100%) rename {shared => rust}/logging/events/run.json (100%) rename {shared => rust}/logging/events/terminal.json (100%) rename {shared => rust}/logging/events/update.json (100%) rename {shared => rust}/logging/events/vcs.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/README.md (99%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/backend-workspace.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/backend.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/command-backend.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/function-protocol.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/plugin-functions-caller.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/plugin-functions-exporter.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/plugin-logging.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/backend/terminal-backend.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-configuration.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-connection-debug-options.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-debug-preparation.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-execution-protocol.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-configuration.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-execution.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-language-server.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-preparation.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-reports.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-run-options.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-selectors.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider-tests.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-provider.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-run-options.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-run-preparation.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-test-discovery.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-test-durations.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-providers/build-test-protocol.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-command-output-file.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-command-stdout.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-instance-options.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-model-consumer.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-model-producer.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-plans.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-static-file.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-tool-path.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/build-systems/build-toolchains.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/debug-adapters/debug-adapter-rejections.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/debug-adapters/debug-adapter.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/debug-adapters/debug-start.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/editor/editor-actions.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/editor/editor-actions.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/editor/editor-providers.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/editor/editor-providers.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/keymap-import/keymap-import-provider.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/command-plans.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/grammars/http.tmLanguage.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/language-grammar.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/language-packaged-server.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/language-server-backend-launch.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/language-server-launch-resolution.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/language-system-command.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/languages/node-runtime.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/manifest/hello-lithe.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/manifest/invalid-manifests.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/marketplace/index-cases.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/marketplace/index.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/marketplace/install-reviews.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/marketplace/invalid-index-entries.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/marketplace/source-urls.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/package-trust/publisher-bundled.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/package-trust/signature-vectors.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/project-generator-tool-field.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/project-generator.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/project-plans.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/templates/.gitattributes (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/templates/app/README.md (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/templates/app/dot-gitignore (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/templates/main/Main.java (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/project-generators/templates/tool-field/README.md (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/run/output-link-matchers.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/run/run-start.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/run/tool-reference-launch.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/settings/settings.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/toolchains/tool-definition.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/toolchains/toolchain-provider.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/ui/native-controls.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/ui/native-form.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/ui/native-panel.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/ui/web-panel.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/fixtures/vcs/vcs-provider.plugin.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/native-ui.schema.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/plugin-index.schema.json (100%) rename shared/{contracts/plugins => plugins/contract}/v1/plugin-manifest.schema.json (99%) rename shared/{contracts/plugins => plugins/contract}/v1/toolchains/README.md (99%) diff --git a/shared/contracts/agent-turn-policy.json b/rust/contracts/agent-turn-policy.json similarity index 100% rename from shared/contracts/agent-turn-policy.json rename to rust/contracts/agent-turn-policy.json diff --git a/shared/contracts/ai-commit.md b/rust/contracts/ai-commit.md similarity index 97% rename from shared/contracts/ai-commit.md rename to rust/contracts/ai-commit.md index e36cb073..addf0c4d 100644 --- a/shared/contracts/ai-commit.md +++ b/rust/contracts/ai-commit.md @@ -1,6 +1,6 @@ # AI commit generation contract -Version: 1. Behavioral fixture: `shared/fixtures/ai/commit-generation-v1.json`. +Version: 1. Behavioral fixture: `rust/fixtures/ai/commit-generation-v1.json`. Windows consumes the typed Rust API in `lithe_core::ai`; macOS uses the same planner and decoder through the Core JSON commands `ai.planCommit` and @@ -80,7 +80,7 @@ without echoing diffs, responses or credentials. Credentials are never command i macOS resolves its selected provider and credentials in the host, attaches HTTP headers locally and preserves its existing staged-diff scope and cancellation. The host rejects responses over 2 MiB before Core JSON decoding. Shared fixture -`shared/fixtures/ai/commit-generation-v1.json` exercises the three protocols on +`rust/fixtures/ai/commit-generation-v1.json` exercises the three protocols on both Core and the macOS bridge. ## Windows adapter surface diff --git a/shared/contracts/ai-generation-requests.md b/rust/contracts/ai-generation-requests.md similarity index 97% rename from shared/contracts/ai-generation-requests.md rename to rust/contracts/ai-generation-requests.md index 282a1489..de982558 100644 --- a/shared/contracts/ai-generation-requests.md +++ b/rust/contracts/ai-generation-requests.md @@ -1,7 +1,7 @@ # AI pull request descriptions and inline edits -Version: 1. Behavioral fixtures: `shared/fixtures/ai/pull-request-description-v1.json` -and `shared/fixtures/ai/inline-edit-v1.json`. +Version: 1. Behavioral fixtures: `rust/fixtures/ai/pull-request-description-v1.json` +and `rust/fixtures/ai/inline-edit-v1.json`. Both features reuse the credential-free provider request model of [AI commit generation](ai-commit.md): Core validates the endpoint, appends the diff --git a/shared/contracts/application-boundary.md b/rust/contracts/application-boundary.md similarity index 98% rename from shared/contracts/application-boundary.md rename to rust/contracts/application-boundary.md index 410a6b8e..fd8a0d00 100644 --- a/shared/contracts/application-boundary.md +++ b/rust/contracts/application-boundary.md @@ -3,7 +3,7 @@ The application boundary describes product behavior that a SwiftUI/AppKit or React/Tauri Windows UI can consume. It does not describe widgets, threads, processes, or operating-system APIs. It defines the cross-platform contract; current -product scope and setup are documented in [`README.md`](../../README.md); the +product scope and setup are documented in [`README.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/README.md); the verification scripts are the executable source of boundary checks. ## Data Rules @@ -20,7 +20,7 @@ verification scripts are the executable source of boundary checks. ## Shared built-in translations -[Shared i18n](../i18n/README.md) owns migrated semantic keys, typed named parameters, +[Shared i18n](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/i18n/README.md) owns migrated semantic keys, typed named parameters, locale registration, platform scopes and documented platform wording overrides. Build-time generation supplies native macOS string tables and the Windows catalog; no runtime JSON loading, package download or write to installed resources occurs. @@ -306,7 +306,7 @@ and launch belong to platform adapters, and launch failures are shown to the use ## Module Lifecycle Contract The macOS reference product implements the built-in manifest in -`shared/fixtures/modules/built-in-v1.json`. Module IDs and manifest fields are +`rust/fixtures/modules/built-in-v1.json`. Module IDs and manifest fields are platform-neutral compatibility surfaces. A future Windows implementation may adopt the contract independently without sharing Swift implementation code or being coupled to the macOS migration schedule. An implementation of this @@ -493,7 +493,7 @@ server advertises them during initialize or dynamic registration. The shared core owns JSON-RPC state, stdio, process lifecycle, and normalized results; platform adapters own executable and provider-resource discovery. Detailed invariants are documented in -[`.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md`](../../.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md). +[`.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md). Session lifecycle is a single discriminated state, never a set of booleans. Capability negotiation is separately `unknown` or `known`; a known capability @@ -624,7 +624,7 @@ image bytes. Native file access authorization and local-image metadata are platform-private, so a preference can be understood on another platform without leaking an unusable absolute path or macOS security-scoped bookmark. -Search and Git examples are kept in `shared/fixtures/`. New behavior should +Search and Git examples are kept in `rust/fixtures/`. New behavior should add a fixture before adding a second platform implementation. Git history clients load reference metadata independently from bounded commit @@ -844,7 +844,7 @@ Both Monaco hosts use the bundled INI tokenizer for `.properties`. Tokenization is presentation only: no grammar or language server is required to open plain text. Register bundled contributions in `frontend/editor`, not in a separate platform-specific tokenizer. Text fixtures are shared under -`shared/fixtures/editor/text-content-v1.json`. +`rust/fixtures/editor/text-content-v1.json`. ### Updating a running Java service @@ -876,7 +876,7 @@ JDT-owned workspace build paths and the existing platform-owned JDT state. - 有交互含义的调用在 `tool_call` / `tool_call_update` 的 `_meta.lithe.claudeDesktop` 附加卡片,字段 `card` 决定类型:`chapter {title, summary?}`、`nextStep {actions: [{label, message}] ≤4}`、`task {title, summary?, prompt?, state: proposed|withdrawn|started|handedOff}`、`pullRequest {action: bound|unbound|monitored|autoMerge, url, number?, autoFix?, enabled?}`、`directory {action: changed|requested, path}`、`widget {title, code ≤256 KiB 字符}`。文本字段截断到 16 KiB 字符;超限的 `widget.code` 置空。 - `nextStep` 是 Host 另发的已完成调用,id 为原 `wrap_up` 调用 id 加 `#next`;任务撤回或启动的结果到达后,Host 以原卡片调用 id 补发整张卡片。后到的卡片整体替换先前的卡片;不带卡片的更新不清除卡片。 - 客户端只在调用状态为 `completed` 时显示卡片;未知 `card` 类型、缺字段或非 http(s) 的 PR 链接退回普通工具行。卡片动作只填写输入框,不代替用户发送。可视化内容必须在禁止网络加载、非持久存储、禁止导航的隔离网页中渲染。 -- 共享样例 `shared/fixtures/agent/claude-desktop-cards-v1.json` 按调用顺序记录 Host 收到的更新(`updates`)和呈现给客户端的结果(`presented`),覆盖六种卡片、只换标题的行和原样透传;Host 测试逐例重放它,客户端按它解码卡片。 +- 共享样例 `rust/fixtures/agent/claude-desktop-cards-v1.json` 按调用顺序记录 Host 收到的更新(`updates`)和呈现给客户端的结果(`presented`),覆盖六种卡片、只换标题的行和原样透传;Host 测试逐例重放它,客户端按它解码卡片。 Agent skill management groups files by Global/Project and retains their Agent diff --git a/shared/contracts/build-local-options-v1.schema.json b/rust/contracts/build-local-options-v1.schema.json similarity index 100% rename from shared/contracts/build-local-options-v1.schema.json rename to rust/contracts/build-local-options-v1.schema.json diff --git a/shared/contracts/build-project-options-v1.schema.json b/rust/contracts/build-project-options-v1.schema.json similarity index 100% rename from shared/contracts/build-project-options-v1.schema.json rename to rust/contracts/build-project-options-v1.schema.json diff --git a/shared/contracts/editor-syntax-theme-v1.schema.json b/rust/contracts/editor-syntax-theme-v1.schema.json similarity index 100% rename from shared/contracts/editor-syntax-theme-v1.schema.json rename to rust/contracts/editor-syntax-theme-v1.schema.json diff --git a/shared/contracts/git-commit-message-context.md b/rust/contracts/git-commit-message-context.md similarity index 100% rename from shared/contracts/git-commit-message-context.md rename to rust/contracts/git-commit-message-context.md diff --git a/shared/contracts/git-patch-exchange.md b/rust/contracts/git-patch-exchange.md similarity index 100% rename from shared/contracts/git-patch-exchange.md rename to rust/contracts/git-patch-exchange.md diff --git a/shared/contracts/git-rebase-session.md b/rust/contracts/git-rebase-session.md similarity index 99% rename from shared/contracts/git-rebase-session.md rename to rust/contracts/git-rebase-session.md index bbf472f3..124934f4 100644 --- a/shared/contracts/git-rebase-session.md +++ b/rust/contracts/git-rebase-session.md @@ -3,7 +3,7 @@ Rust Core owns range validation, the reviewed snapshot, safe todo/message preparation, recovery references, and Git sequencer execution. Native products own plan editing, progress, conflict presentation, and explicit user controls. -The shared example is `shared/fixtures/git/rebase-session-v1.json`. +The shared example is `rust/fixtures/git/rebase-session-v1.json`. ## Preview and plan diff --git a/shared/contracts/git-repository-setup.md b/rust/contracts/git-repository-setup.md similarity index 100% rename from shared/contracts/git-repository-setup.md rename to rust/contracts/git-repository-setup.md diff --git a/shared/contracts/github.md b/rust/contracts/github.md similarity index 97% rename from shared/contracts/github.md rename to rust/contracts/github.md index 7d917647..10d553b6 100644 --- a/shared/contracts/github.md +++ b/rust/contracts/github.md @@ -58,7 +58,7 @@ GitHub sign-in unavailable rather than asking the user for a personal token. `interval`, `elapsedMilliseconds`, and optional `suggestedInterval`. It returns `action`, `intervalSeconds`, and `waitMilliseconds`; it accepts no device code, access token, or other credential. Events and responses are - specified by `shared/fixtures/github/device-authorization-transition-v1.json`. + specified by `rust/fixtures/github/device-authorization-transition-v1.json`. Expiration is bounded to one day and intervals are bounded to that maximum lifetime. The maximum cannot cause early polling: by the time it elapses, the authorization has expired. This preserves the mandatory cumulative @@ -67,7 +67,7 @@ GitHub sign-in unavailable rather than asking the user for a personal token. and [GitHub's device-flow documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow). - `github.pullRequestCheckoutPlan` accepts a pull-request `number`, `headRef`, and a `branchExists` fact. It returns the proposed branch, an explicit - conflict, and ordered actions; see `shared/fixtures/github/pr-checkout-plan-v1.json`. + conflict, and ordered actions; see `rust/fixtures/github/pr-checkout-plan-v1.json`. - `git.pullRequestCheckoutPreview` inspects the repository and returns that plan with its current HEAD, symbolic branch, and a process-salted fingerprint of the effective origin URL; the URL and its possible credentials are not @@ -110,7 +110,7 @@ any other legacy state becomes `status: "completed"` with that state as PR lists are sorted by descending number. Labels, assignees, comments, and files are deterministically ordered as demonstrated by -`shared/fixtures/github/pull-request-v1.json`. +`rust/fixtures/github/pull-request-v1.json`. Branch lists are sorted by branch name and duplicate names are removed before they cross the Rust boundary. The first page is capped at 100 branches, which matches the current creation workflow's bounded picker. diff --git a/shared/contracts/ide-api/v1.md b/rust/contracts/ide-api/v1.md similarity index 98% rename from shared/contracts/ide-api/v1.md rename to rust/contracts/ide-api/v1.md index 80173a06..5166ad8d 100644 --- a/shared/contracts/ide-api/v1.md +++ b/rust/contracts/ide-api/v1.md @@ -26,7 +26,7 @@ outside its declaration before queuing, and `lithe-mcp` resolves that declaratio for `tools/list`. Native plugin calls validate with the same declaration. The local `catalog`/`validate` utilities also accept `toolExposure`; omitting it inspects the full reference catalog and does not grant a connection any additional access. -`shared/fixtures/ide-api/host-tool-exposure.json` records both product inventories. +`rust/fixtures/ide-api/host-tool-exposure.json` records both product inventories. The user enables one opened project in Settings > MCP Configuration. Read access includes logs, which can contain sensitive project information. Configure and @@ -88,13 +88,13 @@ the project dispatcher answered a tool call. MCP `tools/list` includes `ttlMs: 0` and `cacheScope: "private"`, including for `2026-07-28` discovery. Protocol negotiation and envelopes remain SDK-owned; legacy and modern clients are verified in separate stdio sessions using -`shared/fixtures/ide-api/mcp-protocol.json`. +`rust/fixtures/ide-api/mcp-protocol.json`. ## Capabilities Every call carries `name` and an object `arguments`. Unknown keys, missing required arguments, invalid types and unsupported selection fields are rejected before dispatch. See the executable schemas and -`shared/fixtures/ide-api/calls.json`. Requests do not accept a workspace root: +`rust/fixtures/ide-api/calls.json`. Requests do not accept a workspace root: the connection already owns its workspace. Native toolchain paths are local configuration values, never portable workspace identifiers. diff --git a/shared/contracts/maven-launch-context-v1.schema.json b/rust/contracts/maven-launch-context-v1.schema.json similarity index 100% rename from shared/contracts/maven-launch-context-v1.schema.json rename to rust/contracts/maven-launch-context-v1.schema.json diff --git a/shared/contracts/maven-portable-configuration-v1.schema.json b/rust/contracts/maven-portable-configuration-v1.schema.json similarity index 100% rename from shared/contracts/maven-portable-configuration-v1.schema.json rename to rust/contracts/maven-portable-configuration-v1.schema.json diff --git a/shared/contracts/mcp-registry-page.json b/rust/contracts/mcp-registry-page.json similarity index 100% rename from shared/contracts/mcp-registry-page.json rename to rust/contracts/mcp-registry-page.json diff --git a/shared/contracts/project-manifest-v1.schema.json b/rust/contracts/project-manifest-v1.schema.json similarity index 100% rename from shared/contracts/project-manifest-v1.schema.json rename to rust/contracts/project-manifest-v1.schema.json diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/rust/contracts/remote-workspace-protocol-v1.md similarity index 100% rename from shared/contracts/remote-workspace-protocol-v1.md rename to rust/contracts/remote-workspace-protocol-v1.md diff --git a/shared/contracts/run-configuration-v1.schema.json b/rust/contracts/run-configuration-v1.schema.json similarity index 100% rename from shared/contracts/run-configuration-v1.schema.json rename to rust/contracts/run-configuration-v1.schema.json diff --git a/shared/contracts/run-configuration-v2.schema.json b/rust/contracts/run-configuration-v2.schema.json similarity index 100% rename from shared/contracts/run-configuration-v2.schema.json rename to rust/contracts/run-configuration-v2.schema.json diff --git a/shared/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md similarity index 97% rename from shared/contracts/rust-core-api.md rename to rust/contracts/rust-core-api.md index 9cd32180..eb5db500 100644 --- a/shared/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -51,7 +51,7 @@ manifest; an unknown provider or invalid manifest returns `invalid_request`. The payload is bounded to 1 MiB. It performs no filesystem I/O and does not establish package trust: callers must supply the enabled, previously accepted plugin manifest. Selectors and native read limits are defined by the -[plugin contract](plugins/v1/README.md#shortcut-import-providers-core-foundation). +[plugin contract](../../shared/plugins/contract/v1/README.md#shortcut-import-providers-core-foundation). The returned limits bound selectors, recognition probes, files, bytes per file and per snapshot, bytes per chunk, directory entries scanned and matching entries per directory; native adapters apply these values to their I/O. @@ -128,7 +128,7 @@ Compilation is refreshed on binding/preset changes, and the keyboard owner releases preparations on teardown. Expression caching is limited to 256 entries and native compilation to 16 outstanding requests. Cache limits, teardown and stale-store guards belong to the window adapter. macOS has no free-form -`when` parser and is unchanged. See `shared/fixtures/keymap/context-v1.json`. +`when` parser and is unchanged. See `rust/fixtures/keymap/context-v1.json`. ## Shortcut binding conflicts @@ -147,7 +147,7 @@ is the double-tap Shift gesture. Parse failures are listed in `invalid` with `em `multiple_keys`, `too_long` or `too_many_steps` and never take part in conflicts; more than 20000 entries is `invalid_request`. Hosts reject saving a binding when a conflict contains the edited command. Native key-event conversion and the synchronous matching path stay in the host. -See `shared/fixtures/keymap/binding-conflicts-v1.json`. +See `rust/fixtures/keymap/binding-conflicts-v1.json`. `keymap.modifierGestureStep` accepts `{state?, event, timestamp}` and returns `{state, triggered}`. It is the double-Shift gesture as a pure transition: `event` is `{kind: "keyDown"}`, `{kind: "reset"}` or @@ -157,7 +157,7 @@ pure crate `rust/lithe-core-rules` (no I/O, no clock) so it can run on a synchro through the C ABI, and the Windows frontend runs the same source compiled to wasm (`lithe_rules_alloc`, `lithe_rules_dealloc`, `lithe_rules_execute` over JSON envelopes), loaded asynchronously at startup. A malformed payload is `invalid_request`. Hosts only convert native events to this input and act on `triggered`; both are -tested step by step against `shared/fixtures/keymap/modifier-gesture-v1.json`. +tested step by step against `rust/fixtures/keymap/modifier-gesture-v1.json`. `editor.workbenchState.reduce` accepts `{state?, action}` and returns `{state, removedGroupIds}`. `state` is `{groups, focusedGroup?}`; a group is `{id, members, selected?, mru?, pinned?, preview?}` (ordered members, the shown @@ -180,7 +180,7 @@ group or an unknown move destination change nothing. `removedGroupIds` lists rem hosts prune their own layout tree; split directions, ratios, pixels and dragging never reach Core. A malformed payload is `invalid_request`. It lives in `rust/lithe-core-rules` and is called synchronously: macOS through the C ABI, Windows through the wasm build inside zustand `set()`, one call per user action. Both hosts run every case of -`shared/fixtures/editor/workbench-state-v1.json` through it. +`rust/fixtures/editor/workbench-state-v1.json` through it. `workspace.classifyVisibility` accepts `{hiddenDirectoryNames?, hiddenFilePatterns?, entries: [{path, isDirectory?}]}` and returns `{hidden: boolean[], watchedDespiteHiding: boolean[]}` with one answer per entry, in request order. `path` @@ -193,7 +193,7 @@ events must still be delivered, except the built-in noise below it such as `.lit of a directory in one call. `workspace.visibilityDefaults` takes `{}` and returns the built-in lists (`hiddenDirectoryNames`, `hiddenFilePatterns`, `builtInPatterns`, `configurationDirectory`, `configurationPattern`) so hosts display them without keeping a copy. Both live in `rust/lithe-core-rules` like `keymap.modifierGestureStep`, run in wasm on -Windows, and are tested against `shared/fixtures/workspace/visibility-v1.json`. +Windows, and are tested against `rust/fixtures/workspace/visibility-v1.json`. ## Terminal link targets @@ -208,7 +208,7 @@ providers (for example `remote`, `wsl`): such URIs are returned unchanged, and u `mailto:` are `external`; a bare `name:12` is a file, not a scheme. `explicit` is true when the text itself says it is a path (separator, `~`, `file://`, provider URI or line suffix). Core never inspects file extensions or touches the file system: the host checks existence, owns hit regions, and opens the -target. At most 256 texts of 4096 bytes. See `shared/fixtures/terminal/link-target-v1.json`. +target. At most 256 texts of 4096 bytes. See `rust/fixtures/terminal/link-target-v1.json`. The Rust core is the shared application runtime for macOS SwiftUI and Windows React/Tauri. macOS calls the stable C ABI while the Tauri host links the Rust @@ -397,7 +397,7 @@ process tree stops. Its script comes from embedded first-party source and holds no credential. Preparation/spawn failure, cancellation and completion remove the directory. The host does not write installed packages, bundles, CLI settings or user configuration; this runtime helper is excluded from worktree reuse. -The route fixture is `shared/fixtures/agent/acp-events-v1.json`'s +The route fixture is `rust/fixtures/agent/acp-events-v1.json`'s `upstream.claudeSessionRouting`. The user's own CLI is passed as `CODEX_PATH` or `CLAUDE_CODE_EXECUTABLE`, and a non-empty Codex `model` as `CODEX_CONFIG`; Claude uses the per-new-session option above. Without `agentId`, `command` runs @@ -457,7 +457,7 @@ or `refreshQuota`. Results arrive as events: `sessionConfigured`, `nextTurnConfigured`, `modelCall`, `steeringResult`, `turnRetrying`, `turnActivity`, `turnCancelling`, `turnFinished`, `requestFailed`, `stopped`, `authenticationRequired`, `authenticating`, `account`, `quota`, and `quotaFailed`. Commands and events, including their camel-case field names, are fixed by -`shared/fixtures/agent/acp-events-v1.json`; `token` values are echoed so a caller +`rust/fixtures/agent/acp-events-v1.json`; `token` values are echoed so a caller can correlate concurrent requests. `stopReason` uses ACP wire names such as `end_turn` and `cancelled`. @@ -474,7 +474,7 @@ and time-sorted history rows place unknown activity after known timestamps, breaking ties by session ID. The switcher's existing current-session pin is unchanged. Listing failure retains consumers' existing rows through `requestFailed`; it does not fabricate a local timestamp. Other Agents retain standard ACP listing. -`shared/fixtures/agent/history-activity-v1.json` covers initial state, metadata-only +`rust/fixtures/agent/history-activity-v1.json` covers initial state, metadata-only viewing, and a new conversation turn. The history UI geometry is unchanged. History summaries may additionally include `modelProvider`, the opaque provider @@ -590,7 +590,7 @@ instead of sending it; disconnect clears the intent as well. A deferred supplement after a failed turn is failed with that turn's error, never replayed. The next prompt remains blocked until the command finishes. The Host still rejects the command while the session's prompt is running. Shape and cases: -`shared/fixtures/agent/next-turn-configuration-v1.json`. +`rust/fixtures/agent/next-turn-configuration-v1.json`. For a new session, the host also preserves the adapter's optional legacy model catalog while decoding the ACP response and negotiates only the versioned @@ -778,7 +778,7 @@ session's main requests carry tools, tool-less ones no longer do). Products merge contributions in event order (counters add, `reasoningTokens` adds over calls that report it, a present `context` replaces the previous one) and never reinterpret `call` for accounting. Cases: -`shared/fixtures/agent/model-call-statistics-v1.json`. The launch accepts `contextLimits` (`windowTokens`, `autoCompactTokens`, both optional; +`rust/fixtures/agent/model-call-statistics-v1.json`. The launch accepts `contextLimits` (`windowTokens`, `autoCompactTokens`, both optional; zero means unset). Codex receives them as `model_context_window` and `model_auto_compact_token_limit` in its session configuration. Claude Code has a single switch, so the threshold (else the context length) is exported as @@ -957,7 +957,7 @@ or `{action:"parse",line}` to return `{command,args}`. POSIX shell-style quoting is only a lossless text projection: hosts pass argv directly, never evaluate a shell, substitutions, pipes or redirects. Blank and embedded-newline arguments are preserved; malformed quoting is rejected. These operations and probe -results use `shared/fixtures/mcp/tools-v1.json` as a synthetic contract fixture. +results use `rust/fixtures/mcp/tools-v1.json` as a synthetic contract fixture. `mcp.parseRegistry` accepts `{ content: string }` (at most 1 MiB UTF-8) and normalizes an official MCP Registry v0.1 response into `{ entries, nextCursor }`. @@ -973,7 +973,7 @@ mutable tags and major/minor ranges remain documentation-only. Other records remain documentation-only. This operation never fetches, installs or executes anything. Hosts own cancellable directory requests and require the user to review and save a draft before modifying Agent configurations. -`shared/contracts/mcp-registry-page.json` is a portable synthetic response. +`rust/contracts/mcp-registry-page.json` is a portable synthetic response. `mcp.featuredRegistry` accepts `{}` and returns the same page shape with `source: "featured"` from the Git-owned, compile-time catalog at @@ -989,7 +989,7 @@ template, requiring an Authorization bearer value or at least one absolute directory argument after the fixed Filesystem package argument. Hosts must collect and validate these inputs before saving and must not select Agents by default. Public Registry parsing never copies `setup`, even when input uses a -featured-looking ID. `shared/fixtures/mcp/featured-setup-v1.json` illustrates the +featured-looking ID. `rust/fixtures/mcp/featured-setup-v1.json` illustrates the optional field; ordinary entries omit it. Brand marks are platform-owned, read-only bundled assets, selected only for known featured IDs; public entries keep the transport fallback. No response can select arbitrary resources or @@ -1116,7 +1116,7 @@ changes: [{ file, changed, content?, owned, renames: [{ from, to }] }] }` in host file order. `content` is present only when changed; `owned` is the file's next ledger (unchanged files return their input ledger). Hosts write every changed file as one rollback batch and publish ledgers and renames only after -the whole batch succeeds. `shared/fixtures/mcp/library-v1.json` holds the shared +the whole batch succeeds. `rust/fixtures/mcp/library-v1.json` holds the shared cases; its `{{sourceHash::}}` placeholders are resolved from the parsed fixture files. @@ -1138,14 +1138,14 @@ resolves natively against the same text or environment. Malformed input returns `AI_CONFIGURATION_TOO_LARGE`) and never echoes input. `allowIncomplete: true` is for editor drafts: it ignores the environment, accepts blank or partial text and keeps missing endpoint/model fields empty. The shared cases are in -`shared/fixtures/ai/cli-configuration-v1.json`. It replaces the former +`rust/fixtures/ai/cli-configuration-v1.json`. It replaces the former `agent.parseProviderConfiguration`. `agent.edits.reversePlan` takes `{diffs, disk}` (normalized edit evidence plus the current file text, or `null` when the file is missing) and returns the rollback plan or a conflict; it never touches the file system. Path containment, encoding, guarded writes and Trash stay with the platform. Cases are in -`shared/fixtures/agent/edit-review-v1.json`. +`rust/fixtures/agent/edit-review-v1.json`. `agent.instructions.read` takes `{agentId}` (`codex-acp` or `claude-acp`) and returns `{agentId, path, content, exists, revision}`. It does not create files. @@ -1163,7 +1163,7 @@ share it, so revision checks are optimistic rather than an OS-wide transaction. Errors use the existing envelope codes with stable `details` reasons including `unsupported_agent`, `file_access`, `unsafe_file`, `file_changed`, `file_too_large`, `invalid_utf8`, `profile_missing`, `profile_relative`, and `busy`. -The fixture is `shared/fixtures/agent/agent-instructions-v1.json`. +The fixture is `rust/fixtures/agent/agent-instructions-v1.json`. `agent.instructions.shared` takes a platform-selected absolute `dataDirectory` from the writable application-support adapter plus one tagged action: @@ -1228,7 +1228,7 @@ trust. A matching user-owned entry is never claimed or duplicated. Codex still requires native Hook enablement and approval in `/hooks`; `configuredAgents` does not claim execution. Source, ownership journal, lock and temporary siblings are user state outside installed resources and cannot be reused across worktrees. -The shared fixture is `shared/fixtures/agent/hooks-management-v1.json`. +The shared fixture is `rust/fixtures/agent/hooks-management-v1.json`. `agent.hooks.validateDraft` validates an unsaved editor draft without reading or writing files, using the same rules `save` enforces in Agent Host. The payload is @@ -1244,7 +1244,7 @@ saving (null when empty or rejected). Fields and codes: `id` `invalid`; `name` `timeout` `not_integer`, `out_of_range`; `agents` `required`, `unsupported`, `duplicate`. Platforms localize `agent.hooks.draft..` and enable saving only when `valid`. A malformed payload fails with `invalid_request`. The fixture is -`shared/fixtures/agent/hook-draft-validation-v1.json`. +`rust/fixtures/agent/hook-draft-validation-v1.json`. `agent.status`, `agent.install`, `agent.uninstall`, and `agent.installCli` manage ACP adapters in `/agents/`, using the Node.js and npm the user installed. @@ -1272,7 +1272,7 @@ and replaces the previous install only after the adapter executable exists; it honors `operationId` cancellation and `timeoutMilliseconds`. Failures use `runtime_missing` (Node.js or npm unusable), `process_failed` (npm failed, with its output tail), `invalid_request`, `cancelled`, or `timed_out`. Payloads and -results are fixed by `shared/fixtures/agent/agent-management-v1.json`. +results are fixed by `rust/fixtures/agent/agent-management-v1.json`. `agent.installCli` resolves the current PATH executable and its installation owner again on each explicit action. A missing CLI uses npm; an existing npm CLI @@ -1343,7 +1343,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `editor.lineCommentToken` | Resolve the line comment token for a file extension or language id | | `workspace.releaseIndexing` | Retire a policy and clear index/cache data at project close; accepts `root`, optional `ownerId`, and that owner's last observed `revision`, returns `released`; an old close must not retire a new generation | | `workspace.refreshIndexing` | Refresh external Git decisions for a live `root`, `ownerId`, `revision` lease without changing preferences or release tokens; returns lazy `IndexingStatus` | -| `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `shared/fixtures/workspace/indexing-policy-v1.json` | +| `workspace.configureIndexing` | Install a process-local, canonical-root indexing policy before workspace consumers run; returns a monotonic revision, effective directory reasons and Git failure state; see `rust/fixtures/workspace/indexing-policy-v1.json` | | `network.proxyPolicy` | Normalize the stored proxy settings into the policy a network stack must apply (mode, server, credentials, exclusions) | | `workspace.snapshot` | Enumerate visible workspace nodes and relative file paths; `nestedWorktrees` lists linked worktrees checked out inside their own repository, which are left out of the tree, files, and search index; the built-in hidden names are skipped without being read, and include `node_modules`, Python virtual environments (`.venv`, `venv`), `__pycache__` and `*.pyc` files; `includeTree: false` returns only `files` and a childless `root`; `files` (and the tree, when included) keep the lowercase traversal order that bounded consumers such as Windows Quick Open rely on, not the natural display order of `workspace.listDirectories` | | `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | @@ -1351,12 +1351,12 @@ package manager owns the download and Lithe does not infer bytes from logs. | `workspace.planPaste` | Name pasted project items in one destination directory: a free name is kept, a taken one gets the copy suffix, and earlier items of the same paste count as taken | | `workspace.reduceDirectoryAction` | Apply "set role", "exclude" or "cancel exclusion" to a root's persisted directory rules and return the complete state to persist | | `workspace.relocateDirectoryRules` | Move (or drop, after a delete) the directory rule keys of a renamed, moved or deleted subtree, optionally into another workspace root's rules | -| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | +| `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `rust/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | | `workspace.recentProjects.reduce` | Apply one record, remove, pin, import, or normalize action to the recent project list and return the retained order | | `workspace.search` | Search visible file names and UTF-8 text files | | `workspace.searchEverywhere` | Search visible file names, Java types/methods, and UTF-8 text files | -| `workspace.rankNames` | Pure ranking of candidate names that the platform enumerates itself (`{ query, candidates: [{ name, path? }], context: { openPaths, recentPaths }, limitPerTier? }`). Use it only for lists Core cannot index: Windows remote/WSL roots, symbol lists, navigator lists. Roots Core indexes go through `workspace.searchEverywhere`, so an inventory is never sent back to Core. Returns `matches: [{ index, degree, tier, nameMatchRanges }]` ordered by degree, open/recent tier, recency, then file-name order (fixture `shared/fixtures/search/rank-names-v1.json`) | +| `workspace.rankNames` | Pure ranking of candidate names that the platform enumerates itself (`{ query, candidates: [{ name, path? }], context: { openPaths, recentPaths }, limitPerTier? }`). Use it only for lists Core cannot index: Windows remote/WSL roots, symbol lists, navigator lists. Roots Core indexes go through `workspace.searchEverywhere`, so an inventory is never sent back to Core. Returns `matches: [{ index, degree, tier, nameMatchRanges }]` ordered by degree, open/recent tier, recency, then file-name order (fixture `rust/fixtures/search/rank-names-v1.json`) | | `workspace.replacePreview` | Return deterministic replacement lines and complete replacement text | | `workspace.replaceText` | Plan replacement against a host-captured text snapshot without writing | | `workspace.searchTextBatch` | Search provider-captured text snapshots using workspace matching rules | @@ -1748,7 +1748,7 @@ so hosts attach or discard their own metadata (names, icons, branches). Empty, NUL-containing or over-4,096-byte paths, negative times and out-of-range capacities return `invalid_request`. Hosts own persistence and which roots are worth recording; menus may show fewer rows than are retained. See -`shared/fixtures/workspace/recent-projects-v1.json`. +`rust/fixtures/workspace/recent-projects-v1.json`. `workspace.repositories.repositories` is ordered with the containing workspace repository first when present, then repositories under the opened workspace by @@ -1770,7 +1770,7 @@ identifiers retain their protocol and POSIX name semantics. Chinese names, embedded spaces and long paths are not rejected by length; native Git/filesystem errors remain visible. In particular, Windows may reject an over-MAX_PATH Git working directory at process creation even when filesystem lookup succeeds; -this is reported as `process_start_failed`, not a missing repository. Shared examples live in `shared/fixtures/git/windows-paths.json`. +this is reported as `process_start_failed`, not a missing repository. Shared examples live in `rust/fixtures/git/windows-paths.json`. Core treats both `.git` directories and `.git` files as repository markers, but a marker only becomes a listed repository when Git can open it: a `.git` directory needs @@ -1788,7 +1788,7 @@ are never excluded by their names. These are automatic discovery rules, not Git tracking rules: `.gitignore` and file-tree visibility overrides do not configure repository ownership. The rules apply before repository status/reference aggregation on both hosts. Examples live in -`shared/fixtures/workspace/repository-visibility-v1.json`. +`rust/fixtures/workspace/repository-visibility-v1.json`. Git metadata itself is not traversed. Symbolic directory links are not followed, preventing cycles and traversal outside the workspace. Callers may explicitly supply `maxDirectories` and @@ -1816,7 +1816,7 @@ detached or deleted references, and fills missing entries deterministically. The remote HEAD target is preferred as the default branch, followed by `main`, `master`, and the remaining local references in refname order. -The protocol version is currently `1`. Add a fixture under `shared/fixtures/` +The protocol version is currently `1`. Add a fixture under `rust/fixtures/` before changing a response shape or search rule. `document.lifecycle` accepts a discriminated `state` (`clean`, `dirty`, @@ -1834,7 +1834,7 @@ UTF-8 with BOM, UTF-16 LE/BE (with or without BOM), GBK, GB18030, Big5, Shift JI lifecycle remains encoding-agnostic. Watchers are refresh hints, not write authorization. Conflict resolution acknowledges only the disk snapshot observed by the user, and subsequent saves must validate that snapshot again. The -portable examples are in `shared/fixtures/documents/lifecycle-v1.json`. +portable examples are in `rust/fixtures/documents/lifecycle-v1.json`. `document.planSaveBatch` accepts `{ documents }`, where each open document is `{ id, dirty, readOnly, contentSynchronized }`, and returns `{ members }` in @@ -1850,7 +1850,7 @@ while the batch ran, keeps the batch `incomplete`. Platforms attempt every member (sequentially or concurrently) instead of stopping at the first failure, and own dialogs and failure notifications. Duplicate or empty identities, missing or extra results, and more than 10,000 documents are `invalid_request`. -See `shared/fixtures/documents/save-batch-v1.json`. +See `rust/fixtures/documents/save-batch-v1.json`. GitHub command shapes, authorization behavior, and supported pull-request operations are documented in [`github.md`](github.md). Rust Core performs no @@ -1985,13 +1985,13 @@ to …`) reports `operationError.code = "git_authentication_failed"`. When Core adds that error to a response that had none, `message` is the first stderr line and `details` the combined output; an existing message and details are kept. Other failures keep their codes, so hosts must not match stderr themselves. -Samples: `shared/fixtures/git/authentication-failure-v1.json`. +Samples: `rust/fixtures/git/authentication-failure-v1.json`. `git.remoteUrl` accepts `{ root, remote }` and returns `{ url: string | null }`. It uses the internal configuration-read path: a missing key is successful and produces no command row. Invalid requests and real access/executable failures remain structured errors. The configured value is used for repository discovery; -it is not console output. See `shared/fixtures/git/remote-url-v1.json`. +it is not console output. See `rust/fixtures/git/remote-url-v1.json`. `git.executionInspect` takes `{ root, scope?: "local" | "global" }`, default local. It returns `{ executable, version, scope, entries, fields, temporaryConfig, @@ -2016,7 +2016,7 @@ Git's explicit tag refspec and `pruneTags` may fetch tags even with `--no-tags`; partial or negative tag refspecs are reported as `custom` rather than guessed. All occurrences of relevant boolean settings are checked with Git's parser so an invalid shadowed value is not silently interpreted as valid. Inputs and -outputs are illustrated by `shared/fixtures/git/fetch-behavior-v1.json`. +outputs are illustrated by `rust/fixtures/git/fetch-behavior-v1.json`. `git.executionConfigure` adds `{ key, value: string | null, expectedValues: string[] }` to the same request. It permits `fetch.prune`, `fetch.prunetags`, @@ -2038,8 +2038,8 @@ answer `"retry"` to repeat the failed transfer with helper bypass, at most three total attempts. The AskPass C ABI is called only in the child app's early helper mode. Credential answers are never copied into events or settings. -See `shared/fixtures/git/{fetch-plan,execution-events,execution-policy}-v1.json` -and [Git execution and project console](../../.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md). +See `rust/fixtures/git/{fetch-plan,execution-events,execution-policy}-v1.json` +and [Git execution and project console](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-09-12-git-execution-and-project-console.md). The additive `lithe_core_execute_json_with_events(request, callback, context)` C ABI and Rust `execute_json_with_events` API deliver sanitized Git diagnostics @@ -2081,7 +2081,7 @@ had started a command keeps that command with the cancellation as its reason. A request-start event must not allocate a placeholder or consume command-history capacity. Clearing history suppresses late invocations from requests already in preflight, while started operations remain cancellable until `requestFinished`, even after their -text is cleared. See `shared/fixtures/git/console-lifecycle-v1.json`. +text is cleared. See `rust/fixtures/git/console-lifecycle-v1.json`. Console presentation entries include an optional `notice`: `waitingForOutput`, `completedWithoutOutput`, or `fetchUnchanged`. Native hosts localize this as a status line, separate from retained Git stdout/stderr and copied command output. @@ -2118,7 +2118,7 @@ A complete line is redacted before publication. Native diagnostic limits are 16 KiB per line, 512 KiB raw diagnostic input and 4,096 output events per invocation. Limits produce an omission record without suppressing completion. Raw parser capture has an independent 32 MiB per-stream bound and fails on -overflow. See `shared/fixtures/git/execution-events-v1.json`. +overflow. See `rust/fixtures/git/execution-events-v1.json`. `git.rollbackPlan` accepts `changes` (up to 10,000 entries, each containing a repository-relative `path`, optional `originalPath`, and the two-character Git @@ -2211,7 +2211,7 @@ occupied branch. `detached` rejects `name` and requires a typed reference or and uses `--detach`. Independent `noCheckout: true` adds `--no-checkout` in every mode; its default is false, and legacy `checkout` does not control worktree file population. Examples are in -`shared/fixtures/git/worktree-creation-v1.json`. Worktree mutations re-read Git's +`rust/fixtures/git/worktree-creation-v1.json`. Worktree mutations re-read Git's registered list before invoking Git, and each refusal has its own `operationError.code`, so hosts explain it without matching messages: @@ -2258,8 +2258,8 @@ trace. A stash restore conflict is a logical operation failure represented by `stashRestore`, even when a later diagnostic invocation exits successfully. Consumers must therefore consider `operationError` and `stashRestore` in addition to the compatibility `exitCode`. The shared compatibility fixtures are -`shared/fixtures/git/command-response-v1.json` and -`shared/fixtures/git/command-error-response-v1.json`. Invalid arguments found +`rust/fixtures/git/command-response-v1.json` and +`rust/fixtures/git/command-error-response-v1.json`. Invalid arguments found before any Git subprocess use the standard `invalid_request` error envelope. `checkout` uses `referenceKind` values `local`, `remote`, or `tag`. A remote checkout creates its tracking local branch @@ -2273,7 +2273,7 @@ The selected remote commit is pinned and branch identity is rechecked before integration. A composite checkout may switch successfully before a later integration or stash restore fails, so consumers refresh repository state after either outcome. Local checkout and `checkoutAndRebase` retain their existing policies. Scenarios are -in `shared/fixtures/git/remote-checkout-v1.json`. +in `rust/fixtures/git/remote-checkout-v1.json`. `clone` uses `remote` as its source and `destination` as its target path. `publishBranch` validates `name`, creates and checks out that branch at a detached HEAD when needed, then pushes it with @@ -2285,7 +2285,7 @@ legacy `reference`, an optional bounded `limit`, `pushTags`, and an optional `pushTarget`. It returns `localBranch`, `localHead`, `remote`, `remoteBranch`, `targetKind`, `createsRemoteBranch`, nullable `remoteTrackingOid`, nullable `upstream`, exact reviewed `tags`, -`commits`, and `hasMore` using `shared/fixtures/git/push-preview-v1.json`. +`commits`, and `hasMore` using `rust/fixtures/git/push-preview-v1.json`. `targetKind` describes how `remoteBranch` relates to the local branch: `tracking` when both names match, `publish` when the branch has no upstream on the selected remote and will be published under its own name, and `divergent` when the @@ -2431,7 +2431,7 @@ not permission to mutate: `git.historyRewritePreview` still checks worktree, operation state, unsupported commits, published history, and the reviewed snapshot. Clients cancel and discard stale results when the menu closes, selection or repository changes, or the history is refreshed. See -`shared/fixtures/git/history-action-capabilities-v1.json`. +`rust/fixtures/git/history-action-capabilities-v1.json`. `git.historyRewritePreview` accepts `{ "root": string, "operation": string, "revisions": string[] }` for those four operations. It returns `operation`, @@ -2485,7 +2485,7 @@ while preserving `mutationApplied: true` and the recovery point. For these structured responses, compatibility process fields describe the authoritative mutation result instead of a later recovery-cleanup subprocess; diagnostics remain available in `invocations`, `operationError`, and `warnings`. -The compatibility fixture is `shared/fixtures/git/history-rewrite-v1.json`. +The compatibility fixture is `rust/fixtures/git/history-rewrite-v1.json`. `createTag` uses `name` for the new tag, `revision` as its target commit or revision, and an optional `message`: when the field is present (including an @@ -2501,9 +2501,9 @@ annotated tag. Two optional typed fields refine that legacy choice: subprocess; `signed: true` adds `-s`, which creates a signed annotated tag when a message is present. Omitting both fields preserves the existing behavior, so callers that predate the fields are unaffected. The argument -matrix is pinned by `shared/fixtures/git/tag-write-v1.json`. +matrix is pinned by `rust/fixtures/git/tag-write-v1.json`. Tag names must satisfy the `git check-ref-format` refname rules and must not -begin with a dash; `shared/fixtures/git/tag-names.json` pins the boundary cases +begin with a dash; `rust/fixtures/git/tag-names.json` pins the boundary cases for Core and host-side validation. Before invoking Git, `createTag` probes the repository so a duplicate tag (`A tag named '' already exists`) and an unresolvable non-commit target (`Could not resolve tag target ''`) fail with stable @@ -2563,7 +2563,7 @@ The strategy is the user's choice, else the preflight's `configuredStrategy`, else `ffOnly`; a diverged history that would need `ffOnly` asks when `interactive` and is refused otherwise. `{ "stage": "defaultStrategy", "preflight", "fastForwardPossible" }` returns the strategy a Pull dialog -preselects. `shared/fixtures/git/update-plan-v1.json` defines the rules. +preselects. `rust/fixtures/git/update-plan-v1.json` defines the rules. `git.historyWindow` is a pure planner over commit identities in display order. `refreshPlan` returns the first-page `limit`: with `preserveLoaded` it is at @@ -2573,7 +2573,7 @@ commits appeared at the head. `mergePage` returns the `append` positions of a page that are not yet in the window and whether `hasMore`, bounded to 5000 commits. `reconcileSelection` returns the retained `selected` identities in window order plus `focused` and `anchor`, falling back to the first loaded -commit. `shared/fixtures/git/history-window-v1.json` defines the rules. +commit. `rust/fixtures/git/history-window-v1.json` defines the rules. `git.graphProjection` is a pure projection of one loaded history window; it never starts Git. The request is `{ "commits": [{ "hash", "parentHashes", "decorations" }], @@ -2596,8 +2596,8 @@ Each visible row carries `commitHash`, integer `lane` and `laneCount`, a signed `colorId`, `isDotted`, `isMissing`, `hasArrow`, `isTerminal`, `targetHash`). Hosts own pixels, curves, and the mapping from a color ID to a theme color; `colorId` `0` means an unreferenced fragment. Requests over 20,000 commits fail with `invalid_request`. -`shared/fixtures/git/graph-projection-v1.json` defines the rules and -`shared/fixtures/git/graph-idea/` holds the pinned IntelliJ oracles. +`rust/fixtures/git/graph-projection-v1.json` defines the rules and +`rust/fixtures/git/graph-idea/` holds the pinned IntelliJ oracles. `git.integrationPreflight` accepts either `reference` or `gitReference` with `root` and `operation`, where `operation` is `merge`, `rebase`, `cherryPick`, @@ -2652,7 +2652,7 @@ files. `includeUntracked: true` adds untracked worktree files with status `A` and `isUntracked: true`, deduplicated by path; omitted or false keeps the previous response. The flag is invalid for a two-reference range. If status cannot be read, the request fails rather than returning an incomplete result. -See `shared/fixtures/git/comparison-untracked-v1.json`. Platforms must not +See `rust/fixtures/git/comparison-untracked-v1.json`. Platforms must not construct a two-ref range themselves. `git.apply` accepts `root`, `patch`, and `mode`; supported modes are `stage`, `unstage`, `discard`, `restoreIndex`, `worktree`, `restoreIndexCheck`, and @@ -2687,7 +2687,7 @@ refuses with `GIT_DIFF_BLOCK_STALE` when the token no longer matches, modifiedStart, modifiedEnd, originalLines, modifiedLines}` (one-based, end-exclusive) and stages exactly that change from the original bytes, or fails with `GIT_GUTTER_CHANGE_STALE` when the index or saved file no longer shows the -compared lines. See `shared/fixtures/git/diff-blocks-v1.json`. +compared lines. See `rust/fixtures/git/diff-blocks-v1.json`. `git.remotes` returns `{remotes: [{name, url}]}` for fetch entries. `git.tags` returns `{tags: [{name, objectId, targetId, message, date, isAnnotated}]}`; @@ -2704,12 +2704,12 @@ Core performs no IO. The ordered `events` array returns `kind` (`context`, ownership of native loss flags, watch lifetimes, and workspace visibility; on classification failure they must conservatively rescan. Native Rust callers can use the same typed classifier without JSON. The shared fixture is -`shared/fixtures/git/metadata-classification-v1.json`. +`rust/fixtures/git/metadata-classification-v1.json`. `git.parseCheckoutMetadata` and `git.parseGitdirPointer` each accept `{text}` of at most 4096 UTF-8 bytes and return `{branch}` or `{path}`, respectively; the host reads files and resolves relative paths. Examples live in -`shared/fixtures/git/remote-tag-catalog-v1.json` and `checkout-metadata-v1.json`. +`rust/fixtures/git/remote-tag-catalog-v1.json` and `checkout-metadata-v1.json`. `git.history` accepts `root`, an optional full `reference`, and `limit` (the core clamps it to `1...5000`). It remains the compatibility command that @@ -2724,7 +2724,7 @@ and Windows Git Log request date order for their pages and repository graph; other clients that omit the field retain topology order. A cursor is bound to its root, reference, and order; continuations must repeat the same order. A mismatched order returns `invalid_request` without consuming the cursor. The portable request example is -`shared/fixtures/git/history-page-date-request-v1.json`. +`rust/fixtures/git/history-page-date-request-v1.json`. The first request omits `cursor`; each later request returns the prior page's `nextCursor`. Core keeps one bounded, backpressured `git log` stream behind that cursor and clamps the stream to the first 5,000 @@ -2748,8 +2748,8 @@ missing metadata using invocation-only Git configuration; it does not change repository fetch settings, guess tracking from matching names, or fetch remotely. References without an upstream, remote references, and tags return zero for both fields. Portable examples are -`shared/fixtures/git/references-response-v1.json` and -`shared/fixtures/git/history-page-response-v1.json`. +`rust/fixtures/git/references-response-v1.json` and +`rust/fixtures/git/history-page-response-v1.json`. For compatibility, a request that explicitly contains the deprecated numeric `offset` field still uses the bounded offset implementation and returns @@ -2776,7 +2776,7 @@ instead of becoming "not found". Core owns the intermediate stream and only returns a cursor the host can continue; a failed read releases it. Requests without a cursor start at the first page and continuations must repeat the same `root`, `reference`, and `order`. Portable request and response examples are in -`shared/fixtures/git/history-locate-v1.json`. +`rust/fixtures/git/history-locate-v1.json`. `git.repositoryColorAssignments` accepts `{ offset, totalCount, repositoryIds }`. IDs are the host's already normalized, unique repository identities in the full @@ -2787,7 +2787,7 @@ Core rejects duplicates within a page and out-of-range offsets with `fallbackSlot` for unknown IDs, and `showColors` for multi-repository views. Callers must keep pages from the same list generation together, cancel or discard stale results, and never recompute the slots per row. Color palettes remain native -presentation. See `shared/fixtures/git/repository-colors-v1.json`. +presentation. See `rust/fixtures/git/repository-colors-v1.json`. `git.branchNamePreview` is pure: it never starts Git and only reads the reference facts the host already loaded. It accepts `rawName` (at most 1,024 @@ -2812,7 +2812,7 @@ without a reference. `error.code` is one of `empty`, `head`, `clashesWithBranchPrefix` also carries the existing local `branch` that occupies one of the candidate's slash prefixes. Refname validity itself stays with `git check-ref-format` when the branch is created. Portable examples are in -`shared/fixtures/git/branch-name-preview-v1.json`. +`rust/fixtures/git/branch-name-preview-v1.json`. `git.commit` accepts `root` and a revision, returning one `commit` object with the same fields as a history page entry and a `body` string. `body` is the @@ -2820,7 +2820,7 @@ message after the subject paragraph (Git `%b`), keeps internal line breaks and indentation, has trailing whitespace removed, and is empty for a subject-only message. History pages carry only `subject`; a commit detail view reads the body on demand with `git.commit`. See -`shared/fixtures/git/commit-lookup-response-v1.json`. +`rust/fixtures/git/commit-lookup-response-v1.json`. `git.blame` accepts `root` and a workspace-relative `path`; its line numbers are one-based and author timestamps are Unix seconds. @@ -2878,9 +2878,9 @@ names-only rule variant; superseded allocations are released when active requests finish. Watcher updates refresh the retained variants. Agent file completion uses project visibility and leaves generated names disabled; only the Search Everywhere UI explicitly enables them. Directory-versus-file -matching examples are shared in `shared/fixtures/search/exclusions.json`. +matching examples are shared in `rust/fixtures/search/exclusions.json`. -`workspace.searchEverywhere` takes an optional `context: { openPaths, recentPaths }` (paths relative to `root`, `/` separators, recent first). It only breaks ties between equal file-name matches (open first, then recent by recency) and scales the returned `matchingDegree` by 1024 plus the tie-break, using the same code as `workspace.rankNames`; omit it and results are unchanged (fixture `shared/fixtures/search/search-everywhere-context-v1.json`). +`workspace.searchEverywhere` takes an optional `context: { openPaths, recentPaths }` (paths relative to `root`, `/` separators, recent first). It only breaks ties between equal file-name matches (open first, then recent by recency) and scales the returned `matchingDegree` by 1024 plus the tie-break, using the same code as `workspace.rankNames`; omit it and results are unchanged (fixture `rust/fixtures/search/search-everywhere-context-v1.json`). `workspace.searchEverywhere` uses the same query options and visibility fields, and additionally accepts `maxSymbolResults` and optional @@ -2947,7 +2947,7 @@ incremental or automatically paged Windows presentation. Basename matches rank above relative-path fallback matches, even when the query contains no slash (for example `src` matches `src/Foo.swift`). Path-only matches have `matchingDegree: -1` and no basename ranges; literal basename hits use degree -zero when there is no fuzzy degree. See `shared/fixtures/search/path-fallback.json`. +zero when there is no fuzzy degree. See `rust/fixtures/search/path-fallback.json`. Name matches include optional `matchingDegree` and `nameMatchRanges` entries `{ start, length }`, measured in UTF-16 units in the displayed basename or @@ -2957,7 +2957,7 @@ content matches keep their literal search semantics. Case-insensitive contiguous fallback preserves whole-string Unicode lowercase matching, including expansions such as `İ` to `i` plus a combining dot. A partial match inside an expansion highlights the complete original character, not folded -offsets. See `shared/fixtures/search/unicode-contiguous.json` (also used by Agent +offsets. See `rust/fixtures/search/unicode-contiguous.json` (also used by Agent completion regressions). Returned Everywhere matches additionally expose optional `isIgnored`, resolved @@ -2984,7 +2984,7 @@ files or resources are written to disk. macOS Search Everywhere requests pages o Java type and method results still use the existing syntax index and include a one-based line, `symbolName`, and source line in `preview`; language-provider workspace-symbol integration and external-library search are not implied. -The shared name/range examples are in `shared/fixtures/search/name-matching.json`. +The shared name/range examples are in `rust/fixtures/search/name-matching.json`. `workspace.replacePreview` accepts `root`, `query`, `replacement`, the same query options, optional workspace-relative `paths`, optional `textOverrides` @@ -3004,7 +3004,7 @@ per file and 16 MiB in total) and returns unmodified paths, matching lines, UTF-16 ranges and requested surrounding lines. The host owns provider I/O and controls continuation between batches. Examples for these commands and filtered content pages live in -`shared/fixtures/search/text-snapshot-v1.json`. +`rust/fixtures/search/text-snapshot-v1.json`. `lsp.applyTextEdits` accepts `{ "text": string, "edits": [] }`, where each edit has an LSP range with zero-based `line` and UTF-16 `utf16Column` fields plus @@ -3014,7 +3014,7 @@ details `invalidRange`. Successful responses return `{ "text": string }`. Windows workspace edits and formatting use this command instead of calculating text offsets locally. The cross-platform cases in -`shared/fixtures/lsp/text-edits-v1.json` use LSP wire `character` fields; the +`rust/fixtures/lsp/text-edits-v1.json` use LSP wire `character` fields; the Windows adapter maps those fields to Core `utf16Column` without changing their zero-based values. Core retains its existing normalization: columns beyond a line clamp before its line ending, and positions inside a UTF-16 surrogate pair @@ -3050,7 +3050,7 @@ editIndex, otherEditIndex }` with `reason` `rangeOutOfBounds`, `reversed`, for a failed extra position. An empty batch still reports the line ending. Malformed payloads return `invalid_request`. The host keeps document versions, undo grouping and the editor surface. See -`shared/fixtures/editor/text-edit-plan-v1.json`. +`rust/fixtures/editor/text-edit-plan-v1.json`. `lsp.planWorkspaceEdit` accepts `{ platform, workspaceRoot, changes, openDocuments? }`, where `changes` maps a file URI or absolute native path to @@ -3063,7 +3063,7 @@ null and each target is `{ path, relativePath, effect, edits }` with `effect` group ordered by `relativePath`; keys naming one file merge. The host reads and writes files, owns buffers, stale-input checks and rollback, and applies each target's edits with `lsp.applyTextEdits`. See -`shared/fixtures/lsp/workspace-edit-plan-v1.json`. +`rust/fixtures/lsp/workspace-edit-plan-v1.json`. `lsp.classifyNavigationTarget` accepts `{ platform, workspaceRoot?, resolvedWorkspaceRoot?, path, resolvedPath?, sourceLanguageID?, languageID?, @@ -3071,7 +3071,7 @@ resolvedLanguageID?, isReadOnly? }`; the host supplies symlink resolution and the path-to-language lookup as facts. It returns `{ classification, isReadOnly }` where `classification` is `workspace`, `externalReadOnly` (a source file of the language that produced the jump, opened read-only) or -`rejected`. See `shared/fixtures/lsp/navigation-target-v1.json`. +`rejected`. See `rust/fixtures/lsp/navigation-target-v1.json`. `editor.parseNavigationInput` accepts `{ text }` and returns `{ target }`. `target` is null for invalid input, otherwise `{ line, column, selectsWholeLine }` @@ -3081,7 +3081,7 @@ extra characters, zero, negatives and values above JavaScript's maximum safe integer are invalid. Text over 128 UTF-8 bytes is invalid. A line-only input selects its content without the terminator; an explicit column places the caret. Hosts clamp against the current document, not an old snapshot. See -`shared/fixtures/editor/navigation-input-v1.json`. +`rust/fixtures/editor/navigation-input-v1.json`. `editor.navigationHistory.reduce` accepts `{ state, event }` and returns `{ state, destination }`. `state` is `{ back, forward }`, each ordered oldest @@ -3097,7 +3097,7 @@ push `current` onto the opposite stack so caret movement since the last jump survives a return trip. Each stack keeps the newest 100 entries. Platforms decide which gestures record history and restore their previous state when the destination cannot be opened. See -`shared/fixtures/editor/navigation-history-v1.json`. +`rust/fixtures/editor/navigation-history-v1.json`. `editor.lineEdit` applies one deterministic line-level transform. All offsets are UTF-16 code units so both hosts can feed the result straight into their @@ -3124,7 +3124,7 @@ macOS consumes both commands today. Windows still uses its local TypeScript implementations (`comment-toggle.ts`, `line-operations.ts`) and is expected to adopt the same contract in a follow-up change; the fixture pins the canonical behavior for that migration. Deterministic cases are pinned by -`shared/fixtures/editor/line-edit-v1.json`. +`rust/fixtures/editor/line-edit-v1.json`. `network.proxyPolicy` normalizes the stored proxy settings into the policy a network stack must apply. It accepts `{ "mode": "system" | "direct" | @@ -3150,7 +3150,7 @@ dropped, and passwords keep every character. A custom server without a host or port normalizes to `policy: "system"` with `configured: false`; system and direct never carry credentials, so hosts can forward the plan to their network stack without a second credential-scoping decision. Deterministic cases are -pinned by `shared/fixtures/network/proxy-policy-v1.json`. +pinned by `rust/fixtures/network/proxy-policy-v1.json`. The `debug.*` commands are the shared Debug Adapter Protocol boundary. Rust owns DAP framing, request sequences, response correlation, initialization and @@ -3165,7 +3165,7 @@ path, or scoped address; `port` is an integer from 1 through 65535. It returns `{ host, port, remote }`, preserving the input. `localhost` (case insensitive), 127.0.0.0/8 and `::1` are loopback; every other spelling requires remote consent, without resolving DNS. Invalid inputs return `invalid_request`. -Compatibility cases live in `shared/fixtures/debug/adapter-endpoints-v1.json`. +Compatibility cases live in `rust/fixtures/debug/adapter-endpoints-v1.json`. The platform obtains approval before connecting a non-loopback endpoint, once per connection with no remembered approval, and warns that plain TCP provides neither authentication nor encryption. This endpoint is the **IDE-to-adapter** @@ -3271,7 +3271,7 @@ before dispatching them or touching native timer handles. Host-side Core call failures must tear down native resources for `send` as well as callback events; an error observer restarting the stream invalidates the old termination too. Scripted scenarios, with the exact steps expected, -live in `shared/fixtures/debug/transport-lifecycle-v1.json`; platform adapter +live in `rust/fixtures/debug/transport-lifecycle-v1.json`; platform adapter tests can replay them. `debug.createSession` accepts `{ sessionId, adapterId, rootPath, @@ -3297,7 +3297,7 @@ The platform launches the process through its PTY/ConPTY adapter and calls shellProcessId?, message? }`. Core validates process identifiers, emits the DAP response, ignores duplicate or expired completions, and fails pending terminal requests when the session disconnects. The shared compatibility cases are in -`shared/fixtures/debug/run-in-terminal-v1.json`. +`rust/fixtures/debug/run-in-terminal-v1.json`. `debug.launch` accepts an `operationId` and a language-neutral configuration containing `name`, request kind (`launch` or `attach`), provider arguments, and @@ -3318,7 +3318,7 @@ another adapter's policy. Adapter defaults come from the plugin's declared debug-adapter defaults, and class patterns are passed through to the adapter unchanged, so an adapter may use its own placeholder and wildcard syntax. The portable cases are in -`shared/fixtures/debug/stepping-filters-v1.json`. +`rust/fixtures/debug/stepping-filters-v1.json`. Normalized stack frames include `isFiltered`. Core derives it from the active class filters using the DAP frame name, source path, presentation hint, and @@ -3396,7 +3396,7 @@ instead of showing a made-up file or line 1. A scope whose DAP `presentationHint` is `registers` is passed through with that hint; the host has no register table of its own, so register names, groups, and values are the adapter's. The compatibility cases are in -`shared/fixtures/debug/projection-v2-examples.json`. +`rust/fixtures/debug/projection-v2-examples.json`. `debug.setVariable` accepts the selected variable's parent `variablesReference`, name, and replacement text. Core permits mutation only while paused and after @@ -3445,7 +3445,7 @@ fields for every other inspection kind. Normalized scopes, variables, evaluations, and variable-mutation results include non-negative `namedVariables` and `indexedVariables` counts, using zero when the adapter omits or reports an invalid negative value. The compatibility cases are in -`shared/fixtures/debug/variable-paging-v1.json`. +`rust/fixtures/debug/variable-paging-v1.json`. An `evaluate` inspection may carry `context` (`watch`, `hover`, `repl`, `clipboard`, or `variables`) and defaults to `watch`. Native consoles send @@ -3484,10 +3484,10 @@ so later location-free `breakpoint` events preserve both the requested anchor an resolved location. Responses to superseded per-file sets are discarded. Platforms persist the requested anchor and may display the resolved line; placement alone does not imply that bytecode was bound or that execution will suspend there. -The correlation fixture is `shared/fixtures/debug/breakpoint-binding-v1.json`. +The correlation fixture is `rust/fixtures/debug/breakpoint-binding-v1.json`. The compatibility flow is captured in -`shared/fixtures/debug/dap-session-v1.json`; exception normalization cases are -captured in `shared/fixtures/debug/exception-info-v1.json`. +`rust/fixtures/debug/dap-session-v1.json`; exception normalization cases are +captured in `rust/fixtures/debug/exception-info-v1.json`. `debug.disconnect` emits the protocol handshake and enters `terminating`. `debug.disconnect` accepts an optional `terminateDebuggee: boolean`. When it is @@ -3498,7 +3498,7 @@ keep a local debuggee running. An `attach` session never terminates the external process, even when `terminateDebuggee: true` is sent. This prevents a remote detach from killing a JVM the IDE does not own. The compatibility cases are in -`shared/fixtures/debug/disconnect-policy-v1.json`. The platform keeps the +`rust/fixtures/debug/disconnect-policy-v1.json`. The platform keeps the socket or process alive long enough to flush the frame, then closes it and calls `debug.destroySession`. A session allocates no process, socket, timer, or background task, and no session exists until Debug is used. @@ -3526,7 +3526,7 @@ candidate is dropped when a higher-priority provider already offered the same label. Only labels cross the boundary and the platform applies the indices to its full items. A host with a single completion provider (Windows today) keeps that provider's list unchanged and does not call the command. Cases: -`shared/fixtures/lsp/completion-merge-v1.json`. +`rust/fixtures/lsp/completion-merge-v1.json`. `lsp.activationPolicy` accepts `{ state, event, nowMs }` and returns `{ state, decision, retryAfterMs }`. `event` is `automaticStart`, @@ -3538,7 +3538,7 @@ wait 5000 ms, and after 3 consecutive failures only an explicit start (restart, retry, or repair) may run. A ready session or an explicit start clears the history. `nowMs` must come from a monotonic clock; a reading earlier than the stored failure restarts the cooldown. Cases: -`shared/fixtures/lsp/activation-policy-v1.json`. +`rust/fixtures/lsp/activation-policy-v1.json`. `lsp.problems.query` accepts the displayed diagnostics as `{ fileKey, displayPath, line, column, severity, message, source, code }` @@ -3551,7 +3551,7 @@ stable `key`, `indices` into the submitted list, and per-severity `counts`), identity; `displayPath` is the workspace-relative path and orders files with the Project view's natural file-name order. Severity is the LSP number; missing or out-of-range values are unknown (`0`) and rank last. Cases: -`shared/fixtures/lsp/problems-query-v1.json`. +`rust/fixtures/lsp/problems-query-v1.json`. The LSP provider catalog is returned by `lithe_core_lsp_provider_catalog_json`. Each provider descriptor may include `languageServerLaunch` with ordered @@ -3559,7 +3559,7 @@ Each provider descriptor may include `languageServerLaunch` with ordered need to discover a real language-server executable; the selected launch plan is then submitted to the Rust-owned runtime. Built-in descriptors are merged by provider ID with the optional `.lithe/lsp/language-providers.json` workspace document. See -[`.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md`](../../.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md) +[`.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md) for routing, discovery, lifecycle, and compatibility rules. The `lsp.*Server`, `lsp.*Document`, `lsp.request`, `lsp.pollEvents`, and @@ -3710,7 +3710,7 @@ and retain the final diagnostic snapshot in `underlyingMessage` with transfer activity; it does not prove a JVM deadlock. Quiet warnings preserve the initializing/preparation state. Explicit server errors and exits still fail immediately. Shared timing examples live in -`shared/fixtures/lsp/jdt-readiness-v1.json`. +`rust/fixtures/lsp/jdt-readiness-v1.json`. Platform adapters own filesystem discovery and validate that packaged JDT LS contains the Equinox launcher, platform configuration directory, Lombok agent, @@ -3758,7 +3758,7 @@ session's `info` log event. Each build-file observation contains only a workspace-relative `path`; `gradleWrapperDistribution`, when sent, must not be blank. It returns `{ workspaceFingerprint }`; platforms must not recreate or parse this opaque string. Compatibility cases are in -`shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json`. +`rust/fixtures/lsp/jdt-workspace-fingerprint-v1.json`. `lsp.jdtWorkspaceKey` accepts `{ workspaceRoot, workspaceFingerprint? }` and returns `{ workspaceKey }` through the same normalization and SHA-256 algorithm @@ -3783,7 +3783,7 @@ nested-class `$` and `.` treated alike. A class is `failed` when any recorded method under it (including nested classes) failed and `passed` when at least one passed and none failed. Items without a source range, such as inherited test methods, get no marker. The command is pure and never reads the file -system. `shared/fixtures/java/run-markers-v1.json` is the compatibility fixture. +system. `rust/fixtures/java/run-markers-v1.json` is the compatibility fixture. `java.workspacePolicy` accepts `workspacePaths` and `changedPaths` as workspace-relative paths. It starts Java tooling when a non-ignored `.java` @@ -3796,7 +3796,7 @@ therefore does not activate Java tooling; hosts still start a language server on demand when the user opens a `.java` file. The command chooses one deterministic representative source and classifies changes as `ignored`, `source`, `buildConfiguration`, or `other`. The compatibility examples are in -`shared/fixtures/lsp/java-workspace-policy-v1.json`. +`rust/fixtures/lsp/java-workspace-policy-v1.json`. `java.jdtCacheRetention` accepts platform-observed cache directory metadata as `{ nowUnixSeconds, activeWorkspaceKey?, entries }`. Each entry contains a @@ -3806,7 +3806,7 @@ observations using the newest timestamp, never selects the active key, and returns deterministically sorted `expiredWorkspaceKeys` older than the fixed 30-day retention period. Platform adapters own the last-used marker, directory enumeration, revalidation, deletion, and error logging; Core performs no cache -filesystem I/O. See `shared/fixtures/lsp/jdt-cache-retention-v1.json`. +filesystem I/O. See `rust/fixtures/lsp/jdt-cache-retention-v1.json`. `lsp.documentIdentity` accepts `{ workspaceRoot, input, platform }` with the same `posix | windows | auto` path syntax. It returns `{ document: { path, relativePath } | null }`. @@ -3818,7 +3818,7 @@ non-file URIs, URI queries/fragments, drive-relative/rooted-without-drive Window paths, the workspace root itself and outside targets return null. Native roots must be absolute; invalid requests return `invalid_request`. This is lexical placement only: it performs no I/O and grants no symlink or file access permission. -See `shared/fixtures/lsp/document-identity-v1.json`. +See `rust/fixtures/lsp/document-identity-v1.json`. `lsp.documentUri` accepts `{ path, platform }` and returns `{ uri }`. `platform` is `posix`, `windows`, or `auto`; `auto` recognizes drive and UNC paths and @@ -3826,7 +3826,7 @@ otherwise treats an absolute path as POSIX. Paths must be absolute, contain no NUL and fit within 131,068 UTF-8 bytes. Windows extended drive and UNC prefixes are accepted. Core resolves lexical dot segments, preserves a trailing directory separator, and percent-encodes reserved characters and UTF-8. Invalid paths -return `invalid_request`. See `shared/fixtures/lsp/document-uri-v1.json`. +return `invalid_request`. See `rust/fixtures/lsp/document-uri-v1.json`. The `rootUri` of `lsp.startServer` and the `uri` fields of `lsp.syncDocument`, `lsp.saveDocument`, `lsp.closeDocument`, `lsp.request`, `java.navigationMarkers`, @@ -3870,7 +3870,7 @@ save notifications for clean open buffers and propagate transport failures instead of treating disk persistence alone as launch readiness. This is distinct from `didChange`: JDT reloads its on-disk working copy on save, which makes a subsequent Java launch build observe saved dependency edits. -Shared cases: `shared/fixtures/lsp/document-save-v1.json`. +Shared cases: `rust/fixtures/lsp/document-save-v1.json`. `lsp.workspaceFilesChanged` accepts a session ID and ordered file URI changes whose `kind` is `created`, `changed`, or `deleted`. It emits one @@ -3888,7 +3888,7 @@ the batch, and the latest completed version is cached per URI. Markers are sorted by line, UTF-16 column, and relation. A marker contains `direction` (`up` or `down`) and `relation` (`interface` or `inheritance`); zero-target declarations are omitted. The cross-platform examples are in -`shared/fixtures/lsp/java-navigation-v1.json`. +`rust/fixtures/lsp/java-navigation-v1.json`. `java.resolveNavigation` accepts the marker position, direction, relation, and document version. Downward markers use `textDocument/implementation`; upward @@ -3927,7 +3927,7 @@ passes to `build.decodeModel` or `build.decodeDependencies` as `result`; cancellation and late results follow `lsp.cancelOperation`. Execution replies are validated as `{exitCode: Int32, output: string}` and progress is mapped from a Core-owned wire request ID to `requestProgress` for the still-pending -operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution). +operation. See the [provider execution contract](../../shared/plugins/contract/v1/README.md#provider-task-execution). `lsp.toolchainRequest` accepts `{ sessionId, operationId?, params, timeoutMilliseconds? }` and returns `{ operationId }`. `lsp.startServer` takes @@ -3978,7 +3978,7 @@ It sends `lithe/function/invoke` to a session started with `params` must be a JSON object and defaults to `{}`. Refusals are `invalid_request`, `not_supported`, or `permission_denied` with a `plugin.function.*` code in `details`, in the order the -[plugin contract](plugins/v1/README.md#plugin-functions) defines. The +[plugin contract](../../shared/plugins/contract/v1/README.md#plugin-functions) defines. The `requestCompleted` event carries the backend result as `result.value`; a failed call carries `error.code` `plugin.function.failed`, `invalidResult`, or `revoked` and, when the backend supplied one, `error.pluginErrorCode`. @@ -4006,7 +4006,7 @@ the protocol only when it names a version the host offered. Sessions without registry, and publish no function-discovery events. A session that negotiated `litheFunctions` may send the -[host-service requests](plugins/v1/README.md#host-services) +[host-service requests](../../shared/plugins/contract/v1/README.md#host-services) its `hostServices` grant. A session that did not negotiate it gets no host services: its requests are answered `plugin.host.unavailable` (-32601) and its notifications are dropped. Core checks method, grant, and params, then @@ -4021,7 +4021,7 @@ are refused with `plugin.host.failed`. The error may also be `{ message, code?, details? }`: a host code reaches the backend with its own JSON-RPC number, and `details`, an object of at most 4 KiB, as `error.data.details` -([failure codes](plugins/v1/README.md#workspace-contexts-and-capability-discovery)). +([failure codes](../../shared/plugins/contract/v1/README.md#workspace-contexts-and-capability-discovery)). The macOS public document/file/window adapters use method-level grants. Core intersects grants with its method table for capability discovery; macOS first @@ -4033,7 +4033,7 @@ semantics. `lsp.hostNotify` accepts `{ sessionId, method, params }` for a ready plugin-owned session and returns `{ delivered }`. Core sends the -[host notification](plugins/v1/README.md#host-notifications) +[host notification](../../shared/plugins/contract/v1/README.md#host-notifications) when the session negotiated `litheFunctions` and is granted its service, and returns `delivered: false` without sending when it did not. Another method, an ungranted service, malformed or oversized params, a session that is not @@ -4060,7 +4060,7 @@ with an error, in `details.code` and `details.message`), `plugin.host.methodNotDeclared` (-32024), `plugin.host.foreignContribution` (-32026), `plugin.host.timeout`, `plugin.host.cancelled`, `plugin.host.busy`, and `plugin.host.limitExceeded`. The -[plugin contract](plugins/v1/README.md#language-server-host-methods) owns their +[plugin contract](../../shared/plugins/contract/v1/README.md#language-server-host-methods) owns their meaning. `lsp.pluginServerNotify` accepts `{ sessionId, pluginId, serverID, method, @@ -4079,7 +4079,7 @@ remote, before any process is started. A session that negotiated `litheFunctions` and holds the `functions` grant may call another plugin's exported function through the -[plugin-to-plugin relay](plugins/v1/README.md#cross-plugin-calls). +[plugin-to-plugin relay](../../shared/plugins/contract/v1/README.md#cross-plugin-calls). Core runs the whole relay: it checks the declarations, keeps the call chain, writes the forwarded request with the `caller` the callee receives, and answers the caller. `lsp.configureFunctionRelay` @@ -4129,7 +4129,7 @@ objects returned by Core: nullable integer `severity`, `tags`, and `utf16Column` positions. A platform's display severity or marker projection is not a diagnostic request model. Non-LSP markers may request actions with a range and an empty diagnostic context. The round-trip fixture is -`shared/fixtures/lsp/diagnostic-code-actions-v1.json`. +`rust/fixtures/lsp/diagnostic-code-actions-v1.json`. `javaEntrypoints` invokes Java Debug Server's `vscode.java.resolveMainClass` for the session workspace and returns schema version 1 with deterministic workspace-relative `{ sourcePath, mainClass, @@ -4190,7 +4190,7 @@ scope is inferred from the dispatched command: `launchTarget` means the request named a project, not that Core observed Java Debug Server's final project selection. Elapsed time is evidence only and must not become a heuristic gate. The versioned examples are in -`shared/fixtures/lsp/java-build-report-v1.json`. +`rust/fixtures/lsp/java-build-report-v1.json`. Hosts treat these two terminal build outcomes as evidence rather than an irrevocable launch veto. After resolving the usable runtime paths, they may let @@ -4225,7 +4225,7 @@ retains the existing coordinator semantics and never starts classpath resolution Runtime path groups must contain nonblank strings, with at least one path overall. Hosts save documents before submitting and own the final launch decision, JDK discovery and native process. Run plan/toolchain selection remain in existing Core -operations. See `shared/fixtures/lsp/java-launch-preparation-v1.json`. +operations. See `rust/fixtures/lsp/java-launch-preparation-v1.json`. The `semanticTokens` operation uses the open document URI and normal version, timeout, and cancellation rules. Its result is @@ -4234,7 +4234,7 @@ Token positions use zero-based LSP lines and UTF-16 columns; `tokenType` indexes the returned legend and `tokenModifiers` is a UInt32 bitset. Core decodes the server's relative positions with the legend captured when the request was sent. Range-only providers are not advertised as supporting this operation. No delta -result ID crosses the boundary. See `shared/fixtures/lsp/semantic-tokens-v1.json`. +result ID crosses the boundary. See `rust/fixtures/lsp/semantic-tokens-v1.json`. The `semanticTokensRefresh` event invalidates the host's semantic color cache when the server requests `workspace/semanticTokens/refresh`; the request receives a JSON-RPC null acknowledgment. Windows maps `lsp_get_semantic_tokens` to this @@ -4284,7 +4284,7 @@ current visibility rules; similarly prefixed siblings are unchanged. Equal paths are a no-op; overlapping ancestor/descendant paths are rejected. `history.rename` and `history.delete` validate both the relative file path and entry ID before changing stored metadata. -File and subtree relocation cases are defined in `shared/fixtures/history/relocation-v1.json`. +File and subtree relocation cases are defined in `rust/fixtures/history/relocation-v1.json`. A `hiddenFilePatterns` entry starting with `!` turns a rule off instead of adding one: `!**/name/` or `!name` removes that hidden folder (built-in or not), @@ -4328,7 +4328,7 @@ newLineCount, additions, deletions }`. `rows` are side-by-side `rightChanges` under the same rules as `git.diff`. `lines` are unified `{ kind, content, oldLine, newLine }` with `kind` of `context`, `removed`, or `added`, each changed block listing its removals before its additions. See -`shared/fixtures/diff/compare-texts-v1.json`. +`rust/fixtures/diff/compare-texts-v1.json`. `maven.scan` accepts `{ "root": string, "paths"?: string[] }` and returns `null` when neither the root nor the supplied visible workspace-relative paths @@ -4353,7 +4353,7 @@ module cannot claim another module's source root. Entries are de-duplicated and ordered by the documented kind order, then path. Aggregator-only `pom` modules have no default roots. Module paths are relative to the selected Maven root and use `/` separators. Malformed XML returns `parse_failed`. Source-root examples -are in `shared/fixtures/maven/source-roots-v1.json`. +are in `rust/fixtures/maven/source-roots-v1.json`. `maven.launchPlan` accepts a workspace `root`, a versioned `context`, an optional reactor-relative `module`, and an ordered `goals` array whose first @@ -4374,7 +4374,7 @@ values override the project context, including `skipTests: false`. The core never reads `settings.xml` and never copies its path into a portable project document. Maven itself continues to read `.mvn/maven.config`; the plan does not expand or duplicate that file's arguments. Fixtures are in -`shared/fixtures/maven/launch-plan-v1.json`. +`rust/fixtures/maven/launch-plan-v1.json`. `maven.dependencyPlan` accepts the same workspace `root`, versioned `context`, optional reactor-relative `module`, and a required absolute `outputFile`. It @@ -4407,7 +4407,7 @@ node with only the annotations the pinned plugin writes; any other content, invalid UTF-8, a line over 4 KiB, more than 10,000 nodes, 64 levels, or a file over the byte limit derived from those bounds returns `parse_failed` instead of a partial tree. A missing file returns `process_failed`. The compatibility -fixture is `shared/fixtures/maven/dependency-tree-v2.json`. +fixture is `rust/fixtures/maven/dependency-tree-v2.json`. `maven.diagnostics` accepts `{ "root": string, "output": string }` and returns `{ "issues": [] }`. Diagnostic paths may be absolute or workspace-relative; @@ -4436,7 +4436,7 @@ platform's unconstrained choice; a malformed or unsupported `requirements` snapshot returns the parse/version error. Explicit configured paths bypass automatic selection. The operation never generates requirements or probes executables. The cross-platform examples are in -`shared/fixtures/run-configuration/automatic-java-selection.json`. +`rust/fixtures/run-configuration/automatic-java-selection.json`. Windows `run_resolve_toolchains` includes an optional `warning` on resolved JDKs, including inherited Maven JDKs. This warning remains visible even when no @@ -4453,7 +4453,7 @@ without changing source bytes. The command does not discover, probe or run any tool, inspect project descriptors, or write files. `runtimeExecutablePaths` comes from the existing `.lithe/toolchains/local.json`; platforms resolve the selected paths through their existing toolchain adapters. Shared examples are in -`shared/fixtures/run-configuration/project-toolchain-selection.json`. +`rust/fixtures/run-configuration/project-toolchain-selection.json`. When `configurationId` is supplied, explicit Java/Maven paths from that current Run configuration override the canonical project/reactor selection. Core reuses the existing team/local configuration merge without projecting global defaults @@ -4461,7 +4461,7 @@ as explicit configuration choices. Empty configuration fields inherit the canonical selection; missing, disabled, mismatched-root or malformed choices fail instead of falling back. No generated layer is required for an explicitly saved Provider reference. Shared cases are in -`shared/fixtures/run-configuration/configuration-toolchain-selection.json`. +`rust/fixtures/run-configuration/configuration-toolchain-selection.json`. `runConfig.inspect` also returns the local document-level `toolchain`, including when no generated configuration exists (`status: "missing"`). Settings, @@ -4656,7 +4656,7 @@ text, so a `static void main` or `@SpringBootApplication` inside a string literal or comment — common in test fixtures and documentation samples — does not become a run configuration. A declared `main` under `src/test` remains a valid entry and keeps the test classpath; see -`shared/fixtures/execution/maven-java-main-source-sets-v1.json`. +`rust/fixtures/execution/maven-java-main-source-sets-v1.json`. A process detector declares a runtime binding only when that command genuinely consumes the runtime. npm, pnpm, and Yarn scripts consume `project-node`; Bun @@ -4703,7 +4703,7 @@ fingerprints and the detected document remain unchanged by user-layer resolution See `fixtures/run-configuration/build-provider-stable-identities.json`. The platform guards the source snapshot, atomically writes only the local layer, -then re-reads the result. `shared/fixtures/run-configuration/build-provider.json` +then re-reads the result. `rust/fixtures/run-configuration/build-provider.json` is the canonical persistence example. `extensions.build-run` stores portable user choices separately from the selector: @@ -4779,7 +4779,7 @@ and is returned by inspection/resolution; hosts preserve it when saving a run configuration or project defaults. An absent map identifies a document predating subproject migration; a saved empty map is an explicit choice. Old platform preferences are copied and read back before cleanup, with already saved choices -remaining authoritative. See `shared/fixtures/run-configuration/subproject-toolchains.json`. +remaining authoritative. See `rust/fixtures/run-configuration/subproject-toolchains.json`. `execution.resolveSearchPath` accepts already-split `existing` directories and @@ -4787,13 +4787,13 @@ remaining authoritative. See `shared/fixtures/run-configuration/subproject-toolc first, then ID and path. The result appends candidates after existing directories, omits empty entries, and deduplicates exactly or with ASCII case folding when `caseInsensitive` is true. The host owns path splitting, joining, discovery, and -filesystem trust. See `shared/fixtures/execution/search-path-v1.json`. +filesystem trust. See `rust/fixtures/execution/search-path-v1.json`. `runConfig.argumentText` accepts `{ "argumentLists": [["--name", "Alice Smith"], []] }` and returns one editor string per input vector in the same order. Quoting and escaping are reversible by the existing run-configuration option parser; there is no shell expansion or execution. Empty arguments remain explicit. The shared cases are in -`shared/fixtures/run-configuration/argument-text.json`. +`rust/fixtures/run-configuration/argument-text.json`. `runConfig.saveEditorChanges` accepts the normal option-edit payload plus the required `toolchain` object. In addition to Java and Maven paths, that object @@ -5378,7 +5378,7 @@ Windows consumes the original fields and may ignore this marker. ### Declarative plugins The `plugins.declarative.*` commands implement -[`plugins/v1`](plugins/v1/README.md). They are pure: they never read the +[`plugins/v1`](../../shared/plugins/contract/v1/README.md). They are pure: they never read the package or the workspace, so hosts pass the parsed `plugin.json` and the relevant facts with every call. @@ -5393,7 +5393,7 @@ entry the Core build does not know, or whose version it does not support, is a manifest that requires one the host lacks is `plugin.api.unsupported` too. Launches and executable resolution belong to the base contract, not to a capability, and are never listed in `requires`. See Capability versions in -[`plugins/v1`](plugins/v1/README.md). +[`plugins/v1`](../../shared/plugins/contract/v1/README.md). `plugins.declarative.runPlan`, `plugins.declarative.discoverTests`, and `plugins.declarative.testPlan` accept @@ -5405,7 +5405,7 @@ revalidate the manifest first. Their requests are respectively Success returns `plan` (`executable`, `arguments`, `workingDirectory`, plus `label` and `frameworkID` for tests) or `items`; refusals use the same `rejected` shape. Only a malformed payload is an `invalid_request` failure. -`shared/contracts/plugins/v1/fixtures/` holds the golden cases. +`shared/plugins/contract/v1/fixtures/` holds the golden cases. `plugins.declarative.languageServerLaunchPlan` accepts `{ "manifest": object, "languageID"?: string, "buildSystemID"?: string, @@ -5464,7 +5464,7 @@ Refusals are `plugin.launch.unavailable` (no such server), `plugin.launch.invalidResult`, `plugin.launch.outsidePackage`, `plugin.launch.undeclaredTool`, and `plugin.launch.unwritablePath` for the answer. The golden cases are -`shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json`. +`shared/plugins/contract/v1/fixtures/languages/language-server-launch-resolution.json`. `plugins.languageServers.stateRetention` accepts `{ retention: { maxEntries, maxAgeDays }, nowUnixSeconds, entries: [{ name, lastModifiedUnixSeconds }], @@ -5642,7 +5642,7 @@ Invalid documents use `plugin.settings.invalidDocument`; invalid batches, values, or edit scopes use `plugin.settings.invalidEdit`, with a safe `field`. Malformed envelopes use `invalid_request`; manifest failures retain existing rejection codes. Rejection never includes partial replacement text. Rust and -desktop consumers share `shared/fixtures/plugins/settings-documents.json`. +desktop consumers share `rust/fixtures/plugins/settings-documents.json`. `plugins.installed.select` accepts `{ entries: [{ pluginID, version, enabled }], includeDisabled?: bool }`, with at most 4096 native records. It returns @@ -5660,7 +5660,7 @@ Invalid identity/version records and later exact duplicates use available; the native owner must report diagnostics and must not abandon a successfully started session merely because other plugins failed discovery. This is metadata selection, not manifest validation, approval or admission. -Shared cases are in `shared/fixtures/plugins/installed-versions.json`. +Shared cases are in `rust/fixtures/plugins/installed-versions.json`. `plugins.declarative.decodeGrammar` accepts `{ manifest, languageID, grammar }`, where `grammar` is the text the host read from the language's `grammar.path` @@ -5686,7 +5686,7 @@ mismatched methods return `plugin.host.unavailable`, and missing grants return `plugin.host.permissionDenied`. This decision does not replace negotiated-version, parameter, current-session, workspace-context, or resource-ownership checks. Hosts must deny access if the Core call fails. -Shared cases are in `shared/fixtures/plugins/host-api-v1.json`. +Shared cases are in `rust/fixtures/plugins/host-api-v1.json`. `plugins.editorActions.decode` accepts `{version,kind:"registration",value}` or `{version,kind:"actions",value,text}` and returns `{registration}` or `{actions}`. @@ -5721,7 +5721,7 @@ the declaration) yields when a plugin answered. `toolchains.instantiateLaunch` accepts `{ registry, tool, executable, home?, entry?, runtimeArguments, arguments }` and returns `{ status: "ok", launch: { executable, arguments, versionArguments } }`. See -[Launching a tool definition](plugins/v1/toolchains/README.md#launching-a-tool-definition). +[Launching a tool definition](../../shared/plugins/contract/v1/toolchains/README.md#launching-a-tool-definition). `run.decodeStart` accepts `{ manifest, workspaceRoot, temporaryDirectory?, params }`, where `params` are those of `lithe/host/run/start` as the backend sent @@ -5736,7 +5736,7 @@ working directory must be a relative path of an existing directory inside Core writes nothing, the host writes `temporaryFiles`. A start Core refuses is `{ status: "rejected", error }` with `plugin.run.invalidStart` and the failing `field`. See the plugin contract's -[run and debug sessions](plugins/v1/README.md#run-and-debug-sessions). +[run and debug sessions](../../shared/plugins/contract/v1/README.md#run-and-debug-sessions). `debug.decodeBackendConfiguration` accepts `{ manifest, params }` with the params of `lithe/host/debug/start` and returns `{ status: "ok", start }` with @@ -5752,7 +5752,7 @@ sources: [{ pluginID, matchers }], lines }` and returns `{ links }`; the matchers of each enabled plugin as `plugins.declarative.validate` returned them. A request Core cannot accept is `invalid_request`, not a policy outcome. See the plugin contract's -[output link matchers](plugins/v1/README.md#output-link-matchers). +[output link matchers](../../shared/plugins/contract/v1/README.md#output-link-matchers). The `plugins.marketplace.*` commands are equally pure: hosts fetch indexes and @@ -5766,12 +5766,12 @@ is one Core supports and every capability it requires is in `hostCapabilities`; approvedPrograms?, hostCapabilities? }` and returns `{ status: "ok", review: { id, version, permissions, programs, newPrograms } }`. Content problems use the same `rejected` shape; see the Marketplace section of -[`plugins/v1`](plugins/v1/README.md) and `fixtures/marketplace/`. +[`plugins/v1`](../../shared/plugins/contract/v1/README.md) and `fixtures/marketplace/`. ### Build systems The `build.*` commands implement the `buildSystems` capability of -[`plugins/v1`](plugins/v1/README.md). Every build tool shares them, so a host +[`plugins/v1`](../../shared/plugins/contract/v1/README.md). Every build tool shares them, so a host has one build workflow and never branches on a build-system ID: Core plans each process, the host runs it with the discovered executable, and Core decodes what it printed or wrote. @@ -5821,7 +5821,7 @@ and the directories are absolute host paths used only as substitution text. | `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1 and systems; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | -| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/plugins/v1/README.md#optional-read-only-model-inputs) | +| `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/plugins/contract/v1/README.md#optional-read-only-model-inputs) | | `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, scope?, value, source, choices, allowCustom }], dropped, fingerprint }` | | `build.configurationPlan` | plan fields and optional host-selected `tool` | `plan.provider` for `lithe/build/configuration`; no mutation, project or output-file parameters | | `build.decodeConfiguration` | `{ result }` | `configuration`: bounded `litheBuildConfiguration` v1 native sources/fields; see the plugin contract | @@ -5859,13 +5859,13 @@ file refuses a plan without it. Unlike the `plugins.declarative.*` commands, `build.detect`, `build.decodeModel`, `build.decodeTestResults`, `build.decodeDependencies`, and `build.syncFingerprint` read bounded files inside `workspaceRoot` (and the given `outputFile`); none of the commands -writes anything or starts a process. `shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json` +writes anything or starts a process. `shared/plugins/contract/v1/fixtures/build-systems/build-plans.json` holds the golden plans and decoded outputs. ### VCS providers The `vcs.*` commands implement the `vcsProviders` capability of -[`plugins/v1`](plugins/v1/README.md) and the +[`plugins/v1`](../../shared/plugins/contract/v1/README.md) and the [VCS provider contract](vcs-provider-v1.md). Results use the `plugins.declarative.*` envelope; a malformed payload, a relative `workspaceRoot`, or an unknown operation is an `invalid_request` failure. @@ -5879,14 +5879,14 @@ The `vcs.*` commands implement the `vcsProviders` capability of `vcs.detectRoots` reads directory entries below `workspaceRoot` (at most eight levels and 20,000 directories) and the marker names in its ancestors; it never reads file contents, writes anything, or starts a process. The other two -commands touch no file. `shared/fixtures/vcs/model-v1.json` holds the +commands touch no file. `rust/fixtures/vcs/model-v1.json` holds the detection cases, decoded snapshots, and reply validation cases. ### Project generators The `project.*` commands back the New Project window. Payloads, results, and problem codes are specified in the plugin contract's -[Project generators](plugins/v1/README.md#project-generators) section, and -`shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json` holds the golden +[Project generators](../../shared/plugins/contract/v1/README.md#project-generators) section, and +`shared/plugins/contract/v1/fixtures/project-generators/project-plans.json` holds the golden answers. Results use the `plugins.declarative.*` envelope: `catalog`, `form`, `plan`, `name`, and `extracted`. Commands that take a manifest revalidate it and answer `plugin.project.unavailable` for an undeclared generator. @@ -6091,7 +6091,7 @@ symbols whose `range` and `selectionRange` are both `location.range` and whose `containerName` is kept as a hint, since Core does not infer a hierarchy from names. `deprecated` is true for the legacy `deprecated` flag or tag `1`. Items lacking `name`, `kind` or a range are dropped; a `null` or non-array result -returns `symbols: []`. Fixture: `shared/fixtures/lsp/document-symbols-v1.json`. +returns `symbols: []`. Fixture: `rust/fixtures/lsp/document-symbols-v1.json`. Operation `workspaceSymbols` (`workspace/symbol`) searches the whole workspace and names no document. Its request takes an optional `query` string (absent means @@ -6116,7 +6116,7 @@ merging. Both Monaco adapters set `CompletionList.incomplete` and forward Monaco trigger context, so continued typing can request additional candidates instead of filtering a partial cache. Existing document-version, cancellation and attachment checks still reject obsolete results. The fixture is -`shared/fixtures/lsp/completion-refresh-v1.json`. +`rust/fixtures/lsp/completion-refresh-v1.json`. Completion items returned by the LSP client and runtime preserve `insertTextFormat` (`1` for plain text, `2` for snippets; absent values default to `1`). Hosts retain @@ -6137,7 +6137,7 @@ settings declared in the provider's initialization options are retained for the session in initialize, didChangeConfiguration, and parent/leaf section pulls (including favoriteStaticMembers and filteredTypes). This does not disable snippet support or strip parameter text from arbitrary completion items; the shared -configuration fixture is `shared/fixtures/lsp/java-completion-v1.json`. +configuration fixture is `rust/fixtures/lsp/java-completion-v1.json`. ### Java preparation snapshot @@ -6153,7 +6153,7 @@ configuration/build coordinator. Generic indexing never blocks Run. Profile failure is visible but does not globally block unrelated targets; callers still build the selected target before launching. A successful preparation does not promise compilation success. Shared examples live in -`shared/fixtures/lsp/project-preparation-v1.json`. +`rust/fixtures/lsp/project-preparation-v1.json`. ### Workspace commit preconditions @@ -6255,7 +6255,7 @@ implicit Default membership; copies never inherit or modify their source. Ambiguous overlapping renames with different memberships fail closed. Inputs and assignment counts are bounded. Hosts must reject stale asynchronous results and protect persistence against concurrent windows. The shared fixture is -`shared/fixtures/git/changelists-reducer-v1.json`. +`rust/fixtures/git/changelists-reducer-v1.json`. An optional `pathScope: { include, paths, membership? }` restricts local changelist commits. Legacy scopes use literal `paths` grouped by repository ID: @@ -6271,7 +6271,7 @@ exact-path behavior. Scope changes invalidate a reviewed plan; retries inherit the original scope and may not replace it. Continuations retain the entire scope and validate it before writes, alongside exact HEAD/index checks. Omitted scopes preserve existing clients' behavior. Legacy examples: -`shared/fixtures/git/local-changelist-scope-v1.json`. +`rust/fixtures/git/local-changelist-scope-v1.json`. `git.workspaceCommitStep` accepts `{ session }` and returns the next session, executing at most one commit or push. Session fields are `plan`, last observed @@ -6300,8 +6300,8 @@ workspace lifetime. Native clients discard old responses after workspace changes show confirmation/progress, and drive steps until `finished`. macOS uses this shared workflow, as does Windows through its workspace-scoped continuation adapter and real staging checkboxes. The native products must not duplicate planning or retry policy. -See `shared/fixtures/git/workspace-commit-v1.json` for primitive payloads and -`shared/fixtures/git/workspace-commit-workflow-v1.json` for the complete planning +See `rust/fixtures/git/workspace-commit-v1.json` for primitive payloads and +`rust/fixtures/git/workspace-commit-workflow-v1.json` for the complete planning and continuation fixture consumed by Rust, Swift, TypeScript and Tauri adapter tests. ### File encodings @@ -6344,7 +6344,7 @@ touched. A character the encoding cannot represent fails with error code codePoint=U+XXXX` for the first offending scalar; nothing is written and no replacement character is substituted. The result is `{ path, bytesWritten, encoding, hasBom, identity }`, where `bytesWritten` is the encoded length -including any BOM. Fixture: `shared/fixtures/editor/file-encoding-v1.json`. +including any BOM. Fixture: `rust/fixtures/editor/file-encoding-v1.json`. `text.decode` and `text.encode` are the same rules for a host that reads and writes the file itself (for example to guard against a file changing between @@ -6376,7 +6376,7 @@ for invalid UTF-8 or an invalid pointer/length combination. A zero-length input is plain text and may use a null pointer. Nonzero input must point to at least `length` readable bytes and `length` must not exceed `isize::MAX`. The Swift bridge exposes the same lifetime and result contract. Fixtures live in -`shared/fixtures/editor/text-content-v1.json` and exercise both entry points. +`rust/fixtures/editor/text-content-v1.json` and exercise both entry points. ### Process lifecycle decisions @@ -6427,7 +6427,7 @@ Unproven ownership is always `ignore`; a record the user kept is `retain`. Agent tasks are never ended during `crashRecovery` and are always confirmed during `startupSweep`, whatever the settings say. Hosts own discovery, signalling, and dialogs. Fixtures live in -`shared/fixtures/process-lifecycle/exit-plan-v1.json`. +`rust/fixtures/process-lifecycle/exit-plan-v1.json`. `processLifecycle.inventory` accepts `{ scope, mode, applicationSessionId, excludedProcessIds, retained, agentScopes?, approvedTermination, agentConnections, @@ -6468,7 +6468,7 @@ The representative determines `service:{pid}:{startTime}` (`detached: true`). Remaining processes are grouped by session as `terminal:{sessionId}`. With a `context`, `plan` is the `processLifecycle.resolve` result for the items, using scope-token ownership. Fixtures live in -`shared/fixtures/process-lifecycle/inventory-v1.json`. +`rust/fixtures/process-lifecycle/inventory-v1.json`. `processLifecycle.settle` accepts `{ settings, decisions, answers, remember }`. `decisions` repeat each plan decision's `id` and `action`, plus an optional @@ -6483,7 +6483,7 @@ scope-token ownership. Fixtures live in - With `remember`, answers to `ask` records that have a recognizer are written to `settings.rememberedChoices`. -Fixtures live in `shared/fixtures/process-lifecycle/settle-v1.json`. +Fixtures live in `rust/fixtures/process-lifecycle/settle-v1.json`. ## Local IDE capability broker @@ -6518,7 +6518,7 @@ sorted and deduplicated. Empty means no classes were replaced. Java Debug Server `errorMessage` inside a successful DAP response becomes `operationFailed` with `adapterRejected`; the debug session remains usable. Malformed replacement results also fail only the operation. Replacement does not imply continue. -The fixture is `shared/fixtures/debug/hot-code-replace-v1.json`. +The fixture is `rust/fixtures/debug/hot-code-replace-v1.json`. Java debug launches append `-Dspring.devtools.restart.enabled=false` (also for Core-planned direct JDT JDWP launches) so DevTools cannot restart the classloader during @@ -6542,7 +6542,7 @@ Clients handling larger histories must batch requests and abandon stale results. Local calendar boundaries (including daylight-saving transitions) come from the host's system calendar, are converted to absolute seconds, and are passed to Core; Core does not infer an OS timezone or assume every calendar day is 24 hours. See -`shared/fixtures/git/history-date-query-v1.json`. +`rust/fixtures/git/history-date-query-v1.json`. ### Saved changes: directional patches and immutable stashes @@ -6552,7 +6552,7 @@ changing direction requires a new preview. Preview checks use disposable index c and preserve the real index bytes. Applying to `indexAndWorktree` may refresh its stat cache after reviewed-state validation. Git remains responsible for applicability; there is no partial-reject or automatic whitespace repair mode. -See `shared/fixtures/git/patch-exchange-v1.json`. +See `rust/fixtures/git/patch-exchange-v1.json`. `git.stashes` adds `oid`, the full immutable object identity, alongside the positional `reference`. `git.diff` accepts `stashReference` to read a stash including its untracked @@ -6586,8 +6586,8 @@ return no files; invalid, unsafe or oversized inputs are rejected. Shelf retains separate staged and working-tree patches and parses each layer once. Hosts may render the unified and split rows, but must use only byte-faithful hunks for writes and honor the decoding-loss flag. See -`shared/fixtures/git/saved-stash-v1.json` and -`shared/fixtures/git/parsed-diff-v1.json`. +`rust/fixtures/git/saved-stash-v1.json` and +`rust/fixtures/git/parsed-diff-v1.json`. ### Plugin web pages @@ -6654,7 +6654,7 @@ advertise native plugin content. `create`, `import`, and `setEnabled`, with `agentId` (`codex-acp` or `claude-acp`) and optional absolute `workspace`. These management operations never create an Agent session, send a prompt or stop a running conversation. Only an explicit `setEnabled` action changes native visibility settings. -The shared fixture is `shared/fixtures/agent/skills-management-v1.json`. +The shared fixture is `rust/fixtures/agent/skills-management-v1.json`. `list` returns `skills`, `source`, `issues`, and native `directories` (`user`, plus `repo` when a workspace is present). Each row has `name`, `description`, @@ -6700,7 +6700,7 @@ and rejects stale results after project changes. Windows can use the same Core operations; the settings UI and Windows runtime validation remain pending. -Team Build options use `shared/contracts/build-project-options-v1.schema.json` +Team Build options use `rust/contracts/build-project-options-v1.schema.json` at `.lithe/build/options.json`. `options` holds workspace-scoped values; `instanceDefaults` applies to every detected root's instance-scoped options; `instances` overrides those defaults by normalized root. Platform-local values @@ -6732,7 +6732,7 @@ wins its major and alone receives `default: true` in `java.configuration.runtime both at initialization and in configuration responses. Empty homes and unparseable versions are ignored during projection; platform adapters validate explicit choices before launch. Legacy callers that omit `isDefault` retain their previous behavior. -Fixture: `shared/fixtures/lsp/java-project-runtimes-v1.json`. +Fixture: `rust/fixtures/lsp/java-project-runtimes-v1.json`. ### Workspace indexing policy @@ -6783,7 +6783,7 @@ and releases only the last holder; window destruction also revokes late registra takes `{ source, destination?, caseInsensitive?, rules, target? }` and returns `{ rules, target }`: keys at or below `source` leave `rules`, reappear under `destination` (in `target` for a move across workspace roots) and are dropped when there is no destination. Request and response pairs are pinned -by `shared/fixtures/workspace/directory-rules-v1.json`. +by `rust/fixtures/workspace/directory-rules-v1.json`. `workspace.planDuplicate` takes `{ name, isDirectory, existing?, caseInsensitive? }` and returns `{ name }`; `workspace.planPaste` takes `{ items: [{ name, isDirectory }], existing?, caseInsensitive? }` @@ -6792,15 +6792,15 @@ are not. Core never touches the file system: the host passes the names it has se copy without replacing anything (`FileManager.copyItem`, which refuses an existing destination, on macOS; `create_new` and `MoveFileEx` without replacement on Windows) and, when that fails because the name appeared meanwhile, adds the name to `existing` and plans again. After 512 rounds the host reports -the stable `destination_exists` error. Cases are in `shared/fixtures/workspace/file-copy-names-v1.json`. +the stable `destination_exists` error. Cases are in `rust/fixtures/workspace/file-copy-names-v1.json`. `history.planEvent` takes `{ event }` and returns `{ snapshots: [{ source, reason }] }`. `source` says whose text the host records: `beforeChange` (the text an event replaces), `afterChange` (the text it produced) or `editorBuffer` (unsaved editor text that is discarded). A save or auto-save records `beforeChange` and `afterChange`, so a snapshot of the saved state exists immediately. -Cases are in `shared/fixtures/history/event-plan-v1.json`. +Cases are in `rust/fixtures/history/event-plan-v1.json`. -The directory-action sequence contract is `shared/fixtures/workspace/directory-actions-v1.json`. +The directory-action sequence contract is `rust/fixtures/workspace/directory-actions-v1.json`. Role changes retain include exceptions, explicit exclusion deletes only its own exception, and canceling Git exclusion preserves the current role. Only canceling a manual exclusion restores the remembered role. Manual ancestor exclusion cannot be overridden. @@ -6868,7 +6868,7 @@ The response is `{status: ok, preferences: {fixed, source, inheritance, stored}} inert inputs to the existing resolver; this command cannot register a tool, prove package trust, resolve a version, write preferences or grant permission to run a program. Native consumers must recheck their source bytes and active definitions after discovery. The shared fixture is -`shared/fixtures/toolchains/consumer-preferences.json`. The machine-local Run document stores it at `toolchain.preferences`. Run editor updates omit to +`rust/fixtures/toolchains/consumer-preferences.json`. The machine-local Run document stores it at `toolchain.preferences`. Run editor updates omit to preserve, or pass an empty v1 document to reset. `runConfig.projectToolchain` returns the inert `toolPreferences` alongside existing fields. The native host supplies the current installed plugin/build-system contribution identity; both native Build consumers read these preferences @@ -6904,7 +6904,7 @@ or case ran nothing), `noTestsFound` (a project or workspace scope ran nothing), failures outrank the exit code, a non-zero exit outranks an empty result, and a zero exit with decoded results but no executed test is not a pass. Counts that exceed `total` are `InvalidRequest`. No tool or selector syntax is interpreted. -Hosts localize the outcome. Cases: `shared/fixtures/build/test-run-outcome-v1.json`. +Hosts localize the outcome. Cases: `rust/fixtures/build/test-run-outcome-v1.json`. ### Test history merge @@ -6919,7 +6919,7 @@ others stay in place, then the run's covered results follow. Incoming results outside the scope are discarded, and for a repeated id the later entry wins. Core compares strings only: hosts keep the result payloads, and the provider adapter builds the scope (for example turning Java nested classes `Outer$Inner` -into the prefix `Outer$`). Cases: `shared/fixtures/build/test-outcome-merge-v1.json`. +into the prefix `Outer$`). Cases: `rust/fixtures/build/test-outcome-merge-v1.json`. ### Variable and disassembly paging @@ -6935,7 +6935,7 @@ identities and how many of them the host did not already show, and returns older cursor or repeats a page (an adapter that ignores `start`). Paging stops on an oversized page, a page of only known children, the reported count, or a short page without a count. Hosts keep the page size, expansion and child identity. -Cases: `shared/fixtures/debug/variable-page-plan-v1.json`. +Cases: `rust/fixtures/debug/variable-page-plan-v1.json`. `debug.planDisassemblyWindow { direction, reference, count, instructionsBefore? }` returns a fetch whose `request` is the exact DAP `disassemble` arguments; @@ -6954,7 +6954,7 @@ instruction, then the known edge is probed: only an edge that still disassembles ends the direction (`exhausted`), otherwise the refused window's message is returned. Transport failures are never treated as a boundary. Addresses are never parsed or offset by Core or hosts. Hosts keep the viewport, -scroll anchor and rendering. Cases: `shared/fixtures/debug/disassembly-paging-v1.json`. +scroll anchor and rendering. Cases: `rust/fixtures/debug/disassembly-paging-v1.json`. ### Windows source-breakpoint relocation @@ -6963,7 +6963,7 @@ Windows forwards the editor's exact source-preserving edits to one-based lines. The context-expanded patches used to synchronize the editor's source mirror must not be used as anchor edits: unchanged neighboring code must not be treated as deleted. The existing cases in -`shared/fixtures/debug/breakpoint-relocation-v1.json` cover both platforms. +`rust/fixtures/debug/breakpoint-relocation-v1.json` cover both platforms. Each native multi-cursor event is sent as one batch of original-source edits; Core applies the edits before projecting anchors. Rapid events serialize or share @@ -6989,7 +6989,7 @@ fragment if syntax coloring fails. Render failures are shown as localized text; there is no frontend Markdown parser fallback. This does not promise Windows math or diagram rendering parity. -Shared examples: `shared/fixtures/editor/markdown-render-v1.json`, +Shared examples: `rust/fixtures/editor/markdown-render-v1.json`, `markdown-html-v1.json` and `markdown-rich-content-v1.json`. ### Structured logging of recoverable LSP maintenance warnings @@ -7258,9 +7258,9 @@ Missing, unusable, or relative values fail with `plugin.launch.invalidRequest` when needed; `requiredDirectories` adds `log` when used. The host creates the provided writable directory before launch, outside all bundles and installation resources. Validation probes cannot reference `${logDir}`. See the synchronized -[plugin contract](plugins/v1/README.md). Literal arguments that do not use +[plugin contract](../../shared/plugins/contract/v1/README.md). Literal arguments that do not use `${logDir}` remain literal. Wire examples are exercised by -`shared/fixtures/diagnostics/engine-logging-v2.json` and Core tests. +`rust/fixtures/diagnostics/engine-logging-v2.json` and Core tests. Plugin-owned sessions offer `litheLogging: { versions: [0] }` independently of other protocols; a backend that answers `{ "version": 0 }` negotiates it. @@ -7270,7 +7270,7 @@ identity selecting `plugins//host`. It never copies notification JSON into legacy runtime detail. The four facts map to catalog INFO/ERROR/WARN/WARN, and a live wire request ID resolves to its original host `op`; no user content or backend-selected channel/level/message is accepted. See the -[plugin logging contract](plugins/v1/README.md#structured-plugin-diagnostics) +[plugin logging contract](../../shared/plugins/contract/v1/README.md#structured-plugin-diagnostics) and `plugins/v1/fixtures/backend/plugin-logging.json`. Explicit upstream LSP message types retain their severity through separate catalog events; unclassified stderr is DEBUG and actual lifecycle failure is ERROR. @@ -7313,8 +7313,8 @@ transport, spawn, protocol, or remote_; peer prose is excluded. Core records one terminal fact for each dispatched request and one for each Git process, and hands both to the host through `diagnostics.drainCoreEvents`. Event -identities and field shapes are declared in `shared/logging/events/app.json` and -`shared/logging/events/vcs.json`; this section defines when each is emitted. +identities and field shapes are declared in `rust/logging/events/app.json` and +`rust/logging/events/vcs.json`; this section defines when each is emitted. Request traces use channel `app/core`: `app.core.request.finished` (INFO), `app.core.request.traced` (DEBUG) and `app.core.request.failed` (WARN). The @@ -7386,7 +7386,7 @@ rename) before deleting anything. The result is `{delete, keep, seed}`, the firs `referenced` (kept, even when flagged abandoned), `recent` (kept, unreferenced and at most seven days old or of unknown age), `abandoned` and `expired` (deleted). The cache and the ledger live in the platform cache directory, never in the app bundle or install directory. -The fixture is `shared/fixtures/agent/attachment-retention-v1.json`. +The fixture is `rust/fixtures/agent/attachment-retention-v1.json`. ### Atomic local toolchain pin editing @@ -7404,7 +7404,7 @@ and `plugin.toolchain.limitExceeded` structured error envelope. The command performs no I/O and grants no execution authority. The host owns explicit Save confirmation, byte-baseline conflict detection, cancellation, ignore-file preparation, atomic native replacement, and re-reading the source. -See `shared/fixtures/toolchain-pin-edit.json` for the cross-client vector. +See `rust/fixtures/toolchain-pin-edit.json` for the cross-client vector. ### Native toolchain pin sources @@ -7436,7 +7436,7 @@ identity, recheck both even for no-op saves, preserve metadata, and publish the ignore prerequisite before the pin document. A document already committed followed by cleanup/readback failure must invalidate the old draft baseline: reload before retrying. User-selected workspace storage never changes an -installed app or plugin bundle. See `shared/fixtures/toolchain-pin-storage.json`. +installed app or plugin bundle. See `rust/fixtures/toolchain-pin-storage.json`. ### Debug launch response ordering @@ -7444,7 +7444,7 @@ A debug adapter may emit `stopped` or `terminated` before replying to `launch` or `attach`. The acknowledgement completes that operation; it changes state to `running` only if the session is still `launching`. It never resumes a paused target or resurrects a terminated one. See -`shared/fixtures/debug/launch-event-order.json`. Hosts serialize each Core update, +`rust/fixtures/debug/launch-event-order.json`. Hosts serialize each Core update, its outbound frames and its UI publication in the order returned by the engine. ### Settings action planning @@ -7465,7 +7465,7 @@ no function invocation. Otherwise an unconfirmed action whose declaration has Once confirmed (or when the declaration needs no confirmation), `kind:"invoke"` adds `function` and `params:{}`. Labels and function identity always come from the validated manifest; callers cannot supply a different target or arguments. -See `shared/fixtures/plugins/settings-actions.json`. +See `rust/fixtures/plugins/settings-actions.json`. `confirmed` and `workspaceAvailable` are facts from the native owner, not a capability grant. The host must capture user confirmation, check the installed @@ -7495,7 +7495,7 @@ affect selection: the first matching session ID in lexical order wins. This query performs no I/O, starts nothing and does not consume runtime events. There is no cross-workspace fallback. An absent match is explicit null; normal startup/admission and later function invocation checks remain required. -The pure selection vector is `shared/fixtures/plugins/settings-function-provider.json`. +The pure selection vector is `rust/fixtures/plugins/settings-function-provider.json`. This is an internal desktop command, not a new public plugin host method. ### Dynamic plugin settings surfaces @@ -7526,4 +7526,4 @@ refresh displayed sources but receive no runtime effects. Effects are sorted and deduplicated. No setting values cross windows: receivers read their own project layers. Native owners must recheck window/project lifetime on delivery. Invalid surface input returns `plugin.settings.invalidSurface`; owner mismatch -returns `plugin.settings.ownerChanged`. Shared vectors: `shared/fixtures/plugins/settings-surfaces.json`. +returns `plugin.settings.ownerChanged`. Shared vectors: `rust/fixtures/plugins/settings-surfaces.json`. diff --git a/shared/contracts/terminal-profiles.md b/rust/contracts/terminal-profiles.md similarity index 100% rename from shared/contracts/terminal-profiles.md rename to rust/contracts/terminal-profiles.md diff --git a/shared/contracts/toolchain-consumer-preferences-v1.schema.json b/rust/contracts/toolchain-consumer-preferences-v1.schema.json similarity index 100% rename from shared/contracts/toolchain-consumer-preferences-v1.schema.json rename to rust/contracts/toolchain-consumer-preferences-v1.schema.json diff --git a/shared/contracts/toolchain-local-v1.schema.json b/rust/contracts/toolchain-local-v1.schema.json similarity index 100% rename from shared/contracts/toolchain-local-v1.schema.json rename to rust/contracts/toolchain-local-v1.schema.json diff --git a/shared/contracts/toolchain-requirements-v1.schema.json b/rust/contracts/toolchain-requirements-v1.schema.json similarity index 100% rename from shared/contracts/toolchain-requirements-v1.schema.json rename to rust/contracts/toolchain-requirements-v1.schema.json diff --git a/shared/contracts/update-v1.md b/rust/contracts/update-v1.md similarity index 100% rename from shared/contracts/update-v1.md rename to rust/contracts/update-v1.md diff --git a/shared/contracts/update-v1.schema.json b/rust/contracts/update-v1.schema.json similarity index 100% rename from shared/contracts/update-v1.schema.json rename to rust/contracts/update-v1.schema.json diff --git a/shared/contracts/vcs-provider-v1.md b/rust/contracts/vcs-provider-v1.md similarity index 97% rename from shared/contracts/vcs-provider-v1.md rename to rust/contracts/vcs-provider-v1.md index 87c81de2..feb77a7d 100644 --- a/shared/contracts/vcs-provider-v1.md +++ b/rust/contracts/vcs-provider-v1.md @@ -5,15 +5,15 @@ > `vcsProviders` 0 and starts providers for the roots it detects. Windows does > not advertise the capability. > The decision record is -> [Unified VCS model and providers](../../.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); -> example data is in [`shared/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) -> and [`plugins/v1/fixtures/vcs/vcs-provider.plugin.json`](plugins/v1/fixtures/vcs/vcs-provider.plugin.json). +> [Unified VCS model and providers](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); +> example data is in [`rust/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) +> and [`plugins/v1/fixtures/vcs/vcs-provider.plugin.json`](../../shared/plugins/contract/v1/fixtures/vcs/vcs-provider.plugin.json). This contract defines one VCS-neutral model for the features every version control system has: roots, file status, base content, history, annotation, check-in, rollback, and update. A **provider** implements it for one VCS as a plugin process speaking the LSP base protocol, the same process kind as -[build providers](plugins/v1/README.md#build-providers). Subversion is the +[build providers](../../shared/plugins/contract/v1/README.md#build-providers). Subversion is the first provider. Git is not part of this layer yet. Core recognizes `.git` markers so a nested diff --git a/shared/contracts/workbench-background-v1.schema.json b/rust/contracts/workbench-background-v1.schema.json similarity index 100% rename from shared/contracts/workbench-background-v1.schema.json rename to rust/contracts/workbench-background-v1.schema.json diff --git a/shared/fixtures/agent/acp-events-v1.json b/rust/fixtures/agent/acp-events-v1.json similarity index 100% rename from shared/fixtures/agent/acp-events-v1.json rename to rust/fixtures/agent/acp-events-v1.json diff --git a/shared/fixtures/agent/agent-instructions-v1.json b/rust/fixtures/agent/agent-instructions-v1.json similarity index 100% rename from shared/fixtures/agent/agent-instructions-v1.json rename to rust/fixtures/agent/agent-instructions-v1.json diff --git a/shared/fixtures/agent/agent-management-v1.json b/rust/fixtures/agent/agent-management-v1.json similarity index 100% rename from shared/fixtures/agent/agent-management-v1.json rename to rust/fixtures/agent/agent-management-v1.json diff --git a/shared/fixtures/agent/attachment-retention-v1.json b/rust/fixtures/agent/attachment-retention-v1.json similarity index 100% rename from shared/fixtures/agent/attachment-retention-v1.json rename to rust/fixtures/agent/attachment-retention-v1.json diff --git a/shared/fixtures/agent/claude-desktop-cards-v1.json b/rust/fixtures/agent/claude-desktop-cards-v1.json similarity index 99% rename from shared/fixtures/agent/claude-desktop-cards-v1.json rename to rust/fixtures/agent/claude-desktop-cards-v1.json index 6cc96a98..479dcba5 100644 --- a/shared/fixtures/agent/claude-desktop-cards-v1.json +++ b/rust/fixtures/agent/claude-desktop-cards-v1.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "description": "Claude Desktop tool calls as a host receives them (updates) and as lithe-agent-host presents them to clients (presented), in order. Cards are in _meta.lithe.claudeDesktop; see shared/contracts/application-boundary.md.", + "description": "Claude Desktop tool calls as a host receives them (updates) and as lithe-agent-host presents them to clients (presented), in order. Cards are in _meta.lithe.claudeDesktop; see rust/contracts/application-boundary.md.", "cases": [ { "name": "chapter", diff --git a/shared/fixtures/agent/edit-review-v1.json b/rust/fixtures/agent/edit-review-v1.json similarity index 100% rename from shared/fixtures/agent/edit-review-v1.json rename to rust/fixtures/agent/edit-review-v1.json diff --git a/shared/fixtures/agent/history-activity-v1.json b/rust/fixtures/agent/history-activity-v1.json similarity index 100% rename from shared/fixtures/agent/history-activity-v1.json rename to rust/fixtures/agent/history-activity-v1.json diff --git a/shared/fixtures/agent/hook-draft-validation-v1.json b/rust/fixtures/agent/hook-draft-validation-v1.json similarity index 100% rename from shared/fixtures/agent/hook-draft-validation-v1.json rename to rust/fixtures/agent/hook-draft-validation-v1.json diff --git a/shared/fixtures/agent/hooks-management-v1.json b/rust/fixtures/agent/hooks-management-v1.json similarity index 100% rename from shared/fixtures/agent/hooks-management-v1.json rename to rust/fixtures/agent/hooks-management-v1.json diff --git a/shared/fixtures/agent/model-call-statistics-v1.json b/rust/fixtures/agent/model-call-statistics-v1.json similarity index 100% rename from shared/fixtures/agent/model-call-statistics-v1.json rename to rust/fixtures/agent/model-call-statistics-v1.json diff --git a/shared/fixtures/agent/next-turn-configuration-v1.json b/rust/fixtures/agent/next-turn-configuration-v1.json similarity index 100% rename from shared/fixtures/agent/next-turn-configuration-v1.json rename to rust/fixtures/agent/next-turn-configuration-v1.json diff --git a/shared/fixtures/agent/shared-instructions-v1.json b/rust/fixtures/agent/shared-instructions-v1.json similarity index 100% rename from shared/fixtures/agent/shared-instructions-v1.json rename to rust/fixtures/agent/shared-instructions-v1.json diff --git a/shared/fixtures/agent/skills-management-v1.json b/rust/fixtures/agent/skills-management-v1.json similarity index 100% rename from shared/fixtures/agent/skills-management-v1.json rename to rust/fixtures/agent/skills-management-v1.json diff --git a/shared/fixtures/ai/cli-configuration-v1.json b/rust/fixtures/ai/cli-configuration-v1.json similarity index 100% rename from shared/fixtures/ai/cli-configuration-v1.json rename to rust/fixtures/ai/cli-configuration-v1.json diff --git a/shared/fixtures/ai/commit-generation-v1.json b/rust/fixtures/ai/commit-generation-v1.json similarity index 100% rename from shared/fixtures/ai/commit-generation-v1.json rename to rust/fixtures/ai/commit-generation-v1.json diff --git a/shared/fixtures/ai/inline-edit-v1.json b/rust/fixtures/ai/inline-edit-v1.json similarity index 100% rename from shared/fixtures/ai/inline-edit-v1.json rename to rust/fixtures/ai/inline-edit-v1.json diff --git a/shared/fixtures/ai/pull-request-description-v1.json b/rust/fixtures/ai/pull-request-description-v1.json similarity index 100% rename from shared/fixtures/ai/pull-request-description-v1.json rename to rust/fixtures/ai/pull-request-description-v1.json diff --git a/shared/fixtures/build-project-options/local-edits.json b/rust/fixtures/build-project-options/local-edits.json similarity index 100% rename from shared/fixtures/build-project-options/local-edits.json rename to rust/fixtures/build-project-options/local-edits.json diff --git a/shared/fixtures/build-project-options/shared-defaults.json b/rust/fixtures/build-project-options/shared-defaults.json similarity index 100% rename from shared/fixtures/build-project-options/shared-defaults.json rename to rust/fixtures/build-project-options/shared-defaults.json diff --git a/shared/fixtures/build-project-options/validation.json b/rust/fixtures/build-project-options/validation.json similarity index 100% rename from shared/fixtures/build-project-options/validation.json rename to rust/fixtures/build-project-options/validation.json diff --git a/shared/fixtures/build-provider/test-results-v1.json b/rust/fixtures/build-provider/test-results-v1.json similarity index 100% rename from shared/fixtures/build-provider/test-results-v1.json rename to rust/fixtures/build-provider/test-results-v1.json diff --git a/shared/fixtures/build/native-tool-context.json b/rust/fixtures/build/native-tool-context.json similarity index 100% rename from shared/fixtures/build/native-tool-context.json rename to rust/fixtures/build/native-tool-context.json diff --git a/shared/fixtures/build/test-outcome-merge-v1.json b/rust/fixtures/build/test-outcome-merge-v1.json similarity index 100% rename from shared/fixtures/build/test-outcome-merge-v1.json rename to rust/fixtures/build/test-outcome-merge-v1.json diff --git a/shared/fixtures/build/test-run-outcome-v1.json b/rust/fixtures/build/test-run-outcome-v1.json similarity index 100% rename from shared/fixtures/build/test-run-outcome-v1.json rename to rust/fixtures/build/test-run-outcome-v1.json diff --git a/shared/fixtures/community/discourse-auth-v1.json b/rust/fixtures/community/discourse-auth-v1.json similarity index 100% rename from shared/fixtures/community/discourse-auth-v1.json rename to rust/fixtures/community/discourse-auth-v1.json diff --git a/shared/fixtures/core/cancellation.json b/rust/fixtures/core/cancellation.json similarity index 100% rename from shared/fixtures/core/cancellation.json rename to rust/fixtures/core/cancellation.json diff --git a/shared/fixtures/debug/adapter-endpoints-v1.json b/rust/fixtures/debug/adapter-endpoints-v1.json similarity index 100% rename from shared/fixtures/debug/adapter-endpoints-v1.json rename to rust/fixtures/debug/adapter-endpoints-v1.json diff --git a/shared/fixtures/debug/adapter-selection-v1.json b/rust/fixtures/debug/adapter-selection-v1.json similarity index 100% rename from shared/fixtures/debug/adapter-selection-v1.json rename to rust/fixtures/debug/adapter-selection-v1.json diff --git a/shared/fixtures/debug/breakpoint-binding-v1.json b/rust/fixtures/debug/breakpoint-binding-v1.json similarity index 100% rename from shared/fixtures/debug/breakpoint-binding-v1.json rename to rust/fixtures/debug/breakpoint-binding-v1.json diff --git a/shared/fixtures/debug/breakpoint-relocation-v1.json b/rust/fixtures/debug/breakpoint-relocation-v1.json similarity index 100% rename from shared/fixtures/debug/breakpoint-relocation-v1.json rename to rust/fixtures/debug/breakpoint-relocation-v1.json diff --git a/shared/fixtures/debug/dap-session-v1.json b/rust/fixtures/debug/dap-session-v1.json similarity index 100% rename from shared/fixtures/debug/dap-session-v1.json rename to rust/fixtures/debug/dap-session-v1.json diff --git a/shared/fixtures/debug/disassembly-paging-v1.json b/rust/fixtures/debug/disassembly-paging-v1.json similarity index 100% rename from shared/fixtures/debug/disassembly-paging-v1.json rename to rust/fixtures/debug/disassembly-paging-v1.json diff --git a/shared/fixtures/debug/disconnect-policy-v1.json b/rust/fixtures/debug/disconnect-policy-v1.json similarity index 100% rename from shared/fixtures/debug/disconnect-policy-v1.json rename to rust/fixtures/debug/disconnect-policy-v1.json diff --git a/shared/fixtures/debug/exception-info-v1.json b/rust/fixtures/debug/exception-info-v1.json similarity index 100% rename from shared/fixtures/debug/exception-info-v1.json rename to rust/fixtures/debug/exception-info-v1.json diff --git a/shared/fixtures/debug/hot-code-replace-v1.json b/rust/fixtures/debug/hot-code-replace-v1.json similarity index 100% rename from shared/fixtures/debug/hot-code-replace-v1.json rename to rust/fixtures/debug/hot-code-replace-v1.json diff --git a/shared/fixtures/debug/launch-event-order.json b/rust/fixtures/debug/launch-event-order.json similarity index 100% rename from shared/fixtures/debug/launch-event-order.json rename to rust/fixtures/debug/launch-event-order.json diff --git a/shared/fixtures/debug/node-source-map-v1.json b/rust/fixtures/debug/node-source-map-v1.json similarity index 100% rename from shared/fixtures/debug/node-source-map-v1.json rename to rust/fixtures/debug/node-source-map-v1.json diff --git a/shared/fixtures/debug/projection-v2-examples.json b/rust/fixtures/debug/projection-v2-examples.json similarity index 100% rename from shared/fixtures/debug/projection-v2-examples.json rename to rust/fixtures/debug/projection-v2-examples.json diff --git a/shared/fixtures/debug/run-in-terminal-v1.json b/rust/fixtures/debug/run-in-terminal-v1.json similarity index 100% rename from shared/fixtures/debug/run-in-terminal-v1.json rename to rust/fixtures/debug/run-in-terminal-v1.json diff --git a/shared/fixtures/debug/stale-reference-v1.json b/rust/fixtures/debug/stale-reference-v1.json similarity index 100% rename from shared/fixtures/debug/stale-reference-v1.json rename to rust/fixtures/debug/stale-reference-v1.json diff --git a/shared/fixtures/debug/start-debugging-v1.json b/rust/fixtures/debug/start-debugging-v1.json similarity index 100% rename from shared/fixtures/debug/start-debugging-v1.json rename to rust/fixtures/debug/start-debugging-v1.json diff --git a/shared/fixtures/debug/stepping-filters-v1.json b/rust/fixtures/debug/stepping-filters-v1.json similarity index 100% rename from shared/fixtures/debug/stepping-filters-v1.json rename to rust/fixtures/debug/stepping-filters-v1.json diff --git a/shared/fixtures/debug/transport-lifecycle-v1.json b/rust/fixtures/debug/transport-lifecycle-v1.json similarity index 100% rename from shared/fixtures/debug/transport-lifecycle-v1.json rename to rust/fixtures/debug/transport-lifecycle-v1.json diff --git a/shared/fixtures/debug/v1-baseline.json b/rust/fixtures/debug/v1-baseline.json similarity index 94% rename from shared/fixtures/debug/v1-baseline.json rename to rust/fixtures/debug/v1-baseline.json index 6f636fcb..6bea59b6 100644 --- a/shared/fixtures/debug/v1-baseline.json +++ b/rust/fixtures/debug/v1-baseline.json @@ -17,7 +17,7 @@ "platform": "rust", "path": "rust/lithe-core/src/tests/protocol.rs", "test": "debug_create_and_destroy_commands_cross_the_json_boundary", - "fixture": "shared/fixtures/debug/dap-session-v1.json" + "fixture": "rust/fixtures/debug/dap-session-v1.json" }, { "platform": "macos", @@ -64,13 +64,13 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "source_breakpoint_binding_preserves_requested_identity_and_rejects_stale_replies", - "fixture": "shared/fixtures/debug/breakpoint-binding-v1.json" + "fixture": "rust/fixtures/debug/breakpoint-binding-v1.json" }, { "platform": "rust", "path": "rust/lithe-core/src/tests/protocol.rs", "test": "debug_breakpoint_relocation_matches_the_shared_contract_fixture", - "fixture": "shared/fixtures/debug/breakpoint-relocation-v1.json" + "fixture": "rust/fixtures/debug/breakpoint-relocation-v1.json" }, { "platform": "macos", @@ -94,7 +94,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "variable_paging_is_forwarded_and_normalized_from_shared_fixture", - "fixture": "shared/fixtures/debug/variable-paging-v1.json" + "fixture": "rust/fixtures/debug/variable-paging-v1.json" }, { "platform": "rust", @@ -133,7 +133,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "disconnect_policy_matches_shared_fixture", - "fixture": "shared/fixtures/debug/disconnect-policy-v1.json" + "fixture": "rust/fixtures/debug/disconnect-policy-v1.json" }, { "platform": "macos", @@ -162,7 +162,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "run_in_terminal_request_is_normalized_completed_and_stale_safe", - "fixture": "shared/fixtures/debug/run-in-terminal-v1.json" + "fixture": "rust/fixtures/debug/run-in-terminal-v1.json" }, { "platform": "rust", @@ -196,7 +196,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "start_debugging_request_is_normalized_completed_and_stale_safe", - "fixture": "shared/fixtures/debug/start-debugging-v1.json" + "fixture": "rust/fixtures/debug/start-debugging-v1.json" }, { "platform": "rust", @@ -242,7 +242,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "node_source_mapped_locations_pass_through_unchanged", - "fixture": "shared/fixtures/debug/node-source-map-v1.json" + "fixture": "rust/fixtures/debug/node-source-map-v1.json" } ] }, @@ -255,7 +255,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "hot_replace_normalizes_success_empty_and_error_bodies", - "fixture": "shared/fixtures/debug/hot-code-replace-v1.json" + "fixture": "rust/fixtures/debug/hot-code-replace-v1.json" }, { "platform": "rust", @@ -289,7 +289,7 @@ "platform": "rust", "path": "rust/lithe-core/src/tests/protocol.rs", "test": "debug_stepping_filters_match_the_shared_contract_fixture", - "fixture": "shared/fixtures/debug/stepping-filters-v1.json" + "fixture": "rust/fixtures/debug/stepping-filters-v1.json" }, { "platform": "rust", @@ -313,7 +313,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "stale_reference_fixture_matches_core_behavior", - "fixture": "shared/fixtures/debug/stale-reference-v1.json" + "fixture": "rust/fixtures/debug/stale-reference-v1.json" }, { "platform": "macos", @@ -395,7 +395,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/engine.rs", "test": "exception_information_is_capability_gated_and_normalized", - "fixture": "shared/fixtures/debug/exception-info-v1.json" + "fixture": "rust/fixtures/debug/exception-info-v1.json" }, { "platform": "macos", @@ -419,7 +419,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/endpoint.rs", "test": "dap_endpoint_contract_fixtures_match_validation_and_consent_classification", - "fixture": "shared/fixtures/debug/adapter-endpoints-v1.json" + "fixture": "rust/fixtures/debug/adapter-endpoints-v1.json" }, { "platform": "macos", @@ -443,7 +443,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/transport_lifecycle.rs", "test": "lifecycle_fixture_scenarios_match_expected_steps", - "fixture": "shared/fixtures/debug/transport-lifecycle-v1.json" + "fixture": "rust/fixtures/debug/transport-lifecycle-v1.json" }, { "platform": "macos", @@ -467,7 +467,7 @@ "platform": "macos", "path": "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", "test": "adapterSelectionMatchesTheSharedHostFixture", - "fixture": "shared/fixtures/debug/adapter-selection-v1.json" + "fixture": "rust/fixtures/debug/adapter-selection-v1.json" }, { "platform": "macos", @@ -491,7 +491,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/variable_paging.rs", "test": "variable_page_plan_matches_the_shared_fixture", - "fixture": "shared/fixtures/debug/variable-page-plan-v1.json" + "fixture": "rust/fixtures/debug/variable-page-plan-v1.json" } ], "gaps": [] @@ -505,7 +505,7 @@ "platform": "rust", "path": "rust/lithe-core/src/debug/disassembly_paging.rs", "test": "disassembly_paging_matches_the_shared_fixture", - "fixture": "shared/fixtures/debug/disassembly-paging-v1.json" + "fixture": "rust/fixtures/debug/disassembly-paging-v1.json" } ], "gaps": [] diff --git a/shared/fixtures/debug/variable-page-plan-v1.json b/rust/fixtures/debug/variable-page-plan-v1.json similarity index 100% rename from shared/fixtures/debug/variable-page-plan-v1.json rename to rust/fixtures/debug/variable-page-plan-v1.json diff --git a/shared/fixtures/debug/variable-paging-v1.json b/rust/fixtures/debug/variable-paging-v1.json similarity index 100% rename from shared/fixtures/debug/variable-paging-v1.json rename to rust/fixtures/debug/variable-paging-v1.json diff --git a/shared/fixtures/diagnostics/build-manifest-v1.json b/rust/fixtures/diagnostics/build-manifest-v1.json similarity index 100% rename from shared/fixtures/diagnostics/build-manifest-v1.json rename to rust/fixtures/diagnostics/build-manifest-v1.json diff --git a/shared/fixtures/diagnostics/engine-logging-v2.json b/rust/fixtures/diagnostics/engine-logging-v2.json similarity index 100% rename from shared/fixtures/diagnostics/engine-logging-v2.json rename to rust/fixtures/diagnostics/engine-logging-v2.json diff --git a/shared/fixtures/diagnostics/logging-ui-v1.json b/rust/fixtures/diagnostics/logging-ui-v1.json similarity index 100% rename from shared/fixtures/diagnostics/logging-ui-v1.json rename to rust/fixtures/diagnostics/logging-ui-v1.json diff --git a/shared/fixtures/diagnostics/logging-v1.json b/rust/fixtures/diagnostics/logging-v1.json similarity index 100% rename from shared/fixtures/diagnostics/logging-v1.json rename to rust/fixtures/diagnostics/logging-v1.json diff --git a/shared/fixtures/diagnostics/logging-v2.json b/rust/fixtures/diagnostics/logging-v2.json similarity index 100% rename from shared/fixtures/diagnostics/logging-v2.json rename to rust/fixtures/diagnostics/logging-v2.json diff --git a/shared/fixtures/diagnostics/redact-text-v1.json b/rust/fixtures/diagnostics/redact-text-v1.json similarity index 100% rename from shared/fixtures/diagnostics/redact-text-v1.json rename to rust/fixtures/diagnostics/redact-text-v1.json diff --git a/shared/fixtures/diff/compare-texts-v1.json b/rust/fixtures/diff/compare-texts-v1.json similarity index 100% rename from shared/fixtures/diff/compare-texts-v1.json rename to rust/fixtures/diff/compare-texts-v1.json diff --git a/shared/fixtures/documents/encoding-explicit-reopen-v1.json b/rust/fixtures/documents/encoding-explicit-reopen-v1.json similarity index 100% rename from shared/fixtures/documents/encoding-explicit-reopen-v1.json rename to rust/fixtures/documents/encoding-explicit-reopen-v1.json diff --git a/shared/fixtures/documents/lifecycle-v1.json b/rust/fixtures/documents/lifecycle-v1.json similarity index 100% rename from shared/fixtures/documents/lifecycle-v1.json rename to rust/fixtures/documents/lifecycle-v1.json diff --git a/shared/fixtures/documents/save-batch-v1.json b/rust/fixtures/documents/save-batch-v1.json similarity index 100% rename from shared/fixtures/documents/save-batch-v1.json rename to rust/fixtures/documents/save-batch-v1.json diff --git a/shared/fixtures/editor-themes/lithe-v1.json b/rust/fixtures/editor-themes/lithe-v1.json similarity index 100% rename from shared/fixtures/editor-themes/lithe-v1.json rename to rust/fixtures/editor-themes/lithe-v1.json diff --git a/shared/fixtures/editor/basic-file-highlighting-v1.json b/rust/fixtures/editor/basic-file-highlighting-v1.json similarity index 100% rename from shared/fixtures/editor/basic-file-highlighting-v1.json rename to rust/fixtures/editor/basic-file-highlighting-v1.json diff --git a/shared/fixtures/editor/diff-review-v1.json b/rust/fixtures/editor/diff-review-v1.json similarity index 100% rename from shared/fixtures/editor/diff-review-v1.json rename to rust/fixtures/editor/diff-review-v1.json diff --git a/shared/fixtures/editor/file-encoding-v1.json b/rust/fixtures/editor/file-encoding-v1.json similarity index 100% rename from shared/fixtures/editor/file-encoding-v1.json rename to rust/fixtures/editor/file-encoding-v1.json diff --git a/shared/fixtures/editor/line-edit-v1.json b/rust/fixtures/editor/line-edit-v1.json similarity index 100% rename from shared/fixtures/editor/line-edit-v1.json rename to rust/fixtures/editor/line-edit-v1.json diff --git a/shared/fixtures/editor/markdown-html-v1.json b/rust/fixtures/editor/markdown-html-v1.json similarity index 100% rename from shared/fixtures/editor/markdown-html-v1.json rename to rust/fixtures/editor/markdown-html-v1.json diff --git a/shared/fixtures/editor/markdown-render-v1.json b/rust/fixtures/editor/markdown-render-v1.json similarity index 100% rename from shared/fixtures/editor/markdown-render-v1.json rename to rust/fixtures/editor/markdown-render-v1.json diff --git a/shared/fixtures/editor/markdown-rich-content-v1.json b/rust/fixtures/editor/markdown-rich-content-v1.json similarity index 100% rename from shared/fixtures/editor/markdown-rich-content-v1.json rename to rust/fixtures/editor/markdown-rich-content-v1.json diff --git a/shared/fixtures/editor/navigation-history-v1.json b/rust/fixtures/editor/navigation-history-v1.json similarity index 100% rename from shared/fixtures/editor/navigation-history-v1.json rename to rust/fixtures/editor/navigation-history-v1.json diff --git a/shared/fixtures/editor/navigation-input-v1.json b/rust/fixtures/editor/navigation-input-v1.json similarity index 100% rename from shared/fixtures/editor/navigation-input-v1.json rename to rust/fixtures/editor/navigation-input-v1.json diff --git a/shared/fixtures/editor/svg-preview-v1.json b/rust/fixtures/editor/svg-preview-v1.json similarity index 100% rename from shared/fixtures/editor/svg-preview-v1.json rename to rust/fixtures/editor/svg-preview-v1.json diff --git a/shared/fixtures/editor/text-content-v1.json b/rust/fixtures/editor/text-content-v1.json similarity index 100% rename from shared/fixtures/editor/text-content-v1.json rename to rust/fixtures/editor/text-content-v1.json diff --git a/shared/fixtures/editor/text-edit-plan-v1.json b/rust/fixtures/editor/text-edit-plan-v1.json similarity index 100% rename from shared/fixtures/editor/text-edit-plan-v1.json rename to rust/fixtures/editor/text-edit-plan-v1.json diff --git a/shared/fixtures/editor/workbench-state-v1.json b/rust/fixtures/editor/workbench-state-v1.json similarity index 100% rename from shared/fixtures/editor/workbench-state-v1.json rename to rust/fixtures/editor/workbench-state-v1.json diff --git a/shared/fixtures/execution/framework-identity.json b/rust/fixtures/execution/framework-identity.json similarity index 100% rename from shared/fixtures/execution/framework-identity.json rename to rust/fixtures/execution/framework-identity.json diff --git a/shared/fixtures/execution/gradle.json b/rust/fixtures/execution/gradle.json similarity index 100% rename from shared/fixtures/execution/gradle.json rename to rust/fixtures/execution/gradle.json diff --git a/shared/fixtures/execution/launch-environment-v1.json b/rust/fixtures/execution/launch-environment-v1.json similarity index 100% rename from shared/fixtures/execution/launch-environment-v1.json rename to rust/fixtures/execution/launch-environment-v1.json diff --git a/shared/fixtures/execution/maven-java-main-source-sets-v1.json b/rust/fixtures/execution/maven-java-main-source-sets-v1.json similarity index 100% rename from shared/fixtures/execution/maven-java-main-source-sets-v1.json rename to rust/fixtures/execution/maven-java-main-source-sets-v1.json diff --git a/shared/fixtures/execution/maven-runtime-versions.json b/rust/fixtures/execution/maven-runtime-versions.json similarity index 100% rename from shared/fixtures/execution/maven-runtime-versions.json rename to rust/fixtures/execution/maven-runtime-versions.json diff --git a/shared/fixtures/execution/maven.json b/rust/fixtures/execution/maven.json similarity index 100% rename from shared/fixtures/execution/maven.json rename to rust/fixtures/execution/maven.json diff --git a/shared/fixtures/execution/search-path-v1.json b/rust/fixtures/execution/search-path-v1.json similarity index 100% rename from shared/fixtures/execution/search-path-v1.json rename to rust/fixtures/execution/search-path-v1.json diff --git a/shared/fixtures/execution/standalone-java-compile-run-v1.json b/rust/fixtures/execution/standalone-java-compile-run-v1.json similarity index 100% rename from shared/fixtures/execution/standalone-java-compile-run-v1.json rename to rust/fixtures/execution/standalone-java-compile-run-v1.json diff --git a/shared/fixtures/git/authentication-failure-v1.json b/rust/fixtures/git/authentication-failure-v1.json similarity index 100% rename from shared/fixtures/git/authentication-failure-v1.json rename to rust/fixtures/git/authentication-failure-v1.json diff --git a/shared/fixtures/git/branch-name-preview-v1.json b/rust/fixtures/git/branch-name-preview-v1.json similarity index 100% rename from shared/fixtures/git/branch-name-preview-v1.json rename to rust/fixtures/git/branch-name-preview-v1.json diff --git a/shared/fixtures/git/change-targets-v1.json b/rust/fixtures/git/change-targets-v1.json similarity index 100% rename from shared/fixtures/git/change-targets-v1.json rename to rust/fixtures/git/change-targets-v1.json diff --git a/shared/fixtures/git/changelists-reducer-v1.json b/rust/fixtures/git/changelists-reducer-v1.json similarity index 100% rename from shared/fixtures/git/changelists-reducer-v1.json rename to rust/fixtures/git/changelists-reducer-v1.json diff --git a/shared/fixtures/git/checkout-metadata-v1.json b/rust/fixtures/git/checkout-metadata-v1.json similarity index 100% rename from shared/fixtures/git/checkout-metadata-v1.json rename to rust/fixtures/git/checkout-metadata-v1.json diff --git a/shared/fixtures/git/command-error-response-v1.json b/rust/fixtures/git/command-error-response-v1.json similarity index 100% rename from shared/fixtures/git/command-error-response-v1.json rename to rust/fixtures/git/command-error-response-v1.json diff --git a/shared/fixtures/git/command-response-v1.json b/rust/fixtures/git/command-response-v1.json similarity index 100% rename from shared/fixtures/git/command-response-v1.json rename to rust/fixtures/git/command-response-v1.json diff --git a/shared/fixtures/git/commit-lookup-response-v1.json b/rust/fixtures/git/commit-lookup-response-v1.json similarity index 100% rename from shared/fixtures/git/commit-lookup-response-v1.json rename to rust/fixtures/git/commit-lookup-response-v1.json diff --git a/shared/fixtures/git/commit-message-context-v1.json b/rust/fixtures/git/commit-message-context-v1.json similarity index 100% rename from shared/fixtures/git/commit-message-context-v1.json rename to rust/fixtures/git/commit-message-context-v1.json diff --git a/shared/fixtures/git/commit-selection-plan-v1.json b/rust/fixtures/git/commit-selection-plan-v1.json similarity index 100% rename from shared/fixtures/git/commit-selection-plan-v1.json rename to rust/fixtures/git/commit-selection-plan-v1.json diff --git a/shared/fixtures/git/comparison-untracked-v1.json b/rust/fixtures/git/comparison-untracked-v1.json similarity index 100% rename from shared/fixtures/git/comparison-untracked-v1.json rename to rust/fixtures/git/comparison-untracked-v1.json diff --git a/shared/fixtures/git/console-command-v1.json b/rust/fixtures/git/console-command-v1.json similarity index 100% rename from shared/fixtures/git/console-command-v1.json rename to rust/fixtures/git/console-command-v1.json diff --git a/shared/fixtures/git/console-lifecycle-v1.json b/rust/fixtures/git/console-lifecycle-v1.json similarity index 100% rename from shared/fixtures/git/console-lifecycle-v1.json rename to rust/fixtures/git/console-lifecycle-v1.json diff --git a/shared/fixtures/git/console-presentation-v1.json b/rust/fixtures/git/console-presentation-v1.json similarity index 100% rename from shared/fixtures/git/console-presentation-v1.json rename to rust/fixtures/git/console-presentation-v1.json diff --git a/shared/fixtures/git/diff-blocks-v1.json b/rust/fixtures/git/diff-blocks-v1.json similarity index 100% rename from shared/fixtures/git/diff-blocks-v1.json rename to rust/fixtures/git/diff-blocks-v1.json diff --git a/shared/fixtures/git/diff.json b/rust/fixtures/git/diff.json similarity index 100% rename from shared/fixtures/git/diff.json rename to rust/fixtures/git/diff.json diff --git a/shared/fixtures/git/execution-events-v1.json b/rust/fixtures/git/execution-events-v1.json similarity index 100% rename from shared/fixtures/git/execution-events-v1.json rename to rust/fixtures/git/execution-events-v1.json diff --git a/shared/fixtures/git/execution-policy-v1.json b/rust/fixtures/git/execution-policy-v1.json similarity index 100% rename from shared/fixtures/git/execution-policy-v1.json rename to rust/fixtures/git/execution-policy-v1.json diff --git a/shared/fixtures/git/fetch-behavior-v1.json b/rust/fixtures/git/fetch-behavior-v1.json similarity index 100% rename from shared/fixtures/git/fetch-behavior-v1.json rename to rust/fixtures/git/fetch-behavior-v1.json diff --git a/shared/fixtures/git/fetch-plan-v1.json b/rust/fixtures/git/fetch-plan-v1.json similarity index 100% rename from shared/fixtures/git/fetch-plan-v1.json rename to rust/fixtures/git/fetch-plan-v1.json diff --git a/shared/fixtures/git/fetch-skip-boolean-v1.json b/rust/fixtures/git/fetch-skip-boolean-v1.json similarity index 100% rename from shared/fixtures/git/fetch-skip-boolean-v1.json rename to rust/fixtures/git/fetch-skip-boolean-v1.json diff --git a/shared/fixtures/git/graph-idea/NOTICE.txt b/rust/fixtures/git/graph-idea/NOTICE.txt similarity index 100% rename from shared/fixtures/git/graph-idea/NOTICE.txt rename to rust/fixtures/git/graph-idea/NOTICE.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/longEdges_in.txt b/rust/fixtures/git/graph-idea/elementGenerator/longEdges_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/longEdges_in.txt rename to rust/fixtures/git/graph-idea/elementGenerator/longEdges_in.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/longEdges_out.txt b/rust/fixtures/git/graph-idea/elementGenerator/longEdges_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/longEdges_out.txt rename to rust/fixtures/git/graph-idea/elementGenerator/longEdges_out.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/manyNodes_in.txt b/rust/fixtures/git/graph-idea/elementGenerator/manyNodes_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/manyNodes_in.txt rename to rust/fixtures/git/graph-idea/elementGenerator/manyNodes_in.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/manyNodes_out.txt b/rust/fixtures/git/graph-idea/elementGenerator/manyNodes_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/manyNodes_out.txt rename to rust/fixtures/git/graph-idea/elementGenerator/manyNodes_out.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/oneNode_in.txt b/rust/fixtures/git/graph-idea/elementGenerator/oneNode_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/oneNode_in.txt rename to rust/fixtures/git/graph-idea/elementGenerator/oneNode_in.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/oneNode_out.txt b/rust/fixtures/git/graph-idea/elementGenerator/oneNode_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/oneNode_out.txt rename to rust/fixtures/git/graph-idea/elementGenerator/oneNode_out.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_in.txt b/rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_in.txt rename to rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_in.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_out.txt b/rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_out.txt rename to rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown1_out.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_in.txt b/rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_in.txt rename to rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_in.txt diff --git a/shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_out.txt b/rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_out.txt rename to rust/fixtures/git/graph-idea/elementGenerator/oneUpOneDown2_out.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/manyNodes_in.txt b/rust/fixtures/git/graph-idea/layoutBuilder/manyNodes_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/manyNodes_in.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/manyNodes_in.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/manyNodes_out.txt b/rust/fixtures/git/graph-idea/layoutBuilder/manyNodes_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/manyNodes_out.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/manyNodes_out.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/notFullGraph_in.txt b/rust/fixtures/git/graph-idea/layoutBuilder/notFullGraph_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/notFullGraph_in.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/notFullGraph_in.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/notFullGraph_out.txt b/rust/fixtures/git/graph-idea/layoutBuilder/notFullGraph_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/notFullGraph_out.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/notFullGraph_out.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_in.txt b/rust/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_in.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_in.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_out.txt b/rust/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_out.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/oneNodeNotFullGraph_out.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/oneNode_in.txt b/rust/fixtures/git/graph-idea/layoutBuilder/oneNode_in.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/oneNode_in.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/oneNode_in.txt diff --git a/shared/fixtures/git/graph-idea/layoutBuilder/oneNode_out.txt b/rust/fixtures/git/graph-idea/layoutBuilder/oneNode_out.txt similarity index 100% rename from shared/fixtures/git/graph-idea/layoutBuilder/oneNode_out.txt rename to rust/fixtures/git/graph-idea/layoutBuilder/oneNode_out.txt diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-context-idea.txt b/rust/fixtures/git/graph-issue410/issue410-context-idea.txt similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-context-idea.txt rename to rust/fixtures/git/graph-issue410/issue410-context-idea.txt diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-context.tsv b/rust/fixtures/git/graph-issue410/issue410-context.tsv similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-context.tsv rename to rust/fixtures/git/graph-issue410/issue410-context.tsv diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-date-context.tsv b/rust/fixtures/git/graph-issue410/issue410-date-context.tsv similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-date-context.tsv rename to rust/fixtures/git/graph-issue410/issue410-date-context.tsv diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-date-history.tsv b/rust/fixtures/git/graph-issue410/issue410-date-history.tsv similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-date-history.tsv rename to rust/fixtures/git/graph-issue410/issue410-date-history.tsv diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-date-idea.txt b/rust/fixtures/git/graph-issue410/issue410-date-idea.txt similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-date-idea.txt rename to rust/fixtures/git/graph-issue410/issue410-date-idea.txt diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-history.tsv b/rust/fixtures/git/graph-issue410/issue410-history.tsv similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-history.tsv rename to rust/fixtures/git/graph-issue410/issue410-history.tsv diff --git a/macos/Tests/LitheTests/Fixtures/GitGraph/issue410-idea.txt b/rust/fixtures/git/graph-issue410/issue410-idea.txt similarity index 100% rename from macos/Tests/LitheTests/Fixtures/GitGraph/issue410-idea.txt rename to rust/fixtures/git/graph-issue410/issue410-idea.txt diff --git a/shared/fixtures/git/graph-projection-v1.json b/rust/fixtures/git/graph-projection-v1.json similarity index 100% rename from shared/fixtures/git/graph-projection-v1.json rename to rust/fixtures/git/graph-projection-v1.json diff --git a/shared/fixtures/git/graph.json b/rust/fixtures/git/graph.json similarity index 100% rename from shared/fixtures/git/graph.json rename to rust/fixtures/git/graph.json diff --git a/shared/fixtures/git/history-action-capabilities-v1.json b/rust/fixtures/git/history-action-capabilities-v1.json similarity index 100% rename from shared/fixtures/git/history-action-capabilities-v1.json rename to rust/fixtures/git/history-action-capabilities-v1.json diff --git a/shared/fixtures/git/history-date-query-v1.json b/rust/fixtures/git/history-date-query-v1.json similarity index 100% rename from shared/fixtures/git/history-date-query-v1.json rename to rust/fixtures/git/history-date-query-v1.json diff --git a/shared/fixtures/git/history-locate-v1.json b/rust/fixtures/git/history-locate-v1.json similarity index 100% rename from shared/fixtures/git/history-locate-v1.json rename to rust/fixtures/git/history-locate-v1.json diff --git a/shared/fixtures/git/history-page-date-request-v1.json b/rust/fixtures/git/history-page-date-request-v1.json similarity index 100% rename from shared/fixtures/git/history-page-date-request-v1.json rename to rust/fixtures/git/history-page-date-request-v1.json diff --git a/shared/fixtures/git/history-page-response-v1.json b/rust/fixtures/git/history-page-response-v1.json similarity index 100% rename from shared/fixtures/git/history-page-response-v1.json rename to rust/fixtures/git/history-page-response-v1.json diff --git a/shared/fixtures/git/history-response-v1.json b/rust/fixtures/git/history-response-v1.json similarity index 100% rename from shared/fixtures/git/history-response-v1.json rename to rust/fixtures/git/history-response-v1.json diff --git a/shared/fixtures/git/history-rewrite-v1.json b/rust/fixtures/git/history-rewrite-v1.json similarity index 100% rename from shared/fixtures/git/history-rewrite-v1.json rename to rust/fixtures/git/history-rewrite-v1.json diff --git a/shared/fixtures/git/history-window-v1.json b/rust/fixtures/git/history-window-v1.json similarity index 100% rename from shared/fixtures/git/history-window-v1.json rename to rust/fixtures/git/history-window-v1.json diff --git a/shared/fixtures/git/local-changelist-scope-v1.json b/rust/fixtures/git/local-changelist-scope-v1.json similarity index 100% rename from shared/fixtures/git/local-changelist-scope-v1.json rename to rust/fixtures/git/local-changelist-scope-v1.json diff --git a/shared/fixtures/git/metadata-classification-v1.json b/rust/fixtures/git/metadata-classification-v1.json similarity index 100% rename from shared/fixtures/git/metadata-classification-v1.json rename to rust/fixtures/git/metadata-classification-v1.json diff --git a/shared/fixtures/git/parsed-diff-v1.json b/rust/fixtures/git/parsed-diff-v1.json similarity index 100% rename from shared/fixtures/git/parsed-diff-v1.json rename to rust/fixtures/git/parsed-diff-v1.json diff --git a/shared/fixtures/git/patch-exchange-v1.json b/rust/fixtures/git/patch-exchange-v1.json similarity index 100% rename from shared/fixtures/git/patch-exchange-v1.json rename to rust/fixtures/git/patch-exchange-v1.json diff --git a/shared/fixtures/git/push-preview-v1.json b/rust/fixtures/git/push-preview-v1.json similarity index 100% rename from shared/fixtures/git/push-preview-v1.json rename to rust/fixtures/git/push-preview-v1.json diff --git a/shared/fixtures/git/rebase-plan-preview-v1.json b/rust/fixtures/git/rebase-plan-preview-v1.json similarity index 100% rename from shared/fixtures/git/rebase-plan-preview-v1.json rename to rust/fixtures/git/rebase-plan-preview-v1.json diff --git a/shared/fixtures/git/rebase-session-v1.json b/rust/fixtures/git/rebase-session-v1.json similarity index 100% rename from shared/fixtures/git/rebase-session-v1.json rename to rust/fixtures/git/rebase-session-v1.json diff --git a/shared/fixtures/git/reference-tree-v1.json b/rust/fixtures/git/reference-tree-v1.json similarity index 100% rename from shared/fixtures/git/reference-tree-v1.json rename to rust/fixtures/git/reference-tree-v1.json diff --git a/shared/fixtures/git/references-response-v1.json b/rust/fixtures/git/references-response-v1.json similarity index 100% rename from shared/fixtures/git/references-response-v1.json rename to rust/fixtures/git/references-response-v1.json diff --git a/shared/fixtures/git/remote-checkout-v1.json b/rust/fixtures/git/remote-checkout-v1.json similarity index 100% rename from shared/fixtures/git/remote-checkout-v1.json rename to rust/fixtures/git/remote-checkout-v1.json diff --git a/shared/fixtures/git/remote-tag-catalog-v1.json b/rust/fixtures/git/remote-tag-catalog-v1.json similarity index 100% rename from shared/fixtures/git/remote-tag-catalog-v1.json rename to rust/fixtures/git/remote-tag-catalog-v1.json diff --git a/shared/fixtures/git/remote-url-v1.json b/rust/fixtures/git/remote-url-v1.json similarity index 100% rename from shared/fixtures/git/remote-url-v1.json rename to rust/fixtures/git/remote-url-v1.json diff --git a/shared/fixtures/git/repository-colors-v1.json b/rust/fixtures/git/repository-colors-v1.json similarity index 100% rename from shared/fixtures/git/repository-colors-v1.json rename to rust/fixtures/git/repository-colors-v1.json diff --git a/shared/fixtures/git/repository-setup-v1.json b/rust/fixtures/git/repository-setup-v1.json similarity index 100% rename from shared/fixtures/git/repository-setup-v1.json rename to rust/fixtures/git/repository-setup-v1.json diff --git a/shared/fixtures/git/rollback-plan-v1.json b/rust/fixtures/git/rollback-plan-v1.json similarity index 100% rename from shared/fixtures/git/rollback-plan-v1.json rename to rust/fixtures/git/rollback-plan-v1.json diff --git a/shared/fixtures/git/saved-stash-v1.json b/rust/fixtures/git/saved-stash-v1.json similarity index 100% rename from shared/fixtures/git/saved-stash-v1.json rename to rust/fixtures/git/saved-stash-v1.json diff --git a/shared/fixtures/git/shelf-v1.json b/rust/fixtures/git/shelf-v1.json similarity index 100% rename from shared/fixtures/git/shelf-v1.json rename to rust/fixtures/git/shelf-v1.json diff --git a/shared/fixtures/git/tag-names.json b/rust/fixtures/git/tag-names.json similarity index 100% rename from shared/fixtures/git/tag-names.json rename to rust/fixtures/git/tag-names.json diff --git a/shared/fixtures/git/tag-write-v1.json b/rust/fixtures/git/tag-write-v1.json similarity index 100% rename from shared/fixtures/git/tag-write-v1.json rename to rust/fixtures/git/tag-write-v1.json diff --git a/shared/fixtures/git/update-plan-v1.json b/rust/fixtures/git/update-plan-v1.json similarity index 100% rename from shared/fixtures/git/update-plan-v1.json rename to rust/fixtures/git/update-plan-v1.json diff --git a/shared/fixtures/git/windows-paths.json b/rust/fixtures/git/windows-paths.json similarity index 100% rename from shared/fixtures/git/windows-paths.json rename to rust/fixtures/git/windows-paths.json diff --git a/shared/fixtures/git/workspace-commit-v1.json b/rust/fixtures/git/workspace-commit-v1.json similarity index 100% rename from shared/fixtures/git/workspace-commit-v1.json rename to rust/fixtures/git/workspace-commit-v1.json diff --git a/shared/fixtures/git/workspace-commit-workflow-v1.json b/rust/fixtures/git/workspace-commit-workflow-v1.json similarity index 100% rename from shared/fixtures/git/workspace-commit-workflow-v1.json rename to rust/fixtures/git/workspace-commit-workflow-v1.json diff --git a/shared/fixtures/git/worktree-creation-v1.json b/rust/fixtures/git/worktree-creation-v1.json similarity index 100% rename from shared/fixtures/git/worktree-creation-v1.json rename to rust/fixtures/git/worktree-creation-v1.json diff --git a/shared/fixtures/git/worktrees-v1.json b/rust/fixtures/git/worktrees-v1.json similarity index 100% rename from shared/fixtures/git/worktrees-v1.json rename to rust/fixtures/git/worktrees-v1.json diff --git a/shared/fixtures/git/write.json b/rust/fixtures/git/write.json similarity index 100% rename from shared/fixtures/git/write.json rename to rust/fixtures/git/write.json diff --git a/shared/fixtures/github/device-authorization-transition-v1.json b/rust/fixtures/github/device-authorization-transition-v1.json similarity index 100% rename from shared/fixtures/github/device-authorization-transition-v1.json rename to rust/fixtures/github/device-authorization-transition-v1.json diff --git a/shared/fixtures/github/pr-checkout-plan-v1.json b/rust/fixtures/github/pr-checkout-plan-v1.json similarity index 100% rename from shared/fixtures/github/pr-checkout-plan-v1.json rename to rust/fixtures/github/pr-checkout-plan-v1.json diff --git a/shared/fixtures/github/pull-request-v1.json b/rust/fixtures/github/pull-request-v1.json similarity index 100% rename from shared/fixtures/github/pull-request-v1.json rename to rust/fixtures/github/pull-request-v1.json diff --git a/shared/fixtures/history/basic.json b/rust/fixtures/history/basic.json similarity index 100% rename from shared/fixtures/history/basic.json rename to rust/fixtures/history/basic.json diff --git a/shared/fixtures/history/event-plan-v1.json b/rust/fixtures/history/event-plan-v1.json similarity index 100% rename from shared/fixtures/history/event-plan-v1.json rename to rust/fixtures/history/event-plan-v1.json diff --git a/shared/fixtures/history/relocation-v1.json b/rust/fixtures/history/relocation-v1.json similarity index 100% rename from shared/fixtures/history/relocation-v1.json rename to rust/fixtures/history/relocation-v1.json diff --git a/shared/fixtures/i18n/format-v1.json b/rust/fixtures/i18n/format-v1.json similarity index 100% rename from shared/fixtures/i18n/format-v1.json rename to rust/fixtures/i18n/format-v1.json diff --git a/shared/fixtures/i18n/native-abi-v1.json b/rust/fixtures/i18n/native-abi-v1.json similarity index 100% rename from shared/fixtures/i18n/native-abi-v1.json rename to rust/fixtures/i18n/native-abi-v1.json diff --git a/shared/fixtures/ide-api/calls.json b/rust/fixtures/ide-api/calls.json similarity index 100% rename from shared/fixtures/ide-api/calls.json rename to rust/fixtures/ide-api/calls.json diff --git a/shared/fixtures/ide-api/host-tool-exposure.json b/rust/fixtures/ide-api/host-tool-exposure.json similarity index 100% rename from shared/fixtures/ide-api/host-tool-exposure.json rename to rust/fixtures/ide-api/host-tool-exposure.json diff --git a/shared/fixtures/ide-api/mcp-protocol.json b/rust/fixtures/ide-api/mcp-protocol.json similarity index 100% rename from shared/fixtures/ide-api/mcp-protocol.json rename to rust/fixtures/ide-api/mcp-protocol.json diff --git a/shared/fixtures/java/basic.json b/rust/fixtures/java/basic.json similarity index 100% rename from shared/fixtures/java/basic.json rename to rust/fixtures/java/basic.json diff --git a/shared/fixtures/java/run-markers-v1.json b/rust/fixtures/java/run-markers-v1.json similarity index 100% rename from shared/fixtures/java/run-markers-v1.json rename to rust/fixtures/java/run-markers-v1.json diff --git a/shared/fixtures/keymap/binding-conflicts-v1.json b/rust/fixtures/keymap/binding-conflicts-v1.json similarity index 100% rename from shared/fixtures/keymap/binding-conflicts-v1.json rename to rust/fixtures/keymap/binding-conflicts-v1.json diff --git a/shared/fixtures/keymap/context-v1.json b/rust/fixtures/keymap/context-v1.json similarity index 100% rename from shared/fixtures/keymap/context-v1.json rename to rust/fixtures/keymap/context-v1.json diff --git a/shared/fixtures/keymap/import-plan-v1.json b/rust/fixtures/keymap/import-plan-v1.json similarity index 100% rename from shared/fixtures/keymap/import-plan-v1.json rename to rust/fixtures/keymap/import-plan-v1.json diff --git a/shared/fixtures/keymap/import-provider-v1.json b/rust/fixtures/keymap/import-provider-v1.json similarity index 100% rename from shared/fixtures/keymap/import-provider-v1.json rename to rust/fixtures/keymap/import-provider-v1.json diff --git a/shared/fixtures/keymap/modifier-gesture-v1.json b/rust/fixtures/keymap/modifier-gesture-v1.json similarity index 100% rename from shared/fixtures/keymap/modifier-gesture-v1.json rename to rust/fixtures/keymap/modifier-gesture-v1.json diff --git a/shared/fixtures/keymap/provider-negotiation-v1.json b/rust/fixtures/keymap/provider-negotiation-v1.json similarity index 100% rename from shared/fixtures/keymap/provider-negotiation-v1.json rename to rust/fixtures/keymap/provider-negotiation-v1.json diff --git a/shared/fixtures/keymap/source-plans-v1.json b/rust/fixtures/keymap/source-plans-v1.json similarity index 100% rename from shared/fixtures/keymap/source-plans-v1.json rename to rust/fixtures/keymap/source-plans-v1.json diff --git a/shared/fixtures/lsp/activation-policy-v1.json b/rust/fixtures/lsp/activation-policy-v1.json similarity index 100% rename from shared/fixtures/lsp/activation-policy-v1.json rename to rust/fixtures/lsp/activation-policy-v1.json diff --git a/shared/fixtures/lsp/completion-merge-v1.json b/rust/fixtures/lsp/completion-merge-v1.json similarity index 100% rename from shared/fixtures/lsp/completion-merge-v1.json rename to rust/fixtures/lsp/completion-merge-v1.json diff --git a/shared/fixtures/lsp/completion-refresh-v1.json b/rust/fixtures/lsp/completion-refresh-v1.json similarity index 100% rename from shared/fixtures/lsp/completion-refresh-v1.json rename to rust/fixtures/lsp/completion-refresh-v1.json diff --git a/shared/fixtures/lsp/diagnostic-code-actions-v1.json b/rust/fixtures/lsp/diagnostic-code-actions-v1.json similarity index 100% rename from shared/fixtures/lsp/diagnostic-code-actions-v1.json rename to rust/fixtures/lsp/diagnostic-code-actions-v1.json diff --git a/shared/fixtures/lsp/document-identity-v1.json b/rust/fixtures/lsp/document-identity-v1.json similarity index 100% rename from shared/fixtures/lsp/document-identity-v1.json rename to rust/fixtures/lsp/document-identity-v1.json diff --git a/shared/fixtures/lsp/document-save-v1.json b/rust/fixtures/lsp/document-save-v1.json similarity index 100% rename from shared/fixtures/lsp/document-save-v1.json rename to rust/fixtures/lsp/document-save-v1.json diff --git a/shared/fixtures/lsp/document-symbols-v1.json b/rust/fixtures/lsp/document-symbols-v1.json similarity index 100% rename from shared/fixtures/lsp/document-symbols-v1.json rename to rust/fixtures/lsp/document-symbols-v1.json diff --git a/shared/fixtures/lsp/document-uri-v1.json b/rust/fixtures/lsp/document-uri-v1.json similarity index 100% rename from shared/fixtures/lsp/document-uri-v1.json rename to rust/fixtures/lsp/document-uri-v1.json diff --git a/shared/fixtures/lsp/java-build-report-v1.json b/rust/fixtures/lsp/java-build-report-v1.json similarity index 100% rename from shared/fixtures/lsp/java-build-report-v1.json rename to rust/fixtures/lsp/java-build-report-v1.json diff --git a/shared/fixtures/lsp/java-completion-v1.json b/rust/fixtures/lsp/java-completion-v1.json similarity index 100% rename from shared/fixtures/lsp/java-completion-v1.json rename to rust/fixtures/lsp/java-completion-v1.json diff --git a/shared/fixtures/lsp/java-launch-preparation-v1.json b/rust/fixtures/lsp/java-launch-preparation-v1.json similarity index 100% rename from shared/fixtures/lsp/java-launch-preparation-v1.json rename to rust/fixtures/lsp/java-launch-preparation-v1.json diff --git a/shared/fixtures/lsp/java-navigation-v1.json b/rust/fixtures/lsp/java-navigation-v1.json similarity index 100% rename from shared/fixtures/lsp/java-navigation-v1.json rename to rust/fixtures/lsp/java-navigation-v1.json diff --git a/shared/fixtures/lsp/java-project-runtimes-v1.json b/rust/fixtures/lsp/java-project-runtimes-v1.json similarity index 100% rename from shared/fixtures/lsp/java-project-runtimes-v1.json rename to rust/fixtures/lsp/java-project-runtimes-v1.json diff --git a/shared/fixtures/lsp/java-workspace-policy-v1.json b/rust/fixtures/lsp/java-workspace-policy-v1.json similarity index 89% rename from shared/fixtures/lsp/java-workspace-policy-v1.json rename to rust/fixtures/lsp/java-workspace-policy-v1.json index 463618f1..8154b9fe 100644 --- a/shared/fixtures/lsp/java-workspace-policy-v1.json +++ b/rust/fixtures/lsp/java-workspace-policy-v1.json @@ -65,13 +65,13 @@ "workspacePaths": [ "Cargo.toml", "Package.swift", - "shared/fixtures/projects/demo/pom.xml", - "shared/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java" + "rust/fixtures/projects/demo/pom.xml", + "rust/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java" ], "changedPaths": [], "expected": { "shouldStart": false, - "representativeJavaPath": "shared/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java", + "representativeJavaPath": "rust/fixtures/projects/demo/src/main/java/com/example/demo/DemoApplication.java", "changes": [] } } diff --git a/shared/fixtures/lsp/jdt-cache-retention-v1.json b/rust/fixtures/lsp/jdt-cache-retention-v1.json similarity index 100% rename from shared/fixtures/lsp/jdt-cache-retention-v1.json rename to rust/fixtures/lsp/jdt-cache-retention-v1.json diff --git a/shared/fixtures/lsp/jdt-direct-launch-v1.json b/rust/fixtures/lsp/jdt-direct-launch-v1.json similarity index 100% rename from shared/fixtures/lsp/jdt-direct-launch-v1.json rename to rust/fixtures/lsp/jdt-direct-launch-v1.json diff --git a/shared/fixtures/lsp/jdt-readiness-v1.json b/rust/fixtures/lsp/jdt-readiness-v1.json similarity index 100% rename from shared/fixtures/lsp/jdt-readiness-v1.json rename to rust/fixtures/lsp/jdt-readiness-v1.json diff --git a/shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json b/rust/fixtures/lsp/jdt-workspace-fingerprint-v1.json similarity index 100% rename from shared/fixtures/lsp/jdt-workspace-fingerprint-v1.json rename to rust/fixtures/lsp/jdt-workspace-fingerprint-v1.json diff --git a/shared/fixtures/lsp/jdt-workspace-key-v1.json b/rust/fixtures/lsp/jdt-workspace-key-v1.json similarity index 100% rename from shared/fixtures/lsp/jdt-workspace-key-v1.json rename to rust/fixtures/lsp/jdt-workspace-key-v1.json diff --git a/shared/fixtures/lsp/maven-profile-events-v1.json b/rust/fixtures/lsp/maven-profile-events-v1.json similarity index 100% rename from shared/fixtures/lsp/maven-profile-events-v1.json rename to rust/fixtures/lsp/maven-profile-events-v1.json diff --git a/shared/fixtures/lsp/navigation-target-v1.json b/rust/fixtures/lsp/navigation-target-v1.json similarity index 100% rename from shared/fixtures/lsp/navigation-target-v1.json rename to rust/fixtures/lsp/navigation-target-v1.json diff --git a/shared/fixtures/lsp/problems-query-v1.json b/rust/fixtures/lsp/problems-query-v1.json similarity index 100% rename from shared/fixtures/lsp/problems-query-v1.json rename to rust/fixtures/lsp/problems-query-v1.json diff --git a/shared/fixtures/lsp/project-preparation-v1.json b/rust/fixtures/lsp/project-preparation-v1.json similarity index 100% rename from shared/fixtures/lsp/project-preparation-v1.json rename to rust/fixtures/lsp/project-preparation-v1.json diff --git a/shared/fixtures/lsp/semantic-tokens-v1.json b/rust/fixtures/lsp/semantic-tokens-v1.json similarity index 100% rename from shared/fixtures/lsp/semantic-tokens-v1.json rename to rust/fixtures/lsp/semantic-tokens-v1.json diff --git a/shared/fixtures/lsp/text-edits-v1.json b/rust/fixtures/lsp/text-edits-v1.json similarity index 100% rename from shared/fixtures/lsp/text-edits-v1.json rename to rust/fixtures/lsp/text-edits-v1.json diff --git a/shared/fixtures/lsp/workspace-edit-plan-v1.json b/rust/fixtures/lsp/workspace-edit-plan-v1.json similarity index 100% rename from shared/fixtures/lsp/workspace-edit-plan-v1.json rename to rust/fixtures/lsp/workspace-edit-plan-v1.json diff --git a/shared/fixtures/maven/basic.json b/rust/fixtures/maven/basic.json similarity index 100% rename from shared/fixtures/maven/basic.json rename to rust/fixtures/maven/basic.json diff --git a/shared/fixtures/maven/dependency-tree-v2.json b/rust/fixtures/maven/dependency-tree-v2.json similarity index 100% rename from shared/fixtures/maven/dependency-tree-v2.json rename to rust/fixtures/maven/dependency-tree-v2.json diff --git a/shared/fixtures/maven/diagnostics.json b/rust/fixtures/maven/diagnostics.json similarity index 100% rename from shared/fixtures/maven/diagnostics.json rename to rust/fixtures/maven/diagnostics.json diff --git a/shared/fixtures/maven/launch-plan-v1.json b/rust/fixtures/maven/launch-plan-v1.json similarity index 100% rename from shared/fixtures/maven/launch-plan-v1.json rename to rust/fixtures/maven/launch-plan-v1.json diff --git a/shared/fixtures/maven/platform-contract-v1.json b/rust/fixtures/maven/platform-contract-v1.json similarity index 100% rename from shared/fixtures/maven/platform-contract-v1.json rename to rust/fixtures/maven/platform-contract-v1.json diff --git a/shared/fixtures/maven/source-roots-v1.json b/rust/fixtures/maven/source-roots-v1.json similarity index 100% rename from shared/fixtures/maven/source-roots-v1.json rename to rust/fixtures/maven/source-roots-v1.json diff --git a/shared/fixtures/maven/test-results-v1.json b/rust/fixtures/maven/test-results-v1.json similarity index 100% rename from shared/fixtures/maven/test-results-v1.json rename to rust/fixtures/maven/test-results-v1.json diff --git a/shared/fixtures/maven/testcase-durations-v1.json b/rust/fixtures/maven/testcase-durations-v1.json similarity index 100% rename from shared/fixtures/maven/testcase-durations-v1.json rename to rust/fixtures/maven/testcase-durations-v1.json diff --git a/shared/fixtures/mcp/featured-setup-v1.json b/rust/fixtures/mcp/featured-setup-v1.json similarity index 100% rename from shared/fixtures/mcp/featured-setup-v1.json rename to rust/fixtures/mcp/featured-setup-v1.json diff --git a/shared/fixtures/mcp/library-v1.json b/rust/fixtures/mcp/library-v1.json similarity index 100% rename from shared/fixtures/mcp/library-v1.json rename to rust/fixtures/mcp/library-v1.json diff --git a/shared/fixtures/mcp/tools-v1.json b/rust/fixtures/mcp/tools-v1.json similarity index 100% rename from shared/fixtures/mcp/tools-v1.json rename to rust/fixtures/mcp/tools-v1.json diff --git a/shared/fixtures/modules/built-in-v1.json b/rust/fixtures/modules/built-in-v1.json similarity index 100% rename from shared/fixtures/modules/built-in-v1.json rename to rust/fixtures/modules/built-in-v1.json diff --git a/shared/fixtures/mybatis/basic.json b/rust/fixtures/mybatis/basic.json similarity index 100% rename from shared/fixtures/mybatis/basic.json rename to rust/fixtures/mybatis/basic.json diff --git a/shared/fixtures/network/proxy-policy-v1.json b/rust/fixtures/network/proxy-policy-v1.json similarity index 100% rename from shared/fixtures/network/proxy-policy-v1.json rename to rust/fixtures/network/proxy-policy-v1.json diff --git a/shared/fixtures/phpunit-project/.gitignore b/rust/fixtures/phpunit-project/.gitignore similarity index 100% rename from shared/fixtures/phpunit-project/.gitignore rename to rust/fixtures/phpunit-project/.gitignore diff --git a/shared/fixtures/phpunit-project/composer.json b/rust/fixtures/phpunit-project/composer.json similarity index 100% rename from shared/fixtures/phpunit-project/composer.json rename to rust/fixtures/phpunit-project/composer.json diff --git a/shared/fixtures/phpunit-project/phpunit.xml b/rust/fixtures/phpunit-project/phpunit.xml similarity index 100% rename from shared/fixtures/phpunit-project/phpunit.xml rename to rust/fixtures/phpunit-project/phpunit.xml diff --git a/shared/fixtures/phpunit-project/src/Store.php b/rust/fixtures/phpunit-project/src/Store.php similarity index 100% rename from shared/fixtures/phpunit-project/src/Store.php rename to rust/fixtures/phpunit-project/src/Store.php diff --git a/shared/fixtures/phpunit-project/tests/StoreTest.php b/rust/fixtures/phpunit-project/tests/StoreTest.php similarity index 100% rename from shared/fixtures/phpunit-project/tests/StoreTest.php rename to rust/fixtures/phpunit-project/tests/StoreTest.php diff --git a/shared/fixtures/plugins/host-api-v1.json b/rust/fixtures/plugins/host-api-v1.json similarity index 100% rename from shared/fixtures/plugins/host-api-v1.json rename to rust/fixtures/plugins/host-api-v1.json diff --git a/shared/fixtures/plugins/installed-versions.json b/rust/fixtures/plugins/installed-versions.json similarity index 100% rename from shared/fixtures/plugins/installed-versions.json rename to rust/fixtures/plugins/installed-versions.json diff --git a/shared/fixtures/plugins/publisher-signature.json b/rust/fixtures/plugins/publisher-signature.json similarity index 100% rename from shared/fixtures/plugins/publisher-signature.json rename to rust/fixtures/plugins/publisher-signature.json diff --git a/shared/fixtures/plugins/settings-actions.json b/rust/fixtures/plugins/settings-actions.json similarity index 100% rename from shared/fixtures/plugins/settings-actions.json rename to rust/fixtures/plugins/settings-actions.json diff --git a/shared/fixtures/plugins/settings-documents.json b/rust/fixtures/plugins/settings-documents.json similarity index 100% rename from shared/fixtures/plugins/settings-documents.json rename to rust/fixtures/plugins/settings-documents.json diff --git a/shared/fixtures/plugins/settings-function-provider.json b/rust/fixtures/plugins/settings-function-provider.json similarity index 100% rename from shared/fixtures/plugins/settings-function-provider.json rename to rust/fixtures/plugins/settings-function-provider.json diff --git a/shared/fixtures/plugins/settings-surfaces.json b/rust/fixtures/plugins/settings-surfaces.json similarity index 100% rename from shared/fixtures/plugins/settings-surfaces.json rename to rust/fixtures/plugins/settings-surfaces.json diff --git a/shared/fixtures/process-lifecycle/exit-plan-v1.json b/rust/fixtures/process-lifecycle/exit-plan-v1.json similarity index 100% rename from shared/fixtures/process-lifecycle/exit-plan-v1.json rename to rust/fixtures/process-lifecycle/exit-plan-v1.json diff --git a/shared/fixtures/process-lifecycle/inventory-v1.json b/rust/fixtures/process-lifecycle/inventory-v1.json similarity index 100% rename from shared/fixtures/process-lifecycle/inventory-v1.json rename to rust/fixtures/process-lifecycle/inventory-v1.json diff --git a/shared/fixtures/process-lifecycle/settle-v1.json b/rust/fixtures/process-lifecycle/settle-v1.json similarity index 100% rename from shared/fixtures/process-lifecycle/settle-v1.json rename to rust/fixtures/process-lifecycle/settle-v1.json diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/.classpath b/rust/fixtures/projects/lithe-spring-boot-git-graph/.classpath similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/.classpath rename to rust/fixtures/projects/lithe-spring-boot-git-graph/.classpath diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/.project b/rust/fixtures/projects/lithe-spring-boot-git-graph/.project similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/.project rename to rust/fixtures/projects/lithe-spring-boot-git-graph/.project diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.core.resources.prefs b/rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.core.resources.prefs similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.core.resources.prefs rename to rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.core.resources.prefs diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.apt.core.prefs b/rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.apt.core.prefs similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.apt.core.prefs rename to rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.apt.core.prefs diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.core.prefs b/rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.core.prefs similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.core.prefs rename to rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.jdt.core.prefs diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.m2e.core.prefs b/rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.m2e.core.prefs similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.m2e.core.prefs rename to rust/fixtures/projects/lithe-spring-boot-git-graph/.settings/org.eclipse.m2e.core.prefs diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/README.md b/rust/fixtures/projects/lithe-spring-boot-git-graph/README.md similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/README.md rename to rust/fixtures/projects/lithe-spring-boot-git-graph/README.md diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/history/OrderController-feature.java b/rust/fixtures/projects/lithe-spring-boot-git-graph/history/OrderController-feature.java similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/history/OrderController-feature.java rename to rust/fixtures/projects/lithe-spring-boot-git-graph/history/OrderController-feature.java diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/history/UserService-feature.java b/rust/fixtures/projects/lithe-spring-boot-git-graph/history/UserService-feature.java similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/history/UserService-feature.java rename to rust/fixtures/projects/lithe-spring-boot-git-graph/history/UserService-feature.java diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/pom.xml b/rust/fixtures/projects/lithe-spring-boot-git-graph/pom.xml similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/pom.xml rename to rust/fixtures/projects/lithe-spring-boot-git-graph/pom.xml diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/DemoApplication.java b/rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/DemoApplication.java similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/DemoApplication.java rename to rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/DemoApplication.java diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserController.java b/rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserController.java similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserController.java rename to rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserController.java diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserRepository.java b/rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserRepository.java similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserRepository.java rename to rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserRepository.java diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserService.java b/rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserService.java similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserService.java rename to rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/java/com/example/demo/user/UserService.java diff --git a/shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/resources/application.yml b/rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/resources/application.yml similarity index 100% rename from shared/fixtures/projects/lithe-spring-boot-git-graph/src/main/resources/application.yml rename to rust/fixtures/projects/lithe-spring-boot-git-graph/src/main/resources/application.yml diff --git a/shared/fixtures/run-configuration/argument-text.json b/rust/fixtures/run-configuration/argument-text.json similarity index 100% rename from shared/fixtures/run-configuration/argument-text.json rename to rust/fixtures/run-configuration/argument-text.json diff --git a/shared/fixtures/run-configuration/automatic-java-selection.json b/rust/fixtures/run-configuration/automatic-java-selection.json similarity index 100% rename from shared/fixtures/run-configuration/automatic-java-selection.json rename to rust/fixtures/run-configuration/automatic-java-selection.json diff --git a/shared/fixtures/run-configuration/basic.json b/rust/fixtures/run-configuration/basic.json similarity index 100% rename from shared/fixtures/run-configuration/basic.json rename to rust/fixtures/run-configuration/basic.json diff --git a/shared/fixtures/run-configuration/build-provider-options.json b/rust/fixtures/run-configuration/build-provider-options.json similarity index 100% rename from shared/fixtures/run-configuration/build-provider-options.json rename to rust/fixtures/run-configuration/build-provider-options.json diff --git a/shared/fixtures/run-configuration/build-provider-stable-identities.json b/rust/fixtures/run-configuration/build-provider-stable-identities.json similarity index 100% rename from shared/fixtures/run-configuration/build-provider-stable-identities.json rename to rust/fixtures/run-configuration/build-provider-stable-identities.json diff --git a/shared/fixtures/run-configuration/build-provider.json b/rust/fixtures/run-configuration/build-provider.json similarity index 100% rename from shared/fixtures/run-configuration/build-provider.json rename to rust/fixtures/run-configuration/build-provider.json diff --git a/shared/fixtures/run-configuration/configuration-toolchain-selection.json b/rust/fixtures/run-configuration/configuration-toolchain-selection.json similarity index 100% rename from shared/fixtures/run-configuration/configuration-toolchain-selection.json rename to rust/fixtures/run-configuration/configuration-toolchain-selection.json diff --git a/shared/fixtures/run-configuration/context-selection.json b/rust/fixtures/run-configuration/context-selection.json similarity index 100% rename from shared/fixtures/run-configuration/context-selection.json rename to rust/fixtures/run-configuration/context-selection.json diff --git a/shared/fixtures/run-configuration/editor-save.json b/rust/fixtures/run-configuration/editor-save.json similarity index 100% rename from shared/fixtures/run-configuration/editor-save.json rename to rust/fixtures/run-configuration/editor-save.json diff --git a/shared/fixtures/run-configuration/hybrid-spring-vue.json b/rust/fixtures/run-configuration/hybrid-spring-vue.json similarity index 100% rename from shared/fixtures/run-configuration/hybrid-spring-vue.json rename to rust/fixtures/run-configuration/hybrid-spring-vue.json diff --git a/shared/fixtures/run-configuration/invalid-version.json b/rust/fixtures/run-configuration/invalid-version.json similarity index 100% rename from shared/fixtures/run-configuration/invalid-version.json rename to rust/fixtures/run-configuration/invalid-version.json diff --git a/shared/fixtures/run-configuration/maven-module-ownership.json b/rust/fixtures/run-configuration/maven-module-ownership.json similarity index 100% rename from shared/fixtures/run-configuration/maven-module-ownership.json rename to rust/fixtures/run-configuration/maven-module-ownership.json diff --git a/shared/fixtures/run-configuration/multi-module.json b/rust/fixtures/run-configuration/multi-module.json similarity index 100% rename from shared/fixtures/run-configuration/multi-module.json rename to rust/fixtures/run-configuration/multi-module.json diff --git a/shared/fixtures/run-configuration/no-entry.json b/rust/fixtures/run-configuration/no-entry.json similarity index 100% rename from shared/fixtures/run-configuration/no-entry.json rename to rust/fixtures/run-configuration/no-entry.json diff --git a/shared/fixtures/run-configuration/project-environment.json b/rust/fixtures/run-configuration/project-environment.json similarity index 100% rename from shared/fixtures/run-configuration/project-environment.json rename to rust/fixtures/run-configuration/project-environment.json diff --git a/shared/fixtures/run-configuration/project-toolchain-selection.json b/rust/fixtures/run-configuration/project-toolchain-selection.json similarity index 100% rename from shared/fixtures/run-configuration/project-toolchain-selection.json rename to rust/fixtures/run-configuration/project-toolchain-selection.json diff --git a/shared/fixtures/run-configuration/provider-prepared-java.json b/rust/fixtures/run-configuration/provider-prepared-java.json similarity index 100% rename from shared/fixtures/run-configuration/provider-prepared-java.json rename to rust/fixtures/run-configuration/provider-prepared-java.json diff --git a/shared/fixtures/run-configuration/provider-project-ownership.json b/rust/fixtures/run-configuration/provider-project-ownership.json similarity index 100% rename from shared/fixtures/run-configuration/provider-project-ownership.json rename to rust/fixtures/run-configuration/provider-project-ownership.json diff --git a/shared/fixtures/run-configuration/single-module.json b/rust/fixtures/run-configuration/single-module.json similarity index 100% rename from shared/fixtures/run-configuration/single-module.json rename to rust/fixtures/run-configuration/single-module.json diff --git a/shared/fixtures/run-configuration/subproject-toolchains.json b/rust/fixtures/run-configuration/subproject-toolchains.json similarity index 100% rename from shared/fixtures/run-configuration/subproject-toolchains.json rename to rust/fixtures/run-configuration/subproject-toolchains.json diff --git a/shared/fixtures/run-configuration/toolchain-mismatch.json b/rust/fixtures/run-configuration/toolchain-mismatch.json similarity index 100% rename from shared/fixtures/run-configuration/toolchain-mismatch.json rename to rust/fixtures/run-configuration/toolchain-mismatch.json diff --git a/shared/fixtures/search/actions-v1.json b/rust/fixtures/search/actions-v1.json similarity index 100% rename from shared/fixtures/search/actions-v1.json rename to rust/fixtures/search/actions-v1.json diff --git a/shared/fixtures/search/advanced.json b/rust/fixtures/search/advanced.json similarity index 100% rename from shared/fixtures/search/advanced.json rename to rust/fixtures/search/advanced.json diff --git a/shared/fixtures/search/basic.json b/rust/fixtures/search/basic.json similarity index 100% rename from shared/fixtures/search/basic.json rename to rust/fixtures/search/basic.json diff --git a/shared/fixtures/search/exclusions.json b/rust/fixtures/search/exclusions.json similarity index 100% rename from shared/fixtures/search/exclusions.json rename to rust/fixtures/search/exclusions.json diff --git a/shared/fixtures/search/name-matching.json b/rust/fixtures/search/name-matching.json similarity index 100% rename from shared/fixtures/search/name-matching.json rename to rust/fixtures/search/name-matching.json diff --git a/shared/fixtures/search/path-fallback.json b/rust/fixtures/search/path-fallback.json similarity index 100% rename from shared/fixtures/search/path-fallback.json rename to rust/fixtures/search/path-fallback.json diff --git a/shared/fixtures/search/rank-names-v1.json b/rust/fixtures/search/rank-names-v1.json similarity index 100% rename from shared/fixtures/search/rank-names-v1.json rename to rust/fixtures/search/rank-names-v1.json diff --git a/shared/fixtures/search/search-everywhere-context-v1.json b/rust/fixtures/search/search-everywhere-context-v1.json similarity index 100% rename from shared/fixtures/search/search-everywhere-context-v1.json rename to rust/fixtures/search/search-everywhere-context-v1.json diff --git a/shared/fixtures/search/text-snapshot-v1.json b/rust/fixtures/search/text-snapshot-v1.json similarity index 100% rename from shared/fixtures/search/text-snapshot-v1.json rename to rust/fixtures/search/text-snapshot-v1.json diff --git a/shared/fixtures/search/unicode-contiguous.json b/rust/fixtures/search/unicode-contiguous.json similarity index 100% rename from shared/fixtures/search/unicode-contiguous.json rename to rust/fixtures/search/unicode-contiguous.json diff --git a/shared/fixtures/settings/workbench-background-v1.json b/rust/fixtures/settings/workbench-background-v1.json similarity index 100% rename from shared/fixtures/settings/workbench-background-v1.json rename to rust/fixtures/settings/workbench-background-v1.json diff --git a/shared/fixtures/spring/basic.json b/rust/fixtures/spring/basic.json similarity index 100% rename from shared/fixtures/spring/basic.json rename to rust/fixtures/spring/basic.json diff --git a/shared/fixtures/terminal/link-target-v1.json b/rust/fixtures/terminal/link-target-v1.json similarity index 100% rename from shared/fixtures/terminal/link-target-v1.json rename to rust/fixtures/terminal/link-target-v1.json diff --git a/shared/fixtures/terminal/shell-selection-v1.json b/rust/fixtures/terminal/shell-selection-v1.json similarity index 100% rename from shared/fixtures/terminal/shell-selection-v1.json rename to rust/fixtures/terminal/shell-selection-v1.json diff --git a/shared/fixtures/toolchain-pin-edit.json b/rust/fixtures/toolchain-pin-edit.json similarity index 100% rename from shared/fixtures/toolchain-pin-edit.json rename to rust/fixtures/toolchain-pin-edit.json diff --git a/shared/fixtures/toolchain-pin-storage.json b/rust/fixtures/toolchain-pin-storage.json similarity index 100% rename from shared/fixtures/toolchain-pin-storage.json rename to rust/fixtures/toolchain-pin-storage.json diff --git a/shared/fixtures/toolchains/consumer-preferences.json b/rust/fixtures/toolchains/consumer-preferences.json similarity index 100% rename from shared/fixtures/toolchains/consumer-preferences.json rename to rust/fixtures/toolchains/consumer-preferences.json diff --git a/shared/fixtures/toolchains/rpc-id-cases.json b/rust/fixtures/toolchains/rpc-id-cases.json similarity index 100% rename from shared/fixtures/toolchains/rpc-id-cases.json rename to rust/fixtures/toolchains/rpc-id-cases.json diff --git a/shared/fixtures/updates/build-time-v1.json b/rust/fixtures/updates/build-time-v1.json similarity index 100% rename from shared/fixtures/updates/build-time-v1.json rename to rust/fixtures/updates/build-time-v1.json diff --git a/shared/fixtures/updates/channel-selection-v1.json b/rust/fixtures/updates/channel-selection-v1.json similarity index 100% rename from shared/fixtures/updates/channel-selection-v1.json rename to rust/fixtures/updates/channel-selection-v1.json diff --git a/shared/fixtures/updates/update-v1.json b/rust/fixtures/updates/update-v1.json similarity index 100% rename from shared/fixtures/updates/update-v1.json rename to rust/fixtures/updates/update-v1.json diff --git a/shared/fixtures/vcs/model-v1.json b/rust/fixtures/vcs/model-v1.json similarity index 99% rename from shared/fixtures/vcs/model-v1.json rename to rust/fixtures/vcs/model-v1.json index b6d3600d..b07d993c 100644 --- a/shared/fixtures/vcs/model-v1.json +++ b/rust/fixtures/vcs/model-v1.json @@ -1,7 +1,7 @@ { "version": 1, "status": "draft", - "contract": "shared/contracts/vcs-provider-v1.md", + "contract": "rust/contracts/vcs-provider-v1.md", "providers": [ { "vcs": "git", diff --git a/shared/fixtures/workspace/change-filter-v1.json b/rust/fixtures/workspace/change-filter-v1.json similarity index 100% rename from shared/fixtures/workspace/change-filter-v1.json rename to rust/fixtures/workspace/change-filter-v1.json diff --git a/shared/fixtures/workspace/directory-actions-v1.json b/rust/fixtures/workspace/directory-actions-v1.json similarity index 100% rename from shared/fixtures/workspace/directory-actions-v1.json rename to rust/fixtures/workspace/directory-actions-v1.json diff --git a/shared/fixtures/workspace/directory-rules-v1.json b/rust/fixtures/workspace/directory-rules-v1.json similarity index 100% rename from shared/fixtures/workspace/directory-rules-v1.json rename to rust/fixtures/workspace/directory-rules-v1.json diff --git a/shared/fixtures/workspace/entry-order-v1.json b/rust/fixtures/workspace/entry-order-v1.json similarity index 100% rename from shared/fixtures/workspace/entry-order-v1.json rename to rust/fixtures/workspace/entry-order-v1.json diff --git a/shared/fixtures/workspace/file-copy-names-v1.json b/rust/fixtures/workspace/file-copy-names-v1.json similarity index 100% rename from shared/fixtures/workspace/file-copy-names-v1.json rename to rust/fixtures/workspace/file-copy-names-v1.json diff --git a/shared/fixtures/workspace/file-name-order-v1.json b/rust/fixtures/workspace/file-name-order-v1.json similarity index 100% rename from shared/fixtures/workspace/file-name-order-v1.json rename to rust/fixtures/workspace/file-name-order-v1.json diff --git a/shared/fixtures/workspace/indexing-policy-v1.json b/rust/fixtures/workspace/indexing-policy-v1.json similarity index 100% rename from shared/fixtures/workspace/indexing-policy-v1.json rename to rust/fixtures/workspace/indexing-policy-v1.json diff --git a/shared/fixtures/workspace/recent-projects-v1.json b/rust/fixtures/workspace/recent-projects-v1.json similarity index 100% rename from shared/fixtures/workspace/recent-projects-v1.json rename to rust/fixtures/workspace/recent-projects-v1.json diff --git a/shared/fixtures/workspace/repositories-v1.json b/rust/fixtures/workspace/repositories-v1.json similarity index 100% rename from shared/fixtures/workspace/repositories-v1.json rename to rust/fixtures/workspace/repositories-v1.json diff --git a/shared/fixtures/workspace/repository-visibility-v1.json b/rust/fixtures/workspace/repository-visibility-v1.json similarity index 100% rename from shared/fixtures/workspace/repository-visibility-v1.json rename to rust/fixtures/workspace/repository-visibility-v1.json diff --git a/shared/fixtures/workspace/visibility-v1.json b/rust/fixtures/workspace/visibility-v1.json similarity index 100% rename from shared/fixtures/workspace/visibility-v1.json rename to rust/fixtures/workspace/visibility-v1.json diff --git a/rust/lithe-agent-host/src/attachment_retention.rs b/rust/lithe-agent-host/src/attachment_retention.rs index 4d6a142b..b99b209f 100644 --- a/rust/lithe-agent-host/src/attachment_retention.rs +++ b/rust/lithe-agent-host/src/attachment_retention.rs @@ -4,7 +4,7 @@ //! outlive the paste for as long as any conversation still refers to it. Platforms //! list their pasted-image cache and keep a ledger of the references a conversation //! accepted; this module decides which files may be deleted. Deleting stays with -//! the platform. Contract: `shared/fixtures/agent/attachment-retention-v1.json`. +//! the platform. Contract: `rust/fixtures/agent/attachment-retention-v1.json`. use std::collections::HashSet; diff --git a/rust/lithe-agent-host/src/claude_desktop.rs b/rust/lithe-agent-host/src/claude_desktop.rs index bf05a7f1..9f6f441d 100644 --- a/rust/lithe-agent-host/src/claude_desktop.rs +++ b/rust/lithe-agent-host/src/claude_desktop.rs @@ -13,7 +13,7 @@ //! message, `update_plan` a `plan` update, files a pane showed become tool //! locations. //! - A card for mechanisms ACP has no shape for, as `_meta.lithe.claudeDesktop` -//! on the tool call (see `shared/contracts/application-boundary.md`). Every +//! on the tool call (see `rust/contracts/application-boundary.md`). Every //! such call keeps a readable English title and kind, so a client that does //! not draw the card still shows a sensible tool row. //! @@ -695,7 +695,7 @@ mod tests { #[test] fn presented_updates_match_the_shared_card_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/claude-desktop-cards-v1.json" + "../../fixtures/agent/claude-desktop-cards-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-agent-host/src/codex_history/tests.rs b/rust/lithe-agent-host/src/codex_history/tests.rs index c6b81d5c..aa5ec055 100644 --- a/rust/lithe-agent-host/src/codex_history/tests.rs +++ b/rust/lithe-agent-host/src/codex_history/tests.rs @@ -104,7 +104,7 @@ fn updated_at_seconds_become_utc_timestamps() { #[test] fn viewing_history_preserves_activity_but_a_new_turn_advances_it() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/history-activity-v1.json" + "../../../fixtures/agent/history-activity-v1.json" )) .unwrap(); for stage in ["initial", "afterViewing", "afterMessage"] { diff --git a/rust/lithe-agent-host/src/edit_review.rs b/rust/lithe-agent-host/src/edit_review.rs index ca62874a..cfbbe071 100644 --- a/rust/lithe-agent-host/src/edit_review.rs +++ b/rust/lithe-agent-host/src/edit_review.rs @@ -6,7 +6,7 @@ //! Swift `unicodeScalars` and JavaScript code points agree on them, unlike //! grapheme clusters or UTF-16 code units. Rollback planning is pure text work; //! path containment, encoding, guarded writes and Trash stay with the platform. -//! Contract: `shared/contracts/agent-edit-review.md`. +//! Contract: `rust/contracts/agent-edit-review.md`. use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; diff --git a/rust/lithe-agent-host/src/edit_review/tests.rs b/rust/lithe-agent-host/src/edit_review/tests.rs index c15e37dc..0aabd9fb 100644 --- a/rust/lithe-agent-host/src/edit_review/tests.rs +++ b/rust/lithe-agent-host/src/edit_review/tests.rs @@ -2,7 +2,7 @@ use super::*; fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/edit-review-v1.json" + "../../../fixtures/agent/edit-review-v1.json" )) .expect("edit review fixture is JSON") } @@ -117,7 +117,7 @@ fn presented_acp_fixture_edits_carry_host_evidence() { // The platform event fixture shows presented updates; removing the Host // annotation and presenting again must reproduce them exactly. let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/acp-events-v1.json" + "../../../fixtures/agent/acp-events-v1.json" )) .expect("ACP event fixture is JSON"); for name in ["fileEdit", "claudeFileCreated", "claudeFileWriteUpdated"] { diff --git a/rust/lithe-agent-host/src/hooks/tests.rs b/rust/lithe-agent-host/src/hooks/tests.rs index 7484f4e4..d51261b7 100644 --- a/rust/lithe-agent-host/src/hooks/tests.rs +++ b/rust/lithe-agent-host/src/hooks/tests.rs @@ -471,7 +471,7 @@ fn draft(hook: &Hook) -> Draft { #[test] fn draft_validation_matches_the_shared_fixture_and_the_save_rules() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/hook-draft-validation-v1.json" + "../../../fixtures/agent/hook-draft-validation-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs index 9d75d9ae..ba311124 100644 --- a/rust/lithe-agent-host/src/lib.rs +++ b/rust/lithe-agent-host/src/lib.rs @@ -584,7 +584,7 @@ pub struct AgentSessionSummary { /// Events emitted from the connection worker thread. /// -/// Field names are fixed by `shared/fixtures/agent/acp-events-v1.json`. +/// Field names are fixed by `rust/fixtures/agent/acp-events-v1.json`. #[derive(Debug, Serialize)] #[serde( tag = "kind", diff --git a/rust/lithe-agent-host/src/model_call_statistics.rs b/rust/lithe-agent-host/src/model_call_statistics.rs index 63bf0ff0..972aa2f1 100644 --- a/rust/lithe-agent-host/src/model_call_statistics.rs +++ b/rust/lithe-agent-host/src/model_call_statistics.rs @@ -6,7 +6,7 @@ //! non-streaming, which timings describe generation speed, and which request //! describes the current context. Platforms only add contributions with //! [`ModelCallStatistics::merge`]; they never reinterpret a raw call. -//! Contract: `shared/fixtures/agent/model-call-statistics-v1.json`. +//! Contract: `rust/fixtures/agent/model-call-statistics-v1.json`. use std::collections::HashSet; use std::sync::Mutex; @@ -215,7 +215,7 @@ mod tests { #[test] fn fixture_contributions_and_totals_match() { let fixture: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/model-call-statistics-v1.json" + "../../fixtures/agent/model-call-statistics-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-agent-host/src/model_proxy/websocket/tests.rs b/rust/lithe-agent-host/src/model_proxy/websocket/tests.rs index fa5418ff..06c9382c 100644 --- a/rust/lithe-agent-host/src/model_proxy/websocket/tests.rs +++ b/rust/lithe-agent-host/src/model_proxy/websocket/tests.rs @@ -84,7 +84,7 @@ fn feed(observation: &mut Observation, from_client: bool, message: Value, now: I fn full_request() -> Value { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/agent/acp-events-v1.json" + "/../fixtures/agent/acp-events-v1.json" ))) .unwrap(); fixture["upstream"]["codexSubscriptionObservation"]["request"].clone() diff --git a/rust/lithe-agent-host/src/next_turn_config.rs b/rust/lithe-agent-host/src/next_turn_config.rs index 2dcbe631..1226737b 100644 --- a/rust/lithe-agent-host/src/next_turn_config.rs +++ b/rust/lithe-agent-host/src/next_turn_config.rs @@ -8,7 +8,7 @@ //! revalidated against the latest catalog the Agent returned, and each request //! must be confirmed by the Agent's reported value. The first problem stops the //! sequence with one stable failure code. -//! Contract: `shared/fixtures/agent/next-turn-configuration-v1.json`. +//! Contract: `rust/fixtures/agent/next-turn-configuration-v1.json`. use agent_client_protocol::schema::v1::{ SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions, @@ -186,7 +186,7 @@ mod tests { #[test] fn fixture_cases_apply_in_the_shared_order() { let fixture: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/next-turn-configuration-v1.json" + "../../fixtures/agent/next-turn-configuration-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-agent-host/src/prompt_retry.rs b/rust/lithe-agent-host/src/prompt_retry.rs index 6085291b..02501d29 100644 --- a/rust/lithe-agent-host/src/prompt_retry.rs +++ b/rust/lithe-agent-host/src/prompt_retry.rs @@ -36,7 +36,7 @@ pub(crate) fn quiet_notice_delay() -> Duration { static DELAY: std::sync::OnceLock = std::sync::OnceLock::new(); *DELAY.get_or_init(|| { let policy: Policy = serde_json::from_str(include_str!( - "../../../shared/contracts/agent-turn-policy.json" + "../../contracts/agent-turn-policy.json" )) .expect("valid bundled Agent turn policy"); Duration::from_millis(policy.quiet_notice_milliseconds) diff --git a/rust/lithe-agent-host/src/session_defaults.rs b/rust/lithe-agent-host/src/session_defaults.rs index e5a87b18..f343ccd5 100644 --- a/rust/lithe-agent-host/src/session_defaults.rs +++ b/rust/lithe-agent-host/src/session_defaults.rs @@ -156,7 +156,7 @@ mod tests { #[test] fn repair_uses_recommendation_or_a_selectable_catalog_model() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/acp-events-v1.json" + "../../fixtures/agent/acp-events-v1.json" )) .unwrap(); let initial = fixture["upstream"]["staleModelSession"].clone(); diff --git a/rust/lithe-agent-host/src/skills/tests.rs b/rust/lithe-agent-host/src/skills/tests.rs index 29d2e433..b7ab6a6c 100644 --- a/rust/lithe-agent-host/src/skills/tests.rs +++ b/rust/lithe-agent-host/src/skills/tests.rs @@ -605,7 +605,7 @@ async fn native_toggle_validates_path_writes_native_config_and_reads_back() { #[test] fn shared_skill_fixture_preserves_native_scopes_plugins_and_disabled_state() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/skills-management-v1.json" + "../../../fixtures/agent/skills-management-v1.json" )) .unwrap(); let listing = parse_codex(&fixture["nativeCodex"], Path::new("/fixture/workspace")).unwrap(); diff --git a/rust/lithe-agent-host/src/subagents.rs b/rust/lithe-agent-host/src/subagents.rs index 45204160..2a6b462c 100644 --- a/rust/lithe-agent-host/src/subagents.rs +++ b/rust/lithe-agent-host/src/subagents.rs @@ -28,7 +28,7 @@ mod tests { #[test] fn native_and_standard_notifications_round_trip_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/acp-events-v1.json" + "../../fixtures/agent/acp-events-v1.json" )) .unwrap(); for name in [ diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index b6f31cbc..f7e05ff4 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -8,7 +8,7 @@ const WAIT: Duration = Duration::from_secs(5); fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/acp-events-v1.json" + "../../fixtures/agent/acp-events-v1.json" )) .expect("ACP event fixture") } @@ -967,7 +967,7 @@ fn serialized_events_match_the_shared_fixture() { #[test] fn management_status_matches_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/agent-management-v1.json" + "../../fixtures/agent/agent-management-v1.json" )) .expect("agent management fixture"); let data = std::env::temp_dir().join(format!("lithe-status-{}", std::process::id())); @@ -2454,7 +2454,7 @@ async fn session_configuration_round_trips_upstream_options() { #[tokio::test(flavor = "current_thread")] async fn next_turn_configuration_applies_choices_and_reports_one_result() { let next: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/agent/next-turn-configuration-v1.json" + "../../fixtures/agent/next-turn-configuration-v1.json" )) .unwrap(); let mut harness = Harness::ready().await; diff --git a/rust/lithe-core-rules/src/visibility.rs b/rust/lithe-core-rules/src/visibility.rs index 586659d9..3d28056e 100644 --- a/rust/lithe-core-rules/src/visibility.rs +++ b/rust/lithe-core-rules/src/visibility.rs @@ -263,7 +263,7 @@ fn path_glob_matches(pattern: &str, value: &str) -> bool { /// Such an entry names a folder from the project root (or at any depth when it /// starts with `**/`), so the folder and everything below it is hidden instead /// of leaving an empty folder behind. Other entries keep their file-only -/// meaning, as pinned by `shared/fixtures/search/exclusions.json`. +/// meaning, as pinned by `rust/fixtures/search/exclusions.json`. fn folder_glob(pattern: &str) -> Option<&str> { let glob = pattern .strip_suffix("/**") diff --git a/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs b/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs index 2a6abadd..0ed3b6b1 100644 --- a/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs +++ b/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs @@ -3,7 +3,7 @@ use lithe_core_rules::{execute_json, keymap::THRESHOLD_SECONDS}; use serde_json::{json, Value}; -const FIXTURE: &str = include_str!("../../../shared/fixtures/keymap/modifier-gesture-v1.json"); +const FIXTURE: &str = include_str!("../../fixtures/keymap/modifier-gesture-v1.json"); fn step(state: &Value, event: Value, timestamp: f64) -> Value { let request = json!({ diff --git a/rust/lithe-core-rules/tests/visibility_fixture.rs b/rust/lithe-core-rules/tests/visibility_fixture.rs index bb902b59..aa61e282 100644 --- a/rust/lithe-core-rules/tests/visibility_fixture.rs +++ b/rust/lithe-core-rules/tests/visibility_fixture.rs @@ -3,7 +3,7 @@ use lithe_core_rules::execute_json; use serde_json::{json, Value}; -const FIXTURE: &str = include_str!("../../../shared/fixtures/workspace/visibility-v1.json"); +const FIXTURE: &str = include_str!("../../fixtures/workspace/visibility-v1.json"); fn call(command: &str, payload: Value) -> Value { let request = json!({"id": "fixture", "command": command, "payload": payload}); diff --git a/rust/lithe-core-rules/tests/workbench_state_fixture.rs b/rust/lithe-core-rules/tests/workbench_state_fixture.rs index d5abdd99..05d9787f 100644 --- a/rust/lithe-core-rules/tests/workbench_state_fixture.rs +++ b/rust/lithe-core-rules/tests/workbench_state_fixture.rs @@ -3,7 +3,7 @@ use lithe_core_rules::{execute_json, is_rule_command}; use serde_json::{json, Value}; -const FIXTURE: &str = include_str!("../../../shared/fixtures/editor/workbench-state-v1.json"); +const FIXTURE: &str = include_str!("../../fixtures/editor/workbench-state-v1.json"); const COMMAND: &str = "editor.workbenchState.reduce"; fn call(payload: Value) -> Value { diff --git a/rust/lithe-core/src/action_search/mod.rs b/rust/lithe-core/src/action_search/mod.rs index 96964578..b21c84ee 100644 --- a/rust/lithe-core/src/action_search/mod.rs +++ b/rust/lithe-core/src/action_search/mod.rs @@ -83,7 +83,7 @@ mod tests { fn fixture_matches_localized_and_original_text_without_reordering() { let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/search/actions-v1.json" + "/../fixtures/search/actions-v1.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/ai/request_tests.rs b/rust/lithe-core/src/ai/request_tests.rs index 5df79c1b..6ad846c5 100644 --- a/rust/lithe-core/src/ai/request_tests.rs +++ b/rust/lithe-core/src/ai/request_tests.rs @@ -8,14 +8,14 @@ use serde_json::{json, Value}; fn pull_request_fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/ai/pull-request-description-v1.json" + "../../../fixtures/ai/pull-request-description-v1.json" )) .unwrap() } fn inline_edit_fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/ai/inline-edit-v1.json" + "../../../fixtures/ai/inline-edit-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/ai/tests.rs b/rust/lithe-core/src/ai/tests.rs index e81a8f05..4dd6061b 100644 --- a/rust/lithe-core/src/ai/tests.rs +++ b/rust/lithe-core/src/ai/tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; fn configuration_fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/ai/cli-configuration-v1.json" + "../../../fixtures/ai/cli-configuration-v1.json" )) .unwrap() } @@ -196,7 +196,7 @@ fn configuration_parse_errors_are_stable_codes_without_input() { fn fixture() -> (Provider, CommitOptions, Vec, Value) { let value: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/ai/commit-generation-v1.json" + "../../../fixtures/ai/commit-generation-v1.json" )) .unwrap(); ( diff --git a/rust/lithe-core/src/build/test_outcome.rs b/rust/lithe-core/src/build/test_outcome.rs index 9cc3e906..e7bc7113 100644 --- a/rust/lithe-core/src/build/test_outcome.rs +++ b/rust/lithe-core/src/build/test_outcome.rs @@ -146,7 +146,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/build/test-run-outcome-v1.json" + "../../../fixtures/build/test-run-outcome-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/build/test_outcome_merge.rs b/rust/lithe-core/src/build/test_outcome_merge.rs index 62d600da..afd88b24 100644 --- a/rust/lithe-core/src/build/test_outcome_merge.rs +++ b/rust/lithe-core/src/build/test_outcome_merge.rs @@ -121,7 +121,7 @@ mod tests { #[test] fn shared_fixture_cases_merge_to_their_expected_history() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/build/test-outcome-merge-v1.json" + "../../../fixtures/build/test-outcome-merge-v1.json" )) .unwrap(); let cases = fixture["cases"].as_array().unwrap(); diff --git a/rust/lithe-core/src/debug/disassembly_paging.rs b/rust/lithe-core/src/debug/disassembly_paging.rs index 2bd412d3..2e9a4de8 100644 --- a/rust/lithe-core/src/debug/disassembly_paging.rs +++ b/rust/lithe-core/src/debug/disassembly_paging.rs @@ -376,7 +376,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/debug/disassembly-paging-v1.json" + "../../../fixtures/debug/disassembly-paging-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/debug/endpoint.rs b/rust/lithe-core/src/debug/endpoint.rs index e313c504..cf8cb4e3 100644 --- a/rust/lithe-core/src/debug/endpoint.rs +++ b/rust/lithe-core/src/debug/endpoint.rs @@ -42,7 +42,7 @@ mod tests { fn dap_endpoint_contract_fixtures_match_validation_and_consent_classification() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/adapter-endpoints-v1.json" + "/../fixtures/debug/adapter-endpoints-v1.json" ))) .expect("endpoint fixture is JSON"); for case in fixture["cases"].as_array().expect("fixture cases") { diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 11c31226..16cb789d 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -3172,7 +3172,7 @@ mod tests { #[test] fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/debug/launch-event-order.json" + "../../../fixtures/debug/launch-event-order.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -3305,7 +3305,7 @@ mod tests { fn hot_replace_normalizes_success_empty_and_error_bodies() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/hot-code-replace-v1.json" + "/../fixtures/debug/hot-code-replace-v1.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -3498,7 +3498,7 @@ mod tests { fn disconnect_policy_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/disconnect-policy-v1.json" + "/../fixtures/debug/disconnect-policy-v1.json" ))) .unwrap(); @@ -4128,7 +4128,7 @@ mod tests { fn source_breakpoint_binding_preserves_requested_identity_and_rejects_stale_replies() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/breakpoint-binding-v1.json" + "/../fixtures/debug/breakpoint-binding-v1.json" ))) .unwrap(); let session_id = "source-breakpoint-binding"; @@ -4223,7 +4223,7 @@ mod tests { fn start_debugging_request_is_normalized_completed_and_stale_safe() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/start-debugging-v1.json" + "/../fixtures/debug/start-debugging-v1.json" ))) .unwrap(); let session_id = "debug-start-debugging"; @@ -4291,7 +4291,7 @@ mod tests { fn start_debugging_failure_and_unsupported_hosts_answer_the_adapter() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/start-debugging-v1.json" + "/../fixtures/debug/start-debugging-v1.json" ))) .unwrap(); for (session_id, supported) in [ @@ -4347,7 +4347,7 @@ mod tests { fn disconnect_fails_a_pending_start_debugging_request() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/start-debugging-v1.json" + "/../fixtures/debug/start-debugging-v1.json" ))) .unwrap(); let session_id = "debug-start-debugging-disconnect"; @@ -4381,7 +4381,7 @@ mod tests { fn node_source_mapped_locations_pass_through_unchanged() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/node-source-map-v1.json" + "/../fixtures/debug/node-source-map-v1.json" ))) .unwrap(); let session_id = "debug-node-source-map"; @@ -5330,7 +5330,7 @@ mod tests { fn exception_information_is_capability_gated_and_normalized() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/exception-info-v1.json" + "/../fixtures/debug/exception-info-v1.json" ))) .unwrap(); let session_id = "debug-exception-info"; @@ -5443,7 +5443,7 @@ mod tests { fn variable_paging_is_forwarded_and_normalized_from_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/variable-paging-v1.json" + "/../fixtures/debug/variable-paging-v1.json" ))) .unwrap(); let session_id = "debug-variable-paging"; @@ -5617,7 +5617,7 @@ mod tests { fn run_in_terminal_request_is_normalized_completed_and_stale_safe() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/run-in-terminal-v1.json" + "/../fixtures/debug/run-in-terminal-v1.json" ))) .unwrap(); let session_id = "debug-run-in-terminal"; @@ -6142,7 +6142,7 @@ mod tests { fn stale_reference_fixture_matches_core_behavior() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/stale-reference-v1.json" + "/../fixtures/debug/stale-reference-v1.json" ))) .unwrap(); let session_id = "debug-stale-fixture"; diff --git a/rust/lithe-core/src/debug/projection_v2.rs b/rust/lithe-core/src/debug/projection_v2.rs index c4b69f86..8736369a 100644 --- a/rust/lithe-core/src/debug/projection_v2.rs +++ b/rust/lithe-core/src/debug/projection_v2.rs @@ -512,7 +512,7 @@ mod tests { fn examples() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/debug/projection-v2-examples.json" + "../../../fixtures/debug/projection-v2-examples.json" )) .unwrap() } diff --git a/rust/lithe-core/src/debug/transport_lifecycle.rs b/rust/lithe-core/src/debug/transport_lifecycle.rs index 1420e479..33dd8127 100644 --- a/rust/lithe-core/src/debug/transport_lifecycle.rs +++ b/rust/lithe-core/src/debug/transport_lifecycle.rs @@ -784,7 +784,7 @@ mod tests { fn lifecycle_fixture_scenarios_match_expected_steps() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/transport-lifecycle-v1.json" + "/../fixtures/debug/transport-lifecycle-v1.json" ))) .expect("transport lifecycle fixture is JSON"); for scenario in fixture["scenarios"].as_array().expect("scenarios") { diff --git a/rust/lithe-core/src/debug/variable_paging.rs b/rust/lithe-core/src/debug/variable_paging.rs index db038c78..d7831535 100644 --- a/rust/lithe-core/src/debug/variable_paging.rs +++ b/rust/lithe-core/src/debug/variable_paging.rs @@ -288,7 +288,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/debug/variable-page-plan-v1.json" + "../../../fixtures/debug/variable-page-plan-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/diagnostics/catalog_generated.rs b/rust/lithe-core/src/diagnostics/catalog_generated.rs index 6ec904b8..df34eabf 100644 --- a/rust/lithe-core/src/diagnostics/catalog_generated.rs +++ b/rust/lithe-core/src/diagnostics/catalog_generated.rs @@ -1,4 +1,4 @@ -//! Generated event metadata; edit shared/logging/events/*.json instead. +//! Generated event metadata; edit rust/logging/events/*.json instead. // Automatically generated by scripts/logging/generate-log-events.mjs. DO NOT EDIT. use super::{EventMetadata, FieldMetadata, FieldType, LogLevel, SpanPhase}; diff --git a/rust/lithe-core/src/diagnostics/collection.rs b/rust/lithe-core/src/diagnostics/collection.rs index 23d1b121..401cec49 100644 --- a/rust/lithe-core/src/diagnostics/collection.rs +++ b/rust/lithe-core/src/diagnostics/collection.rs @@ -261,7 +261,7 @@ mod tests { #[test] fn shared_collection_fixture_keeps_active_channels_and_category_compatibility() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/diagnostics/logging-v2.json" + "../../../fixtures/diagnostics/logging-v2.json" )) .unwrap(); for case in fixture["collection"].as_array().unwrap() { diff --git a/rust/lithe-core/src/diagnostics/logging.rs b/rust/lithe-core/src/diagnostics/logging.rs index 94775719..c544c20f 100644 --- a/rust/lithe-core/src/diagnostics/logging.rs +++ b/rust/lithe-core/src/diagnostics/logging.rs @@ -440,7 +440,7 @@ mod tests { #[test] fn time_filters_and_ordering_compare_instants_from_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/diagnostics/logging-v1.json" + "../../../fixtures/diagnostics/logging-v1.json" )) .unwrap(); for case in fixture["timeQueries"].as_array().unwrap() { @@ -457,7 +457,7 @@ mod tests { #[test] fn shared_fixture_pins_domains_and_budgets() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/diagnostics/logging-v1.json" + "../../../fixtures/diagnostics/logging-v1.json" )) .unwrap(); assert_eq!(fixture["categories"], serde_json::json!(LOG_CATEGORIES)); diff --git a/rust/lithe-core/src/diagnostics/plugin_operations.rs b/rust/lithe-core/src/diagnostics/plugin_operations.rs index d564d565..92ad3217 100644 --- a/rust/lithe-core/src/diagnostics/plugin_operations.rs +++ b/rust/lithe-core/src/diagnostics/plugin_operations.rs @@ -527,7 +527,7 @@ mod tests { #[test] fn diagnostics_host_plugin_operations_match_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/diagnostics/engine-logging-v2.json" + "../../../fixtures/diagnostics/engine-logging-v2.json" )) .unwrap(); for case in fixture["hostOperations"].as_array().unwrap() { diff --git a/rust/lithe-core/src/diagnostics/record.rs b/rust/lithe-core/src/diagnostics/record.rs index 76917be9..7ca5bf6a 100644 --- a/rust/lithe-core/src/diagnostics/record.rs +++ b/rust/lithe-core/src/diagnostics/record.rs @@ -791,7 +791,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/diagnostics/logging-v2.json" + "../../../fixtures/diagnostics/logging-v2.json" )) .unwrap() } diff --git a/rust/lithe-core/src/diff/tests.rs b/rust/lithe-core/src/diff/tests.rs index 522436f4..d895e440 100644 --- a/rust/lithe-core/src/diff/tests.rs +++ b/rust/lithe-core/src/diff/tests.rs @@ -8,7 +8,7 @@ use serde_json::{json, Value}; fn fixture() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/diff/compare-texts-v1.json" + "/../fixtures/diff/compare-texts-v1.json" ))) .expect("compare-texts fixture should parse") } diff --git a/rust/lithe-core/src/editor/tests.rs b/rust/lithe-core/src/editor/tests.rs index 33a106b4..3b889e65 100644 --- a/rust/lithe-core/src/editor/tests.rs +++ b/rust/lithe-core/src/editor/tests.rs @@ -9,7 +9,7 @@ use super::line_edit::{line_comment_token, line_edit, LineCommentTokenRequest, L #[test] fn navigation_input_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/navigation-input-v1.json" + "../../../fixtures/editor/navigation-input-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -32,7 +32,7 @@ fn navigation_input_matches_shared_fixture() { #[test] fn text_edit_plan_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/text-edit-plan-v1.json" + "../../../fixtures/editor/text-edit-plan-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -90,7 +90,7 @@ fn navigation_history(payload: Value) -> Value { #[test] fn navigation_history_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/navigation-history-v1.json" + "../../../fixtures/editor/navigation-history-v1.json" )) .unwrap(); assert_eq!( @@ -782,7 +782,7 @@ fn every_operation_maps_selections_inside_the_edited_document() { fn shared_fixture_cases_match_core_outcomes() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/editor/line-edit-v1.json" + "/../fixtures/editor/line-edit-v1.json" ))) .expect("line edit fixture should be valid JSON"); for case_value in fixture["cases"].as_array().expect("cases") { diff --git a/rust/lithe-core/src/execution/argument_text.rs b/rust/lithe-core/src/execution/argument_text.rs index a8f41066..582b906c 100644 --- a/rust/lithe-core/src/execution/argument_text.rs +++ b/rust/lithe-core/src/execution/argument_text.rs @@ -45,7 +45,7 @@ mod tests { #[test] fn argument_text_command_formats_a_batch_from_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/argument-text.json" + "../../../fixtures/run-configuration/argument-text.json" )) .unwrap(); let cases = fixture["cases"].as_array().unwrap(); @@ -64,7 +64,7 @@ mod tests { #[test] fn argument_text_matches_the_shared_roundtrip_examples() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/argument-text.json" + "../../../fixtures/run-configuration/argument-text.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/execution/context_selection.rs b/rust/lithe-core/src/execution/context_selection.rs index 705342d4..63aa4df4 100644 --- a/rust/lithe-core/src/execution/context_selection.rs +++ b/rust/lithe-core/src/execution/context_selection.rs @@ -67,7 +67,7 @@ mod tests { #[test] fn context_selection_command_matches_shared_provider_neutral_cases() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/context-selection.json" + "../../../fixtures/run-configuration/context-selection.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/execution/environment.rs b/rust/lithe-core/src/execution/environment.rs index 592d55e5..3bd4066d 100644 --- a/rust/lithe-core/src/execution/environment.rs +++ b/rust/lithe-core/src/execution/environment.rs @@ -105,7 +105,7 @@ mod tests { #[test] fn search_path_command_follows_the_shared_order_and_identity_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/execution/search-path-v1.json" + "../../../fixtures/execution/search-path-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -120,7 +120,7 @@ mod tests { #[test] fn environment_command_follows_the_shared_precedence_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/execution/launch-environment-v1.json" + "../../../fixtures/execution/launch-environment-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/authentication_failure.rs b/rust/lithe-core/src/git/authentication_failure.rs index b45bda14..f936af48 100644 --- a/rust/lithe-core/src/git/authentication_failure.rs +++ b/rust/lithe-core/src/git/authentication_failure.rs @@ -117,7 +117,7 @@ mod tests { #[test] fn shared_fixture_samples_classify_as_documented() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/authentication-failure-v1.json" + "../../../fixtures/git/authentication-failure-v1.json" )) .unwrap(); for (key, expected) in [("failures", true), ("unrelated", false)] { diff --git a/rust/lithe-core/src/git/change_targets.rs b/rust/lithe-core/src/git/change_targets.rs index 301f3f8a..6cd4b62a 100644 --- a/rust/lithe-core/src/git/change_targets.rs +++ b/rust/lithe-core/src/git/change_targets.rs @@ -115,7 +115,7 @@ mod tests { #[test] fn shared_fixture_cases_keep_only_operation_targets() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/change-targets-v1.json" + "../../../fixtures/git/change-targets-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/changelists.rs b/rust/lithe-core/src/git/changelists.rs index f80846d8..98969e94 100644 --- a/rust/lithe-core/src/git/changelists.rs +++ b/rust/lithe-core/src/git/changelists.rs @@ -353,7 +353,7 @@ mod tests { #[test] fn shared_fixture_matches_the_reducer() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/changelists-reducer-v1.json" + "../../../fixtures/git/changelists-reducer-v1.json" )) .unwrap(); assert_eq!( diff --git a/rust/lithe-core/src/git/commit_selection.rs b/rust/lithe-core/src/git/commit_selection.rs index 2fcceac4..25182224 100644 --- a/rust/lithe-core/src/git/commit_selection.rs +++ b/rust/lithe-core/src/git/commit_selection.rs @@ -489,7 +489,7 @@ mod tests { #[test] fn shared_fixture_plans_without_overwriting_selected_index_content() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/commit-selection-plan-v1.json" + "../../../fixtures/git/commit-selection-plan-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/configuration.rs b/rust/lithe-core/src/git/configuration.rs index 0aacfc66..c98bf0e8 100644 --- a/rust/lithe-core/src/git/configuration.rs +++ b/rust/lithe-core/src/git/configuration.rs @@ -599,7 +599,7 @@ mod tests { #[test] fn resolved_fetch_behavior_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/fetch-behavior-v1.json" + "../../../fixtures/git/fetch-behavior-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/console/tests.rs b/rust/lithe-core/src/git/console/tests.rs index e0d2ce97..6e5f5249 100644 --- a/rust/lithe-core/src/git/console/tests.rs +++ b/rust/lithe-core/src/git/console/tests.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; #[test] fn presentation_matches_shared_console_cases_without_rewriting_retained_output() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/git/console-presentation-v1.json" + "../../../../fixtures/git/console-presentation-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs index 8d699793..4ea870cd 100644 --- a/rust/lithe-core/src/git/execution_events.rs +++ b/rust/lithe-core/src/git/execution_events.rs @@ -811,7 +811,7 @@ mod tests { } } let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/execution-events-v1.json" + "../../../fixtures/git/execution-events-v1.json" )) .unwrap(); assert_eq!(serde_json::json!(events), fixture["events"]); diff --git a/rust/lithe-core/src/git/execution_policy.rs b/rust/lithe-core/src/git/execution_policy.rs index 439afc26..f0ff69c6 100644 --- a/rust/lithe-core/src/git/execution_policy.rs +++ b/rust/lithe-core/src/git/execution_policy.rs @@ -174,7 +174,7 @@ mod tests { #[test] fn console_projection_preserves_raw_arguments_and_folds_only_global_options() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/console-command-v1.json" + "../../../fixtures/git/console-command-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -216,7 +216,7 @@ mod tests { fn named_defaults_are_scoped_and_do_not_duplicate_transfer_progress() { let before = current(); let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/execution-policy-v1.json" + "../../../fixtures/git/execution-policy-v1.json" )) .unwrap(); assert_eq!( diff --git a/rust/lithe-core/src/git/fetch.rs b/rust/lithe-core/src/git/fetch.rs index 054bc901..3179a799 100644 --- a/rust/lithe-core/src/git/fetch.rs +++ b/rust/lithe-core/src/git/fetch.rs @@ -175,7 +175,7 @@ mod tests { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/fetch-plan-v1.json" + "../../../fixtures/git/fetch-plan-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/git/graph_tests.rs b/rust/lithe-core/src/git/graph_tests.rs index 0fca1cec..881e82e1 100644 --- a/rust/lithe-core/src/git/graph_tests.rs +++ b/rust/lithe-core/src/git/graph_tests.rs @@ -66,7 +66,7 @@ fn read_fixture(relative: &str) -> String { fn shared_fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/graph-projection-v1.json" + "../../../fixtures/git/graph-projection-v1.json" )) .unwrap() } @@ -197,7 +197,7 @@ fn permanent_layout_matches_pinned_intellij_indices() { ] { let read = |suffix: &str| { read_fixture(&format!( - "shared/fixtures/git/graph-idea/layoutBuilder/{name}_{suffix}.txt" + "rust/fixtures/git/graph-idea/layoutBuilder/{name}_{suffix}.txt" )) }; let commits = read("in") @@ -237,7 +237,7 @@ fn compact_positions_and_half_edges_match_pinned_intellij_printing() { ] { let read = |suffix: &str| { read_fixture(&format!( - "shared/fixtures/git/graph-idea/elementGenerator/{name}_{suffix}.txt" + "rust/fixtures/git/graph-idea/elementGenerator/{name}_{suffix}.txt" )) }; let mut commits = Vec::new(); @@ -319,7 +319,7 @@ fn compact_positions_and_half_edges_match_pinned_intellij_printing() { fn read_tsv(name: &str) -> Vec { read_fixture(&format!( - "macos/Tests/LitheTests/Fixtures/GitGraph/{name}.tsv" + "rust/fixtures/git/graph-issue410/{name}.tsv" )) .lines() .filter(|line| !line.is_empty()) @@ -381,7 +381,7 @@ fn real_history_matches_intellij_for_page_context_and_date_order() { } } let mut expected = read_fixture(&format!( - "macos/Tests/LitheTests/Fixtures/GitGraph/{oracle}.txt" + "rust/fixtures/git/graph-issue410/{oracle}.txt" )) .lines() .filter(|line| !line.is_empty()) diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index e9acacce..70aee5cc 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -1432,7 +1432,7 @@ mod tests { .collect() }; let request: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/history-page-date-request-v1.json" + "../../../fixtures/git/history-page-date-request-v1.json" )) .expect("portable date-order request"); let first = page(request.clone()); @@ -1478,7 +1478,7 @@ mod tests { use std::collections::BTreeSet; let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/history-locate-v1.json" + "../../../fixtures/git/history-locate-v1.json" )) .expect("history locate fixture should be valid JSON"); diff --git a/rust/lithe-core/src/git/history_window.rs b/rust/lithe-core/src/git/history_window.rs index 5b18938d..ef8bffb6 100644 --- a/rust/lithe-core/src/git/history_window.rs +++ b/rust/lithe-core/src/git/history_window.rs @@ -244,7 +244,7 @@ mod tests { #[test] fn shared_fixture_windows_keep_depth_and_selection() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/history-window-v1.json" + "../../../fixtures/git/history-window-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 7f869c91..ceac6ba5 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -9064,7 +9064,7 @@ mod tests { #[test] fn windows_git_paths_follow_shared_identity_policy() { let cases: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/windows-paths.json" + "../../../fixtures/git/windows-paths.json" )) .unwrap(); for case in cases["accepted"].as_array().unwrap() { @@ -9289,7 +9289,7 @@ mod tests { // including the combinations that must fail before any subprocess. let fixture: TagWriteFixture = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/tag-write-v1.json" + "/../fixtures/git/tag-write-v1.json" ))) .expect("tag write fixture should be valid JSON"); for case in &fixture.cases { @@ -9464,7 +9464,7 @@ mod tests { fn command_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/command-response-v1.json" + "/../fixtures/git/command-response-v1.json" ))) .expect("Git command response fixture should be valid JSON"); let response = GitCommandResponse { @@ -9547,7 +9547,7 @@ mod tests { fn history_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/history-response-v1.json" + "/../fixtures/git/history-response-v1.json" ))) .expect("Git history response fixture should be valid JSON"); let feature = GitReferenceResponse { @@ -9600,7 +9600,7 @@ mod tests { fn references_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/references-response-v1.json" + "/../fixtures/git/references-response-v1.json" ))) .expect("Git references response fixture should be valid JSON"); let feature = GitReferenceResponse { @@ -9640,7 +9640,7 @@ mod tests { fn history_page_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/history-page-response-v1.json" + "/../fixtures/git/history-page-response-v1.json" ))) .expect("Git history page response fixture should be valid JSON"); let response = GitHistoryPageResponse { @@ -9671,7 +9671,7 @@ mod tests { fn commit_lookup_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/commit-lookup-response-v1.json" + "/../fixtures/git/commit-lookup-response-v1.json" ))) .expect("Git commit lookup response fixture should be valid JSON"); let response = GitCommitLookupResponse { @@ -9720,7 +9720,7 @@ mod tests { fn push_preview_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/push-preview-v1.json" + "/../fixtures/git/push-preview-v1.json" ))) .expect("Git push preview fixture should be valid JSON"); let response = GitPushPreviewResponse { @@ -9761,7 +9761,7 @@ mod tests { fn command_error_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/command-error-response-v1.json" + "/../fixtures/git/command-error-response-v1.json" ))) .expect("Git command error response fixture should be valid JSON"); let response = GitCommandResponse { @@ -9810,7 +9810,7 @@ mod tests { fn structured_diff_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/diff.json" + "/../fixtures/git/diff.json" ))) .expect("diff fixture should be valid JSON"); let patch = fixture["patch"] diff --git a/rust/lithe-core/src/git/progress.rs b/rust/lithe-core/src/git/progress.rs index 5ebf1a0b..5d8b2493 100644 --- a/rust/lithe-core/src/git/progress.rs +++ b/rust/lithe-core/src/git/progress.rs @@ -49,7 +49,7 @@ mod tests { #[test] fn progress_matches_shared_phases_without_treating_errors_as_percentages() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/execution-policy-v1.json" + "../../../fixtures/git/execution-policy-v1.json" )) .unwrap(); for case in fixture["progress"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/rebase_session.rs b/rust/lithe-core/src/git/rebase_session.rs index 4f3ddef6..21493a1f 100644 --- a/rust/lithe-core/src/git/rebase_session.rs +++ b/rust/lithe-core/src/git/rebase_session.rs @@ -1239,7 +1239,7 @@ mod plan_preview_tests { #[test] fn shared_fixture_drafts_match_start_semantics() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/rebase-plan-preview-v1.json" + "../../../fixtures/git/rebase-plan-preview-v1.json" )) .unwrap(); let commits = &fixture["commits"]; @@ -1257,7 +1257,7 @@ mod plan_preview_tests { #[test] fn issue_free_preview_is_accepted_by_the_start_planner() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/rebase-plan-preview-v1.json" + "../../../fixtures/git/rebase-plan-preview-v1.json" )) .unwrap(); let commits = fixture["commits"] diff --git a/rust/lithe-core/src/git/reference_tree.rs b/rust/lithe-core/src/git/reference_tree.rs index 09a051ae..784f0fa4 100644 --- a/rust/lithe-core/src/git/reference_tree.rs +++ b/rust/lithe-core/src/git/reference_tree.rs @@ -165,7 +165,7 @@ mod tests { #[test] fn shared_fixture_trees_have_one_grouping_and_order() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/reference-tree-v1.json" + "../../../fixtures/git/reference-tree-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/update_plan.rs b/rust/lithe-core/src/git/update_plan.rs index ee825657..9c87d1e3 100644 --- a/rust/lithe-core/src/git/update_plan.rs +++ b/rust/lithe-core/src/git/update_plan.rs @@ -324,7 +324,7 @@ mod tests { #[test] fn shared_fixture_steps_are_the_only_update_rules() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/update-plan-v1.json" + "../../../fixtures/git/update-plan-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/git/workspace_commit/tests.rs b/rust/lithe-core/src/git/workspace_commit/tests.rs index ba99ca86..a60fc4f5 100644 --- a/rust/lithe-core/src/git/workspace_commit/tests.rs +++ b/rust/lithe-core/src/git/workspace_commit/tests.rs @@ -172,7 +172,7 @@ fn missing_unfinished_roots_and_cycles_fail_closed() { #[test] fn shared_workflow_fixture_is_the_complete_portable_contract() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json" + "../../../../fixtures/git/workspace-commit-workflow-v1.json" )) .unwrap(); let request = serde_json::from_value(fixture["request"].clone()).unwrap(); @@ -275,7 +275,7 @@ fn changelist_scope_is_checked_on_continuations_and_compared_during_review() { #[test] fn local_changelist_scope_fixture_defines_default_and_custom_membership() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/git/local-changelist-scope-v1.json" + "../../../../fixtures/git/local-changelist-scope-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/github/mod.rs b/rust/lithe-core/src/github/mod.rs index c88bb8f5..5c801e85 100644 --- a/rust/lithe-core/src/github/mod.rs +++ b/rust/lithe-core/src/github/mod.rs @@ -187,7 +187,7 @@ impl GitHubRepository { #[serde(rename_all = "camelCase")] /// Shared input for one GitHub request-plan operation. pub struct RequestPlanRequest { - /// Stable operation name documented in `shared/contracts/github.md`. + /// Stable operation name documented in `rust/contracts/github.md`. pub operation: String, /// Repository required by repository-scoped operations. #[serde(default)] diff --git a/rust/lithe-core/src/lsp/document_identity.rs b/rust/lithe-core/src/lsp/document_identity.rs index 9899fcad..4335d55b 100644 --- a/rust/lithe-core/src/lsp/document_identity.rs +++ b/rust/lithe-core/src/lsp/document_identity.rs @@ -114,7 +114,7 @@ mod tests { #[test] fn matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/document-identity-v1.json" + "../../../fixtures/lsp/document-identity-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/lsp/host_policy/tests.rs b/rust/lithe-core/src/lsp/host_policy/tests.rs index 12b6b46a..e12e46bc 100644 --- a/rust/lithe-core/src/lsp/host_policy/tests.rs +++ b/rust/lithe-core/src/lsp/host_policy/tests.rs @@ -23,14 +23,14 @@ fn run_fixture(command: &str, fixture: &str) { fn problems_query_matches_shared_fixture() { run_fixture( "lsp.problems.query", - include_str!("../../../../../shared/fixtures/lsp/problems-query-v1.json"), + include_str!("../../../../fixtures/lsp/problems-query-v1.json"), ); } #[test] fn activation_policy_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/lsp/activation-policy-v1.json" + "../../../../fixtures/lsp/activation-policy-v1.json" )) .unwrap(); // The fixture documents the budget; keep it pinned to the constants. @@ -44,7 +44,7 @@ fn activation_policy_matches_shared_fixture() { ); run_fixture( "lsp.activationPolicy", - include_str!("../../../../../shared/fixtures/lsp/activation-policy-v1.json"), + include_str!("../../../../fixtures/lsp/activation-policy-v1.json"), ); } @@ -52,7 +52,7 @@ fn activation_policy_matches_shared_fixture() { fn completion_merge_matches_shared_fixture() { run_fixture( "lsp.mergeCompletions", - include_str!("../../../../../shared/fixtures/lsp/completion-merge-v1.json"), + include_str!("../../../../fixtures/lsp/completion-merge-v1.json"), ); } diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index 19dccbbb..26677205 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -12,7 +12,7 @@ //! selectors and inline test reports, run and debug preparation, typed run //! options and runtime debug connections, host-resolved model inputs, and //! source-backed native configuration. The rules live in the plugin contract -//! (`shared/contracts/plugins/v1/README.md`, "Build providers"). +//! (`shared/plugins/contract/v1/README.md`, "Build providers"). use crate::plugins::capabilities::{versions as capability_versions, BUILD_PROVIDERS}; use serde_json::{json, Value}; @@ -221,7 +221,7 @@ mod tests { #[test] fn execution_replies_and_progress_match_the_shared_contract() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json" + "../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-execution-protocol.json" )) .unwrap(); assert_eq!( diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index bf745a6b..137ff754 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -2367,7 +2367,7 @@ mod semantic_tests { #[test] fn semantic_tokens_decode_relative_utf16_positions_and_reject_malformed_data() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/lsp/semantic-tokens-v1.json" + "../../../../fixtures/lsp/semantic-tokens-v1.json" )) .unwrap(); let options = fixture["serverOptions"].clone(); @@ -2545,7 +2545,7 @@ mod formatting_and_completion_tests { #[test] fn document_symbol_golden_fixture_matches_normalization() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/lsp/document-symbols-v1.json" + "../../../../fixtures/lsp/document-symbols-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index ee6138a2..e1789d3d 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -6508,7 +6508,7 @@ mod tests { #[test] fn engine_logging_wire_fixture_preserves_defaults_and_routes_new_commands() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/diagnostics/engine-logging-v2.json" + "../../../../fixtures/diagnostics/engine-logging-v2.json" )) .unwrap(); let request: StartServerRequest = serde_json::from_value(fixture["start"].clone()).unwrap(); @@ -8223,7 +8223,7 @@ mod tests { fn document_saves_honor_server_options_and_never_replace_newer_editor_text() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/lsp/document-save-v1.json" + "/../fixtures/lsp/document-save-v1.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -8572,7 +8572,7 @@ mod tests { #[test] fn provider_run_options_reach_the_wire_for_run_preparation_only() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json" + "../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-run-options.json" )) .unwrap(); let params = json!({"launchOptions":fixture["launchOptions"]}); @@ -8605,7 +8605,7 @@ mod tests { #[test] fn provider_connection_debug_options_reach_the_wire() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json" + "../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-connection-debug-options.json" )) .unwrap(); let params = json!({"launchOptions":fixture["launchOptions"]}); @@ -8634,7 +8634,7 @@ mod tests { .await_request("lithe/build/prepareRun") .unwrap(); let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-run-preparation.json" + "../../../../../shared/plugins/contract/v1/fixtures/build-providers/build-run-preparation.json" )) .unwrap(); harness diff --git a/rust/lithe-core/src/lsp/interface/engine_function_provider.rs b/rust/lithe-core/src/lsp/interface/engine_function_provider.rs index 48620f31..4933ae76 100644 --- a/rust/lithe-core/src/lsp/interface/engine_function_provider.rs +++ b/rust/lithe-core/src/lsp/interface/engine_function_provider.rs @@ -151,7 +151,7 @@ mod tests { fn function_provider_selection_matches_shared_workspace_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-function-provider.json" + "/../fixtures/plugins/settings-function-provider.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/lsp/interface/function_relay.rs b/rust/lithe-core/src/lsp/interface/function_relay.rs index d92576f3..1014cf9a 100644 --- a/rust/lithe-core/src/lsp/interface/function_relay.rs +++ b/rust/lithe-core/src/lsp/interface/function_relay.rs @@ -19,7 +19,7 @@ //! the callee receives, never as the callee session's identity. //! //! The rules live in the plugin contract -//! (`shared/contracts/plugins/v1/README.md`, "Plugin functions"). +//! (`shared/plugins/contract/v1/README.md`, "Plugin functions"). use super::plugin_function::{codes, is_function_id, is_plugin_id}; use crate::plugins::plugin_functions::KNOWN_CONTEXTS; diff --git a/rust/lithe-core/src/lsp/interface/keymap_provider.rs b/rust/lithe-core/src/lsp/interface/keymap_provider.rs index efcc11c0..bb2e58fb 100644 --- a/rust/lithe-core/src/lsp/interface/keymap_provider.rs +++ b/rust/lithe-core/src/lsp/interface/keymap_provider.rs @@ -67,7 +67,7 @@ mod tests { #[test] fn shortcut_providers_negotiate_only_an_offered_subset() { let fixtures: Value = serde_json::from_str(include_str!( - "../../../../../shared/fixtures/keymap/provider-negotiation-v1.json" + "../../../../fixtures/keymap/provider-negotiation-v1.json" )) .unwrap(); for case in fixtures["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 8b225f1a..22bf0deb 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -16,7 +16,7 @@ //! planning, outcome and progress normalization, and the refusal of requests a //! backend may not make); the session engine applies them to live traffic. //! The rules live in the plugin contract -//! (`shared/contracts/plugins/v1/README.md`, "Plugin functions"). +//! (`shared/plugins/contract/v1/README.md`, "Plugin functions"). use crate::plugins::capabilities::{versions as capability_versions, BACKEND}; use crate::plugins::language_server_sessions::{self, is_protocol_name}; @@ -1886,7 +1886,7 @@ fn refusal(category: ErrorCode, message: &str, code: &str) -> CoreError { #[cfg(test)] mod tests { - //! Golden cases from `shared/contracts/plugins/v1/fixtures/backend/function-protocol.json`. + //! Golden cases from `shared/plugins/contract/v1/fixtures/backend/function-protocol.json`. //! Timing behavior (cancellation, deadlines, late replies, revocation of a //! running call) is covered against a scripted backend in the engine tests. @@ -1894,7 +1894,7 @@ mod tests { const FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/backend/function-protocol.json" + "/../../shared/plugins/contract/v1/fixtures/backend/function-protocol.json" )); fn fixture() -> Value { diff --git a/rust/lithe-core/src/lsp/interface/plugin_logging.rs b/rust/lithe-core/src/lsp/interface/plugin_logging.rs index 5fdbb76b..b68b8301 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_logging.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_logging.rs @@ -134,7 +134,7 @@ mod tests { #[test] fn shared_plugin_logging_fixture_matches_ingress_and_catalog() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../../shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json" + "../../../../../shared/plugins/contract/v1/fixtures/backend/plugin-logging.json" )) .unwrap(); let functions = fixture["registeredFunctions"] diff --git a/rust/lithe-core/src/lsp/interface/toolchain_provider.rs b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs index b6966696..36d47de5 100644 --- a/rust/lithe-core/src/lsp/interface/toolchain_provider.rs +++ b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs @@ -5,7 +5,7 @@ //! (the `toolchainProviders` capability version) and a subset of the offer. `toolchains/query` is written only to a session whose //! answer passed [`negotiate`]. Replies stay untrusted here: Core transports them //! unchanged, and `toolchains.decodeReply` validates them against native facts. -//! The rules live in `shared/contracts/plugins/v1/toolchains/README.md`. +//! The rules live in `shared/plugins/contract/v1/toolchains/README.md`. use crate::plugins::capabilities::{versions as capability_versions, TOOLCHAIN_PROVIDERS}; use serde_json::{json, Value}; diff --git a/rust/lithe-core/src/lsp/interface/vcs_provider.rs b/rust/lithe-core/src/lsp/interface/vcs_provider.rs index 71b7919b..1cf82777 100644 --- a/rust/lithe-core/src/lsp/interface/vcs_provider.rs +++ b/rust/lithe-core/src/lsp/interface/vcs_provider.rs @@ -7,7 +7,7 @@ //! Only plugin-owned sessions are offered the protocol, and requests are //! written only to a session whose answer named a version this client //! offered, a valid VCS ID, and every required capability. The rules live in -//! `shared/contracts/vcs-provider-v1.md`. +//! `rust/contracts/vcs-provider-v1.md`. use crate::plugins::capabilities::{versions as capability_versions, VCS_PROVIDERS}; use crate::vcs::{negotiated_capabilities, Capabilities}; diff --git a/rust/lithe-core/src/lsp/navigation_target.rs b/rust/lithe-core/src/lsp/navigation_target.rs index a442b04a..60d19f79 100644 --- a/rust/lithe-core/src/lsp/navigation_target.rs +++ b/rust/lithe-core/src/lsp/navigation_target.rs @@ -102,7 +102,7 @@ mod tests { #[test] fn matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/navigation-target-v1.json" + "../../../fixtures/lsp/navigation-target-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 2a59fd5a..7cf51b93 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -447,7 +447,7 @@ fn file_uri_paths_decode_spaces_and_utf8_characters() { #[test] fn document_uri_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/document-uri-v1.json" + "../../../fixtures/lsp/document-uri-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -515,7 +515,7 @@ fn document_uri_accepts_non_ascii_windows_extended_length_paths() { #[test] fn document_uri_identity_keeps_reserved_characters_distinct() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/document-uri-v1.json" + "../../../fixtures/lsp/document-uri-v1.json" )) .unwrap(); for case in fixture["identityCases"].as_array().unwrap() { @@ -2677,7 +2677,7 @@ fn client_core_rejects_unknown_server_requests_with_method_not_found() { #[test] fn client_diagnostic_code_action_fixture_preserves_core_fields() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/diagnostic-code-actions-v1.json" + "../../../fixtures/lsp/diagnostic-code-actions-v1.json" )) .unwrap(); for case in fixture["severityCases"].as_array().unwrap() { @@ -2734,7 +2734,7 @@ fn client_diagnostic_code_action_fixture_preserves_core_fields() { #[test] fn client_completion_preserves_refresh_context_and_incomplete_lists() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/completion-refresh-v1.json" + "../../../fixtures/lsp/completion-refresh-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -2800,7 +2800,7 @@ fn client_completion_rejects_invalid_trigger_context() { #[test] fn text_edits_follow_shared_windows_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/text-edits-v1.json" + "../../../fixtures/lsp/text-edits-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/lsp/workspace_edit_plan.rs b/rust/lithe-core/src/lsp/workspace_edit_plan.rs index f67feaae..47274c92 100644 --- a/rust/lithe-core/src/lsp/workspace_edit_plan.rs +++ b/rust/lithe-core/src/lsp/workspace_edit_plan.rs @@ -168,7 +168,7 @@ mod tests { #[test] fn matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/lsp/workspace-edit-plan-v1.json" + "../../../fixtures/lsp/workspace-edit-plan-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/mcp/library_tests.rs b/rust/lithe-core/src/mcp/library_tests.rs index 6b0402c8..3cbe465b 100644 --- a/rust/lithe-core/src/mcp/library_tests.rs +++ b/rust/lithe-core/src/mcp/library_tests.rs @@ -1,12 +1,12 @@ //! Shared-fixture regressions for MCP library merging, draft validation and -//! multi-file batch planning (`shared/fixtures/mcp/library-v1.json`). +//! multi-file batch planning (`rust/fixtures/mcp/library-v1.json`). use super::{batch_plan, library_snapshot, parse_configuration, validate_draft}; use serde_json::{json, Value}; fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/mcp/library-v1.json" + "../../../fixtures/mcp/library-v1.json" )) .expect("library fixture is JSON") } diff --git a/rust/lithe-core/src/mcp/registry.rs b/rust/lithe-core/src/mcp/registry.rs index b68cae34..1007c78a 100644 --- a/rust/lithe-core/src/mcp/registry.rs +++ b/rust/lithe-core/src/mcp/registry.rs @@ -304,7 +304,7 @@ mod tests { #[test] fn registry_page_keeps_versions_cursor_and_only_complete_drafts() { - let content = include_str!("../../../../shared/contracts/mcp-registry-page.json"); + let content = include_str!("../../../contracts/mcp-registry-page.json"); let page = parse_registry(json!({"content": content})).unwrap(); assert_eq!(page["entries"].as_array().unwrap().len(), 3); assert_eq!(page["nextCursor"], "opaque/+ cursor?"); diff --git a/rust/lithe-core/src/network/tests.rs b/rust/lithe-core/src/network/tests.rs index 16a058ce..3e4cf099 100644 --- a/rust/lithe-core/src/network/tests.rs +++ b/rust/lithe-core/src/network/tests.rs @@ -16,7 +16,7 @@ fn policy_for(request: Value) -> Value { fn fixture_cases_match_the_shared_contract() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/network/proxy-policy-v1.json" + "/../fixtures/network/proxy-policy-v1.json" ))) .expect("proxy policy fixture is JSON"); for case in fixture["cases"].as_array().expect("fixture cases") { diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 7c66e8bf..b6d830b4 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -4,7 +4,7 @@ //! A declarative plugin carries no host-loaded code. The host reads its //! `plugin.json`, asks this module to validate and normalize it, and launches //! the declared external processes itself. Both products must produce the -//! same outcome for the fixtures in `shared/contracts/plugins/v1/`. +//! same outcome for the fixtures in `shared/plugins/contract/v1/`. //! //! Content problems in a manifest or request are reported as `rejected` //! outcomes with a stable `plugin.*` code instead of Core failures, so hosts diff --git a/rust/lithe-core/src/plugins/editor_actions.rs b/rust/lithe-core/src/plugins/editor_actions.rs index b2feec0e..6d475448 100644 --- a/rust/lithe-core/src/plugins/editor_actions.rs +++ b/rust/lithe-core/src/plugins/editor_actions.rs @@ -650,7 +650,7 @@ mod tests { fn contract_fixture_cases() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/editor/editor-actions.json" + "/../../shared/plugins/contract/v1/fixtures/editor/editor-actions.json" ))) .unwrap(); let host: EditorHostOptions = serde_json::from_value(fixture["host"].clone()).unwrap(); diff --git a/rust/lithe-core/src/plugins/editor_providers.rs b/rust/lithe-core/src/plugins/editor_providers.rs index e47db8e1..e9844e7e 100644 --- a/rust/lithe-core/src/plugins/editor_providers.rs +++ b/rust/lithe-core/src/plugins/editor_providers.rs @@ -867,7 +867,7 @@ mod tests { fn contract_fixture_cases() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/editor/editor-providers.json" + "/../../shared/plugins/contract/v1/fixtures/editor/editor-providers.json" ))) .unwrap(); let types = &fixture["host"]["providerTypes"]; diff --git a/rust/lithe-core/src/plugins/host_api.rs b/rust/lithe-core/src/plugins/host_api.rs index 7574a1a4..34d2d5f8 100644 --- a/rust/lithe-core/src/plugins/host_api.rs +++ b/rust/lithe-core/src/plugins/host_api.rs @@ -105,7 +105,7 @@ mod tests { #[test] fn authorization_does_not_widen_metadata_grants_or_accept_spoofed_services() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/plugins/host-api-v1.json" + "../../../fixtures/plugins/host-api-v1.json" )) .unwrap(); for case in fixture["authorization"].as_array().unwrap() { diff --git a/rust/lithe-core/src/plugins/installed.rs b/rust/lithe-core/src/plugins/installed.rs index 5fc0f4f9..30b98a3d 100644 --- a/rust/lithe-core/src/plugins/installed.rs +++ b/rust/lithe-core/src/plugins/installed.rs @@ -90,7 +90,7 @@ mod tests { fn shared_installed_version_fixture_uses_the_existing_numeric_comparator() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/installed-versions.json" + "/../fixtures/plugins/installed-versions.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/plugins/keymap_import.rs b/rust/lithe-core/src/plugins/keymap_import.rs index 534dac47..319affb3 100644 --- a/rust/lithe-core/src/plugins/keymap_import.rs +++ b/rust/lithe-core/src/plugins/keymap_import.rs @@ -459,7 +459,7 @@ mod tests { #[test] fn import_binding_plans_follow_shared_fixtures() { let fixtures: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/import-plan-v1.json" + "../../../fixtures/keymap/import-plan-v1.json" )) .unwrap(); for case in fixtures["cases"].as_array().unwrap() { @@ -479,7 +479,7 @@ mod tests { #[test] fn normalized_import_provider_replies_follow_shared_fixtures() { let fixtures: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/import-provider-v1.json" + "../../../fixtures/keymap/import-provider-v1.json" )) .unwrap(); for case in fixtures["cases"].as_array().unwrap() { @@ -500,7 +500,7 @@ mod tests { #[test] fn dispatcher_returns_the_validated_reply_without_partial_results() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/import-provider-v1.json" + "../../../fixtures/keymap/import-provider-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -549,7 +549,7 @@ mod tests { #[test] fn import_provider_manifest_is_generic_bounded_and_permission_checked() { let plugin: Value = serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" )) .unwrap(); let validate = crate::plugins::declarative::validate_manifest_value; @@ -649,11 +649,11 @@ mod tests { #[test] fn source_plans_only_expose_inputs_of_the_requested_provider() { let plugin: Value = serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" )) .unwrap(); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/source-plans-v1.json" + "../../../fixtures/keymap/source-plans-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -674,7 +674,7 @@ mod tests { plan_sources_command(json!({"manifest": invalid, "providerID": "second"})).is_err() ); let mut invalid: Value = serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json" )) .unwrap(); invalid["keymapImportProviders"][1]["recognition"][0]["path"] = json!("../private"); diff --git a/rust/lithe-core/src/plugins/marketplace.rs b/rust/lithe-core/src/plugins/marketplace.rs index 528f66d7..4592295c 100644 --- a/rust/lithe-core/src/plugins/marketplace.rs +++ b/rust/lithe-core/src/plugins/marketplace.rs @@ -6,7 +6,7 @@ //! this module only decides, deterministically, which entries are valid, //! which version a platform can install, and which programs a package will //! start, so macOS and Windows present the same catalog and the same trust -//! prompts. The contract lives in `shared/contracts/plugins/v1/`. +//! prompts. The contract lives in `shared/plugins/contract/v1/`. use super::capabilities::{self, HostCapabilities, Requires}; use super::declarative::{ diff --git a/rust/lithe-core/src/plugins/settings_document_tests.rs b/rust/lithe-core/src/plugins/settings_document_tests.rs index d9bb8726..109a5177 100644 --- a/rust/lithe-core/src/plugins/settings_document_tests.rs +++ b/rust/lithe-core/src/plugins/settings_document_tests.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/settings/settings.plugin.json" ))) .unwrap() } @@ -83,7 +83,7 @@ fn settings_state_dispatches_with_core_owned_sources_and_normalized_scope() { fn complete_edit_fixture_preserves_types_siblings_and_atomic_rejection() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-documents.json" + "/../fixtures/plugins/settings-documents.json" ))) .unwrap(); for case in fixture["editCases"].as_array().unwrap() { @@ -141,7 +141,7 @@ fn complete_edit_fixture_preserves_types_siblings_and_atomic_rejection() { fn missing_files_are_empty_but_empty_existing_documents_are_rejected() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-documents.json" + "/../fixtures/plugins/settings-documents.json" ))) .unwrap(); let valid = execute( @@ -163,7 +163,7 @@ fn missing_files_are_empty_but_empty_existing_documents_are_rejected() { fn invalid_stored_values_fall_back_without_hiding_raw_executable_overrides() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-documents.json" + "/../fixtures/plugins/settings-documents.json" ))) .unwrap(); let result = execute( @@ -193,7 +193,7 @@ fn invalid_stored_values_fall_back_without_hiding_raw_executable_overrides() { fn document_size_and_batch_limits_reject_before_returning_replacement_text() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-documents.json" + "/../fixtures/plugins/settings-documents.json" ))) .unwrap(); let oversized = execute( @@ -223,7 +223,7 @@ fn document_size_and_batch_limits_reject_before_returning_replacement_text() { fn plugins_without_setting_declarations_do_not_request_native_document_reads() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-documents.json" + "/../fixtures/plugins/settings-documents.json" ))) .unwrap(); let mut manifest = fixture["manifest"].clone(); @@ -245,7 +245,7 @@ fn plugins_without_setting_declarations_do_not_request_native_document_reads() { fn editor_projection_owns_scope_reset_values_and_section_grouping() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-documents.json" + "/../fixtures/plugins/settings-documents.json" ))) .unwrap(); let mut manifest = fixture["manifest"].clone(); @@ -297,7 +297,7 @@ fn editor_projection_owns_scope_reset_values_and_section_grouping() { fn setting_action_plans_match_shared_fixture_without_starting_a_process() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-actions.json" + "/../fixtures/plugins/settings-actions.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -323,7 +323,7 @@ fn setting_action_plans_match_shared_fixture_without_starting_a_process() { fn settings_surfaces_shared_vectors() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/plugins/settings-surfaces.json" + "/../fixtures/plugins/settings-surfaces.json" ))) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/plugins/web_content_tests.rs b/rust/lithe-core/src/plugins/web_content_tests.rs index 37eeefa9..19c60777 100644 --- a/rust/lithe-core/src/plugins/web_content_tests.rs +++ b/rust/lithe-core/src/plugins/web_content_tests.rs @@ -4,7 +4,7 @@ use super::*; fn manifest() -> Value { serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/ui/web-panel.plugin.json" )) .expect("fixture") } diff --git a/rust/lithe-core/src/process/inventory.rs b/rust/lithe-core/src/process/inventory.rs index cd30b96f..55f41295 100644 --- a/rust/lithe-core/src/process/inventory.rs +++ b/rust/lithe-core/src/process/inventory.rs @@ -12,7 +12,7 @@ //! //! The rules are described in //! `.agents/notes/proposed/architecture/2026-10-16-unified-child-process-lifecycle.md`; -//! fixtures live in `shared/fixtures/process-lifecycle/`. +//! fixtures live in `rust/fixtures/process-lifecycle/`. use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -582,7 +582,7 @@ mod tests { #[test] fn shared_fixture_inventories_match() { let fixture: Fixture = serde_json::from_str(include_str!( - "../../../../shared/fixtures/process-lifecycle/inventory-v1.json" + "../../../fixtures/process-lifecycle/inventory-v1.json" )) .expect("fixture should parse"); assert_eq!(fixture.schema_version, 1); diff --git a/rust/lithe-core/src/process/lifecycle.rs b/rust/lithe-core/src/process/lifecycle.rs index 5f7e5226..b94245db 100644 --- a/rust/lithe-core/src/process/lifecycle.rs +++ b/rust/lithe-core/src/process/lifecycle.rs @@ -7,7 +7,7 @@ //! `.agents/notes/proposed/architecture/2026-10-16-unified-child-process-lifecycle.md` //! is decided once. `settle` turns the user's answers into what is ended and //! kept, so the remembered-choice rules are not repeated by the hosts either. -//! Fixtures live in `shared/fixtures/process-lifecycle/`. +//! Fixtures live in `rust/fixtures/process-lifecycle/`. use std::collections::BTreeMap; @@ -636,7 +636,7 @@ mod tests { #[test] fn shared_fixture_decisions_match() { let fixture: Fixture = serde_json::from_str(include_str!( - "../../../../shared/fixtures/process-lifecycle/exit-plan-v1.json" + "../../../fixtures/process-lifecycle/exit-plan-v1.json" )) .expect("fixture should parse"); assert_eq!(fixture.schema_version, 1); @@ -651,7 +651,7 @@ mod tests { #[test] fn shared_fixture_settlements_match() { let fixture: Fixture = serde_json::from_str(include_str!( - "../../../../shared/fixtures/process-lifecycle/settle-v1.json" + "../../../fixtures/process-lifecycle/settle-v1.json" )) .expect("fixture should parse"); assert_eq!(fixture.schema_version, 1); diff --git a/rust/lithe-core/src/project/directory_actions.rs b/rust/lithe-core/src/project/directory_actions.rs index 5b79d961..60951c37 100644 --- a/rust/lithe-core/src/project/directory_actions.rs +++ b/rust/lithe-core/src/project/directory_actions.rs @@ -14,7 +14,7 @@ //! //! Directory keys are compared as paths: with `caseInsensitive` set, keys that differ only //! by case name the same directory. Stored keys keep the spelling they were written with. -//! The sequence contract is `shared/fixtures/workspace/directory-actions-v1.json`; the +//! The sequence contract is `rust/fixtures/workspace/directory-actions-v1.json`; the //! request and response shapes are pinned by `directory-rules-v1.json`. use crate::protocol::{CoreError, ErrorCode}; diff --git a/rust/lithe-core/src/project/file_name_order.rs b/rust/lithe-core/src/project/file_name_order.rs index 86e7116c..475f7187 100644 --- a/rust/lithe-core/src/project/file_name_order.rs +++ b/rust/lithe-core/src/project/file_name_order.rs @@ -10,7 +10,7 @@ //! d8c6714c7a3c110c56994d6b0f10814f902100e7 (Apache-2.0, Copyright JetBrains s.r.o. //! and contributors). The macOS Swift port (`NaturalNameOrder`) and the Windows //! TypeScript port follow the same source and share -//! `shared/fixtures/workspace/file-name-order-v1.json`. +//! `rust/fixtures/workspace/file-name-order-v1.json`. //! //! Hosts that enumerate a directory natively (Windows local, SSH and WSL trees, and //! macOS plugin listings) do not compare names themselves; they send the siblings to diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index 7fd0035d..a98fa3f0 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -2280,7 +2280,7 @@ mod event_filter_tests { #[test] fn shared_text_snapshot_contract_fixture() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/text-snapshot-v1.json" + "../../../fixtures/search/text-snapshot-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -2376,7 +2376,7 @@ mod event_filter_tests { #[test] fn shared_workspace_change_fixture() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/change-filter-v1.json" + "../../../fixtures/workspace/change-filter-v1.json" )) .unwrap(); let paths: Vec = serde_json::from_value(fixture["paths"].clone()).unwrap(); diff --git a/rust/lithe-core/src/project/indexing.rs b/rust/lithe-core/src/project/indexing.rs index 3323d118..f66fb1ae 100644 --- a/rust/lithe-core/src/project/indexing.rs +++ b/rust/lithe-core/src/project/indexing.rs @@ -868,7 +868,7 @@ mod tests { #[test] fn directory_action_fixture_preserves_real_git_search_eligibility() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/directory-actions-v1.json" + "../../../fixtures/workspace/directory-actions-v1.json" )) .unwrap(); let workspace = Workspace::new(); @@ -1155,7 +1155,7 @@ mod tests { parent.configure(&[], &[], false); nested.configure(&["deep"], &[], false); let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/indexing-policy-v1.json" + "../../../fixtures/workspace/indexing-policy-v1.json" )) .unwrap(); for case in fixture["ownershipCases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/project/markdown.rs b/rust/lithe-core/src/project/markdown.rs index e1d45183..6c63ca85 100644 --- a/rust/lithe-core/src/project/markdown.rs +++ b/rust/lithe-core/src/project/markdown.rs @@ -174,7 +174,7 @@ mod tests { #[test] fn preserves_rich_content_for_offline_preview() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/markdown-rich-content-v1.json" + "../../../fixtures/editor/markdown-rich-content-v1.json" )) .expect("rich Markdown fixture should decode"); let output = html(fixture["source"].as_str().expect("fixture source")); @@ -194,7 +194,7 @@ mod tests { #[test] fn preserves_html_alignment_and_inline_badges_without_allowing_styles() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/markdown-html-v1.json" + "../../../fixtures/editor/markdown-html-v1.json" )) .expect("Markdown HTML fixture should decode"); let output = html(fixture["source"].as_str().expect("fixture source")); diff --git a/rust/lithe-core/src/project/name_matching.rs b/rust/lithe-core/src/project/name_matching.rs index 7da3ab4c..fa974557 100644 --- a/rust/lithe-core/src/project/name_matching.rs +++ b/rust/lithe-core/src/project/name_matching.rs @@ -192,7 +192,7 @@ mod tests { #[test] fn unicode_contiguous_matches_map_back_to_original_utf16() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/unicode-contiguous.json" + "../../../fixtures/search/unicode-contiguous.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/project/search_index.rs b/rust/lithe-core/src/project/search_index.rs index 85245117..64911453 100644 --- a/rust/lithe-core/src/project/search_index.rs +++ b/rust/lithe-core/src/project/search_index.rs @@ -826,7 +826,7 @@ mod tests { fs::write(file, "Name before").unwrap(); } let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/exclusions.json" + "../../../fixtures/search/exclusions.json" )) .unwrap(); // The patterns come from the shared fixture, so Rust and Swift always diff --git a/rust/lithe-core/src/remote/sessions_integration_tests.rs b/rust/lithe-core/src/remote/sessions_integration_tests.rs index 0ebb4351..28ddc82f 100644 --- a/rust/lithe-core/src/remote/sessions_integration_tests.rs +++ b/rust/lithe-core/src/remote/sessions_integration_tests.rs @@ -104,7 +104,7 @@ fn remote_build_detects_host_models_normalizes_diagnostics_and_invalidates_sync( .unwrap(); let manifest: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/build-systems/build-static-file.plugin.json" ))) .unwrap(); let detection = host.request( @@ -179,7 +179,7 @@ fn remote_build_detects_a_root_opened_through_a_host_symbolic_link() { std::os::unix::fs::symlink(&root, &link).unwrap(); let manifest: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/build-systems/build-static-file.plugin.json" ))) .unwrap(); let detection = host.request_at( diff --git a/rust/lithe-core/src/runtime/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs index 6fb9f830..2c509dd3 100644 --- a/rust/lithe-core/src/runtime/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -255,7 +255,7 @@ pub unsafe extern "C" fn lithe_agent_open_json( /// Queues one UTF-8 JSON command on an ACP connection; returns 1 when accepted. /// -/// Commands follow `shared/fixtures/agent/acp-events-v1.json`. Results arrive +/// Commands follow `rust/fixtures/agent/acp-events-v1.json`. Results arrive /// asynchronously as events; 0 means the JSON was invalid, the permission /// request is gone, or the connection has stopped. /// @@ -303,7 +303,7 @@ mod text_content_tests { #[test] fn decoded_text_contract_matches_c_abi_and_json() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/text-content-v1.json" + "../../../fixtures/editor/text-content-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index 40efa409..dd76dd7c 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -4,7 +4,7 @@ use serde_json::{json, Value}; #[test] fn global_instruction_contract_dispatches_and_rejects_unknown_agents_without_file_access() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/agent-instructions-v1.json" + "../../../fixtures/agent/agent-instructions-v1.json" )) .unwrap(); for key in ["readRequest", "saveRequest"] { @@ -52,7 +52,7 @@ fn run(command: &str, payload: Value) -> Value { #[test] fn hook_contract_dispatch_is_strict_before_accessing_native_files() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/hooks-management-v1.json" + "../../../fixtures/agent/hooks-management-v1.json" )) .unwrap(); assert!(crate::protocol::CoreCommand::parse(fixture["command"].as_str().unwrap()).is_some()); @@ -70,7 +70,7 @@ fn hook_contract_dispatch_is_strict_before_accessing_native_files() { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/agent-management-v1.json" + "../../../fixtures/agent/agent-management-v1.json" )) .expect("agent management fixture") } @@ -232,7 +232,7 @@ fn cli_update_outcome_fixture_preserves_verified_version_and_warning() { #[test] fn edit_reverse_plan_dispatches_shared_fixture_and_rejects_malformed_payloads() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/edit-review-v1.json" + "../../../fixtures/agent/edit-review-v1.json" )) .unwrap(); assert!(crate::protocol::CoreCommand::parse("agent.edits.reversePlan").is_some()); @@ -258,7 +258,7 @@ fn edit_reverse_plan_dispatches_shared_fixture_and_rejects_malformed_payloads() #[test] fn hook_draft_validation_dispatch_returns_the_shared_field_codes() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/hook-draft-validation-v1.json" + "../../../fixtures/agent/hook-draft-validation-v1.json" )) .unwrap(); let command = fixture["command"].as_str().unwrap(); @@ -274,7 +274,7 @@ fn hook_draft_validation_dispatch_returns_the_shared_field_codes() { #[test] fn attachment_retention_dispatch_follows_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/agent/attachment-retention-v1.json" + "../../../fixtures/agent/attachment-retention-v1.json" )) .unwrap(); let command = fixture["command"].as_str().unwrap(); diff --git a/rust/lithe-core/src/tests/build_configuration.rs b/rust/lithe-core/src/tests/build_configuration.rs index d6275fb9..ca4456ae 100644 --- a/rust/lithe-core/src/tests/build_configuration.rs +++ b/rust/lithe-core/src/tests/build_configuration.rs @@ -9,13 +9,13 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/build-providers/build-provider-configuration.plugin.json" )) .unwrap() } fn snapshot() -> Value { let mut value: Value = serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json" + "../../../../shared/plugins/contract/v1/fixtures/build-providers/build-configuration.json" )) .unwrap(); if cfg!(windows) { diff --git a/rust/lithe-core/src/tests/build_project_options.rs b/rust/lithe-core/src/tests/build_project_options.rs index 212843c0..35370434 100644 --- a/rust/lithe-core/src/tests/build_project_options.rs +++ b/rust/lithe-core/src/tests/build_project_options.rs @@ -6,11 +6,11 @@ use serde_json::{json, Value}; #[test] fn local_option_edits_check_declarations_preserve_siblings_and_reset_to_team_values() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/build-project-options/local-edits.json" + "../../../fixtures/build-project-options/local-edits.json" )) .unwrap(); let mut manifest: Value = serde_json::from_str(include_str!( - "../../../../shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json" + "../../../../shared/plugins/contract/v1/fixtures/build-systems/build-instance-options.plugin.json" )) .unwrap(); // Exercise a workspace option with model-owned choices as well as the @@ -129,7 +129,7 @@ fn local_option_edits_check_declarations_preserve_siblings_and_reset_to_team_val #[test] fn native_build_options_validate_the_shared_cases_without_dropping_custom_values() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/build-project-options/validation.json" + "../../../fixtures/build-project-options/validation.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -151,7 +151,7 @@ fn native_build_options_validate_the_shared_cases_without_dropping_custom_values assert_eq!(case["document"], original); } let defaults: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/build-project-options/shared-defaults.json" + "../../../fixtures/build-project-options/shared-defaults.json" )) .unwrap(); let result = project_options_command(json!({"document":defaults})).unwrap(); diff --git a/rust/lithe-core/src/tests/build_runtime_versions.rs b/rust/lithe-core/src/tests/build_runtime_versions.rs index 91c7f7a1..65eb71b7 100644 --- a/rust/lithe-core/src/tests/build_runtime_versions.rs +++ b/rust/lithe-core/src/tests/build_runtime_versions.rs @@ -7,7 +7,7 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/build-systems/build-toolchains.plugin.json" ))) .expect("Cargo fixture is valid JSON") } diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index da9503ba..58090479 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -22,7 +22,7 @@ macro_rules! contract_file { ($path:literal) => { include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/", + "/../../shared/plugins/contract/v1/", $path )) }; diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 53fc5ae3..e5f5a95d 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -8,7 +8,7 @@ macro_rules! contract_file { ($path:literal) => { include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/", + "/../../shared/plugins/contract/v1/", $path )) }; diff --git a/rust/lithe-core/src/tests/directory_actions.rs b/rust/lithe-core/src/tests/directory_actions.rs index bbed28a0..c1ef9250 100644 --- a/rust/lithe-core/src/tests/directory_actions.rs +++ b/rust/lithe-core/src/tests/directory_actions.rs @@ -10,7 +10,7 @@ fn execute(command: &str, payload: Value) -> Value { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/directory-rules-v1.json" + "../../../fixtures/workspace/directory-rules-v1.json" )) .expect("directory rules fixture should be JSON") } @@ -75,7 +75,7 @@ fn invalid_requests_are_rejected() { #[test] fn action_sequences_reach_the_documented_role_and_inclusion() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/directory-actions-v1.json" + "../../../fixtures/workspace/directory-actions-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/tests/file_name_order.rs b/rust/lithe-core/src/tests/file_name_order.rs index c9003cb5..0eff4bfc 100644 --- a/rust/lithe-core/src/tests/file_name_order.rs +++ b/rust/lithe-core/src/tests/file_name_order.rs @@ -7,7 +7,7 @@ use std::fs; fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/file-name-order-v1.json" + "../../../fixtures/workspace/file-name-order-v1.json" )) .expect("file-name order fixture should be JSON") } @@ -209,7 +209,7 @@ fn execute(command: &str, payload: Value) -> Value { #[test] fn order_entries_matches_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/entry-order-v1.json" + "../../../fixtures/workspace/entry-order-v1.json" )) .expect("entry-order fixture should be JSON"); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/tests/file_operations.rs b/rust/lithe-core/src/tests/file_operations.rs index af1cd5ed..0adf61f6 100644 --- a/rust/lithe-core/src/tests/file_operations.rs +++ b/rust/lithe-core/src/tests/file_operations.rs @@ -10,7 +10,7 @@ fn execute(command: &str, payload: Value) -> Value { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/file-copy-names-v1.json" + "../../../fixtures/workspace/file-copy-names-v1.json" )) .expect("file copy names fixture should be JSON") } diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 09770555..cda74a51 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -9,7 +9,7 @@ use std::time::{Duration, UNIX_EPOCH}; #[test] fn repository_color_assignments_match_shared_pages() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/repository-colors-v1.json" + "../../../fixtures/git/repository-colors-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -45,7 +45,7 @@ fn repository_color_assignments_reject_invalid_pages() { #[test] fn git_checkout_metadata_and_gitdir_pointer_match_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/checkout-metadata-v1.json" + "../../../fixtures/git/checkout-metadata-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -127,7 +127,7 @@ fn git_remote_and_tag_catalog_returns_unambiguous_typed_data() { ]); let commit = run(&["rev-parse", "HEAD"]); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/remote-tag-catalog-v1.json" + "../../../fixtures/git/remote-tag-catalog-v1.json" )) .unwrap(); let call = |command: &str| -> Value { @@ -193,7 +193,7 @@ fn git_comparison_can_include_untracked_worktree_files() { fs::write(root.join("tracked.txt"), "after\n").unwrap(); fs::write(root.join("a-untracked.txt"), "new\n").unwrap(); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/comparison-untracked-v1.json" + "../../../fixtures/git/comparison-untracked-v1.json" )) .unwrap(); let mut payload = fixture["request"].clone(); @@ -2354,7 +2354,7 @@ fn git_write_creates_deletes_and_records_tags() { // plain invalid_request envelope. let tag_names: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/tag-names.json" + "/../fixtures/git/tag-names.json" ))) .expect("tag name fixture should be valid JSON"); let empty = request( @@ -6320,7 +6320,7 @@ fn git_line_history_merge_rename_edit_keeps_previous_path_and_ranges() { #[test] fn rollback_plan_matches_shared_fixture_and_rejects_unsafe_paths() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/rollback-plan-v1.json" + "../../../fixtures/git/rollback-plan-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -6814,7 +6814,7 @@ fn prepare_commit_selection_allows_a_tracked_submodule_reference() { #[test] fn git_history_query_compares_absolute_instants_with_half_open_bounds() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/history-date-query-v1.json" + "../../../fixtures/git/history-date-query-v1.json" )) .unwrap(); let result: Value = serde_json::from_str(&execute_json( @@ -6984,7 +6984,7 @@ fn reconcile_deleted_selection_works_before_the_first_commit() { fn git_branch_name_preview_matches_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/git/branch-name-preview-v1.json" + "/../fixtures/git/branch-name-preview-v1.json" ))) .expect("branch name preview fixture should be valid JSON"); for case in fixture["cases"].as_array().expect("fixture cases") { @@ -7355,7 +7355,7 @@ fn git_create_tag_applies_the_explicit_lightweight_form() { #[test] fn git_metadata_classification_matches_shared_fixture() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/metadata-classification-v1.json" + "../../../fixtures/git/metadata-classification-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -7384,7 +7384,7 @@ fn git_metadata_classification_matches_shared_fixture() { #[test] fn git_metadata_classification_rejects_invalid_or_unbounded_pages() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/metadata-classification-v1.json" + "../../../fixtures/git/metadata-classification-v1.json" )) .unwrap(); let context = &fixture["contexts"]["main"]; diff --git a/rust/lithe-core/src/tests/git_diff_blocks.rs b/rust/lithe-core/src/tests/git_diff_blocks.rs index 4a81acc9..d944871a 100644 --- a/rust/lithe-core/src/tests/git_diff_blocks.rs +++ b/rust/lithe-core/src/tests/git_diff_blocks.rs @@ -10,7 +10,7 @@ use std::process::Command; fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/diff-blocks-v1.json" + "../../../fixtures/git/diff-blocks-v1.json" )) .expect("diff block fixture should be JSON") } diff --git a/rust/lithe-core/src/tests/git_fetch.rs b/rust/lithe-core/src/tests/git_fetch.rs index 8dad6c62..e7dc7824 100644 --- a/rust/lithe-core/src/tests/git_fetch.rs +++ b/rust/lithe-core/src/tests/git_fetch.rs @@ -62,7 +62,7 @@ impl Drop for Repository { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/fetch-skip-boolean-v1.json" + "../../../fixtures/git/fetch-skip-boolean-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/tests/git_history_rewrite.rs b/rust/lithe-core/src/tests/git_history_rewrite.rs index 7c637970..160b278c 100644 --- a/rust/lithe-core/src/tests/git_history_rewrite.rs +++ b/rust/lithe-core/src/tests/git_history_rewrite.rs @@ -112,7 +112,7 @@ fn history_action_capabilities_use_real_head_and_complete_first_parent_chain() { let side = repo.commit("side.txt", "side\n", "side"); repo.git(&["checkout", "-q", "main"]); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/history-action-capabilities-v1.json" + "../../../fixtures/git/history-action-capabilities-v1.json" )) .unwrap(); let capabilities = |revisions: Vec<&str>| { @@ -988,7 +988,7 @@ fn native_rebase_large_escaped_manifest_remains_readable_through_abort() { #[test] fn github_pr_checkout_rejects_conflicts_and_stale_review_before_creating_branch() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/github/pr-checkout-plan-v1.json" + "../../../fixtures/github/pr-checkout-plan-v1.json" )) .unwrap(); let codes = &fixture["errorCodes"]; diff --git a/rust/lithe-core/src/tests/git_patch_exchange.rs b/rust/lithe-core/src/tests/git_patch_exchange.rs index fe6772fd..3208f69d 100644 --- a/rust/lithe-core/src/tests/git_patch_exchange.rs +++ b/rust/lithe-core/src/tests/git_patch_exchange.rs @@ -59,7 +59,7 @@ impl PatchRepository { fn export_shelf_working_layer(&self) -> Value { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/patch-exchange-v1.json" + "../../../fixtures/git/patch-exchange-v1.json" )) .unwrap(); self.data( @@ -102,7 +102,7 @@ fn patch_exchange_reverse_roundtrip_preserves_both_versions_and_untracked_bytes( .to_string(); let mut checked_direction = false; let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/patch-exchange-v1.json" + "../../../fixtures/git/patch-exchange-v1.json" )) .unwrap(); let mut apply = |patch: &str, target: &str, reverse: bool| { @@ -278,7 +278,7 @@ fn saved_stash_diff_returns_file_sections_and_core_documents() { repository.write("text.txt", b"newer stash\n"); repository.git(&["stash", "push", "-m", "newer"]); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/saved-stash-v1.json" + "../../../fixtures/git/saved-stash-v1.json" )) .unwrap(); let mut request = fixture["sectionRequest"].clone(); @@ -355,7 +355,7 @@ fn structured_patch_and_git_diff_preserve_file_identity_and_writable_hunks() { .unwrap() .to_owned(); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/parsed-diff-v1.json" + "../../../fixtures/git/parsed-diff-v1.json" )) .unwrap(); let mut request = fixture["parseRequest"].clone(); @@ -964,7 +964,7 @@ fn shelf_clean_and_retry_restore_preserve_staged_working_and_untracked() { "capture must not refresh the real index" ); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/shelf-v1.json" + "../../../fixtures/git/shelf-v1.json" )) .unwrap(); assert_eq!( @@ -1094,7 +1094,7 @@ fn commit_message_context_rejects_new_index_content_at_the_same_path() { let selection = json!([{ "root": repository.0, "files": [{"path":"text.txt", "changeKind":"modified"}] }]); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/commit-message-context-v1.json" + "../../../fixtures/git/commit-message-context-v1.json" )) .unwrap(); let mut request = fixture["captureRequest"].clone(); diff --git a/rust/lithe-core/src/tests/git_remote_checkout.rs b/rust/lithe-core/src/tests/git_remote_checkout.rs index e738ce5d..11c15fcd 100644 --- a/rust/lithe-core/src/tests/git_remote_checkout.rs +++ b/rust/lithe-core/src/tests/git_remote_checkout.rs @@ -133,7 +133,7 @@ fn assert_success(response: &Value) { fn contract() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/remote-checkout-v1.json" + "../../../fixtures/git/remote-checkout-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/tests/git_repository_discovery.rs b/rust/lithe-core/src/tests/git_repository_discovery.rs index 9b3f81cd..ae3b5669 100644 --- a/rust/lithe-core/src/tests/git_repository_discovery.rs +++ b/rust/lithe-core/src/tests/git_repository_discovery.rs @@ -68,7 +68,7 @@ fn canonical(root: &Path) -> String { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/repository-visibility-v1.json" + "../../../fixtures/workspace/repository-visibility-v1.json" )) .unwrap() } diff --git a/rust/lithe-core/src/tests/git_workspace_commit.rs b/rust/lithe-core/src/tests/git_workspace_commit.rs index 4477928b..112e9544 100644 --- a/rust/lithe-core/src/tests/git_workspace_commit.rs +++ b/rust/lithe-core/src/tests/git_workspace_commit.rs @@ -330,7 +330,7 @@ fn git_workspace_commit_unstage_preserves_edits_after_initial_staging() { #[test] fn git_workspace_commit_state_matches_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/git/workspace-commit-v1.json" + "../../../fixtures/git/workspace-commit-v1.json" )) .unwrap(); let state: crate::git::GitCommitState = diff --git a/rust/lithe-core/src/tests/github.rs b/rust/lithe-core/src/tests/github.rs index cd62aa7c..522b7e55 100644 --- a/rust/lithe-core/src/tests/github.rs +++ b/rust/lithe-core/src/tests/github.rs @@ -228,7 +228,7 @@ fn github_http_failures_use_stable_error_categories_without_response_bodies_in_d #[test] fn device_authorization_transition_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/github/device-authorization-transition-v1.json" + "../../../fixtures/github/device-authorization-transition-v1.json" )) .expect("device authorization fixture must be valid JSON"); for case in fixture["cases"].as_array().expect("fixture cases") { @@ -244,7 +244,7 @@ fn device_authorization_transition_shared_fixture() { #[test] fn pull_request_checkout_plan_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/github/pr-checkout-plan-v1.json" + "../../../fixtures/github/pr-checkout-plan-v1.json" )) .expect("checkout fixture must be valid JSON"); for case in fixture["cases"].as_array().expect("fixture cases") { diff --git a/rust/lithe-core/src/tests/history_events.rs b/rust/lithe-core/src/tests/history_events.rs index be5a5b36..95bcab2f 100644 --- a/rust/lithe-core/src/tests/history_events.rs +++ b/rust/lithe-core/src/tests/history_events.rs @@ -11,7 +11,7 @@ fn execute(payload: Value) -> Value { fn fixture() -> Value { serde_json::from_str(include_str!( - "../../../../shared/fixtures/history/event-plan-v1.json" + "../../../fixtures/history/event-plan-v1.json" )) .expect("history event fixture should be JSON") } diff --git a/rust/lithe-core/src/tests/language_server_sessions.rs b/rust/lithe-core/src/tests/language_server_sessions.rs index ad772261..6644f660 100644 --- a/rust/lithe-core/src/tests/language_server_sessions.rs +++ b/rust/lithe-core/src/tests/language_server_sessions.rs @@ -1,7 +1,7 @@ //! Contract tests for plugin-owned language server sessions (API-25). //! //! The manifest fixture and the launch-resolution golden live in -//! `shared/contracts/plugins/v1/fixtures/` so the plugin documentation +//! `shared/plugins/contract/v1/fixtures/` so the plugin documentation //! repository and other hosts run the same cases. use crate::plugins::declarative::{ @@ -14,7 +14,7 @@ macro_rules! contract_file { ($path:literal) => { include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/", + "/../../shared/plugins/contract/v1/", $path )) }; diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index c5b67d09..9b888e16 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -7,7 +7,7 @@ macro_rules! contract_file { ($path:literal) => { include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/", + "/../../shared/plugins/contract/v1/", $path )) }; diff --git a/rust/lithe-core/src/tests/plugin_editor_resources.rs b/rust/lithe-core/src/tests/plugin_editor_resources.rs index c15e0bd5..2df1bb6c 100644 --- a/rust/lithe-core/src/tests/plugin_editor_resources.rs +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -7,7 +7,7 @@ macro_rules! contract_file { ($path:literal) => { include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/", + "/../../shared/plugins/contract/v1/", $path )) }; diff --git a/rust/lithe-core/src/tests/plugin_run_debug.rs b/rust/lithe-core/src/tests/plugin_run_debug.rs index 477934a1..a56c0608 100644 --- a/rust/lithe-core/src/tests/plugin_run_debug.rs +++ b/rust/lithe-core/src/tests/plugin_run_debug.rs @@ -18,7 +18,7 @@ macro_rules! contract_file { ($path:literal) => { include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/", + "/../../shared/plugins/contract/v1/", $path )) }; diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index 2c6de1c6..21e2c2a9 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -149,7 +149,7 @@ fn search_everywhere_uses_git_ignore_rules_for_filename_foreground() { #[test] fn search_everywhere_preserves_unicode_contiguous_filename_matches() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/unicode-contiguous.json" + "../../../fixtures/search/unicode-contiguous.json" )) .unwrap(); let root = temporary_root("unicode-contiguous-names"); @@ -190,7 +190,7 @@ fn search_everywhere_preserves_unicode_contiguous_filename_matches() { #[test] fn search_everywhere_preserves_directory_fallback_below_basename_matches() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/path-fallback.json" + "../../../fixtures/search/path-fallback.json" )) .unwrap(); let root = temporary_root("search-path-fallback"); @@ -253,7 +253,7 @@ fn search_everywhere_matches_names_before_limits_and_shares_utf16_fragments() { } let _cleanup = Cleanup(root.clone()); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/name-matching.json" + "../../../fixtures/search/name-matching.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -1112,7 +1112,7 @@ fn search_pages_apply_path_filters_before_the_page_limit() { fs::write(root.join(file), " 😀needle\n").unwrap(); } let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/text-snapshot-v1.json" + "../../../fixtures/search/text-snapshot-v1.json" )) .unwrap(); let case = &fixture["filteredPage"]; @@ -1237,7 +1237,7 @@ fn preserve_case_matches_original_occurrence_shape() { #[test] fn local_history_relocates_excluded_files_and_subtrees_from_retained_metadata() { let cases: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/history/relocation-v1.json" + "../../../fixtures/history/relocation-v1.json" )) .unwrap(); for case in cases["cases"].as_array().unwrap() { @@ -1639,7 +1639,7 @@ fn file_commands_round_trip_and_reject_traversal() { #[test] fn document_lifecycle_matches_the_cross_platform_fixture() { let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/documents/lifecycle-v1.json"); + .join("../fixtures/documents/lifecycle-v1.json"); let fixture: Value = serde_json::from_str( &fs::read_to_string(fixture_path).expect("document lifecycle fixture should be readable"), ) @@ -1672,7 +1672,7 @@ fn document_lifecycle_matches_the_cross_platform_fixture() { #[test] fn document_save_batch_matches_the_cross_platform_fixture() { let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/documents/save-batch-v1.json"); + .join("../fixtures/documents/save-batch-v1.json"); let fixture: Value = serde_json::from_str( &fs::read_to_string(fixture_path).expect("save batch fixture should be readable"), ) @@ -1927,7 +1927,7 @@ fn search_everywhere_pages_all_sources_past_200_without_duplicates_or_missing_ma #[test] fn recent_project_reductions_match_shared_fixture() { let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/recent-projects-v1.json" + "../../../fixtures/workspace/recent-projects-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -1959,7 +1959,7 @@ fn recent_projects_default_capacity_keeps_the_newest_unpinned_projects() { // The fixture documents the default; this guards the shared constant // without a 21-entry JSON case. let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/recent-projects-v1.json" + "../../../fixtures/workspace/recent-projects-v1.json" )) .unwrap(); let capacity = crate::project::recent_projects::DEFAULT_RECENT_PROJECT_CAPACITY; @@ -1998,7 +1998,7 @@ fn call(command: &str, payload: Value) -> Value { #[test] fn file_encoding_matches_the_golden_fixture() { let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/editor/file-encoding-v1.json"); + .join("../fixtures/editor/file-encoding-v1.json"); let fixture: Value = serde_json::from_str(&fs::read_to_string(fixture_path).unwrap()).unwrap(); let root = temporary_root("file-encoding"); fs::create_dir_all(&root).unwrap(); @@ -2073,7 +2073,7 @@ fn text_commands_match_the_golden_fixture_without_files() { use base64::Engine as _; let base64 = base64::engine::general_purpose::STANDARD; let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/editor/file-encoding-v1.json"); + .join("../fixtures/editor/file-encoding-v1.json"); let fixture: Value = serde_json::from_str(&fs::read_to_string(fixture_path).unwrap()).unwrap(); for case in fixture["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index ccba4a29..7cc41777 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -9,23 +9,23 @@ use std::path::PathBuf; const FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/project-generators/project-generator.plugin.json" )); const PLANS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json" + "/../../shared/plugins/contract/v1/fixtures/project-generators/project-plans.json" )); const TOOL_FIELD_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/project-generators/project-generator-tool-field.plugin.json" )); const TOOL_DEFINITION_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json" )); const BUILD_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/build-systems/build-static-file.plugin.json" )); fn json(source: &str) -> Value { @@ -39,7 +39,7 @@ fn repository_path(relative: &str) -> PathBuf { } fn fixtures_path() -> PathBuf { - repository_path("shared/contracts/plugins/v1/fixtures/project-generators") + repository_path("shared/plugins/contract/v1/fixtures/project-generators") } /// A plugin that only contributes the `java` language, so a generator's diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 6323984c..7824ca30 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -6,7 +6,7 @@ use serde_json::Value; #[test] fn keymap_context_fixtures_cross_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/context-v1.json" + "../../../fixtures/keymap/context-v1.json" )) .unwrap(); let call = |command: &str, payload: Value| -> Value { @@ -79,7 +79,7 @@ fn action_search_crosses_the_json_boundary() { fn debug_adapter_endpoint_validation_crosses_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/adapter-endpoints-v1.json" + "/../fixtures/debug/adapter-endpoints-v1.json" ))) .expect("endpoint fixture is JSON"); for case in fixture["cases"].as_array().expect("endpoint cases") { @@ -149,7 +149,7 @@ fn ping_exposes_protocol_version() { fn debug_create_and_destroy_commands_cross_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/dap-session-v1.json" + "/../fixtures/debug/dap-session-v1.json" ))) .expect("debug fixture should be valid JSON"); let session = &fixture["session"]; @@ -194,7 +194,7 @@ fn debug_create_and_destroy_commands_cross_the_json_boundary() { fn debug_run_in_terminal_response_crosses_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/run-in-terminal-v1.json" + "/../fixtures/debug/run-in-terminal-v1.json" ))) .expect("debug run-in-terminal fixture should be valid JSON"); let session_id = "debug-terminal-protocol-boundary"; @@ -286,7 +286,7 @@ fn debug_run_in_terminal_response_crosses_the_json_boundary() { fn debug_stepping_filters_match_the_shared_contract_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/stepping-filters-v1.json" + "/../fixtures/debug/stepping-filters-v1.json" ))) .expect("debug stepping-filter fixture should be valid JSON"); @@ -315,7 +315,7 @@ fn debug_stepping_filters_match_the_shared_contract_fixture() { fn debug_breakpoint_relocation_matches_the_shared_contract_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/debug/breakpoint-relocation-v1.json" + "/../fixtures/debug/breakpoint-relocation-v1.json" ))) .expect("debug breakpoint-relocation fixture should be valid JSON"); @@ -350,7 +350,7 @@ fn call_fixture_command(command: &str, payload: Value) -> Value { #[test] fn keymap_conflict_fixtures_cross_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/binding-conflicts-v1.json" + "../../../fixtures/keymap/binding-conflicts-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -374,7 +374,7 @@ fn keymap_conflict_fixtures_cross_the_json_boundary() { #[test] fn modifier_gesture_fixture_runs_through_core_dispatch() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/keymap/modifier-gesture-v1.json" + "../../../fixtures/keymap/modifier-gesture-v1.json" )) .unwrap(); for sequence in fixture["sequences"].as_array().unwrap() { @@ -415,7 +415,7 @@ fn modifier_gesture_fixture_runs_through_core_dispatch() { #[test] fn workbench_state_fixture_runs_through_core_dispatch() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/editor/workbench-state-v1.json" + "../../../fixtures/editor/workbench-state-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { @@ -446,7 +446,7 @@ fn workbench_state_fixture_runs_through_core_dispatch() { #[test] fn visibility_fixture_crosses_the_core_json_boundary() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/workspace/visibility-v1.json" + "../../../fixtures/workspace/visibility-v1.json" )) .unwrap(); let defaults = call_fixture_command("workspace.visibilityDefaults", serde_json::json!({})); @@ -490,7 +490,7 @@ fn every_rules_crate_command_is_routed_by_core() { #[test] fn terminal_link_fixtures_cross_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/terminal/link-target-v1.json" + "../../../fixtures/terminal/link-target-v1.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/tests/rank_names.rs b/rust/lithe-core/src/tests/rank_names.rs index 35cb276d..c3659baf 100644 --- a/rust/lithe-core/src/tests/rank_names.rs +++ b/rust/lithe-core/src/tests/rank_names.rs @@ -18,7 +18,7 @@ fn execute(payload: Value) -> Value { #[test] fn rank_names_matches_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/rank-names-v1.json" + "../../../fixtures/search/rank-names-v1.json" )) .expect("rank-names fixture should be JSON"); for case in fixture["cases"].as_array().unwrap() { @@ -55,7 +55,7 @@ fn rank_names_rejects_oversized_input_instead_of_truncating() { #[test] fn search_everywhere_context_matches_the_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/search/search-everywhere-context-v1.json" + "../../../fixtures/search/search-everywhere-context-v1.json" )) .expect("search context fixture should be JSON"); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index beb84a39..2cf658d5 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -131,7 +131,7 @@ fn project_tool_preferences_preserve_symbolic_choices_across_legacy_editor_saves } let _cleanup = Cleanup(root.clone()); let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/toolchains/consumer-preferences.json" + "../../../fixtures/toolchains/consumer-preferences.json" )) .unwrap(); let call = |command: &str, payload: Value| -> Value { @@ -209,7 +209,7 @@ fn project_tool_preferences_reject_invalid_references_in_reads_and_edits_without #[test] fn argument_editor_text_roundtrips_the_shared_argv_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/argument-text.json" + "../../../fixtures/run-configuration/argument-text.json" )) .unwrap(); let root = temporary_root("run-argument-text"); @@ -635,7 +635,7 @@ fn legacy_v2_runtime_requirements_are_reconciled_without_regeneration() { #[test] fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { let directory = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../shared/fixtures/run-configuration"); + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/run-configuration"); let mut fixture_count = 0; for entry in fs::read_dir(directory).unwrap() { let path = entry.unwrap().path(); @@ -657,7 +657,7 @@ fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { #[test] fn shared_hybrid_fixture_executes_scoped_toolchain_expectations() { let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/run-configuration/hybrid-spring-vue.json"); + .join("../fixtures/run-configuration/hybrid-spring-vue.json"); let fixture: Value = serde_json::from_str(&fs::read_to_string(fixture_path).unwrap()).unwrap(); let root = temporary_root("run-config-hybrid-fixture"); fs::create_dir_all(root.join(".lithe/run")).unwrap(); @@ -719,7 +719,7 @@ fn shared_hybrid_fixture_executes_scoped_toolchain_expectations() { #[test] fn shared_editor_save_fixture_executes_the_document_contract() { let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/run-configuration/editor-save.json"); + .join("../fixtures/run-configuration/editor-save.json"); let fixture: Value = serde_json::from_str(&fs::read_to_string(fixture_path).unwrap()).unwrap(); let root = temporary_root("run-config-editor-fixture"); fs::create_dir_all(root.join(".lithe/run")).unwrap(); @@ -1291,7 +1291,7 @@ fn shared_run_scan_excludes_caches_and_keeps_hidden_sources() { #[test] fn build_provider_configurations_persist_before_generation_and_reuse_identity() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/build-provider.json" + "../../../fixtures/run-configuration/build-provider.json" )) .unwrap(); let root = temporary_root("saved-build-provider"); @@ -1433,7 +1433,7 @@ fn build_provider_configurations_persist_before_generation_and_reuse_identity() #[test] fn explicit_provider_references_keep_arbitrary_ids_and_shadow_only_current_selector() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/build-provider-stable-identities.json" + "../../../fixtures/run-configuration/build-provider-stable-identities.json" )) .unwrap(); let root = temporary_root("provider-stable-identity"); @@ -1560,7 +1560,7 @@ fn explicit_provider_references_keep_arbitrary_ids_and_shadow_only_current_selec #[test] fn provider_run_options_preserve_exact_arguments_and_layered_build_choices() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/run-configuration/build-provider-options.json" + "../../../fixtures/run-configuration/build-provider-options.json" )) .unwrap(); let root = temporary_root("provider-run-options"); diff --git a/rust/lithe-core/src/tests/support.rs b/rust/lithe-core/src/tests/support.rs index 7014b2af..5ea8f5ce 100644 --- a/rust/lithe-core/src/tests/support.rs +++ b/rust/lithe-core/src/tests/support.rs @@ -9,7 +9,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub(super) fn fixture() -> Value { let fixture_path = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../shared/fixtures/search/basic.json"); + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/search/basic.json"); serde_json::from_str(&fs::read_to_string(fixture_path).expect("fixture should be readable")) .expect("fixture should be valid JSON") } diff --git a/rust/lithe-core/src/tests/toolchain_definitions.rs b/rust/lithe-core/src/tests/toolchain_definitions.rs index fd18d487..b6f47f72 100644 --- a/rust/lithe-core/src/tests/toolchain_definitions.rs +++ b/rust/lithe-core/src/tests/toolchain_definitions.rs @@ -277,7 +277,7 @@ fn a_definition_plugin_may_provide_installations_of_its_own_tool() { fn build_toolchain_consumers_reference_definitions_without_owning_them() { let mut plugin: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/build-systems/build-toolchains.plugin.json" ))) .unwrap(); assert!(validate_manifest_value(plugin.clone()).is_ok()); @@ -293,7 +293,7 @@ fn build_toolchain_consumers_reference_definitions_without_owning_them() { fn tool_definitions_do_not_enable_toolchain_layers_in_build_executable_sets() { let mut plugin: Value = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/build-systems/build-tool-path.plugin.json" ))) .unwrap(); plugin["requires"]["toolDefinitions"] = json!(0); @@ -309,14 +309,14 @@ fn production_toolchain_fixtures_require_explicit_host_capabilities() { ( include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json" )), "requires.toolDefinitions", ), ( include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/toolchain-provider.plugin.json" )), "requires.toolchainProviders", ), diff --git a/rust/lithe-core/src/tests/toolchain_pins.rs b/rust/lithe-core/src/tests/toolchain_pins.rs index 30728709..daeaa854 100644 --- a/rust/lithe-core/src/tests/toolchain_pins.rs +++ b/rust/lithe-core/src/tests/toolchain_pins.rs @@ -333,7 +333,7 @@ fn toolchain_pin_edits_prune_empty_containers_and_leave_absent_removals_inert() #[test] fn toolchain_pin_edits_match_the_shared_boundary_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/toolchain-pin-edit.json" + "../../../fixtures/toolchain-pin-edit.json" )) .unwrap(); let response = edit(fixture["request"].clone()); diff --git a/rust/lithe-core/src/tests/toolchain_queries.rs b/rust/lithe-core/src/tests/toolchain_queries.rs index 505cd573..000a2f60 100644 --- a/rust/lithe-core/src/tests/toolchain_queries.rs +++ b/rust/lithe-core/src/tests/toolchain_queries.rs @@ -8,11 +8,11 @@ fn contract(name: &str) -> Value { let source = match name { "java" => include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json" )), "mise" => include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/toolchain-provider.plugin.json" )), _ => panic!("unknown contract fixture"), }; @@ -53,7 +53,7 @@ fn toolchain_queries_decode_installed_contract_reply_without_native_io() { #[test] fn toolchain_queries_preserve_bounded_string_and_numeric_wire_identities() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/toolchains/rpc-id-cases.json" + "../../../fixtures/toolchains/rpc-id-cases.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/tests/toolchain_resolution.rs b/rust/lithe-core/src/tests/toolchain_resolution.rs index 90d70d25..71af6c22 100644 --- a/rust/lithe-core/src/tests/toolchain_resolution.rs +++ b/rust/lithe-core/src/tests/toolchain_resolution.rs @@ -16,11 +16,11 @@ fn contract(name: &str) -> Value { let source = match name { "java" => include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json" )), "mise" => include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/toolchain-provider.plugin.json" )), _ => panic!("unknown fixture"), }; diff --git a/rust/lithe-core/src/tests/toolchain_vectors.rs b/rust/lithe-core/src/tests/toolchain_vectors.rs index 05ab7d11..28e8083a 100644 --- a/rust/lithe-core/src/tests/toolchain_vectors.rs +++ b/rust/lithe-core/src/tests/toolchain_vectors.rs @@ -9,7 +9,7 @@ use serde_json::{json, Value}; /// Tool-definition fixture whose single tool every vector queries and pins. const TOOL_DEFINITION: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json" )); /// Home of the tool that the query reply reports and the root pin selects. diff --git a/rust/lithe-core/src/tests/vcs.rs b/rust/lithe-core/src/tests/vcs.rs index 89665259..2394b947 100644 --- a/rust/lithe-core/src/tests/vcs.rs +++ b/rust/lithe-core/src/tests/vcs.rs @@ -1,6 +1,6 @@ //! VCS providers declared by plugins: manifest validation, //! install review, launch plans, root detection over real directories, and -//! reply decoding, checked against `shared/fixtures/vcs/model-v1.json` and +//! reply decoding, checked against `rust/fixtures/vcs/model-v1.json` and //! the Subversion provider manifest fixture. use super::support::temporary_root; @@ -17,11 +17,11 @@ use std::path::{Path, PathBuf}; const MODEL: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/fixtures/vcs/model-v1.json" + "/../fixtures/vcs/model-v1.json" )); const SVN_PROVIDER: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json" + "/../../shared/plugins/contract/v1/fixtures/vcs/vcs-provider.plugin.json" )); fn model() -> Value { diff --git a/rust/lithe-core/src/toolchain/pin_sources.rs b/rust/lithe-core/src/toolchain/pin_sources.rs index 4dfb7e66..8ecae069 100644 --- a/rust/lithe-core/src/toolchain/pin_sources.rs +++ b/rust/lithe-core/src/toolchain/pin_sources.rs @@ -193,7 +193,7 @@ mod tests { #[test] fn source_edits_match_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/toolchain-pin-storage.json" + "../../../fixtures/toolchain-pin-storage.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-core/src/toolchain/preferences.rs b/rust/lithe-core/src/toolchain/preferences.rs index 26057da2..edbd25ba 100644 --- a/rust/lithe-core/src/toolchain/preferences.rs +++ b/rust/lithe-core/src/toolchain/preferences.rs @@ -346,7 +346,7 @@ mod tests { #[test] fn shared_preferences_fixture_resolves_without_native_or_domain_facts() { let fixture: Value = serde_json::from_str(include_str!( - "../../../../shared/fixtures/toolchains/consumer-preferences.json" + "../../../fixtures/toolchains/consumer-preferences.json" )) .unwrap(); for case in fixture["cases"].as_array().unwrap() { diff --git a/rust/lithe-ide-host/src/catalog.rs b/rust/lithe-ide-host/src/catalog.rs index cc01b2f9..2e7b322b 100644 --- a/rust/lithe-ide-host/src/catalog.rs +++ b/rust/lithe-ide-host/src/catalog.rs @@ -285,7 +285,7 @@ mod tests { #[test] fn native_inventory_can_only_narrow_known_tools_and_their_argument_fields() { let fixture: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/ide-api/host-tool-exposure.json" + "../../fixtures/ide-api/host-tool-exposure.json" )) .unwrap(); let permissions = Permissions { @@ -375,7 +375,7 @@ mod tests { #[test] fn shared_fixture_protects_the_plugin_and_mcp_contract() { let fixture: Value = - serde_json::from_str(include_str!("../../../shared/fixtures/ide-api/calls.json")) + serde_json::from_str(include_str!("../../fixtures/ide-api/calls.json")) .unwrap(); let permissions = Permissions { configure: true, diff --git a/rust/lithe-ide-host/src/lib.rs b/rust/lithe-ide-host/src/lib.rs index b1a1fdbd..8582ae13 100644 --- a/rust/lithe-ide-host/src/lib.rs +++ b/rust/lithe-ide-host/src/lib.rs @@ -688,7 +688,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_authenticated_client_cannot_dispatch_unadvertised_native_workflows_or_fields() { let exposure: Value = serde_json::from_str(include_str!( - "../../../shared/fixtures/ide-api/host-tool-exposure.json" + "../../fixtures/ide-api/host-tool-exposure.json" )) .unwrap(); let (_fixture, broker, headers) = push_fixture_exposed(exposure["macos"].clone()); diff --git a/shared/logging/README.md b/rust/logging/README.md similarity index 97% rename from shared/logging/README.md rename to rust/logging/README.md index 73c37985..8629c1db 100644 --- a/shared/logging/README.md +++ b/rust/logging/README.md @@ -1,6 +1,6 @@ # 结构化日志 v2 事件目录 -`shared/logging/events/.json` 是事件声明的唯一来源。macOS 使用生成的类型化事件,经 `MacLogStore` 写入 v2 会话文件;Windows 仍使用 v1。日志消息是供诊断使用的英文事实,**不走 i18n**;界面上的错误、提示和操作名称仍然走共享 i18n。 +`rust/logging/events/.json` 是事件声明的唯一来源。macOS 使用生成的类型化事件,经 `MacLogStore` 写入 v2 会话文件;Windows 仍使用 v1。日志消息是供诊断使用的英文事实,**不走 i18n**;界面上的错误、提示和操作名称仍然走共享 i18n。 本文规定声明语法、通道、字段编码与隐私边界、级别与 span、执行轨迹事件、Core 事件队列、引擎宿主接入、辅助 crate、生成器和用法门禁。什么必须记录、怎样设计一条日志和审查清单见 `.agents/skills/develop-lithe/references/logging.md`;决策原因见 `.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md`。 @@ -9,7 +9,7 @@ - 固定根为 `app`、`editor`、`perf`、`lsp`、`vcs`、`run`、`debug`、`terminal`、`remote`、`agent`、`update`、`plugins`。每个根恰有一个 JSON 文件,形状为 `{ version, root, events }`;没有实际调用的根保持空 `events`,不预造事件。 - 事件 ID 是小写点分的稳定标识,如 `editor.document.save_failed`,结果词用过去时。`owner` 标明拥有这次操作结果的模块;由 Core 记录的事件 owner 必须是 `lithe-core`。 - `message` 是英文单行模板,只能引用本事件声明的必填字段,例如 `Git {command} failed`;可选字段不参与模板。 -- 生成器拒绝未知属性、跨文件重名、生成类型名冲突、保留键和编码后的字段名冲突。`shared/logging/event-catalog.schema.json` 描述静态形状;生成器另外执行通道、引用、span 和磁盘键展开等跨字段语义校验。 +- 生成器拒绝未知属性、跨文件重名、生成类型名冲突、保留键和编码后的字段名冲突。`rust/logging/event-catalog.schema.json` 描述静态形状;生成器另外执行通道、引用、span 和磁盘键展开等跨字段语义校验。 ## 二、通道 @@ -88,7 +88,7 @@ Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落 - 启动者显式声明 role、真实 `pluginId` 和 VCS `logSubject`;宿主不解析 providerID。 - stderr 读取器限制每行 4 KiB、每秒 200 行,之后再走 `text` 的 1 KiB 脱敏规范化。与 v1 相比唯一的兼容变化是从任意 trim 的读取块改为逐行文本:保留行内空格,移除 LF 和末尾 CR,并应用已声明的长度上限和限流。 - 上游明确给出的严重度通过独立的目录事件保留:`window/logMessage` 和 `window/showMessage` 的 type 1/2/3/4 分别为 ERROR/WARN/INFO/DEBUG,未知类型为 DEBUG。未分类的 stderr 是 DEBUG 排障证据,流名称不表示警告;实际的进程或协议失败由 `*.session.failed` 记录 ERROR,正常关闭仍是生命周期状态。 -- 插件自身使用独立协商的 `litheLogging` 0 和 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额和 fixture 以 [插件契约](../contracts/plugins/v1/README.md#structured-plugin-diagnostics) 为准。 +- 插件自身使用独立协商的 `litheLogging` 0 和 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额和 fixture 以 [插件契约](../../shared/plugins/contract/v1/README.md#structured-plugin-diagnostics) 为准。 - 声明式贡献没有插件进程,不能发 `lithe/log`。实际执行它的一方通过 `diagnostics.recordPluginOperation` 代记每次尝试的一条终态,事件和通道与 SDK 插件相同:`plugins.operation.finished` 或 `plugins.operation.failed`,写入 `plugins/{plugin}/host`。平台层只上报观察到的阶段和耗时,级别和 `host.*` 失败类别只由 Core 按阶段决定;取消、停止和过期结果不记录。宿主当前代记下表四种贡献,唯一来源是 `rust/lithe-core/src/diagnostics/plugin_operations.rs` 的 `CONTRIBUTIONS`;输入、阶段到类别的对应和 -32064 见 [Rust Core API](../contracts/rust-core-api.md#engine-logging-v2-handoff-pr3)。 | operation | 声明来源 | 记录方 | 可能的失败阶段 | diff --git a/shared/logging/event-catalog.schema.json b/rust/logging/event-catalog.schema.json similarity index 100% rename from shared/logging/event-catalog.schema.json rename to rust/logging/event-catalog.schema.json diff --git a/shared/logging/events/agent.json b/rust/logging/events/agent.json similarity index 100% rename from shared/logging/events/agent.json rename to rust/logging/events/agent.json diff --git a/shared/logging/events/app.json b/rust/logging/events/app.json similarity index 100% rename from shared/logging/events/app.json rename to rust/logging/events/app.json diff --git a/shared/logging/events/debug.json b/rust/logging/events/debug.json similarity index 100% rename from shared/logging/events/debug.json rename to rust/logging/events/debug.json diff --git a/shared/logging/events/editor.json b/rust/logging/events/editor.json similarity index 100% rename from shared/logging/events/editor.json rename to rust/logging/events/editor.json diff --git a/shared/logging/events/lsp.json b/rust/logging/events/lsp.json similarity index 100% rename from shared/logging/events/lsp.json rename to rust/logging/events/lsp.json diff --git a/shared/logging/events/perf.json b/rust/logging/events/perf.json similarity index 100% rename from shared/logging/events/perf.json rename to rust/logging/events/perf.json diff --git a/shared/logging/events/plugins.json b/rust/logging/events/plugins.json similarity index 100% rename from shared/logging/events/plugins.json rename to rust/logging/events/plugins.json diff --git a/shared/logging/events/remote.json b/rust/logging/events/remote.json similarity index 100% rename from shared/logging/events/remote.json rename to rust/logging/events/remote.json diff --git a/shared/logging/events/run.json b/rust/logging/events/run.json similarity index 100% rename from shared/logging/events/run.json rename to rust/logging/events/run.json diff --git a/shared/logging/events/terminal.json b/rust/logging/events/terminal.json similarity index 100% rename from shared/logging/events/terminal.json rename to rust/logging/events/terminal.json diff --git a/shared/logging/events/update.json b/rust/logging/events/update.json similarity index 100% rename from shared/logging/events/update.json rename to rust/logging/events/update.json diff --git a/shared/logging/events/vcs.json b/rust/logging/events/vcs.json similarity index 100% rename from shared/logging/events/vcs.json rename to rust/logging/events/vcs.json diff --git a/shared/contracts/plugins/v1/README.md b/shared/plugins/contract/v1/README.md similarity index 99% rename from shared/contracts/plugins/v1/README.md rename to shared/plugins/contract/v1/README.md index 09af81db..f715cf57 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/plugins/contract/v1/README.md @@ -39,7 +39,7 @@ This directory is the cross-platform source of truth: Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in `rust/lithe-core/src/build/`) and exposed through the -`plugins.declarative.*` commands in [`shared/contracts/rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md). Hosts must not +`plugins.declarative.*` commands in [`rust/contracts/rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md). Hosts must not reimplement them. Plugin authors do not need this repository. The public @@ -125,7 +125,7 @@ the provider never interprets it as a path. Core bounds the operation and validates normalized results against the host's action catalog. Providers own source formats and must not silently promote conditional or multi-stroke rules to global shortcuts. The exact Core commands, limits and fixtures are in the -[Rust Core API](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md#normalized-shortcut-import-validation). +[Rust Core API](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md#normalized-shortcut-import-validation). When granted individually, methods `lithe/host/keymapImport/listInputs` and `lithe/host/keymapImport/readChunk` take `snapshotID` and `providerID`. @@ -529,7 +529,7 @@ carries one `code`: User cancellation and superseded attempts record nothing. Plugin output, paths, and error text never appear in these facts. The executing owner and the host-facing command are specified as `diagnostics.recordPluginOperation` in -[`rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md). +[`rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md). Severity follows the actual result. An operation that failed for the user is ERROR, a recovery or fallback is WARN, and a useful successful user operation @@ -935,7 +935,7 @@ process does not use `${workspaceStateDir}`; otherwise the request is | `environment`, `writablePaths` | Present only in the plan `decodeLaunch` returns: the variables of the process, and the paths it writes. | | `sessionPolicy` | Present for a language server that declares session fields: the [session policy](#plugin-owned-sessions) the host passes to `lsp.startServer` unchanged. | | `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/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | +| `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | | `backend` | Present only for a `backend` selection: `{hostServices, version}`, the sorted [host services](#host-services) the plugin's permissions grant, plus `version`, the manifest's `requires.backend` (`0`). The host passes `hostServices` to `lsp.startServer.hostServices` unchanged. | A language without a server, an undeclared build system, a build system @@ -2190,7 +2190,7 @@ contract or a canonical format instead. ### Commands -The `build.*` Core commands (see `shared/contracts/rust-core-api.md`) take the +The `build.*` Core commands (see `rust/contracts/rust-core-api.md`) take the manifest, the build-system ID, and a request, and revalidate the manifest every time. `build.detect` walks the workspace once for all enabled plugins and returns instances, with the model of `static` and `hostIntegration` @@ -2223,7 +2223,7 @@ 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/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md). +defined in [the VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md). ## Debug adapters @@ -3933,7 +3933,7 @@ Disabling, enabling, replacing and uninstalling a plugin take effect for the relay at once and need no restart of Lithe or of any other plugin. The host sends the plugin and workspace-context snapshot with the Core command `lsp.configureFunctionRelay` -([Rust Core API](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md)), +([Rust Core API](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md)), and calls the new snapshot no longer allows end at once with the errors above. Installing or updating a plugin that has a backend is different on a host that @@ -4045,7 +4045,7 @@ command payload is a Core `invalid_request` failure instead. | `plugin.grammar.invalid` | Core | A language's grammar file is not a TextMate grammar Core accepts; `field` names the failing part | | `plugin.grammar.unavailable` | Core | The plugin declares no grammar for the language | | `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/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md) | +| `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/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 `0` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | @@ -4413,7 +4413,7 @@ per-window, per-workspace state store; removed fields, invalid types and removed options are discarded when restoring. Provider replies never overwrite local fields. Form declarations and choices are static. See [native-form.plugin.json](fixtures/ui/native-form.plugin.json) and -[host UI acceptance fixture](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. +[host UI acceptance fixture](https://github.com/1lck/Lithe-IDEA/blob/preview/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. An optional `source` names this plugin's `buildSystemID` and a function returning `{ nodes: [...] }`. The source uses the existing provider transport; it adds no diff --git a/shared/contracts/plugins/v1/fixtures/backend/backend-workspace.plugin.json b/shared/plugins/contract/v1/fixtures/backend/backend-workspace.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/backend-workspace.plugin.json rename to shared/plugins/contract/v1/fixtures/backend/backend-workspace.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/backend.plugin.json b/shared/plugins/contract/v1/fixtures/backend/backend.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/backend.plugin.json rename to shared/plugins/contract/v1/fixtures/backend/backend.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/command-backend.plugin.json b/shared/plugins/contract/v1/fixtures/backend/command-backend.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/command-backend.plugin.json rename to shared/plugins/contract/v1/fixtures/backend/command-backend.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/function-protocol.json b/shared/plugins/contract/v1/fixtures/backend/function-protocol.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/function-protocol.json rename to shared/plugins/contract/v1/fixtures/backend/function-protocol.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-caller.plugin.json b/shared/plugins/contract/v1/fixtures/backend/plugin-functions-caller.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/plugin-functions-caller.plugin.json rename to shared/plugins/contract/v1/fixtures/backend/plugin-functions-caller.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/plugin-functions-exporter.plugin.json b/shared/plugins/contract/v1/fixtures/backend/plugin-functions-exporter.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/plugin-functions-exporter.plugin.json rename to shared/plugins/contract/v1/fixtures/backend/plugin-functions-exporter.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json b/shared/plugins/contract/v1/fixtures/backend/plugin-logging.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/plugin-logging.json rename to shared/plugins/contract/v1/fixtures/backend/plugin-logging.json diff --git a/shared/contracts/plugins/v1/fixtures/backend/terminal-backend.plugin.json b/shared/plugins/contract/v1/fixtures/backend/terminal-backend.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/backend/terminal-backend.plugin.json rename to shared/plugins/contract/v1/fixtures/backend/terminal-backend.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json b/shared/plugins/contract/v1/fixtures/build-providers/build-configuration.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-configuration.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-configuration.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json b/shared/plugins/contract/v1/fixtures/build-providers/build-connection-debug-options.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-connection-debug-options.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-connection-debug-options.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-debug-preparation.json b/shared/plugins/contract/v1/fixtures/build-providers/build-debug-preparation.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-debug-preparation.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-debug-preparation.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json b/shared/plugins/contract/v1/fixtures/build-providers/build-execution-protocol.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-execution-protocol.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-execution-protocol.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-configuration.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-configuration.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-configuration.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-execution.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-execution.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-execution.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-execution.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-language-server.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-language-server.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-language-server.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-language-server.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-preparation.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-preparation.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-reports.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-reports.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-reports.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-reports.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-options.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-run-options.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-options.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-run-options.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-run-preparation.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-selectors.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-selectors.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-selectors.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-selectors.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider-tests.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider-tests.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider-tests.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider-tests.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-provider.plugin.json b/shared/plugins/contract/v1/fixtures/build-providers/build-provider.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-provider.plugin.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-provider.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json b/shared/plugins/contract/v1/fixtures/build-providers/build-run-options.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-run-options.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-run-options.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-run-preparation.json b/shared/plugins/contract/v1/fixtures/build-providers/build-run-preparation.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-run-preparation.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-run-preparation.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-test-discovery.json b/shared/plugins/contract/v1/fixtures/build-providers/build-test-discovery.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-test-discovery.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-test-discovery.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-test-durations.json b/shared/plugins/contract/v1/fixtures/build-providers/build-test-durations.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-test-durations.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-test-durations.json diff --git a/shared/contracts/plugins/v1/fixtures/build-providers/build-test-protocol.json b/shared/plugins/contract/v1/fixtures/build-providers/build-test-protocol.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-providers/build-test-protocol.json rename to shared/plugins/contract/v1/fixtures/build-providers/build-test-protocol.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-command-output-file.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-command-output-file.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-command-output-file.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-command-output-file.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-command-stdout.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-command-stdout.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-command-stdout.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-command-stdout.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-instance-options.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-instance-options.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-instance-options.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-model-consumer.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-model-consumer.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-model-consumer.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-model-consumer.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-model-producer.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-model-producer.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-model-producer.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-model-producer.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json b/shared/plugins/contract/v1/fixtures/build-systems/build-plans.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-plans.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-plans.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-static-file.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-static-file.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-static-file.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-tool-path.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-tool-path.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-tool-path.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json b/shared/plugins/contract/v1/fixtures/build-systems/build-toolchains.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/build-systems/build-toolchains.plugin.json rename to shared/plugins/contract/v1/fixtures/build-systems/build-toolchains.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json b/shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json rename to shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json b/shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json rename to shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-rejections.json b/shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-rejections.json rename to shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json b/shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json rename to shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json b/shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json rename to shared/plugins/contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/debug-adapters/debug-start.json b/shared/plugins/contract/v1/fixtures/debug-adapters/debug-start.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/debug-adapters/debug-start.json rename to shared/plugins/contract/v1/fixtures/debug-adapters/debug-start.json diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-actions.json b/shared/plugins/contract/v1/fixtures/editor/editor-actions.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/editor/editor-actions.json rename to shared/plugins/contract/v1/fixtures/editor/editor-actions.json diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-actions.plugin.json b/shared/plugins/contract/v1/fixtures/editor/editor-actions.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/editor/editor-actions.plugin.json rename to shared/plugins/contract/v1/fixtures/editor/editor-actions.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-providers.json b/shared/plugins/contract/v1/fixtures/editor/editor-providers.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/editor/editor-providers.json rename to shared/plugins/contract/v1/fixtures/editor/editor-providers.json diff --git a/shared/contracts/plugins/v1/fixtures/editor/editor-providers.plugin.json b/shared/plugins/contract/v1/fixtures/editor/editor-providers.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/editor/editor-providers.plugin.json rename to shared/plugins/contract/v1/fixtures/editor/editor-providers.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json b/shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/keymap-import/keymap-import-provider.plugin.json rename to shared/plugins/contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/command-plans.json b/shared/plugins/contract/v1/fixtures/languages/command-plans.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/command-plans.json rename to shared/plugins/contract/v1/fixtures/languages/command-plans.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/grammars/http.tmLanguage.json b/shared/plugins/contract/v1/fixtures/languages/grammars/http.tmLanguage.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/grammars/http.tmLanguage.json rename to shared/plugins/contract/v1/fixtures/languages/grammars/http.tmLanguage.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-grammar.plugin.json b/shared/plugins/contract/v1/fixtures/languages/language-grammar.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/language-grammar.plugin.json rename to shared/plugins/contract/v1/fixtures/languages/language-grammar.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-packaged-server.plugin.json b/shared/plugins/contract/v1/fixtures/languages/language-packaged-server.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/language-packaged-server.plugin.json rename to shared/plugins/contract/v1/fixtures/languages/language-packaged-server.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-server-backend-launch.plugin.json b/shared/plugins/contract/v1/fixtures/languages/language-server-backend-launch.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/language-server-backend-launch.plugin.json rename to shared/plugins/contract/v1/fixtures/languages/language-server-backend-launch.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json b/shared/plugins/contract/v1/fixtures/languages/language-server-launch-resolution.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/language-server-launch-resolution.json rename to shared/plugins/contract/v1/fixtures/languages/language-server-launch-resolution.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/language-system-command.plugin.json b/shared/plugins/contract/v1/fixtures/languages/language-system-command.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/language-system-command.plugin.json rename to shared/plugins/contract/v1/fixtures/languages/language-system-command.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/languages/node-runtime.plugin.json b/shared/plugins/contract/v1/fixtures/languages/node-runtime.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/languages/node-runtime.plugin.json rename to shared/plugins/contract/v1/fixtures/languages/node-runtime.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/manifest/hello-lithe.plugin.json b/shared/plugins/contract/v1/fixtures/manifest/hello-lithe.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/manifest/hello-lithe.plugin.json rename to shared/plugins/contract/v1/fixtures/manifest/hello-lithe.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json b/shared/plugins/contract/v1/fixtures/manifest/invalid-manifests.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json rename to shared/plugins/contract/v1/fixtures/manifest/invalid-manifests.json diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json b/shared/plugins/contract/v1/fixtures/marketplace/index-cases.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json rename to shared/plugins/contract/v1/fixtures/marketplace/index-cases.json diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index.json b/shared/plugins/contract/v1/fixtures/marketplace/index.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/marketplace/index.json rename to shared/plugins/contract/v1/fixtures/marketplace/index.json diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json b/shared/plugins/contract/v1/fixtures/marketplace/install-reviews.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json rename to shared/plugins/contract/v1/fixtures/marketplace/install-reviews.json diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/invalid-index-entries.json b/shared/plugins/contract/v1/fixtures/marketplace/invalid-index-entries.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/marketplace/invalid-index-entries.json rename to shared/plugins/contract/v1/fixtures/marketplace/invalid-index-entries.json diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/source-urls.json b/shared/plugins/contract/v1/fixtures/marketplace/source-urls.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/marketplace/source-urls.json rename to shared/plugins/contract/v1/fixtures/marketplace/source-urls.json diff --git a/shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json b/shared/plugins/contract/v1/fixtures/package-trust/publisher-bundled.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/package-trust/publisher-bundled.plugin.json rename to shared/plugins/contract/v1/fixtures/package-trust/publisher-bundled.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/package-trust/signature-vectors.json b/shared/plugins/contract/v1/fixtures/package-trust/signature-vectors.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/package-trust/signature-vectors.json rename to shared/plugins/contract/v1/fixtures/package-trust/signature-vectors.json diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json b/shared/plugins/contract/v1/fixtures/project-generators/project-generator-tool-field.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json rename to shared/plugins/contract/v1/fixtures/project-generators/project-generator-tool-field.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json b/shared/plugins/contract/v1/fixtures/project-generators/project-generator.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json rename to shared/plugins/contract/v1/fixtures/project-generators/project-generator.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json b/shared/plugins/contract/v1/fixtures/project-generators/project-plans.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json rename to shared/plugins/contract/v1/fixtures/project-generators/project-plans.json diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/templates/.gitattributes b/shared/plugins/contract/v1/fixtures/project-generators/templates/.gitattributes similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/templates/.gitattributes rename to shared/plugins/contract/v1/fixtures/project-generators/templates/.gitattributes diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/templates/app/README.md b/shared/plugins/contract/v1/fixtures/project-generators/templates/app/README.md similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/templates/app/README.md rename to shared/plugins/contract/v1/fixtures/project-generators/templates/app/README.md diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/templates/app/dot-gitignore b/shared/plugins/contract/v1/fixtures/project-generators/templates/app/dot-gitignore similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/templates/app/dot-gitignore rename to shared/plugins/contract/v1/fixtures/project-generators/templates/app/dot-gitignore diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/templates/main/Main.java b/shared/plugins/contract/v1/fixtures/project-generators/templates/main/Main.java similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/templates/main/Main.java rename to shared/plugins/contract/v1/fixtures/project-generators/templates/main/Main.java diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/templates/tool-field/README.md b/shared/plugins/contract/v1/fixtures/project-generators/templates/tool-field/README.md similarity index 100% rename from shared/contracts/plugins/v1/fixtures/project-generators/templates/tool-field/README.md rename to shared/plugins/contract/v1/fixtures/project-generators/templates/tool-field/README.md diff --git a/shared/contracts/plugins/v1/fixtures/run/output-link-matchers.json b/shared/plugins/contract/v1/fixtures/run/output-link-matchers.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/run/output-link-matchers.json rename to shared/plugins/contract/v1/fixtures/run/output-link-matchers.json diff --git a/shared/contracts/plugins/v1/fixtures/run/run-start.json b/shared/plugins/contract/v1/fixtures/run/run-start.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/run/run-start.json rename to shared/plugins/contract/v1/fixtures/run/run-start.json diff --git a/shared/contracts/plugins/v1/fixtures/run/tool-reference-launch.plugin.json b/shared/plugins/contract/v1/fixtures/run/tool-reference-launch.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/run/tool-reference-launch.plugin.json rename to shared/plugins/contract/v1/fixtures/run/tool-reference-launch.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json b/shared/plugins/contract/v1/fixtures/settings/settings.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/settings/settings.plugin.json rename to shared/plugins/contract/v1/fixtures/settings/settings.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json b/shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json rename to shared/plugins/contract/v1/fixtures/toolchains/tool-definition.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json b/shared/plugins/contract/v1/fixtures/toolchains/toolchain-provider.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/toolchains/toolchain-provider.plugin.json rename to shared/plugins/contract/v1/fixtures/toolchains/toolchain-provider.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/ui/native-controls.plugin.json b/shared/plugins/contract/v1/fixtures/ui/native-controls.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/ui/native-controls.plugin.json rename to shared/plugins/contract/v1/fixtures/ui/native-controls.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/ui/native-form.plugin.json b/shared/plugins/contract/v1/fixtures/ui/native-form.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/ui/native-form.plugin.json rename to shared/plugins/contract/v1/fixtures/ui/native-form.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/ui/native-panel.plugin.json b/shared/plugins/contract/v1/fixtures/ui/native-panel.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/ui/native-panel.plugin.json rename to shared/plugins/contract/v1/fixtures/ui/native-panel.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json b/shared/plugins/contract/v1/fixtures/ui/web-panel.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/ui/web-panel.plugin.json rename to shared/plugins/contract/v1/fixtures/ui/web-panel.plugin.json diff --git a/shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json b/shared/plugins/contract/v1/fixtures/vcs/vcs-provider.plugin.json similarity index 100% rename from shared/contracts/plugins/v1/fixtures/vcs/vcs-provider.plugin.json rename to shared/plugins/contract/v1/fixtures/vcs/vcs-provider.plugin.json diff --git a/shared/contracts/plugins/v1/native-ui.schema.json b/shared/plugins/contract/v1/native-ui.schema.json similarity index 100% rename from shared/contracts/plugins/v1/native-ui.schema.json rename to shared/plugins/contract/v1/native-ui.schema.json diff --git a/shared/contracts/plugins/v1/plugin-index.schema.json b/shared/plugins/contract/v1/plugin-index.schema.json similarity index 100% rename from shared/contracts/plugins/v1/plugin-index.schema.json rename to shared/plugins/contract/v1/plugin-index.schema.json diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/plugins/contract/v1/plugin-manifest.schema.json similarity index 99% rename from shared/contracts/plugins/v1/plugin-manifest.schema.json rename to shared/plugins/contract/v1/plugin-manifest.schema.json index 6d63ba77..bb251b4e 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/plugins/contract/v1/plugin-manifest.schema.json @@ -43,7 +43,7 @@ "keymapImportProviders": { "enum": [0] }, "pluginFunctions": { "description": "Required exactly when pluginFunctions is present: functions this plugin's backend exports to other plugins, and exported functions it calls. Needs backend.", "enum": [0] }, "ui": { "description": "Required exactly when ui is present. Covers tool windows with text, web content, and native controls, and backend-only commands.", "enum": [0] }, - "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md).", "enum": [0] } + "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md).", "enum": [0] } } }, "vendor": { @@ -1588,7 +1588,7 @@ "unevaluatedProperties": false }, "vcsProvider": { - "description": "A VCS provider process (https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", + "description": "A VCS provider process (https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", "type": "object", "$ref": "#/$defs/processLaunch", "unevaluatedProperties": false, diff --git a/shared/contracts/plugins/v1/toolchains/README.md b/shared/plugins/contract/v1/toolchains/README.md similarity index 99% rename from shared/contracts/plugins/v1/toolchains/README.md rename to shared/plugins/contract/v1/toolchains/README.md index dc258b6e..42eaabac 100644 --- a/shared/contracts/plugins/v1/toolchains/README.md +++ b/shared/plugins/contract/v1/toolchains/README.md @@ -176,7 +176,7 @@ the session's request timeout applies. Completion carries `result.value: {id, response}`: the wire ID and the complete, unvalidated response, error replies included. The host passes it with the same `params` to `decodeReply`. Cancellation and late results follow -[`lsp.toolchainRequest`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/contracts/rust-core-api.md). +[`lsp.toolchainRequest`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md). ## Provider reply validation From cae76160a8e3480e59f669da43e28b404a2dda96 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 11 Nov 2026 20:31:47 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(plugins):=20=E5=B9=B6=E5=85=A5=20lithe?= =?UTF-8?q?-plugin-docs=EF=BC=8C=E6=8F=92=E4=BB=B6=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E3=80=81=E5=BC=80=E5=8F=91=E8=B5=84=E6=96=99=E4=B8=8E=20SDK=20?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=20shared/plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/plugins/README.md 为插件开发入口;guides/、reference/(API 总表、能力与版本、协议更新记录、domains/ 领域参考)按基线 0 重写,去掉版本对照和宿主平台现状说法 - sdk/{node,java,rust} 三个官方后端 SDK 与示例并入;5 个用 Node/Java 启动的示例补上 requiresTools - sdk/e2e 经 lithe-core 的 plugin-sdk-e2e feature 编译;新增 runtime-tools.plugin.json 代替运行时插件,host_launch_arguments 按宿主流程调 toolchains.instantiateLaunch 与 toolchains.checkVersion,替换已删除的 checkRuntimeVersion - scripts/verify-docs.mjs(schema $id、相对链接、拒绝指向 lithe-plugin-docs 的文件链接)及测试,scripts/sdk-e2e.mjs - .github/workflows:plugins-verify 与按 sdk-*-v* 标签发布三个 SDK - 契约 README 删除 Windows 映射与宿主现状小节,修复失效锚点和死链,去掉不存在的 build-host-integration fixture;SDK README 的宿主服务缺口改为链接能力页的唯一清单 - 根目录 README 说明目录分工;.gitignore 忽略 SDK 构建产物;cargo fmt 处理路径缩短后的格式差异 --- .../workflows/plugins-release-java-sdk.yml | 79 + .../workflows/plugins-release-node-sdk.yml | 76 + .../workflows/plugins-release-rust-sdk.yml | 73 + .github/workflows/plugins-verify.yml | 142 ++ .gitignore | 5 + README.md | 40 + rust/contracts/vcs-provider-v1.md | 7 +- .../lithe-agent-host/src/edit_review/tests.rs | 13 +- rust/lithe-agent-host/src/prompt_retry.rs | 7 +- rust/lithe-agent-host/src/session_defaults.rs | 6 +- rust/lithe-agent-host/src/subagents.rs | 6 +- rust/lithe-agent-host/src/tests.rs | 6 +- rust/lithe-core/Cargo.toml | 25 + rust/lithe-core/src/ai/request_tests.rs | 5 +- rust/lithe-core/src/git/change_targets.rs | 7 +- rust/lithe-core/src/git/configuration.rs | 7 +- rust/lithe-core/src/git/fetch.rs | 5 +- rust/lithe-core/src/git/graph_tests.rs | 44 +- rust/lithe-core/src/git/history.rs | 7 +- rust/lithe-core/src/git/history_window.rs | 7 +- rust/lithe-core/src/git/mod.rs | 6 +- rust/lithe-core/src/git/reference_tree.rs | 7 +- rust/lithe-core/src/git/update_plan.rs | 7 +- rust/lithe-core/src/lsp/tests.rs | 18 +- rust/lithe-core/src/mcp/library_tests.rs | 6 +- rust/lithe-core/src/plugins/host_api.rs | 7 +- rust/lithe-core/src/plugins/keymap_import.rs | 7 +- rust/lithe-core/src/project/search_index.rs | 6 +- rust/lithe-core/src/tests/agent.rs | 6 +- rust/lithe-core/src/tests/git.rs | 6 +- rust/lithe-core/src/tests/git_diff_blocks.rs | 6 +- .../src/tests/git_patch_exchange.rs | 31 +- rust/lithe-core/src/tests/history_events.rs | 6 +- rust/lithe-core/src/tests/project.rs | 18 +- rust/lithe-core/src/tests/protocol.rs | 6 +- rust/lithe-core/src/tests/rank_names.rs | 7 +- .../lithe-core/src/tests/run_configuration.rs | 3 +- rust/lithe-core/src/tests/toolchain_pins.rs | 6 +- rust/lithe-core/src/toolchain/pin_sources.rs | 7 +- rust/lithe-ide-host/src/catalog.rs | 3 +- shared/plugins/LICENSE | 203 +++ shared/plugins/README.md | 409 +++++ shared/plugins/contract/v1/README.md | 176 +- .../plugins/contract/v1/native-ui.schema.json | 2 +- .../contract/v1/plugin-index.schema.json | 2 +- .../contract/v1/plugin-manifest.schema.json | 2 +- .../plugins/contract/v1/toolchains/README.md | 6 +- .../plugins/guides/build-tool-integration.md | 469 +++++ shared/plugins/guides/debug-adapter-plugin.md | 377 ++++ shared/plugins/guides/lsp-plugin-lifecycle.md | 959 ++++++++++ .../plugins/guides/native-build-workflows.md | 197 ++ shared/plugins/guides/native-terminals.md | 221 +++ .../guides/plugin-backend-workspace.md | 647 +++++++ shared/plugins/guides/plugin-logging.md | 165 ++ shared/plugins/reference/README.md | 93 + shared/plugins/reference/api-catalog.md | 345 ++++ .../reference/capabilities-and-versions.md | 207 +++ shared/plugins/reference/domains/backend.md | 441 +++++ .../reference/domains/build-providers.md | 239 +++ .../reference/domains/build-systems.md | 195 ++ .../reference/domains/debug-adapters.md | 277 +++ shared/plugins/reference/domains/editor.md | 179 ++ .../reference/domains/keymap-import.md | 99 + shared/plugins/reference/domains/languages.md | 529 ++++++ shared/plugins/reference/domains/manifest.md | 225 +++ .../plugins/reference/domains/marketplace.md | 275 +++ .../reference/domains/package-trust.md | 203 +++ .../reference/domains/project-generators.md | 169 ++ shared/plugins/reference/domains/settings.md | 209 +++ .../plugins/reference/domains/toolchains.md | 309 ++++ shared/plugins/reference/domains/ui.md | 113 ++ shared/plugins/reference/domains/vcs.md | 109 ++ .../plugins/reference/protocol-changelog.md | 150 ++ shared/plugins/scripts/sdk-e2e.mjs | 98 + shared/plugins/scripts/verify-docs.mjs | 75 + shared/plugins/scripts/verify-docs.test.mjs | 40 + shared/plugins/sdk/e2e/plugin_sdk/backend.rs | 644 +++++++ shared/plugins/sdk/e2e/plugin_sdk/keymap.rs | 72 + shared/plugins/sdk/e2e/plugin_sdk/mod.rs | 674 +++++++ .../sdk/e2e/plugin_sdk/native_build.rs | 370 ++++ shared/plugins/sdk/e2e/plugin_sdk/vcs.rs | 314 ++++ shared/plugins/sdk/e2e/plugin_sdk_build.rs | 68 + shared/plugins/sdk/e2e/plugin_sdk_java.rs | 58 + shared/plugins/sdk/e2e/plugin_sdk_node.rs | 42 + shared/plugins/sdk/e2e/plugin_sdk_rust.rs | 44 + .../plugins/sdk/e2e/runtime-tools.plugin.json | 42 + shared/plugins/sdk/java/README.md | 422 +++++ .../java/examples/sample-build/plugin.json | 87 + .../sdk/java/examples/sample-build/pom.xml | 51 + .../org/example/samplebuild/SampleBuild.java | 248 +++ .../examples/sample-native-build/plugin.json | 156 ++ .../java/examples/sample-native-build/pom.xml | 51 + .../samplenativebuild/SampleNativeBuild.java | 200 ++ shared/plugins/sdk/java/plugin-sdk/pom.xml | 41 + .../github/litheidea/plugin/sdk/Backend.java | 1556 ++++++++++++++++ .../litheidea/plugin/sdk/CallContext.java | 143 ++ .../litheidea/plugin/sdk/Cancellation.java | 112 ++ .../plugin/sdk/CancelledException.java | 14 + .../litheidea/plugin/sdk/Diagnostics.java | 72 + .../github/litheidea/plugin/sdk/Framing.java | 138 ++ .../io/github/litheidea/plugin/sdk/Host.java | 637 +++++++ .../litheidea/plugin/sdk/HostChannel.java | 495 +++++ .../litheidea/plugin/sdk/HostError.java | 126 ++ .../io/github/litheidea/plugin/sdk/Json.java | 426 +++++ .../litheidea/plugin/sdk/PluginError.java | 38 + .../litheidea/plugin/sdk/PluginSdkError.java | 32 + .../litheidea/plugin/sdk/Processes.java | 629 +++++++ .../github/litheidea/plugin/sdk/Protocol.java | 234 +++ .../litheidea/plugin/sdk/StateStore.java | 198 ++ .../litheidea/plugin/sdk/BackendTest.java | 607 +++++++ .../litheidea/plugin/sdk/BuildTest.java | 224 +++ .../github/litheidea/plugin/sdk/Fixtures.java | 133 ++ .../github/litheidea/plugin/sdk/HostTest.java | 877 +++++++++ .../litheidea/plugin/sdk/KeymapTest.java | 103 ++ .../litheidea/plugin/sdk/SupportTest.java | 370 ++++ .../github/litheidea/plugin/sdk/TestHost.java | 217 +++ shared/plugins/sdk/java/pom.xml | 78 + shared/plugins/sdk/node/README.md | 496 +++++ .../examples/sample-build/backend/main.mjs | 155 ++ .../node/examples/sample-build/plugin.json | 50 + .../sample-native-build/backend/main.mjs | 121 ++ .../examples/sample-native-build/plugin.json | 156 ++ .../sdk/node/examples/sample-vcs/plugin.json | 36 + .../examples/sample-vcs/provider/main.mjs | 250 +++ shared/plugins/sdk/node/package-lock.json | 51 + shared/plugins/sdk/node/package.json | 29 + shared/plugins/sdk/node/src/backend.mjs | 1081 +++++++++++ shared/plugins/sdk/node/src/diagnostics.mjs | 34 + shared/plugins/sdk/node/src/errors.mjs | 101 ++ shared/plugins/sdk/node/src/framing.mjs | 74 + shared/plugins/sdk/node/src/host.mjs | 413 +++++ shared/plugins/sdk/node/src/index.d.ts | 713 ++++++++ shared/plugins/sdk/node/src/index.mjs | 8 + shared/plugins/sdk/node/src/process.mjs | 188 ++ shared/plugins/sdk/node/src/protocol.mjs | 270 +++ shared/plugins/sdk/node/src/state.mjs | 148 ++ shared/plugins/sdk/node/test/backend.test.mjs | 334 ++++ shared/plugins/sdk/node/test/build.test.mjs | 197 ++ .../sdk/node/test/diagnostics.test.mjs | 86 + shared/plugins/sdk/node/test/harness.mjs | 106 ++ shared/plugins/sdk/node/test/host.test.mjs | 720 ++++++++ shared/plugins/sdk/node/test/keymap.test.mjs | 53 + .../sdk/node/test/sample-build.test.mjs | 64 + shared/plugins/sdk/node/test/support.test.mjs | 321 ++++ shared/plugins/sdk/node/test/types/build.mts | 64 + shared/plugins/sdk/node/test/types/host.mts | 139 ++ shared/plugins/sdk/node/test/types/keymap.mts | 8 + .../plugins/sdk/node/test/types/tsconfig.json | 11 + shared/plugins/sdk/node/test/vcs.test.mjs | 167 ++ shared/plugins/sdk/rust/Cargo.lock | 120 ++ shared/plugins/sdk/rust/Cargo.toml | 13 + shared/plugins/sdk/rust/README.md | 452 +++++ .../rust/examples/sample-backend/Cargo.toml | 11 + .../rust/examples/sample-backend/plugin.json | 50 + .../rust/examples/sample-backend/src/main.rs | 381 ++++ .../examples/sample-backend/ui/index.html | 23 + .../rust/examples/sample-backend/ui/main.js | 43 + .../rust/examples/sample-backend/ui/style.css | 3 + shared/plugins/sdk/rust/plugin-sdk/Cargo.toml | 12 + .../sdk/rust/plugin-sdk/src/backend.rs | 1603 +++++++++++++++++ .../sdk/rust/plugin-sdk/src/diagnostics.rs | 170 ++ .../plugins/sdk/rust/plugin-sdk/src/error.rs | 392 ++++ .../sdk/rust/plugin-sdk/src/framing.rs | 170 ++ .../plugins/sdk/rust/plugin-sdk/src/host.rs | 1310 ++++++++++++++ shared/plugins/sdk/rust/plugin-sdk/src/lib.rs | 53 + .../sdk/rust/plugin-sdk/src/panic_boundary.rs | 29 + .../sdk/rust/plugin-sdk/src/protocol.rs | 283 +++ .../plugins/sdk/rust/plugin-sdk/src/stdio.rs | 31 + .../sdk/rust/plugin-sdk/tests/common/mod.rs | 161 ++ .../rust/plugin-sdk/tests/host_services.rs | 1136 ++++++++++++ .../sdk/rust/plugin-sdk/tests/keymap.rs | 150 ++ .../sdk/rust/plugin-sdk/tests/session.rs | 528 ++++++ 172 files changed, 33664 insertions(+), 297 deletions(-) create mode 100644 .github/workflows/plugins-release-java-sdk.yml create mode 100644 .github/workflows/plugins-release-node-sdk.yml create mode 100644 .github/workflows/plugins-release-rust-sdk.yml create mode 100644 .github/workflows/plugins-verify.yml create mode 100644 README.md create mode 100644 shared/plugins/LICENSE create mode 100644 shared/plugins/README.md create mode 100644 shared/plugins/guides/build-tool-integration.md create mode 100644 shared/plugins/guides/debug-adapter-plugin.md create mode 100644 shared/plugins/guides/lsp-plugin-lifecycle.md create mode 100644 shared/plugins/guides/native-build-workflows.md create mode 100644 shared/plugins/guides/native-terminals.md create mode 100644 shared/plugins/guides/plugin-backend-workspace.md create mode 100644 shared/plugins/guides/plugin-logging.md create mode 100644 shared/plugins/reference/README.md create mode 100644 shared/plugins/reference/api-catalog.md create mode 100644 shared/plugins/reference/capabilities-and-versions.md create mode 100644 shared/plugins/reference/domains/backend.md create mode 100644 shared/plugins/reference/domains/build-providers.md create mode 100644 shared/plugins/reference/domains/build-systems.md create mode 100644 shared/plugins/reference/domains/debug-adapters.md create mode 100644 shared/plugins/reference/domains/editor.md create mode 100644 shared/plugins/reference/domains/keymap-import.md create mode 100644 shared/plugins/reference/domains/languages.md create mode 100644 shared/plugins/reference/domains/manifest.md create mode 100644 shared/plugins/reference/domains/marketplace.md create mode 100644 shared/plugins/reference/domains/package-trust.md create mode 100644 shared/plugins/reference/domains/project-generators.md create mode 100644 shared/plugins/reference/domains/settings.md create mode 100644 shared/plugins/reference/domains/toolchains.md create mode 100644 shared/plugins/reference/domains/ui.md create mode 100644 shared/plugins/reference/domains/vcs.md create mode 100644 shared/plugins/reference/protocol-changelog.md create mode 100644 shared/plugins/scripts/sdk-e2e.mjs create mode 100644 shared/plugins/scripts/verify-docs.mjs create mode 100644 shared/plugins/scripts/verify-docs.test.mjs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk/backend.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk/keymap.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk/mod.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk/native_build.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk/vcs.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk_build.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk_java.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk_node.rs create mode 100644 shared/plugins/sdk/e2e/plugin_sdk_rust.rs create mode 100644 shared/plugins/sdk/e2e/runtime-tools.plugin.json create mode 100644 shared/plugins/sdk/java/README.md create mode 100644 shared/plugins/sdk/java/examples/sample-build/plugin.json create mode 100644 shared/plugins/sdk/java/examples/sample-build/pom.xml create mode 100644 shared/plugins/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java create mode 100644 shared/plugins/sdk/java/examples/sample-native-build/plugin.json create mode 100644 shared/plugins/sdk/java/examples/sample-native-build/pom.xml create mode 100644 shared/plugins/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/pom.xml create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostError.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java create mode 100644 shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java create mode 100644 shared/plugins/sdk/java/pom.xml create mode 100644 shared/plugins/sdk/node/README.md create mode 100644 shared/plugins/sdk/node/examples/sample-build/backend/main.mjs create mode 100644 shared/plugins/sdk/node/examples/sample-build/plugin.json create mode 100644 shared/plugins/sdk/node/examples/sample-native-build/backend/main.mjs create mode 100644 shared/plugins/sdk/node/examples/sample-native-build/plugin.json create mode 100644 shared/plugins/sdk/node/examples/sample-vcs/plugin.json create mode 100644 shared/plugins/sdk/node/examples/sample-vcs/provider/main.mjs create mode 100644 shared/plugins/sdk/node/package-lock.json create mode 100644 shared/plugins/sdk/node/package.json create mode 100644 shared/plugins/sdk/node/src/backend.mjs create mode 100644 shared/plugins/sdk/node/src/diagnostics.mjs create mode 100644 shared/plugins/sdk/node/src/errors.mjs create mode 100644 shared/plugins/sdk/node/src/framing.mjs create mode 100644 shared/plugins/sdk/node/src/host.mjs create mode 100644 shared/plugins/sdk/node/src/index.d.ts create mode 100644 shared/plugins/sdk/node/src/index.mjs create mode 100644 shared/plugins/sdk/node/src/process.mjs create mode 100644 shared/plugins/sdk/node/src/protocol.mjs create mode 100644 shared/plugins/sdk/node/src/state.mjs create mode 100644 shared/plugins/sdk/node/test/backend.test.mjs create mode 100644 shared/plugins/sdk/node/test/build.test.mjs create mode 100644 shared/plugins/sdk/node/test/diagnostics.test.mjs create mode 100644 shared/plugins/sdk/node/test/harness.mjs create mode 100644 shared/plugins/sdk/node/test/host.test.mjs create mode 100644 shared/plugins/sdk/node/test/keymap.test.mjs create mode 100644 shared/plugins/sdk/node/test/sample-build.test.mjs create mode 100644 shared/plugins/sdk/node/test/support.test.mjs create mode 100644 shared/plugins/sdk/node/test/types/build.mts create mode 100644 shared/plugins/sdk/node/test/types/host.mts create mode 100644 shared/plugins/sdk/node/test/types/keymap.mts create mode 100644 shared/plugins/sdk/node/test/types/tsconfig.json create mode 100644 shared/plugins/sdk/node/test/vcs.test.mjs create mode 100644 shared/plugins/sdk/rust/Cargo.lock create mode 100644 shared/plugins/sdk/rust/Cargo.toml create mode 100644 shared/plugins/sdk/rust/README.md create mode 100644 shared/plugins/sdk/rust/examples/sample-backend/Cargo.toml create mode 100644 shared/plugins/sdk/rust/examples/sample-backend/plugin.json create mode 100644 shared/plugins/sdk/rust/examples/sample-backend/src/main.rs create mode 100644 shared/plugins/sdk/rust/examples/sample-backend/ui/index.html create mode 100644 shared/plugins/sdk/rust/examples/sample-backend/ui/main.js create mode 100644 shared/plugins/sdk/rust/examples/sample-backend/ui/style.css create mode 100644 shared/plugins/sdk/rust/plugin-sdk/Cargo.toml create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/backend.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/diagnostics.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/error.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/framing.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/host.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/lib.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/panic_boundary.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/src/stdio.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/tests/common/mod.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/tests/host_services.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/tests/keymap.rs create mode 100644 shared/plugins/sdk/rust/plugin-sdk/tests/session.rs diff --git a/.github/workflows/plugins-release-java-sdk.yml b/.github/workflows/plugins-release-java-sdk.yml new file mode 100644 index 00000000..4789cc78 --- /dev/null +++ b/.github/workflows/plugins-release-java-sdk.yml @@ -0,0 +1,79 @@ +# Releases the official Java plugin SDK (`shared/plugins/sdk/java`) through its own tag series +# (`sdk-java-v`). The release holds `lithe-plugin-sdk-java-.jar` +# and its `.sha256`; plugin repositories pin both and shade the JAR into their +# backend at build time. +name: Release Java Plugin SDK + +on: + push: + tags: + - "sdk-java-v*" + +permissions: + contents: read + +concurrency: + group: release-java-plugin-sdk-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release ${{ github.ref_name }} + if: github.repository == 'Lithe-IDEA/Lithe-core-rust' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Check out source + uses: actions/checkout@v7 + + # The SDK supports Java 8: the tests run on a Java 8 runtime, while + # Maven builds with the newer JDK set up last. + - name: Set up Java 8 runtime + id: java8 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '8' + + - name: Set up Java 21 for Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Require the tag to match the SDK version + id: version + env: + TAG: ${{ github.ref_name }} + run: | + version=$(mvn -B -ntp -q -f shared/plugins/sdk/java/plugin-sdk/pom.xml help:evaluate -Dexpression=project.version -DforceStdout) + test "$TAG" = "sdk-java-v$version" || { echo "Tag $TAG does not match shared/plugins/sdk/java version $version"; exit 1; } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Build and test on Java 8 + env: + JAVA8: ${{ steps.java8.outputs.path }}/bin/java + run: mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify -Djvm="$JAVA8" + + - name: Collect the release assets + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mkdir -p dist + asset="lithe-plugin-sdk-java-$VERSION.jar" + cp "shared/plugins/sdk/java/plugin-sdk/target/$asset" "dist/$asset" + (cd dist && sha256sum "$asset" | cut -d' ' -f1 > "$asset.sha256") + + - name: Publish the SDK release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + asset="lithe-plugin-sdk-java-$VERSION.jar" + gh release create "$TAG" "dist/$asset" "dist/$asset.sha256" \ + --title "Java plugin SDK $VERSION" \ + --notes "Official Java plugin SDK $VERSION for Lithe plugin backends. Requires Java 8 or newer and has no runtime dependencies. Pin the JAR by its SHA-256 (\`$asset.sha256\`) and shade it into the backend JAR; see shared/plugins/sdk/java/README.md." diff --git a/.github/workflows/plugins-release-node-sdk.yml b/.github/workflows/plugins-release-node-sdk.yml new file mode 100644 index 00000000..9af5015a --- /dev/null +++ b/.github/workflows/plugins-release-node-sdk.yml @@ -0,0 +1,76 @@ +# Releases the official Node.js plugin SDK (`shared/plugins/sdk/node`) through its own tag +# series (`sdk-node-v`). The release holds +# `lithe-plugin-sdk-node-.tgz` and its `.sha256`; plugin repositories +# pin both and vendor the unpacked package into their backend at build time, +# because an installed plugin has no `node_modules` install step. +name: Release Node Plugin SDK + +on: + push: + tags: + - "sdk-node-v*" + +permissions: + contents: read + +concurrency: + group: release-node-plugin-sdk-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release ${{ github.ref_name }} + if: github.repository == 'Lithe-IDEA/Lithe-core-rust' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + + steps: + - name: Check out source + uses: actions/checkout@v7 + + # The SDK supports Node.js 20.3 and newer, so the release is tested on + # the oldest supported major. + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Require the tag to match the SDK version + id: version + env: + TAG: ${{ github.ref_name }} + run: | + version=$(node -p "require('./shared/plugins/sdk/node/package.json').version") + test "$TAG" = "sdk-node-v$version" || { echo "Tag $TAG does not match shared/plugins/sdk/node version $version"; exit 1; } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Test + run: node --test shared/plugins/sdk/node/test/*.test.mjs + + # `npm pack` only builds the archive; `private: true` still keeps the + # SDK off the npm registry. The repository LICENSE is copied in so the + # archive carries its own license text. + - name: Collect the release assets + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mkdir -p dist + cp shared/plugins/LICENSE shared/plugins/sdk/node/LICENSE + (cd shared/plugins/sdk/node && npm pack --pack-destination ../../../../dist) + asset="lithe-plugin-sdk-node-$VERSION.tgz" + mv "dist/lithe-plugin-sdk-$VERSION.tgz" "dist/$asset" + tar -tzf "dist/$asset" | sort + (cd dist && sha256sum "$asset" | cut -d' ' -f1 > "$asset.sha256") + + - name: Publish the SDK release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + asset="lithe-plugin-sdk-node-$VERSION.tgz" + gh release create "$TAG" "dist/$asset" "dist/$asset.sha256" \ + --title "Node.js plugin SDK $VERSION" \ + --notes "Official Node.js plugin SDK $VERSION for Lithe plugin backends. Requires Node.js 20.3 or newer and has no dependencies. Pin the archive by its SHA-256 (\`$asset.sha256\`), unpack it, and vendor its \`package/\` directory into the plugin package; see shared/plugins/sdk/node/README.md." diff --git a/.github/workflows/plugins-release-rust-sdk.yml b/.github/workflows/plugins-release-rust-sdk.yml new file mode 100644 index 00000000..c7cb0ef0 --- /dev/null +++ b/.github/workflows/plugins-release-rust-sdk.yml @@ -0,0 +1,73 @@ +# Releases the official Rust plugin SDK (`shared/plugins/sdk/rust/plugin-sdk`) through its +# own tag series (`sdk-rust-v`). Plugin repositories either depend +# on the tag through Git, which their Cargo.lock pins to the commit, or pin +# `lithe-plugin-sdk-rust-.crate` by its `.sha256` and vendor the +# unpacked crate; the SDK is not published to crates.io. +name: Release Rust Plugin SDK + +on: + push: + tags: + - "sdk-rust-v*" + +permissions: + contents: read + +concurrency: + group: release-rust-plugin-sdk-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release ${{ github.ref_name }} + if: github.repository == 'Lithe-IDEA/Lithe-core-rust' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Check out source + uses: actions/checkout@v7 + + # The release is tested on the oldest supported Rust. + - uses: dtolnay/rust-toolchain@1.87 + + - name: Require the tag to match the SDK version + id: version + env: + TAG: ${{ github.ref_name }} + run: | + version=$(cargo +1.87 metadata --locked --no-deps --format-version 1 --manifest-path shared/plugins/sdk/rust/Cargo.toml \ + | jq -r '.packages[] | select(.name == "lithe-plugin-sdk") | .version') + test "$TAG" = "sdk-rust-v$version" || { echo "Tag $TAG does not match shared/plugins/sdk/rust version $version"; exit 1; } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Test + run: cargo +1.87 test --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml + + # `cargo package` only builds the archive; `publish = false` keeps the + # SDK off crates.io. The repository LICENSE is copied in so the archive + # carries its own license text. + - name: Collect the release assets + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cp shared/plugins/LICENSE shared/plugins/sdk/rust/plugin-sdk/LICENSE + cargo +1.87 package --locked --allow-dirty --manifest-path shared/plugins/sdk/rust/Cargo.toml -p lithe-plugin-sdk + mkdir -p dist + asset="lithe-plugin-sdk-rust-$VERSION.crate" + cp "shared/plugins/sdk/rust/target/package/lithe-plugin-sdk-$VERSION.crate" "dist/$asset" + tar -tzf "dist/$asset" | sort + (cd dist && sha256sum "$asset" | cut -d' ' -f1 > "$asset.sha256") + + - name: Publish the SDK release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + asset="lithe-plugin-sdk-rust-$VERSION.crate" + gh release create "$TAG" "dist/$asset" "dist/$asset.sha256" \ + --title "Rust plugin SDK $VERSION" \ + --notes "Official Rust plugin SDK $VERSION for Lithe plugin backends. Requires Rust 1.87 or newer and depends only on serde_json and base64. Depend on the tag through Git, or pin the archive by its SHA-256 (\`$asset.sha256\`) and vendor the unpacked crate; see shared/plugins/sdk/rust/README.md." diff --git a/.github/workflows/plugins-verify.yml b/.github/workflows/plugins-verify.yml new file mode 100644 index 00000000..89e46c03 --- /dev/null +++ b/.github/workflows/plugins-verify.yml @@ -0,0 +1,142 @@ +name: Plugin SDKs + +# The plugin contract, developer guides and official SDKs live in +# shared/plugins next to the Rust Core that executes the contract, so every +# change is tested against Core at the same commit. Only changes that can +# affect them start this workflow. +on: + pull_request: + paths: + - 'shared/plugins/**' + - 'rust/**' + - '.github/workflows/plugins-verify.yml' + push: + branches: [main] + paths: + - 'shared/plugins/**' + - 'rust/**' + - '.github/workflows/plugins-verify.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: plugins-verify-${{ github.ref }} + cancel-in-progress: true + +jobs: + docs: + name: Contract and docs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Test the tooling + run: node --test shared/plugins/scripts/*.test.mjs + - name: Check schema IDs and links + run: node shared/plugins/scripts/verify-docs.mjs + + sdk-node: + name: Node.js SDK + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + # The SDK supports Node.js 20.3 and newer, so it is tested on the oldest + # supported major. + - uses: actions/setup-node@v6 + with: + node-version: 20 + + - name: Test + run: node --test shared/plugins/sdk/node/test/*.test.mjs + - name: Check TypeScript consumers + run: | + npm ci --prefix shared/plugins/sdk/node --ignore-scripts --no-audit --no-fund + npm run --prefix shared/plugins/sdk/node test:types + + sdk-java: + name: Java SDK + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + # The SDK supports Java 8: the tests run on a Java 8 runtime, while + # Maven builds with the newer JDK set up last. + - name: Set up Java 8 runtime + id: java8 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '8' + - name: Set up Java 21 for Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Build and test on Java 8 + env: + JAVA8: ${{ steps.java8.outputs.path }}/bin/java + run: mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify -Djvm="$JAVA8" + + sdk-rust: + name: Rust SDK + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + # The SDK supports Rust 1.87 and newer: the tests run on that release, + # and formatting and lints on the current stable toolchain. + - uses: dtolnay/rust-toolchain@1.87 + - name: Test on the oldest supported Rust + run: cargo +1.87 test --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Check formatting and lints + run: | + cargo fmt --manifest-path shared/plugins/sdk/rust/Cargo.toml --all -- --check + cargo clippy --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml --all-targets -- -D warnings + + sdk-e2e: + name: SDK end-to-end with Rust Core + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: 20 + - name: Set up Java 8 runtime + id: java8 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '8' + - name: Set up Java 21 for Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + - uses: dtolnay/rust-toolchain@stable + + - uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + shared/plugins/sdk/rust/target + key: sdk-e2e-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock', 'shared/plugins/sdk/rust/Cargo.lock') }} + restore-keys: sdk-e2e-${{ runner.os }}- + + - name: Run the SDK scenarios against this Core + env: + LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java + run: node shared/plugins/scripts/sdk-e2e.mjs diff --git a/.gitignore b/.gitignore index a9d13237..24b3542d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ /.artifacts/ /rust/target/ /scripts/rust-core/comment-lexer/target/ +node_modules/ +/shared/plugins/sdk/java/**/target/ +/shared/plugins/sdk/rust/target/ +*.tmp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 00000000..5c0f90fc --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Lithe Rust Core + +Lithe 的跨平台应用内核,以及插件契约、插件开发资料和官方插件 SDK 的唯一来源。 + +| 目录 | 内容 | +| --- | --- | +| [`rust/`](rust) | Rust Core 各 crate,以及 Core 自己的接口契约 `rust/contracts/`、测试数据 `rust/fixtures/` 和日志事件目录 `rust/logging/` | +| [`shared/plugins/`](shared/plugins) | 插件契约 `contract/v1/`、开发指南 `guides/`、参考 `reference/`、官方 SDK `sdk/{node,java,rust}` 和它们的端到端测试 | +| [`scripts/rust-core/`](scripts/rust-core) | Rust Core 注释规范检查(模块文档、英文注释、`unsafe` 安全说明、导出项 Rustdoc) | + +**插件开发者**从 [`shared/plugins/README.md`](shared/plugins/README.md) 开始,不需要克隆 Lithe 应用仓库。 + +## 常用命令 + +```sh +cargo test --manifest-path rust/Cargo.toml --workspace # Core 测试 +scripts/rust-core/verify-rust-core-comments.sh # 注释规范 +node shared/plugins/scripts/verify-docs.mjs # 契约 $id 与文档链接 +node shared/plugins/scripts/sdk-e2e.mjs # 三个 SDK 对当前 Core 的端到端测试 +``` + +修改 `shared/plugins/contract/v1/`、`rust/lithe-core/src/plugins/capabilities.rs` 或 +`rust/lithe-core/src/lsp/interface/plugin_function.rs` 的 PR,要在同一个 PR 里更新 +[协议更新记录](shared/plugins/reference/protocol-changelog.md)。 + +--- + +# Lithe Rust Core (English) + +Lithe's cross-platform application core, and the single source of the plugin contract, the plugin developer documentation and the official plugin SDKs. + +| Directory | Contents | +| --- | --- | +| [`rust/`](rust) | The Rust Core crates, plus Core's own interface contracts `rust/contracts/`, test data `rust/fixtures/` and logging event catalog `rust/logging/` | +| [`shared/plugins/`](shared/plugins) | The plugin contract `contract/v1/`, guides `guides/`, reference `reference/`, the official SDKs `sdk/{node,java,rust}` and their end-to-end tests | +| [`scripts/rust-core/`](scripts/rust-core) | The Rust Core comment rules check (module docs, English comments, `unsafe` safety sections, Rustdoc on exported items) | + +**Plugin authors** start at [`shared/plugins/README.md`](shared/plugins/README.md) and never need to clone the Lithe application repository. + +A pull request that changes `shared/plugins/contract/v1/`, `rust/lithe-core/src/plugins/capabilities.rs` or `rust/lithe-core/src/lsp/interface/plugin_function.rs` updates the [protocol changelog](shared/plugins/reference/protocol-changelog.md) in the same pull request. diff --git a/rust/contracts/vcs-provider-v1.md b/rust/contracts/vcs-provider-v1.md index feb77a7d..02142915 100644 --- a/rust/contracts/vcs-provider-v1.md +++ b/rust/contracts/vcs-provider-v1.md @@ -1,9 +1,8 @@ # VCS provider contract -> **Status: Core implemented; macOS hosts providers.** Core accepts the -> `vcsProviders` manifest capability and the commands below. macOS advertises -> `vcsProviders` 0 and starts providers for the roots it detects. Windows does -> not advertise the capability. +> **Status: Core implemented; hosts that advertise `vcsProviders` run providers.** +> Core accepts the `vcsProviders` manifest capability and the commands below. +> A host that advertises `vcsProviders` 0 starts providers for the roots it detects. > The decision record is > [Unified VCS model and providers](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md); > example data is in [`rust/fixtures/vcs/model-v1.json`](../fixtures/vcs/model-v1.json) diff --git a/rust/lithe-agent-host/src/edit_review/tests.rs b/rust/lithe-agent-host/src/edit_review/tests.rs index 0aabd9fb..74b49e2d 100644 --- a/rust/lithe-agent-host/src/edit_review/tests.rs +++ b/rust/lithe-agent-host/src/edit_review/tests.rs @@ -1,10 +1,8 @@ use super::*; fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/agent/edit-review-v1.json" - )) - .expect("edit review fixture is JSON") + serde_json::from_str(include_str!("../../../fixtures/agent/edit-review-v1.json")) + .expect("edit review fixture is JSON") } /// Expands `{repeat, count, suffix?}` text generators used by the shared fixture. @@ -116,10 +114,9 @@ fn canonical_match_maps_multibyte_offsets_back_to_the_original() { fn presented_acp_fixture_edits_carry_host_evidence() { // The platform event fixture shows presented updates; removing the Host // annotation and presenting again must reproduce them exactly. - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/agent/acp-events-v1.json" - )) - .expect("ACP event fixture is JSON"); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/agent/acp-events-v1.json")) + .expect("ACP event fixture is JSON"); for name in ["fileEdit", "claudeFileCreated", "claudeFileWriteUpdated"] { let presented = &fixture["events"][name]["update"]; let mut raw = presented.clone(); diff --git a/rust/lithe-agent-host/src/prompt_retry.rs b/rust/lithe-agent-host/src/prompt_retry.rs index 02501d29..6648ead1 100644 --- a/rust/lithe-agent-host/src/prompt_retry.rs +++ b/rust/lithe-agent-host/src/prompt_retry.rs @@ -35,10 +35,9 @@ pub(crate) fn quiet_notice_delay() -> Duration { } static DELAY: std::sync::OnceLock = std::sync::OnceLock::new(); *DELAY.get_or_init(|| { - let policy: Policy = serde_json::from_str(include_str!( - "../../contracts/agent-turn-policy.json" - )) - .expect("valid bundled Agent turn policy"); + let policy: Policy = + serde_json::from_str(include_str!("../../contracts/agent-turn-policy.json")) + .expect("valid bundled Agent turn policy"); Duration::from_millis(policy.quiet_notice_milliseconds) }) } diff --git a/rust/lithe-agent-host/src/session_defaults.rs b/rust/lithe-agent-host/src/session_defaults.rs index f343ccd5..a660abe5 100644 --- a/rust/lithe-agent-host/src/session_defaults.rs +++ b/rust/lithe-agent-host/src/session_defaults.rs @@ -155,10 +155,8 @@ mod tests { #[test] fn repair_uses_recommendation_or_a_selectable_catalog_model() { - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../fixtures/agent/acp-events-v1.json" - )) - .unwrap(); + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../fixtures/agent/acp-events-v1.json")).unwrap(); let initial = fixture["upstream"]["staleModelSession"].clone(); let parsed = |value| serde_json::from_value::(value).unwrap(); assert_eq!( diff --git a/rust/lithe-agent-host/src/subagents.rs b/rust/lithe-agent-host/src/subagents.rs index 2a6b462c..c2f90ba2 100644 --- a/rust/lithe-agent-host/src/subagents.rs +++ b/rust/lithe-agent-host/src/subagents.rs @@ -27,10 +27,8 @@ mod tests { #[test] fn native_and_standard_notifications_round_trip_the_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../fixtures/agent/acp-events-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../fixtures/agent/acp-events-v1.json")).unwrap(); for name in [ "subagentSpawned", "nestedSubagentSpawned", diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index f7e05ff4..f8d6d86b 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -7,10 +7,8 @@ use tokio::io::{ const WAIT: Duration = Duration::from_secs(5); fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../fixtures/agent/acp-events-v1.json" - )) - .expect("ACP event fixture") + serde_json::from_str(include_str!("../../fixtures/agent/acp-events-v1.json")) + .expect("ACP event fixture") } fn provider() -> ProviderCredentials { diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 69af9266..fc406c54 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -8,6 +8,11 @@ description = "Cross-platform application core for Lithe" [lib] crate-type = ["rlib", "staticlib", "cdylib"] +[features] +# Builds the plugin SDK end-to-end tests in shared/plugins/sdk/e2e; run them +# with `node shared/plugins/scripts/sdk-e2e.mjs`, which builds the samples. +plugin-sdk-e2e = [] + [dependencies] lithe-core-rules = { path = "../lithe-core-rules" } lithe-git-host = { path = "../lithe-git-host" } @@ -42,3 +47,23 @@ ignore = "=0.4.33" [target.'cfg(unix)'.dev-dependencies] lithe-remote = { path = "../lithe-remote", default-features = false, features = ["server"] } + +[[test]] +name = "plugin_sdk_node" +path = "../../shared/plugins/sdk/e2e/plugin_sdk_node.rs" +required-features = ["plugin-sdk-e2e"] + +[[test]] +name = "plugin_sdk_java" +path = "../../shared/plugins/sdk/e2e/plugin_sdk_java.rs" +required-features = ["plugin-sdk-e2e"] + +[[test]] +name = "plugin_sdk_rust" +path = "../../shared/plugins/sdk/e2e/plugin_sdk_rust.rs" +required-features = ["plugin-sdk-e2e"] + +[[test]] +name = "plugin_sdk_build" +path = "../../shared/plugins/sdk/e2e/plugin_sdk_build.rs" +required-features = ["plugin-sdk-e2e"] diff --git a/rust/lithe-core/src/ai/request_tests.rs b/rust/lithe-core/src/ai/request_tests.rs index 6ad846c5..13becb5d 100644 --- a/rust/lithe-core/src/ai/request_tests.rs +++ b/rust/lithe-core/src/ai/request_tests.rs @@ -14,10 +14,7 @@ fn pull_request_fixture() -> Value { } fn inline_edit_fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/ai/inline-edit-v1.json" - )) - .unwrap() + serde_json::from_str(include_str!("../../../fixtures/ai/inline-edit-v1.json")).unwrap() } fn typed(fixture: &Value) -> (Provider, PullRequestOptions, PullRequestComparison) { diff --git a/rust/lithe-core/src/git/change_targets.rs b/rust/lithe-core/src/git/change_targets.rs index 6cd4b62a..ae255525 100644 --- a/rust/lithe-core/src/git/change_targets.rs +++ b/rust/lithe-core/src/git/change_targets.rs @@ -114,10 +114,9 @@ mod tests { #[test] fn shared_fixture_cases_keep_only_operation_targets() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/change-targets-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/change-targets-v1.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response = call(case["request"].clone()); assert_eq!(response["ok"], true, "{}: {response}", case["name"]); diff --git a/rust/lithe-core/src/git/configuration.rs b/rust/lithe-core/src/git/configuration.rs index c98bf0e8..d4e8e70a 100644 --- a/rust/lithe-core/src/git/configuration.rs +++ b/rust/lithe-core/src/git/configuration.rs @@ -598,10 +598,9 @@ mod tests { use super::*; #[test] fn resolved_fetch_behavior_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/fetch-behavior-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/fetch-behavior-v1.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let entries: Vec = serde_json::from_value(case["request"]["entries"].clone()).unwrap(); diff --git a/rust/lithe-core/src/git/fetch.rs b/rust/lithe-core/src/git/fetch.rs index 3179a799..06eabf31 100644 --- a/rust/lithe-core/src/git/fetch.rs +++ b/rust/lithe-core/src/git/fetch.rs @@ -174,10 +174,7 @@ mod tests { use serde_json::{json, Value}; fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/git/fetch-plan-v1.json" - )) - .unwrap() + serde_json::from_str(include_str!("../../../fixtures/git/fetch-plan-v1.json")).unwrap() } fn request(payload: Value) -> Value { diff --git a/rust/lithe-core/src/git/graph_tests.rs b/rust/lithe-core/src/git/graph_tests.rs index 881e82e1..cadc9e61 100644 --- a/rust/lithe-core/src/git/graph_tests.rs +++ b/rust/lithe-core/src/git/graph_tests.rs @@ -318,23 +318,21 @@ fn compact_positions_and_half_edges_match_pinned_intellij_printing() { } fn read_tsv(name: &str) -> Vec { - read_fixture(&format!( - "rust/fixtures/git/graph-issue410/{name}.tsv" - )) - .lines() - .filter(|line| !line.is_empty()) - .map(|line| { - let columns = line.split('\t').collect::>(); - assert_eq!(columns.len(), 4, "{line}"); - CommitInput { - hash: columns[0].to_string(), - parent_hashes: columns[1].split_whitespace().map(str::to_string).collect(), - decorations: columns[2] - .trim_matches(|c| c == ' ' || c == '(' || c == ')') - .to_string(), - } - }) - .collect() + read_fixture(&format!("rust/fixtures/git/graph-issue410/{name}.tsv")) + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let columns = line.split('\t').collect::>(); + assert_eq!(columns.len(), 4, "{line}"); + CommitInput { + hash: columns[0].to_string(), + parent_hashes: columns[1].split_whitespace().map(str::to_string).collect(), + decorations: columns[2] + .trim_matches(|c| c == ' ' || c == '(' || c == ')') + .to_string(), + } + }) + .collect() } #[test] @@ -380,13 +378,11 @@ fn real_history_matches_intellij_for_page_context_and_date_order() { )); } } - let mut expected = read_fixture(&format!( - "rust/fixtures/git/graph-issue410/{oracle}.txt" - )) - .lines() - .filter(|line| !line.is_empty()) - .map(str::to_string) - .collect::>(); + let mut expected = read_fixture(&format!("rust/fixtures/git/graph-issue410/{oracle}.txt")) + .lines() + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect::>(); actual.sort(); expected.sort(); let missing = expected diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 70aee5cc..4433b899 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -1477,10 +1477,9 @@ mod tests { use serde_json::Value; use std::collections::BTreeSet; - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/history-locate-v1.json" - )) - .expect("history locate fixture should be valid JSON"); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/history-locate-v1.json")) + .expect("history locate fixture should be valid JSON"); let minimal: super::GitHistoryLocateRequest = serde_json::from_value(fixture["minimalRequest"].clone()) diff --git a/rust/lithe-core/src/git/history_window.rs b/rust/lithe-core/src/git/history_window.rs index ef8bffb6..a804ff75 100644 --- a/rust/lithe-core/src/git/history_window.rs +++ b/rust/lithe-core/src/git/history_window.rs @@ -243,10 +243,9 @@ mod tests { #[test] fn shared_fixture_windows_keep_depth_and_selection() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/history-window-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/history-window-v1.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response = call(case["request"].clone()); assert_eq!(response["ok"], true, "{}: {response}", case["name"]); diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index ceac6ba5..657ff6c3 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -9063,10 +9063,8 @@ mod tests { #[test] fn windows_git_paths_follow_shared_identity_policy() { - let cases: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/windows-paths.json" - )) - .unwrap(); + let cases: Value = + serde_json::from_str(include_str!("../../../fixtures/git/windows-paths.json")).unwrap(); for case in cases["accepted"].as_array().unwrap() { let path = simplified_canonical_path(PathBuf::from(case["input"].as_str().unwrap())).unwrap(); diff --git a/rust/lithe-core/src/git/reference_tree.rs b/rust/lithe-core/src/git/reference_tree.rs index 784f0fa4..e2bdff59 100644 --- a/rust/lithe-core/src/git/reference_tree.rs +++ b/rust/lithe-core/src/git/reference_tree.rs @@ -164,10 +164,9 @@ mod tests { #[test] fn shared_fixture_trees_have_one_grouping_and_order() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/reference-tree-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/reference-tree-v1.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response = call(case["request"].clone()); assert_eq!(response["ok"], true, "{}: {response}", case["name"]); diff --git a/rust/lithe-core/src/git/update_plan.rs b/rust/lithe-core/src/git/update_plan.rs index 9c87d1e3..4dc39675 100644 --- a/rust/lithe-core/src/git/update_plan.rs +++ b/rust/lithe-core/src/git/update_plan.rs @@ -323,10 +323,9 @@ mod tests { #[test] fn shared_fixture_steps_are_the_only_update_rules() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/update-plan-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/update-plan-v1.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response = call(case["request"].clone()); assert_eq!(response["ok"], true, "{}: {response}", case["name"]); diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 7cf51b93..adb27ed8 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -446,10 +446,8 @@ fn file_uri_paths_decode_spaces_and_utf8_characters() { #[test] fn document_uri_matches_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/lsp/document-uri-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/lsp/document-uri-v1.json")).unwrap(); for case in fixture["cases"].as_array().unwrap() { let response: Value = serde_json::from_str(&crate::execute_json( &json!({"command":"lsp.documentUri", "payload":{ @@ -514,10 +512,8 @@ fn document_uri_accepts_non_ascii_windows_extended_length_paths() { #[test] fn document_uri_identity_keeps_reserved_characters_distinct() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/lsp/document-uri-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/lsp/document-uri-v1.json")).unwrap(); for case in fixture["identityCases"].as_array().unwrap() { let opened_uri = case["opened"].as_str().unwrap(); let published_uri = case["published"].as_str().unwrap(); @@ -2799,10 +2795,8 @@ fn client_completion_rejects_invalid_trigger_context() { #[test] fn text_edits_follow_shared_windows_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/lsp/text-edits-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/lsp/text-edits-v1.json")).unwrap(); for case in fixture["cases"].as_array().unwrap() { let edits: Vec = case["edits"].as_array().unwrap().iter().map(|edit| serde_json::json!({ "range": { "start": { "line": edit["range"]["start"]["line"], "utf16Column": edit["range"]["start"]["character"] }, diff --git a/rust/lithe-core/src/mcp/library_tests.rs b/rust/lithe-core/src/mcp/library_tests.rs index 3cbe465b..2a08bede 100644 --- a/rust/lithe-core/src/mcp/library_tests.rs +++ b/rust/lithe-core/src/mcp/library_tests.rs @@ -5,10 +5,8 @@ use super::{batch_plan, library_snapshot, parse_configuration, validate_draft}; use serde_json::{json, Value}; fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/mcp/library-v1.json" - )) - .expect("library fixture is JSON") + serde_json::from_str(include_str!("../../../fixtures/mcp/library-v1.json")) + .expect("library fixture is JSON") } /// Resolves `{{sourceHash::}}` and `{{definitionHash:...}}` diff --git a/rust/lithe-core/src/plugins/host_api.rs b/rust/lithe-core/src/plugins/host_api.rs index 34d2d5f8..883c24cb 100644 --- a/rust/lithe-core/src/plugins/host_api.rs +++ b/rust/lithe-core/src/plugins/host_api.rs @@ -104,10 +104,9 @@ mod tests { #[test] fn authorization_does_not_widen_metadata_grants_or_accept_spoofed_services() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/plugins/host-api-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/plugins/host-api-v1.json")) + .unwrap(); for case in fixture["authorization"].as_array().unwrap() { assert_eq!( authorize_command(case["input"].clone()).unwrap(), diff --git a/rust/lithe-core/src/plugins/keymap_import.rs b/rust/lithe-core/src/plugins/keymap_import.rs index 319affb3..17162127 100644 --- a/rust/lithe-core/src/plugins/keymap_import.rs +++ b/rust/lithe-core/src/plugins/keymap_import.rs @@ -458,10 +458,9 @@ mod tests { #[test] fn import_binding_plans_follow_shared_fixtures() { - let fixtures: Value = serde_json::from_str(include_str!( - "../../../fixtures/keymap/import-plan-v1.json" - )) - .unwrap(); + let fixtures: Value = + serde_json::from_str(include_str!("../../../fixtures/keymap/import-plan-v1.json")) + .unwrap(); for case in fixtures["cases"].as_array().unwrap() { let request = json!({"id": "fixture", "command": "keymap.planImportBindings", "payload": case["request"]}); let response: Value = diff --git a/rust/lithe-core/src/project/search_index.rs b/rust/lithe-core/src/project/search_index.rs index 64911453..65dc3cf4 100644 --- a/rust/lithe-core/src/project/search_index.rs +++ b/rust/lithe-core/src/project/search_index.rs @@ -825,10 +825,8 @@ mod tests { fs::create_dir_all(file.parent().unwrap()).unwrap(); fs::write(file, "Name before").unwrap(); } - let fixture: serde_json::Value = serde_json::from_str(include_str!( - "../../../fixtures/search/exclusions.json" - )) - .unwrap(); + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../../fixtures/search/exclusions.json")).unwrap(); // The patterns come from the shared fixture, so Rust and Swift always // judge the same list. let rules = VisibilityRules { diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs index dd76dd7c..e1f94c5a 100644 --- a/rust/lithe-core/src/tests/agent.rs +++ b/rust/lithe-core/src/tests/agent.rs @@ -231,10 +231,8 @@ fn cli_update_outcome_fixture_preserves_verified_version_and_warning() { #[test] fn edit_reverse_plan_dispatches_shared_fixture_and_rejects_malformed_payloads() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/agent/edit-review-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/agent/edit-review-v1.json")).unwrap(); assert!(crate::protocol::CoreCommand::parse("agent.edits.reversePlan").is_some()); for case in fixture["reversePlan"].as_array().unwrap() { // Reverse plan cases use literal text only; generators belong to evidence cases. diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index cda74a51..170171d8 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -6319,10 +6319,8 @@ fn git_line_history_merge_rename_edit_keeps_previous_path_and_ranges() { #[test] fn rollback_plan_matches_shared_fixture_and_rejects_unsafe_paths() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/rollback-plan-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/rollback-plan-v1.json")).unwrap(); for case in fixture["cases"].as_array().unwrap() { let response: Value = serde_json::from_str(&execute_json( &serde_json::json!({ diff --git a/rust/lithe-core/src/tests/git_diff_blocks.rs b/rust/lithe-core/src/tests/git_diff_blocks.rs index d944871a..2604765f 100644 --- a/rust/lithe-core/src/tests/git_diff_blocks.rs +++ b/rust/lithe-core/src/tests/git_diff_blocks.rs @@ -9,10 +9,8 @@ use std::path::PathBuf; use std::process::Command; fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/git/diff-blocks-v1.json" - )) - .expect("diff block fixture should be JSON") + serde_json::from_str(include_str!("../../../fixtures/git/diff-blocks-v1.json")) + .expect("diff block fixture should be JSON") } fn side(value: &Value) -> (&str, &str) { diff --git a/rust/lithe-core/src/tests/git_patch_exchange.rs b/rust/lithe-core/src/tests/git_patch_exchange.rs index 3208f69d..42830ce9 100644 --- a/rust/lithe-core/src/tests/git_patch_exchange.rs +++ b/rust/lithe-core/src/tests/git_patch_exchange.rs @@ -58,10 +58,9 @@ impl PatchRepository { } fn export_shelf_working_layer(&self) -> Value { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/patch-exchange-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/patch-exchange-v1.json")) + .unwrap(); self.data( "git.patchExport", fixture["unstagedWithUntrackedRequest"].clone(), @@ -101,10 +100,8 @@ fn patch_exchange_reverse_roundtrip_preserves_both_versions_and_untracked_bytes( .unwrap() .to_string(); let mut checked_direction = false; - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/patch-exchange-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/patch-exchange-v1.json")).unwrap(); let mut apply = |patch: &str, target: &str, reverse: bool| { let index_before = fs::read(repository.0.join(".git/index")).unwrap(); let mut payload = fixture[if reverse { @@ -277,10 +274,8 @@ fn saved_stash_diff_returns_file_sections_and_core_documents() { let oid = entries["stashes"][0]["oid"].as_str().unwrap(); repository.write("text.txt", b"newer stash\n"); repository.git(&["stash", "push", "-m", "newer"]); - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/saved-stash-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/saved-stash-v1.json")).unwrap(); let mut request = fixture["sectionRequest"].clone(); request["stashReference"] = json!(oid); let saved = repository.data("git.diff", request); @@ -354,10 +349,8 @@ fn structured_patch_and_git_diff_preserve_file_identity_and_writable_hunks() { .as_str() .unwrap() .to_owned(); - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/parsed-diff-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/parsed-diff-v1.json")).unwrap(); let mut request = fixture["parseRequest"].clone(); request["patch"] = json!(patch); let parsed = repository.data("git.parsePatch", request); @@ -963,10 +956,8 @@ fn shelf_clean_and_retry_restore_preserve_staged_working_and_untracked() { index_before_capture, "capture must not refresh the real index" ); - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/git/shelf-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/git/shelf-v1.json")).unwrap(); assert_eq!( repository.data("git.shelf", json!({"action":"plan", "phase":"clean"})), fixture["cleanPlan"] diff --git a/rust/lithe-core/src/tests/history_events.rs b/rust/lithe-core/src/tests/history_events.rs index 95bcab2f..6ae518e9 100644 --- a/rust/lithe-core/src/tests/history_events.rs +++ b/rust/lithe-core/src/tests/history_events.rs @@ -10,10 +10,8 @@ fn execute(payload: Value) -> Value { } fn fixture() -> Value { - serde_json::from_str(include_str!( - "../../../fixtures/history/event-plan-v1.json" - )) - .expect("history event fixture should be JSON") + serde_json::from_str(include_str!("../../../fixtures/history/event-plan-v1.json")) + .expect("history event fixture should be JSON") } #[test] diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index 21e2c2a9..5c68f4a1 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -189,10 +189,8 @@ fn search_everywhere_preserves_unicode_contiguous_filename_matches() { #[test] fn search_everywhere_preserves_directory_fallback_below_basename_matches() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/search/path-fallback.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/search/path-fallback.json")).unwrap(); let root = temporary_root("search-path-fallback"); struct Cleanup(std::path::PathBuf); impl Drop for Cleanup { @@ -252,10 +250,8 @@ fn search_everywhere_matches_names_before_limits_and_shares_utf16_fragments() { } } let _cleanup = Cleanup(root.clone()); - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/search/name-matching.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/search/name-matching.json")).unwrap(); for case in fixture["cases"].as_array().unwrap() { let path = root.join(case["path"].as_str().unwrap()); fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -1236,10 +1232,8 @@ fn preserve_case_matches_original_occurrence_shape() { #[test] fn local_history_relocates_excluded_files_and_subtrees_from_retained_metadata() { - let cases: Value = serde_json::from_str(include_str!( - "../../../fixtures/history/relocation-v1.json" - )) - .unwrap(); + let cases: Value = + serde_json::from_str(include_str!("../../../fixtures/history/relocation-v1.json")).unwrap(); for case in cases["cases"].as_array().unwrap() { let root = temporary_root("excluded-history-relocation"); struct Cleanup(std::path::PathBuf, Option); diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 7824ca30..9f979ef2 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -5,10 +5,8 @@ use serde_json::Value; #[test] fn keymap_context_fixtures_cross_the_json_boundary() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/keymap/context-v1.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/keymap/context-v1.json")).unwrap(); let call = |command: &str, payload: Value| -> Value { serde_json::from_str(&execute_json( &serde_json::json!({ diff --git a/rust/lithe-core/src/tests/rank_names.rs b/rust/lithe-core/src/tests/rank_names.rs index c3659baf..afc9f7ac 100644 --- a/rust/lithe-core/src/tests/rank_names.rs +++ b/rust/lithe-core/src/tests/rank_names.rs @@ -17,10 +17,9 @@ fn execute(payload: Value) -> Value { #[test] fn rank_names_matches_the_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/search/rank-names-v1.json" - )) - .expect("rank-names fixture should be JSON"); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/search/rank-names-v1.json")) + .expect("rank-names fixture should be JSON"); for case in fixture["cases"].as_array().unwrap() { let response = execute(case["request"].clone()); assert_eq!(response["ok"], true, "{}: {response}", case["id"]); diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 2cf658d5..1a2ec741 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -634,8 +634,7 @@ fn legacy_v2_runtime_requirements_are_reconciled_without_regeneration() { #[test] fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { - let directory = - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/run-configuration"); + let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/run-configuration"); let mut fixture_count = 0; for entry in fs::read_dir(directory).unwrap() { let path = entry.unwrap().path(); diff --git a/rust/lithe-core/src/tests/toolchain_pins.rs b/rust/lithe-core/src/tests/toolchain_pins.rs index daeaa854..6f1ceb8e 100644 --- a/rust/lithe-core/src/tests/toolchain_pins.rs +++ b/rust/lithe-core/src/tests/toolchain_pins.rs @@ -332,10 +332,8 @@ fn toolchain_pin_edits_prune_empty_containers_and_leave_absent_removals_inert() #[test] fn toolchain_pin_edits_match_the_shared_boundary_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/toolchain-pin-edit.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/toolchain-pin-edit.json")).unwrap(); let response = edit(fixture["request"].clone()); assert_eq!(response["data"]["edit"]["document"], fixture["document"]); } diff --git a/rust/lithe-core/src/toolchain/pin_sources.rs b/rust/lithe-core/src/toolchain/pin_sources.rs index 8ecae069..b30ea288 100644 --- a/rust/lithe-core/src/toolchain/pin_sources.rs +++ b/rust/lithe-core/src/toolchain/pin_sources.rs @@ -192,10 +192,9 @@ mod tests { #[test] fn source_edits_match_shared_fixture() { - let fixture: Value = serde_json::from_str(include_str!( - "../../../fixtures/toolchain-pin-storage.json" - )) - .unwrap(); + let fixture: Value = + serde_json::from_str(include_str!("../../../fixtures/toolchain-pin-storage.json")) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let response: Value = serde_json::from_str(&crate::execute_json( &json!({"command": case["command"], "payload": case["input"]}).to_string(), diff --git a/rust/lithe-ide-host/src/catalog.rs b/rust/lithe-ide-host/src/catalog.rs index 2e7b322b..4eef9285 100644 --- a/rust/lithe-ide-host/src/catalog.rs +++ b/rust/lithe-ide-host/src/catalog.rs @@ -375,8 +375,7 @@ mod tests { #[test] fn shared_fixture_protects_the_plugin_and_mcp_contract() { let fixture: Value = - serde_json::from_str(include_str!("../../fixtures/ide-api/calls.json")) - .unwrap(); + serde_json::from_str(include_str!("../../fixtures/ide-api/calls.json")).unwrap(); let permissions = Permissions { configure: true, execute: true, diff --git a/shared/plugins/LICENSE b/shared/plugins/LICENSE new file mode 100644 index 00000000..69bef1d8 --- /dev/null +++ b/shared/plugins/LICENSE @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or + Object form, that is based on (or derived from) the Work and + for which the editorial revisions, annotations, elaborations, + or other modifications represent, as a whole, an original work + of authorship. For the purposes of this License, Derivative Works + shall not include works that remain separable from, or merely link + (or bind by name) to the interfaces of, the Work and Derivative Works + thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of + its distribution, then any Derivative Works that You + distribute must include a readable copy of the attribution + notices contained within such NOTICE file, excluding those + notices that do not pertain to any part of the Derivative + Works, in at least one of the following places: within a + NOTICE text file distributed as part of the Derivative Works; + within the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as + a result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[ ]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/shared/plugins/README.md b/shared/plugins/README.md new file mode 100644 index 00000000..d2d147f8 --- /dev/null +++ b/shared/plugins/README.md @@ -0,0 +1,409 @@ +# Lithe 插件开发 / Lithe plugin development + +[中文](#中文) · [English](#english) + +## 中文 + +写插件、测试插件、发布插件,都在**你自己的仓库**里完成。不需要克隆 [Lithe](https://github.com/1lck/Lithe-IDEA) 主仓库,也不需要给它提 PR。插件开发需要的资料都在这个目录:契约、指南、参考和官方 SDK 都在 [Lithe Core 仓库](https://github.com/Lithe-IDEA/Lithe-core-rust)的 `shared/plugins/` 下。 + +| 想做什么 | 看哪里 | +| --- | --- | +| 了解插件长什么样 | [插件是什么](#插件是什么) | +| 动手做第一个插件 | [五步做出第一个插件](#五步做出第一个插件) | +| 跟着教程完整做一遍 | [教程](#教程) | +| 写 `plugin.json` 时有提示和校验 | [在编辑器里校验](#在编辑器里校验-pluginjson) | +| 需要自己的后端进程 | [插件后端 SDK](#插件后端-sdk可选) | +| 接入宿主日志、排查插件故障 | [插件接入统一日志](guides/plugin-logging.md) | +| 参考现成插件 | [示例插件](#示例插件) | +| 查 API-01–44 现在能用什么、每种能力和协议的版本、Core 和 Lithe 桌面端支持哪些 | [插件参考](reference/README.md) | +| 查字段的准确含义 | [插件契约](#插件契约) | +| 修改契约、指南或 SDK,在本地验证 | [贡献与维护](#贡献与维护) | + +### 插件是什么 + +一个 Lithe 插件就是一个目录:`plugin.json` 声明插件提供什么,其余文件是它用到的资源,例如语言服务器、脚本和图标。 + +```text +io.github.<你的用户名>.zig-support/ +├── plugin.json +└── … # plugin.json 引用的资源 +``` + +Lithe 读取并校验 `plugin.json`,然后自己启动其中声明的外部进程。插件代码不会加载进 Lithe 进程,所以同一个插件包不需要为每个 Lithe 版本重新构建。 + +社区插件必须遵守这几条: + +- `id` 用你自己的反向域名,例如 `io.github.<你的用户名>.zig-support`。`dev.lithe` 和 `dev.lithe.*` 是官方保留命名空间。 +- `vendor.signatureRequirement` 写 `"none"`。社区插件不带 Lithe 签名,用户安装前会看到完整的程序、权限和来源,并确认。 +- 包内路径只用 `/`,不能有 `..`、绝对路径或符号链接。 +- 插件包安装后是只读的。缓存和状态使用 Lithe 通过启动模板传入的 `${stateDir}`、`${workspaceStateDir}`;诊断日志使用 SDK 与宿主统一入口。确需原生文件日志时才使用 `${logDir}`,没有本地日志目录的宿主会拒绝这类启动,详见[插件接入统一日志](guides/plugin-logging.md)。 + +### 五步做出第一个插件 + +1. **复制模板。** 以 [Lua 插件](https://github.com/Lithe-IDEA/lithe-plugin-lua) 为起点,它是 Lithe Store 的示例和模板。 +2. **改 `plugin.json`。** 换成你自己的 ID、名称、语言和语言服务器,并按[下面](#清单里的版本号)的规则写版本号和 `requires`。先按[后面一节](#在编辑器里校验-pluginjson)配置编辑器,写错字段时会直接提示。 +3. **在构建阶段准备好所有文件。** 语言服务器、后端都在打包时放进插件包,运行时不下载任何东西。详见 [LSP 插件完整生命周期教程](guides/lsp-plugin-lifecycle.md)。 +4. **在 Lithe 里试装。** 开发时可以用“从磁盘安装插件…”选择 zip 或目录(同样要确认安装审查;Lithe 安装的是一份快照,改完要重新安装)。发布前在自己仓库发一个 Release,并在仓库根目录放 `lithe-plugin-index.json`,然后在“管理插件仓库…”里加入你的仓库地址,走一遍用户的真实安装流程。 +5. **发布。** 想让所有 Lithe 用户在插件市场看到,按 [Lithe Store 发布指南](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md) 收录;收录后新版本会被自动追踪。 + +需要运行、测试或构建工具集成时,再读 [构建、运行与测试接入指南](guides/build-tool-integration.md)。 + +#### 清单里的版本号 + +`plugin.json` 里有四处版本相关的字段,含义各不相同: + +| 字段 | 写什么 | 含义 | +| --- | --- | --- | +| `schemaVersion` | `0` | 清单的文件格式 | +| `pluginAPIVersion` | `0` | 插件 API 版本,Lithe 据此判断能不能读你的清单 | +| `requires` | 每个用到的能力写 `0` | 你的插件用了哪些能力;Lithe 只在支持这些能力时才提供安装 | +| `version` | 你自己定 | 你的插件自己的版本 | + +`requires` 必须**恰好**列出实际用到的能力:漏一个或多列一个,清单都会被拒绝。例如只用声明式构建系统时写 `"requires": { "buildSystems": 0 }`,不要多写。 + +能力以后可能出新版本。一次改动只给一个能力加一个新版本,没用到它的插件继续写 `0`,继续可以安装。每个能力的内容、Core 的支持表和 Lithe 桌面端是否声明它,见[能力与协议版本对照](reference/capabilities-and-versions.md#能力版本requires);Lithe 桌面端没有声明的能力,需要它的插件不会被提供安装。 + +### 在编辑器里校验 plugin.json + +契约里的两份 JSON Schema 地址固定: + +| 文件 | Schema | +| --- | --- | +| `plugin.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json` | +| `lithe-plugin-index.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json` | + +> **不要在 `plugin.json` 里写 `"$schema"`。** Lithe 会拒绝清单中的未知字段,加了以后插件反而装不上。请在编辑器里按文件名关联 Schema。 + +VS Code:在插件仓库的 `.vscode/settings.json` 中加入 + +```json +{ + "json.schemas": [ + { + "fileMatch": ["**/plugin.json"], + "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json" + }, + { + "fileMatch": ["**/lithe-plugin-index.json"], + "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json" + } + ] +} +``` + +JetBrains IDE:设置 → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings,添加上面的 URL,文件模式填 `plugin.json`。 + +Schema 只检查 JSON 结构。ID 引用、权限、激活事件、模板变量这类规则由 Lithe 在安装时检查,规则写在[插件契约](#插件契约)里。 + +### 插件后端 SDK(可选) + +只用语言服务器、运行和测试的插件不需要后端。需要构建后端(`buildProviders`)、插件函数,或给插件自己的网页配一个后端进程(`backend`)时,用官方 SDK 写:协议、取消、进程清理和错误处理由 SDK 负责,你只写处理函数。 + +| SDK | 版本 | 运行时 | Release | 怎么放进插件 | +| --- | --- | --- | --- | --- | +| [Node.js](sdk/node/README.md) | 0.5.0 | Node.js 20.3+ | [`sdk-node-v0.5.0`](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-node-v0.5.0):`lithe-plugin-sdk-node-0.5.0.tgz` 和 `.sha256` | 解压后把 `package/` 复制进插件包,用相对路径导入 | +| [Java](sdk/java/README.md) | 0.5.0 | Java 8+ | [`sdk-java-v0.5.0`](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-java-v0.5.0):`lithe-plugin-sdk-java-0.5.0.jar` 和 `.sha256` | shade 进后端 JAR | +| [Rust](sdk/rust/README.md) | 0.4.0 | Rust 1.87+ | [`sdk-rust-v0.4.0`](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-rust-v0.4.0):`lithe-plugin-sdk-rust-0.4.0.crate` 和 `.sha256` | 用 Git 依赖锁定标签(`Cargo.lock` 记录 commit),或解压后作为 `path` 依赖;编译成每个平台各一份的插件后端可执行文件 | + +Rust 的 Git 依赖这样写: + +```toml +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/Lithe-core-rust", tag = "sdk-rust-v0.4.0" } +``` + +SDK 的源码都在本目录的 `sdk/` 下,发布在 [Core 仓库的 Releases](https://github.com/Lithe-IDEA/Lithe-core-rust/releases) 里(标签 `sdk-node-v<版本>`、`sdk-java-v<版本>`、`sdk-rust-v<版本>`),不在 npm、Maven 或 crates.io 上。Rust SDK 只实现插件后端(`backend`):插件函数和宿主服务;构建后端请用 Node.js 或 Java SDK,VCS 后端请用 Node.js SDK。各 SDK 的 README 有 “What the Lithe desktop serves” 一节,说明 Lithe 桌面端提供哪些协议;各协议的版本见[进程协议版本](reference/capabilities-and-versions.md#进程协议版本)。以上三个版本说的是版本 `0` 的协议;已退役的 `lithe-plugin-docs` 仓库发布过更早的 SDK,它们说的是旧协议,Lithe 桌面端不接受用它们写的插件,请只使用 Core 仓库发布的版本。 + +规则只有一条:**在构建时下载,并按你锁定的 SHA-256(或 Git commit)校验;运行时不下载任何东西。** 插件包安装后没有 `npm install` 步骤,也必须能离线运行。 + +[Maven 插件](https://github.com/Lithe-IDEA/lithe-plugin-maven) 的 `sdk.lock.json` 是锁定 Java SDK 的现成例子。 + +### 示例插件 + +| 插件 | 示范什么 | +| --- | --- | +| [Lua](https://github.com/Lithe-IDEA/lithe-plugin-lua) | **模板**;随包附带语言服务器(LuaLS),运行当前文件 | +| [Go](https://github.com/Lithe-IDEA/lithe-plugin-go) | 使用用户自己安装的 gopls,运行 main 包和 `go test` | +| [PHP](https://github.com/Lithe-IDEA/lithe-plugin-php) | 使用用户自己安装的 Intelephense(许可证不允许随包分发),PHPUnit | +| [Gradle](https://github.com/Lithe-IDEA/lithe-plugin-gradle) | 纯声明式构建系统,不需要后端 | +| [Maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) | 用 Java SDK 写的构建后端 | +| [Cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) | 用 Node.js SDK 写的构建后端和插件设置 | +| [LLDB](https://github.com/Lithe-IDEA/lithe-plugin-lldb) | 调试适配器的参考实现;用 Xcode 自带的 `lldb-dap` 调试 C、C++、Swift 和 Rust,含 `executable` 查找和调试控制台补全 | + +示例插件在各自的仓库里维护。照着做之前,对照它们的 `plugin.json` 里的 `requires` 和[版本对照](reference/capabilities-and-versions.md#能力版本requires),确认 Lithe 桌面端声明了它们需要的能力。 + +### 教程 + +| 教程 | 内容 | +| --- | --- | +| [LSP 插件完整生命周期](guides/lsp-plugin-lifecycle.md) | 从 Lua 模板开始:清单、服务器打包、激活、状态目录、停止、升级与卸载 | +| [插件接入统一日志](guides/plugin-logging.md) | 三 SDK 的安全失败边界、受管诊断通道,以及 `${logDir}` 的本地会话生命周期 | +| [原生构建工作流](guides/native-build-workflows.md) | Node/Java SDK 的构建执行、精确测试、运行准备,以及用真实 Core 做端到端验证 | +| [构建、运行与测试接入](guides/build-tool-integration.md) | 执行提供者示例、工程后端边界,以及声明式构建系统的现状和已发布示例 | +| [编写调试适配器插件](guides/debug-adapter-plugin.md) | 从最小清单到启动方式、先编译再调试的 `executable`,以及调试控制台补全 | +| [插件后端:工作区上下文与宿主服务](guides/plugin-backend-workspace.md) | 免页面启动、工作区上下文、纯后端命令、文档/文件/窗口宿主方法、宿主通知、失败码,以及宿主服务只增不改时后端要遵守的规则 | +| [插件自有的原生终端](guides/native-terminals.md) | `terminal/*`:创建、显示、关闭、重开和退出通知(本机工作区) | + +收录和上架的规则由 Lithe Store 维护:[发布社区插件](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md)讲怎么打包、提交收录和发布新版本,[版本追踪与更新](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md)讲 Lithe 怎么选择可安装的版本。 + +### 插件参考 + +[`reference/`](reference/README.md) 按功能整理契约,中英双语: + +| 页面 | 内容 | +| --- | --- | +| [插件 API 总表](reference/api-catalog.md) | API-01–44 每一项现在能用的方法、尚未提供的部分和对应功能页 | +| [能力与协议版本对照](reference/capabilities-and-versions.md) | `requires` 能力版本、进程协议版本、文档格式版本,对照 Core 和 Lithe 桌面端支持哪些 | +| [插件协议更新记录](reference/protocol-changelog.md) | 每个协议、能力和 API 是哪天、哪个 PR 改的,以及每个版本首次出现在哪个 PR;只有中文 | +| [功能页](reference/README.md#功能页) | 15 个功能各一页:字段、宿主现状、契约章节、示例与用例 | + +### 插件契约 + +插件契约规定每个字段的含义、校验规则和错误码,完整内容在本目录的 [`contract/v1/`](contract/v1/README.md),以英文为准: + +| 文件 | 内容 | +| --- | --- | +| [`README.md`](contract/v1/README.md) | 字段、版本规则、能力、权限和错误码的完整说明 | +| [`plugin-manifest.schema.json`](contract/v1/plugin-manifest.schema.json) | `plugin.json` 的 JSON Schema | +| [`plugin-index.schema.json`](contract/v1/plugin-index.schema.json) | `lithe-plugin-index.json` 的 JSON Schema | +| [`native-ui.schema.json`](contract/v1/native-ui.schema.json) | `ui` 能力里宿主渲染的原生内容的 JSON Schema | +| [`toolchains/`](contract/v1/toolchains/README.md) | 插件提供的工具链:工具定义、版本管理器提供方、提供方后端协商(`litheToolchains`)、查询与回复校验(含稳定失败码)、固定值(pin)和四层解析。Core 实现这些命令;Lithe 桌面端声明 `toolDefinitions`,未声明 `toolchainProviders` | +| [`fixtures/`](contract/v1/fixtures) | 标准用例,按功能分目录(`manifest`、`languages`、`backend`、`build-systems`、`build-providers`、`marketplace` 等),和 [reference 功能页](reference/README.md#功能页)一一对应:合法和非法的清单、命令和构建规划、插件函数协议、市场索引,以及 Lithe 对每个用例的预期结果 | + +fixture 放在 `fixtures/<功能>/` 下,文件按演示的能力命名,例如 `build-systems/build-command-stdout.plugin.json` 演示“解码命令的标准输出”。它们借 Cargo、Go、Gradle、Maven 等真实工具作为输入,但不是这些插件本身;要参考完整插件,看[示例插件](#示例插件)。每个目录的文件和用例数量见对应功能页的“示例与用例”。 + +契约由 Lithe 的 Rust Core 统一执行。契约、指南、参考、官方 SDK、SDK 端到端测试和脚本都在 Core 仓库的 `shared/plugins/` 下,和执行契约的 Core 代码(仓库根的 `rust/`)在同一个 PR 里修改,所以每个版本的契约、SDK 和 Core 彼此一致,不存在需要同步的副本。本目录以 Core 仓库 `main` 为准;`main` 上的内容可能还没进入已发布的 Lithe 版本,发布插件前以目标版本实际支持为准。 + +对契约有疑问或建议,到 [Core 仓库提 issue](https://github.com/Lithe-IDEA/Lithe-core-rust/issues)。 + +### 贡献与维护 + +修改契约、指南、参考或 SDK 的 PR 提到 [Core 仓库](https://github.com/Lithe-IDEA/Lithe-core-rust),和对应的 Rust Core 改动放在同一个 PR 里。下面的命令都在 **Core 仓库的根目录**运行;CI 工作流是仓库根的 `.github/workflows/plugins-verify.yml`,每次涉及 `shared/plugins/` 或 `rust/` 的提交都会运行它们。 + +| 命令 | 作用 | +| --- | --- | +| `node shared/plugins/scripts/verify-docs.mjs` | 离线检查:每份 Schema 的 `$id` 等于它的发布地址 `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/<文件>`、Markdown 相对链接都能解析、没有指向已退役的 `lithe-plugin-docs` 仓库的链接 | +| `node --test shared/plugins/scripts/*.test.mjs` | 脚本自身的测试 | + +改契约、能力版本或插件函数协议时,同一个 PR 还要更新[插件协议更新记录](reference/protocol-changelog.md),以及 [`reference/`](reference/README.md) 里受影响的功能页和版本表:它们是手写的对照,不会自动更新。更新记录的做法见该页的“维护”一节。 + +#### SDK + +| 命令 | 作用 | +| --- | --- | +| `node --test shared/plugins/sdk/node/test/*.test.mjs` | Node.js SDK 的单元测试 | +| `npm run --prefix shared/plugins/sdk/node test:types` | Node.js SDK 的 TypeScript 使用方检查;先运行 `npm ci --prefix shared/plugins/sdk/node --ignore-scripts` 安装开发依赖 | +| `mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify` | Java SDK 的单元测试;加 `-Djvm=` 可指定运行测试的 Java(CI 用 Java 8) | +| `cargo test --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml` | Rust SDK 的单元测试(CI 用 Rust 1.87,另用 stable 跑 `cargo fmt` 和 `cargo clippy`) | +| `node shared/plugins/scripts/sdk-e2e.mjs` | 端到端测试:先构建三个 SDK 的示例后端,再运行 `cargo test -p lithe-core --features plugin-sdk-e2e` 的 `plugin_sdk_node`、`plugin_sdk_java`、`plugin_sdk_rust` 和 `plugin_sdk_build` 四个测试,对象是**当前工作树的 Core**;总是包含原生构建工作流和快捷键导入场景 | + +端到端测试需要 Rust、Node.js 20.3+、Java(`LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定,或 `PATH` 上的 `java`)和 Maven。场景文件在 [`sdk/e2e/`](sdk/e2e):Node.js 和 Java 跑同一套构建后端场景,Rust 跑插件后端场景(`plugin_sdk/backend.rs`),测试代替宿主应答凭据请求。`LITHE_PLUGIN_SDK_JAVA_SAMPLE`、`LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE` 和 `LITHE_PLUGIN_SDK_RUST_SAMPLE` 可以指定已构建好的示例,跳过构建。 + +发版:先改版本号(Node.js 改 `shared/plugins/sdk/node/package.json`;Java 运行 `mvn -f shared/plugins/sdk/java/pom.xml versions:set -DnewVersion=<版本> -DgenerateBackupPoms=false`,三个 pom 一起改;Rust 改 `shared/plugins/sdk/rust/Cargo.toml` 的 `workspace.package.version` 并更新 `Cargo.lock`),合并后推送对应标签 `sdk-node-v<版本>`、`sdk-java-v<版本>` 或 `sdk-rust-v<版本>`。仓库根的发版工作流 `.github/workflows/plugins-release-{node,java,rust}-sdk.yml` 由标签触发,检查标签和版本一致,测试通过后把包和 `.sha256` 发到 Core 仓库的 Releases。发版后更新本页和各 SDK README 里的版本号。 + +### 许可证 + +[Apache-2.0](LICENSE) + +--- + +## English + +Writing, testing, and publishing a plugin all happen in **your own repository**. You never need to clone the [Lithe](https://github.com/1lck/Lithe-IDEA) repository or send it a pull request. Everything a plugin developer needs is in this directory: the contract, guides, reference, and official SDKs all live under `shared/plugins/` in the [Lithe Core repository](https://github.com/Lithe-IDEA/Lithe-core-rust). + +| To | Go to | +| --- | --- | +| See what a plugin looks like | [What a plugin is](#what-a-plugin-is) | +| Build your first plugin | [Your first plugin in five steps](#your-first-plugin-in-five-steps) | +| Follow a complete walkthrough | [Guides](#guides) | +| Get completion and validation while writing `plugin.json` | [Validate in your editor](#validate-pluginjson-in-your-editor) | +| Run a backend process of your own | [Plugin backend SDKs](#plugin-backend-sdks-optional) | +| Connect to host logging and diagnose plugin failures | [Plugin logging](guides/plugin-logging.md) | +| Learn from existing plugins | [Example plugins](#example-plugins) | +| See what API-01–44 offer today, the version of each capability and protocol, and what Core and the Lithe desktop client support | [Plugin reference](reference/README.md) | +| Look up the exact meaning of a field | [Plugin contract](#plugin-contract) | +| Change the contract, guides, or SDKs and verify locally | [Contributing and maintenance](#contributing-and-maintenance) | + +### What a plugin is + +A Lithe plugin is a directory: `plugin.json` declares what the plugin provides, and the other files are the resources it uses, such as language servers, scripts, and icons. + +```text +io.github..zig-support/ +├── plugin.json +└── … # resources referenced by plugin.json +``` + +Lithe reads and validates `plugin.json` and then starts the external processes it declares itself. Plugin code is never loaded into the Lithe process, so the same package does not need rebuilding for each Lithe version. + +Community plugins must follow these rules: + +- `id` uses your own reverse domain name, such as `io.github..zig-support`. `dev.lithe` and `dev.lithe.*` are reserved for official plugins. +- `vendor.signatureRequirement` is `"none"`. Community plugins carry no Lithe signature; before installing, users see and confirm the complete programs, permissions, and source. +- Package paths use only `/`, with no `..`, absolute paths, or symbolic links. +- An installed package is read-only. Caches and state go to `${stateDir}` and `${workspaceStateDir}`, which Lithe passes through launch templates; diagnostic logs go through the shared SDK and host path. Use `${logDir}` only when you really need native file logs; a host without a local log directory rejects such launches. See [Plugin logging](guides/plugin-logging.md). + +### Your first plugin in five steps + +1. **Copy the template.** Start from the [Lua plugin](https://github.com/Lithe-IDEA/lithe-plugin-lua), Lithe Store's example and template. +2. **Edit `plugin.json`.** Use your own ID, name, language, and language server, and write the version numbers and `requires` by the rules [below](#version-numbers-in-the-manifest). Set up your editor as in the [section after that](#validate-pluginjson-in-your-editor) first, so mistyped fields are flagged as you write. +3. **Prepare every file at build time.** Language servers and backends go into the package when you build it; nothing is downloaded at run time. See the [LSP plugin lifecycle guide](guides/lsp-plugin-lifecycle.md). +4. **Try it in Lithe.** During development, use "Install Plugin from Disk…" with a zip or directory (you still confirm the install review; Lithe installs a snapshot, so reinstall after each change). Before publishing, create a Release in your repository, put `lithe-plugin-index.json` at the repository root, and add your repository under "Manage Plugin Repositories…" to walk through the real user install flow. +5. **Publish.** To list the plugin in every Lithe user's marketplace, follow the [Lithe Store publishing guide](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md); new versions are tracked automatically once listed. + +When you need run, test, or build tool integration, read the [build, run, and test integration guide](guides/build-tool-integration.md). + +#### Version numbers in the manifest + +`plugin.json` has four version-related fields, each with its own meaning: + +| Field | Write | Meaning | +| --- | --- | --- | +| `schemaVersion` | `0` | The manifest file format | +| `pluginAPIVersion` | `0` | The plugin API version; Lithe uses it to decide whether it can read your manifest | +| `requires` | `0` for each capability you use | Which capabilities your plugin uses; Lithe offers the plugin for installation only where it supports them | +| `version` | Your choice | Your plugin's own version | + +`requires` must list **exactly** the capabilities you use: leaving one out or listing one extra gets the manifest rejected. For example, a plugin that only uses declarative build systems writes `"requires": { "buildSystems": 0 }` and nothing more. + +A capability may get a new version later. One change adds a new version to exactly one capability, and a plugin that does not use it keeps writing `0` and stays installable. For what each capability covers, Core's support table, and whether the Lithe desktop client declares it, see [Capability and protocol versions](reference/capabilities-and-versions.md#capability-versions-requires); a plugin that needs a capability the Lithe desktop client does not declare is not offered for installation there. + +### Validate plugin.json in your editor + +The contract's two JSON Schemas have fixed addresses: + +| File | Schema | +| --- | --- | +| `plugin.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json` | +| `lithe-plugin-index.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json` | + +> **Do not put `"$schema"` in `plugin.json`.** Lithe refuses unknown manifest fields, so adding it makes the plugin uninstallable. Associate the schema by file name in your editor instead. + +VS Code: add this to `.vscode/settings.json` in your plugin repository: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["**/plugin.json"], + "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json" + }, + { + "fileMatch": ["**/lithe-plugin-index.json"], + "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json" + } + ] +} +``` + +JetBrains IDEs: Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings; add the URL above with the file pattern `plugin.json`. + +A schema checks only JSON structure. Rules such as ID references, permissions, activation events, and template variables are checked by Lithe at install time and are written in the [plugin contract](#plugin-contract). + +### Plugin backend SDKs (optional) + +A plugin that uses only language servers, run, and test needs no backend. When you need a build backend (`buildProviders`), plugin functions, or a backend process for the plugin's own web page (`backend`), write it with an official SDK: the SDK handles the protocol, cancellation, process cleanup, and errors, and you write only the handlers. + +| SDK | Version | Runtime | Release | How it goes into the plugin | +| --- | --- | --- | --- | --- | +| [Node.js](sdk/node/README.md) | 0.5.0 | Node.js 20.3+ | [`sdk-node-v0.5.0`](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-node-v0.5.0): `lithe-plugin-sdk-node-0.5.0.tgz` and `.sha256` | Unpack it, copy `package/` into the plugin package, and import it by relative path | +| [Java](sdk/java/README.md) | 0.5.0 | Java 8+ | [`sdk-java-v0.5.0`](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-java-v0.5.0): `lithe-plugin-sdk-java-0.5.0.jar` and `.sha256` | Shade it into the backend JAR | +| [Rust](sdk/rust/README.md) | 0.4.0 | Rust 1.87+ | [`sdk-rust-v0.4.0`](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-rust-v0.4.0): `lithe-plugin-sdk-rust-0.4.0.crate` and `.sha256` | Pin the tag as a Git dependency (`Cargo.lock` records the commit), or unpack it as a `path` dependency; compile one backend executable per platform | + +The Rust Git dependency looks like this: + +```toml +lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/Lithe-core-rust", tag = "sdk-rust-v0.4.0" } +``` + +All SDK sources live under `sdk/` in this directory and are published in the [Core repository's Releases](https://github.com/Lithe-IDEA/Lithe-core-rust/releases) (tags `sdk-node-v`, `sdk-java-v`, `sdk-rust-v`), not on npm, Maven, or crates.io. The Rust SDK implements only the plugin backend (`backend`): plugin functions and host services; write build backends with the Node.js or Java SDK and VCS backends with the Node.js SDK. Each SDK's README has a "What the Lithe desktop serves" section naming the protocols the Lithe desktop client provides; the version of each protocol is in [Process protocol versions](reference/capabilities-and-versions.md#process-protocol-versions). The three versions above speak the version `0` protocols; the retired `lithe-plugin-docs` repository published earlier SDKs that speak an older protocol, and the Lithe desktop client does not accept plugins written with them, so use only versions released from the Core repository. + +There is one rule: **download at build time and verify against the SHA-256 (or Git commit) you pinned; download nothing at run time.** An installed package has no `npm install` step and must run offline. + +The [Maven plugin](https://github.com/Lithe-IDEA/lithe-plugin-maven)'s `sdk.lock.json` is a ready example of pinning the Java SDK. + +### Example plugins + +| Plugin | Demonstrates | +| --- | --- | +| [Lua](https://github.com/Lithe-IDEA/lithe-plugin-lua) | **Template**; a packaged language server (LuaLS) and running the current file | +| [Go](https://github.com/Lithe-IDEA/lithe-plugin-go) | The user's own gopls installation, running the main package and `go test` | +| [PHP](https://github.com/Lithe-IDEA/lithe-plugin-php) | The user's own Intelephense installation (its license forbids bundling), PHPUnit | +| [Gradle](https://github.com/Lithe-IDEA/lithe-plugin-gradle) | A purely declarative build system with no backend | +| [Maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) | A build backend written with the Java SDK | +| [Cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) | A build backend and plugin settings written with the Node.js SDK | +| [LLDB](https://github.com/Lithe-IDEA/lithe-plugin-lldb) | Reference debug adapter; debugs C, C++, Swift, and Rust with Xcode's `lldb-dap`, including `executable` lookup and debug console completion | + +The example plugins are maintained in their own repositories. Before copying one, compare the `requires` in its `plugin.json` with the [version page](reference/capabilities-and-versions.md#capability-versions-requires) to confirm the Lithe desktop client declares the capabilities it needs. + +### Guides + +| Guide | Covers | +| --- | --- | +| [LSP plugin lifecycle](guides/lsp-plugin-lifecycle.md) | Starting from the Lua template: manifest, server packaging, activation, state directories, shutdown, upgrade, and uninstall | +| [Plugin logging](guides/plugin-logging.md) | The safe failure boundary of the three SDKs, the managed diagnostics channel, and the local session lifecycle of `${logDir}` | +| [Native build workflows](guides/native-build-workflows.md) | Build execution, exact tests, and run preparation in the Node and Java SDKs, and end-to-end verification against a real Core | +| [Build, run, and test integration](guides/build-tool-integration.md) | Execution provider examples, the project backend boundary, and the current status and published examples of declarative build systems | +| [Writing a debug adapter plugin](guides/debug-adapter-plugin.md) | From a minimal manifest to launch modes, compile-then-debug `executable`, and debug console completion | +| [Plugin backend: workspace contexts and host services](guides/plugin-backend-workspace.md) | Launching without a page, workspace contexts, backend-only commands, document, file, and window host methods, host notifications, failure codes, and the rules a backend follows while host services only grow | +| [Plugin-owned native terminals](guides/native-terminals.md) | `terminal/*`: create, show, close, reopen, and exit notifications (local workspaces) | + +Lithe Store maintains the listing rules: [Publishing community plugins](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md) covers packaging, submitting a listing, and releasing new versions, and [Version tracking and updates](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md) covers how Lithe chooses an installable version. + +### Plugin reference + +[`reference/`](reference/README.md) organizes the contract by feature, in Chinese and English: + +| Page | Covers | +| --- | --- | +| [Plugin API catalog](reference/api-catalog.md) | For each of API-01–44, the methods usable today, what is still missing, and its feature page | +| [Capability and protocol versions](reference/capabilities-and-versions.md) | `requires` capability versions, process protocol versions, and document format versions, compared across Core and the Lithe desktop client | +| [Plugin protocol changelog](reference/protocol-changelog.md) | When, and in which PR, each protocol, capability, and API changed, and the PR that introduced each version; Chinese only | +| [Feature pages](reference/README.md#feature-pages) | One page for each of 15 features: fields, host status, contract sections, examples and cases | + +### Plugin contract + +The plugin contract defines the meaning, validation rules, and error codes of every field. It lives in [`contract/v1/`](contract/v1/README.md) of this directory, and its English text is normative: + +| File | Contents | +| --- | --- | +| [`README.md`](contract/v1/README.md) | Complete description of fields, version rules, capabilities, permissions, and error codes | +| [`plugin-manifest.schema.json`](contract/v1/plugin-manifest.schema.json) | JSON Schema for `plugin.json` | +| [`plugin-index.schema.json`](contract/v1/plugin-index.schema.json) | JSON Schema for `lithe-plugin-index.json` | +| [`native-ui.schema.json`](contract/v1/native-ui.schema.json) | JSON Schema for the host-rendered native content of the `ui` capability | +| [`toolchains/`](contract/v1/toolchains/README.md) | Plugin-owned toolchains: tool definitions, version manager providers, provider backend negotiation (`litheToolchains`), query and reply validation (with stable failure codes), pins, and four-layer resolution. Core implements these commands; the Lithe desktop client declares `toolDefinitions` and does not declare `toolchainProviders` | +| [`fixtures/`](contract/v1/fixtures) | Conformance cases in one directory per feature (`manifest`, `languages`, `backend`, `build-systems`, `build-providers`, `marketplace`, and so on), matching the [reference feature pages](reference/README.md#feature-pages): valid and invalid manifests, command and build plans, plugin function protocol, marketplace indexes, and Lithe's expected result for each case | + +Fixtures live under `fixtures//` and are named for the capability they demonstrate; for example `build-systems/build-command-stdout.plugin.json` demonstrates decoding a command's standard output. They use real tools such as Cargo, Go, Gradle, and Maven as inputs but are not those plugins; for complete plugins, see the [example plugins](#example-plugins). Each feature page's "Examples and cases" section lists its files and case counts. + +Lithe's Rust Core enforces the contract. The contract, guides, reference, official SDKs, SDK end-to-end tests, and scripts all live under `shared/plugins/` in the Core repository and change in the same PR as the Core code that enforces the contract (`rust/` at the repository root), so the contract, SDKs, and Core always agree and there is no copy to synchronize. This directory follows the Core repository's `main`; what is on `main` may not be in a released Lithe version yet, so check what your target version supports before publishing. + +For questions or suggestions about the contract, [open an issue in the Core repository](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +### Contributing and maintenance + +A PR that changes the contract, guides, reference, or SDKs goes to the [Core repository](https://github.com/Lithe-IDEA/Lithe-core-rust), in the same PR as the matching Rust Core change. Run the commands below at the **root of the Core repository**; CI runs them from `.github/workflows/plugins-verify.yml` at the repository root on every change that touches `shared/plugins/` or `rust/`. + +| Command | Purpose | +| --- | --- | +| `node shared/plugins/scripts/verify-docs.mjs` | Offline check: each schema's `$id` equals its published address `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/`, Markdown relative links resolve, and no link points to the retired `lithe-plugin-docs` repository | +| `node --test shared/plugins/scripts/*.test.mjs` | Tests for the scripts themselves | + +When a change touches the contract, a capability version, or the plugin function protocol, the same PR also updates the [plugin protocol changelog](reference/protocol-changelog.md) and the affected feature pages and version tables in [`reference/`](reference/README.md): they are hand-written comparisons and do not update on their own. The changelog's "Maintenance" section describes how. + +#### SDKs + +| Command | Purpose | +| --- | --- | +| `node --test shared/plugins/sdk/node/test/*.test.mjs` | Node.js SDK unit tests | +| `npm run --prefix shared/plugins/sdk/node test:types` | Node.js SDK TypeScript consumer check; first run `npm ci --prefix shared/plugins/sdk/node --ignore-scripts` to install the development dependencies | +| `mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify` | Java SDK unit tests; add `-Djvm=` to choose the Java that runs them (CI uses Java 8) | +| `cargo test --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml` | Rust SDK unit tests (CI uses Rust 1.87, plus stable for `cargo fmt` and `cargo clippy`) | +| `node shared/plugins/scripts/sdk-e2e.mjs` | End-to-end tests: builds the sample backends of the three SDKs, then runs the four tests `plugin_sdk_node`, `plugin_sdk_java`, `plugin_sdk_rust`, and `plugin_sdk_build` of `cargo test -p lithe-core --features plugin-sdk-e2e` against the **Core in the current working tree**; it always includes the native build workflow and shortcut import scenarios | + +The end-to-end tests need Rust, Node.js 20.3+, Java (set `LITHE_PLUGIN_SDK_JAVA_RUNTIME`, or have `java` on `PATH`), and Maven. The scenario files are in [`sdk/e2e/`](sdk/e2e): Node.js and Java run the same build backend scenarios, Rust runs the plugin backend scenario (`plugin_sdk/backend.rs`), and the test answers credential requests in place of the host. `LITHE_PLUGIN_SDK_JAVA_SAMPLE`, `LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE`, and `LITHE_PLUGIN_SDK_RUST_SAMPLE` can point at already built samples to skip building them. + +Releasing: first bump the version (Node.js in `shared/plugins/sdk/node/package.json`; for Java run `mvn -f shared/plugins/sdk/java/pom.xml versions:set -DnewVersion= -DgenerateBackupPoms=false`, which updates all three poms; for Rust change `workspace.package.version` in `shared/plugins/sdk/rust/Cargo.toml` and update `Cargo.lock`), then after merging push the tag `sdk-node-v`, `sdk-java-v`, or `sdk-rust-v`. The release workflows `.github/workflows/plugins-release-{node,java,rust}-sdk.yml` at the repository root start from the tag, check that it matches the version, and, once tests pass, publish the package and its `.sha256` to the Core repository's Releases. After a release, update the version numbers in this page and in each SDK README. + +### License + +[Apache-2.0](LICENSE) diff --git a/shared/plugins/contract/v1/README.md b/shared/plugins/contract/v1/README.md index f715cf57..177f83f4 100644 --- a/shared/plugins/contract/v1/README.md +++ b/shared/plugins/contract/v1/README.md @@ -15,7 +15,7 @@ This directory is the cross-platform source of truth: | `native-ui.schema.json` | JSON shape of native UI documents rendered by the host | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/` | Valid example manifests and golden cases, one folder per contract area (below). Manifests are named after the capability they exercise rather than a published plugin. Case files name their base manifest by file name, so file names are unique across the folders | -| `toolchains/README.md` | Implemented Core foundation: plugin definitions/provider declarations, registry (a short tool ID defined by several enabled plugins belongs to none of them), reply validation, directory pins, snapshot resolution, version matching and discovery plans; macOS uses it for its read-only settings pages and for Build and run tools, Windows is not connected | +| `toolchains/README.md` | Implemented Core foundation: plugin definitions/provider declarations, registry (a short tool ID defined by several enabled plugins belongs to none of them), reply validation, directory pins, snapshot resolution, version matching and discovery plans; hosts that declare `toolDefinitions` use it for read-only settings pages and for Build and run tools | | Fixture folder | Contents | | --- | --- | @@ -25,14 +25,14 @@ This directory is the cross-platform source of truth: | `fixtures/editor/` | Editor actions served by a backend: `editor-actions.plugin.json`, icons, alternatives and click results in `editor-actions.json`; editor providers in `editor-providers.plugin.json` with registration and result cases in `editor-providers.json` | | `fixtures/ui/` | Native controls, forms, and panels, and a web panel | | `fixtures/settings/` | Plugin settings | -| `fixtures/build-systems/` | Build systems (`build-*`: static file, command output, host integration, instance options, model producer and consumer, tool path, toolchains); `build-plans.json` holds golden build-system plans and decoded outputs | +| `fixtures/build-systems/` | Build systems (`build-*`: static file, command output, instance options, model producer and consumer, tool path, toolchains); `build-plans.json` holds golden build-system plans and decoded outputs | | `fixtures/build-providers/` | Build providers (`build-provider*`) and the golden cases of their configuration, run options, run and debug preparation, execution, and test protocols | | `fixtures/debug-adapters/` | Debug adapters (standalone, served by a language server, remote attach, and `debug-adapter-features` with declared features and defaults), `debug-adapter-rejections.json`, and `debug-start.json`: what `debug.decodeBackendConfiguration` returns for each `debug/start` case | | `fixtures/run/` | Run sessions and console links: `tool-reference-launch` names its runtimes by tool-definition ID and declares output link matchers; `run-start.json` holds what `run.decodeStart` returns for each case, including the rejections, and `output-link-matchers.json` the golden matches and resolutions | | `fixtures/vcs/` | A version control provider | | `fixtures/keymap-import/` | Two independent shortcut import providers sharing one packaged backend | | `fixtures/project-generators/` | Project generators: template files (`templates/`), a scaffolding command, and an archive download; `project-generator-tool-field.plugin.json` with `templates/tool-field/` picks tool installations with `tool` fields; `project-plans.json` holds golden `project.*` form evaluations, generation plans, and name checks, and a case's optional `manifest` names the fixture it runs against (`project-generator.plugin.json` by default) | -| `fixtures/toolchains/` | Production-shape fixtures for tool definitions and a packaged toolchain provider; they require `toolDefinitions` and `toolchainProviders`, which only macOS advertises | +| `fixtures/toolchains/` | Production-shape fixtures for tool definitions and a packaged toolchain provider; they require `toolDefinitions` and `toolchainProviders`, which a host advertises only when it implements them | | `fixtures/package-trust/` | `publisher-bundled.plugin.json` and `signature-vectors.json`: public cross-platform vectors for the package publisher signature: the RFC 8032 test key, a fixed package with non-ASCII file names, its canonical payload, RFC 8032 deterministic signature, and content digest. Both platforms must build the same canonical payload and digest and accept the recorded signature; signers that add randomness, such as CryptoKit, check the signature by verification instead of byte comparison | | `fixtures/marketplace/` | Marketplace index, availability, source URL, and install review golden cases | @@ -42,14 +42,16 @@ Validation and template expansion are implemented once in Rust Core `plugins.declarative.*` commands in [`rust/contracts/rust-core-api.md`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md). Hosts must not reimplement them. -Plugin authors do not need this repository. The public -[`Lithe-IDEA/lithe-plugin-docs`](https://github.com/Lithe-IDEA/lithe-plugin-docs) -repository holds the developer guide and a byte-identical copy of this whole -directory under `contract/v1/`, pinned to a revision of this repository; each -schema's `$id` is its URL there. Contract changes land here first and must then -be synced to that repository, so links in this file that leave this directory -are absolute. `plugin.json` may not carry a `$schema` key, because unknown -fields are rejected; editors associate the schema by file name instead. +This directory is the only copy of the contract. It lives in `shared/plugins/` +of the Rust Core repository together with the +[developer guide](../../README.md) and the official SDKs, and changes in the +same pull request as the Core code that enforces it. Plugin authors read it +here and never need the Lithe application repository. Each schema's `$id` is +its published URL, +`https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/`; +`node shared/plugins/scripts/verify-docs.mjs` checks the IDs and the relative +links. `plugin.json` may not carry a `$schema` key, because unknown fields are +rejected; editors associate the schema by file name instead. ## Plugin-owned toolchains (Core foundation) @@ -68,12 +70,12 @@ plugin-owned. Any plugin may define a short ID such as `compiler` or a qualified by more than one enabled plugin belongs to none of them: the registry omits those definitions and reports the contest as a conflict. -macOS advertises these capabilities for its read-only toolchain settings pages. +A host that advertises these capabilities uses them for its read-only toolchain settings pages. The native adapter scans declarations, probes checked executables, queries plugin backends and watches their inputs; Core consumes captured facts and owns validation and resolution. A separate fresh resolution serves Build tools and run plans whose `toolchain` executable names a plugin-defined tool rather than a host -toolchain; settings detections never grant launch authority. Windows does not advertise the capabilities. Saving pins, +toolchain; settings detections never grant launch authority. Saving pins, `toolchainConsumers`, generic runtime launches and live backend activation remain unsupported, and Java and language-server consumers keep their previous adapters. Version-manager implementations such as mise belong to their own plugin packages. See @@ -137,9 +139,8 @@ and provider, revokes them when the import or session ends, and rejects files replaced or changed after capture. A new snapshot for that provider revokes the previous one. Errors never disclose absolute paths. -macOS advertises `keymapImportProviders` 0 and implements the scoped reader. -Windows has no import source reader and does not advertise the capability; a -host advertises it only with the complete import workflow. The official Node, +A host that advertises `keymapImportProviders` 0 implements the scoped reader. +A host advertises the capability only with the complete import workflow. The official Node, Java and Rust SDKs implement `litheKeymapImport` 0. ## Package layout @@ -175,10 +176,10 @@ symbolic links. A host treats an installed package as read-only. | `settings` | User-adjustable plugin options. | | `ui` | Host-rendered tool windows and commands. | | `buildSystems` | Project-level build tools. | -| `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains/README.md)); requires `toolDefinitions: 0`; macOS hosts read-only discovery and resolves Build and run tools. | +| `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains/README.md)); requires `toolDefinitions: 0`; a host may offer read-only discovery and resolve Build and run tools. | | `toolchainProviders` | Backend fact-provider declarations ([Core foundation](toolchains/README.md)); requires `toolchainProviders: 0`, `backend: 0` and `toolchains.provide`. | -| `keymapImportProviders` | Independent shortcut import providers; requires `keymapImportProviders: 0`, `backend: 0` and `keymaps.provide`. macOS provides access to explicitly selected sources; Windows does not yet advertise this capability. | -| `pluginFunctions` | [Functions the plugin exports to, and calls from, other plugins](#cross-plugin-calls); requires `pluginFunctions: 0` and `backend: 0`, and grants no permission. macOS relays the calls; Windows does not advertise this capability. | +| `keymapImportProviders` | Independent shortcut import providers; requires `keymapImportProviders: 0`, `backend: 0` and `keymaps.provide`. A host provides access to explicitly selected sources. | +| `pluginFunctions` | [Functions the plugin exports to, and calls from, other plugins](#cross-plugin-calls); requires `pluginFunctions: 0` and `backend: 0`, and grants no permission. A host that advertises this capability relays the calls. | Unknown fields are refused (`plugin.manifest.invalid`) so a host rejects a package instead of ignoring part of it. A new optional field in an existing @@ -974,7 +975,7 @@ declaring one: `resolveParams` and the workspace `context`. A host may also send `tools`, `{: {executable, home, version}}`, the resolution of each tool the manifest lists in `requiresTools` that it could find; Core does not - validate it. The macOS host resolves each required tool as an installation + validate it. The host resolves each required tool as an installation with the user's choice and discovery, and leaves out a tool that is missing, unusable, or inside the project; `home` and `version` are present when the installation has them. A function must not require `tools` or any @@ -1021,7 +1022,7 @@ directory, `plugin.launch.unavailable` for a language without a server, and `plugin.launch.notBackend`. See `fixtures/languages/language-server-backend-launch.plugin.json` and `fixtures/languages/language-server-launch-resolution.json`. -Host limits of the macOS host today: a `{kind: "tool"}` answer is resolved +Limits the Lithe desktop host applies today: a `{kind: "tool"}` answer is resolved with the user's tool choice and discovery, and a program that lies inside the project is refused, because opening a project must not run it; a server whose launch the backend resolves does not start in a remote workspace; and the @@ -1103,7 +1104,7 @@ See [`build-tool-path.plugin.json`](fixtures/build-systems/build-tool-path.plugi A candidate may use `{ "kind": "toolchain", "toolchainID": "org.example.compiler:compiler" }`. Core accepts the same short/qualified ID grammar as tool definitions and adds the -normalized instance `root`. The macOS adapter resolves installed definitions +normalized instance `root`. The host adapter resolves installed definitions against fresh native evidence and directory pins. Unavailable selections are terminal, including when a prerequisite runtime fails first; they cannot silently fall back to Provider discovery. @@ -1196,7 +1197,7 @@ declaration gets no bundled exemption. The valid example is A host pins the signature requirement of its official plugin IDs so a package cannot downgrade itself. Selecting a file from disk never overrides that pin, namespace restrictions, publisher-key checks, or the installation seal required -by `sameTeamAsHost`. On macOS only DEBUG permits an unsigned publisher package +by `sameTeamAsHost`. A development build may permit an unsigned publisher package as `localDevelopment`; release builds refuse that origin. Community `none` packages use their own approval path in both build configurations and must not be reclassified as development packages. @@ -1319,8 +1320,8 @@ entries also carry `initializationOptions` when declared. Hosts show all `permissions` and the complete `programs`, and persist each approved program as canonical JSON without dropping fields. A display may use readable labels but must retain all canonical content, including a full JSON fallback for -program kinds it cannot describe completely. On macOS, recognized kinds keep -their readable summary and offer a native “Full Program Declaration” disclosure +program kinds it cannot describe completely. Recognized kinds may keep +their readable summary and offer a “Full Program Declaration” disclosure with the complete canonical JSON; unknown kinds already render that JSON and do not need a duplicate disclosure. @@ -1337,10 +1338,10 @@ When several marketplace sources list the same ID, the user chooses the source at install time and later updates come only from that source. Local disk provenance is never a marketplace source or an automatic update subscription. -### Local disk snapshots and installation provenance (macOS) +### Local disk snapshots and installation provenance -The macOS disk entry accepts a ZIP or an unpacked package directory. It does -not run code from the selection. `MacPluginDiskPackageImporter` copies into a +A host's disk entry accepts a ZIP or an unpacked package directory. It does +not run code from the selection. The host's importer copies into a fresh private directory (mode `0700`) before declaration validation, trust checks, review, or installation. It refuses aliases, symbolic links, special files, ambiguous manifests, and detected source changes during copying, and @@ -1349,7 +1350,7 @@ copy depth is limited to 64, and its temporary root must stay outside the selected source. Before extraction, ZIP preflight checks central and local header paths/types and rejects duplicate or colliding paths. -`MacServiceContainer` injects `fileStorage.temporaryDirectory()`. Within that +The host injects `fileStorage.temporaryDirectory()`. Within that platform-owned writable root the importer creates `lithe-plugin-disk-/archive.zip` for a ZIP and `package/` for copied or extracted files. `archiveSHA256` is computed from the copied ZIP, not by reading @@ -1396,19 +1397,8 @@ They are excluded as `plugin-disk-install-state`. No runtime write targets the signed app bundle or Windows installation resources, so these paths do not change code signatures or Sparkle differential-update baselines. -Windows has a narrower disk import. It copies a ZIP or unpacked directory into -a private snapshot under the application's local data directory -(`plugin-temp/lithe-plugin-disk-`), reviews it with -`plugins.marketplace.installReview`, and installs only after the user confirms. -Confirmation re-runs the review and refuses a snapshot whose `plugin.json` -changed after review. It does not verify publisher signatures, does not compute -or recheck a digest of the other package files, and does not record approved -programs or provenance; its installation record holds only the ID, active -version and enabled state. It has no marketplace install path. Its existing -worker-package import is a separate format. The feature matrix -therefore keeps Windows `partial` and macOS runtime verification `pending`; -static checks and Node resource tests alone do not verify the UI confirmation, -complete program rendering, cleanup, or bundle hashes in a real workflow. +Static checks alone do not verify the UI confirmation, complete program rendering, +cleanup, or bundle hashes in a real workflow. ## Build systems @@ -1418,12 +1408,12 @@ commands: Core validates the declaration, detects instances, decodes the model, and expands every command line; the host only runs the processes Core planned and passes their output back. A community plugin therefore needs no host change to add a build tool, and no host code branches on a build-system -ID. One complete fixture covers each model source: `build-static-file` reads -a project file, `build-command-stdout` decodes a command's standard output, -`build-command-output-file` decodes a file the command writes, and -`build-host-integration` hands the model to a host integration. They use -Cargo, Go, Gradle, and Maven as realistic inputs; they are not those -plugins. `build-provider` and `build-provider-language-server` show the two +ID. One complete fixture covers each plugin-described model source: +`build-static-file` reads a project file, `build-command-stdout` decodes a +command's standard output, and `build-command-output-file` decodes a file the +command writes; the `hostIntegration` source, whose model a host integration +supplies, has no fixture. They use Cargo, Go, and Gradle as realistic inputs; +they are not those plugins. `build-provider` and `build-provider-language-server` show the two forms of a build provider, and `fixtures/build-systems/build-plans.json` is the golden list of plans and decoded outputs every host gets from them. @@ -1459,7 +1449,7 @@ resetting `a` cannot change `b`, even when both accept the same choices. Explici matching scope before detection, static run generation, model/dependency/task requests, synchronization fingerprints, and language import. Instance data cannot override workspace options, and workspace values cannot silently override an -instance option. When an existing option moves to instance scope, macOS copies its shared value +instance option. When an existing option moves to instance scope, the host copies its shared value to each detected or previously stored root, preserving explicit instance values and empty markers. It removes the shared representation only after readback, then redetects static models. Failure retains the source and blocks publishing defaults @@ -1468,7 +1458,7 @@ until retry; scope is not a fallback chain. See `fixtures/build-systems/build-in ### Presentation Each enabled build system gets a tool window of its own, titled with its -`displayName`; `icon` (a [UI icon](#ui-icons-macos-host)) is its activity-bar +`displayName`; `icon` (a [UI icon](#ui-icons)) is its activity-bar icon. As for every tool window, the host owns the shell: dock, order, visibility, layout, and lifecycle. The host draws the window body from the model, and three optional fields tell it where an entry goes. None of them @@ -1489,10 +1479,9 @@ changes a plan. than four headings or has an empty heading falls back to the template's, so a tool that reports a bad heading still has its task filed. -The macOS host gives each detected build system its own tool window and uses +A host that draws build-system windows gives each detected build system its own tool window and uses these presentation fields. The host-drawn windows share Build Output, docking, layout restoration, activation, and lifecycle with other workbench contributions. -Windows has not integrated build-system windows yet. ### Templates @@ -2710,11 +2699,11 @@ Functions complement the typed domain protocols; they never replace them. The protocol is implemented in Rust Core (`rust/lithe-core/src/lsp/interface/plugin_function.rs`, `function_relay.rs`). -macOS starts every build provider process as a session of the plugin that +A host starts every build provider process as a session of the plugin that owns it and invokes its functions from that plugin's web pages (the `functions.*` bridge methods below). A provider served by a shared language server is started by the host for every plugin of its language, so it serves -no plugin's functions. Windows has no plugin backend host. The +no plugin's functions. The [official SDKs](#official-backend-sdk) implement the backend side. ### Capability ownership @@ -3121,8 +3110,8 @@ unsupported-option cases. ### Public document, file and window methods -These methods are implemented by the macOS host. Windows does not advertise -`backend` support. `getCapabilities` lists only the intersection of the plugin's +A host that advertises `backend` support implements these methods. +`getCapabilities` lists only the intersection of the plugin's permission-derived grants and this host's registered method handlers. Grants in `lsp.startServer.hostServices` for the methods below use **full @@ -3206,8 +3195,8 @@ may be retried with bounds; `permissionDenied`/`readOnly` need user intervention [`terminal-backend.plugin.json`](fixtures/backend/terminal-backend.plugin.json) is the minimal manifest. `terminal.create` grants `lithe/host/terminal/create`, `show`, `close`, `reopen`, `dispose`, and `onExit`. It requires `backend`; discover the individual -methods through `getCapabilities` before using them. macOS implements this -service. Windows has no handler and does not advertise its methods. The +methods through `getCapabilities` before using them. A host without this +handler does not advertise its methods. The plugin's web page calls its backend through `functions.invoke`; the backend uses these host requests with the invocation's workspace context. There is no direct web `terminal.*` bridge. @@ -3394,8 +3383,8 @@ The `languageServer` host service lets a plugin backend talk to the language servers its own plugin declares. It is granted by the `languageServer.request` permission, only for a language server that lists `requestMethods` or `forwardNotifications`, and each method is granted by its full name. -`getCapabilities` lists the methods this host implements. Windows does not -advertise `backend` support. +`getCapabilities` lists the methods this host implements. A host without +`backend` support advertises none of them. Every method takes `context: {contextID, generation}` and `serverID`, the `languageID` of one of the calling plugin's servers. A server of another @@ -3534,12 +3523,12 @@ workspace context: with workspace context and `{document:{documentID,version,path},text}`. Version is the same public buffer version as `documents/getSnapshot`, not a WebView revision. Source is the dirty live buffer, capped at the host's - `limits.editorActionSourceBytes` (1 MiB on macOS). Remote workspaces and + `limits.editorActionSourceBytes`. Remote workspaces and read-only preview surfaces are not served. - Collection is read-only by contract: no network request or business execution. Edits invalidate the old actions immediately and cancel older collection work; the host debounces fresh collection and rejects stale or withdrawn results. - macOS bounds the whole collection batch to eight seconds, not eight seconds per + The host bounds the whole collection batch to eight seconds, not eight seconds per binding. Refresh acknowledges scheduling without waiting for collectors; a slow or failed collector must not fail editing or the save synchronization barrier. - Return `{actions:[{id,range:{start:{line,column},end:{line,column}},title,icon, @@ -3553,7 +3542,7 @@ workspace context: - An action may carry `alternatives`: at most 4 `{id,title,icon,invoke}` entries with the same rules and IDs unique within the action, offered in the action's menu after the action itself (for example Debug or Test beside Run). -- The macOS host draws `run`, `run-all`, `debug`, `test`, `test-passed`, +- The Lithe desktop host draws `run`, `run-all`, `debug`, `test`, `test-passed`, `test-failed`, `test-skipped`, `navigate-super` and `navigate-implementations`. An icon names a picture, not an operation: a test icon does not make the action a test, and the host never starts anything because of an icon. @@ -3575,7 +3564,7 @@ workspace context: windows. A private process started by the plugin is not a substitute. Click executions are not cancelled by unrelated re-parsing; withdrawing the binding, ending its session/context or tearing down the owning editor cancels them. The host never retries side effects automatically. -- Toolbar and menu Run/Debug on macOS reuse these actions for a language the +- Toolbar and menu Run/Debug in the Lithe desktop reuse these actions for a language the package declares in `languages` without an execution or debug module and without the matching `hostCapabilities` entry: with a current-file Run configuration the host saves the document, collects the document's actions as @@ -3646,7 +3635,7 @@ per workspace context: (optional leading dot) and literal file names, matched case-insensitively. A document matches when any entry matches. At most 32 registrations per plugin across contexts; duplicate IDs in a context are refused. -- `providers` maps a provider type to `{function}`. The macOS host routes +- `providers` maps a provider type to `{function}`. The Lithe desktop host routes `definition`, `hover`, `completion`, `codeLens`, `foldingRange`, `semanticTokens` and `documentLink`. Completion may add 1 to 16 single-character `triggerCharacters`; semantic tokens must add their @@ -3776,7 +3765,7 @@ service. `functionID`, sorted by plugin ID, each with its exported `version`. Disabling, enabling, replacing and uninstalling a plugin take effect for the list at once; a host that registers backend plugins only when it - launches (macOS today) lists a newly installed or updated backend plugin + launches lists a newly installed or updated backend plugin only after a restart of Lithe (see [Hot activation](#hot-activation)). The caller must have declared the function, otherwise `plugin.function.notDeclared`. A listed provider's backend need not be @@ -3937,7 +3926,7 @@ command `lsp.configureFunctionRelay` and calls the new snapshot no longer allows end at once with the errors above. Installing or updating a plugin that has a backend is different on a host that -registers backend plugins only when it launches (macOS today): that host lists +registers backend plugins only when it launches: that host lists a newly installed or updated backend plugin in `providers` only after a restart of Lithe. @@ -3963,11 +3952,11 @@ restart of Lithe. ### Official backend SDK -The official SDKs live in -[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs), which -also releases them; they implement this contract and never define protocol. +The official SDKs live next to this contract in +[`shared/plugins/sdk/`](../../sdk) and are released from this repository; +they implement this contract and never define protocol. -[`sdk/node`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/README.md) is the official SDK for backends +[`sdk/node`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/sdk/node/README.md) is the official SDK for backends started through a `node` runtime launch. It implements this protocol with its host services, `litheBuild`, and VCS providers, so a plugin author writes only handlers. It is released (`sdk-node-v`) as a `.tgz` with its @@ -3976,7 +3965,7 @@ carries only protocol frames, `PluginError` is the only way to set a `pluginErrorCode`, processes started for a call end with it, and its state store refuses the plugin package. -[`sdk/java`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/java/README.md) is the Java twin for backends +[`sdk/java`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/sdk/java/README.md) is the Java twin for backends started through a `java` runtime launch. It implements this protocol with its host services and `litheBuild`, but not VCS providers. It needs Java 8 or newer, has no runtime dependencies, and is released as a JAR with its SHA-256 @@ -3984,7 +3973,7 @@ for plugins to shade into their backend. It enforces the same rules with the sam mapping, with one difference: on Java 8 it stops only a cancelled tool's direct child, and the host's process scope ends the rest. -[`sdk/rust`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/rust/README.md) is the SDK for +[`sdk/rust`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/sdk/rust/README.md) is the SDK for [plugin backends](#plugin-backend), which ship as packaged executables. It implements this protocol with its host services, but neither build nor VCS providers. It needs Rust 1.87 or newer, depends only on `serde_json`, and is @@ -4101,15 +4090,6 @@ command payload is a Core `invalid_request` failure instead. Host-facing plugin states are `notInstalled`, `installed` (enabled or disabled), `running`, `restartRequired`, `updateRequired`, and `failed`. -## Windows mapping - -The Windows extension manifest (`Plugins/win/Official/*/plugin.json`) predates -this contract. Its adapter maps `languages[].extensions` to -`languages[].fileExtensions`, `lsp.server`/`lsp.args` to `languageServers[]`, -`lsp.requiredExecutables: ["node"]` to `runtimeDependencies`, and -`runActions.manifestFiles` to `test.projectMarkers`. Composer script discovery -is Windows-only behavior outside this contract. - ## Host-owned workbench UI `ui` contributions need `requires.ui: 0` and the `ui.contribute` permission. @@ -4141,7 +4121,7 @@ and the UI cases in `fixtures/manifest/invalid-manifests.json`. Unknown and temporarily absent contributions never execute an action; placement/order preferences remain available if the plugin returns. -The macOS host supports mouse docking through the existing shared dock state; +A host may support mouse docking through the existing shared dock state; that presentation behavior does not grant additional plugin execution capabilities. ### Web content @@ -4243,21 +4223,6 @@ consent, and closing it cancels them. Reusing a `call` ID is consent as the host's provider requests: once given for a plugin and workspace, it holds until the project is closed. Calls to the plugin's backend ask for none; the install review covered it. -macOS implements web content with WKWebView, including the native controls -described below. Windows implements web content with WebView2 under a -plugin-private `lithe-plugin://` origin; its bridge answers `ui.getState`, -`ui.setState`, and `workspace.openFile`, and returns `plugin.web.unknownMethod` -for `build.*` and `functions.*` until the Windows build-system capability -lands. - -### Current Windows availability - -Windows renders host tool windows, commands and package web content, but not -native controls or backend-only commands. Under the -[declaration rule](#versioning) it therefore does not declare `ui`, and Core -refuses packages that require it there. Windows installs declarative packages -from disk only and has no marketplace browser yet. - ### Existing community store The community index is [Lithe Store](https://github.com/Lithe-IDEA/Lithe-store). @@ -4278,7 +4243,7 @@ To add a host-rendered view to a community package: namespace, not a ready-to-publish community package. Change its ID and all references to your own namespace before using it in Lithe Store. -### UI icons (macOS host) +### UI icons Tool windows and commands accept an optional `icon`. Prefer a stable host icon ID; it resolves through Lithe's existing icon library, not SF Symbols: @@ -4335,12 +4300,12 @@ using a different image. Assets are read-only and held in process memory; no runtime disk cache or app-bundle write is created. Installed icon bytes follow the existing package signature/digest, update and rollback lifecycle. -macOS 宿主布局菜单允许把已注册工具窗口移到左侧、右侧或底部,并保存入口 +Lithe 桌面端布局菜单允许把已注册工具窗口移到左侧、右侧或底部,并保存入口 顺序与显隐;`ui.toolWindows[].placement` 只声明首次使用的 `bottom`/`right` 位置。项目、提交和搜索采用相同的工具窗口注册路径, 原生业务内容保持宿主所有;左侧恢复插件窗口仍按 `onView` 激活所属模块。 -macOS 动作的可见、可用、勾选和激活中状态由宿主计算,不接受清单中的可执行 +Lithe 桌面端动作的可见、可用、勾选和激活中状态由宿主计算,不接受清单中的可执行 条件脚本。窗口尚在激活时,其打开命令在工具栏、菜单和命令面板禁用;窗口入口 仍可取消显示。多个窗口同时请求同一插件模块时只构造一次;禁用或关闭会取消 并清理在途激活,旧的菜单或命令引用不能重新调用已禁用的贡献。 @@ -4354,7 +4319,7 @@ style, script, HTML or host-action fields. The host renders every control with its shared theme. See [native-panel.plugin.json](fixtures/ui/native-panel.plugin.json) and the runnable -[Node SDK Sample Explorer](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/examples/sample-build/plugin.json). +[Node SDK Sample Explorer](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/sdk/node/examples/sample-build/plugin.json). `native.kind` is `tree`, `list` or `form`; `nodes` contains literal `id`, `label` and optional `children`. IDs are stable, case-sensitive ASCII letters/digits plus `._-` (1–128 bytes). The component, initial nodes and actions have distinct IDs. @@ -4389,9 +4354,7 @@ provide `span` (1–columns); omitted span fills a row. Controls pack in declara order, wrapping before a span would exceed the row. Without layout, the form retains its original single-column presentation and must omit span. Layout is form-only. Gaps, alignment and breakpoints remain host-owned; plugins cannot -supply pixels, CSS or gap values. macOS uses a native `Layout`, 12pt gaps, -top-aligned variable-height rows, and switches to one column below 640pt of -content width. Reflow preserves the same controls, local values and focus. +supply pixels, CSS or gap values. Reflow preserves the same controls, local values and focus. Buttons reference an existing non-selection action through `action`, have stable `id`, literal `label`, optional `primary`, `enabled` and `info`. @@ -4412,8 +4375,7 @@ A pending call disables all fields. Values use the existing 64 KiB per-plugin, per-window, per-workspace state store; removed fields, invalid types and removed options are discarded when restoring. Provider replies never overwrite local fields. Form declarations and choices are static. -See [native-form.plugin.json](fixtures/ui/native-form.plugin.json) and -[host UI acceptance fixture](https://github.com/1lck/Lithe-IDEA/blob/preview/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. +See [native-form.plugin.json](fixtures/ui/native-form.plugin.json) for acceptance. An optional `source` names this plugin's `buildSystemID` and a function returning `{ nodes: [...] }`. The source uses the existing provider transport; it adds no diff --git a/shared/plugins/contract/v1/native-ui.schema.json b/shared/plugins/contract/v1/native-ui.schema.json index 50ba63da..0634d684 100644 --- a/shared/plugins/contract/v1/native-ui.schema.json +++ b/shared/plugins/contract/v1/native-ui.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/native-ui.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/native-ui.schema.json", "title": "Host-rendered native plugin UI schema 0 (plugin contract v1)", "type": "object", "additionalProperties": false, diff --git a/shared/plugins/contract/v1/plugin-index.schema.json b/shared/plugins/contract/v1/plugin-index.schema.json index 177b7c8d..a92b0d73 100644 --- a/shared/plugins/contract/v1/plugin-index.schema.json +++ b/shared/plugins/contract/v1/plugin-index.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-index.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json", "title": "Lithe plugin marketplace index (plugin contract v1)", "description": "Shape of a marketplace source index file, lithe-plugin-index.json. Semantic rules that JSON Schema cannot express (download URLs inside the entry's own repository, duplicate IDs and versions, the official namespace, platform and API filtering) are defined in README.md and enforced by plugins.marketplace.validateIndex.", "type": "object", diff --git a/shared/plugins/contract/v1/plugin-manifest.schema.json b/shared/plugins/contract/v1/plugin-manifest.schema.json index bb251b4e..50309a81 100644 --- a/shared/plugins/contract/v1/plugin-manifest.schema.json +++ b/shared/plugins/contract/v1/plugin-manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-manifest.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json", "title": "Lithe declarative plugin manifest (plugin contract v1)", "description": "Shape of plugin.json for a declarative plugin. Semantic rules that JSON Schema cannot express (cross references, permissions, activation events, template variables) are defined in README.md and enforced by plugins.declarative.validate.", "type": "object", diff --git a/shared/plugins/contract/v1/toolchains/README.md b/shared/plugins/contract/v1/toolchains/README.md index 42eaabac..f8adac31 100644 --- a/shared/plugins/contract/v1/toolchains/README.md +++ b/shared/plugins/contract/v1/toolchains/README.md @@ -421,6 +421,6 @@ Core acceptance vectors are in with sibling pins/query/resolution suites. The pin document, query exchange and one error reply per stable code that those suites share are inline in [toolchain_vectors.rs](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_vectors.rs). -Passing them is not native product verification. Contract changes must be mirrored after merge with -`node scripts/contract.mjs sync --revision ` in -[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs). +Passing them is not native product verification. Contract changes land in +the same pull request as the Core code that enforces them; there is no other +copy to update. diff --git a/shared/plugins/guides/build-tool-integration.md b/shared/plugins/guides/build-tool-integration.md new file mode 100644 index 00000000..dce41d5c --- /dev/null +++ b/shared/plugins/guides/build-tool-integration.md @@ -0,0 +1,469 @@ +# 为语言插件接入构建、运行与测试 / Adding build, run, and test to a language plugin + +[中文](#中文) · [English](#english) + +## 中文 + +LSP 服务器负责语言分析,构建工具负责项目导入、编译、依赖解析与产物。 +一个实用的语言插件应把这两条链路分别验证,再验证它们在保存、切换项目和停止时能配合工作。 +先完成[LSP 插件生命周期教程](lsp-plugin-lifecycle.md),再按本篇增加执行能力。 + +> **宿主支持。** 目前唯一的宿主是 Lithe 桌面端,下文的“宿主”都指它。它声明 `buildSystems`、`buildProviders` 和 `debugAdapters`, +> **不声明 `executionProviders`**:`requires` 里有它的插件(第 2、3 节的运行和测试声明)不会被提供安装,所以这两节的清单按契约是有效的, +> 但不能在桌面端安装和运行。要在桌面端运行固定命令或构建任务,用第 5 节的构建系统。 +> 桌面端的 Build 窗口检测构建实例、加载提供者模型、解析可执行文件候选并运行命令任务和提供者任务;构建提供者的运行准备、测试、 +> 调试准备、模型输入、原生配置、内联测试报告、运行时调试,以及构建系统的实例选项和任务准备尚未呈现。 +> 完整声明和说明见 [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) +> 中 `Declared()` 上方的注释,各能力的对照见[能力与协议版本对照](../reference/capabilities-and-versions.md)。 +> 对本文有疑问,请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +### 1. 先选择当前能发布的接入方式 + +各能力的字段和限制见[构建系统](../reference/domains/build-systems.md)和[构建提供者](../reference/domains/build-providers.md)。 +发布前仍要核实目标宿主声明了你用到的能力。 + +| 需求 | 接入方式 | 能做什么、边界在哪里 | +| --- | --- | --- | +| 补全、跳转、诊断 | `languageServers` | 按 LSP 提供能力,不自动声明编译和测试 | +| 运行文件或固定项目命令 | `executionProviders[].run` | 命令与 argv 模板,工作目录为工作区根;不提供通用目标选择或构建模型。桌面端不声明 `executionProviders` | +| 运行整个工程、文件所在范围或指定测试 | `executionProviders[].test` | 测试项目标记、文件发现和命令规划;不是完整语义测试发现与结构化结果协议。桌面端不声明 `executionProviders` | +| Java 项目的运行/测试/调试 | 任何插件都可以声明 `languageServers[].hostIntegration`,例如 `jdt` | 使用宿主实现的集成;集成只读插件自己包里的资源,交给同一插件启动的语言服务器,不额外授予权限;宿主未实现的集成 ID 返回 `plugin.integration.unsupported`,见[契约](../contract/v1/README.md#language-server-host-integrations)。桌面端的声明说明没有列出语言服务器宿主集成,使用前先在桌面端验证 | +| 通用 Build 工具窗口、工程模型、任务、依赖与同步 | `buildSystems`;模型需要程序计算时再加 `buildProviders` | Core 校验并规划,桌面端声明并呈现这两个能力,呈现范围见开头的宿主支持 | + +当需求只是 `lua <当前文件>`、`go test ./...` 这类固定命令,契约提供执行提供者(`executionProviders`)。 +当需求需要 Maven/Gradle/Cargo 的模块、profile、target、classpath、依赖树或任务动态发现, +应使用构建系统和成熟构建后端给出的完整模型;不要把一个 `run` 命令包装成已经完成工程集成。 + +### 2. 给 Lua 模板加上运行能力 + +在生命周期教程的最小清单中,替换下面四个顶层字段,其他字段保留: + +```json +{ + "requires": { "languageServers": 0, "languages": 0, "executionProviders": 0 }, + "permissions": ["languageServer.launch", "process.run"], + "activationEvents": ["onLanguage:lua", "onRun:lua"], + "executionProviders": [ + { + "languageID": "lua", + "run": { + "executable": { "command": "lua" }, + "arguments": ["${file}", "${userArguments}"] + } + } + ] +} +``` + +`requires` 必须恰好列出清单用到的能力,所以加了 `executionProviders` 就要加 `executionProviders: 0`。因为桌面端不声明这个能力, +这份清单目前不会被提供安装,见开头的宿主支持。 +用户需要另行安装 Lua 解释器;随包提供的 LuaLS 不等于解释器。 +`process.run` 声明运行权限,`onRun:lua` 声明按运行操作激活,二者都不能省略。 +测试声明同理需要 `process.test` 和 `onTest:`。 + +`executable` 只能选一个:裸 `command` 交给宿主工具路径发现,或使用宿主已经支持的 +项目 `toolchain` ID,例如 Go 的 `project-go`。插件不能发明一个 toolchain ID 就指望宿主实现。 + +所有参数都是独立 argv 元素,不经过 shell: + +| 模板 | 含义与范围 | +| --- | --- | +| `${file}` | 工作区相对路径;以 `-` 开头时宿主加 `./`,适用于运行、文件测试和用例测试 | +| `${fileDirectory}` | 文件所在目录,根目录为 `.`,其他为 `./…` | +| `${userArguments}` | 仅用于运行,必须独占一个元素,展开为用户参数数组 | +| `${testName}` | 指定测试用例名,去除首尾空白 | +| `${testName:regex}` | 将用例名的正则特殊字符转义,适合测试筛选参数 | + +不要在数组里写 `"lua ${file} && echo done"`、额外 shell 引号或依赖变量替换; +那只是一个普通参数。路径中的空格也不需要作者再加引号。执行提供者没有可自行 +设置的 `cwd`、`${projectDir}` 或通用构建设置字段,工作目录固定是工作区根。 + +### 3. 用 Go 说明三个测试范围 + +下面是一个 **Go 语言插件**的顶层字段片段,需要与声明 `go` 语言及其服务器的清单合并, +不能直接粘贴到 Lua 插件中使用;`requires` 同样要写 `executionProviders: 0`,桌面端同样不会提供安装。 + +```json +{ + "requires": { "languageServers": 0, "languages": 0, "executionProviders": 0 }, + "permissions": ["languageServer.launch", "process.run", "process.test"], + "activationEvents": ["onLanguage:go", "onRun:go", "onTest:go"], + "executionProviders": [ + { + "languageID": "go", + "run": { + "executable": { "toolchain": "project-go" }, + "arguments": ["run", "${file}", "${userArguments}"] + }, + "test": { + "frameworkID": "go", + "workspaceLabel": "All Go Tests", + "projectMarkers": ["go.mod", "go.work"], + "testFilePatterns": ["*_test.go"], + "executable": { "toolchain": "project-go" }, + "workspaceArguments": ["test", "./..."], + "fileArguments": ["test", "${fileDirectory}"], + "testCaseArguments": [ + "test", + { "value": "${fileDirectory}", "when": "fileSelected" }, + { "value": "./...", "when": "noFileSelected" }, + "-run", + "^${testName:regex}$" + ] + } + } + ] +} +``` + +- 工作区有任一 `projectMarkers` 才被识别为测试项目。`testFilePatterns` 是忽略大小写、 + 只支持 `*` 的文件名模式,不是正则或任意 glob。 +- 工作区范围运行 `go test ./...`;“文件”范围运行文件所在的包,符合 Go 的测试单位, + 不承诺只执行该文件。 +- 用例范围有选中文件时限定包,没有文件时使用 `./...`;引用 `${file}` 或 + `${fileDirectory}` 的用例参数必须用 `when: "fileSelected"` 保护。 +- 声明式发现返回工作区和匹配文件,不会仅凭上述字段解析任意框架的测试函数、 + 测试结果树或调试入口。要得到这些能力,需要构建系统和构建提供者。 + +这里的 `go run <文件>` 也只运行选中文件,不能替代需要多个源码文件的包运行。 +需求超出模板能力时,应如实说明边界,或改用第 5 节的构建系统。 + +### 4. 将项目事实留给成熟后端 + +先检查你选用的语言服务器或项目后端是否已经支持工程导入、构建命令和运行目标。 +例如 Java 的模块、classpath、测试/调试目标应使用相应 Java 后端给出的事实, +不要由插件另读构建文件再拼一套容易漂移的 classpath。其他语言也应复用其成熟工具链。 + +一个完整的工程工作流需要确认以下顺序: + +```text +保存文档 → 等待后端接收并同步工程变更 → 构建目标 → 检查构建结果 + → 取得本次构建后的运行路径/产物 → 启动所选入口 +``` + +构建失败时不能继续运行旧产物;项目切换后不能用旧模型启动新项目;取消后不能把迟到结果 +覆盖到新一次操作。上述职责需要真实后端与宿主配合,声明一条命令并不会自动完成。 + +构建工具正常解析项目依赖时,可能需要访问网络并写用户的工具缓存;这与启动插件时临时下载 +语言服务器不同。语言服务器和随包运行时应在打包阶段准备好。项目依赖、构建产物、 +Gradle daemon 状态、Maven 仓库或 Cargo target 等应由对应工具放到其用户缓存或工作区, +不能写入插件包或应用安装目录。每个后台进程都要有明确的停止与清理策略。 + +### 5. 需要完整工程模型时:构建系统 + +`buildSystems` 用数据描述一个项目级构建工具:识别、可执行文件、选项、模型、任务、运行入口和同步。 +Core 校验声明、检测实例、解码模型、展开命令行;桌面端执行这些计划,并显示 Build 工具窗口。 +模型只能由程序计算时(例如 Gradle Tooling API),在 `buildSystems[].provider` 声明一个 +[构建提供者](../reference/domains/build-providers.md),用 `litheBuild` 协议回答模型、任务、测试和运行准备请求。 +字段见[构建系统](../reference/domains/build-systems.md); +运行原生程序、调试准备和终端见[原生构建工作流](native-build-workflows.md)。 +注意桌面端目前不呈现构建提供者的测试、运行准备和调试准备,见开头的宿主支持。 + +可以参考实现的插件: + +| 插件 | 做法 | +| --- | --- | +| [Gradle](https://github.com/Lithe-IDEA/lithe-plugin-gradle) | 纯声明式构建系统,不需要后端 | +| [Maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) | 用 Java SDK 写的构建后端 | +| [Cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) | 用 Node.js SDK 写的构建后端和插件设置 | + +这些仓库的已发布版本声明的能力版本,以各自发布页和清单为准;写自己的插件时按契约把所有版本写成 `0`。 + +设计自己的构建系统时,先用下列问题检查。它们是设计检查项,不能替代产品验收: + +| 模型部分 | 需要回答的问题 | +| --- | --- | +| 检测 | 如何发现根工程、嵌套模块、wrapper,如何排除生成目录? | +| 工具与运行时 | wrapper/系统命令如何选择,所需 JDK/其他运行时如何发现和验证? | +| 选项 | profile、target、模式、平台等如何进入明确的参数与环境,而不是拼接 shell? | +| 工程模型 | 模块、目标、依赖和运行入口来自哪个成熟后端,如何解码与校验? | +| 任务 | 支持哪些范围、是否需要用户参数、是否执行项目代码或访问网络? | +| 同步 | 哪些配置变更触发重导入,如何取消旧请求并拒绝过期结果? | +| 测试与诊断 | 发现、执行、结果映射由谁提供,路径如何对应用户源码? | +| 生命周期 | 谁启动进程、监督超时、终止任务并清理状态? | + +Core 的计划接口本身不启动进程、不监视文件、不持久化选择,也不会显示界面; +执行、权限/确认、取消、状态、结果和工具窗口都由宿主负责。fixture 能生成正确命令, +只说明声明和规划正确,仍要在真实宿主里验收。 +不要把内部 dispatcher 命令写成社区插件可以调用的宿主脚本 API。 + +`languageServers[].hostIntegration`(语言服务器侧)与构建系统 `model.source: "hostIntegration"` +是不同接口,开放范围也不同: + +- 语言服务器侧对所有插件开放。任何插件都可以声明宿主已实现的集成 ID(契约里的例子是 `jdt`); + 集成只读取声明插件自己包里的资源,交给同一插件启动的服务器或辅助进程,不额外授予权限。 + 宿主未实现的 ID 返回 `plugin.integration.unsupported`,见 + [契约](../contract/v1/README.md#language-server-host-integrations)。 +- 构建系统侧只限官方插件,目前只有 `maven`;官方命名空间以外的插件请求它同样得到 + `plugin.integration.unsupported`,见[契约](../contract/v1/README.md#host-integrations)。 + 不要复制官方 ID 或修改 `vendor` 来绕过这个限制。 + 社区构建插件用 `static`、`command` 或 `provider` 模型。 + +两侧都不是作者自己命名就能新增的机制:集成 ID 必须是宿主已经实现的。 + +### 6. 验证完整工作流 + +打包与清单校验完成后,还应在目标平台、实际宿主中完成: + +1. 分别准备单文件、多文件、嵌套工程和带空格路径的项目,明确插件支持的范围。 +2. 保存修改后运行/测试,确认使用当前文件和目标;将源码改错,确认错误能被看到, + 完整构建集成应阻止继续启动旧产物。 +3. 覆盖工作区、文件和指定用例范围,核对实际命令、工作目录、输出与退出码。 +4. 缺少解释器、工具链版本不符、wrapper 无执行权限或依赖下载失败时,错误能定位到原因。 +5. 取消耗时任务,随后再运行;切换项目与关闭工作区,确认旧任务、锁与后端进程没有残留。 + 若宿主或工具尚不能做到,应报告限制,不能只在文档里宣称已经支持。 +6. 禁用/卸载语言插件后检查该语言的运行、测试与 LSP 都处于正确状态。 + 插件声明的语言由插件负责,不能假定缺失能力会回退到宿主内置实现。 +7. 清理验收启动的应用和辅助进程,再按[生命周期教程](lsp-plugin-lifecycle.md)里“把停止、失败和卸载也当作功能验收”一节 + 验证重启、升级和卸载。 + +命令规划测试证明参数正确,进程冒烟验证证明工具能够启动,真实项目验收才能证明用户工作流 +成立。发布说明应明确已经验证的宿主、语言服务器及构建工具版本。 + +相关入口:[生命周期教程](lsp-plugin-lifecycle.md) · [发布社区插件](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md) · +[版本追踪](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md) · +[Lithe 插件契约](../contract/v1/README.md)。 + +--- + +## English + +A language server handles language analysis; a build tool handles project import, compilation, +dependency resolution, and artifacts. A useful language plugin verifies each chain on its own, +then verifies that they work together on save, on project switch, and on stop. +Finish the [LSP plugin lifecycle tutorial](lsp-plugin-lifecycle.md) first, then add execution with this guide. + +> **Host support.** The only host today is the Lithe desktop, and "the host" below means it. It declares `buildSystems`, `buildProviders`, and `debugAdapters` +> and **does not declare `executionProviders`**: a plugin whose `requires` lists it (the run and test declarations of sections 2 and 3) is not offered for install, +> so the manifests in those two sections are valid under the contract but cannot be installed or run on the desktop. +> To run a fixed command or a build task on the desktop, use the build system of section 5. +> The desktop's Build window detects build instances, loads provider models, resolves executable candidates, and runs command tasks and provider tasks; build-provider run preparation, tests, +> debug preparation, model inputs, native configuration, inline test reports, runtime debugging, and build-system instance options and task preparations are not presented yet. +> The full declarations and notes are in the comment above `Declared()` in [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go); +> for a per-capability comparison see the [capability and protocol versions](../reference/capabilities-and-versions.md). +> Questions about this guide go to [Lithe-core-rust Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +### 1. Pick an integration you can publish today + +For the fields and limits of each capability, see [build systems](../reference/domains/build-systems.md) and +[build providers](../reference/domains/build-providers.md). +Before publishing, still confirm that the target host declares every capability you use. + +| Need | Integration | What it does and where it stops | +| --- | --- | --- | +| Completion, navigation, diagnostics | `languageServers` | Features come from LSP; compile and test are not declared automatically | +| Run a file or a fixed project command | `executionProviders[].run` | A command and argv template; the working directory is the workspace root; no general target selection or build model. The desktop does not declare `executionProviders` | +| Run the whole project, a file's scope, or a named test | `executionProviders[].test` | Test-project markers, file discovery, and command planning; not a full semantic test-discovery or structured-results protocol. The desktop does not declare `executionProviders` | +| Run, test, and debug Java projects | Any plugin may declare `languageServers[].hostIntegration`, for example `jdt` | Uses an integration the host implements; the integration reads only resources inside the plugin's own package and hands them to the language server that same plugin starts, and grants no extra permission; an integration ID the host does not implement returns `plugin.integration.unsupported`; see the [contract](../contract/v1/README.md#language-server-host-integrations). The desktop's declarations do not list any language-server host integration, so verify on the desktop before relying on one | +| A general Build tool window, project model, tasks, dependencies, and sync | `buildSystems`, plus `buildProviders` when a program must compute the model | Core validates and plans; the desktop declares and presents both capabilities, to the extent described in the host support note at the top | + +When all you need is a fixed command such as `lua ` or `go test ./...`, the contract offers +execution providers (`executionProviders`). When you need Maven/Gradle/Cargo modules, profiles, targets, +classpaths, dependency trees, or dynamic task discovery, use a build system with the +full model a mature build backend reports; do not wrap a single `run` command and call it project integration. + +### 2. Add run support to the Lua template + +In the lifecycle tutorial's minimal manifest, replace these four top-level fields and keep the rest: + +```json +{ + "requires": { "languageServers": 0, "languages": 0, "executionProviders": 0 }, + "permissions": ["languageServer.launch", "process.run"], + "activationEvents": ["onLanguage:lua", "onRun:lua"], + "executionProviders": [ + { + "languageID": "lua", + "run": { + "executable": { "command": "lua" }, + "arguments": ["${file}", "${userArguments}"] + } + } + ] +} +``` + +`requires` must list exactly the capabilities the manifest uses, so adding `executionProviders` means adding `executionProviders: 0`. Because the desktop does not declare this capability, +this manifest is not offered for install there today; see the host support note at the top. +Users must install a Lua interpreter themselves; the bundled LuaLS is not an interpreter. +`process.run` declares the run permission and `onRun:lua` declares activation on run; neither can be omitted. +A test declaration likewise needs `process.test` and `onTest:`. + +`executable` is exactly one of two things: a bare `command` that the host finds on its tool path, +or a project `toolchain` ID the host already supports, such as Go's `project-go`. +A plugin cannot invent a toolchain ID and expect the host to implement it. + +Every argument is a separate argv element and never goes through a shell: + +| Template | Meaning and scope | +| --- | --- | +| `${file}` | Workspace-relative path; the host prefixes `./` when it starts with `-`; valid for run, file tests, and test-case tests | +| `${fileDirectory}` | The file's directory: `.` for the root, `./…` otherwise | +| `${userArguments}` | Run only; must be a whole element on its own and expands to the user's argument array | +| `${testName}` | The selected test-case name, trimmed | +| `${testName:regex}` | The test-case name with regex metacharacters escaped, for test-filter arguments | + +Do not write `"lua ${file} && echo done"`, extra shell quotes, or anything that relies on variable +substitution; that is just one ordinary argument. Paths with spaces need no extra quoting either. +Execution providers have no author-settable `cwd`, no `${projectDir}`, and no general build-settings +field; the working directory is always the workspace root. + +### 3. Three test scopes, using Go + +Below are the top-level fields of a **Go language plugin**. Merge them into a manifest that declares the `go` language and its server; +they cannot be pasted into the Lua plugin as is. `requires` likewise lists `executionProviders: 0`, and the desktop likewise does not offer it for install. + +```json +{ + "requires": { "languageServers": 0, "languages": 0, "executionProviders": 0 }, + "permissions": ["languageServer.launch", "process.run", "process.test"], + "activationEvents": ["onLanguage:go", "onRun:go", "onTest:go"], + "executionProviders": [ + { + "languageID": "go", + "run": { + "executable": { "toolchain": "project-go" }, + "arguments": ["run", "${file}", "${userArguments}"] + }, + "test": { + "frameworkID": "go", + "workspaceLabel": "All Go Tests", + "projectMarkers": ["go.mod", "go.work"], + "testFilePatterns": ["*_test.go"], + "executable": { "toolchain": "project-go" }, + "workspaceArguments": ["test", "./..."], + "fileArguments": ["test", "${fileDirectory}"], + "testCaseArguments": [ + "test", + { "value": "${fileDirectory}", "when": "fileSelected" }, + { "value": "./...", "when": "noFileSelected" }, + "-run", + "^${testName:regex}$" + ] + } + } + ] +} +``` + +- A workspace is a test project only if it has at least one of the `projectMarkers`. + `testFilePatterns` are case-insensitive file-name patterns that support only `*`; they are not regexes or arbitrary globs. +- The workspace scope runs `go test ./...`. The file scope runs the file's package, which is Go's + unit of testing; it does not promise to run only that file. +- The test-case scope narrows to the package when a file is selected and uses `./...` when none is. + Test-case arguments that reference `${file}` or `${fileDirectory}` must be guarded with `when: "fileSelected"`. +- Declarative discovery returns the workspace and matching files. These fields alone do not parse + any framework's test functions, result trees, or debug entries; for those you need a build system and build provider. + +`go run ` here also runs only the selected file and cannot replace running a package made of several source files. +When a need goes beyond what the templates can express, state the limit honestly, or switch to the build system in section 5. + +### 4. Leave project facts to a mature backend + +First check whether the language server or project backend you chose already supports project import, +build commands, and run targets. For Java, for example, modules, classpaths, and test/debug targets +should come from what the Java backend reports; do not have the plugin read build files again and +assemble its own classpath that will drift. Other languages should reuse their mature toolchains too. + +A complete project workflow must confirm this order: + +```text +save document → wait for the backend to receive and sync the project change → build the target + → check the build result → get the run path/artifact from this build → launch the chosen entry +``` + +A failed build must not go on to run an old artifact; after a project switch the old model must not +launch the new project; after a cancel, a late result must not overwrite the next operation. +These duties need a real backend working with the host; declaring a command does not do them for you. + +When a build tool resolves project dependencies normally, it may access the network and write the +user's tool caches; that is different from downloading a language server when the plugin starts. +Language servers and bundled runtimes must be prepared at packaging time. Project dependencies, +build outputs, Gradle daemon state, the Maven repository, Cargo targets, and so on belong in the +tool's user cache or the workspace, never in the plugin package or the application install directory. +Every background process needs a clear stop and cleanup policy. + +### 5. When you need the full project model: build systems + +`buildSystems` describes a project-level build tool as data: detection, executables, options, model, +tasks, run entries, and sync. Core validates the declaration, detects instances, decodes the model, +and expands command lines; the desktop executes those plans and shows the Build tool window. +When only a program can compute the model (the Gradle Tooling API, for example), declare a +[build provider](../reference/domains/build-providers.md) in `buildSystems[].provider`; it answers +model, task, test, and run-preparation requests over the `litheBuild` protocol. +For fields see [build systems](../reference/domains/build-systems.md); +for running native programs, debug preparation, and terminals see [native build workflows](native-build-workflows.md). +Note that the desktop does not yet present build-provider tests, run preparation, or debug preparation; see the host support note at the top. + +Plugins whose implementation you can read: + +| Plugin | Approach | +| --- | --- | +| [Gradle](https://github.com/Lithe-IDEA/lithe-plugin-gradle) | A purely declarative build system with no backend | +| [Maven](https://github.com/Lithe-IDEA/lithe-plugin-maven) | A build backend written with the Java SDK | +| [Cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) | A build backend and plugin settings written with the Node.js SDK | + +The capability versions these repositories' published releases declare are whatever their release pages and manifests say; in your own plugin, write every version as `0`, as the contract requires. + +When designing your own build system, check it against these questions first. +They are a design checklist and do not replace product acceptance: + +| Model part | Questions to answer | +| --- | --- | +| Detection | How are root projects, nested modules, and wrappers found, and how are generated directories excluded? | +| Tools and runtimes | How is a wrapper or system command chosen, and how is the required JDK or other runtime found and verified? | +| Options | How do profiles, targets, modes, and platforms become explicit arguments and environment instead of shell concatenation? | +| Project model | Which mature backend supplies modules, targets, dependencies, and run entries, and how are they decoded and validated? | +| Tasks | Which scopes are supported, do they need user arguments, and do they run project code or access the network? | +| Sync | Which configuration changes trigger a re-import, and how are old requests cancelled and stale results rejected? | +| Tests and diagnostics | Who provides discovery, execution, and result mapping, and how do paths map back to the user's sources? | +| Lifecycle | Who starts processes, enforces timeouts, terminates tasks, and cleans up state? | + +Core's planning interface does not start processes, watch files, persist selections, or show any UI; +execution, permissions and confirmation, cancellation, status, results, and the tool window are the host's job. +A fixture that produces the right command only shows that the declaration and plan are correct; +you still have to accept it in a real host. +Do not present internal dispatcher commands as a host scripting API that community plugins can call. + +`languageServers[].hostIntegration` (the language-server side) and the build-system +`model.source: "hostIntegration"` are different interfaces, and they are open to different plugins: + +- The language-server side is open to every plugin. Any plugin may declare an integration ID the host + already implements (the contract's example is `jdt`); the integration reads only + resources inside the declaring plugin's own package and hands them to the server or helper process + that same plugin starts, and grants no extra permission. An ID the host does not implement returns + `plugin.integration.unsupported`; see the [contract](../contract/v1/README.md#language-server-host-integrations). +- The build-system side is limited to official plugins, and currently has only `maven`; a plugin + outside the official namespace that asks for it also gets `plugin.integration.unsupported`; + see the [contract](../contract/v1/README.md#host-integrations). + Do not copy an official ID or change `vendor` to get around this limit. + Community build plugins use the `static`, `command`, or `provider` models. + +Neither side lets an author add an integration by picking a name: the ID must be one the host already implements. + +### 6. Verify the complete workflow + +After packaging and manifest validation, also do the following on the target platform in a real host: + +1. Prepare single-file, multi-file, nested-project, and space-in-path projects, and state which ones the plugin supports. +2. Run or test after saving a change and confirm the current file and target are used; break the source + and confirm the error is visible. A full build integration must stop an old artifact from launching. +3. Cover the workspace, file, and named-test scopes, and check the actual command, working directory, output, and exit code. +4. When the interpreter is missing, the toolchain version is wrong, the wrapper is not executable, + or a dependency download fails, the error must point to the cause. +5. Cancel a long task and run again; switch projects and close the workspace, and confirm no old + tasks, locks, or backend processes remain. If the host or tool cannot do this yet, report the limit + instead of only claiming support in the docs. +6. After disabling or uninstalling the language plugin, check that run, test, and LSP for that language + are all in the right state. The plugin owns the languages it declares; do not assume missing + features fall back to a built-in host implementation. +7. Clean up the apps and helper processes the acceptance run started, then verify restart, upgrade, + and uninstall with the section "Treat stop, failure, and uninstall as acceptance criteria" of the [lifecycle tutorial](lsp-plugin-lifecycle.md). + +Command-planning tests prove the arguments are right, process smoke tests prove the tool can start, +and only acceptance on real projects proves the user workflow holds. Release notes must state the +host, language server, and build tool versions you verified. + +Related: [lifecycle tutorial](lsp-plugin-lifecycle.md) · [publishing a community plugin](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md) · +[version tracking](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md) · +[Lithe plugin contract](../contract/v1/README.md). diff --git a/shared/plugins/guides/debug-adapter-plugin.md b/shared/plugins/guides/debug-adapter-plugin.md new file mode 100644 index 00000000..ec35f34e --- /dev/null +++ b/shared/plugins/guides/debug-adapter-plugin.md @@ -0,0 +1,377 @@ +# 编写调试适配器插件 / Writing a debug adapter plugin + +[中文](#中文) · [English](#english) + +## 中文 + +这篇指南面向想让 Lithe 调试某种语言或运行时的插件作者。调试适配器插件只需要一份 `plugin.json`:声明一个遵循 +[DAP(Debug Adapter Protocol)](https://microsoft.github.io/debug-adapter-protocol/)的进程怎么启动,Lithe 负责断点、 +单步、变量、调用栈、调试控制台这些界面和会话管理。我们以官方的 +[LLDB 插件](https://github.com/Lithe-IDEA/lithe-plugin-lldb)为例,它用 Xcode 自带的 `lldb-dap` 调试 C、C++、Objective-C、Rust 和 Swift。 + +字段的权威定义在[契约](../contract/v1/README.md#debug-adapters),这里只讲怎么用、为什么这样设计。发布步骤见 +[发布社区插件](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md)。 + +> **宿主支持。** 调试适配器由 Lithe 桌面端实现,支持 stdio 和插件语言服务器内的回环 TCP 两种传输。宿主提供哪些能力,以 +> 桌面端的[能力声明](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)(`Declared()` 上方注释)为准; +> 能力版本见[版本说明](../reference/capabilities-and-versions.md)。 + +### 1. 插件和宿主各自负责什么 + +| 对象 | 插件作者负责 | Lithe 负责 | +| --- | --- | --- | +| 调试适配器 | 选型、许可、固定版本、启动入口 | 启动进程、DAP 通信、会话停止 | +| 要调试的程序 | 说明怎么产生它(构建命令、编译参数) | 在设置里显示实际找到的路径,把它交给适配器 | +| 调试界面 | 不需要写 | 断点、变量、调用栈、调试控制台与补全 | +| 权限 | 声明 `debug.launch` | 安装时让用户确认 | +| 激活 | 声明 `onDebug:<适配器 id>` | 调试会话需要时才启动适配器 | + +插件不加载进 Lithe 进程。清单是纯数据:没有解析器、没有工作流、没有宿主集成,也不能传 `initializationOptions`,写了会被拒绝。 + +### 2. 最小清单 + +```json +{ + "schemaVersion": 0, + "id": "com.example.demo-debug", + "displayName": "Demo Debugger", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "debugAdapters": 0, "languages": 0 }, + "vendor": { "id": "com.example", "displayName": "Example", "signatureRequirement": "none" }, + "permissions": ["debug.launch"], + "activationEvents": ["onDebug:demo"], + "languages": [ + { "id": "demo", "displayName": "Demo", "fileExtensions": ["demo"] } + ], + "debugAdapters": [ + { + "id": "demo", + "displayName": "Demo", + "requests": ["launch"], + "selectors": [{ "targetKinds": ["file"], "languageIDs": ["demo"] }], + "configurations": { + "launch": { + "arguments": { "stopOnEntry": false }, + "targetBindings": { "program": "target.path", "cwd": "target.workingDirectory" } + } + }, + "launch": { + "process": { "launch": { "kind": "systemCommand", "commands": ["demo-dap"] } }, + "transport": "stdio" + } + } + ] +} +``` + +要点: + +- `pluginAPIVersion` 和 `schemaVersion` 都是 `0`。`requires` 必须逐项列出实际用到的能力,每项都写 `0`:使用 `debugAdapters` 时列出它;声明 `languages` 时列出 `languages`;由后端发起调试时还要列出 `backend`,见第 6 节。 +- 每个适配器都要有一条 `onDebug:` 激活事件,并声明 `debug.launch` 权限。适配器列出 `attach` 时才需要(也只能声明)`debug.attach`。 +- `selectors` 决定 Lithe 在哪些目标上自动选这个适配器:`targetKinds` 是目标种类,`languageIDs` 是语言。不写 `selectors` 表示只能被用户显式选择。 +- `languages[]` 声明适配器服务的语言。如果这个语言已经由别的插件提供(例如 Rust),你的声明只会给它增加“可以调试”的能力,并并入你声明的文件扩展名;语言名、语言标识和语言服务器不变,装上调试器也不会重启正在运行的语言服务器。 +- `targetBindings` 把调试目标的事实填进适配器的启动参数:`program` ← 目标路径,`cwd` ← 工作目录,`processId` ← 进程 ID。只填 `arguments` 里没写的字段,不会覆盖你显式写出的默认值。目标缺少绑定要读的事实时,Lithe 会拒绝这次启动并说明缺什么。 +- `configurationTypes` 声明适配器对应的 `launch.json` `type`,用于导入用户已有的 VS Code 风格配置。 + +### 3. 怎么启动适配器 + +`launch.process` 有三种常见写法: + +| 写法 | 适用场景 | +| --- | --- | +| `{"launch": {"kind": "packaged", "path": …}}` | 适配器许可允许分发、你能为每个平台提供构建;可执行文件随插件包安装 | +| `{"launch": {"kind": "systemCommand", "commands": ["lldb-dap"]}}` | 适配器已经装在用户机器上(例如随 Xcode 命令行工具提供);宿主用自己的工具发现解析命令名。Lithe 桌面端目前只查 PATH,依赖项目工具链或用户覆盖的启动会被拒绝 | +| `{"launch": {"kind": "runSelectedRuntime", "tool": "<工具定义 ID>"}}` | 适配器是 Python / Java / Node.js 等程序,用用户选定的运行时启动;`tool` 必须是清单顶层 `requiresTools` 列出的工具定义 ID | + +`runSelectedRuntime.tool` 的规则见契约的 [Tool references](../contract/v1/README.md#tool-references)。 + +`launch.transport` 是 `stdio`,或者 TCP(`tcpAnnounced`:适配器在输出里打印端口;`tcpPort`:固定端口)。TCP 只允许回环地址。 + +插件包和 Lithe 的安装目录运行时是只读的,适配器不能往里面写日志、索引或状态,否则会破坏代码签名和增量更新。宿主会把适配器的状态放在它自己的可写目录下,不放进插件包或安装目录。 + +### 4. 调试要先编译的语言:`executable` + +DAP 适配器大多直接接受“要运行的程序”,但 C、C++、Swift 这类语言的用户打开的是**源文件**,真正要调试的是编译产物。 +`configurations.launch.executable` 就是为这种情况设计的: + +```json +"configurations": { + "launch": { + "targetBindings": { "program": "target.executable", "cwd": "target.workingDirectory" }, + "executable": { + "setting": "program", + "candidates": [ + "${fileDirectory}/${fileBasenameNoExtension}", + "${workspaceFolder}/${fileBasenameNoExtension}", + "${workspaceFolder}/build/${fileBasenameNoExtension}" + ] + } + } +} +``` + +- `setting` 是你在 `settings[]` 里声明的一个 `file` 类型设置的 id。用户在设置页里选了路径,就用它。 +- 用户没选时,Lithe 依次查找 `candidates`(最多 8 个,每个必须以 `${workspaceFolder}/` 或 `${fileDirectory}/` 开头,可以用 `${fileBasenameNoExtension}`,不能含 `..`),用找到的第一个可运行文件。 +- 找到的路径会**回显在这个设置里**,用户能看到实际会调试哪个文件。 +- 用户选的路径如果不是可运行文件,是错误,**不会**悄悄回落到候选路径。 +- `executable` 和 `targetBindings.program: "target.executable"` 必须一起声明,缺一不可。 + +设置的 `description` 是教用户怎么产生这个程序的地方,写清楚构建命令(例如 `clang -g main.c -o main`)和适配器本身的安装方式。LLDB 插件的 `program` 设置就是这样写的。设置的写法见契约的 `settings` 一节,`scope: "project"`、`effect: "nextRun"` 适合这类“下次运行生效”的路径。 + +### 5. 调试控制台:提示符、补全和 `console` + +Lithe 的调试控制台有输入行和补全弹窗。补全来自适配器的 DAP `completions` 请求;`console` 补充两件事: + +- `commandPrefix`:1 到 8 个字符、不含空格。有些适配器把“光秃秃的单词”当表达式(lldb-dap 就是这样),补全第一个词时会混进 `long` 之类的语言关键字。声明前缀(lldb-dap 用反引号 `` ` ``)后,Lithe 只在向适配器请求补全时把它加在输入前面,并把适配器的答案移回去,用户看不到它。 +- `commands[]`:适配器不支持 `completions`,或者它的实时答案缺一些命令时,列出调试器命令(`name`,可选的一行 `description`)。名字按原样插入,不能重复,不要在名字里补空格;说明按原样显示,不翻译。 + +两者至少声明一个。适配器能实时补全时,不要把整个命令集再抄一遍。 + +### 6. 适配器行为声明与由后端发起调试 + +宿主从不按 ID 认识某个适配器:适配器特有的行为写在清单里,共享的调试引擎照着执行。`features` 和 `defaults` 是 `debugAdapters` 条目的可选字段,不需要额外的版本。 + +- `features.hotCodeReplace`:`request` 是宿主发给适配器、让它把改动的代码应用到运行中的被调试程序的 DAP 命令,声明了就表示支持热代码替换; + `event` 是其 `message` 在调试控制台里显示为进度的 DAP 事件;`changedField`、`errorField` 是响应体里已替换单元的列表和适配器在成功响应里报告的失败信息。 +- `features.stepFilters.argument`:适配器接收步进过滤器所用的启动参数名;用户配置里已经设置的同名参数保持不变。 +- `defaults.stepFilters`:用户没有配置过滤器时的默认值(`classNameFilters`、`skipSynthetics`、`skipStaticInitializers`、`skipConstructors`、`hideFilteredStackFrames`)。 +- 名字写错或使用契约未定义的字段是 `plugin.manifest.invalid`;两者都不声明时,适配器按默认行为运行。完整字段和限制以契约的 + [Adapter features and defaults](../contract/v1/README.md#adapter-features-and-defaults)为准。 + +插件有后端(`requires.backend`)时,可以由后端决定启动什么调试会话,会话仍由宿主拥有(进程、控制台、停止按钮、会话结束)。方法名前缀是 `lithe/host/`: + +| 方法 | API | 权限 | 用途 | +| --- | --- | --- | --- | +| `debug/start` | API-30 | `debug.start` | 在本插件自己声明的适配器上启动 `launch` 或 `attach` 会话,返回 `sessionID`;需要已声明的 `debugAdapters` 条目 | +| `run/start` 的 `debug` 字段 | API-23 | `run.start` | 把一次宿主拥有的运行变成调试会话 | +| `run/stop` | API-23 | `run.start` | 按 `sessionID`(或 `runID`)停止本插件启动的调试会话或运行 | + +- `debug/start` 的参数是 `context, label, adapter, request, configuration`。`adapter` 只能是本插件的适配器(本地 ID 或 `<插件 ID>/<本地 ID>`), + 别的插件的适配器是 `plugin.host.notFound`;`request` 必须是该适配器声明过的;`configuration` 是不超过 256 KiB 的 JSON 对象, + 作为 DAP 的 launch 或 attach 参数叠在适配器声明的默认值之上,`noDebug` 和以 `__` 开头的键属于宿主,会被拒绝。适配器本身照常声明 `debug.launch`(`attach` 还要 `debug.attach`)。 +- `run/start` 的 `debug` 是 `{configurationType, target}`。`target` 为 `{kind: "connection", port}` 时,进程在 `port` 上等调试器,宿主等它出现后挂接适配器; + 为 `{kind: "launch"}` 时由适配器自己启动进程,程序、参数、目录和环境变量经 `targetBindings` 取自 `process`。 + `process.executable` 只能是清单 `requiresTools` 列出的工具定义 ID,或插件包内的文件。运行结束时宿主发 `run/didExit` 通知。 + +这三个方法能否调用,以宿主 `getCapabilities` 的返回为准。Lithe 桌面端的能力声明注释把 run 与 debug 服务列为尚未注册,目前不要假定它们可用。 +官方 Node、Java、Rust SDK 还没有 `debug/start`、`run/start`、`run/stop` 的封装,需要时可以用 SDK 的通用宿主请求,以完整方法名调用, +例如 Node SDK 的 `backend.host.request(method, params?)`,见 [Host services](../sdk/node/README.md#host-services)。 + +细节见[调试适配器](../reference/domains/debug-adapters.md)和契约的 +[Run and debug sessions](../contract/v1/README.md#run-and-debug-sessions)。 +示例:[`debug-adapters/debug-adapter-features.plugin.json`](../contract/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json)、 +[`debug-adapters/debug-start.json`](../contract/v1/fixtures/debug-adapters/debug-start.json)、 +[`run/run-start.json`](../contract/v1/fixtures/run/run-start.json)。 + +### 7. 本地验证 + +1. 对照契约示例 [`contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json`](../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json),并用[编辑器 Schema](../README.md#在编辑器里校验-pluginjson) 检查字段。 +2. [`contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json`](../contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json) 列出每种拒绝的稳定错误码、出错字段和报告它的层(`schema`、`coreManifest`、`corePlan`、`coreInstall`),写测试时可以直接对照。 +3. 在 Lithe 桌面端安装插件,打开一个目标文件,设置断点并启动调试。适配器被拒绝请求时,Lithe 会显示适配器自己的解释,所以适配器返回的错误文字要写得让用户能看懂。 +4. 在设置页确认 `executable` 找到的路径被回显,再把它改成一个不存在的文件,确认得到的是错误而不是悄悄换文件。 + +### 8. 安装后是否需要重启 + +只贡献调试适配器(以及它声明的语言和设置)的插件,在支持热激活的 Lithe 里安装、更新、回退、卸载、启用、停用后**不需要重启**。是否免重启由宿主按它实际能在运行期登记和撤回的贡献类型决定,不是 `plugin.json` 里的字段,所以插件作者不需要也不能声明。带有其他贡献(语言服务器、构建系统、界面等)的插件仍然在重启后生效。 + +### 9. 发布 + +调试适配器插件使用基线 0 的契约,宿主提供了 `debugAdapters` 才会提供安装。 + +- 插件声明的能力,或所用 SDK 要求的协议(例如 `litheLogging`),还没进入 Lithe 稳定版时,把 GitHub Release 标为**预发布**。Lithe Store 的自动追踪只收正式 Release,预发布需要提交一个手工的索引条目,写法参考官方插件已有的预发布索引条目。 +- 稳定版支持插件需要的全部能力和协议后,把 Release 转为正式版,追踪器会自动提交后续版本的索引更新。 +- 发布后不要替换或删除资产;索引固定了它的 SHA-256。 + +### 10. 当前边界 + +- 调试适配器的宿主实现是 Lithe 桌面端。 +- 宿主自己的调试入口对插件适配器只发起 `launch` 请求。`attach` 可以在清单里声明:宿主在构建提供者服务运行的连接调试中选用它(见契约的 [Registry connection debugging](../contract/v1/README.md#registry-connection-debugging)),插件后端也可以用 `debug/start` 或 `run/start` 的 `debug` 字段发起(见第 6 节);用户手动填写主机和端口的远程 attach 以宿主实际呈现为准,不要依赖。 +- 适配器声明是纯数据,没有自定义界面、没有宿主集成点;需要调试之外的能力(构建、测试、运行配置)请另用对应的贡献类型。 + +--- + +## English + +This guide is for plugin authors who want Lithe to debug a language or runtime. A debug adapter plugin needs only a `plugin.json`: it declares how to start a process that speaks the +[Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/), and Lithe provides the breakpoint, stepping, variable, call stack, and debug console UI and manages the session. The running example is the official +[LLDB plugin](https://github.com/Lithe-IDEA/lithe-plugin-lldb), which debugs C, C++, Objective-C, Rust, and Swift with the `lldb-dap` that ships with Xcode. + +The normative field definitions are in the [contract](../contract/v1/README.md#debug-adapters); this guide covers only how to use them and why they are designed this way. For publishing steps, see +[Publishing community plugins](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md). + +> **Host support.** Debug adapters are implemented by the Lithe desktop app, over stdio and over loopback TCP inside a plugin's language server. Which capabilities the host offers is decided by its +> [capability declaration](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) (the comment above `Declared()`); for capability versions, see the [version notes](../reference/capabilities-and-versions.md). + +### 1. What the plugin and the host each own + +| Item | The plugin author owns | Lithe owns | +| --- | --- | --- | +| Debug adapter | Choosing it, its license, the pinned version, the launch entry | Starting the process, DAP communication, stopping the session | +| Program to debug | Explaining how it is produced (build command, compiler flags) | Showing the path actually found in settings and passing it to the adapter | +| Debugging UI | Nothing to write | Breakpoints, variables, call stack, debug console and completion | +| Permissions | Declaring `debug.launch` | Asking the user to confirm at install time | +| Activation | Declaring `onDebug:` | Starting the adapter only when a debug session needs it | + +Plugins are not loaded into the Lithe process. The manifest is pure data: no resolvers, no workflows, no host integrations, and no `initializationOptions`; writing one is rejected. + +### 2. A minimal manifest + +```json +{ + "schemaVersion": 0, + "id": "com.example.demo-debug", + "displayName": "Demo Debugger", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "debugAdapters": 0, "languages": 0 }, + "vendor": { "id": "com.example", "displayName": "Example", "signatureRequirement": "none" }, + "permissions": ["debug.launch"], + "activationEvents": ["onDebug:demo"], + "languages": [ + { "id": "demo", "displayName": "Demo", "fileExtensions": ["demo"] } + ], + "debugAdapters": [ + { + "id": "demo", + "displayName": "Demo", + "requests": ["launch"], + "selectors": [{ "targetKinds": ["file"], "languageIDs": ["demo"] }], + "configurations": { + "launch": { + "arguments": { "stopOnEntry": false }, + "targetBindings": { "program": "target.path", "cwd": "target.workingDirectory" } + } + }, + "launch": { + "process": { "launch": { "kind": "systemCommand", "commands": ["demo-dap"] } }, + "transport": "stdio" + } + } + ] +} +``` + +Key points: + +- `pluginAPIVersion` and `schemaVersion` are both `0`. `requires` must list exactly the capabilities you use, each at `0`: list `debugAdapters` for the adapters, `languages` when you declare `languages`, and `backend` when the backend starts debug sessions (see section 6). +- Every adapter needs an `onDebug:` activation event and the `debug.launch` permission. `debug.attach` is needed (and allowed) only when the adapter lists `attach`. +- `selectors` decide on which targets Lithe picks this adapter automatically: `targetKinds` are target kinds and `languageIDs` are languages. Without `selectors`, the adapter can only be chosen explicitly by the user. +- `languages[]` declares the languages the adapter serves. If another plugin already provides the language (for example Rust), your declaration only adds the ability to debug it and merges in the file extensions you declare; the language name, language ID, and language server stay the same, and installing the debugger does not restart a running language server. +- `targetBindings` fills facts about the debug target into the adapter's launch arguments: `program` ← the target path, `cwd` ← the working directory, `processId` ← the process ID. Only fields missing from `arguments` are filled; defaults you wrote explicitly are never overwritten. When the target lacks a fact a binding reads, Lithe refuses the launch and says what is missing. +- `configurationTypes` declares the `launch.json` `type` values the adapter matches, used to import the user's existing VS Code-style configurations. + +### 3. Launching the adapter + +`launch.process` has three common forms: + +| Form | When to use it | +| --- | --- | +| `{"launch": {"kind": "packaged", "path": …}}` | The adapter's license allows distribution and you can provide a build for each platform; the executable installs with the plugin package | +| `{"launch": {"kind": "systemCommand", "commands": ["lldb-dap"]}}` | The adapter is already installed on the user's machine (for example with the Xcode command line tools); the host resolves the command name with its own tool discovery. The Lithe desktop app currently searches only PATH, and a launch that depends on a project toolchain or a user override is refused | +| `{"launch": {"kind": "runSelectedRuntime", "tool": ""}}` | The adapter is a Python, Java, or Node.js program started with the runtime the user selected; `tool` must be a tool-definition ID listed in the top-level `requiresTools` | + +For the rules on `runSelectedRuntime.tool`, see [Tool references](../contract/v1/README.md#tool-references) in the contract. + +`launch.transport` is `stdio` or TCP (`tcpAnnounced`: the adapter prints its port in its output; `tcpPort`: a fixed port). TCP allows only loopback addresses. + +The plugin package and the Lithe install directory are read-only at run time. An adapter must not write logs, indexes, or state there, or it breaks code signing and delta updates. The host keeps the adapter's state in its own writable directory, never in the plugin package or the install directory. + +### 4. Languages that compile before debugging: `executable` + +Most DAP adapters take "the program to run" directly, but in languages such as C, C++, and Swift the user opens a **source file** while the thing to debug is the compiled output. +`configurations.launch.executable` is designed for this case: + +```json +"configurations": { + "launch": { + "targetBindings": { "program": "target.executable", "cwd": "target.workingDirectory" }, + "executable": { + "setting": "program", + "candidates": [ + "${fileDirectory}/${fileBasenameNoExtension}", + "${workspaceFolder}/${fileBasenameNoExtension}", + "${workspaceFolder}/build/${fileBasenameNoExtension}" + ] + } + } +} +``` + +- `setting` is the ID of a `file` setting you declare in `settings[]`. When the user picks a path on the settings page, that path is used. +- When the user has picked nothing, Lithe tries `candidates` in order (at most 8; each must start with `${workspaceFolder}/` or `${fileDirectory}/`, may use `${fileBasenameNoExtension}`, and must not contain `..`) and uses the first runnable file it finds. +- The path it finds is **shown back in that setting**, so the user can see which file will actually be debugged. +- A user-picked path that is not a runnable file is an error; Lithe does **not** quietly fall back to the candidates. +- `executable` and `targetBindings.program: "target.executable"` must be declared together. + +The setting's `description` is where you teach the user how to produce the program: give the build command (for example `clang -g main.c -o main`) and how to install the adapter itself. The LLDB plugin's `program` setting is written this way. For writing settings, see the contract's `settings` section; `scope: "project"` and `effect: "nextRun"` suit a path like this that takes effect on the next run. + +### 5. The debug console: prompt, completion, and `console` + +Lithe's debug console has an input line and a completion popup. Completions come from the adapter's DAP `completions` request; `console` adds two things: + +- `commandPrefix`: 1 to 8 characters with no spaces. Some adapters treat a bare word as an expression (lldb-dap does), so completing the first word mixes in language keywords such as `long`. With a declared prefix (lldb-dap uses a backtick `` ` ``), Lithe adds it in front of the input only when asking the adapter for completions and moves the adapter's answers back, so the user never sees it. +- `commands[]`: when the adapter does not support `completions`, or its live answers miss some commands, list the debugger commands (`name`, with an optional one-line `description`). Names are inserted as written, must be unique, and must not have padding spaces; descriptions are shown as written and are not translated. + +Declare at least one of the two. When the adapter completes live, do not copy its whole command set as well. + +### 6. Adapter behavior declarations and backend-started debugging + +A host never recognizes an adapter by its ID: what is specific to an adapter is written in the manifest, and the shared debug engine follows it. `features` and `defaults` are optional fields of a `debugAdapters` entry and need no version of their own. + +- `features.hotCodeReplace`: `request` is the DAP command the host sends to have the adapter apply changed code to the running debuggee, and declaring it means the adapter supports hot code replace; + `event` is a DAP event whose `message` the Debug Console shows as progress; `changedField` and `errorField` are the response-body fields for the list of replaced units and for a failure the adapter reports in a successful response. +- `features.stepFilters.argument`: the launch argument through which the adapter receives stepping filters; an argument the user's configuration already sets is kept. +- `defaults.stepFilters`: the filters used when the user configured none (`classNameFilters`, `skipSynthetics`, `skipStaticInitializers`, `skipConstructors`, `hideFilteredStackFrames`). +- A malformed name, or a field the contract does not define, is `plugin.manifest.invalid`; when neither is declared, the adapter runs with default behavior. For the full fields and limits, go by + [Adapter features and defaults](../contract/v1/README.md#adapter-features-and-defaults) in the contract. + +A plugin with a backend (`requires.backend`) can decide which debug session to start, while the host still owns the session (the process, the console, the Stop button, and the end of the session). The method prefix is `lithe/host/`: + +| Method | API | Permission | Purpose | +| --- | --- | --- | --- | +| `debug/start` | API-30 | `debug.start` | Start a `launch` or `attach` session on an adapter the plugin declares itself and return a `sessionID`; needs a declared `debugAdapters` entry | +| `debug` field of `run/start` | API-23 | `run.start` | Turn a host-owned run into a debug session | +| `run/stop` | API-23 | `run.start` | Stop a debug session (by `sessionID`) or a run (by `runID`) the plugin started | + +- `debug/start` takes `context, label, adapter, request, configuration`. `adapter` must be one of the plugin's own adapters (its local ID or `/`); + an adapter of another plugin is `plugin.host.notFound`. `request` must be one the adapter declares. `configuration` is a JSON object of at most 256 KiB that is passed to the adapter as the DAP launch or attach arguments + on top of its declared defaults; `noDebug` and keys starting with `__` belong to the host and are refused. The adapter itself still declares `debug.launch` (plus `debug.attach` for `attach`). +- The `debug` field of `run/start` is `{configurationType, target}`. With `target` as `{kind: "connection", port}`, the process listens for a debugger on `port` and the host waits for it and attaches the adapter; + with `{kind: "launch"}`, the adapter starts the process itself and takes the program, arguments, directory, and environment from `process` through its `targetBindings`. + `process.executable` can only be a tool-definition ID listed in the manifest's `requiresTools`, or a file inside the plugin package. The host sends the `run/didExit` notification when the run ends. + +Whether these three methods can be called is decided by what the host returns from `getCapabilities`. The Lithe desktop app's capability declaration comment lists the run and debug services as not yet registered, so do not assume they are available. +The official Node, Java, and Rust SDKs do not wrap `debug/start`, `run/start`, or `run/stop` yet; where needed, call them with the SDK's generic host request using the full method name, +for example `backend.host.request(method, params?)` in the Node SDK, see [Host services](../sdk/node/README.md#host-services). + +For details, see [Debug adapters](../reference/domains/debug-adapters.md) and +[Run and debug sessions](../contract/v1/README.md#run-and-debug-sessions) in the contract. +Examples: [`debug-adapters/debug-adapter-features.plugin.json`](../contract/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json), +[`debug-adapters/debug-start.json`](../contract/v1/fixtures/debug-adapters/debug-start.json), and +[`run/run-start.json`](../contract/v1/fixtures/run/run-start.json). + +### 7. Local verification + +1. Compare with the contract example [`contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json`](../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) and check fields with the [editor schema](../README.md#validate-pluginjson-in-your-editor). +2. [`contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json`](../contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json) lists, for each rejection, the stable error code, the offending field, and the layer that reports it (`schema`, `coreManifest`, `corePlan`, `coreInstall`); compare against it directly when writing tests. +3. Install the plugin in the Lithe desktop app, open a target file, set a breakpoint, and start debugging. When the adapter rejects a request, Lithe shows the adapter's own explanation, so write the adapter's error text for users to understand. +4. On the settings page, confirm that the path `executable` found is shown, then change it to a file that does not exist and confirm you get an error rather than a quietly swapped file. + +### 8. Whether installing needs a restart + +A plugin that contributes only debug adapters (and the languages and settings it declares) needs **no restart** after install, update, rollback, uninstall, enable, or disable in a Lithe that supports hot activation. The host decides this from the contribution types it can actually register and withdraw at run time; it is not a `plugin.json` field, so plugin authors neither need to nor can declare it. Plugins with other contributions (language servers, build systems, UI, and so on) still take effect after a restart. + +### 9. Publishing + +A debug adapter plugin uses the baseline-0 contract, and the marketplace offers it for installation only on a host that provides `debugAdapters`. + +- Mark your GitHub Release as a **prerelease** while a capability the plugin declares, or a protocol its SDK requires (for example `litheLogging`), is not yet in a stable Lithe release. Lithe Store's automatic tracking only picks up full Releases, so a prerelease needs a hand-written index entry; follow the existing prerelease index entries of the official plugins. +- Once a stable release supports every capability and protocol the plugin needs, convert the Release to a full release, and the tracker submits index updates for later versions automatically. +- Do not replace or delete assets after publishing; the index pins their SHA-256. + +### 10. Current limits + +- The host implementation of debug adapters is the Lithe desktop app. +- The host's own debug entry issues only `launch` requests to plugin adapters. `attach` can be declared in the manifest: the host chooses it for connection debugging of build-provider service runs (see [Registry connection debugging](../contract/v1/README.md#registry-connection-debugging) in the contract), and a plugin backend can start it with `debug/start` or the `debug` field of `run/start` (see section 6). Remote attach with a user-entered host and port follows what the host actually presents; do not rely on it. +- An adapter declaration is pure data, with no custom UI and no host integration points; for capabilities beyond debugging (build, test, run configurations), use the matching contribution types. diff --git a/shared/plugins/guides/lsp-plugin-lifecycle.md b/shared/plugins/guides/lsp-plugin-lifecycle.md new file mode 100644 index 00000000..6944fe6c --- /dev/null +++ b/shared/plugins/guides/lsp-plugin-lifecycle.md @@ -0,0 +1,959 @@ +# 从零构建一个完整生命周期的 LSP 插件 / Building an LSP plugin with a complete lifecycle + +[中文](#中文) · [English](#english) + +## 中文 + +这篇教程面向想给 Lithe 增加语言支持的插件作者。我们用可运行的 +[Lua Support 模板](https://github.com/Lithe-IDEA/lithe-plugin-lua)走完选型、清单、语言服务器打包、 +安装与激活、日常运行、停止、升级和卸载;构建、运行与测试的接入另见 +[构建工具接入指南](build-tool-integration.md)。发布步骤见[发布社区插件](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md)。 + +本文和契约、SDK 一样放在 Core 仓库的 `shared/plugins/` 下,只有这一份来源,随 Core 的同一次改动更新; +你不需要克隆 Lithe 主仓库。清单字段、版本和错误码以[插件契约](../contract/v1/README.md)为准; +宿主能运行什么,以 Lithe 桌面端的[声明与说明](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) +(`Declared()` 及其上方的注释)为准。目前唯一的宿主是 Lithe 桌面端,下文的“宿主”都指它。对本文有疑问,请到 +[Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +桌面端与本文相关的现状: + +- 语言服务器:桌面端解析 Core 规划的启动(包内程序、包内运行时、PATH 上的裸命令,或插件后端回答的命令行), + 启动进程,让会话文档与编辑器保持同步,并绘制服务器发布的诊断。**目前只呈现诊断**;补全、悬停、跳转、重命名、 + 格式化还没有编辑器界面。系统命令只从 PATH 查找,依赖项目工具链或用户覆盖的计划会被拒绝。 +- 不声明 `executionProviders`:`requires` 里有它的插件不会被提供安装。因此本文的示例只含语言服务器。 +- 安装:从插件市场(含自行添加的 HTTPS 索引源)或从磁盘选择 zip,两条路径都先显示安装审查,由用户确认; + 安装、启用、停用和卸载立即生效,不需要重启 Lithe。 +- 各能力的支持情况见[版本对照](../reference/capabilities-and-versions.md)。Lua 模板目前只提供 macOS arm64、x64 两个包。 + +### 1. 先确定插件与宿主各自负责什么 + +LSP(Language Server Protocol,语言服务器协议)让编辑器通过独立进程获得补全、诊断、 +跳转等能力。Lithe 的声明式插件用 JSON 描述要启动的程序;宿主读取清单、发现运行时并管理 +进程及 LSP 会话。你不需要编写在宿主进程内运行的插件入口。命令行要到启动时才能确定的服务器, +可以改由插件后端在每次启动时解析,见第 3 节的“由插件后端驱动的语言服务器”。 + +```text +开发与构建:固定上游版本 → 下载并校验 → 组装各平台插件包 → 发布 zip → 索引收录 +用户安装:下载 zip(或从磁盘选择包)→ 校验摘要与清单 → 确认程序及权限 → 安装并记录完整性摘要 +正常运行:启用 → 打开对应语言的文件 → 启动服务器 → 初始化 LSP → 编辑与诊断 +结束使用:禁用/关闭工作区 → 宿主停止会话和进程;更新/卸载 → 替换或移除包 +``` + +| 对象 | 插件作者负责 | Lithe 负责 | +| --- | --- | --- | +| 语言识别 | 文件扩展名、完整文件名、项目标记 | 匹配文件并选择提供者 | +| 语言服务器 | 上游选型、许可、固定版本、启动入口、正确参数 | 校验声明、按需启动、LSP 通信与停止 | +| 运行时 | 声明依赖(`requiresTools`、`runtimeDependencies`),或用 `packagedRuntime` 随包提供 | 按声明发现、检查并启动运行时 | +| 运行与测试 | 声明命令、参数和测试范围 | 生成计划、执行与展示结果 | +| 可写数据 | 指定上游使用的日志、索引和状态路径 | 为启动模板提供平台可写目录 | +| 发布与维护 | 各平台产物、版本升级、兼容与回退验证 | 下载校验、安装审查、插件启停 | + +先选成熟且许可允许分发的语言服务器。项目导入、依赖解析、符号和模块模型应尽量由该语言的 +成熟后端提供。不要为了“接上 LSP”再复制一套工程模型。LSP 支持的具体功能仍取决于服务器 +能力与宿主支持;安装服务器不会自动带来构建、测试、DAP 调试或自定义界面。 + +### 2. 跑通原版模板,再改成自己的插件 + +本地需要 Git、Node.js 22+、`tar`、`zip` 和 `unzip`。打包工具使用 Node.js, +不代表目标语言服务器需要 Node.js;LuaLS 是平台相关的原生程序。 + +```bash +git clone https://github.com/Lithe-IDEA/lithe-plugin-lua.git +cd lithe-plugin-lua +node scripts/package.mjs +unzip -l dist/lua-support-0.3.1-darwin-arm64.zip +shasum -a 256 dist/lua-support-0.3.1-darwin-arm64.zip +``` + +这里的 `0.3.1` 是本文核对时的模板版本;上游更新后,产物名以模板清单和脚本输出为准。 +脚本会重新生成 `dist/`,下载两个 macOS 架构的 LuaLS、核对固定 SHA-256,并输出每个 zip +的 SHA-256。Linux CI 可以组装 macOS 包,但这只证明打包成功,不能替代在目标 Mac 上运行。 + +模板源码与产物的关系: + +```text +源码仓库 +├── io.github.lithe-idea.lua-support/ +│ ├── plugin.json +│ └── bin/lithe-lua-language-server # 保留可执行权限的启动器 +├── language-server.json # 构建输入:版本、各平台 URL 与 SHA-256 +├── scripts/package.mjs # 下载、校验、解包、组装 zip +└── .github/workflows/ # PR 校验与标签发布 + +产物 lua-support-0.3.1-darwin-arm64.zip +└── io.github.lithe-idea.lua-support/ + ├── plugin.json + ├── bin/lithe-lua-language-server + └── language-server/ # 完整上游资源,不只是可执行文件 + ├── bin/lua-language-server + └── … # 上游依赖、脚本和许可文件 +``` + +模板清单除语言服务器外,还声明了运行 `lua <当前文件>` 的执行提供者(`process.run` 权限和 +`onRun:lua` 事件);启动器的第一个参数是宿主传入的 `${logDir}`。桌面端不声明 `executionProviders`, +所以要让插件能在桌面端安装,清单里不能带这一段(见第 3 节的示例)。 + +派生自己的仓库后,同步修改以下位置: + +1. 包目录名、`plugin.json` 的 `id`、显示名、版本与 `vendor`,使用你自己的稳定反向域名。 +2. `scripts/package.mjs` 的 `PACKAGE_DIR`、`ASSET_PREFIX`,以及与新服务器对应的资源检查。 +3. `language-server.json` 的版本、各平台 URL、摘要和许可信息。它是模板脚本自己的输入, + 不是宿主自动解释的统一下载 API;更换清单结构时也要修改打包脚本。 +4. 启动器中的包内入口、参数和缓存命名空间。不能继续写入 Lua 模板的缓存目录。 +5. CI 中读取清单的目录名、Release 说明、README、许可证与第三方声明。 + +不要只换包目录名:模板 CI 中也有原插件 ID,启动器中也有缓存路径。 + +### 3. 写一个最小、完整的 LSP 清单 + +下面是一个纯 LSP 示例。它沿用模板的目录布局和启动器,先只接入 LuaLS;如要运行 Lua 程序, +再按[构建工具指南](build-tool-integration.md)添加运行提供者(目前不能在桌面端安装)。示例中的作者和 ID 要换成自己的。 + +```json +{ + "schemaVersion": 0, + "id": "io.github.alice.lua-support", + "displayName": "Lua Support", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, + "vendor": { + "id": "io.github.alice", + "displayName": "Alice", + "signatureRequirement": "none" + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:lua"], + "languages": [ + { + "id": "lua", + "displayName": "Lua", + "fileExtensions": ["lua"], + "projectFileNames": [".luarc.json", ".luarc.jsonc"] + } + ], + "languageServers": [ + { + "languageID": "lua", + "languageIdentifier": "lua", + "launch": { + "kind": "packaged", + "path": "bin/lithe-lua-language-server" + }, + "arguments": ["${logDir}"], + "validationArguments": [] + } + ] +} +``` + +清单里有几个容易混淆的版本:`schemaVersion` 是清单文件格式,`pluginAPIVersion` 是基础字段的版本, +`requires` 里是每个能力的版本,这三处现在都写 `0`;`version: "0.1.0"` 是你的插件版本。 +上游 LuaLS 的版本另记在构建输入中。`requires` 必须**恰好**列出清单用到的能力:漏一个或多列一个都是 +`plugin.manifest.invalid`;宿主没有声明的能力会让安装被拒绝,错误是 `plugin.api.unsupported`,`field` 指出是哪一个。 +启动进程与 `${logDir}` 这类启动模板属于基础契约,不是能力,不需要声明。 + +`languages[].id`、`languageServers[].languageID` 和 `onLanguage:` 必须对应。 +声明服务器需要 `languageServer.launch` 权限及激活事件;每个语言最多一个服务器。 +事件集合必须与实际提供者精确对应,不能随意增加未使用的事件。权限是能力声明, +不是限制外部程序磁盘或网络访问的操作系统沙箱。 +启用只表示允许激活,打开项目中相应语言的文档才触发服务器启动。 +文件后缀不等于 LSP 的文档标识,必要时单独设置 `languageIdentifier`。 + +#### 选择启动方式 + +四种启动方式属于基础契约(见 [Process start](../contract/v1/README.md#process-start)): + +| 方式 | 适用场景 | 作者要做的事 | +| --- | --- | --- | +| `packaged` | 随包提供原生服务器或启动器 | 声明相对路径,打包全部资源并保留可执行权限 | +| `systemCommand` | 明确要求用户自行安装的服务器 | 声明裸命令候选和正确探测参数,记录用户安装要求;桌面端只在 PATH 上查找 | +| `runtime` | 包内 JS/JAR 由 Node.js/Java 启动 | 声明包内入口、运行时的工具 ID(列入 `requiresTools`)及最低版本,或用 `packagedRuntime` 随包提供运行时 | +| `backend` | 命令行取决于启动时才能确定的内容(解析版本、引导工具、按项目派生选项) | 声明解析命令行的后端函数;见下文“由插件后端驱动的语言服务器” | + +例如,下面是 `languageServers[]` 中一个服务器声明的替代方案,要求用户预先安装 `gopls`, +完整清单还必须声明 `go` 语言及相应权限、事件,并在清单顶层写 `"requiresTools": ["go"]` +(`go` 的工具定义来自本插件或另一个已启用插件的 `toolDefinitions`): + +```json +{ + "languageID": "go", + "launch": { "kind": "systemCommand", "commands": ["gopls"] }, + "arguments": ["serve"], + "validationArguments": ["version"], + "runtimeDependencies": [{ "tool": "go" }] +} +``` + +探测参数必须能自行以退出码 0 结束,不能用会持续运行的 LSP 启动参数。若上游没有合适探测 +方式,可以像 Lua 模板一样用空数组,让 LSP 握手决定是否可用。通过探测仍不代表补全已经正常。 + +随包分发时优先使用固定、可复验的服务器。`systemCommand` 不会替用户安装工具,也不能作为 +启动时下载服务器的变通方案。运行时依赖与主入口是两回事:例如随包服务器调用 Go 时, +`runtimeDependencies` 让宿主发现 Go 并传递环境(把路径导出为 `LITHE_GO_PATH`,并把它的目录放到服务器 `PATH` 前面)。 +每个依赖都是一个工具定义 ID,必须列在清单顶层的 `requiresTools` 中;启动用到的每个工具 ID, +包括 `runtime` 启动的 `tool`,都要列入,没列入的 ID 会以 `plugin.runtime.undeclared` 被拒绝。 +详见 [Tool references](../contract/v1/README.md#tool-references)。 + +#### 由插件后端驱动的语言服务器 + +上面几种方式都由清单直接写出命令行。命令行要到启动时才能确定(解析某个版本、引导一个工具、按项目派生选项), +或者插件需要在服务器运行期间向它发请求、接收它的通知时,可以让插件后端参与。进程、LSP 会话和停止仍归宿主; +后端只回答命令行,并通过宿主方法与**自己的**服务器交谈。 + +- **后端决定启动方式。** `launch` 写 `{"kind": "backend", "function": "launch.resolve", "timeoutMilliseconds": 30000}`。 + 每次启动时,宿主调用这个插件函数,后端返回可执行文件(包内文件,或插件自己定义的工具)、参数、环境变量等,宿主按答案启动。 + 这类服务器不再声明 `arguments`、`validationArguments`、`runtimeDependencies`、`initializationOptions` 和 `hostIntegration`。 + 清单需要 `backend`、`languageServers` 和 `languages` 三个能力(答案里用到插件自己的工具时再加 `toolDefinitions`, + 并在 `requiresTools` 列出该工具),并声明 `backend.launch` 权限。 + 桌面端目前不处理 `{"kind": "tool"}` 这种答案,会在启动时拒绝;后端应回答包内可执行文件(`{"kind": "packaged", "path": …}`)。 +- **会话字段。** 后端只能做服务器条目声明过的事: + `requestMethods`(允许用 `languageServer/request`、`languageServer/notify` 发的 LSP 方法)、 + `commands`(允许执行的 `workspace/executeCommand` 命令,与 `requestMethods` 是否列出该方法一一对应)、 + `serializedCommands`(`commands` 的子集:同一命令同时只跑一个,后来的请求并入并得到同一个结果)、 + `forwardNotifications`(宿主转给后端的服务器通知)、 + `state`(按工作区的状态目录及 `retention` 的 `maxEntries`、`maxAgeDays`,宿主据此在后台回收旧目录)、 + `remote`(`supported` 或 `unsupported`,后者不在远程工作区启动)。 + 这些声明由 Core 的会话策略强制执行:宿主不能放宽,后端也越不过去。 +- **权限 `languageServer.request`。** 只有服务器列出 `requestMethods` 或 `forwardNotifications` 时才能声明,反过来也一样;需要 `backend` 能力。 + 它只授予本插件自己服务器的 `languageServer/*` 宿主方法和下面两个通知。 + +宿主方法和通知(前缀 `lithe/host/`;方法都要带 `context` 和 `serverID`,`serverID` 是本插件某个服务器的 `languageID`, +别的插件的服务器返回 `plugin.host.foreignContribution`): + +| 方法或通知 | API | 权限 | 用途 | +| --- | --- | --- | --- | +| `languageServer/request` | API-25 | `languageServer.request` | 向自己的服务器发 JSON-RPC 请求,返回服务器的 `result`;超时默认 60 秒、最长 300 秒 | +| `languageServer/notify` | API-25 | `languageServer.request` | 向自己的服务器发通知 | +| `languageServer/setStatus` | API-22、API-25 | `languageServer.request` | 在宿主状态界面显示插件自己的准备状态(`preparing`、`ready`、`degraded`、`failed`,可带消息和百分比),用于导入工程、建索引 | +| `languageServer/didChangeState`(通知) | API-25 | `languageServer.request` | 服务器状态变化(`starting`、`running`、`stopped`、`crashed`),带 `generation` | +| `languageServer/didReceiveNotification`(通知) | API-25 | `languageServer.request` | 转发服务器发出、`forwardNotifications` 列出的通知;过大时只送 `truncated: true` | +| `documents/registerContentProvider` | API-12 | `documents.contentProvider` | 把一个 URI scheme 绑定到后端函数,见下 | + +服务器交出的文档没有对应文件(例如反编译的库类)时,用 `documents/registerContentProvider` 提供:用户打开该 scheme 的文档时, +宿主调用绑定的函数取只读内容(最多 1 MiB,宿主等 30 秒)。每个工作区窗口每个 scheme 只能有一个 provider,用 `subscriptions/dispose` 撤销。 + +边界:声明 `remote: "unsupported"` 的服务器不在远程工作区启动。官方 Node、Java、Rust SDK 还没有这些方法的专用封装, +可以用 SDK 的通用宿主请求,以完整方法名调用,例如 Node SDK 的 `backend.host.request(method, params?)`, +见 [Host services](../sdk/node/README.md#host-services)。桌面端只注册自己实现的宿主服务, +调用前用 `supports(method)` 确认。 + +细节见[语言、语言服务器与运行/测试](../reference/domains/languages.md)、[编辑器、文档与预览](../reference/domains/editor.md), +以及契约的 [Backend-resolved launch](../contract/v1/README.md#backend-resolved-launch)、 +[Plugin-owned sessions](../contract/v1/README.md#plugin-owned-sessions)、 +[Language server host methods](../contract/v1/README.md#language-server-host-methods)、 +[Content providers](../contract/v1/README.md#content-providers)。 +示例包:[`languages/language-server-backend-launch.plugin.json`](../contract/v1/fixtures/languages/language-server-backend-launch.plugin.json)。 + +### 4. 在构建阶段准备完整语言服务器 + +沿用模板的处理顺序: + +1. 固定上游发行版本和每个平台/架构的 HTTPS 归档地址,核实许可证及再分发条件。 +2. 将归档 SHA-256 写入版本控制。摘要应通过上游校验资料或经过审查的下载确认, + 构建时不能现下载、现算一个摘要就当作预期值。 +3. 下载后先核对摘要,再解压到临时组装目录。缓存命中也必须重新校验;不匹配立即失败。 +4. 复制服务器所需的全部文件:二进制、脚本、资源、动态库、许可证等;检查声明入口存在。 +5. 检查归档没有符号链接、越界路径和开发机绝对路径,保留启动器与二进制的可执行权限。 +6. 按平台生成 zip,再计算最终 zip 的 SHA-256,交给市场索引固定。 + +上游归档摘要与最终 zip 摘要用途不同:前者保证构建输入,后者保证用户拿到同一个发布包。 +更换语言服务器时不能只替换下载地址,必须同步检查解压布局、入口、依赖、探测方式及许可。 +模板中的归档处理适配 LuaLS 的 tar.gz;其他格式需要在自己的打包脚本中明确实现。 + +社区插件使用自己的 ID 和 `vendor.signatureRequirement: "none"`,通过市场源的 +摘要校验或从磁盘安装时的私有快照、用户确认和加载时内容复查安装。不要伪造 Lithe 发布者签名, +也不要使用保留的 `dev.lithe.*` 命名空间。官方包的发布者签名是另一条信任路径,签名必须在完整组装后完成。 + +### 5. 将可写状态与安装文件分开 + +Lithe 会在加载时复查社区包内容。服务器如果将日志、索引、锁或生成文件写回插件目录, +下次加载就可能失败;写入应用安装包还会破坏应用签名与增量更新基线。 + +| 数据 | 放在哪里 | 需要注意的生命周期 | +| --- | --- | --- | +| 服务器、运行时、默认配置、许可证 | 只读插件包 | 随插件版本安装和替换 | +| 上游原生日志 | `${logDir}`(宿主提供的本次会话日志目录) | 由宿主的日志存储管理,不写结构化事件,见[插件日志](plugin-logging.md) | +| 可重建缓存 | `${stateDir}`,或平台用户缓存目录 | 设置容量策略,记录清理方法 | +| 项目索引、锁、项目相关状态 | `${workspaceStateDir}` 等按工作区隔离的可写目录 | 不同项目、工作树不能共用同一份可变索引 | +| 用户源码与构建产物 | 用户工作区或构建工具指定目录 | 卸载插件不应误删用户工程 | + +Lua 模板的启动器把宿主传入的 `${logDir}` 交给 LuaLS 的 `--logpath`,把生成的库定义放在 +`~/Library/Caches/io.github.lithe-idea.lua-support/meta`(设置了 `XDG_CACHE_HOME` 时放在它下面), +再用 `exec` 将启动器替换为真实服务器。 +不要把服务器放到后台、`nohup` 或自行创建常驻守护进程,否则宿主难以完整停止它。 +标准输出只承载 LSP 的 JSON-RPC 帧,启动器提示与日志应写标准错误或日志文件。 + +模板中的自定义缓存路径属于启动器自己的约定,**不能假定宿主卸载时自动清理**。 +需要说明残留内容及安全删除方法。若自己的服务器维护工作区索引,还要增加稳定的工作区隔离, +不要直接复用模板的单一缓存目录保存所有项目索引。 + +#### 让宿主提供状态目录 + +也可以去掉启动器,把上面示例的 `languageServers` 替换为以下值,直接运行包内 LuaLS +并传入宿主管理的目录: + +```json +[ + { + "languageID": "lua", + "languageIdentifier": "lua", + "launch": { + "kind": "packaged", + "path": "language-server/bin/lua-language-server" + }, + "arguments": [ + "--logpath=${logDir}", + "--metapath=${workspaceStateDir}" + ], + "validationArguments": [] + } +] +``` + +`${logDir}` 是宿主显式提供的本次会话插件日志目录(只用于语言服务器和插件后端);宿主没有提供 `logDirectory` 时, +用到它的启动以 `plugin.launch.invalidRequest` 被拒绝。 +`${stateDir}` 是宿主为该插件服务器管理的可写状态目录,卸载插件时删除;`${workspaceStateDir}` 进一步 +按工作区隔离。`${pluginDir}` 是只读包根路径;`${arch}` 的值是 `arm64` 或 `x86_64`, +与 Release 资产里的 `x64` 拼写不同。`${setting:}` 引用已声明的设置。状态、日志和设置模板不能放进 `validationArguments`。 +完整变量表见 [Launch templates](../contract/v1/README.md#launch-templates)。 + +如果服务器需要 Node.js/Java,`runtime` 启动可免去启动器。以下是服务器声明中的 +`launch` 片段,入口要替换成自己的真实资源,服务器专用参数仍放在 `arguments`; +清单顶层要写 `"requiresTools": ["node"]`: + +```json +{ + "kind": "runtime", + "tool": "node", + "minimumVersion": "18.17", + "entry": "language-server/server.js" +} +``` + +宿主负责发现并检查运行时;不要再在 `runtimeDependencies` 中重复声明同一个启动工具。 +`runtime` 不声明 `validationArguments`,运行时探测由宿主执行。若改为 `packagedRuntime`, +该字段与 `minimumVersion` 互斥,插件要随包提供运行时、依赖和许可,并分别验证目标架构。 +见 [Runtime launch](../contract/v1/README.md#runtime-launch)。 + +服务器还可以带 `initializationOptions` 和声明式 `settings`(需要在 `requires` 里加 `settings`)。只传上游真实支持的选项; +`${setting:}` 只能引用已声明设置,值必须符合类型和范围。设置是插件级、跨工作区共享(`scope: "project"` 的设置可由项目覆盖), +修改后宿主默认重启相应服务器,`effect` 可以改变这一点。详细格式见[正式契约](../contract/v1/README.md#plugin-settings)。 + +### 6. 本地验证、安装与激活 + +先运行模板现有 CI 中的两层检查:`node scripts/package.mjs` 组装产物,随后用 Lithe Store +的 `readPackageManifests` 和 `checkManifest` 检查包路径、ID、版本和社区信任声明。 +可参考模板的 [CI 工作流](https://github.com/Lithe-IDEA/lithe-plugin-lua/blob/main/.github/workflows/ci.yml)。 +这些检查不会启动服务器,也不等同于完整的 Core 清单语义校验。 +在插件仓库根目录可以按 CI 的方式本地检查产物;下面的相邻目录应使用一个尚未占用的名字: + +```bash +git clone --depth 1 https://github.com/Lithe-IDEA/Lithe-store.git ../lithe-store-validation +node --input-type=module <<'JS' +import { readdirSync, readFileSync } from "node:fs"; +import { readPackageManifests, checkManifest } from "../lithe-store-validation/scripts/validate-index.mjs"; +const manifest = JSON.parse(readFileSync("io.github.lithe-idea.lua-support/plugin.json", "utf8")); +for (const name of readdirSync("dist")) { + const { manifests, unsafe } = readPackageManifests("dist/" + name, manifest.id); + const problems = [...unsafe, ...checkManifest(manifests, { id: manifest.id }, manifest)]; + if (problems.length) throw new Error(name + ": " + problems.join("; ")); + console.log("validated " + name); +} +JS +``` + +派生模板后,把这里读取 `plugin.json` 的目录名也换成自己的 ID。 +如果错误来自跨引用、模板上下文或 `requires`,还需对照契约并通过目标宿主的安装校验。 + +桌面端有两条安装 `none` 社区包的路径,两条都会显示安装审查,由用户确认: + +- **从磁盘安装**:在插件管理中选择从磁盘安装,选中 `dist/` 里的 zip。 + 宿主校验清单后保存一份私有快照,并把它标为发布者与源码仓库都未经验证的社区插件; + 首次安装、更新、重装和同版本替换每次都要用户确认。 + 它适合开发中反复验证,但不检查索引条目和 Release 资产的摘要。 +- **自己的插件源**:按[发布流程](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md)在自己的公开 GitHub 仓库 + 发布 zip,再准备仓库根目录的 `lithe-plugin-index.json`。索引格式与 Store 相同, + 下载 URL 指向该仓库的 Release 资产,摘要固定最终 zip;不必先申请进入公共 Store。 + 在插件市场里把你的仓库 URL(或索引文件的 HTTPS 地址)添加为插件源,即可通过自己的源安装,走的是用户真正会用的路径。 + +发布前至少用自己的插件源完整安装一次;磁盘安装不能替代这条路径。 + +安装前先禁用与你的插件声明同一语言的其他插件,避免提供者冲突。然后: + +1. 在插件市场选择你的源和版本(或从磁盘选择包),确认显示的程序、权限、来源都符合预期。 + 安装后无需重启 Lithe;如果插件没有出现,先看 `requires` 是否列了桌面端没有声明的能力。 +2. 打开一个小项目中的 `.lua` 文件,在代码里制造一个错误。应当出现诊断; + 不要只检查进程“已运行”。补全、悬停和跳转目前没有编辑器界面,需要核对时, + 用别的 LSP 客户端对同一个服务器验证。 +3. 保存修改,确认诊断更新;关闭并重开文件,确认没有重复服务器和重复诊断。 +4. 从图形界面(而不是终端)启动 Lithe 再试一次,避免依赖终端启动时偶然继承的 PATH。 +5. 若增加运行/测试,分别验证;LuaLS 已正常工作并不代表用户的 `lua` 已安装。 + +启动后的协议顺序也应能解释清楚:宿主发送 `initialize`,服务器返回能力后接收 +`initialized`,随后按协商能力通过 `didOpen`、`didChange`、`didSave`、`didClose` 同步文档, +诊断沿同一会话传输。正常停止先 `shutdown`,再 `exit`;初始化未完成或关闭 +超时时,宿主可以强停进程。超时取值由宿主决定,不要依赖某个具体数字:服务器应尽快完成初始化,并及时响应 `shutdown`。 +取消请求通过 `$/cancelRequest` 通知服务器,服务器仍可能完成计算,不能将取消当作进程已退出。 + +可用这个文件进行最小交互验收: + +```lua +---@param name string +local function greet(name) + return "Hello, " .. name +end + +print(greet("Lithe")) +``` + +把调用参数改成数字,观察类型诊断;再删掉函数名里的一个字母,观察未定义变量的诊断。 +具体提示随 LuaLS 版本和项目配置变化,验收时应记录所用版本和配置。 + +### 7. 把停止、失败和卸载也当作功能验收 + +声明式插件没有让作者注册任意 `activate()` / `deactivate()` 回调的机制。 +宿主管理会话、进程和停用;作者负责让服务器保持前台运行、遵守 LSP 生命周期, +并把所有子进程和状态设计成可以停止、重启和隔离。不能假定宿主的强停会自动终止服务器任意派生的整棵进程树, +所以启动器要用 `exec` 让位,服务器要自己回收子进程。下面是发布前需要执行的验收, +不是声明后即可免测的保证。 + +| 操作或故障 | 要检查的结果 | +| --- | --- | +| 仅安装/启用,不打开该语言 | 不因安装就提前启动语言服务器 | +| 编辑、保存、关闭/重开文件 | 文档同步和诊断正常,没有重复会话 | +| 快速切换两个项目/工作区 | 旧结果不串到新项目,索引与锁不互相占用 | +| 服务器异常退出或初始化失败 | 宿主能显示可定位的失败;恢复操作不会留下重复进程 | +| 缺少 Node.js/Go/Java 或版本不足 | 给出可操作的运行时错误,不在启动时自行下载 | +| 修改默认会重启服务器的设置 | 新会话使用新值,旧进程退出 | +| 禁用插件、关闭工作区、退出 Lithe | 相应会话与进程被停止,启动器/辅助子进程不残留 | +| 卸载与重新安装 | 卸载先停用,再删除包和宿主管理的插件状态;自定义缓存有明确说明 | +| 篡改安装包内一个文件后重新加载 | 内容复查拒绝加载,不能靠服务器自行修改清单“修复” | + +安装、启用、停用和卸载立即生效,没有重启这一步可以依赖。验收要分别覆盖这些操作之前和之后的状态, +卸载请求会先停用已注册的插件。 + +验收完成后关闭测试启动的应用、服务器和子进程。不要把关闭编辑器标签等同于卸载服务器, +也不要假定崩溃后一定会自动重试。发现生命周期问题时, +在插件仓库记录日志、宿主版本、包版本、架构和复现步骤;宿主进程管理问题再提交到 Lithe。 + +### 8. 发布、更新与回退演练 + +按[发布社区插件](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md)递增插件版本、发布正式 Release 并首次提交收录。 +模板的 Release 工作流会校验 `v<版本>` 与清单一致;原版示例是 +`node scripts/package.mjs --expect-tag v0.3.1`,派生后使用自己的版本。 + +每次更新服务器或运行时,都要更新固定输入和许可,重建对应平台包,走完交互及生命周期验收。 +发布后的 zip 不可替换,同一版本不能重用;修复要发布新版本。新增可运行程序会触发新的 +安装确认,具体规则见[版本追踪与更新](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md)。 + +发布前至少演练一次“旧版 → 新版 → 恢复旧版逻辑”: + +- 保留旧版本资产和索引记录,确保宿主仍有兼容版本可装;把插件改到新的能力版本时,索引要保留上一个旧要求的版本。 +- 新旧服务器使用不兼容索引格式时,隔离状态或提供安全重建;不能让旧版读损坏状态。 +- 当前版本有问题时,发布更高版本的修复包,必要时在新包里恢复上一个稳定服务器; + 同时申请撤回问题版本,不能依赖用户机器恰好保留上一版本,也不能指望宿主恢复服务器索引或用户数据。 +- 卸载重装也要验证。源码、用户构建产物与自定义缓存的处理必须说明,避免误删用户数据。 + +### 9. 按失败阶段排查 + +| 现象 | 优先检查 | +| --- | --- | +| 市场看不到或不可安装 | 源地址、当前平台资产,以及 `requires` 里是否有桌面端没有声明的能力(`plugin.api.unsupported`,`field` 指出哪一个) | +| `plugin.archive.checksumMismatch` | 是否替换过 Release 资产;索引固定的是否为最终 zip 摘要 | +| `plugin.manifest.invalid` | 未知字段、`requires` 与实际用到的能力不一致、ID/引用 | +| `plugin.schema.unsupported` / `plugin.api.unsupported` | `schemaVersion`、`pluginAPIVersion` 和 `requires` 里的版本是否都是 `0` | +| `plugin.permission.undeclared` / `plugin.activation.invalid` | 提供者、权限与事件是否一一对应 | +| `plugin.launch.missing` | 相对路径、大小写、打包完整性与可执行权限 | +| `plugin.launch.invalidRequest` | 用到 `${logDir}` 或 `${workspaceStateDir}`,但宿主没有提供对应目录 | +| `plugin.runtime.missing` / `plugin.runtime.undeclared` | 运行时发现、最低版本、图形界面启动时的环境;启动用到的工具 ID 是否都列在 `requiresTools` | +| 有进程但无法补全 | stdio 是否被日志污染、LSP 握手、文档语言标识与上游配置;桌面端目前只呈现诊断 | +| 用过一次后拒绝加载 | 服务器是否向包内写了日志、索引、锁或生成文件 | +| 禁用后仍有进程 | 启动器是否使用 `exec`,是否后台化或产生未清理辅助进程 | +| 能补全但无法构建/测试 | 是否实际声明并验证了独立的运行/测试或构建集成 | + +字段全集、版本规则和稳定错误码以 Lithe 的 +[插件契约](../contract/v1/README.md)及 +[清单 Schema](../contract/v1/plugin-manifest.schema.json)为准。 +它们和本文都在 Core 仓库的 `shared/plugins/`,同一次改动一起更新;宿主现状以 Lithe 桌面端的 `Declared()` 为准。 +发布前确认目标 Lithe 版本声明了你的 `requires` 里的每一个能力。 + +--- + +## English + +This tutorial is for plugin authors who want to add language support to Lithe. Using the runnable +[Lua Support template](https://github.com/Lithe-IDEA/lithe-plugin-lua), it walks through choosing a server, the manifest, packaging the language server, +installation and activation, daily operation, shutdown, upgrade, and uninstall. For build, run, and test, see the +[build tool integration guide](build-tool-integration.md). For publishing, see [Publishing a community plugin](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md). + +This page lives in `shared/plugins/` of the Core repository, next to the contract and the SDKs. It has one source and changes in the same +change as Core; you do not need to clone the Lithe repository. Manifest fields, versions, and error codes follow the [plugin contract](../contract/v1/README.md); +what the host can run follows the Lithe desktop's [declaration and its comment](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) +(`Declared()` and the comment above it). The only host today is the Lithe desktop, and "the host" below means it. For questions about this page, open an issue in +[Lithe-core-rust](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +What the desktop does that matters here: + +- Language servers: the desktop resolves the launch Core plans (a packaged program, a packaged runtime, a bare command found on PATH, or a command line the plugin backend answers), + starts the process, keeps the session's documents in step with the editor, and draws the diagnostics the server publishes. **Only diagnostics are presented today**; + completion, hover, navigation, rename, and formatting have no editor surface yet. System commands are looked up on PATH only, and a plan that depends on a project toolchain or a user override is refused. +- It does not declare `executionProviders`: a plugin whose `requires` lists it is not offered for installation. The examples on this page therefore contain only the language server. +- Installation: from the plugin marketplace (including HTTPS index sources you add yourself) or from a zip on disk; both paths show an install review for the user to confirm. + Install, enable, disable, and uninstall take effect immediately, with no Lithe restart. +- For what each capability supports, see the [version tables](../reference/capabilities-and-versions.md). The Lua template ships only macOS arm64 and x64 packages. + +### 1. Decide what the plugin owns and what the host owns + +LSP (Language Server Protocol) lets an editor get completion, diagnostics, navigation, and more +from a separate process. A Lithe declarative plugin describes the program to start in JSON; the host reads the manifest, discovers runtimes, and manages +the process and the LSP session. You do not write a plugin entry point that runs inside the host process. A server whose command line is only known at launch +can have the plugin backend resolve it at each start; see "Language servers driven by the plugin backend" in section 3. + +```text +Develop and build: pin upstream version → download and verify → assemble per-platform packages → publish zips → index entry +User install: download zip (or pick a package on disk) → verify checksum and manifest → confirm programs and permissions → install and record integrity digests +Normal operation: enable → open a file in the language → start the server → initialize LSP → edit and diagnose +Ending use: disable / close workspace → host stops sessions and processes; update / uninstall → replace or remove the package +``` + +| Concern | The plugin author owns | Lithe owns | +| --- | --- | --- | +| Language detection | File extensions, full file names, project markers | Matching files and choosing a provider | +| Language server | Choosing upstream, license, pinned version, entry point, correct arguments | Validating declarations, starting on demand, LSP communication and shutdown | +| Runtime | Declaring dependencies (`requiresTools`, `runtimeDependencies`), or shipping one with `packagedRuntime` | Discovering, checking, and starting runtimes as declared | +| Run and test | Declaring commands, arguments, and test scopes | Building plans, executing, and showing results | +| Writable data | The log, index, and state paths the upstream uses | Platform-writable directories for launch templates | +| Release and maintenance | Per-platform artifacts, version upgrades, compatibility and rollback checks | Download verification, install review, enabling and stopping plugins | + +Pick a mature language server whose license allows redistribution. Project import, dependency resolution, and symbol and module models should come from +the language's mature backend wherever possible. Do not duplicate a project model just to "hook up LSP". What LSP features you get still depends on the server's +capabilities and on host support; installing a server does not bring build, test, DAP debugging, or custom UI. + +### 2. Run the original template, then make it your own plugin + +You need Git, Node.js 22+, `tar`, `zip`, and `unzip`. The packaging tool uses Node.js; +that does not mean the target language server needs Node.js. LuaLS is a platform-specific native program. + +```bash +git clone https://github.com/Lithe-IDEA/lithe-plugin-lua.git +cd lithe-plugin-lua +node scripts/package.mjs +unzip -l dist/lua-support-0.3.1-darwin-arm64.zip +shasum -a 256 dist/lua-support-0.3.1-darwin-arm64.zip +``` + +`0.3.1` is the template version when this page was checked; after upstream updates, take the artifact names from the template manifest and the script output. +The script regenerates `dist/`, downloads LuaLS for both macOS architectures, checks the pinned SHA-256 values, and prints the SHA-256 of each zip. +Linux CI can assemble the macOS packages, but that only proves packaging works; it does not replace running on a target Mac. + +How the template source maps to the artifacts: + +```text +Source repository +├── io.github.lithe-idea.lua-support/ +│ ├── plugin.json +│ └── bin/lithe-lua-language-server # launcher, keeps its executable bit +├── language-server.json # build input: version, per-platform URLs and SHA-256 +├── scripts/package.mjs # download, verify, unpack, assemble zips +└── .github/workflows/ # PR checks and tag releases + +Artifact lua-support-0.3.1-darwin-arm64.zip +└── io.github.lithe-idea.lua-support/ + ├── plugin.json + ├── bin/lithe-lua-language-server + └── language-server/ # the complete upstream resources, not just the executable + ├── bin/lua-language-server + └── … # upstream dependencies, scripts, and license files +``` + +Besides the language server, the template manifest declares an execution provider that runs `lua ` (the `process.run` permission and +the `onRun:lua` event), and the launcher's first argument is the `${logDir}` the host passes in. The desktop does not declare `executionProviders`, +so for the plugin to install on the desktop, the manifest must not carry that part (see the example in section 3). + +After forking, change all of these together: + +1. The package directory name and, in `plugin.json`, the `id`, display name, version, and `vendor`, using your own stable reverse domain. +2. `PACKAGE_DIR` and `ASSET_PREFIX` in `scripts/package.mjs`, and the resource checks for your server. +3. The version, per-platform URLs, digests, and license information in `language-server.json`. It is the template script's own input, + not a unified download API the host interprets; if you change its structure, change the packaging script too. +4. The in-package entry, arguments, and cache namespace in the launcher. Do not keep writing into the Lua template's cache directory. +5. The directory name CI reads the manifest from, the Release notes, the README, the license, and third-party notices. + +Do not change only the package directory name: the template's CI also contains the original plugin ID, and the launcher contains its cache path. + +### 3. Write a minimal, complete LSP manifest + +Below is an LSP-only example. It keeps the template's layout and launcher and wires up only LuaLS; to run Lua programs, +add a run provider following the [build tool guide](build-tool-integration.md) (it cannot be installed on the desktop today). Replace the author and ID with your own. + +```json +{ + "schemaVersion": 0, + "id": "io.github.alice.lua-support", + "displayName": "Lua Support", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "languageServers": 0, "languages": 0 }, + "vendor": { + "id": "io.github.alice", + "displayName": "Alice", + "signatureRequirement": "none" + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:lua"], + "languages": [ + { + "id": "lua", + "displayName": "Lua", + "fileExtensions": ["lua"], + "projectFileNames": [".luarc.json", ".luarc.jsonc"] + } + ], + "languageServers": [ + { + "languageID": "lua", + "languageIdentifier": "lua", + "launch": { + "kind": "packaged", + "path": "bin/lithe-lua-language-server" + }, + "arguments": ["${logDir}"], + "validationArguments": [] + } + ] +} +``` + +Keep the versions apart: `schemaVersion` is the manifest format, `pluginAPIVersion` is the version of the base fields, +and `requires` holds each capability's version; all three are `0` today. `version: "0.1.0"` is your plugin's version. +The upstream LuaLS version lives in the build input. `requires` must list **exactly** the capabilities the manifest uses: one missing or one extra is +`plugin.manifest.invalid`; a capability the host does not declare makes the install fail with `plugin.api.unsupported`, and `field` names which one. +Starting a process and launch templates such as `${logDir}` belong to the base contract; they are not capabilities and need no declaration. + +`languages[].id`, `languageServers[].languageID`, and `onLanguage:` must match. +A server needs the `languageServer.launch` permission and an activation event; each language has at most one server. +The event set must match the actual providers exactly; do not add unused events. Permissions declare capabilities; +they are not an operating-system sandbox that limits an external program's disk or network access. +Enabling only allows activation; opening a document of the language in a project starts the server. +A file extension is not the LSP document identifier; set `languageIdentifier` separately when needed. + +#### Choosing a launch kind + +The four launch kinds belong to the base contract (see [Process start](../contract/v1/README.md#process-start)): + +| Kind | When to use it | What the author does | +| --- | --- | --- | +| `packaged` | A native server or launcher shipped in the package | Declare the relative path, package every resource, and keep the executable bit | +| `systemCommand` | A server the user is explicitly expected to install | Declare bare command candidates and a correct probe, and document the install requirement; the desktop looks only on PATH | +| `runtime` | A packaged JS file or JAR started by Node.js or Java | Declare the packaged entry, the runtime's tool ID (listed in `requiresTools`) and minimum version, or ship the runtime with `packagedRuntime` | +| `backend` | A command line that is only known at launch (resolving a version, bootstrapping a tool, deriving options from the project) | Declare the backend function that resolves the command line; see "Language servers driven by the plugin backend" below | + +For example, this is an alternative server declaration in `languageServers[]` that expects the user to have installed `gopls`; +the full manifest must also declare the `go` language, the matching permission and event, and write `"requiresTools": ["go"]` at the top level +(the `go` tool definition comes from this plugin's or another enabled plugin's `toolDefinitions`): + +```json +{ + "languageID": "go", + "launch": { "kind": "systemCommand", "commands": ["gopls"] }, + "arguments": ["serve"], + "validationArguments": ["version"], + "runtimeDependencies": [{ "tool": "go" }] +} +``` + +Probe arguments must exit with code 0 on their own; never use the long-running LSP start arguments. If the upstream has no suitable probe, +use an empty array as the Lua template does, and let the LSP handshake decide. Passing the probe still does not mean completion works. + +When shipping a server, prefer a pinned, reproducible one. `systemCommand` does not install tools for the user, and it is not a workaround for +downloading a server at launch. Runtime dependencies are separate from the main entry: for example, when a packaged server calls Go, +`runtimeDependencies` makes the host discover Go and pass the environment (it exports the path as `LITHE_GO_PATH` and puts its directory in front of the server's `PATH`). +Each dependency is a tool definition ID and must be listed in the manifest's top-level `requiresTools`; every tool ID a launch uses, +including the `tool` of a `runtime` launch, must be listed, and an unlisted ID is refused with `plugin.runtime.undeclared`. +See [Tool references](../contract/v1/README.md#tool-references). + +#### Language servers driven by the plugin backend + +The kinds above all write the command line into the manifest. When the command line is only known at launch (resolving a version, bootstrapping a tool, +deriving options from the project), or when the plugin needs to send requests to the running server and receive its notifications, the plugin backend can take part. +The process, the LSP session, and stopping still belong to the host; the backend only answers the command line and talks to **its own** server +through host methods. + +- **The backend decides how to launch.** Write `launch` as `{"kind": "backend", "function": "launch.resolve", "timeoutMilliseconds": 30000}`. + At each start the host calls that plugin function, the backend returns the executable (a file in the package, or a tool the plugin defines), the arguments, the environment, and so on, + and the host starts the server from the answer. Such a server no longer declares `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions`, or `hostIntegration`. + The manifest needs the `backend`, `languageServers`, and `languages` capabilities (plus `toolDefinitions` when the answer uses a tool the plugin defines, + with that tool listed in `requiresTools`), and declares the `backend.launch` permission. + The desktop does not handle an answer of `{"kind": "tool"}` and refuses it at start; the backend should answer a packaged executable (`{"kind": "packaged", "path": …}`). +- **Session fields.** The backend may do only what the server entry declares: + `requestMethods` (the LSP methods `languageServer/request` and `languageServer/notify` may send), + `commands` (the `workspace/executeCommand` commands it may run; declared exactly when `requestMethods` lists that method), + `serializedCommands` (a subset of `commands`: one runs at a time, and a later request joins it and gets the same result), + `forwardNotifications` (server notifications the host forwards to the backend), + `state` (per-workspace state directories with a `retention` of `maxEntries` and `maxAgeDays`, which the host uses to prune old directories in the background), + and `remote` (`supported` or `unsupported`; the latter is not started for a remote workspace). + Core's session policy enforces these declarations: the host cannot widen them and the backend cannot step outside them. +- **The `languageServer.request` permission.** It can be declared only when a server lists `requestMethods` or `forwardNotifications`, and the other way round; it needs the `backend` capability. + It grants only the `languageServer/*` host methods and the two notifications below, for the plugin's own servers. + +Host methods and notifications (prefix `lithe/host/`; every method takes `context` and `serverID`, the `languageID` of one of the plugin's servers; +a server of another plugin returns `plugin.host.foreignContribution`): + +| Method or notification | API | Permission | Purpose | +| --- | --- | --- | --- | +| `languageServer/request` | API-25 | `languageServer.request` | Send a JSON-RPC request to your own server and get the server's `result`; the timeout defaults to 60 s and is at most 300 s | +| `languageServer/notify` | API-25 | `languageServer.request` | Send a notification to your own server | +| `languageServer/setStatus` | API-22, API-25 | `languageServer.request` | Show the plugin's own preparation state in the host's status UI (`preparing`, `ready`, `degraded`, `failed`, with an optional message and percentage), for importing a project or indexing | +| `languageServer/didChangeState` (notification) | API-25 | `languageServer.request` | The server's state changed (`starting`, `running`, `stopped`, `crashed`), with a `generation` | +| `languageServer/didReceiveNotification` (notification) | API-25 | `languageServer.request` | A notification the server sent and `forwardNotifications` lists; only `truncated: true` is delivered when it is too large | +| `documents/registerContentProvider` | API-12 | `documents.contentProvider` | Bind a URI scheme to a backend function; see below | + +When the server hands out documents that have no file (for example the decompiled source of a library class), serve them with `documents/registerContentProvider`: when the user opens a document of that scheme, +the host calls the bound function for its read-only content (at most 1 MiB; the host waits 30 seconds). Each workspace window can have one provider per scheme; revoke it with `subscriptions/dispose`. + +Limits: a server that declares `remote: "unsupported"` does not start in a remote workspace. The official Node, Java, and Rust SDKs do not wrap these methods yet; +until they do, call them with the SDK's generic host request using the full method name, +for example `backend.host.request(method, params?)` in the Node SDK, see [Host services](../sdk/node/README.md#host-services). The desktop registers only the host services it implements, +so confirm with `supports(method)` before calling. + +For details, see [Languages, language servers, and run and test](../reference/domains/languages.md) and [Editor, documents, and previews](../reference/domains/editor.md), +and these contract sections: [Backend-resolved launch](../contract/v1/README.md#backend-resolved-launch), +[Plugin-owned sessions](../contract/v1/README.md#plugin-owned-sessions), +[Language server host methods](../contract/v1/README.md#language-server-host-methods), and +[Content providers](../contract/v1/README.md#content-providers). +Example package: [`languages/language-server-backend-launch.plugin.json`](../contract/v1/fixtures/languages/language-server-backend-launch.plugin.json). + +### 4. Prepare the complete language server at build time + +Follow the template's order: + +1. Pin the upstream release version and the HTTPS archive URL for each platform and architecture, and check the license and redistribution terms. +2. Commit the archive SHA-256 values. Confirm them from the upstream's checksum data or from a reviewed download; + never download at build time and treat a freshly computed digest as the expected value. +3. Verify the digest after downloading, then unpack into a temporary assembly directory. Re-verify on a cache hit too; fail immediately on a mismatch. +4. Copy every file the server needs: binaries, scripts, resources, dynamic libraries, licenses; check that the declared entry exists. +5. Check that the archive has no symbolic links, paths escaping the root, or absolute paths from the build machine, and keep the executable bit on the launcher and binaries. +6. Produce one zip per platform, then compute the SHA-256 of each final zip for the marketplace index to pin. + +The upstream archive digest and the final zip digest serve different purposes: the first guarantees the build input, the second that users get the same released package. +Switching language servers is more than swapping the download URL; recheck the unpacked layout, entry, dependencies, probe, and license. +The template's archive handling fits LuaLS's tar.gz; other formats need explicit handling in your own packaging script. + +A community plugin uses its own ID and `vendor.signatureRequirement: "none"`, and installs through a marketplace source's +checksum or, from disk, a private snapshot, plus user confirmation and a content recheck at load. Do not forge a Lithe publisher signature, +and do not use the reserved `dev.lithe.*` namespace. Official packages' publisher signatures are a separate trust path, and signing must happen after full assembly. + +### 5. Keep writable state apart from installed files + +Lithe rechecks a community package's contents at load. If the server writes logs, indexes, locks, or generated files back into the plugin directory, +the next load may fail; writing into the application bundle also breaks the app signature and the delta-update baseline. + +| Data | Where it goes | Lifecycle to consider | +| --- | --- | --- | +| Server, runtime, default configuration, licenses | The read-only plugin package | Installed and replaced with the plugin version | +| Native upstream logs | `${logDir}` (the current-session log directory the host supplies) | Managed by the host's log storage; not structured events, see [plugin logging](plugin-logging.md) | +| Rebuildable caches | `${stateDir}`, or the platform user cache directory | Set a size policy and document how to clean up | +| Project indexes, locks, project state | A per-workspace writable directory such as `${workspaceStateDir}` | Different projects and worktrees must not share one mutable index | +| User source and build output | The user's workspace or the directory the build tool chooses | Uninstalling the plugin must not delete user projects | + +The Lua template's launcher passes the host's `${logDir}` to LuaLS's `--logpath`, keeps generated library definitions in +`~/Library/Caches/io.github.lithe-idea.lua-support/meta` (under `XDG_CACHE_HOME` when set), +and then uses `exec` to replace itself with the real server. +Do not background the server, use `nohup`, or create your own daemon, or the host cannot stop it completely. +Standard output carries only LSP JSON-RPC frames; launcher messages and logs go to standard error or a log file. + +The template's custom cache path is the launcher's own convention, and **you cannot assume the host cleans it up on uninstall**. +Document what remains and how to delete it safely. If your server keeps workspace indexes, add stable per-workspace isolation; +do not reuse the template's single cache directory for every project's index. + +#### Let the host provide state directories + +You can also drop the launcher: replace `languageServers` in the example above with the following to run the packaged LuaLS directly +with host-managed directories: + +```json +[ + { + "languageID": "lua", + "languageIdentifier": "lua", + "launch": { + "kind": "packaged", + "path": "language-server/bin/lua-language-server" + }, + "arguments": [ + "--logpath=${logDir}", + "--metapath=${workspaceStateDir}" + ], + "validationArguments": [] + } +] +``` + +`${logDir}` is the current-session plugin log directory the host supplies explicitly (only for language servers and plugin backends); when the host supplies no `logDirectory`, +a launch that uses it is rejected with `plugin.launch.invalidRequest`. +`${stateDir}` is a writable state directory the host keeps for this plugin's server and removes on uninstall; `${workspaceStateDir}` is further +isolated per workspace. `${pluginDir}` is the read-only package root; `${arch}` is `arm64` or `x86_64`, +spelled differently from the `x64` in Release asset names. `${setting:}` references a declared setting. State, log, and setting templates are not allowed in `validationArguments`. +For the full variable table, see [Launch templates](../contract/v1/README.md#launch-templates). + +If the server needs Node.js or Java, a `runtime` launch removes the need for a launcher. This is the `launch` fragment of a server declaration; +replace the entry with your real resource, keep server-specific arguments in `arguments`, +and write `"requiresTools": ["node"]` at the top level of the manifest: + +```json +{ + "kind": "runtime", + "tool": "node", + "minimumVersion": "18.17", + "entry": "language-server/server.js" +} +``` + +The host discovers and checks the runtime; do not also declare the same launch tool in `runtimeDependencies`. +A `runtime` launch declares no `validationArguments`; the host probes the runtime. With `packagedRuntime` instead, +which excludes `minimumVersion`, the plugin ships the runtime, its dependencies, and licenses, and must be verified on each target architecture. +See [Runtime launch](../contract/v1/README.md#runtime-launch). + +A server can also carry `initializationOptions` and declarative `settings` (add `settings` to `requires`). Pass only options the upstream really supports; +`${setting:}` may reference only declared settings, and values must match their type and range. Settings are per plugin and shared across workspaces +(a setting with `scope: "project"` can be overridden per project); after a change the host restarts the affected servers by default, and `effect` can change that. +For the format, see the [contract](../contract/v1/README.md#plugin-settings). + +### 6. Verify locally, install, and activate + +First run the two checks in the template's CI: `node scripts/package.mjs` assembles the artifacts, then Lithe Store's +`readPackageManifests` and `checkManifest` check package paths, the ID, the version, and the community trust declaration. +See the template's [CI workflow](https://github.com/Lithe-IDEA/lithe-plugin-lua/blob/main/.github/workflows/ci.yml). +These checks do not start the server and are not the full Core semantic manifest validation. +From the plugin repository root you can check the artifacts the same way CI does; pick an unused name for the sibling directory: + +```bash +git clone --depth 1 https://github.com/Lithe-IDEA/Lithe-store.git ../lithe-store-validation +node --input-type=module <<'JS' +import { readdirSync, readFileSync } from "node:fs"; +import { readPackageManifests, checkManifest } from "../lithe-store-validation/scripts/validate-index.mjs"; +const manifest = JSON.parse(readFileSync("io.github.lithe-idea.lua-support/plugin.json", "utf8")); +for (const name of readdirSync("dist")) { + const { manifests, unsafe } = readPackageManifests("dist/" + name, manifest.id); + const problems = [...unsafe, ...checkManifest(manifests, { id: manifest.id }, manifest)]; + if (problems.length) throw new Error(name + ": " + problems.join("; ")); + console.log("validated " + name); +} +JS +``` + +After forking, change the directory this reads `plugin.json` from to your own ID. +If an error comes from cross-references, template contexts, or `requires`, also check the contract and pass the target host's install validation. + +The desktop has two paths for installing a `none` community package, and both show an install review for the user to confirm: + +- **Install from disk**: in plugin management choose install from disk and pick a zip from `dist/`. + The host validates the manifest, keeps a private snapshot, and marks it as a community plugin whose publisher and source repository are unverified; + a first install, an update, a reinstall, and a same-version replacement each ask the user to confirm again. + It suits repeated checks during development, but it does not check an index entry or the Release asset's checksum. +- **Your own plugin source**: following the [publishing guide](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md), publish the zips in your own public GitHub repository, + then add a `lithe-plugin-index.json` at the repository root. The index format is the same as the Store's; + the download URLs point at that repository's Release assets, and the digests pin the final zips. You do not need to apply to the public Store first. + In the plugin marketplace, add your repository URL (or the HTTPS address of the index file) as a plugin source to install from it, which is the path users will actually take. + +Before publishing, install from your own plugin source at least once end to end; installing from disk does not replace this path. + +Before installing, disable other plugins that declare the same language as yours, to avoid provider conflicts. Then: + +1. In the plugin marketplace, choose your source and version (or pick the package on disk), and confirm the programs, permissions, and source shown are what you expect. + No Lithe restart is needed after installing; if the plugin does not appear, first check whether `requires` lists a capability the desktop does not declare. +2. Open a `.lua` file in a small project and introduce an error in the code. A diagnostic should appear; + do not just check that the process "is running". Completion, hover, and navigation have no editor surface today; to check them, + run the same server under another LSP client. +3. Save a change and confirm diagnostics update; close and reopen the file, and confirm there is no duplicate server and no duplicate diagnostics. +4. Launch Lithe from the graphical shell (not a terminal) and try again, so you do not depend on a PATH inherited by chance from a terminal launch. +5. If you add run or test, verify them separately; LuaLS working does not mean the user's `lua` is installed. + +You should also be able to explain the protocol order after start: the host sends `initialize`, the server returns its capabilities and then receives +`initialized`, documents sync through `didOpen`, `didChange`, `didSave`, and `didClose` per the negotiated capabilities, +and diagnostics travel over the same session. A normal stop is `shutdown`, then `exit`; when initialization does not finish or shutdown +times out, the host may force-stop the process. The host decides the timeouts, so do not depend on a specific number: the server should finish initialization promptly and answer `shutdown` quickly. +Cancellation reaches the server as `$/cancelRequest`; the server may still finish the computation, so do not treat cancellation as the process having exited. + +Use this file for a minimal interactive check: + +```lua +---@param name string +local function greet(name) + return "Hello, " .. name +end + +print(greet("Lithe")) +``` + +Change the call's argument to a number and watch for the type diagnostic; then delete one letter from the function name and watch for the undefined-variable diagnostic. +The exact messages vary with the LuaLS version and project configuration, so record both when you check. + +### 7. Treat stop, failure, and uninstall as acceptance criteria + +Declarative plugins have no mechanism for authors to register arbitrary `activate()` / `deactivate()` callbacks. +The host manages sessions, processes, and deactivation; the author keeps the server in the foreground, follows the LSP lifecycle, +and designs every child process and all state to be stoppable, restartable, and isolated. You cannot assume the host's force-stop ends +an arbitrary process tree the server spawns, so the launcher must hand over with `exec` and the server must reap its own children. The following are checks to run before publishing, +not guarantees you get by declaring something. + +| Action or failure | What to check | +| --- | --- | +| Install or enable only, without opening the language | The language server does not start just because it was installed | +| Edit, save, close and reopen files | Document sync and diagnostics work, with no duplicate sessions | +| Switch quickly between two projects or workspaces | Old results do not leak into the new project; indexes and locks do not collide | +| The server exits unexpectedly or fails to initialize | The host shows a failure you can trace; recovery leaves no duplicate processes | +| Node.js, Go, or Java missing or too old | An actionable runtime error, with no download at launch | +| Change a setting whose default restarts the server | New sessions use the new value, and the old process exits | +| Disable the plugin, close the workspace, quit Lithe | The sessions and processes stop, with no launcher or helper child left behind | +| Uninstall and reinstall | Uninstall deactivates first, then removes the package and host-managed plugin state; custom caches are documented | +| Tamper with one file in the installed package and reload | The content recheck refuses to load it; the server must not "repair" the manifest itself | + +Install, enable, disable, and uninstall take effect immediately; there is no restart step to lean on. Acceptance must cover the state before and after each of these actions, +and an uninstall request deactivates a registered plugin first. + +When the checks are done, close the app, servers, and child processes you started for testing. Do not treat closing an editor tab as uninstalling the server, +and do not assume a crash always triggers an automatic retry. When you find a lifecycle problem, +record the logs, host version, package version, architecture, and reproduction steps in the plugin repository; file host process-management problems with Lithe. + +### 8. Rehearse publishing, updates, and rollback + +Following [Publishing a community plugin](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md), bump the plugin version, publish a full Release, and submit the first index entry. +The template's Release workflow checks that `v` matches the manifest; for the original it is +`node scripts/package.mjs --expect-tag v0.3.1`. Use your own version after forking. + +Every time you update the server or runtime, update the pinned inputs and licenses, rebuild the affected platform packages, and run the interactive and lifecycle checks again. +A published zip cannot be replaced, and a version cannot be reused; ship fixes as a new version. Adding a runnable program triggers a new +install confirmation; see [Version tracking and updates](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md). + +Before publishing, rehearse "old version → new version → back to the old logic" at least once: + +- Keep the old version's assets and index records, so the host still has a compatible version to install; when you move the plugin to a new capability version, keep the previous version that has the old requirement in the index. +- When the old and new servers use incompatible index formats, isolate the state or rebuild it safely; never let the old version read corrupted state. +- When the current version is broken, publish a higher fix version, restoring the last stable server in the new package if needed, + and ask for the broken version to be withdrawn; do not rely on users' machines happening to keep the previous version, and do not expect the host to restore server indexes or user data. +- Verify uninstall and reinstall too. Document what happens to source, user build output, and custom caches, so user data is not deleted by mistake. + +### 9. Troubleshoot by failure stage + +| Symptom | Check first | +| --- | --- | +| Not visible in the marketplace, or not installable | Source URL, an asset for the current platform, and whether `requires` lists a capability the desktop does not declare (`plugin.api.unsupported`, with `field` naming it) | +| `plugin.archive.checksumMismatch` | Whether a Release asset was replaced; whether the index pins the final zip's digest | +| `plugin.manifest.invalid` | Unknown fields, `requires` that does not match the capabilities actually used, IDs and references | +| `plugin.schema.unsupported` / `plugin.api.unsupported` | Whether `schemaVersion`, `pluginAPIVersion`, and the versions in `requires` are all `0` | +| `plugin.permission.undeclared` / `plugin.activation.invalid` | Whether providers, permissions, and events match one to one | +| `plugin.launch.missing` | Relative path, letter case, packaging completeness, and the executable bit | +| `plugin.launch.invalidRequest` | A launch uses `${logDir}` or `${workspaceStateDir}`, but the host did not supply that directory | +| `plugin.runtime.missing` / `plugin.runtime.undeclared` | Runtime discovery, the minimum version, the environment of a graphical launch; whether every tool ID a launch uses is listed in `requiresTools` | +| A process runs but there is no completion | Whether logs pollute stdio, the LSP handshake, the document language identifier, upstream configuration; the desktop presents only diagnostics today | +| Refuses to load after one use | Whether the server wrote logs, indexes, locks, or generated files into the package | +| Processes remain after disabling | Whether the launcher uses `exec`, backgrounds itself, or leaves helper processes behind | +| Completion works but build or test does not | Whether a separate run, test, or build integration was actually declared and verified | + +For the full field set, version rules, and stable error codes, go by Lithe's +[plugin contract](../contract/v1/README.md) and the +[manifest schema](../contract/v1/plugin-manifest.schema.json). +They and this page live in `shared/plugins/` of the Core repository and change together; the host status follows the Lithe desktop's `Declared()`. +Before publishing, check that the target Lithe version declares every capability in your `requires`. diff --git a/shared/plugins/guides/native-build-workflows.md b/shared/plugins/guides/native-build-workflows.md new file mode 100644 index 00000000..5fbdd238 --- /dev/null +++ b/shared/plugins/guides/native-build-workflows.md @@ -0,0 +1,197 @@ +# 原生构建、测试与运行准备 / Native build, test, and run preparation + +[中文](#中文) · [English](#english) + +## 中文 + +插件可以只提供工作区后端,不创建网页。用户从原生工具窗口执行构建、发现与运行测试、准备应用启动;宿主继续管理工作区、取消、输出和应用进程。 + +这些入口都属于 `buildProviders`(版本 `0`):清单要求 `requires.buildProviders: 0`,进程协议 `litheBuild` 的版本就是 `buildProviders` 的版本。Node.js SDK 0.5.0 和 Java SDK 0.5.0 实现了全部入口;Rust SDK 0.4.0 没有构建提供者接口。能力定义见[构建提供者](../reference/domains/build-providers.md),字段和错误码以[契约](../contract/v1/README.md#build-providers)为准。 + +> **宿主支持。** Lithe 桌面端声明 `buildSystems` 和 `buildProviders`:构建窗口发现构建实例、加载提供者模型、解析可执行文件候选并运行任务。构建提供者的运行准备、测试、调试准备、model inputs、原生配置、内联测试报告和运行时调试,桌面端尚未呈现,见 +> [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) 中 `Declared()` 上方的注释。下文的入口因此由 Core 与 SDK 的端到端测试验证,插件作者在桌面端界面里暂时看不到测试、运行和调试这几类入口的效果。 + +### 选择入口 + +| 工作 | Node.js SDK | Java SDK | 清单里的声明 | +| --- | --- | --- | --- | +| 执行有界任务 | `backend.build.execute` | `onExecute` | 任务 `source: "provider"` | +| 运行精确测试 | `backend.build.test` | `onTest` | `test.source: "provider"` | +| 发现可运行的用例 | `backend.build.discoverTests` | `onDiscoverTests` | `test.discovery: true` | +| 准备应用启动 | `backend.build.prepareRun` | `onPrepareRun` | `run[].source: "provider"` | +| 准备原生程序调试 | `backend.build.prepareDebug` | `onPrepareDebug` | `run[].debug: {"adapter": "native"}` | + +安装时检查清单的能力版本;启动时 SDK 只在注册了构建处理器、且宿主提供了 `litheBuild` 版本 `0` 时才协商构建协议。没有协商成功时,构建方法在进入处理器之前被拒绝;不会改发私有函数,也不会展开成另一条宿主命令。Node.js 用 `backend.buildVersion` 查看结果(协商到为 `0`,否则为 `null`),Java 用 `backend.negotiated().buildVersion()`。 + +处理器必须在 `listen` 前注册。模型、依赖和测试发现回复由 SDK 补齐对应的 `format` 和 `version: 0`;执行回复是 `{ exitCode, output }`,启动准备回复是完整的可移植计划。Core 继续检查选择器、工作区路径和大小限制。 + +### 所有权与清理 + +插件从其选定的上游引擎读取当前工程、测试和运行事实。发现结果中的每个用例都必须能通过同一套件、用例、工程与目标选择器精确执行;处理器重新核对选择,不把失效用例当成“运行全部”。不要在宿主或 SDK 里再扫描 Java 注解、拼 classpath 或解释 Maven 模型。 + +构建与测试处理器使用调用上下文的 `run` 启动工具,取消或调用结束会结束它拥有的工具树。工具正常返回非零码仍是执行结果;异常属于协议或准备失败。不要启动脱离调用的进程,或用一条私有函数代替这些领域入口。 + +运行准备先等待上游同步和必要构建,再返回 `executable`、原始 `arguments` 数组、工作区相对 `workingDirectory` 和 `environment`。`executable.kind` 只能是 `command`(宿主解析的裸命令名)或 `toolchain`(宿主工具链标识)。处理器不启动长期运行的应用;宿主校验后启动、停止并显示应用输出。构建失败、取消或旧结果失效时不能启动应用。 + +执行与测试请求的后端拒绝由 Core 报告为 `plugin.build.failed`。运行准备的 RPC 错误保留通用 LSP `serverError` 分类,不是带退出码的执行结果,也不使用私有函数的 `pluginErrorCode`。宿主在两种失败下都不能启动应用或改走旧命令。 + +执行、测试、运行与调试准备上下文携带 Core 分配的 `operationID`,进度自动带上它。Node.js 使用 `context.progress({ message, percentage, output })`;Java 使用 `context.progress(message, percentage, output)`。消息上限 4096 UTF-8 字节,单次输出上限 64 KiB,百分比限制在 0–100;取消或结束后的报告被丢弃。模型、依赖和发现不提供执行进度。 + +状态写入宿主提供的可写目录;安装后的插件包只读。`prepareRun` 的结果不能变成跨重启缓存的 classpath 或命令:宿主保存入口选择器,重启重新准备。 + +### 可运行示例 + +两套示例使用相同的虚构构建引擎,工程事实来自工作区的 `lithe-sample.json`。这是协议教学用的引擎,不是真实的 Maven 或 Java 构建。 + +- [Node.js 示例](../sdk/node/examples/sample-native-build/backend/main.mjs)及其[清单](../sdk/node/examples/sample-native-build/plugin.json),清单要求 `buildProviders`、`buildSystems` 和 `debugAdapters`(版本都是 `0`)。开发时从源码引入 SDK;分发时将 `src/` 放入 `backend/vendor/`,把导入改为 `./vendor/index.mjs`。端到端测试按这种结构打包验证。 +- [Java 示例](../sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java)及其[清单](../sdk/java/examples/sample-native-build/plugin.json)。在仓库根运行 `mvn -B -ntp -f shared/plugins/sdk/java/pom.xml package`,将 `shared/plugins/sdk/java/examples/sample-native-build/target/sample-native-build.jar` 放到包内 `backend/`。SDK 已 shade 进同一 JAR。 + +两个 SDK 的完整用法见各自 README:[Node.js](../sdk/node/README.md#native-build-workflows),[Java](../sdk/java/README.md)。 + +示例工程包含构建命令、精确用例及应用参数,例如: + +```json +{ + "projects": [{ + "name": "app", "path": "app", + "build": ["node", "-e", "console.log('built')"], + "tests": [{ "suite": "Suite", "case": "exact case", + "command": ["node", "-e", "console.log('passed')", "--"] }], + "run": { "command": "node", + "arguments": ["-e", "console.log(process.argv.slice(1))", "--"] } + }] +} +``` + +模型的路径相对构建实例,后续请求的工程和目标选择器相对整个工作区;示例显式转换这两种表示,不把嵌套实例当成根工程。 + +`app` 目录需存在,测试工具需可执行。示例重新读取配置、核对工程、目标和测试,并解析真实路径以拒绝工作区外目录;实际插件应调用其上游工程引擎,而非复制这份虚构引擎。 + +### 验证 + +下面的命令都在仓库根运行: + +```sh +node --test shared/plugins/sdk/node/test/*.test.mjs +npm ci --prefix shared/plugins/sdk/node --ignore-scripts +npm run --prefix shared/plugins/sdk/node test:types +mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify +node --test shared/plugins/scripts/*.test.mjs + +# 端到端:对当前工作树的 Core 运行,总是包含原生构建工作流。 +node shared/plugins/scripts/sdk-e2e.mjs +``` + +`sdk-e2e.mjs` 先构建三个 SDK 的示例后端,再用 `cargo test -p lithe-core --features plugin-sdk-e2e` 运行 `plugin_sdk_node`、`plugin_sdk_java`、`plugin_sdk_rust` 和 `plugin_sdk_build`,被测对象是当前工作树里的 Core,所以改 Core 与改 SDK 或契约可以在同一个 PR 里一起验证。运行需要 Rust、Node.js 20.3 或更高版本、Java(用 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定,或放在 `PATH` 上的 `java`)和 Maven。 + +这些测试通过真实 Core 规划与解码,覆盖构建非零退出码、精确筛选、运行前构建、准备计划、进度、取消后的工具清理、并行工作区隔离,以及包含 SDK 和 JAR 的完整包文件不变。它们不代替原生界面、真实 Maven/JDT 工程或桌面端实机验证。 + +若复用预构建的示例,分别设置 `LITHE_PLUGIN_SDK_JAVA_SAMPLE`、`LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE` 和 `LITHE_PLUGIN_SDK_RUST_SAMPLE`。Java 示例用 JDK 21 编译,产物面向 Java 8;可用 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定实际运行时。 + +调试适配器、长期服务和跨插件协作有各自的宿主契约;这些处理器不开放跨包私有函数或反向 `lithe/*` 调用。 + +### 原生程序调试准备 + +运行声明加 `debug: {"adapter":"native"}`,同一插件提供 `native` 适配器;清单要求 `buildProviders: 0` 与 `debugAdapters: 0`,并声明 `debug.launch` 权限。适配器声明 launch 的进程和 transport,使用 program、cwd、args、env 的目标绑定。不要用跨包 ID,也不要在后端返回 DAP 进程、端口或任意调试参数。 + +`prepareDebug` 先完成构建,再返回 `{program, arguments, workingDirectory, environment}`。程序是当前实例内的已有文件,路径从工作区起算;宿主校验路径和可执行权限,再复用现有的调试适配器注册表。取消、错误和过期结果都不能启动调试。Node.js 和 Java 的 sample-native-build 从虚构引擎的 `run.debugProgram` 读取程序,参数使用当前请求的 userArguments;示例的 `example-dap` 是占位适配器命令,真实插件应打包符合其许可的成熟适配器。 + +端到端示例验证两个 SDK 的真实进程、运行前构建、原生调试准备前构建、Core 路径解码、进度及失败不返回目标。它不启动真实调试器,也不代表 Java/JDT 或跨插件协作已经完成。 + +--- + +## English + +A plugin can provide only a workspace backend and create no web page. Users build, discover and run tests, and prepare application launches from native tool windows; the host keeps managing the workspace, cancellation, output, and application processes. + +These entry points all belong to `buildProviders` (version `0`): the manifest requires `requires.buildProviders: 0`, and the version of the `litheBuild` process protocol is the version of `buildProviders`. Node.js SDK 0.5.0 and Java SDK 0.5.0 implement every entry point; Rust SDK 0.4.0 has no build-provider interface. For the capability see [Build providers](../reference/domains/build-providers.md); the [contract](../contract/v1/README.md#build-providers) is normative for fields and error codes. + +> **Host support.** The Lithe desktop declares `buildSystems` and `buildProviders`: the Build window detects build instances, loads provider models, resolves executable candidates, and runs tasks. Build-provider run preparation, tests, debug preparation, model inputs, native configuration, inline test reports, and runtime debugging are not presented on the desktop yet; see the comment above `Declared()` in +> [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go). The entry points below are therefore verified by the Core and SDK end-to-end tests, and a plugin author cannot yet see the effect of the test, run, and debug entry points in the desktop UI. + +### Choosing an entry point + +| Work | Node.js SDK | Java SDK | Manifest declaration | +| --- | --- | --- | --- | +| Execute a bounded task | `backend.build.execute` | `onExecute` | task `source: "provider"` | +| Run exact tests | `backend.build.test` | `onTest` | `test.source: "provider"` | +| Discover runnable cases | `backend.build.discoverTests` | `onDiscoverTests` | `test.discovery: true` | +| Prepare an application launch | `backend.build.prepareRun` | `onPrepareRun` | `run[].source: "provider"` | +| Prepare native program debugging | `backend.build.prepareDebug` | `onPrepareDebug` | `run[].debug: {"adapter": "native"}` | + +The manifest's capability versions are checked at install time; at startup the SDK negotiates the build protocol only when build handlers are registered and the host offers `litheBuild` version `0`. Without a negotiated protocol, a build method is rejected before it reaches the handler; it is never rerouted to a private function or expanded into a different host command. Read the result with `backend.buildVersion` in Node.js (`0` when negotiated, otherwise `null`) or `backend.negotiated().buildVersion()` in Java. + +Register handlers before `listen`. For model, dependency, and test discovery replies, the SDK fills in the matching `format` and `version: 0`; an execution reply is `{ exitCode, output }`, and a launch preparation reply is a complete portable plan. Core still checks selectors, workspace paths, and size limits. + +### Ownership and cleanup + +The plugin reads the current project, test, and run facts from the upstream engine it chose. Every case in a discovery result must be exactly runnable through the same suite, case, project, and target selectors; the handler checks the selection again and never treats a stale case as "run all". Do not scan Java annotations, assemble a classpath, or interpret a Maven model in the host or SDK. + +Build and test handlers start tools through the call context's `run`; cancellation or the end of the call ends the tool tree it owns. A tool that returns a non-zero code normally is still an execution result; an exception is a protocol or preparation failure. Do not start processes that outlive the call, and do not replace these domain entry points with a private function. + +Run preparation first waits for upstream sync and any required build, then returns `executable`, the raw `arguments` array, a workspace-relative `workingDirectory`, and `environment`. `executable.kind` is only `command` (a bare command name the host resolves) or `toolchain` (a host toolchain identifier). The handler does not start a long-running application; the host validates the plan, then starts and stops the application and shows its output. A build failure, cancellation, or stale result must not start the application. + +Core reports a backend rejection of an execute or test request as `plugin.build.failed`. An RPC error from run preparation keeps the generic LSP `serverError` classification; it is not an execution result with an exit code and does not use the private-function `pluginErrorCode`. In both failures the host must not start the application or fall back to an old command. + +Execute, test, run preparation, and debug preparation contexts carry an `operationID` assigned by Core, and progress carries it automatically. Node.js uses `context.progress({ message, percentage, output })`; Java uses `context.progress(message, percentage, output)`. Messages are limited to 4096 UTF-8 bytes, each output chunk to 64 KiB, and percentages are clamped to 0–100; reports after cancellation or completion are dropped. Model, dependency, and discovery requests have no execution progress. + +State goes into the writable directories the host provides; an installed plugin package is read-only. A `prepareRun` result must not become a classpath or command cached across restarts: the host stores the entry selector and prepares again after a restart. + +### Runnable examples + +Both examples use the same fictional build engine, whose project facts come from `lithe-sample.json` in the workspace. It is an engine for teaching the protocol, not a real Maven or Java build. + +- The [Node.js example](../sdk/node/examples/sample-native-build/backend/main.mjs) and its [manifest](../sdk/node/examples/sample-native-build/plugin.json); the manifest requires `buildProviders`, `buildSystems`, and `debugAdapters` (all version `0`). During development it imports the SDK from source; for distribution, put `src/` into `backend/vendor/` and change the import to `./vendor/index.mjs`. The end-to-end tests package and verify that layout. +- The [Java example](../sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java) and its [manifest](../sdk/java/examples/sample-native-build/plugin.json). From the repository root run `mvn -B -ntp -f shared/plugins/sdk/java/pom.xml package` and put `shared/plugins/sdk/java/examples/sample-native-build/target/sample-native-build.jar` into the package's `backend/`. The SDK is already shaded into the same JAR. + +Each SDK's README has its full usage: [Node.js](../sdk/node/README.md#native-build-workflows), [Java](../sdk/java/README.md). + +The example project contains a build command, exact cases, and application arguments, for example: + +```json +{ + "projects": [{ + "name": "app", "path": "app", + "build": ["node", "-e", "console.log('built')"], + "tests": [{ "suite": "Suite", "case": "exact case", + "command": ["node", "-e", "console.log('passed')", "--"] }], + "run": { "command": "node", + "arguments": ["-e", "console.log(process.argv.slice(1))", "--"] } + }] +} +``` + +Model paths are relative to the build instance, while project and target selectors in later requests are relative to the whole workspace; the example converts between the two explicitly and never treats a nested instance as the root project. + +The `app` directory must exist and the test tool must be executable. The example rereads its configuration, checks the project, target, and test, and resolves real paths to reject directories outside the workspace. A real plugin should call its upstream project engine instead of copying this fictional one. + +### Verification + +Run these from the repository root: + +```sh +node --test shared/plugins/sdk/node/test/*.test.mjs +npm ci --prefix shared/plugins/sdk/node --ignore-scripts +npm run --prefix shared/plugins/sdk/node test:types +mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify +node --test shared/plugins/scripts/*.test.mjs + +# End to end: runs against this working tree's Core and always includes the native build workflows. +node shared/plugins/scripts/sdk-e2e.mjs +``` + +`sdk-e2e.mjs` first builds the three SDKs' sample backends, then runs `plugin_sdk_node`, `plugin_sdk_java`, `plugin_sdk_rust`, and `plugin_sdk_build` with `cargo test -p lithe-core --features plugin-sdk-e2e`. The code under test is the Core in the working tree, so a change to Core and a change to an SDK or the contract are verified together in one PR. It needs Rust, Node.js 20.3 or newer, Java (set with `LITHE_PLUGIN_SDK_JAVA_RUNTIME`, or `java` on `PATH`), and Maven. + +These tests plan and decode through a real Core and cover a non-zero build exit code, exact filtering, build before run, preparation plans, progress, tool cleanup after cancellation, isolation between parallel workspaces, and an unchanged complete package including the SDK and JAR. They do not replace verification of the native UI, real Maven/JDT projects, or the desktop on a real machine. + +To reuse prebuilt examples, set `LITHE_PLUGIN_SDK_JAVA_SAMPLE`, `LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE`, and `LITHE_PLUGIN_SDK_RUST_SAMPLE`. The Java examples compile with JDK 21 and target Java 8; `LITHE_PLUGIN_SDK_JAVA_RUNTIME` selects the runtime that actually runs them. + +Debug adapters, long-running services, and cross-plugin collaboration have host contracts of their own; these handlers open no cross-package private functions or reverse `lithe/*` calls. + +### Native program debug preparation + +Add `debug: {"adapter":"native"}` to the run declaration and provide a `native` adapter from the same plugin; the manifest requires `buildProviders: 0` and `debugAdapters: 0` and declares the `debug.launch` permission. The adapter declares the launch process and transport and uses the target bindings for program, cwd, args, and env. Do not use a cross-package ID, and do not return a DAP process, port, or arbitrary debug arguments from the backend. + +`prepareDebug` finishes the build first and then returns `{program, arguments, workingDirectory, environment}`. The program is an existing file inside the current instance, with a path relative to the workspace; the host checks the path and execute permission and then reuses the existing debug adapter registry. Cancellation, errors, and stale results must not start debugging. The Node.js and Java sample-native-build examples read the program from the fictional engine's `run.debugProgram` and use the current request's userArguments as arguments. The examples' `example-dap` is a placeholder adapter command; a real plugin should package a mature adapter whose license allows it. + +The end-to-end example verifies real processes for both SDKs, build before run, build before native debug preparation, Core path decoding, progress, and that failures return no target. It does not start a real debugger and does not mean Java/JDT or cross-plugin collaboration is done. diff --git a/shared/plugins/guides/native-terminals.md b/shared/plugins/guides/native-terminals.md new file mode 100644 index 00000000..9ae0cc50 --- /dev/null +++ b/shared/plugins/guides/native-terminals.md @@ -0,0 +1,221 @@ +# 从插件打开 Lithe 原生终端 / Open a native Lithe terminal from a plugin + +[中文](#中文) · [English](#english) + +## 中文 + +这些 SDK 封装随 Node.js SDK 0.5.0、Java SDK 0.5.0 和 Rust SDK 0.4.0 提供,并且需要宿主声明终端方法。契约和 SDK 都在 Core 仓库的 `shared/plugins/` 中,规则见[原生终端契约](../contract/v1/README.md#plugin-owned-native-terminals)。 + +交互式客户端请使用宿主的集成终端。声明 `requires.backend: 2` 和权限 `terminal.create`;网页调用自己后端的函数,后端带着该函数的工作区上下文调用宿主。插件不需要实现终端模拟器,不需要通过插件函数转发按键,也不需要成为构建或运行提供者。 + +先用 `host.supports` 探测 `lithe/host/terminal/create` 和 `reopen`。宿主不提供这些方法时,探测返回 false。首个实现只支持本机工作区,并明确拒绝远程 authority。宿主检查调用方、显示执行确认,并管理 PTY、渲染、焦点、尺寸调整和有界的进程树关闭。传 `show:false` 时,create 和 reopen 保留当前的终端选择,不展开终端面板,也不请求输入焦点。需要让新标签页显示出来时,调用 `show`。 + +### Node.js 示例 + +```js +const host = context.host; +const workspace = context.workspaceContext; +if (!workspace || !await host.supports('lithe/host/terminal/reopen')) { + throw new Error('This host has no native terminal API.'); +} +const terminal = await host.terminal.create(workspace, { + title: 'Interactive client', + executable: 'example-client', + arguments: ['--interactive'], + retainedContextBytes: 16 * 1024, +}); +// 退出订阅需要比这次调用活得更久时,把它放在后端生命周期的 host 上。 +const events = await backend.host.terminal.onExit(workspace, terminal.terminalID, event => { + console.error(event.generation, event.exitCode, event.reason); +}); +await host.terminal.close(workspace, { terminalID: terminal.terminalID, retainedContextBytes: 4096 }); +await host.terminal.reopen(workspace, { terminalID: terminal.terminalID }); +await events.dispose(); +await host.terminal.dispose(workspace, { terminalID: terminal.terminalID }); +``` + +只传契约支持的字段。尤其是 close 要传 `{terminalID, retainedContextBytes:4096}`,不要把结果里的元数据复制进请求参数;宿主会拒绝未知字段。所有 SDK 都不会在取消后自动重试启动。 + +### Rust 示例 + +```rust,ignore +let workspace = context.workspace_context().ok_or_else(|| + lithe_plugin_sdk::PluginError::new("example.noWorkspace", "Open a workspace first."))?; +let host = context.host(); +if !host.supports("lithe/host/terminal/reopen")? { + return Err(lithe_plugin_sdk::PluginError::new( + "example.unavailable", "This host has no native terminal API.").into()); +} +let terminal = host.terminal(); +let opened = terminal.create(workspace, serde_json::json!({ + "executable": "example-client", "arguments": ["--interactive"], + "retainedContextBytes": 4096 +}))?; +let id = opened["terminalID"].as_str().ok_or_else(|| + lithe_plugin_sdk::FunctionError::internal("Host returned no terminalID"))?; +terminal.close(workspace, id, Some(4096))?; +terminal.reopen(workspace, id)?; +terminal.dispose(workspace, id)?; +``` + +Rust 还提供 `terminal.on_exit(workspace,id)`,返回 SDK 已有的 `Subscription`,用有界的 `recv_timeout` 接收,并显式调用 `dispose`。 + +### Java 接口 + +`host.terminal()` 提供 `create(context,params)`、`show(context,params)`、`close(context,params)`、`reopen(context,params)`、`dispose(context,params)` 和 `onExit(context,params)`。参数是普通的 JSON Map。前五个返回 `CompletableFuture>`(show 和 dispose 返回 null);`onExit` 返回 `CompletableFuture`。上下文、取消、宿主失败和订阅路由与其他协议 3 方法使用同一通道。 + +### 启动与重开的语义 + +`executable` 是 PATH 中的裸命令名或绝对路径;`arguments` 保持 argv 数组。可选的 `cwd` 是工作区内的 `{rootID,path}` 目录资源,不能离开工作区。`environment` 是字符串覆盖值或表示删除的 null 组成的映射,不是 shell 赋值语句。不要把凭据放进标题、参数或日志。输入和输出都留在原生终端里;没有公开的 `sendText`、读取终端输出或枚举用户终端的 API。 + +`close` 移除标签页并停止进程,保留启动配置和已显示输出末尾的一段有界纯文本。`retainedContextBytes` 是 UTF-8 字节预算:默认 16384,最大 262144,0 表示不保留历史。可以在 create 时设置,也可以在 close 时覆盖。为了不截断 Unicode 字符,实际保留的字节数可能更少。显示或回显过的密钥可能出现在这段历史里,这类会话请设为 0。原始转义序列和 stdin 事件都不保留。 + +`reopen` 用同样的 executable、argv、cwd 和 environment 启动一个**新进程**,启动前先把保留的那段输出恢复到显示中。它不会重放输入、恢复进程内存或找回 shell 变量。它保留 `terminalID` 并递增 `generation`;事件带有 generation,旧进程的退出不会被误认为新进程的退出。退出订阅在 reopen 之后继续有效。进程自然退出和用户关闭标签页之后也可以 reopen。这两条路径都会先完成有界的原生清理,只保留选定的快照,并释放原生会话和渲染缓冲区,然后才通知订阅者。清理期间句柄处于忙碌状态;退出订阅者收到事件后可以立即 reopen。字节预算为 0 时,任何退出路径都不保留历史。 + +`dispose` 忘掉句柄、启动设置和历史;会话仍在运行时先关闭它。每个插件最多保留 8 个句柄(包括已关闭的),所以不会再 reopen 的句柄要 dispose。后端或会话重启、插件停用或更新、关闭或替换工作区,也会释放这些句柄。上下文和历史只在内存中,从不写入已安装的插件包。 + +Rust 示例的 `terminal.cycle` 函数和 Core 端到端场景覆盖协议的往返。该场景由测试提供宿主应答;真实的原生 PTY 输入、输出和历史由宿主自己的测试验证。 + +终端生命周期场景在 Core 仓库根目录运行,对象是当前工作树的 Core: + +```sh +node shared/plugins/scripts/sdk-e2e.mjs +``` + +示例在已安装的清单里声明了 `terminal.create`,所以测试拿到的授权与已安装插件相同。没有这个权限的旧宿主会拒绝该示例清单;没有声明它的其他插件保持原有兼容性。 + +--- + +## English + +These SDK wrappers ship from Node.js SDK 0.5.0, Java SDK 0.5.0, and Rust SDK +0.4.0, and require a host that advertises the terminal methods. The contract and the SDKs live in the +Core repository under `shared/plugins/`. See the +[native terminal contract](../contract/v1/README.md#plugin-owned-native-terminals). + +Use the host's integrated terminal for an interactive client. Declare +`requires.backend: 2` and permission `terminal.create`; a web page invokes a +function in its own backend, which calls the host with the function's workspace +context. It does not need to implement a terminal emulator, stream keystrokes +through plugin functions, or become a Build/Run provider. + +Discover `lithe/host/terminal/create` and `reopen` with `host.supports` first. +A host without these methods makes the probe return false. The first implementation supports +only a local workspace; it explicitly refuses a remote authority. +The host checks the caller, displays execution confirmation, and manages the +PTY, rendering, focus, resize and bounded process-tree shutdown. +With `show:false`, create/reopen preserve the current terminal selection and +do not reveal the terminal panel or request input focus. Call `show` when the +new tab should become visible. + +### Node.js + +```js +const host = context.host; +const workspace = context.workspaceContext; +if (!workspace || !await host.supports('lithe/host/terminal/reopen')) { + throw new Error('This host has no native terminal API.'); +} +const terminal = await host.terminal.create(workspace, { + title: 'Interactive client', + executable: 'example-client', + arguments: ['--interactive'], + retainedContextBytes: 16 * 1024, +}); +// Keep an exit subscription on a backend-lifetime host if it must outlive this call. +const events = await backend.host.terminal.onExit(workspace, terminal.terminalID, event => { + console.error(event.generation, event.exitCode, event.reason); +}); +await host.terminal.close(workspace, { terminalID: terminal.terminalID, retainedContextBytes: 4096 }); +await host.terminal.reopen(workspace, { terminalID: terminal.terminalID }); +await events.dispose(); +await host.terminal.dispose(workspace, { terminalID: terminal.terminalID }); +``` + +Pass only supported fields. In particular, use `{terminalID, +retainedContextBytes:4096}` for close rather than copying result metadata into +request params; the host refuses unknown fields. No SDK retries a launch after +cancellation automatically. + +### Rust + +```rust,ignore +let workspace = context.workspace_context().ok_or_else(|| + lithe_plugin_sdk::PluginError::new("example.noWorkspace", "Open a workspace first."))?; +let host = context.host(); +if !host.supports("lithe/host/terminal/reopen")? { + return Err(lithe_plugin_sdk::PluginError::new( + "example.unavailable", "This host has no native terminal API.").into()); +} +let terminal = host.terminal(); +let opened = terminal.create(workspace, serde_json::json!({ + "executable": "example-client", "arguments": ["--interactive"], + "retainedContextBytes": 4096 +}))?; +let id = opened["terminalID"].as_str().ok_or_else(|| + lithe_plugin_sdk::FunctionError::internal("Host returned no terminalID"))?; +terminal.close(workspace, id, Some(4096))?; +terminal.reopen(workspace, id)?; +terminal.dispose(workspace, id)?; +``` + +Rust also exposes `terminal.on_exit(workspace,id)` as an existing SDK +`Subscription`, with bounded `recv_timeout` and explicit `dispose`. + +### Java + +`host.terminal()` exposes `create(context,params)`, `show(context,params)`, +`close(context,params)`, `reopen(context,params)`, `dispose(context,params)` and +`onExit(context,params)`. Params are normal JSON maps. The first five return a +`CompletableFuture>` (null for show/dispose); `onExit` returns +`CompletableFuture`. Context, cancellation, host failures and +subscription routing use the same channel as the other protocol-3 methods. + +### Launch and reopen semantics + +`executable` is a bare PATH command or absolute path; `arguments` stays an argv +array. Optional `cwd` is a workspace `{rootID,path}` directory resource and +cannot escape the workspace. `environment` is a map of string overrides/null +removals, not shell assignments. Never put credentials in titles, arguments or +logs. Input and output live in the native terminal; there is no public +`sendText`, terminal-output read or user-terminal enumeration API. + +`close` removes the tab and stops the process, retaining the launch configuration +and a bounded plain-text suffix of the displayed output. `retainedContextBytes` +is a UTF-8 byte budget: default 16384, maximum 262144, 0 disables history. It may +be set on create or overridden at close. Actual retained bytes can be smaller +to keep Unicode intact. Displayed/echoed secrets can be in that history; choose +0 for such sessions. No raw escape sequence or stdin event is retained. + +`reopen` uses a **new process** with the same executable/argv/cwd/environment, +restoring that suffix to the display before spawning. It never replays input, +recovers process memory or resumes shell variables. It keeps `terminalID` and +increases `generation`; events include generation so old exits cannot be +mistaken for the new process. The exit subscription continues across reopen. +Natural exit and user tab close are reopenable too. +Both paths finish bounded native cleanup, retain only the chosen snapshot, +and release the native session and render buffer before notifying subscribers. +The handle is busy during cleanup; an exit subscriber may reopen immediately +after receiving the event. A zero-byte budget keeps no history on any exit path. + +`dispose` forgets the handle, launch settings and history, closing a live session +first. Eight handles per plugin (including closed ones) are retained, so dispose +handles that will not be reopened. A backend/session restart, plugin disable or +update, and closing/replacing the workspace also release them. Context and +history are in-memory only and never written to the installed package. + +The Rust sample's `terminal.cycle` function and Core end-to-end scenario exercise +the protocol roundtrip. That scenario supplies host responses; the host's own +tests verify actual native PTY input and output/history. + +The terminal lifecycle scenario runs from the Core repository root against the +working-tree Core: + +```sh +node shared/plugins/scripts/sdk-e2e.mjs +``` + +The sample declares `terminal.create` in its installed manifest, so the test +uses the same grants an installed plugin receives. Older hosts without this +permission refuse that sample manifest; other plugins that do not declare it +retain their existing compatibility. diff --git a/shared/plugins/guides/plugin-backend-workspace.md b/shared/plugins/guides/plugin-backend-workspace.md new file mode 100644 index 00000000..33894389 --- /dev/null +++ b/shared/plugins/guides/plugin-backend-workspace.md @@ -0,0 +1,647 @@ +# 插件后端:工作区上下文与宿主服务 / Plugin backend: workspace contexts and host services + +[中文](#中文) · [English](#english) + +## 中文 + +这篇指南面向写插件后端(`backend`)的作者:让后端在没有页面时也能启动、知道用户打开了哪些工作区、响应命令,并按需调用宿主能力,包括调用其他插件导出的函数。 + +字段与错误码的权威定义在[契约](../contract/v1/README.md#backend-lifecycle-and-workspace-contexts),这里只讲怎么用、为什么这样设计。 + +> **宿主支持。** Lithe 桌面端声明 `backend` 和 `pluginFunctions`(版本都是 `0`),所以本篇的函数协议、宿主服务和跨插件调用都在桌面端运行。宿主只授予它实现了的服务:一次会话能调用哪些方法,以运行时 `lithe/host/getCapabilities` 的 `services` 为准,不要按文档假定(第 5 节、第 10 节)。桌面端的声明和说明见 +> [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) 中 `Declared()` 上方的注释。 +> 三个官方 SDK 都实现了本篇的协议:Node.js SDK 0.5.0、Java SDK 0.5.0 和 Rust SDK 0.4.0 +> (见各自 README 的 Host services:[Node.js](../sdk/node/README.md#host-services)、[Java](../sdk/java/README.md#host-services)、 +> [Rust](../sdk/rust/README.md#host-services);发布页:[sdk-node-v0.5.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-node-v0.5.0)、 +> [sdk-java-v0.5.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-java-v0.5.0)、 +> [sdk-rust-v0.4.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-rust-v0.4.0))。用其他语言需要自己处理下文的请求和通知。 +> 各能力在 Core 和桌面端的支持情况见[能力与协议版本对照](../reference/capabilities-and-versions.md)。对契约有疑问,请到 +> [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +### 1. 三个机制各管什么 + +| 写在哪里 | 值 | 决定什么 | +| --- | --- | --- | +| `requires.backend` | `0` | 插件能不能安装,后端进程**什么时候启动**,能不能声明 `workspace.metadata` 和 `onWorkspaceOpen` | +| `initialize` 应答的 `litheFunctions.version` | `0` | 进程启动后的**线路协议**:函数注册与调用、宿主服务、能力发现、工作区上下文、宿主通知、带类型的失败码、跨插件调用。协议没有独立的版本,它的版本就是 `backend` 的版本 | +| `getCapabilities.services` | 运行时查询结果 | 本次会话**实际能调用**哪些宿主方法、会收到哪些通知 | + +清单只做声明,宿主读清单决定装不装、什么时候启动;进程起来以后能做什么,靠协商和 `getCapabilities` 判断。 + +### 2. 最小清单 + +```json +{ + "schemaVersion": 0, + "id": "io.github.<你的用户名>.orders", + "displayName": "Orders", + "version": "1.0.0", + "pluginAPIVersion": 0, + "requires": { "backend": 0 }, + "vendor": { "id": "io.github.<你的用户名>", "displayName": "<你的名字>", "signatureRequirement": "none" }, + "permissions": ["backend.launch", "workspace.metadata"], + "activationEvents": ["onWorkspaceOpen"], + "languages": [], + "backend": { + "launch": { "kind": "packaged", "path": "bin/backend" }, + "arguments": ["--state", "${stateDir}"] + } +} +``` + +- `requires` 必须恰好列出清单用到的能力。启动进程([Process start](../contract/v1/README.md#process-start))属于基础契约,不是能力,不写进 `requires`。 +- `backend` 至少要有一种启动方式:网页工具窗口(首次页面调用)、`ui.commands[]` 里的命令(`onCommand:` 也会启动后端)、`onWorkspaceOpen`,或宿主向它查询的提供者(工具链提供者、快捷键导入提供者,或 `launch.kind` 为 `backend` 的语言服务器,它们启动时向后端要命令行)。一种都没有是 `plugin.manifest.invalid`。 +- 只想要命令、不要工具窗口时,在 `requires` 里加 `ui: 0`:命令可以不写 `toolWindowID`,`toolWindows` 可以为空或省略,见第 7 节。 +- `workspace.metadata` 只让后端知道打开的工作区名字、根目录、ID、来源(本地或远程)和每个工作区的私有状态目录, + 不读任何文件内容。不需要这些信息就不要声明,安装审查会把它列给用户。 +- 拒绝 `${workspaceStateDir}`:一个插件只有一个后端进程,被所有窗口共享,工作区不是进程状态。 + 每个工作区的状态目录从 `getContext` 的 `stateDirectory` 取;插件自己的整体状态目录是 `${stateDir}`,也是后端的启动根目录和工作目录。 +- 一个平台一个发布包,包内路径不按 `${arch}` 变化。 + +### 3. 启动时机 + +- 一个插件最多一个后端进程,所有工作区和窗口共享,在 Lithe 所在的机器上运行,远程工作区也一样。 +- `onWorkspaceOpen` 不会拖慢打开工作区。宿主在窗口显示之后逐个启动这些后端;`initialize` 超时的后端会被停止, + 并像其他启动失败一样报告,不会挡住工作区。后端失败或被禁用也不会阻塞工作区。 +- 后端在插件被禁用、更新、卸载和宿主退出时结束。 +- 激活只看插件是否已安装、启用、通过审查。Lithe 目前没有工作区信任状态;会运行或修改东西的操作,由宿主逐次询问用户。 +- 后端只服务插件自己:它不回答 `lithe/build/*` 或 VCS 请求。只回答构建请求的后端(不回答 `litheFunctions`)收不到函数请求,函数请求得到 `-32601`。 + +### 4. 协商协议 + +宿主在 `initialize` 里提供 `capabilities.experimental.litheFunctions: { "versions": [0] }`。后端实现了协议就这样作答: + +```json +"experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "orders.refresh" }] } } +``` + +`functions` 可省略,省略表示没有私有函数;之后也可以用 `lithe/function/register` 和 `lithe/function/unregister` 增删。回答宿主没有提供的版本,或函数 ID 格式错误、重复,整个协议都不会协商成功,这次会话不会带着半份注册表启动。不认识的字段双方都忽略。 + +函数 ID 是用 `.` 分隔的小驼峰段(`orders.refresh`),最多 128 字节,一个会话最多 256 个函数。 + +### 5. 先问宿主能做什么 + +协商成功以后,第一件事是调 `lithe/host/getCapabilities`(不带参数或 `{}`): + +```json +{ + "protocolVersion": 0, + "backendAuthority": "local", + "services": { + "commands": { "methods": [], "notifications": ["lithe/host/commands/didRun"] }, + "host": { "methods": ["lithe/host/getCapabilities"], "notifications": [] }, + "subscriptions": { + "methods": ["lithe/host/subscriptions/dispose"], + "notifications": ["lithe/host/subscriptions/notify"] + }, + "workspace": { + "methods": ["lithe/host/workspace/listContexts", "lithe/host/workspace/getContext", "lithe/host/workspace/getFolders"], + "notifications": ["lithe/host/workspace/didChangeContexts"] + } + }, + "limits": { + "pendingHostRequests": 16, + "functionResultBytes": 1048576, + "hostRequestBytes": 1048576, + "hostResultBytes": 1048576, + "errorMessageBytes": 1024, + "errorDetailsBytes": 4096, + "notificationBytes": 1048576 + } +} +``` + +上例是声明了 `workspace.metadata` 的会话。`host`、`subscriptions`、`commands` 每个插件会话都有;`services` 只列本次会话可用的方法, +没声明的权限、宿主没有实现的服务都不会出现,所以列出的方法不会因为宿主缺少处理器而失败。数值以实际返回为准。`protocolVersion` 是协商到的 `litheFunctions` 版本。 +一些服务条目还带 `options`,例如已授予编辑器动作或 Provider 时,`services.editor.options` 列出宿主绘制的图标和路由的 Provider 类型,`limits` 多一项 `editorActionSourceBytes`。 +**按 `services` 判断能不能调用**,见第 10 节。 + +### 6. 工作区上下文 + +一个上下文就是“某个窗口里打开的某个工作区”: + +```json +{ + "contextID": "w-3f2a9c", + "generation": 2, + "name": "orders", + "authority": { "kind": "local" }, + "folders": [{ "rootID": "r0c1d2e3f4a5b6c7d", "name": "orders", "path": "/Users/me/orders" }], + "stateDirectory": "/path/chosen/by/the/host/" +} +``` + +| 字段 | 怎么用 | +| --- | --- | +| `contextID` | 窗口显示这个工作区期间不变。窗口切到别的工作区时,旧上下文关闭、新上下文换新 ID,所以按 ID 缓存的状态不会串到别的工作区 | +| `generation` | 从 1 起;同一工作区的根目录或来源变化时加一。请求里带旧的 `generation`,或上下文已关闭,会得到 `plugin.host.contextExpired`,重新 `listContexts` 即可 | +| `rootID` | 同一位置跨重启稳定,不暴露路径;按插件加密派生,两个插件对同一目录看到的 ID 不同 | +| `path` | 只有本地工作区有。远程工作区(`authority.kind` 为 `remote`,另有不透明的 `authority.id`)没有 `path`,宿主在本地为它保留的目录只是空占位 | +| `stateDirectory` | 插件与工作区私有,在插件包和应用包之外,插件被禁用或更新后保留、卸载时删除。只有 `getContext` 返回它并在那时创建;`listContexts` 不创建目录 | + +常用请求: + +| 请求 | 参数 | 结果 | +| --- | --- | --- | +| `lithe/host/workspace/listContexts` | 无 | `{ revision, contexts }`,条目不含 `stateDirectory`;`revision` 在上下文打开、关闭或变化时增长 | +| `lithe/host/workspace/getContext` | `{ context: { contextID, generation } }` | 完整上下文 | +| `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders }` | + +页面调用后端函数时,宿主会按页面所在窗口填好上下文:后端在 `lithe/function/invoke` 的 +`params.context` 里收到 `{ contextID, generation }`,直接拿它调 `getContext`。 + +### 7. 宿主通知 + +宿主用 JSON-RPC 通知告诉后端发生了变化,后端不应答: + +| 通知 | 参数 | 什么时候来 | +| --- | --- | --- | +| `lithe/host/workspace/didChangeContexts` | `{ revision }` | 有上下文打开、关闭或变化;收到后重新 `listContexts` | +| `lithe/host/commands/didRun` | `{ commandID, context? }` | 用户运行了插件自己的命令。后端没在运行且声明了 `onCommand:` 时,宿主先启动它,就绪后再送达 | +| `lithe/host/subscriptions/notify` | `{ subscriptionID, sequence, event }` | 某个订阅的事件。`sequence` 每个订阅从 1 起逐一递增,可以据此发现丢失;用 `lithe/host/subscriptions/dispose` 取消订阅 | +| `lithe/host/run/didExit` | `{ runID, exitCode?, signal? }` | 后端用 `run/start` 启动的运行结束了;只发给持有 `run.start` 的会话 | +| `lithe/host/languageServer/didChangeState`、`didReceiveNotification` | 见契约 | 本插件自己的语言服务器状态变化,或转发它的通知;需要 `languageServer.request` | + +后端晚启动时不会补发之前的 `didChangeContexts`,启动后自己调一次 `listContexts`。 + +`didRun` 只是事件,不代表命令要做的事已经成功;宿主也不会为了送达它而打开一个空工具窗口。命令到达时若 `initialize` 还没完成,会等到就绪;崩溃的后端可以由它声明的命令事件再次启动。 + +#### 纯后端命令 + +```json +"requires": { "backend": 0, "ui": 0 }, +"permissions": ["backend.launch", "ui.contribute"], +"activationEvents": ["onCommand:io.github.<你的用户名>.orders.refresh"], +"ui": { + "toolWindows": [], + "commands": [ + { "id": "io.github.<你的用户名>.orders.refresh", "title": "Refresh Orders", "placements": ["commandPalette"] } + ] +} +``` + +不写 `toolWindowID` 的命令要求 `backend`、`ui.contribute` 与 `backend.launch` 权限,以及对应的 `onCommand:`。 +桌面端会呈现挂在工具窗口上的命令;不属于任何工具窗口的 `ui` 命令桌面端尚未呈现(见本页开头的宿主说明)。完整示例见 +[`fixtures/backend/command-backend.plugin.json`](../contract/v1/fixtures/backend/command-backend.plugin.json)。 + +### 8. 文档、文件与窗口方法 + +宿主开放一组公共方法,每组对应一个权限。`getCapabilities.services` 只列出“清单权限推出的授权”与“本宿主已实现的方法”的交集,所以照第 10 节第 1 条先查再调。 + +| 方法(前缀 `lithe/host/`) | 权限 | 用途 | +| --- | --- | --- | +| `credentials/get`、`credentials/set`、`credentials/delete` | `credentials.store` | 插件私有的密钥,存在平台的密钥库里,不在状态目录或插件包中;`key` 是 1–128 个字母、数字、`.`、`_`、`:`、`-`,密钥最多 16 KiB;卸载插件时删除 | +| `documents/list`、`documents/getSnapshot`、`editor/getActive`、`editor/getSelections` | `documents.read` | 列出打开的文档,读编辑器里未保存的内容、当前标签页和选区 | +| `editor/applyEdits`、`editor/replaceSelections`、`editor/insertText` | `editor.edit` | 带 `expectedVersion` 的编辑;一批编辑是一次原子操作、一步撤销 | +| `workspace/readDirectory`、`workspace/stat`、`workspace/readFile` | `workspace.files.read` | 读磁盘上的目录和文件 | +| `workspace/watch` | `workspace.files.watch` | 订阅文件变化,事件经 `subscriptions/notify` 送达 | +| `workspace/findFiles`、`workspace/findText` | `workspace.search` | 按文件名或内容搜索,分页返回 | +| `editor/open`、`editor/reveal` | `editor.open` | 打开文件并定位到某处 | +| `preview/openHTML` | `preview.open` | 用内置预览打开 HTML 文件 | +| `browser/openExternal` | `browser.open` | 用系统浏览器打开 HTTP(S) 链接或 HTML 文件 | +| `window/showInformation`、`window/showWarning`、`window/showError` | `window.messages` | 显示消息,可带最多 3 个按钮,返回用户选了哪个 | + +参数、结果和上限的权威定义在[契约](../contract/v1/README.md#public-document-file-and-window-methods)。写后端时要注意: + +- 每个方法都要带 `context: { contextID, generation }`。文件用 `{ rootID, path }` 指明,`path` 用 `/`、相对于 + `getFolders` 返回的根目录。这组方法明确拒绝远程工作区(`authorityUnsupported`)。 +- `workspace.metadata` 只给工作区信息,**不授权读文件**;读文件要另外声明 `workspace.files.read`。 +- 位置 `{ line, column }` 从 1 开始、按 UTF-16 计;范围是半开区间。 +- 单次读取最多返回 128 KiB:快照超出时得到 `limitExceeded`,读文件则用 `isTruncated` 标明截断。一批编辑或选区最多 1,000 项;目录分页默认 200 项、最多 1,000 项,搜索分页默认 100 项、最多 200 项;宿主侧的期限是 30 秒。 +- 取消或超时**不代表已回滚**:编辑或打开可能已经生效,不要自动重放修改。遇到 `versionConflict` 先重新读取再计算; + 遇到 `contextExpired` 要重新找到原来的目标,不要改用当前聚焦的窗口;`busy`、`rateLimited` 可以有限次重试; + `permissionDenied`、`readOnly` 需要用户处理。 +- 后台没有挂载编辑器的文档可以列出和读取,但编辑与选区可能返回 `busy`。 +- 消息有频率和排队上限(每插件 10 秒 5 条、最多 3 条待答;同一窗口最多 10 条;正文最多 2,000 个字符,按钮最多 3 个、每个最多 40 个字符),用户回答前请求一直挂起。 + +另有几组方法,各自带权限,调用前同样先查 `getCapabilities.services`,清单条件见对应的契约章节: + +- 插件自有的编辑器动作(`editor.actions`)、编辑器语言功能 Provider(`editor.providers`)和原生终端(`terminal.create`),分别见[编辑器、文档与预览](../reference/domains/editor.md)和[从插件打开 Lithe 原生终端](native-terminals.md)。 +- 运行与调试会话(权限 `run.start`、`debug.start`):`run/start`、`run/stop` 启动并停止由宿主拥有的 Run 会话(Run 窗口条目、控制台、停止按钮、退出状态),`debug/start` 在本插件自己声明的调试适配器上启动调试会话,`run/didExit` 通知运行结束;见[插件后端与插件函数](../reference/domains/backend.md)、[调试适配器](../reference/domains/debug-adapters.md)和契约的[运行与调试会话](../contract/v1/README.md#run-and-debug-sessions)。 +- 自有语言服务器(权限 `languageServer.request`,只对声明了 `requestMethods` 或 `forwardNotifications` 的服务器开放):`languageServer/request`、`languageServer/notify` 向本插件自己声明的语言服务器发送请求和通知,`languageServer/setStatus` 在宿主状态界面显示它的准备状态;见[语言、语言服务器与运行/测试](../reference/domains/languages.md)和契约的[语言服务器宿主方法](../contract/v1/README.md#language-server-host-methods)。 +- 内容 Provider(权限 `documents.contentProvider`):`documents/registerContentProvider` 把一个 URI scheme 绑定到后端函数,用户打开该 scheme 的文档时宿主调用这个函数取只读内容;见[编辑器、文档与预览](../reference/domains/editor.md)和契约的[内容 Provider](../contract/v1/README.md#content-providers)。 + +官方 SDK 为凭据、工作区、文档、编辑器、预览、浏览器、窗口消息、命令、订阅和跨插件调用提供了封装;`run/*`、`debug/start`、`languageServer/*`、`documents/registerContentProvider` 没有专门的封装,用 SDK 的通用宿主请求调用。 + +### 9. 宿主失败与错误码 + +宿主失败带类型:JSON-RPC 号码与 `error.data.code` 一一对应,号码永不改义,部分失败还带 `error.data.details`。 + +| `error.data.code` | 号码 | 后端通常怎么做 | +| --- | --- | --- | +| `plugin.host.unavailable` | `-32601` | 宿主没有这个方法;先查 `getCapabilities` | +| `plugin.host.invalidParams` | `-32602` | 参数格式错误,修代码 | +| `plugin.host.failed` | `-32603` | 其他失败,报告给用户 | +| `plugin.host.cancelled` | `-32800` | 你自己用 `$/cancelRequest` 取消了它 | +| `plugin.host.permissionDenied` | `-32010` | 没声明权限,或用户拒绝 | +| `plugin.host.contextExpired` | `-32011` | 重新 `listContexts` 后再试 | +| `plugin.host.authorityUnsupported` | `-32012` | 该服务不支持远程工作区,宿主不会改在本地执行 | +| `plugin.host.versionConflict` | `-32013` | 资源版本已变;`details.currentVersion` 给出当前版本 | +| `plugin.host.limitExceeded` | `-32014` | 超出限制,例如同时挂起的请求超过 `limits.pendingHostRequests` | +| `plugin.host.timeout` | `-32015` | 宿主超时 | +| `plugin.host.notFound` | `-32016` | 资源不存在 | +| `plugin.host.readOnly` | `-32017` | 资源或工作区不可写 | +| `plugin.host.unsupportedOption` | `-32018` | 宿主不认识请求里的某个字段;`details.option` 指明是哪个,去掉它重试 | +| `plugin.host.preconditionFailed` | `-32019` | 所需状态不成立,例如用户没有确认 | +| `plugin.host.busy` | `-32020` | 资源正被占用,稍后重试 | +| `plugin.host.rateLimited` | `-32021` | 请求太频繁;有 `details.retryAfterMilliseconds` 时按它等待 | +| `plugin.host.languageServerUnavailable` | `-32022` | 本插件的语言服务器在这个上下文里没有运行,或请求进行中它停止了;等 `didChangeState` 报告新的 `generation` 再试 | +| `plugin.host.languageServerError` | `-32023` | 语言服务器用 JSON-RPC 错误应答;`details` 是 `{code, message}` | +| `plugin.host.methodNotDeclared` | `-32024` | 方法或 `workspace/executeCommand` 的命令不在服务器声明的会话范围内 | +| `plugin.host.toolUnresolved` | `-32025` | 请求里的工具定义在这个工作区解析不到可执行文件;`details` 是 `{ tool, reason }` | +| `plugin.host.foreignContribution` | `-32026` | 指名的服务器或贡献属于别的插件 | + +`-32000` 到 `-32009` 留给后端自己的失败,宿主号码从 `-32010` 起。跨插件调用另有 `plugin.function.failed`(`-32070`)和 `plugin.function.invalidResult`(`-32071`),见第 11 节。 + +一个会话最多同时有 16 个未应答的宿主请求,第 17 个得到 `plugin.host.limitExceeded`。后端可以用 `$/cancelRequest` 取消自己挂起的宿主请求,Core 立刻回 `-32800`。 + +### 10. 宿主服务只增不改:后端要遵守的四条 + +宿主再开放新能力(新服务及其方法和通知、可选参数、结果字段、错误码)都在同一版本内新增,不升版本;只有会让已有后端读错的改动(删除或改名、改字段含义或类型、把可选参数改成必填、改错误码号码、削弱顺序或生命周期保证) +才会出新的版本(`backend` 版本 `1`,协议随它一起变)。这样插件只要按下面四条实现一次,就能在今后支持这一版本的所有宿主上工作: + +1. **只调用 `getCapabilities.services` 列出的方法。** 其他方法会得到 `-32601` `plugin.host.unavailable`。 +2. **忽略不认识的字段和通知。** 结果、通知、`details`、`limits` 里多出来的字段,以及不认识的 `lithe/host/*` 通知,都直接忽略。 +3. **新选项不被支持时会得到明确回答。** 请求里带了宿主不认识的字段,Core 在宿主看到请求之前回 + `-32018` `plugin.host.unsupportedOption`,`details: { "option": "<字段名>" }`。宿主不会悄悄忽略它, + 你可以去掉这个选项重试,或告诉用户需要更新 Lithe。 +4. **不认识的号码按宿主失败处理。** 新宿主可能带来你没见过的错误码;认识 `error.data.code` 时按它分支,否则当作一般失败。 + +新服务总是配一个新的清单权限。不认识这个权限的宿主会在安装时以 `plugin.permission.unknown` 拒绝整个插件包, +权限不会被悄悄授予或忽略。 + +### 11. 跨插件调用 + +插件函数默认只属于插件自己。导出函数后,另一个已启用、且在清单里声明了这次调用的插件可以经 Core 调用它:调用方接触不到被调方的进程,被调方的后端也只知道自己正在服务的那次调用。完整规则见[插件后端与插件函数](../reference/domains/backend.md#跨插件调用)和契约的[Cross-plugin calls](../contract/v1/README.md#cross-plugin-calls),这里只列要点: + +- **清单。** 声明 `pluginFunctions`:`requires` 里写 `pluginFunctions: 0` 和 `backend: 0`;`exports` 的每一项是 `{ id, version, context: "workspace" }`,`calls` 的每一项是 `{ id, version }`。它不授予任何清单权限:调用方的 `calls` 和被调方的 `exports` 就是双方的同意。不支持 `pluginFunctions` 的宿主以 `plugin.api.unsupported` 拒绝这类清单。 +- **注册。** 声明导出不等于注册:后端仍要用 `lithe/function/register` 注册该函数。只导出函数的插件不需要网页、命令或 `onWorkspaceOpen`,被调用时启动后端;只调用的插件仍要有其中一种启动方式。 +- **调用方的两个中转方法。** 先用 `functions/providers` 找到导出某个函数的插件,再用 `functions/invoke` 调用其中一个。调用方没有声明任何 `calls` 会得到 `-32010` `plugin.host.permissionDenied`。 +- **被调方收到的 `caller`。** 中转的 `lithe/function/invoke` 顶层多一个 `caller: { pluginID }`,由 Core 按调用方会话写入;没有这个字段就表示宿主发起的调用。要按调用方判断的被调方只读这个顶层字段,不读 `params` 里自称的插件 ID。 +- **嵌套调用。** 被调方在处理中转调用时再发起调用,要把这次调用顶层的 `operationID` 填进 `parentOperationID`,Core 才知道嵌套调用属于哪条调用链;调用链最长 4 次,调用链上已有的插件不能再被调用(`plugin.function.callCycle`)。 +- **信任不传递。** 被调方不得接受调用方给的可执行路径或命令行去启动进程。 +- 远程工作区的上下文被拒绝(`plugin.host.authorityUnsupported`)。 + +官方 SDK 封装了 `functions/providers`、`functions/invoke`,被调函数能读到 `caller`;`parentOperationID` 不传时,SDK 取正在处理的 `lithe/function/invoke` 的 `operationID`。 +下面以 Node.js 为例,[Java](../sdk/java/README.md#calling-other-plugins) 和 [Rust](../sdk/rust/README.md#calling-other-plugins) 的对应方法见各自 README: + +```js +// Caller. The manifest declares pluginFunctions.calls for document.format. +backend.function('format.current', async (params, context) => { + const workspace = context.workspaceContext; + if (!workspace || !await context.host.supports('lithe/host/functions/invoke')) { + throw new PluginError('format.unavailable', 'Cross-plugin calls are unavailable.'); + } + const { providers } = await context.host.functions.providers('document.format'); + if (providers.length === 0) return null; + const { value } = await context.host.functions.invoke(workspace, { + pluginID: providers[0].pluginID, + functionID: 'document.format', + params: { path: params.path }, + timeoutMilliseconds: 30000, + }); + return value; +}); + +// Provider. `caller` is { pluginID } for a relayed call and null when the host started the call. +backend.function('document.format', async (params, context) => { + const callerID = context.caller?.pluginID ?? null; + return formatDocument(params, callerID); +}); +``` + +### 12. 验证 + +- 用契约里的 [`fixtures/backend/function-protocol.json`](../contract/v1/fixtures/backend/function-protocol.json) 核对你的实现: + `hostRequests` 有不认识的选项等请求用例,`invocationContexts` 有调用上下文,`invocationCallers` 有被中转调用的 `caller`, + `hostFailures` 有失败码映射,`relayErrors` 有中转失败码,`hostNotifications` 有通知用例。 +- 清单示例是 [`fixtures/backend/backend-workspace.plugin.json`](../contract/v1/fixtures/backend/backend-workspace.plugin.json); + 纯后端命令的示例是 [`fixtures/backend/command-backend.plugin.json`](../contract/v1/fixtures/backend/command-backend.plugin.json); + 跨插件调用的示例是 [`fixtures/backend/plugin-functions-exporter.plugin.json`](../contract/v1/fixtures/backend/plugin-functions-exporter.plugin.json) 和 + [`fixtures/backend/plugin-functions-caller.plugin.json`](../contract/v1/fixtures/backend/plugin-functions-caller.plugin.json); + [`fixtures/manifest/invalid-manifests.json`](../contract/v1/fixtures/manifest/invalid-manifests.json) 里的 backend 用例是会被拒绝的写法。 +- 官方 SDK 的示例后端可以在本仓库根目录用 `node shared/plugins/scripts/sdk-e2e.mjs` 对当前工作树的 Core 做端到端检查; + 文档和契约的离线检查是 `node shared/plugins/scripts/verify-docs.mjs`。 + +--- + +## English + +This guide is for authors writing a plugin backend (`backend`): start without a page, know which workspaces the user has open, respond to commands, and call host services when needed, including functions that other plugins export. + +The [contract](../contract/v1/README.md#backend-lifecycle-and-workspace-contexts) is normative for fields and error codes; this guide covers how to use them and why they are designed this way. + +> **Host support.** The Lithe desktop declares `backend` and `pluginFunctions` (both version `0`), so the function protocol, host services, and cross-plugin calls in this guide run there. The host grants only the services it implements: which methods a session can call is decided by `services` in the run-time `lithe/host/getCapabilities` answer, so do not assume it from this document (sections 5 and 10). The desktop's declarations and notes are in the comment above `Declared()` in +> [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go). +> All three official SDKs implement the protocol in this guide: Node.js SDK 0.5.0, Java SDK 0.5.0, and Rust SDK 0.4.0 +> (see Host services in each README: [Node.js](../sdk/node/README.md#host-services), [Java](../sdk/java/README.md#host-services), +> [Rust](../sdk/rust/README.md#host-services); release pages: [sdk-node-v0.5.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-node-v0.5.0), +> [sdk-java-v0.5.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-java-v0.5.0), +> [sdk-rust-v0.4.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-rust-v0.4.0)). With another language, handle the requests and notifications below yourself. +> For how far Core and the desktop support each capability, see the [capability and protocol versions](../reference/capabilities-and-versions.md). Questions about the contract go to +> [Lithe-core-rust Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +### 1. What the three mechanisms control + +| Where | Value | Controls | +| --- | --- | --- | +| `requires.backend` | `0` | Whether the plugin installs, **when** the backend process starts, and whether it may declare `workspace.metadata` and `onWorkspaceOpen` | +| `litheFunctions.version` in the `initialize` answer | `0` | The **wire protocol** once the process runs: function registration and invocation, host services, capability discovery, workspace contexts, host notifications, typed failure codes, and cross-plugin calls. The protocol has no version of its own; it is the version of `backend` | +| `getCapabilities.services` | Runtime query result | Which host methods this session **can actually call**, and which notifications it receives | + +The manifest only declares. The host reads it to decide whether to install and when to start; what the running process can do is decided by negotiation and `getCapabilities`. + +### 2. Minimal manifest + +```json +{ + "schemaVersion": 0, + "id": "io.github..orders", + "displayName": "Orders", + "version": "1.0.0", + "pluginAPIVersion": 0, + "requires": { "backend": 0 }, + "vendor": { "id": "io.github.", "displayName": "", "signatureRequirement": "none" }, + "permissions": ["backend.launch", "workspace.metadata"], + "activationEvents": ["onWorkspaceOpen"], + "languages": [], + "backend": { + "launch": { "kind": "packaged", "path": "bin/backend" }, + "arguments": ["--state", "${stateDir}"] + } +} +``` + +- `requires` must list exactly the capabilities the manifest uses. Starting a process ([Process start](../contract/v1/README.md#process-start)) is part of the base contract, not a capability, and is not written in `requires`. +- A `backend` needs at least one way to start: a web tool window (the first page call), a command in `ui.commands[]` (`onCommand:` also starts the backend), `onWorkspaceOpen`, or a provider the host queries (a toolchain provider, a keymap import provider, or a language server whose `launch.kind` is `backend`, which asks the backend for its command line when it starts). Having none is `plugin.manifest.invalid`. +- For commands without a tool window, add `ui: 0` to `requires`: commands may omit `toolWindowID`, and `toolWindows` may be empty or omitted; see section 7. +- `workspace.metadata` only tells the backend the open workspaces' names, roots, IDs, authority (local or remote), and each workspace's private state directory; + it reads no file contents. Do not declare it unless you need this information; installation review lists it to the user. +- `${workspaceStateDir}` is refused: a plugin has one backend process shared by every window, so a workspace is not process state. + Take each workspace's state directory from `stateDirectory` in `getContext`; the plugin-wide state directory is `${stateDir}`, which is also the backend's start root and working directory. +- A plugin publishes one archive per platform, so the packaged path does not vary by `${arch}`. + +### 3. When the backend starts + +- A plugin has at most one backend process, shared by every workspace and window, running on the machine that runs Lithe, for remote workspaces too. +- `onWorkspaceOpen` does not slow down opening a workspace. The host starts these backends one by one after the window appears; a backend whose `initialize` times out is stopped + and reported like any other start failure, without blocking the workspace. A failed or disabled backend never blocks the workspace either. +- The backend ends when the plugin is disabled, updated, or uninstalled, and when the host quits. +- Activation only checks that the plugin is installed, enabled, and approved in review. Lithe has no workspace trust state yet; the host asks the user each time before an operation runs or changes something. +- A backend serves only its own plugin: it never answers `lithe/build/*` or VCS requests. A build-only backend (one that does not answer `litheFunctions`) receives no function requests, and those get `-32601`. + +### 4. Negotiating the protocol + +The host offers `capabilities.experimental.litheFunctions: { "versions": [0] }` in `initialize`. A backend that implements the protocol answers: + +```json +"experimental": { "litheFunctions": { "version": 0, "functions": [{ "id": "orders.refresh" }] } } +``` + +`functions` is optional; leaving it out means no private functions, and you can add or remove them later with `lithe/function/register` and `lithe/function/unregister`. An answer with a version the host did not offer, or with a malformed or duplicate function ID, does not negotiate the protocol at all, so a session never starts with half a registry. Both sides ignore unknown fields. + +A function ID is dot-separated lowerCamelCase segments (`orders.refresh`), at most 128 bytes, and a session holds at most 256 functions. + +### 5. Ask the host what it can do first + +Once the protocol is negotiated, first call `lithe/host/getCapabilities` (no params, or `{}`): + +```json +{ + "protocolVersion": 0, + "backendAuthority": "local", + "services": { + "commands": { "methods": [], "notifications": ["lithe/host/commands/didRun"] }, + "host": { "methods": ["lithe/host/getCapabilities"], "notifications": [] }, + "subscriptions": { + "methods": ["lithe/host/subscriptions/dispose"], + "notifications": ["lithe/host/subscriptions/notify"] + }, + "workspace": { + "methods": ["lithe/host/workspace/listContexts", "lithe/host/workspace/getContext", "lithe/host/workspace/getFolders"], + "notifications": ["lithe/host/workspace/didChangeContexts"] + } + }, + "limits": { + "pendingHostRequests": 16, + "functionResultBytes": 1048576, + "hostRequestBytes": 1048576, + "hostResultBytes": 1048576, + "errorMessageBytes": 1024, + "errorDetailsBytes": 4096, + "notificationBytes": 1048576 + } +} +``` + +The example is a session that declared `workspace.metadata`. Every plugin session has `host`, `subscriptions`, and `commands`; `services` lists only the methods this session may use, +so undeclared permissions and services the host does not implement never appear, and a listed method never fails for want of a handler. Treat the numbers as whatever the host returns. `protocolVersion` is the negotiated `litheFunctions` version. +Some service entries also carry `options`: when editor actions or providers are granted, `services.editor.options` lists the icons the host draws and the provider types it routes, and `limits` gains `editorActionSourceBytes`. +**Decide what you can call from `services`**; see section 10. + +### 6. Workspace contexts + +A context is "one workspace open in one window": + +```json +{ + "contextID": "w-3f2a9c", + "generation": 2, + "name": "orders", + "authority": { "kind": "local" }, + "folders": [{ "rootID": "r0c1d2e3f4a5b6c7d", "name": "orders", "path": "/Users/me/orders" }], + "stateDirectory": "/path/chosen/by/the/host/" +} +``` + +| Field | How to use it | +| --- | --- | +| `contextID` | Stable while the window shows this workspace. When the window switches to another workspace, the old context closes and the new one gets a new ID, so state cached by ID never leaks into another workspace | +| `generation` | Starts at 1 and increments when the same workspace's roots or authority change. A request with a stale `generation`, or for a closed context, gets `plugin.host.contextExpired`; call `listContexts` again | +| `rootID` | Stable for the same location across restarts and does not reveal the path; derived cryptographically per plugin, so two plugins see different IDs for the same folder | +| `path` | Present only for local workspaces. A remote workspace (`authority.kind` is `remote`, with an opaque `authority.id`) has no `path`, and the local directory the host keeps for it is only an empty placeholder | +| `stateDirectory` | Private to the plugin and the workspace, outside the plugin package and the app bundle; it survives disabling and updating the plugin and is deleted on uninstall. Only `getContext` returns it, creating it at that moment; `listContexts` creates no directory | + +Common requests: + +| Request | Params | Result | +| --- | --- | --- | +| `lithe/host/workspace/listContexts` | none | `{ revision, contexts }`; entries have no `stateDirectory`; `revision` grows whenever a context opens, closes, or changes | +| `lithe/host/workspace/getContext` | `{ context: { contextID, generation } }` | The full context | +| `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders }` | + +When a page calls a backend function, the host fills in the context of the page's window: the backend receives `{ contextID, generation }` +in `params.context` of `lithe/function/invoke` and can pass it straight to `getContext`. + +### 7. Host notifications + +The host tells the backend about changes with JSON-RPC notifications; the backend does not answer them: + +| Notification | Params | When it arrives | +| --- | --- | --- | +| `lithe/host/workspace/didChangeContexts` | `{ revision }` | A context opened, closed, or changed; call `listContexts` again | +| `lithe/host/commands/didRun` | `{ commandID, context? }` | The user ran one of the plugin's own commands. If the backend is not running and declares `onCommand:`, the host starts it first and delivers the notification once it is ready | +| `lithe/host/subscriptions/notify` | `{ subscriptionID, sequence, event }` | An event of a subscription. `sequence` starts at 1 per subscription and increments by one, so gaps reveal losses; cancel with `lithe/host/subscriptions/dispose` | +| `lithe/host/run/didExit` | `{ runID, exitCode?, signal? }` | A run the backend started with `run/start` ended; sent only to a session holding `run.start` | +| `lithe/host/languageServer/didChangeState`, `didReceiveNotification` | See the contract | State changes of the plugin's own language server, or a notification forwarded from it; needs `languageServer.request` | + +A backend that starts late gets no replay of earlier `didChangeContexts`; call `listContexts` once after starting. + +`didRun` is only an event and does not mean the command's work succeeded; the host also never opens an empty tool window just to deliver it. A command that arrives while `initialize` is pending waits until the backend is ready, and a crashed backend can start again on its declared command event. + +#### Backend-only commands + +```json +"requires": { "backend": 0, "ui": 0 }, +"permissions": ["backend.launch", "ui.contribute"], +"activationEvents": ["onCommand:io.github..orders.refresh"], +"ui": { + "toolWindows": [], + "commands": [ + { "id": "io.github..orders.refresh", "title": "Refresh Orders", "placements": ["commandPalette"] } + ] +} +``` + +A command without `toolWindowID` requires `backend`, the `ui.contribute` and `backend.launch` permissions, and the matching `onCommand:`. +The desktop presents commands that belong to a tool window; `ui` commands that belong to no tool window are not presented on the desktop yet (see the host note at the top of this page). For a complete example, see +[`fixtures/backend/command-backend.plugin.json`](../contract/v1/fixtures/backend/command-backend.plugin.json). + +### 8. Document, file, and window methods + +The host opens a set of public methods, each group behind a permission. `getCapabilities.services` lists only the intersection of "grants derived from the manifest's permissions" and "methods this host implements", so check before calling, as rule 1 of section 10 says. + +| Method (prefix `lithe/host/`) | Permission | Purpose | +| --- | --- | --- | +| `credentials/get`, `credentials/set`, `credentials/delete` | `credentials.store` | Secrets private to the plugin, kept in the platform's secret store, never in the state directory or the package; a `key` is 1 to 128 letters, digits, `.`, `_`, `:`, or `-`, a secret is at most 16 KiB; deleted when the plugin is uninstalled | +| `documents/list`, `documents/getSnapshot`, `editor/getActive`, `editor/getSelections` | `documents.read` | List open documents; read unsaved editor content, the active tab, and selections | +| `editor/applyEdits`, `editor/replaceSelections`, `editor/insertText` | `editor.edit` | Edits with `expectedVersion`; a batch is one atomic operation and one undo step | +| `workspace/readDirectory`, `workspace/stat`, `workspace/readFile` | `workspace.files.read` | Read directories and files on disk | +| `workspace/watch` | `workspace.files.watch` | Subscribe to file changes; events arrive through `subscriptions/notify` | +| `workspace/findFiles`, `workspace/findText` | `workspace.search` | Search by file name or content, paged | +| `editor/open`, `editor/reveal` | `editor.open` | Open a file and go to a location | +| `preview/openHTML` | `preview.open` | Open an HTML file in the built-in preview | +| `browser/openExternal` | `browser.open` | Open an HTTP(S) link or HTML file in the system browser | +| `window/showInformation`, `window/showWarning`, `window/showError` | `window.messages` | Show a message with up to 3 buttons and return which one the user chose | + +The [contract](../contract/v1/README.md#public-document-file-and-window-methods) is normative for params, results, and limits. When writing a backend: + +- Every method takes `context: { contextID, generation }`. Files are named by `{ rootID, path }`, where `path` uses `/` and is relative to + a root returned by `getFolders`. These methods refuse remote workspaces explicitly (`authorityUnsupported`). +- `workspace.metadata` only gives workspace information and **does not grant file reads**; declare `workspace.files.read` separately to read files. +- Positions `{ line, column }` start at 1 and count UTF-16 units; ranges are half-open. +- One read returns at most 128 KiB: an oversized snapshot gets `limitExceeded`, and a file read marks truncation with `isTruncated`. A batch of edits or selections has at most 1,000 items; a directory page defaults to 200 items and holds at most 1,000, a search page defaults to 100 and holds at most 200; the host-side deadline is 30 seconds. +- Cancellation or timeout **does not mean rollback**: an edit or open may already have taken effect, so do not replay changes automatically. On `versionConflict`, reread and recompute; + on `contextExpired`, find the original target again instead of switching to the focused window; `busy` and `rateLimited` may be retried a bounded number of times; + `permissionDenied` and `readOnly` need the user. +- Documents open in the background without a mounted editor can be listed and read, but edits and selections may return `busy`. +- Messages have rate and queue limits (5 per 10 seconds per plugin, at most 3 awaiting an answer, at most 10 per window; text of at most 2,000 characters, at most 3 buttons of at most 40 characters each), and the request stays pending until the user answers. + +Several more groups of methods, each behind its own permission, also need a `getCapabilities.services` check before calling; their manifest conditions are in the contract sections named below: + +- The plugin's own editor actions (`editor.actions`), editor language-feature providers (`editor.providers`), and native terminals (`terminal.create`); see [Editor, documents, and previews](../reference/domains/editor.md) and [Open a native Lithe terminal from a plugin](native-terminals.md). +- Run and debug sessions (permissions `run.start`, `debug.start`): `run/start` and `run/stop` start and stop a host-owned Run session (Run window entry, console, Stop button, exit status), `debug/start` starts a debug session on a debug adapter the plugin itself declares, and the `run/didExit` notification reports that a run ended; see [Plugin backend and plugin functions](../reference/domains/backend.md), [Debug adapters](../reference/domains/debug-adapters.md), and the contract's [Run and debug sessions](../contract/v1/README.md#run-and-debug-sessions). +- Own language servers (permission `languageServer.request`, open only for servers that list `requestMethods` or `forwardNotifications`): `languageServer/request` and `languageServer/notify` send requests and notifications to a language server the plugin itself declares, and `languageServer/setStatus` shows its preparation state in the host's status UI; see [Languages, language servers, run and test](../reference/domains/languages.md) and the contract's [Language server host methods](../contract/v1/README.md#language-server-host-methods). +- Content providers (permission `documents.contentProvider`): `documents/registerContentProvider` binds a URI scheme to a backend function, and the host calls that function for read-only content when the user opens a document of that scheme; see [Editor, documents, and previews](../reference/domains/editor.md) and the contract's [Content providers](../contract/v1/README.md#content-providers). + +The official SDKs wrap credentials, workspaces, documents, editors, preview, the browser, window messages, commands, subscriptions, and cross-plugin calls; `run/*`, `debug/start`, `languageServer/*`, and `documents/registerContentProvider` have no dedicated wrappers, so call them with the SDK's generic host request. + +### 9. Host failures and error codes + +Host failures are typed: each JSON-RPC number maps one-to-one to an `error.data.code`, a number never changes meaning, and some failures carry `error.data.details`. + +| `error.data.code` | Number | What a backend usually does | +| --- | --- | --- | +| `plugin.host.unavailable` | `-32601` | The host has no such method; check `getCapabilities` first | +| `plugin.host.invalidParams` | `-32602` | Malformed params; fix the code | +| `plugin.host.failed` | `-32603` | Any other failure; report it to the user | +| `plugin.host.cancelled` | `-32800` | You cancelled it yourself with `$/cancelRequest` | +| `plugin.host.permissionDenied` | `-32010` | The permission is not declared, or the user refused | +| `plugin.host.contextExpired` | `-32011` | Call `listContexts` again, then retry | +| `plugin.host.authorityUnsupported` | `-32012` | The service does not support remote workspaces, and the host will not run it locally instead | +| `plugin.host.versionConflict` | `-32013` | The resource version changed; `details.currentVersion` gives the current version | +| `plugin.host.limitExceeded` | `-32014` | A limit was exceeded, for example more pending requests than `limits.pendingHostRequests` | +| `plugin.host.timeout` | `-32015` | The host timed out | +| `plugin.host.notFound` | `-32016` | The resource does not exist | +| `plugin.host.readOnly` | `-32017` | The resource or workspace is not writable | +| `plugin.host.unsupportedOption` | `-32018` | The host does not know a field in the request; `details.option` names it, so drop it and retry | +| `plugin.host.preconditionFailed` | `-32019` | A required state does not hold, for example the user did not confirm | +| `plugin.host.busy` | `-32020` | The resource is in use; retry later | +| `plugin.host.rateLimited` | `-32021` | Too many requests; wait `details.retryAfterMilliseconds` when given | +| `plugin.host.languageServerUnavailable` | `-32022` | The plugin's language server is not running for the context, or stopped while the request was in flight; retry after `didChangeState` reports a new `generation` | +| `plugin.host.languageServerError` | `-32023` | The language server answered with a JSON-RPC error; `details` is `{code, message}` | +| `plugin.host.methodNotDeclared` | `-32024` | The method, or the `workspace/executeCommand` command, is not in the server's declared session | +| `plugin.host.toolUnresolved` | `-32025` | A tool definition the request names resolves to no executable for this workspace; `details` is `{ tool, reason }` | +| `plugin.host.foreignContribution` | `-32026` | The named server or contribution belongs to another plugin | + +`-32000` through `-32009` are reserved for the backend's own failures; host numbers start at `-32010`. Cross-plugin calls add `plugin.function.failed` (`-32070`) and `plugin.function.invalidResult` (`-32071`); see section 11. + +A session has at most 16 unanswered host requests, and the 17th gets `plugin.host.limitExceeded`. A backend can cancel its own pending host request with `$/cancelRequest`, and Core answers it at once with `-32800`. + +### 10. Host services only grow: four rules for backends + +New host capabilities (new services with their methods and notifications, optional params, result fields, error codes) are added within the same version without raising it; only changes that would make an existing backend misread something (removing or renaming, changing a field's meaning or type, making an optional param required, changing an error number, weakening an ordering or lifecycle guarantee) +lead to a new version (`backend` version `1`, with the protocol moving with it). So a plugin that follows these four rules once works on every future host that supports this version: + +1. **Call only methods listed in `getCapabilities.services`.** Any other method gets `-32601` `plugin.host.unavailable`. +2. **Ignore unknown fields and notifications.** Ignore extra fields in results, notifications, `details`, and `limits`, and any unknown `lithe/host/*` notification. +3. **An unsupported new option gets an explicit answer.** If a request carries a field the host does not know, Core answers + `-32018` `plugin.host.unsupportedOption` with `details: { "option": "" }` before the host sees the request. The host never silently ignores it; + drop the option and retry, or tell the user to update Lithe. +4. **Treat unknown numbers as host failures.** A newer host may bring error codes you have not seen; branch on `error.data.code` when you know it, and otherwise treat it as a general failure. + +A new service always comes with a new manifest permission. A host that does not know the permission refuses the whole plugin package at installation with `plugin.permission.unknown`, +so the permission is never silently granted or ignored. + +### 11. Cross-plugin calls + +A plugin function is private to its plugin unless the plugin exports it. An exported function can be called, through Core, by another enabled plugin whose manifest declares that call: the caller never reaches the provider's process, and the provider's backend never learns more than the call it serves. The full rules are in [Plugin backend and plugin functions](../reference/domains/backend.md#cross-plugin-calls) and the contract's [Cross-plugin calls](../contract/v1/README.md#cross-plugin-calls); the essentials: + +- **Manifest.** Declare `pluginFunctions`: `requires` lists `pluginFunctions: 0` and `backend: 0`; each `exports` entry is `{ id, version, context: "workspace" }` and each `calls` entry is `{ id, version }`. It grants no manifest permission: the caller's `calls` and the provider's `exports` are the consent of the two sides. A host without `pluginFunctions` refuses such a manifest with `plugin.api.unsupported`. +- **Registration.** Declaring an export does not register it: the backend still registers the function with `lithe/function/register`. A plugin that only exports needs no page, command, or `onWorkspaceOpen`, because a call starts its backend; a plugin that only calls still needs one of them. +- **Two relay methods for the caller.** It finds the plugins that export a function with `functions/providers`, then calls one of them with `functions/invoke`. A plugin that declares no `calls` gets `-32010` `plugin.host.permissionDenied`. +- **`caller` in a relayed call.** The `lithe/function/invoke` a provider receives has one more top-level field, `caller: { pluginID }`, which Core writes from the calling session; its absence means the host started the call. A provider that decides by who is asking reads only this top-level field, never a plugin ID that `params` claims. +- **Nested calls.** When a provider makes a call while serving a relayed call, it puts that call's top-level `operationID` in `parentOperationID`, so Core knows which call chain the new call belongs to. A chain has at most 4 calls, and a plugin already on the chain cannot be called again (`plugin.function.callCycle`). +- **Trust is not transitive.** A provider must not accept an executable path or command line from its caller and start a process with it. +- A call whose context is a remote workspace is refused (`plugin.host.authorityUnsupported`). + +The official SDKs wrap `functions/providers` and `functions/invoke` and expose `caller` to the called function; when `parentOperationID` is omitted, the SDK uses the `operationID` of the `lithe/function/invoke` being handled. +The example below uses Node.js; for the matching methods in [Java](../sdk/java/README.md#calling-other-plugins) and [Rust](../sdk/rust/README.md#calling-other-plugins), see their READMEs: + +```js +// Caller. The manifest declares pluginFunctions.calls for document.format. +backend.function('format.current', async (params, context) => { + const workspace = context.workspaceContext; + if (!workspace || !await context.host.supports('lithe/host/functions/invoke')) { + throw new PluginError('format.unavailable', 'Cross-plugin calls are unavailable.'); + } + const { providers } = await context.host.functions.providers('document.format'); + if (providers.length === 0) return null; + const { value } = await context.host.functions.invoke(workspace, { + pluginID: providers[0].pluginID, + functionID: 'document.format', + params: { path: params.path }, + timeoutMilliseconds: 30000, + }); + return value; +}); + +// Provider. `caller` is { pluginID } for a relayed call and null when the host started the call. +backend.function('document.format', async (params, context) => { + const callerID = context.caller?.pluginID ?? null; + return formatDocument(params, callerID); +}); +``` + +### 12. Verification + +- Check your implementation against the contract's [`fixtures/backend/function-protocol.json`](../contract/v1/fixtures/backend/function-protocol.json): + `hostRequests` has request cases such as unknown options, `invocationContexts` has invocation contexts, `invocationCallers` has the `caller` of a relayed call, + `hostFailures` has the failure code mapping, `relayErrors` has the relay failure codes, and `hostNotifications` has notification cases. +- The manifest example is [`fixtures/backend/backend-workspace.plugin.json`](../contract/v1/fixtures/backend/backend-workspace.plugin.json); + the backend-only command example is [`fixtures/backend/command-backend.plugin.json`](../contract/v1/fixtures/backend/command-backend.plugin.json); + the cross-plugin call examples are [`fixtures/backend/plugin-functions-exporter.plugin.json`](../contract/v1/fixtures/backend/plugin-functions-exporter.plugin.json) and + [`fixtures/backend/plugin-functions-caller.plugin.json`](../contract/v1/fixtures/backend/plugin-functions-caller.plugin.json); + the backend cases in [`fixtures/manifest/invalid-manifests.json`](../contract/v1/fixtures/manifest/invalid-manifests.json) are the forms that are refused. +- From the repository root, `node shared/plugins/scripts/sdk-e2e.mjs` runs the official SDKs' sample backends against this working tree's Core; + `node shared/plugins/scripts/verify-docs.mjs` is the offline check of the documents and the contract. diff --git a/shared/plugins/guides/plugin-logging.md b/shared/plugins/guides/plugin-logging.md new file mode 100644 index 00000000..cbf7463c --- /dev/null +++ b/shared/plugins/guides/plugin-logging.md @@ -0,0 +1,165 @@ +# 插件接入统一日志 / Plugin logging + +[中文](#中文) · [English](#english) + +## 中文 + +插件使用 `litheLogging` 0 与 `lithe/log` 发送受限的操作事实,由宿主的共享事件目录、脱敏、队列和文件写入器落盘。三个 SDK 不提供自由文本 `log` 接口,也没有 stderr 回退。插件无需实现日志文件格式、文件轮转、脱敏器或后台写入线程。 + +宿主必须在 initialize 中提供 `capabilities.experimental.litheLogging.versions: [0]`,SDK 回复 `capabilities.experimental.litheLogging.version: 0`。缺少协议时,SDK 拒绝初始化;不自动退回其他日志方式。初始化尚未协商时的失败只通过原有 RPC 错误与宿主进程生命周期记录,不能提前发送未协商的日志通知。 + +本文的 API 由 Node.js SDK 0.5.0、Java SDK 0.5.0 和 Rust SDK 0.4.0 提供,发布标签分别为 `sdk-node-v0.5.0`、`sdk-java-v0.5.0` 和 `sdk-rust-v0.4.0`。插件在构建输入中锁定对应的 release tag 与 SHA-256,不要引用开发机的绝对 path 依赖。这些 SDK 要求宿主提供 `litheLogging` 0;在提供它的 Lithe 稳定版发布之前,用这些 SDK 构建的插件把 GitHub Release 标为预发布。 + +### 每种事实与级别 + +| `kind` | 目录事件 | 级别与用途 | +| --- | --- | --- | +| `operationFinished` | `plugins.operation.finished` | INFO,关键用户操作的真实成功终态 | +| `operationFailed` | `plugins.operation.failed` | ERROR,影响请求结果的失败;SDK 在拥有结果的边界发送一次 | +| `degraded` | `plugins.operation.fallback_selected` | WARN,已处理并选择回退的失败 | +| `recovered` | `plugins.operation.recovery_finished` | WARN,曾失败后的真实恢复 | + +`operationFinished` 和 `operationFailed` 必须有单调时钟计算的 `durationMs`。失败、降级、恢复使用受控的 `code`;成功没有 `code`。可选 `count` 与 `durationMs` 都是 0…9007199254740991 的整数。API 不接受原始 message、stack、request、URL、SQL、命令输出或字段字典。 + +`operation` 只接受本 SDK 已注册的函数 ID 或封闭协议操作,超过 64 字节的已注册函数 ID 统一为 `function-call`。公开的协议操作包括 initialize/shutdown/notification/function-call/result-encoding、SDK 已知的 `lithe/build/*` 与 `lithe/vcs/*` 方法,以及快捷键导入请求 `lithe/keymap/import`(三种 SDK 自动上报其终态);任意用户文本即使字符合法也不接受。`code` 是最长 64 字节的 ASCII 类别标识,不是错误正文。 + +通道由宿主从已校验的安装插件 ID 得到 `plugins//host`,插件不能更改通道或拼接路径。上下文诊断优先携带宿主原始 `operationID`,缺少合法值时沿用当前请求原始 JSON-RPC ID(安全字符串或整数的十进制字符串),wire 名为 `operationId`,只保留最长 128 字节的受控 ASCII 标识;无真实身份时省略。通知没有请求 ID,不生成回退身份。后台共享任务可以使用 backend/handle 的无 op 诊断,不能伪造一个操作 ID。 + +### 三个 SDK 的用法 + +```js +backend.function('project.refresh', async (params, context) => { + // 只在决定继续执行的真实降级分支调用,不把异常正文传进日志。 + context.diagnostics.degraded('project.refresh', 'cache_unavailable', 1); + return refreshWithoutCache(params); +}); +// 高频读取或轮询不产生 INFO 成功噪声,失败仍自动记录。 +backend.function('project.inspect', (params) => inspectProject(params)); +backend.suppressSuccessDiagnostics('project.inspect'); +backend.diagnostics.recovered('project.refresh', 'cache_restored'); +``` + +```java +context.diagnostics().degraded("lithe/build/model", "snapshot_unavailable", 1L); +backend.diagnostics().recovered("lithe/build/model", "snapshot_restored"); +backend.suppressSuccessDiagnostics("lithe/build/dependencies"); +``` + +```rust +context.diagnostics().degraded("query.run", "rows_truncated", Some(1))?; +handle.diagnostics().recovered("query.run", "connection_restored", None)?; +backend.suppress_success_diagnostics("query.inspect")?; +``` + +`degraded`、`recovered` 的 operation 必须已注册,静态示例需要放到实际的注册完成之后。Rust 的返回错误只表达调用参数非法;协议写入复用现有 writer,诊断传输失败不会把已经成功的用户操作改成失败。没有额外 writer 或后台队列。 + +混合用户操作和高频读取的单个入口可以在当前调用上使用 `context.suppressSuccessDiagnostics()`(Rust 为 `context.suppress_success_diagnostics()`);该决定只影响本次成功事实,不影响其他调用与失败。 + +SDK 在函数与受管 build/VCS 请求的结果边界自动发送一次终态,通知在 RPC 响应之前发送,宿主才能关联仍活动的请求。不要在处理函数里先发送失败再返回或抛出同一次失败。取消不产生失败事件,正常缺失的收尾对象不应产生 WARN。高频只读、预览、统计与轮询通过 `suppressSuccessDiagnostics` / `suppress_success_diagnostics` 关闭成功事实;失败与真正降级仍保留。 + +Rust SDK 用一次安装的 panic hook 包装器,仅在线程处于 SDK 拥有的 catch 边界时屏蔽原始 payload 输出,随后发送固定 `panic` 类别。其他线程和边界外的 panic 继续执行原 hook。不要把用户内容放进 panic,插件也不要在 SDK 启动后替换该 hook 来恢复正文输出。 + +### 上游诊断与文件位置 + +上游语言服务器的 stderr、`window/logMessage` 继续由宿主接收;它们不是插件自己的结构化日志入口。插件不得把源码、协议载荷、SQL、HTTP body 或构建输出复制进上游诊断,以为脱敏与截断能使其安全。控制台输出走已有 Build、Run、Test、VCS 或插件界面结果。 + +日志窗口与导出的平台支持以 Lithe 桌面端的实际实现为准。Core 接收目录事件,不等于每个平台的日志窗口都已经展示它;只有在对应目标平台执行工作流后才能声明该平台已验证。 + +只有上游工具确实只能写文件时,才使用 `${logDir}`。宿主显式提供当前会话的 `plugins//files/`,通过可写存储适配器创建,不能从插件安装路径或 app bundle 推导: + +- 日志目录只属于当前会话,不保存数据库、索引、下载或工作区状态,不跨工作树共享。 +- 缓存和状态继续使用 `${stateDir}`、`${workspaceStateDir}`。 +- 日志存储不可用时,宿主不提供该变量。远程进程不能使用本机日志目录;引用 `${logDir}` 却没有绝对 `logDirectory` 的启动计划会失败。 +- 不需要原生文件的插件不要增加这个启动依赖。先核对目标宿主和平台确实支持,再修改启动模板。 +- 原生文件不会自动变成时间线事件,也不能把整个文件打印进 stderr。 +- 已发布的应用包、插件包和安装目录运行时只读;日志与锁文件不得改变代码签名或增量更新的基线。 + +### 最小验证 + +1. 用已有有界协议测试确认 logging 协商、四类事实、级别、原始操作关联、成功抑制、取消和非法参数拒绝。 +2. 触发真实失败,确认原有用户错误码与提示保持不变,诊断只有固定类别,不含请求正文、原始异常或堆栈。 +3. 使用真实 Core 查询 `diagnostics.drainCoreEvents`,核对目录事件与 `op`。 +4. 在目标宿主中安装并执行对应工作流,核对日志窗口。SDK 组件通过不能替代产品验收。 +5. 原生日志位置有变化时比较工作流前后安装目录清单或哈希,确认发行资源不变;结束后清理本轮进程。 + +--- + +## English + +Plugins use `litheLogging` 0 and `lithe/log` to send restricted operation facts. The host's shared event catalog, redaction, queue, and file writer persist them. The three SDKs offer no free-text `log` interface and have no stderr fallback. A plugin does not implement the log file format, file rotation, a redactor, or a background writer thread. + +The host must offer `capabilities.experimental.litheLogging.versions: [0]` in initialize, and the SDK replies with `capabilities.experimental.litheLogging.version: 0`. When the protocol is missing, the SDK refuses to initialize; it does not fall back to another logging path. A failure before initialization has negotiated logging is recorded only through the existing RPC error and the host's process lifecycle; the plugin must not send a log notification before negotiation. + +The APIs in this guide are provided by Node.js SDK 0.5.0, Java SDK 0.5.0, and Rust SDK 0.4.0, with release tags `sdk-node-v0.5.0`, `sdk-java-v0.5.0`, and `sdk-rust-v0.4.0`. Pin the matching release tag and SHA-256 in your build inputs; do not depend on an absolute path on a development machine. These SDKs require the host to offer `litheLogging` 0; until a stable Lithe release offers it, mark the GitHub Release of a plugin built with these SDKs as a prerelease. + +### Facts and levels + +| `kind` | Catalog event | Level and use | +| --- | --- | --- | +| `operationFinished` | `plugins.operation.finished` | INFO, the real successful end of a key user operation | +| `operationFailed` | `plugins.operation.failed` | ERROR, a failure that affects the request's result; the SDK sends it once at the boundary that owns the result | +| `degraded` | `plugins.operation.fallback_selected` | WARN, a failure that was handled by choosing a fallback | +| `recovered` | `plugins.operation.recovery_finished` | WARN, a real recovery after an earlier failure | + +`operationFinished` and `operationFailed` must carry a `durationMs` measured with a monotonic clock. Failure, degradation, and recovery use a controlled `code`; success has no `code`. The optional `count` and `durationMs` are integers from 0 to 9007199254740991. The API does not accept raw messages, stacks, requests, URLs, SQL, command output, or field dictionaries. + +`operation` accepts only function IDs registered with this SDK or closed protocol operations; a registered function ID longer than 64 bytes is reported as `function-call`. The public protocol operations are initialize, shutdown, notification, function-call, and result-encoding, the `lithe/build/*` and `lithe/vcs/*` methods the SDK knows, and the shortcut import request `lithe/keymap/import` (all three SDKs report its end state automatically). Arbitrary user text is not accepted even when its characters are valid. `code` is an ASCII category identifier of at most 64 bytes, not an error message. + +The host derives the channel `plugins//host` from the validated installed plugin ID; the plugin cannot change the channel or build a path. Contextual diagnostics carry the host's original `operationID` first; without a valid one they reuse the current request's original JSON-RPC ID (a safe string, or an integer as a decimal string). The wire name is `operationId`, and only a controlled ASCII identifier of at most 128 bytes is kept; it is omitted when there is no real identity. Notifications have no request ID, and no fallback identity is generated for them. Shared background work can use the backend or handle diagnostics without an operation, but must not invent an operation ID. + +### Using the three SDKs + +```js +backend.function('project.refresh', async (params, context) => { + // Call only in a real degraded branch that decides to continue; never pass exception text to the log. + context.diagnostics.degraded('project.refresh', 'cache_unavailable', 1); + return refreshWithoutCache(params); +}); +// High-frequency reads or polling produce no INFO success noise; failures are still recorded automatically. +backend.function('project.inspect', (params) => inspectProject(params)); +backend.suppressSuccessDiagnostics('project.inspect'); +backend.diagnostics.recovered('project.refresh', 'cache_restored'); +``` + +```java +context.diagnostics().degraded("lithe/build/model", "snapshot_unavailable", 1L); +backend.diagnostics().recovered("lithe/build/model", "snapshot_restored"); +backend.suppressSuccessDiagnostics("lithe/build/dependencies"); +``` + +```rust +context.diagnostics().degraded("query.run", "rows_truncated", Some(1))?; +handle.diagnostics().recovered("query.run", "connection_restored", None)?; +backend.suppress_success_diagnostics("query.inspect")?; +``` + +The operation passed to `degraded` and `recovered` must be registered, so put static examples after registration has actually finished. A Rust error return means only that the call's arguments are invalid. Protocol writes reuse the existing writer, and a failed diagnostic send never turns a successful user operation into a failure. There is no extra writer or background queue. + +A single entry point that mixes user operations with high-frequency reads can call `context.suppressSuccessDiagnostics()` (in Rust, `context.suppress_success_diagnostics()`) for the current call. That decision affects only this call's success fact, not other calls or failures. + +The SDK sends one end state automatically at the result boundary of functions and managed build and VCS requests. The notification goes out before the RPC response so the host can relate it to the still-active request. Do not send a failure from a handler and then return or throw the same failure. Cancellation produces no failure event, and a normally absent cleanup object should not produce a WARN. High-frequency reads, previews, statistics, and polling turn off success facts with `suppressSuccessDiagnostics` / `suppress_success_diagnostics`; failures and real degradations are kept. + +The Rust SDK installs a panic hook wrapper once. It suppresses the raw payload output only while the thread is inside a catch boundary the SDK owns, and then sends the fixed `panic` category. Panics on other threads and outside the boundary still run the original hook. Do not put user content in a panic, and do not replace the hook after the SDK starts to restore message output. + +### Upstream diagnostics and file locations + +The host still receives an upstream language server's stderr and `window/logMessage`; they are not the plugin's own structured logging path. A plugin must not copy source code, protocol payloads, SQL, HTTP bodies, or build output into upstream diagnostics in the belief that redaction and truncation make them safe. Console output goes through the existing Build, Run, Test, VCS, or plugin UI results. + +Log window and export support follows the Lithe desktop app's actual implementation. Core accepting catalog events does not mean every platform's log window already shows them. Declare a platform verified only after running the workflow on that target platform. + +Use `${logDir}` only when an upstream tool really can write only to files. The host explicitly provides the current session's `plugins//files/`, created through the writable storage adapter; it is never derived from the plugin's install path or the app bundle: + +- The log directory belongs only to the current session. It holds no databases, indexes, downloads, or workspace state and is not shared across worktrees. +- Caches and state keep using `${stateDir}` and `${workspaceStateDir}`. +- When log storage is unavailable, the host does not provide the variable. Remote processes cannot use a local log directory; a launch plan that references `${logDir}` without an absolute `logDirectory` fails. +- A plugin that needs no native files should not add this launch dependency. Check that the target host and platform support it before changing the launch template. +- Native files do not become timeline events automatically, and the whole file must not be printed to stderr instead. +- Published app bundles, plugin packages, and install directories are read-only at run time; logs and lock files must not change the code signature or the incremental-update baseline. + +### Minimum verification + +1. Use the existing bounded protocol tests to confirm logging negotiation, the four facts, levels, original operation correlation, success suppression, cancellation, and rejection of invalid arguments. +2. Trigger a real failure and confirm that the existing user error code and message are unchanged and that diagnostics contain only fixed categories, with no request body, raw exception, or stack. +3. Query `diagnostics.drainCoreEvents` against a real Core and check the catalog events and `op`. +4. Install the plugin in the target host, run the matching workflow, and check the log window. Passing SDK component tests does not replace product acceptance. +5. When the native log location changes, compare the install directory listing or hashes before and after the workflow to confirm the release resources are unchanged, and clean up this run's processes afterwards. diff --git a/shared/plugins/reference/README.md b/shared/plugins/reference/README.md new file mode 100644 index 00000000..c4887412 --- /dev/null +++ b/shared/plugins/reference/README.md @@ -0,0 +1,93 @@ +# 插件参考 / Plugin reference + +[中文](#中文) · [English](#english) + +## 中文 + +这里按功能整理插件契约:每个编号的 API 现在能用什么,每个能力、协议和格式的版本,以及 Core 和 Lithe 桌面端各自支持哪些部分。所有版本号都从 0 开始。规范以英文的 [`contract/v1/README.md`](../contract/v1/README.md) 为准,本目录只做导航和对照;两边不一致时以契约为准,并请到 [Core 仓库提交问题](https://github.com/Lithe-IDEA/Lithe-core-rust/issues)。 + +### 从哪里开始 + +| 想知道 | 看这里 | +| --- | --- | +| API-01–44 每一项现在能用什么、还缺什么 | [插件 API 总表](api-catalog.md) | +| `requires` 能力版本、进程协议版本、文档格式版本,以及 Core 和 Lithe 桌面端支持哪些 | [能力与协议版本对照](capabilities-and-versions.md) | +| 每个协议、能力和 API 是哪天、哪个 PR 改的 | [插件协议更新记录](protocol-changelog.md)(只有中文) | +| 某个功能的字段、宿主现状、契约章节和示例 | 下方的功能页 | +| 一步步做出一个插件 | [开发指南](../guides/) | + +### 功能页 + +| 功能页 | 内容 | API 编号 | 能力(`requires`) | +| --- | --- | --- | --- | +| [清单与打包](domains/manifest.md) | `plugin.json` 字段、权限与激活事件、包目录 | 不在编号内 | — | +| [语言、语言服务器与运行/测试](domains/languages.md) | 语言、语法、语言服务器、进程启动、运行和测试命令、控制台输出链接 | API-25 | `languages`、`languageServers`、`executionProviders`、`outputLinkMatchers` | +| [插件后端与插件函数](domains/backend.md) | 后端进程、函数协议、宿主方法、工作区上下文、文件、凭据、终端、跨插件调用 | API-01、04–08、20–22、24、35、37、40、41 | `backend`、`pluginFunctions` | +| [编辑器、文档与预览](domains/editor.md) | 文档和选区、版本化编辑、打开与定位、HTML 预览、编辑器动作、编辑器语言功能 Provider、虚拟文档内容提供者 | API-09、10、12、15、16、25、27 | `backend` | +| [界面](domains/ui.md) | 工具窗口、命令、包内网页、原生控件、后端命令、消息框 | API-02、03、13、14、17 | `ui` | +| [插件设置](domains/settings.md) | 声明式设置、放置位置、作用域、生效方式、设置页按钮 | API-19 | `settings` | +| [声明式构建系统](domains/build-systems.md) | 构建检测、模型、命令规划、项目准备角色 | API-29 | `buildSystems` | +| [构建提供者](domains/build-providers.md) | `litheBuild` 协议:同步、任务、测试、运行和调试准备、模型输入 | API-29 | `buildProviders`、`buildModelInputs` | +| [调试适配器](domains/debug-adapters.md) | DAP 适配器声明、启动和传输 | API-30 | `debugAdapters` | +| [版本控制提供者](domains/vcs.md) | VCS 提供者进程和协议 | API-31 | `vcsProviders` | +| [项目生成器](domains/project-generators.md) | 新建项目的模板和步骤 | API-33 | `projectGenerators` | +| [工具链](domains/toolchains.md) | 工具定义、可执行文件候选、工具链提供者、Core 命令 | API-23 | `toolDefinitions`、`toolchainProviders` | +| [快捷键导入](domains/keymap-import.md) | 从其他编辑器导入快捷键的提供者 | 不在编号内 | `keymapImportProviders` | +| [包信任](domains/package-trust.md) | 签名要求、签名文件格式、随应用发布的插件 | 不在编号内 | `packageTrust` | +| [插件市场](domains/marketplace.md) | 索引来源和格式、可用性判断、安装审查和确认 | 不在编号内 | — | + +计划中、还没有方法的 API(API-11、18、26、28、32、34、36、38、39、42–44)只在[总表](api-catalog.md#计划中的-api)列出,没有功能页。 + +### 每个功能页的结构 + +1. 用途和摘要表:能力 ID、Core 命令或协议方法、Lithe 桌面端现状、官方 SDK。 +2. 字段、方法、错误码等细节表。 +3. 契约章节:链接到 `contract/v1/README.md` 的对应小节。 +4. 示例与用例:`contract/v1/fixtures/` 里的文件和用例数量。 +5. 相关指南。 + +--- + +## English + +This directory organizes the plugin contract by feature: what each numbered API offers today, the version of each capability, protocol, and format, and which parts Core and the Lithe desktop client each support. Every version number starts at 0. The English [`contract/v1/README.md`](../contract/v1/README.md) is normative; this directory only navigates and compares. When they disagree the contract wins, and please [file an issue in the Core repository](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +### Where to start + +| To find | Go to | +| --- | --- | +| What each of API-01–44 offers today and what is missing | [Plugin API catalog](api-catalog.md) | +| `requires` capability versions, process protocol versions, document format versions, and which of them Core and the Lithe desktop client support | [Capability and protocol versions](capabilities-and-versions.md) | +| When, and in which PR, each protocol, capability, and API changed | [Plugin protocol changelog](protocol-changelog.md) (Chinese only) | +| A feature's fields, host status, contract sections, and examples | The feature pages below | +| Building a plugin step by step | [Guides](../guides/) | + +### Feature pages + +| Page | Covers | API numbers | Capabilities (`requires`) | +| --- | --- | --- | --- | +| [Manifest and packaging](domains/manifest.md) | `plugin.json` fields, permissions and activation events, package layout | Not numbered | — | +| [Languages, language servers, run and test](domains/languages.md) | Languages, grammars, language servers, process launch, run and test commands, console output links | API-25 | `languages`, `languageServers`, `executionProviders`, `outputLinkMatchers` | +| [Plugin backend and plugin functions](domains/backend.md) | Backend process, function protocol, host methods, workspace contexts, files, credentials, terminals, cross-plugin calls | API-01, 04–08, 20–22, 24, 35, 37, 40, 41 | `backend`, `pluginFunctions` | +| [Editor, documents, and previews](domains/editor.md) | Documents and selections, versioned edits, open and reveal, HTML preview, editor actions, editor language-feature providers, virtual-document content providers | API-09, 10, 12, 15, 16, 25, 27 | `backend` | +| [UI](domains/ui.md) | Tool windows, commands, package web content, native controls, backend commands, message boxes | API-02, 03, 13, 14, 17 | `ui` | +| [Plugin settings](domains/settings.md) | Declarative settings, placement, scope, apply behavior, settings page actions | API-19 | `settings` | +| [Declarative build systems](domains/build-systems.md) | Build detection, models, command planning, project preparation roles | API-29 | `buildSystems` | +| [Build providers](domains/build-providers.md) | The `litheBuild` protocol: sync, tasks, tests, run and debug preparation, model inputs | API-29 | `buildProviders`, `buildModelInputs` | +| [Debug adapters](domains/debug-adapters.md) | DAP adapter declarations, launch, and transports | API-30 | `debugAdapters` | +| [VCS providers](domains/vcs.md) | VCS provider processes and protocol | API-31 | `vcsProviders` | +| [Project generators](domains/project-generators.md) | New-project templates and steps | API-33 | `projectGenerators` | +| [Toolchains](domains/toolchains.md) | Tool definitions, executable candidates, toolchain providers, Core commands | API-23 | `toolDefinitions`, `toolchainProviders` | +| [Shortcut import](domains/keymap-import.md) | Providers that import shortcuts from other editors | Not numbered | `keymapImportProviders` | +| [Package trust](domains/package-trust.md) | Signature requirements, the signature file format, plugins bundled with the application | Not numbered | `packageTrust` | +| [Marketplace](domains/marketplace.md) | Index sources and format, availability, install review and confirmation | Not numbered | — | + +Planned APIs that have no methods yet (API-11, 18, 26, 28, 32, 34, 36, 38, 39, 42–44) are listed only in the [catalog](api-catalog.md#planned-apis) and have no feature page. + +### Layout of each feature page + +1. Purpose and a summary table: capability IDs, Core commands or protocol methods, Lithe desktop client status, official SDKs. +2. Detail tables for fields, methods, error codes, and so on. +3. Contract sections: links to the matching sections of `contract/v1/README.md`. +4. Examples and cases: files in `contract/v1/fixtures/` and their case counts. +5. Related guides. diff --git a/shared/plugins/reference/api-catalog.md b/shared/plugins/reference/api-catalog.md new file mode 100644 index 00000000..940bb246 --- /dev/null +++ b/shared/plugins/reference/api-catalog.md @@ -0,0 +1,345 @@ +# 插件 API 总表(API-01–44) / Plugin API catalog (API-01–44) + +[中文](#中文) · [English](#english) + +## 中文 + +Lithe 的插件公共 API 按 [API 实施计划](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md)统一编号为 API-01 到 API-44。本页列出每个编号**现在能用什么**、**还没有什么**,并把能用的部分链接到契约章节和功能页。参数、返回值、错误码以 [`contract/v1/README.md`](../contract/v1/README.md) 为准;版本号见[能力与协议版本对照](capabilities-and-versions.md)。内容以本仓库 `main` 上的契约和 Core 源码为准。 + +读这张表前先知道三件事: + +- **本页描述契约,不描述某个宿主。** 所有 `lithe/host/*` 方法由 Rust Core 校验、宿主执行。当前的宿主是 Lithe 桌面端:它声明了哪些能力、哪些部分尚未呈现,见[版本对照页](capabilities-and-versions.md#能力版本requires)。Lithe 桌面端目前没有注册 `workspace/readDirectory`、`stat`、`readFile`、`watch`、`findFiles`、`findText` 和 `preview/openHTML`,申请对应权限的插件会被宿主拒绝。 +- **运行时以 `getCapabilities` 为准。** 某个方法出现在本表,不代表当前宿主一定提供;后端调用前先查 `lithe/host/getCapabilities` 返回的 `services`。 +- **计划中的条目没有可用方法名。** 第二张表只描述要做的能力,不列候选方法名;在契约正式写入之前,不要按猜测的名字调用。 + +状态含义: + +| 状态 | 含义 | +| --- | --- | +| 已公开 | 方法已进入 Core 白名单,契约已定义,插件按权限可以调用;宿主是否注册见上面的说明 | +| 既有能力 | 在这次编号之前就已存在的契约能力,继续可用 | +| 部分完成 | 一部分已经可用,表中写明缺口 | +| 进行中 | 正在实现,尚不可用 | +| 未做 | 尚未开放;不代表 IDE 内部没有相关功能 | + +### 现在能用的 API + +| ID | 名称 | 状态 | 现在能用 | 尚未提供 | 功能页 | +| --- | --- | --- | --- | --- | --- | +| API-01 | 能力发现、激活与订阅 | 已公开 | [`getCapabilities`](../contract/v1/README.md#workspace-contexts-and-capability-discovery)、`backend` 的 `onWorkspaceOpen` 激活、`subscriptions/dispose` | 新安装的后端仍需重启后才能启动 | [插件后端](domains/backend.md) | +| API-02 | 命令 | 部分完成 | [`ui` 不打开工具窗口的命令](../contract/v1/README.md#backend-only-commands)和 `commands/didRun` 通知 | 运行期动态注册命令、带返回结果的执行 | [界面](domains/ui.md) | +| API-03 | 菜单、快捷键、上下文键、状态栏 | 部分完成 | 已有的命令放置位置(`ui.commands`) | 通用菜单、快捷键贡献、上下文键、状态栏 | [界面](domains/ui.md) | +| API-04 | 工作区与文档事件 | 部分完成 | `workspace/didChangeContexts`、`workspace/watch` 的文件事件 | 文档、选区、活动编辑器的变化事件 | [插件后端](domains/backend.md) | +| API-05 | 工作区上下文 | 已公开 | [`workspace/listContexts`、`getContext`、`getFolders`](../contract/v1/README.md#workspace-contexts-and-capability-discovery),带 `contextID`/`generation` 与 `rootID` | — | [插件后端](domains/backend.md) | +| API-06 | 文件读取 | 已公开 | [`workspace/readDirectory`、`stat`、`readFile`](../contract/v1/README.md#public-document-file-and-window-methods);每次最多读 128 KiB | Lithe 桌面端尚未注册这三个方法 | [插件后端](domains/backend.md) | +| API-07 | 文件监听 | 已公开 | `workspace/watch` 加 `subscriptions/notify`、`dispose`;上下文或会话结束时回收,溢出时要求重扫 | Lithe 桌面端尚未注册 `workspace/watch` | [插件后端](domains/backend.md) | +| API-08 | 搜索 | 部分完成 | `workspace/findFiles`、`findText`,分页、可取消 | 只搜磁盘内容,不含未保存的修改;匹配的精确范围目前是 `null`;Lithe 桌面端尚未注册这两个方法 | [插件后端](domains/backend.md) | +| API-09 | 文档与选区读取 | 已公开 | `documents/list`、`documents/getSnapshot`、`editor/getActive`、`editor/getSelections`,包含未保存状态、版本、编码、换行符 | — | [编辑器](domains/editor.md) | +| API-10 | 版本化编辑 | 已公开 | `editor/applyEdits`、`replaceSelections`、`insertText`;校验版本,拒绝输入法组字中和只读文档,一批编辑是一次撤销 | — | [编辑器](domains/editor.md) | +| API-12 | 多文件编辑、虚拟文档、diff | 部分完成 | [内容提供者 `documents/registerContentProvider`](../contract/v1/README.md#content-providers):把一个 URI scheme 绑定到后端函数,打开该 scheme 的文档时宿主取只读内容(≤1 MiB,每个工作区窗口每个 scheme 一个) | 跨文件编辑、对比视图 | [编辑器](domains/editor.md) | +| API-13 | 原生视图数据源 | 部分完成 | [`ui` 原生 Tree、List、Form](../contract/v1/README.md#host-rendered-native-content) | 独立的后端数据源、懒加载 | [界面](domains/ui.md) | +| API-14 | 网页消息 | 部分完成 | [包内网页](../contract/v1/README.md#web-content)的桥接、状态和生命周期 | 通用的页面双向消息 | [界面](domains/ui.md) | +| API-15 | 打开与定位 | 部分完成 | `editor/open`、`editor/reveal`,返回 `documentID` 和版本 | 传入范围时只把光标放到起点;不能替换预览标签 | [编辑器](domains/editor.md) | +| API-16 | HTML 预览与外部浏览器 | 已公开 | `preview/openHTML`(`editorAndPreview` 或 `previewOnly`)、`browser/openExternal`(HTTP(S) 地址或已保存的 HTML) | Lithe 桌面端尚未注册 `preview/openHTML`,`browser/openExternal` 只接受 HTTP(S) 地址 | [编辑器](domains/editor.md) | +| API-17 | 消息、选择、输入 | 部分完成 | `window/showInformation`、`showWarning`、`showError`,带按钮和取消 | 通用的快速选择、输入框、经授权的文件句柄 | [界面](domains/ui.md) | +| API-19 | 设置 | 部分完成 | [声明式设置 `settings`](../contract/v1/README.md#plugin-settings) | 运行期读取、查看来源、修改设置和设置变化事件 | [设置](domains/settings.md) | +| API-20 | 存储 | 部分完成 | 插件级 `${stateDir}`、后端和工作区的可写目录(`stateDirectory`)、网页状态;语言服务器按工作区的状态目录,按声明的 [`state.retention`](../contract/v1/README.md#plugin-owned-sessions) 由宿主在后台回收(Core 决定) | 统一的键值存储、配额;用户可见的状态重置入口 | [插件后端](domains/backend.md) | +| API-21 | 凭据 | 既有能力 | [`credentials/get`、`set`、`delete`](../contract/v1/README.md#host-services),按插件隔离 | — | [插件后端](domains/backend.md) | +| API-22 | 输出、日志、进度 | 部分完成 | [插件函数的进度和取消](../contract/v1/README.md#progress);面向开发者的[结构化诊断日志 `litheLogging`](../contract/v1/README.md#structured-plugin-diagnostics);[`languageServer/setStatus`](../contract/v1/README.md#language-server-host-methods) 在宿主状态界面显示插件自己的语言服务器准备状态(只有消息和百分比,没有 actions);[`outputLinkMatchers`](../contract/v1/README.md#output-link-matchers):插件声明的匹配器让 Run 与测试控制台的输出出现链接 | 独立的输出面板、面向用户的日志和进度 API | [插件后端](domains/backend.md) | +| API-23 | 工具链与受管进程 | 部分完成 | [进程启动](../contract/v1/README.md#process-start)、运行时解析、[共享可执行文件候选](../contract/v1/README.md#executable-resolution)、工具定义、工具链提供者(`toolchainProviders`);[按工具定义 ID 引用运行时](../contract/v1/README.md#tool-references)(清单 `requiresTools`);后端发起的[`run/start`、`run/stop` 和 `run/didExit` 通知](../contract/v1/README.md#run-and-debug-sessions),由宿主拥有 Run 会话(权限 `run.start`;工具解析不到可执行文件时返回 `plugin.host.toolUnresolved`) | 后端可调用的通用工具链和进程服务;保存固定(pin)、`toolchainConsumers`;Lithe 桌面端未声明 `toolchainProviders` | [工具链](domains/toolchains.md) | +| API-24 | 终端 | 部分完成 | [插件自有原生终端](../contract/v1/README.md#plugin-owned-native-terminals) `terminal/create`、`show`、`close`、`reopen`、`dispose`、`onExit`(本机工作区) | 向终端发送文本、读取终端输出 | [插件后端](domains/backend.md) | +| API-25 | 语言、语法、LSP | 部分完成 | [语言](../contract/v1/README.md#languages)与[语言服务器](../contract/v1/README.md#language-servers);[`languages` 的包内 Monarch 语法和编辑器配置](../contract/v1/README.md#grammars-and-editor-configuration);[编辑器语言功能 Provider `editor/registerProviders`](../contract/v1/README.md#editor-language-feature-providers),把 `definition`、`hover`、`completion`、`codeLens`、`foldingRange`、`semanticTokens`、`documentLink` 这 7 种类型绑定到普通插件函数,语言服务器优先、插件结果只补充;插件自有的语言服务器会话:[`languageServers` 的会话声明](../contract/v1/README.md#plugin-owned-sessions)给出后端可用的请求、命令、转发通知,[`languageServer/request`、`notify`、`setStatus`](../contract/v1/README.md#language-server-host-methods) 与 `languageServer/didChangeState`、`languageServer/didReceiveNotification` 两个通知,会话策略由 Core 执行;[`launch.kind: "backend"`](../contract/v1/README.md#backend-resolved-launch) 由插件后端决定启动方式 | 符号、格式化、重命名、Code Action、签名帮助、内联提示、运行期传入 JSON 动态注册高亮;远程工作区中由后端决定启动方式的服务器 | [语言](domains/languages.md)、[编辑器](domains/editor.md) | +| API-27 | 编辑器动作、主题、装饰 | 部分完成 | [独立编辑器动作 `editor/registerActions`](../contract/v1/README.md#independent-editor-actions),绑定普通函数和文件范围,不依赖 LSP、构建或运行;支持行首多图标、至多 4 个 `alternatives`、点击结果的 `reveal` | 主题、代码片段、其他装饰 | [编辑器](domains/editor.md) | +| API-29 | 构建、任务、测试、运行准备 | 部分完成 | [`buildSystems`](../contract/v1/README.md#build-systems)、[`buildProviders`](../contract/v1/README.md#build-providers)、[`buildModelInputs`](../contract/v1/README.md#optional-read-only-model-inputs) | 开发指南还不完整;Lithe 桌面端未声明 `buildModelInputs` | [声明式构建](domains/build-systems.md)、[构建提供者](domains/build-providers.md) | +| API-30 | 调试适配器 | 部分完成 | [`debugAdapters`](../contract/v1/README.md#debug-adapters) 与宿主的 DAP 会话;[`features`、`defaults`](../contract/v1/README.md#adapter-features-and-defaults)(热代码替换、步进过滤器);后端发起的 [`debug/start`](../contract/v1/README.md#run-and-debug-sessions)(权限 `debug.start`) | 后端只能在本插件自己声明的适配器上启动会话,不能使用其他插件的适配器 | [调试适配器](domains/debug-adapters.md) | +| API-31 | VCS 提供者 | 既有能力 | [`vcsProviders`](../contract/v1/README.md#vcs-providers) | 不含内置 Git 的公共读取(见 API-42);Lithe 桌面端未声明 `vcsProviders` | [VCS](domains/vcs.md) | +| API-33 | 项目生成器 | 既有能力 | [`projectGenerators`](../contract/v1/README.md#project-generators) | — | [项目生成器](domains/project-generators.md) | +| API-35 | 插件间调用与依赖 | 部分完成 | 第一阶段:[`pluginFunctions`](../contract/v1/README.md#cross-plugin-calls)(清单 `exports`、`calls` 声明,不授予清单权限)与 [`functions/providers`、`functions/invoke`](../contract/v1/README.md#discovery-and-invocation):经 Core 中转,调用另一个已启用插件导出的函数;信任不传递,不是安装依赖(本机工作区) | 依赖与导出的安装闭包、版本求解、远程 Provider;在只于启动时注册后端插件的宿主上,新安装或更新的后端插件要重启 Lithe 后才会出现在 `providers` 里(见 [Hot activation](../contract/v1/README.md#hot-activation)) | [插件后端](domains/backend.md) | +| API-37 | 环境信息与语言包 | 部分完成 | `getCapabilities` 返回协议版本、可用方法、限制和后端运行位置(`backendAuthority`) | 语言包、环境和主题变化事件 | [插件后端](domains/backend.md) | +| API-40 | 工作区 authority | 部分完成 | 工作区上下文带 `local` 或 `remote`;文件和编辑器方法对远程上下文明确返回 `plugin.host.authorityUnsupported` | 远程执行 | [插件后端](domains/backend.md) | +| API-41 | 激活事件与启动预算 | 部分完成 | [`onWorkspaceOpen`](../contract/v1/README.md#permissions-and-activation)、`onCommand`、后台两路并发启动、`initialize` 超时、有界通知队列 | 按工作区内容匹配激活、全局冷启动预算 | [插件后端](domains/backend.md) | + +### 计划中的 API + +| ID | 名称 | 状态 | 计划提供 | +| --- | --- | --- | --- | +| API-11 | 保存与文件变更 | 未做 | 保存、另存为、关闭,以及工作区文件的创建、改名、删除 | +| API-18 | 剪贴板、Finder | 未做 | 剪贴板读写、在 Finder 中显示 | +| API-26 | 诊断集合 | 未做 | 后端推送的诊断集合,带文档版本;现有的 LSP 诊断不属于这个 API | +| API-28 | 自定义编辑器 | 未做 | 插件自有文档的编辑器,含保存、撤销、恢复 | +| API-32 | 服务 | 未做 | 通用服务提供者(仍是提案) | +| API-34 | IDE/AI/MCP | 未做 | 插件可用的 IDE、AI、MCP 能力与授权 | +| API-36 | WASM | 未做 | WASM/WASI 运行时 | +| API-38 | 受管网络 | 未做 | 宿主代为执行的网络请求和下载 | +| API-39 | 身份验证 | 未做 | 身份验证会话和提供者 | +| API-42 | 内置 Git 只读快照 | 未做 | 内置 Git 状态的只读快照和变化事件 | +| API-43 | 保存前事件 | 未做 | 保存前事件,以及有时限的保存前编辑 | +| API-44 | Agent 侧贡献 | 未做 | 插件贡献 Skills、Hooks、MCP,并单独审查授权 | + +清单、市场、包信任、快捷键导入是安装和打包层面的契约,不在 API-01–44 编号内,见各自的功能页:[清单](domains/manifest.md)、[市场](domains/marketplace.md)、[包信任](domains/package-trust.md)、[快捷键导入](domains/keymap-import.md)。 + +### 已公开的宿主方法 + +后端向宿主发送的请求,前缀都是 `lithe/host/`,下表省略前缀。一个 API 编号下可以有多个方法,每个方法单独一行,按 API 编号排列。 + +- **“API”列是规划编号**,来自 [API 实施计划](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md),和“协议”列的 `litheFunctions` 版本、清单里的能力版本分开记录。同一个 API 的方法可以分几个 PR 陆续加入;向后兼容地新增方法不升协议版本(见契约 [Extending the host services](../contract/v1/README.md#extending-the-host-services))。每个方法哪天、由哪个 PR 首次加入,见[插件协议更新记录](protocol-changelog.md#宿主方法按-api-编号)。 +- **“协议”列**是方法所属的 `litheFunctions` 版本,现在都是 0。除 `getCapabilities`、`subscriptions/dispose`、`workspace/listContexts`、`credentials/*`、`run/stop`、`functions/providers` 外,其余方法都要带 `context: {contextID, generation}`。`functions/*` 只对声明了至少一个 `calls` 的后端开放;三个官方 SDK 都已封装 `functions/providers` 和 `functions/invoke`。 +- **“权限”列**:每个权限都要求清单声明 `backend`(`requires.backend` 为 0),否则安装时拒绝。`functions/*` 没有清单权限,由清单 `pluginFunctions` 段的 `calls` 是否声明了该函数决定。另有两条前置条件:`debug.start` 还要求已声明的 `debugAdapters` 条目;`languageServer.request` 只在某个语言服务器列出 `requestMethods` 或 `forwardNotifications` 时才能声明。 + +| API | 方法 | 用途 | 权限 | 协议 | +| --- | --- | --- | --- | --- | +| API-01、API-37 | `getCapabilities` | 查询协商的协议版本、后端运行位置(`backendAuthority`)、当前可用的服务和限制;由 Core 直接回答 | 每个插件会话都有 | 0 | +| API-01 | `subscriptions/dispose` | 取消一个订阅,即 `workspace/watch`、`editor/registerActions`、`terminal/onExit` 返回的 `subscriptionID`;未知或已取消的 ID 也返回 `null` | 每个插件会话都有 | 0 | +| API-05 | `workspace/listContexts` | 列出当前的工作区上下文,带变化序号 `revision` | `workspace.metadata` | 0 | +| API-05 | `workspace/getContext` | 读取一个工作区上下文 | `workspace.metadata` | 0 | +| API-05 | `workspace/getFolders` | 列出上下文的根目录,每个根目录带 `rootID` | `workspace.metadata` | 0 | +| API-06 | `workspace/readDirectory` | 分页列出目录项 | `workspace.files.read` | 0 | +| API-06 | `workspace/stat` | 读取文件或目录的类型、大小和修改时间 | `workspace.files.read` | 0 | +| API-06 | `workspace/readFile` | 读取磁盘上的文件,返回文本或 base64,超过上限时截断 | `workspace.files.read` | 0 | +| API-07 | `workspace/watch` | 监听文件或目录的变化,事件经 `subscriptions/notify` 送达 | `workspace.files.watch` | 0 | +| API-08 | `workspace/findFiles` | 在一个根目录下按名称查找文件,分页 | `workspace.search` | 0 | +| API-08 | `workspace/findText` | 在磁盘内容中搜索文本,按行返回匹配,分页 | `workspace.search` | 0 | +| API-09 | `documents/list` | 列出已打开文档的摘要 | `documents.read` | 0 | +| API-09 | `documents/getSnapshot` | 读取文档当前缓冲区的全文,包含未保存的修改 | `documents.read` | 0 | +| API-09 | `editor/getActive` | 读取前台文本标签的文档摘要,没有时为 `null` | `documents.read` | 0 | +| API-09 | `editor/getSelections` | 读取文档的全部选区和主选区 | `documents.read` | 0 | +| API-10 | `editor/applyEdits` | 按范围批量编辑,先校验 `expectedVersion` | `editor.edit` | 0 | +| API-10 | `editor/replaceSelections` | 替换选区的文本:一个替换文本用于全部选区,或每个选区一个 | `editor.edit` | 0 | +| API-10 | `editor/insertText` | 在指定位置插入文本 | `editor.edit` | 0 | +| API-12 | `documents/registerContentProvider` | 把一个 URI scheme 绑定到后端函数,打开该 scheme 的文档时宿主调用函数取只读内容(≤1 MiB),返回 `subscriptionID`;用 `subscriptions/dispose` 撤销 | `documents.contentProvider` | 0 | +| API-15 | `editor/open` | 在编辑器中打开文件,没有缓冲区时从磁盘读取;可以前台或后台打开,并定位光标 | `editor.open` | 0 | +| API-15 | `editor/reveal` | 参数和 `editor/open` 相同,但只处理已打开的文件;文件没打开时返回 `plugin.host.notFound`,不会从磁盘打开 | `editor.open` | 0 | +| API-16 | `preview/openHTML` | 打开 HTML 文件的预览(`editorAndPreview` 或 `previewOnly`),预览使用实时缓冲区 | `preview.open` | 0 | +| API-16 | `browser/openExternal` | 在外部浏览器打开 HTTP(S) 地址或已保存的 HTML 文件 | `browser.open` | 0 | +| API-17 | `window/showInformation` | 显示信息消息,可以带按钮,返回用户点的按钮序号 | `window.messages` | 0 | +| API-17 | `window/showWarning` | 显示警告消息,用法同 `showInformation` | `window.messages` | 0 | +| API-17 | `window/showError` | 显示错误消息,用法同 `showInformation` | `window.messages` | 0 | +| API-21 | `credentials/get` | 读取本插件保存的密钥,没有时为 `null` | `credentials.store` | 0 | +| API-21 | `credentials/set` | 保存密钥,最多 16 KiB | `credentials.store` | 0 | +| API-21 | `credentials/delete` | 删除密钥,没有保存过也返回 `null` | `credentials.store` | 0 | +| API-22、API-25 | `languageServer/setStatus` | 在宿主状态界面显示插件自己的语言服务器准备状态(`preparing`、`ready`、`degraded`、`failed`,可带消息和百分比) | `languageServer.request` | 0 | +| API-23 | `run/start` | 启动宿主拥有的 Run 会话(Run 窗口条目、控制台、停止按钮);可执行文件只能是工具定义 ID 或插件包内文件,可带临时文件、调试和再次运行,返回 `runID` | `run.start` | 0 | +| API-23 | `run/stop` | 按 `runID` 或 `sessionID` 停止本插件启动的运行或调试会话;不带 `context` | `run.start` | 0 | +| API-24 | `terminal/create` | 经执行确认创建插件自有原生终端,返回 `terminalID` 和 `generation` | `terminal.create` | 0 | +| API-24 | `terminal/show` | 显示并选中本插件的终端 | `terminal.create` | 0 | +| API-24 | `terminal/close` | 结束进程树并关闭标签,按字节预算保留显示历史和启动配置 | `terminal.create` | 0 | +| API-24 | `terminal/reopen` | 经重新确认,用原启动配置创建新进程,`generation` 加 1,并恢复有界的显示历史 | `terminal.create` | 0 | +| API-24 | `terminal/dispose` | 关闭终端,彻底释放句柄、启动配置和历史 | `terminal.create` | 0 | +| API-24 | `terminal/onExit` | 订阅终端每个 `generation` 的退出结果 | `terminal.create` | 0 | +| API-25 | `editor/registerProviders` | 注册编辑器语言功能 Provider:把 `definition`、`hover`、`completion`、`codeLens`、`foldingRange`、`semanticTokens`、`documentLink` 绑定到后端的普通函数和文件范围(`selector`),返回 `subscriptionID`;语言服务器优先,插件结果只补充 | `editor.providers`(同时需要 `documents.read`) | 0 | +| API-25 | `languageServer/request` | 向本插件自己的语言服务器发 JSON-RPC 请求,受会话策略检查,返回 `{value}` | `languageServer.request` | 0 | +| API-25 | `languageServer/notify` | 向本插件自己的语言服务器发通知,返回 `{delivered}` | `languageServer.request` | 0 | +| API-27 | `editor/registerActions` | 注册编辑器动作:把后端的普通函数绑定到文件范围(`selector`),返回 `subscriptionID` | `editor.actions`(同时需要 `documents.read`) | 0 | +| API-30 | `debug/start` | 在本插件自己声明的调试适配器上启动 launch 或 attach 调试会话,返回 `sessionID` | `debug.start` | 0 | +| API-35 | `functions/providers` | 列出导出某函数、已启用、且不是调用方自己的插件及其导出版本 | 无清单权限,需在 `pluginFunctions.calls` 声明该函数 | 0 | +| API-35 | `functions/invoke` | 经 Core 调用另一个插件导出的函数,可带 `parentOperationID` 标明所属调用链,返回 `{value}` | 无清单权限,需在 `pluginFunctions.calls` 声明该函数 | 0 | + +快捷键导入提供者另有 `lithe/host/keymapImport/listInputs`、`lithe/host/keymapImport/readChunk` 两个方法,属于 `litheKeymapImport` 协议,不在 API-01–44 内,见[快捷键导入](domains/keymap-import.md)。 + +宿主发给后端的通知(后端不回复),前缀同样是 `lithe/host/`: + +| API | 通知 | 用途 | 授予条件 | +| --- | --- | --- | --- | +| API-02 | `commands/didRun` | 用户运行了本插件的命令;只是事件,不表示命令的业务操作已经成功 | 每个插件会话都有 | +| API-04、API-05 | `workspace/didChangeContexts` | 工作区上下文有变化,带新的 `revision`;后端再调用 `listContexts` 读取 | `workspace.metadata` | +| API-07、API-24 | `subscriptions/notify` | 送达一个订阅的事件,例如文件变化(`workspace/watch`)、终端退出(`terminal/onExit`) | 每个插件会话都有 | +| API-23 | `run/didExit` | 后端用 `run/start` 启动的运行结束,带 `exitCode` 或 `signal`(宿主丢失进程时两者都没有);每次运行只发一次 | `run.start` | +| API-25 | `languageServer/didChangeState` | 本插件某个语言服务器的状态变化(`starting`、`running`、`stopped`、`crashed`),带 `generation` | `languageServer.request` | +| API-25 | `languageServer/didReceiveNotification` | 转发服务器发出、`forwardNotifications` 列出的通知;超过 `limits.notificationBytes` 时只送一次 `truncated: true`,不带 `params` | `languageServer.request` | + +### 限制 + +| 项目 | 上限 | +| --- | --- | +| 单次文件读取、文档快照 | 128 KiB 源文本或字节 | +| 发给编辑器动作收集函数的文档 | 宿主的 `limits.editorActionSourceBytes`;宿主未报告时 128 KiB | +| 一批编辑或选区 | 1,000 项 | +| 目录分页 | 默认 200,最多 1,000 | +| 搜索分页 | 默认 100,最多 200(搜索总结果 200) | +| 机器请求的宿主时限 | 30 秒(等待用户选择的消息不计) | +| 窗口消息 | 每个插件每 10 秒 5 条,每个插件最多 3 条待回答,每个窗口最多 10 条;正文 2,000 字符,最多 3 个按钮,每个 40 字符 | +| 未回答的宿主请求 | 每个会话 16 个 | +| 通知队列 | 每个会话 128 条 | +| 插件自有终端 | 每个插件最多保留 8 个句柄 | +| 编辑器 Provider 注册 | 每个选择器 1–32 项,每个插件跨上下文最多 32 个注册 | +| 编辑器 Provider 单次调用时限 | hover、completion 400 ms,definition 1500 ms,其余类型 2000 ms;迟到的答复直接丢弃,注册不能自定义时限 | +| `languageServer/request` | `timeoutMilliseconds` 1–300000 ms,默认 60000;每个会话最多 128 个等待 gate 打开的请求,第 129 个返回 `plugin.host.busy` | +| `languageServer/setStatus` | `message` 按字符边界截到 1024 字节 | +| `run/start` | `label` 1–256 字符;`temporaryFiles` 最多 4 个,每个文本 ≤4 MiB;`rerun.data` ≤64 KiB | +| `debug/start` | `configuration` ≤256 KiB | +| 内容提供者 | 提供的内容 ≤1 MiB,宿主等待 30 秒 | +| `functions/invoke` | `timeoutMilliseconds` 默认 60000 ms,上限 600000 ms;`parentOperationID` 1–128 字节 | + +失败时的错误码和处理方式见契约的 [Outcomes and error codes](../contract/v1/README.md#outcomes-and-error-codes) 与[宿主失败码表](../contract/v1/README.md#workspace-contexts-and-capability-discovery)。取消或超时**不保证回滚**:编辑可能已经生效,不要自动重放。 + +--- + +## English + +Lithe numbers its public plugin APIs API-01 to API-44 in the [API implementation plan](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md). This page lists, for each ID, **what is usable now** and **what is still missing**, and links the usable parts to contract sections and domain pages. [`contract/v1/README.md`](../contract/v1/README.md) is normative for params, results, and error codes; versions are in [Capability and protocol versions](capabilities-and-versions.md). It follows the contract and Core source on this repository's `main`. + +Three things to know before reading the tables: + +- **This page describes the contract, not one host.** Rust Core checks every `lithe/host/*` method and the host executes it. The current host is the Lithe desktop client: which capabilities it declares and which parts it does not present yet are in the [version page](capabilities-and-versions.md#capability-versions-requires). The Lithe desktop client does not register `workspace/readDirectory`, `stat`, `readFile`, `watch`, `findFiles`, `findText`, or `preview/openHTML` today, and the host refuses a plugin that asks for the matching permission. +- **At runtime, `getCapabilities` decides.** A method listed here is not necessarily offered by the current host; check `services` from `lithe/host/getCapabilities` before calling it. +- **Planned entries have no usable method names.** The second table only describes the capability to come and lists no candidate method names; until the contract defines them, do not call guessed names. + +Status meanings: + +| Status | Meaning | +| --- | --- | +| Public | The method is on Core's allowlist and defined by the contract; a plugin with the permission can call it, and whether the host registers it is covered above | +| Existing | A contract capability that predates the numbering and stays available | +| Partial | Part of it is usable; the table names the gaps | +| In progress | Being implemented, not usable yet | +| Not started | Not open yet; this does not mean the IDE lacks the feature internally | + +### APIs usable now + +| ID | Name | Status | Usable now | Not yet | Domain page | +| --- | --- | --- | --- | --- | --- | +| API-01 | Capability discovery, activation, subscriptions | Public | [`getCapabilities`](../contract/v1/README.md#workspace-contexts-and-capability-discovery), `onWorkspaceOpen` activation of `backend`, `subscriptions/dispose` | A newly installed backend still needs a restart before it can start | [Plugin backend](domains/backend.md) | +| API-02 | Commands | Partial | [`ui` commands without a tool window](../contract/v1/README.md#backend-only-commands) and the `commands/didRun` notification | Registering commands at runtime, result-bearing execution | [UI](domains/ui.md) | +| API-03 | Menus, keybindings, context keys, status bar | Partial | Existing command placements (`ui.commands`) | General menus, keybinding contributions, context keys, status bar | [UI](domains/ui.md) | +| API-04 | Workspace and document events | Partial | `workspace/didChangeContexts`, file events from `workspace/watch` | Document, selection, and active editor change events | [Plugin backend](domains/backend.md) | +| API-05 | Workspace contexts | Public | [`workspace/listContexts`, `getContext`, `getFolders`](../contract/v1/README.md#workspace-contexts-and-capability-discovery) with `contextID`/`generation` and `rootID` | — | [Plugin backend](domains/backend.md) | +| API-06 | File reads | Public | [`workspace/readDirectory`, `stat`, `readFile`](../contract/v1/README.md#public-document-file-and-window-methods); at most 128 KiB per read | The Lithe desktop client does not register these three methods | [Plugin backend](domains/backend.md) | +| API-07 | File watching | Public | `workspace/watch` with `subscriptions/notify` and `dispose`; reclaimed when the context or session ends, with a rescan request on overflow | The Lithe desktop client does not register `workspace/watch` | [Plugin backend](domains/backend.md) | +| API-08 | Search | Partial | `workspace/findFiles` and `findText`, paged and cancellable | Searches disk content only, not unsaved changes; the exact match range is `null` for now; the Lithe desktop client does not register these two methods | [Plugin backend](domains/backend.md) | +| API-09 | Documents and selections | Public | `documents/list`, `documents/getSnapshot`, `editor/getActive`, `editor/getSelections`, with dirty state, version, encoding, and line ending | — | [Editor](domains/editor.md) | +| API-10 | Versioned edits | Public | `editor/applyEdits`, `replaceSelections`, `insertText`; version checks, refusal during IME composition and in read-only documents, one undo step per batch | — | [Editor](domains/editor.md) | +| API-12 | Multi-file edits, virtual documents, diff | Partial | [Content providers `documents/registerContentProvider`](../contract/v1/README.md#content-providers): bind a URI scheme to a backend function, and the host fetches read-only content from it when a document of that scheme opens (≤1 MiB, one provider per scheme per workspace window) | Cross-file edits, a diff view | [Editor](domains/editor.md) | +| API-13 | Native view data sources | Partial | [`ui` native Tree, List, Form](../contract/v1/README.md#host-rendered-native-content) | An independent backend data source, lazy loading | [UI](domains/ui.md) | +| API-14 | Webview messaging | Partial | The [package web page](../contract/v1/README.md#web-content) bridge, state, and lifecycle | General two-way page messaging | [UI](domains/ui.md) | +| API-15 | Open and reveal | Partial | `editor/open` and `editor/reveal`, returning `documentID` and version | A range only places the caret at its start; no preview-tab replacement | [Editor](domains/editor.md) | +| API-16 | HTML preview and external browser | Public | `preview/openHTML` (`editorAndPreview` or `previewOnly`), `browser/openExternal` (HTTP(S) URLs or saved HTML) | The Lithe desktop client does not register `preview/openHTML`, and its `browser/openExternal` accepts HTTP(S) URLs only | [Editor](domains/editor.md) | +| API-17 | Messages, quick pick, input | Partial | `window/showInformation`, `showWarning`, `showError` with buttons and dismissal | General quick pick, input box, authorized file handles | [UI](domains/ui.md) | +| API-19 | Settings | Partial | [Declarative settings `settings`](../contract/v1/README.md#plugin-settings) | Reading, inspecting the source of, and updating settings at runtime, and settings change events | [Settings](domains/settings.md) | +| API-20 | Storage | Partial | The plugin-wide `${stateDir}`, writable backend and workspace directories (`stateDirectory`), web page state; a language server's per-workspace state directories, which the host prunes in the background by the declared [`state.retention`](../contract/v1/README.md#plugin-owned-sessions) (Core decides) | A unified key-value store, quotas; a user-facing way to reset state | [Plugin backend](domains/backend.md) | +| API-21 | Credentials | Existing | [`credentials/get`, `set`, `delete`](../contract/v1/README.md#host-services), isolated per plugin | — | [Plugin backend](domains/backend.md) | +| API-22 | Output, log, progress | Partial | [Plugin function progress and cancellation](../contract/v1/README.md#progress); developer-facing [structured diagnostics `litheLogging`](../contract/v1/README.md#structured-plugin-diagnostics); [`languageServer/setStatus`](../contract/v1/README.md#language-server-host-methods) shows the plugin's own language server preparation state in the host's status UI (a message and a percentage only, no actions); [`outputLinkMatchers`](../contract/v1/README.md#output-link-matchers): matchers a plugin declares make output in the Run and Test consoles clickable | A dedicated output panel, user-facing log and progress APIs | [Plugin backend](domains/backend.md) | +| API-23 | Toolchains and managed processes | Partial | [Process start](../contract/v1/README.md#process-start), runtime resolution, [shared executable candidates](../contract/v1/README.md#executable-resolution), tool definitions, toolchain providers (`toolchainProviders`); [references to runtimes by tool-definition ID](../contract/v1/README.md#tool-references) (manifest `requiresTools`); backend-started [`run/start`, `run/stop`, and the `run/didExit` notification](../contract/v1/README.md#run-and-debug-sessions), where the host owns the Run session (permission `run.start`; a tool that resolves to no executable returns `plugin.host.toolUnresolved`) | A general toolchain and process service backends can call; saving pins, `toolchainConsumers`; the Lithe desktop client does not declare `toolchainProviders` | [Toolchains](domains/toolchains.md) | +| API-24 | Terminals | Partial | [Plugin-owned native terminals](../contract/v1/README.md#plugin-owned-native-terminals) `terminal/create`, `show`, `close`, `reopen`, `dispose`, `onExit` (local workspaces) | Sending text to a terminal, reading its output | [Plugin backend](domains/backend.md) | +| API-25 | Languages, grammars, LSP | Partial | [Languages](../contract/v1/README.md#languages) and [language servers](../contract/v1/README.md#language-servers); [packaged Monarch grammars and editor configuration in `languages`](../contract/v1/README.md#grammars-and-editor-configuration); [editor language-feature providers `editor/registerProviders`](../contract/v1/README.md#editor-language-feature-providers), binding 7 types (`definition`, `hover`, `completion`, `codeLens`, `foldingRange`, `semanticTokens`, `documentLink`) to ordinary plugin functions, where the language server answers first and plugin results only add; plugin-owned language server sessions: [the session declaration of `languageServers`](../contract/v1/README.md#plugin-owned-sessions) lists the requests, commands, and forwarded notifications a backend may use, with [`languageServer/request`, `notify`, `setStatus`](../contract/v1/README.md#language-server-host-methods) and the `languageServer/didChangeState` and `languageServer/didReceiveNotification` notifications, and Core enforces the session policy; [`launch.kind: "backend"`](../contract/v1/README.md#backend-resolved-launch) lets the plugin backend decide how the server starts | Symbols, formatting, rename, code actions, signature help, inlay hints, dynamic registration of highlighting from JSON passed at runtime; servers whose launch the backend decides in remote workspaces | [Languages](domains/languages.md), [Editor](domains/editor.md) | +| API-27 | Editor actions, themes, decorations | Partial | [Independent editor actions `editor/registerActions`](../contract/v1/README.md#independent-editor-actions), binding ordinary functions to file ranges without LSP, build, or run; several icons at a line start, up to 4 `alternatives`, and a `reveal` in the click result | Themes, snippets, other decorations | [Editor](domains/editor.md) | +| API-29 | Build, tasks, tests, run preparation | Partial | [`buildSystems`](../contract/v1/README.md#build-systems), [`buildProviders`](../contract/v1/README.md#build-providers), [`buildModelInputs`](../contract/v1/README.md#optional-read-only-model-inputs) | The developer guides are incomplete; the Lithe desktop client does not declare `buildModelInputs` | [Build systems](domains/build-systems.md), [Build providers](domains/build-providers.md) | +| API-30 | Debug adapters | Partial | [`debugAdapters`](../contract/v1/README.md#debug-adapters) and the host's DAP sessions; [`features` and `defaults`](../contract/v1/README.md#adapter-features-and-defaults) (hot code replace, step filters); backend-started [`debug/start`](../contract/v1/README.md#run-and-debug-sessions) (permission `debug.start`) | A backend can start sessions only on adapters its own plugin declares, not on another plugin's adapters | [Debug adapters](domains/debug-adapters.md) | +| API-31 | VCS providers | Existing | [`vcsProviders`](../contract/v1/README.md#vcs-providers) | Public reads of built-in Git are not included (see API-42); the Lithe desktop client does not declare `vcsProviders` | [VCS](domains/vcs.md) | +| API-33 | Project generators | Existing | [`projectGenerators`](../contract/v1/README.md#project-generators) | — | [Project generators](domains/project-generators.md) | +| API-35 | Cross-plugin calls and dependencies | Partial | Phase one: [`pluginFunctions`](../contract/v1/README.md#cross-plugin-calls) (`exports` and `calls` in the manifest, granting no manifest permission) and [`functions/providers` and `functions/invoke`](../contract/v1/README.md#discovery-and-invocation): Core relays a call to a function another enabled plugin exports; trust is not transitive and this is not an install dependency (local workspaces) | The install closure of dependencies and exports, version solving, remote providers; on a host that registers backend plugins only when it launches, a newly installed or updated backend plugin appears in `providers` only after Lithe restarts (see [Hot activation](../contract/v1/README.md#hot-activation)) | [Plugin backend](domains/backend.md) | +| API-37 | Environment info and l10n | Partial | `getCapabilities` returns the protocol version, usable methods, limits, and where the backend runs (`backendAuthority`) | Language packs, environment and theme change events | [Plugin backend](domains/backend.md) | +| API-40 | Workspace authority | Partial | Workspace contexts carry `local` or `remote`; file and editor methods refuse a remote context with `plugin.host.authorityUnsupported` | Remote execution | [Plugin backend](domains/backend.md) | +| API-41 | Activation events and startup budget | Partial | [`onWorkspaceOpen`](../contract/v1/README.md#permissions-and-activation), `onCommand`, two concurrent background starts, `initialize` timeout, bounded notification queues | Activation by workspace content, a global cold-start budget | [Plugin backend](domains/backend.md) | + +### Planned APIs + +| ID | Name | Status | Planned | +| --- | --- | --- | --- | +| API-11 | Save and file operations | Not started | Save, save as, close, and creating, renaming, and deleting workspace files | +| API-18 | Clipboard and Finder | Not started | Reading and writing the clipboard, revealing in Finder | +| API-26 | Diagnostics collections | Not started | Diagnostics collections pushed by a backend, with document versions; existing LSP diagnostics are not this API | +| API-28 | Custom editors | Not started | Editors for plugin-owned documents, with save, undo, and restore | +| API-32 | Services | Not started | General service providers (still a proposal) | +| API-34 | IDE, AI, MCP | Not started | IDE, AI, and MCP capabilities for plugins, with authorization | +| API-36 | WASM | Not started | A WASM/WASI runtime | +| API-38 | Managed network | Not started | Network requests and downloads run by the host | +| API-39 | Authentication | Not started | Authentication sessions and providers | +| API-42 | Built-in Git read-only snapshot | Not started | A read-only snapshot of built-in Git state and change events | +| API-43 | Will-save events | Not started | Will-save events and time-bounded edits before save | +| API-44 | Agent contributions | Not started | Skills, hooks, and MCP contributed by plugins, with separate authorization review | + +The manifest, marketplace, package trust, and shortcut import are installation and packaging contracts outside API-01–44; see their domain pages: [Manifest](domains/manifest.md), [Marketplace](domains/marketplace.md), [Package trust](domains/package-trust.md), [Shortcut import](domains/keymap-import.md). + +### Public host methods + +Requests a backend sends to the host, all prefixed `lithe/host/`; the tables omit the prefix. One API ID can cover several methods; each method has its own row, ordered by API ID. + +- **The “API” column holds planning IDs** from the [API implementation plan](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md). They are recorded separately from the `litheFunctions` version in the “Protocol” column and from manifest capability versions. The methods of one API can arrive over several PRs, and adding a backward-compatible method does not bump the protocol version (see [Extending the host services](../contract/v1/README.md#extending-the-host-services) in the contract). The date and PR that first added each method are in the [protocol changelog](protocol-changelog.md#宿主方法按-api-编号) (Chinese only). +- **The “Protocol” column** is the `litheFunctions` version the method belongs to, and is 0 for every method today. Apart from `getCapabilities`, `subscriptions/dispose`, `workspace/listContexts`, `credentials/*`, `run/stop`, and `functions/providers`, every method takes `context: {contextID, generation}`. `functions/*` is open only to a backend whose plugin declares at least one `calls` entry; the three official SDKs all wrap `functions/providers` and `functions/invoke`. +- **The “Permission” column**: every permission requires the manifest to declare `backend` (`requires.backend` is 0), or installation refuses it. `functions/*` has no manifest permission; whether the manifest's `pluginFunctions` section lists the function under `calls` decides. Two more preconditions: `debug.start` also requires a declared `debugAdapters` entry, and `languageServer.request` can be declared only when a language server lists `requestMethods` or `forwardNotifications`. + +| API | Method | Purpose | Permission | Protocol | +| --- | --- | --- | --- | --- | +| API-01, API-37 | `getCapabilities` | Reports the negotiated protocol version, where the backend runs (`backendAuthority`), and the services and limits available now; Core answers it itself | Every plugin-owned session | 0 | +| API-01 | `subscriptions/dispose` | Cancels a subscription, that is, a `subscriptionID` returned by `workspace/watch`, `editor/registerActions`, or `terminal/onExit`; an unknown or already disposed ID also returns `null` | Every plugin-owned session | 0 | +| API-05 | `workspace/listContexts` | Lists the current workspace contexts with the change counter `revision` | `workspace.metadata` | 0 | +| API-05 | `workspace/getContext` | Reads one workspace context | `workspace.metadata` | 0 | +| API-05 | `workspace/getFolders` | Lists the context's root folders, each with a `rootID` | `workspace.metadata` | 0 | +| API-06 | `workspace/readDirectory` | Lists directory entries page by page | `workspace.files.read` | 0 | +| API-06 | `workspace/stat` | Reads the kind, size, and modification time of a file or directory | `workspace.files.read` | 0 | +| API-06 | `workspace/readFile` | Reads a file from disk as text or base64, truncated past the limit | `workspace.files.read` | 0 | +| API-07 | `workspace/watch` | Watches a file or directory for changes; events arrive through `subscriptions/notify` | `workspace.files.watch` | 0 | +| API-08 | `workspace/findFiles` | Finds files by name under one root, page by page | `workspace.search` | 0 | +| API-08 | `workspace/findText` | Searches disk content for text and returns matching lines, page by page | `workspace.search` | 0 | +| API-09 | `documents/list` | Lists summaries of the open documents | `documents.read` | 0 | +| API-09 | `documents/getSnapshot` | Reads the full text of a document's current buffer, unsaved changes included | `documents.read` | 0 | +| API-09 | `editor/getActive` | Reads the document summary of the foreground text tab, or `null` | `documents.read` | 0 | +| API-09 | `editor/getSelections` | Reads all of a document's selections and the primary one | `documents.read` | 0 | +| API-10 | `editor/applyEdits` | Applies a batch of range edits after checking `expectedVersion` | `editor.edit` | 0 | +| API-10 | `editor/replaceSelections` | Replaces the text of selections: one replacement for all of them, or one per selection | `editor.edit` | 0 | +| API-10 | `editor/insertText` | Inserts text at a position | `editor.edit` | 0 | +| API-12 | `documents/registerContentProvider` | Binds a URI scheme to a backend function; when a document of that scheme opens, the host calls the function to get read-only content (≤1 MiB), and the call returns a `subscriptionID`, which `subscriptions/dispose` withdraws | `documents.contentProvider` | 0 | +| API-15 | `editor/open` | Opens a file in the editor, reading it from disk when no buffer exists; opens it in the foreground or background and can place the caret | `editor.open` | 0 | +| API-15 | `editor/reveal` | Takes the same params as `editor/open` but only handles files already open; a file that is not open returns `plugin.host.notFound` and is not opened from disk | `editor.open` | 0 | +| API-16 | `preview/openHTML` | Opens a preview of an HTML file (`editorAndPreview` or `previewOnly`) that uses the live buffer | `preview.open` | 0 | +| API-16 | `browser/openExternal` | Opens an HTTP(S) URL or a saved HTML file in the external browser | `browser.open` | 0 | +| API-17 | `window/showInformation` | Shows an information message, optionally with buttons, and returns the index of the button the user chose | `window.messages` | 0 | +| API-17 | `window/showWarning` | Shows a warning message; used like `showInformation` | `window.messages` | 0 | +| API-17 | `window/showError` | Shows an error message; used like `showInformation` | `window.messages` | 0 | +| API-21 | `credentials/get` | Reads a secret this plugin stored, or `null` | `credentials.store` | 0 | +| API-21 | `credentials/set` | Stores a secret of at most 16 KiB | `credentials.store` | 0 | +| API-21 | `credentials/delete` | Deletes a secret; also returns `null` when nothing was stored | `credentials.store` | 0 | +| API-22, API-25 | `languageServer/setStatus` | Shows the plugin's own language server preparation state (`preparing`, `ready`, `degraded`, `failed`, optionally with a message and a percentage) in the host's status UI | `languageServer.request` | 0 | +| API-23 | `run/start` | Starts a host-owned Run session (Run window entry, console, Stop button); the executable can only be a tool-definition ID or a file inside the plugin package, and the call can carry temporary files, debugging, and rerun, and returns a `runID` | `run.start` | 0 | +| API-23 | `run/stop` | Stops a run or debug session this plugin started, by `runID` or `sessionID`; takes no `context` | `run.start` | 0 | +| API-24 | `terminal/create` | Creates a plugin-owned native terminal after execution consent and returns `terminalID` and `generation` | `terminal.create` | 0 | +| API-24 | `terminal/show` | Shows and selects one of this plugin's terminals | `terminal.create` | 0 | +| API-24 | `terminal/close` | Ends the process tree and closes the tab, keeping display history within a byte budget and the launch settings | `terminal.create` | 0 | +| API-24 | `terminal/reopen` | After renewed consent, starts a new process with the original launch settings, increments `generation`, and restores bounded display history | `terminal.create` | 0 | +| API-24 | `terminal/dispose` | Closes the terminal and releases its handle, launch settings, and history for good | `terminal.create` | 0 | +| API-24 | `terminal/onExit` | Subscribes to the exit outcome of each `generation` of a terminal | `terminal.create` | 0 | +| API-25 | `editor/registerProviders` | Registers editor language-feature providers: binds `definition`, `hover`, `completion`, `codeLens`, `foldingRange`, `semanticTokens`, or `documentLink` to an ordinary backend function and file ranges (`selector`), and returns a `subscriptionID`; the language server answers first and plugin results only add | `editor.providers` (also needs `documents.read`) | 0 | +| API-25 | `languageServer/request` | Sends a JSON-RPC request to the plugin's own language server, checked against the session policy, and returns `{value}` | `languageServer.request` | 0 | +| API-25 | `languageServer/notify` | Sends a notification to the plugin's own language server and returns `{delivered}` | `languageServer.request` | 0 | +| API-27 | `editor/registerActions` | Registers editor actions that bind an ordinary backend function to file ranges (`selector`) and returns a `subscriptionID` | `editor.actions` (also needs `documents.read`) | 0 | +| API-30 | `debug/start` | Starts a launch or attach debug session on a debug adapter the plugin itself declares, and returns a `sessionID` | `debug.start` | 0 | +| API-35 | `functions/providers` | Lists the enabled plugins, other than the caller, that export a function, with their exported versions | No manifest permission; the function must be declared in `pluginFunctions.calls` | 0 | +| API-35 | `functions/invoke` | Calls a function another plugin exports, relayed by Core; can carry `parentOperationID` to name the call chain it belongs to, and returns `{value}` | No manifest permission; the function must be declared in `pluginFunctions.calls` | 0 | + +Shortcut import providers also have two methods, `lithe/host/keymapImport/listInputs` and `lithe/host/keymapImport/readChunk`. They belong to the `litheKeymapImport` protocol, outside API-01–44; see [Shortcut import](domains/keymap-import.md). + +Notifications the host sends to a backend (the backend answers none), also prefixed `lithe/host/`: + +| API | Notification | Purpose | Granted by | +| --- | --- | --- | --- | +| API-02 | `commands/didRun` | The user ran one of this plugin's commands; it is an event only and does not mean the command's business action succeeded | Every plugin-owned session | +| API-04, API-05 | `workspace/didChangeContexts` | The workspace contexts changed, with the new `revision`; the backend then calls `listContexts` to read them | `workspace.metadata` | +| API-07, API-24 | `subscriptions/notify` | Delivers an event of one subscription, such as a file change (`workspace/watch`) or a terminal exit (`terminal/onExit`) | Every plugin-owned session | +| API-23 | `run/didExit` | A run the backend started with `run/start` ended, with `exitCode` or `signal` (neither when the host lost the process); sent once per run | `run.start` | +| API-25 | `languageServer/didChangeState` | The state of one of this plugin's language servers changed (`starting`, `running`, `stopped`, `crashed`), with `generation` | `languageServer.request` | +| API-25 | `languageServer/didReceiveNotification` | Forwards a notification the server sent and `forwardNotifications` lists; past `limits.notificationBytes` it is delivered once with `truncated: true` and without `params` | `languageServer.request` | + +### Limits + +| Item | Limit | +| --- | --- | +| One file read or document snapshot | 128 KiB of source text or bytes | +| The document sent to an editor action collector | The host's `limits.editorActionSourceBytes`; 128 KiB when the host does not report it | +| One batch of edits or selections | 1,000 entries | +| Directory pages | 200 by default, at most 1,000 | +| Search pages | 100 by default, at most 200 (200 search results in total) | +| Host deadline for machine requests | 30 seconds (messages waiting on the user are exempt) | +| Window messages | 5 per plugin per 10 seconds, at most 3 pending per plugin and 10 per window; 2,000 text characters, at most 3 buttons of 40 characters | +| Unanswered host requests | 16 per session | +| Notification queue | 128 entries per session | +| Plugin-owned terminals | At most 8 retained handles per plugin | +| Editor provider registrations | 1–32 selector entries per registration, at most 32 registrations per plugin across contexts | +| Editor provider call budget | 400 ms for hover and completion, 1500 ms for definition, 2000 ms for the other types; a late answer is dropped, and a registration cannot set its own budget | +| `languageServer/request` | `timeoutMilliseconds` 1–300000 ms, default 60000; at most 128 requests per session waiting for a gate to open, and the 129th returns `plugin.host.busy` | +| `languageServer/setStatus` | `message` is cut to 1024 bytes on a character boundary | +| `run/start` | `label` 1–256 characters; `temporaryFiles` at most 4, each up to 4 MiB of text; `rerun.data` up to 64 KiB | +| `debug/start` | `configuration` up to 256 KiB | +| Content providers | Provided content up to 1 MiB; the host waits 30 seconds | +| `functions/invoke` | `timeoutMilliseconds` defaults to 60000 ms, capped at 600000 ms; `parentOperationID` is 1–128 bytes | + +For failure codes and how to handle them, see the contract's [Outcomes and error codes](../contract/v1/README.md#outcomes-and-error-codes) and the [host failure table](../contract/v1/README.md#workspace-contexts-and-capability-discovery). Cancellation or timeout **does not promise rollback**: an edit may already have taken effect, so do not replay it automatically. diff --git a/shared/plugins/reference/capabilities-and-versions.md b/shared/plugins/reference/capabilities-and-versions.md new file mode 100644 index 00000000..ee24555f --- /dev/null +++ b/shared/plugins/reference/capabilities-and-versions.md @@ -0,0 +1,207 @@ +# 能力与协议版本对照 / Capability and protocol versions + +[中文](#中文) · [English](#english) + +## 中文 + +本页把插件会碰到的所有版本号放在一起:`pluginAPIVersion`、清单格式、18 个能力、进程协议和文档格式。**它们现在的版本全部是 `0`。** 规范以 [`contract/v1/README.md`](../contract/v1/README.md) 的 [Versioning](../contract/v1/README.md#versioning)、[Capabilities](../contract/v1/README.md#capabilities)、[Host capabilities](../contract/v1/README.md#host-capabilities) 和 [Publishing](../contract/v1/README.md#publishing) 为准;本页与契约不一致时以契约为准,并请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +Core 列以本仓库 `main` 上的 Core 源码为准([`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/plugins/capabilities.rs) 的 `SUPPORTED_CAPABILITIES`,[`declarative.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/plugins/declarative.rs) 的 `SUPPORTED_PLUGIN_API_VERSIONS`);Lithe 桌面端列以桌面端的[声明与说明](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)为准。Core 能校验不等于宿主能运行:桌面端声明了 12 个能力,另外 6 个没有声明,需要它们的插件不会被提供安装。 + +### 四类版本号 + +| 版本号 | 写在哪里 | 谁检查 | 不满足时 | +| --- | --- | --- | --- | +| 插件 API 版本 `pluginAPIVersion` | `plugin.json` | Core | `plugin.api.unsupported`,`field` 为 `pluginAPIVersion` | +| 能力版本 `requires.<能力>` | `plugin.json`(必填) | Core 的支持表,再和宿主的 `hostCapabilities` 取交集 | `plugin.api.unsupported`,`field` 为 `requires.<能力>`;市场不提供这个插件版本 | +| 进程协议版本 `experimental.lithe*` | 不写进清单;进程启动时在 LSP `initialize` 里协商 | 宿主给出版本列表,后端从中选一个 | 该协议不启用,例如不向这个进程发构建请求 | +| 文档格式版本 | 各文档自己的 `schemaVersion` 或 `version` 字段 | Core | 解析失败 | + +能力版本决定**能不能安装**;协议版本决定**进程启动后能说什么**。进程协议没有独立的版本,它的版本就是拥有它的能力的版本,宿主给出的列表恰好是这一个版本。 + +以后的变化遵循契约的规则: + +- 一次改动只给**一个**能力加一个新版本(`1`),清单段和协议一起变;没用到这次改动的插件继续写 `0`,在原来的宿主上继续可装。 +- 已发布的能力版本不改变含义,宿主一直支持它。 +- 插件只声明自己用到的能力,每个能力取能覆盖它用法的**最低**版本。没用到新内容就提高版本,只会让旧宿主装不上。 +- 把插件改到新的能力版本必须发新的插件版本;市场索引保留上一个旧要求的版本,旧宿主仍有版本可装。 +- 支持某个版本的宿主先发布,声明它的插件后发布。 + +### 插件 API 版本与清单格式 + +| 版本 | 内容 | Core | Lithe 桌面端 | 详见 | +| --- | --- | --- | --- | --- | +| `pluginAPIVersion` `0` | 基础字段:`schemaVersion`、`id`、`displayName`、`version`、`vendor`、`permissions`、`activationEvents`、`requires`、`requiresTools`;同时是 `litheLogging` 的版本。其他取值一律被拒绝 | ✅ | ✅ | [清单与打包](domains/manifest.md)、[Versioning](../contract/v1/README.md#versioning) | +| `schemaVersion` `0` | `plugin.json` 的文件格式;其他取值是 `plugin.schema.unsupported` | ✅ | ✅ | [`plugin-manifest.schema.json`](../contract/v1/plugin-manifest.schema.json) | + +### 能力版本(`requires`) + +每一行是一个能力。`requires` 必须**恰好**列出清单用到的能力:漏一个或多列一个都是 `plugin.manifest.invalid`;每个能力的值都是 `0`。启动进程([Process start](../contract/v1/README.md#process-start))和可执行文件解析([Executable resolution](../contract/v1/README.md#executable-resolution))属于基础契约,不是能力;写 `requires.serverProcess` 会像未知能力一样被拒绝。 + +| 版本 | 内容 | Core | Lithe 桌面端 | 详见 | +| --- | --- | --- | --- | --- | +| `languages` `0` | `languages[]`:语言 ID、文件扩展名,以及包内 TextMate [`grammar` 与 `configuration`](../contract/v1/README.md#grammars-and-editor-configuration);宿主从包里读取并在编辑器里应用,不启动进程 | ✅ | ✅ | [语言](domains/languages.md) | +| `packageTrust` `0` | `vendor.bundledSignatureRequirement`:随宿主发布的发布者签名插件副本按来源信任 | ✅ | 未声明 | [包信任](domains/package-trust.md)、[Trust](../contract/v1/README.md#trust) | +| `languageServers` `0` | `languageServers[]`(启动字段按[启动进程](../contract/v1/README.md#process-start)),包括[`workspaceConfiguration`](../contract/v1/README.md#workspace-configuration)和[插件自有服务器的会话字段](../contract/v1/README.md#plugin-owned-sessions) | ✅ | ✅¹ | [语言](domains/languages.md)、[Language servers](../contract/v1/README.md#language-servers) | +| `executionProviders` `0` | `executionProviders[]`:用声明的命令运行和发现测试 | ✅ | 未声明 | [语言](domains/languages.md)、[Execution providers](../contract/v1/README.md#execution-providers) | +| `settings` `0` | `settings[]` 与 `settingActions[]`,含[放置位置、作用域、生效方式、页面按钮](../contract/v1/README.md#placement-scope-effect-and-page-buttons) | ✅ | ✅ | [插件设置](domains/settings.md)、[Plugin settings](../contract/v1/README.md#plugin-settings) | +| `ui` `0` | [`ui`](../contract/v1/README.md#host-owned-workbench-ui):文本、[包内网页](../contract/v1/README.md#web-content)和[原生控件](../contract/v1/README.md#host-rendered-native-content)的工具窗口、命令,以及[不打开工具窗口的后端命令](../contract/v1/README.md#backend-only-commands) | ✅ | ✅¹ | [界面](domains/ui.md) | +| `buildSystems` `0` | 不带 `provider` 的 [`buildSystems[]`](../contract/v1/README.md#build-systems):检测、选项、项目模型、任务、运行入口、测试、诊断、依赖、同步和[声明式项目准备](../contract/v1/README.md#declarative-project-preparation) | ✅ | ✅¹ | [声明式构建系统](domains/build-systems.md) | +| `buildProviders` `0` | `buildSystems[].provider`:[构建提供者](../contract/v1/README.md#build-providers),可以是自有进程或共享语言服务器;说 `litheBuild`,包含任务执行、测试执行与发现、运行准备、原生调试准备、原生配置、内联测试报告、语义测试选择器、类型化运行选项和注册表连接调试 | ✅ | ✅¹ | [构建提供者](domains/build-providers.md) | +| `buildModelInputs` `0` | `buildSystems[].modelSharing` 与 `modelInputs`:[已启用工作区插件之间共享的只读模型输入](../contract/v1/README.md#optional-read-only-model-inputs)(可选) | ✅ | 未声明 | [构建提供者](domains/build-providers.md) | +| `vcsProviders` `0` | [`vcsProviders[]`](../contract/v1/README.md#vcs-providers);说 `litheVcs` | ✅ | 未声明 | [版本控制提供者](domains/vcs.md) | +| `debugAdapters` `0` | [`debugAdapters[]`](../contract/v1/README.md#debug-adapters):独立适配器、语言服务器内的适配器、[远程附加](../contract/v1/README.md#remote-attach)、参数和环境变量、[`features` 与 `defaults`](../contract/v1/README.md#adapter-features-and-defaults) | ✅ | ✅ | [调试适配器](domains/debug-adapters.md) | +| `outputLinkMatchers` `0` | [`outputLinkMatchers[]`](../contract/v1/README.md#output-link-matchers):插件声明的匹配规则让 Run 和测试控制台的输出出现链接;Core 负责匹配,`resolve` 函数在点击时才调用 | ✅ | ✅ | [语言](domains/languages.md) | +| `projectGenerators` `0` | [`projectGenerators[]`](../contract/v1/README.md#project-generators):新建项目窗口里的条目,含 [`tool` 字段](../contract/v1/README.md#tool-fields) | ✅ | ✅ | [项目生成器](domains/project-generators.md) | +| `backend` `0` | [`backend`](../contract/v1/README.md#plugin-backend):插件自己的长驻进程,带或不带页面启动,按权限提供宿主服务,说 `litheFunctions` | ✅ | ✅¹ | [插件后端与插件函数](domains/backend.md) | +| `toolDefinitions` `0` | `toolDefinitions[]`:[工具身份、布局、发现和四层候选](../contract/v1/toolchains/README.md) | ✅ | ✅¹ | [工具链](domains/toolchains.md) | +| `toolchainProviders` `0` | `toolchainProviders[]`:[提供者声明和按权限校验应答](../contract/v1/toolchains/README.md);说 `litheToolchains` | ✅ | 未声明 | [工具链](domains/toolchains.md) | +| `pluginFunctions` `0` | 清单顶层 [`pluginFunctions`](../contract/v1/README.md#cross-plugin-calls):导出的函数(`exports`)和要调用的其他插件函数(`calls`);不授予任何清单权限,调用经 Core 中转 | ✅ | ✅¹ | [插件后端与插件函数](domains/backend.md) | +| `keymapImportProviders` `0` | `keymapImportProviders[]`:[独立的快捷键导入提供者](../contract/v1/README.md#shortcut-import-providers-core-foundation);说 `litheKeymapImport` | ✅ | 未声明 | [快捷键导入](domains/keymap-import.md) | + +¹ 桌面端声明了这个能力,但其中有些部分还没有界面:对应的部分没有按钮、菜单或窗口,插件照常安装,也不会因此失败。尚未呈现的部分以桌面端源码里 `Declared()` 上方的注释为准([`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)): + +- 语言服务器只呈现诊断;补全、悬停、跳转、重命名和格式化没有编辑器界面;启动应答 `{kind: "tool"}` 会被拒绝。 +- 系统命令的工具发现只读 `PATH`;依赖项目工具链或用户覆盖的计划会被拒绝。 +- 构建提供者的运行准备、测试、调试准备、模型输入、原生配置、内联测试报告和运行期调试;构建系统的实例选项和任务准备。 +- 不属于任何工具窗口的 `ui` 命令。 +- 后端宿主服务:工作区文件与搜索(`workspace/readDirectory`、`stat`、`readFile`、`watch`、`findFiles`、`findText`)和 `preview/openHTML` 尚未注册,请求这些权限的插件被拒绝。运行期以 `lithe/host/getCapabilities` 的返回为准,见 [API 总表](api-catalog.md)。 + +### 进程协议版本 + +这些协议在进程启动时协商,不写进清单。宿主在 `initialize` 的 `capabilities.experimental.<协议>` 里给出 `{ "versions": [0] }`,后端回答 `{ "version": 0 }`。回答了宿主没给出的版本,或格式不对,该协议整个不启用。每个协议的版本就是拥有它的能力的版本。 + +| 版本 | 内容 | Core | Lithe 桌面端 | 详见 | +| --- | --- | --- | --- | --- | +| `litheFunctions` `0`(`backend`) | 插件函数:注册、撤回、调用、结果、进度、取消;宿主服务 `lithe/host/*`(凭据、工作区上下文与能力发现、宿主通知、公共文档/文件/窗口方法、插件自有原生终端、独立编辑器动作、编辑器语言功能 Provider、运行与调试会话、语言服务器方法、内容提供者);跨插件调用 `functions/providers`、`functions/invoke`。提供给插件拥有的会话 | ✅ | ✅¹ | [Plugin functions](../contract/v1/README.md#plugin-functions) | +| `litheBuild` `0`(`buildProviders`) | `lithe/build/*`:模型与依赖、任务执行、测试执行与发现、运行准备、原生调试准备、只读模型输入、原生配置、内联测试报告、语义测试选择器、类型化运行选项、注册表连接调试。提供给构建提供者 | ✅ | ✅¹ | [Build providers](../contract/v1/README.md#build-providers) | +| `litheVcs` `0`(`vcsProviders`) | VCS 提供者的能力和请求(`lithe/vcs/*`)。提供给 VCS 提供者 | ✅ | 未声明 | [VCS providers](../contract/v1/README.md#vcs-providers) | +| `litheToolchains` `0`(`toolchainProviders`) | `toolchains/query`;后端从声明的提供者中选出子集。提供给声明了工具链提供者的插件会话 | ✅ | 未声明 | [Provider backend negotiation](../contract/v1/toolchains/README.md#provider-backend-negotiation) | +| `litheKeymapImport` `0`(`keymapImportProviders`) | 后端从声明的提供者中选出要启用的子集;`lithe/keymap/import`。提供给声明了 `keymapImportProviders` 的插件会话 | ✅ | 未声明 | [Shortcut import](../contract/v1/README.md#shortcut-import-providers-core-foundation) | +| `litheLogging` `0`(`pluginAPIVersion`) | 结构化诊断通知 `lithe/log`;官方 SDK 要求宿主提供它,否则拒绝初始化。提供给每个插件拥有的进程 | ✅ | ✅ | [Structured diagnostics](../contract/v1/README.md#structured-plugin-diagnostics) | + +- `litheFunctions` 的宿主服务不靠版本号区分:后端调用 `lithe/host/getCapabilities` 得到宿主此刻注册了哪些方法,并受权限约束。新增宿主方法、通知或可选字段不需要新版本,见 [Extending the host services](../contract/v1/README.md#extending-the-host-services)。 +- 构建提供者(`litheBuild`)里宿主不呈现的部分按上面的脚注¹处理:后端可以实现,宿主不会调用。 +- 插件私有函数不需要清单字段,增删它们不改变任何版本。 + +### 文档格式版本 + +| 版本 | 内容 | Core | Lithe 桌面端 | 详见 | +| --- | --- | --- | --- | --- | +| 原生 UI 文档 `schemaVersion` `0` | 宿主渲染的原生控件文档 | ✅ | ✅(随 `ui`) | [`native-ui.schema.json`](../contract/v1/native-ui.schema.json) | +| 市场索引 `schemaVersion` `0` | 市场来源的索引文件 | ✅ | ✅ | [`plugin-index.schema.json`](../contract/v1/plugin-index.schema.json)、[插件市场](domains/marketplace.md) | +| 构建模型 `litheBuildModel`、依赖树 `litheDependencies`、测试结果 `litheTestResults`、测试发现 `litheTestDiscovery` 的 `version` `0` | 规范构建文档 | ✅ | ✅¹ | [Model and outputs](../contract/v1/README.md#model-and-outputs)、[Test discovery](../contract/v1/README.md#test-discovery) | +| 原生构建配置 `litheBuildConfiguration`、`litheConfigurationEdit` 的 `version` `0` | 基于真实来源的原生配置文档 | ✅ | 尚未呈现¹ | [Optional native configuration](../contract/v1/README.md#optional-native-configuration) | +| `modelInputs` 快照的 `version` `0` | 共享给其他工作区插件的只读模型输入 | ✅ | 未声明(`buildModelInputs`) | [Optional read-only model inputs](../contract/v1/README.md#optional-read-only-model-inputs) | +| `plugins.editorActions.decode` 请求的 `version` `0` | Core 命令的请求版本;必填,Core 只接受 `0` | ✅ | 尚未呈现¹ | [Independent editor actions](../contract/v1/README.md#independent-editor-actions) | +| 插件网页桥 `window.lithe.version` `0` | 包内网页可见的桥版本(`bridgeVersion`) | ✅ | ✅ | [Web content](../contract/v1/README.md#web-content) | +| 启动计划里的 `backend.version` | 取清单的 `requires.backend`,即 `0` | ✅ | ✅ | [Plugin backend](../contract/v1/README.md#plugin-backend) | + +导出或调用的插件函数的 `version`(1 到 1000)不是契约版本:它归导出该函数的插件所有。 + +### 官方 SDK + +Node.js SDK 0.5.0、Java SDK 0.5.0 和 Rust SDK 0.4.0 说的是版本 `0` 的协议;各 SDK 封装哪些协议见契约的 [Official backend SDK](../contract/v1/README.md#official-backend-sdk) 和各自的 [Node.js](../sdk/node/README.md)、[Java](../sdk/java/README.md)、[Rust](../sdk/rust/README.md) README。下载地址在 [Lithe-core-rust 的 Releases](https://github.com/Lithe-IDEA/Lithe-core-rust/releases)。 + +--- + +## English + +This page puts every version number a plugin meets in one place: `pluginAPIVersion`, the manifest format, the 18 capabilities, the process protocols, and the document formats. **Every one of them is `0`.** The normative text is [Versioning](../contract/v1/README.md#versioning), [Capabilities](../contract/v1/README.md#capabilities), [Host capabilities](../contract/v1/README.md#host-capabilities), and [Publishing](../contract/v1/README.md#publishing) in [`contract/v1/README.md`](../contract/v1/README.md). Where this page disagrees with the contract, the contract wins; please report it in the [Lithe-core-rust issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +The Core column follows the Core source on this repository's `main` (`SUPPORTED_CAPABILITIES` in [`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/plugins/capabilities.rs), `SUPPORTED_PLUGIN_API_VERSIONS` in [`declarative.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/plugins/declarative.rs)); the Lithe desktop column follows the desktop client's [declarations and notes](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go). Validating a capability is not running it: the desktop client declares 12 capabilities and does not declare the other 6, so a plugin that needs one of those is not offered for installation. + +### Four kinds of version + +| Version | Where it is written | Who checks it | When it is not met | +| --- | --- | --- | --- | +| Plugin API version `pluginAPIVersion` | `plugin.json` | Core | `plugin.api.unsupported` with `field: "pluginAPIVersion"` | +| Capability version `requires.` | `plugin.json` (required) | Core's support table, intersected with the host's `hostCapabilities` | `plugin.api.unsupported` with `field: "requires."`; the marketplace does not offer that plugin version | +| Process protocol version `experimental.lithe*` | Not in the manifest; negotiated in LSP `initialize` when the process starts | The host offers a list, the backend picks one | The protocol is not enabled, for example no build request goes to that process | +| Document format version | The document's own `schemaVersion` or `version` field | Core | Decoding fails | + +A capability version decides **whether a plugin installs**; a protocol version decides **what a running process can say**. A process protocol has no version of its own: its version is that of the capability that owns it, and the host offers exactly that one version. + +Future changes follow the contract's rules: + +- A change adds one new version (`1`) to exactly **one** capability, and the manifest section and the protocol move together. A plugin that does not use the change keeps `0` and stays installable wherever it was. +- A released capability version never changes meaning, and hosts keep supporting it. +- A plugin declares only the capabilities it uses, each at the **lowest** version that covers its use. Raising one without using what it adds only cuts off older hosts. +- Moving a plugin to a new capability version needs a new plugin version; the index keeps the last version with the previous requirements so older hosts still have one to install. +- A host release that supports a version ships before any plugin that declares it. + +### Plugin API version and manifest format + +| Version | Content | Core | Lithe desktop | See | +| --- | --- | --- | --- | --- | +| `pluginAPIVersion` `0` | The base fields: `schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, `requires`, `requiresTools`; also the version of `litheLogging`. Any other value is refused | ✅ | ✅ | [Manifest and packaging](domains/manifest.md), [Versioning](../contract/v1/README.md#versioning) | +| `schemaVersion` `0` | The file format of `plugin.json`; any other value is `plugin.schema.unsupported` | ✅ | ✅ | [`plugin-manifest.schema.json`](../contract/v1/plugin-manifest.schema.json) | + +### Capability versions (`requires`) + +Each row is one capability. `requires` lists **exactly** the capabilities a manifest uses: leaving one out or listing an unused one is `plugin.manifest.invalid`; every value is `0`. [Process start](../contract/v1/README.md#process-start) and [Executable resolution](../contract/v1/README.md#executable-resolution) belong to the base contract and are not capabilities; `requires.serverProcess` is refused like any unknown capability. + +| Version | Content | Core | Lithe desktop | See | +| --- | --- | --- | --- | --- | +| `languages` `0` | `languages[]`: language IDs, file extensions, and packaged TextMate [`grammar` and `configuration`](../contract/v1/README.md#grammars-and-editor-configuration); the host reads them from the package and applies them in the editor without starting a process | ✅ | ✅ | [Languages](domains/languages.md) | +| `packageTrust` `0` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | ✅ | Not declared | [Package trust](domains/package-trust.md), [Trust](../contract/v1/README.md#trust) | +| `languageServers` `0` | `languageServers[]` (launch fields as in [Process start](../contract/v1/README.md#process-start)), including [`workspaceConfiguration`](../contract/v1/README.md#workspace-configuration) and the [session fields for a plugin's own server](../contract/v1/README.md#plugin-owned-sessions) | ✅ | ✅¹ | [Languages](domains/languages.md), [Language servers](../contract/v1/README.md#language-servers) | +| `executionProviders` `0` | `executionProviders[]`: run and discover tests through the declared commands | ✅ | Not declared | [Languages](domains/languages.md), [Execution providers](../contract/v1/README.md#execution-providers) | +| `settings` `0` | `settings[]` and `settingActions[]`, with [placement, scope, effect, and page buttons](../contract/v1/README.md#placement-scope-effect-and-page-buttons) | ✅ | ✅ | [Plugin settings](domains/settings.md), [Plugin settings](../contract/v1/README.md#plugin-settings) | +| `ui` `0` | [`ui`](../contract/v1/README.md#host-owned-workbench-ui): text, [web](../contract/v1/README.md#web-content), and [native](../contract/v1/README.md#host-rendered-native-content) tool windows, commands, and [backend-only commands](../contract/v1/README.md#backend-only-commands) without a tool window | ✅ | ✅¹ | [UI](domains/ui.md) | +| `buildSystems` `0` | [`buildSystems[]`](../contract/v1/README.md#build-systems) without `provider`: detection, options, project model, tasks, run entries, tests, diagnostics, dependencies, sync, and [declarative preparation](../contract/v1/README.md#declarative-project-preparation) | ✅ | ✅¹ | [Declarative build systems](domains/build-systems.md) | +| `buildProviders` `0` | `buildSystems[].provider`: [build providers](../contract/v1/README.md#build-providers), in their own process or a shared language server; they speak `litheBuild`, which covers task execution, test execution and discovery, run preparation, native debug preparation, native configuration, inline test reports, semantic test selectors, typed run options, and registry connection debugging | ✅ | ✅¹ | [Build providers](domains/build-providers.md) | +| `buildModelInputs` `0` | `buildSystems[].modelSharing` and `modelInputs`: [optional read-only model inputs](../contract/v1/README.md#optional-read-only-model-inputs) shared between enabled workspace plugins | ✅ | Not declared | [Build providers](domains/build-providers.md) | +| `vcsProviders` `0` | [`vcsProviders[]`](../contract/v1/README.md#vcs-providers); they speak `litheVcs` | ✅ | Not declared | [VCS providers](domains/vcs.md) | +| `debugAdapters` `0` | [`debugAdapters[]`](../contract/v1/README.md#debug-adapters): standalone adapters, adapters inside a language server, [remote attach](../contract/v1/README.md#remote-attach), arguments and environment, [`features` and `defaults`](../contract/v1/README.md#adapter-features-and-defaults) | ✅ | ✅ | [Debug adapters](domains/debug-adapters.md) | +| `outputLinkMatchers` `0` | [`outputLinkMatchers[]`](../contract/v1/README.md#output-link-matchers): plugin-declared patterns that turn Run and Test console output into links; Core does the matching, and the `resolve` function is called only on click | ✅ | ✅ | [Languages](domains/languages.md) | +| `projectGenerators` `0` | [`projectGenerators[]`](../contract/v1/README.md#project-generators): entries of the New Project window, including [`tool` fields](../contract/v1/README.md#tool-fields) | ✅ | ✅ | [Project generators](domains/project-generators.md) | +| `backend` `0` | [`backend`](../contract/v1/README.md#plugin-backend): the plugin's own long-lived process, started with or without a page, serving the host services its permissions grant and speaking `litheFunctions` | ✅ | ✅¹ | [Plugin backend and plugin functions](domains/backend.md) | +| `toolDefinitions` `0` | `toolDefinitions[]`: [tool identities, layouts, discovery, and four-layer candidates](../contract/v1/toolchains/README.md) | ✅ | ✅¹ | [Toolchains](domains/toolchains.md) | +| `toolchainProviders` `0` | `toolchainProviders[]`: [provider declarations and permission-bound answer validation](../contract/v1/toolchains/README.md); they speak `litheToolchains` | ✅ | Not declared | [Toolchains](domains/toolchains.md) | +| `pluginFunctions` `0` | The manifest's top-level [`pluginFunctions`](../contract/v1/README.md#cross-plugin-calls): the functions the plugin exports (`exports`) and the functions of other plugins it calls (`calls`); grants no manifest permission, and Core relays the calls | ✅ | ✅¹ | [Plugin backend and plugin functions](domains/backend.md) | +| `keymapImportProviders` `0` | `keymapImportProviders[]`: [independent shortcut import providers](../contract/v1/README.md#shortcut-import-providers-core-foundation); they speak `litheKeymapImport` | ✅ | Not declared | [Shortcut import](domains/keymap-import.md) | + +¹ The desktop client declares this capability, but some of its parts have no surface yet: the missing part has no button, menu, or window, and the plugin installs and does not fail because of it. The notes above `Declared()` in the desktop source are authoritative for what is not presented ([`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)): + +- Language servers present only diagnostics; completion, hover, navigation, rename, and formatting have no editor surface; a launch answer of `{kind: "tool"}` is refused. +- Tool discovery for system commands reads `PATH` only; a plan that depends on a project toolchain or a user override is refused. +- For build providers: run preparation, tests, debug preparation, model inputs, native configuration, inline test reports, and runtime debugging; for build systems: instance options and task preparations. +- `ui` commands that belong to no tool window. +- Backend host services: the workspace file and search methods (`workspace/readDirectory`, `stat`, `readFile`, `watch`, `findFiles`, `findText`) and `preview/openHTML` are not registered, so a plugin that asks for one of those permissions is refused. At runtime, the reply of `lithe/host/getCapabilities` is authoritative; see the [API catalog](api-catalog.md). + +### Process protocol versions + +These protocols are negotiated when a process starts and never appear in the manifest. The host offers `{ "versions": [0] }` under `capabilities.experimental.` in `initialize`; the backend answers `{ "version": 0 }`. An answer with a version the host did not offer, or a malformed answer, leaves the whole protocol disabled. A protocol's version is the version of the capability that owns it. + +| Version | Content | Core | Lithe desktop | See | +| --- | --- | --- | --- | --- | +| `litheFunctions` `0` (`backend`) | Plugin functions: register, withdraw, invoke, results, progress, cancellation; the `lithe/host/*` host services (credentials, workspace contexts and capability discovery, host notifications, public document, file, and window methods, plugin-owned native terminals, independent editor actions, editor language-feature providers, run and debug sessions, language server methods, content providers); cross-plugin calls `functions/providers` and `functions/invoke`. Offered to plugin-owned sessions | ✅ | ✅¹ | [Plugin functions](../contract/v1/README.md#plugin-functions) | +| `litheBuild` `0` (`buildProviders`) | `lithe/build/*`: model and dependencies, task execution, test execution and discovery, run preparation, native debug preparation, read-only model inputs, native configuration, inline test reports, semantic test selectors, typed run options, registry connection debugging. Offered to build providers | ✅ | ✅¹ | [Build providers](../contract/v1/README.md#build-providers) | +| `litheVcs` `0` (`vcsProviders`) | VCS provider capabilities and requests (`lithe/vcs/*`). Offered to VCS providers | ✅ | Not declared | [VCS providers](../contract/v1/README.md#vcs-providers) | +| `litheToolchains` `0` (`toolchainProviders`) | `toolchains/query`; the backend selects a subset of its declared providers. Offered to plugin sessions that declare toolchain providers | ✅ | Not declared | [Provider backend negotiation](../contract/v1/toolchains/README.md#provider-backend-negotiation) | +| `litheKeymapImport` `0` (`keymapImportProviders`) | The backend selects a subset of its declared providers; `lithe/keymap/import`. Offered to plugin sessions with declared `keymapImportProviders` | ✅ | Not declared | [Shortcut import](../contract/v1/README.md#shortcut-import-providers-core-foundation) | +| `litheLogging` `0` (`pluginAPIVersion`) | Structured diagnostic notification `lithe/log`; the official SDKs require the host to offer it and refuse to initialize otherwise. Offered to every plugin-owned process | ✅ | ✅ | [Structured diagnostics](../contract/v1/README.md#structured-plugin-diagnostics) | + +- The host services of `litheFunctions` are not told apart by a version: a backend calls `lithe/host/getCapabilities` to learn which methods the host has registered right now, bound by its permissions. A new host method, notification, or optional field needs no new version; see [Extending the host services](../contract/v1/README.md#extending-the-host-services). +- Parts of `litheBuild` that the host does not present are handled as in footnote¹ above: a backend may implement them, and the host never calls them. +- A plugin-private function needs no manifest field, so adding or removing one changes no version. + +### Document format versions + +| Version | Content | Core | Lithe desktop | See | +| --- | --- | --- | --- | --- | +| Native UI document `schemaVersion` `0` | The document of host-rendered native controls | ✅ | ✅ (with `ui`) | [`native-ui.schema.json`](../contract/v1/native-ui.schema.json) | +| Marketplace index `schemaVersion` `0` | The index file of a marketplace source | ✅ | ✅ | [`plugin-index.schema.json`](../contract/v1/plugin-index.schema.json), [Marketplace](domains/marketplace.md) | +| `version` `0` of the build model `litheBuildModel`, dependency tree `litheDependencies`, test results `litheTestResults`, and test discovery `litheTestDiscovery` | Canonical build documents | ✅ | ✅¹ | [Model and outputs](../contract/v1/README.md#model-and-outputs), [Test discovery](../contract/v1/README.md#test-discovery) | +| `version` `0` of the native build configuration `litheBuildConfiguration` and `litheConfigurationEdit` | Source-backed native configuration documents | ✅ | Not presented yet¹ | [Optional native configuration](../contract/v1/README.md#optional-native-configuration) | +| `version` `0` of the `modelInputs` snapshot | Read-only model inputs shared with other workspace plugins | ✅ | Not declared (`buildModelInputs`) | [Optional read-only model inputs](../contract/v1/README.md#optional-read-only-model-inputs) | +| `version` `0` of the `plugins.editorActions.decode` request | The request version of a Core command; required, and Core accepts only `0` | ✅ | Not presented yet¹ | [Independent editor actions](../contract/v1/README.md#independent-editor-actions) | +| Plugin web bridge `window.lithe.version` `0` | The bridge version package web content sees (`bridgeVersion`) | ✅ | ✅ | [Web content](../contract/v1/README.md#web-content) | +| `backend.version` in a launch plan | Taken from the manifest's `requires.backend`, that is `0` | ✅ | ✅ | [Plugin backend](../contract/v1/README.md#plugin-backend) | + +The `version` of an exported or called plugin function (1 to 1000) is not a contract version: the plugin that exports the function owns it. + +### Official SDKs + +Node.js SDK 0.5.0, Java SDK 0.5.0, and Rust SDK 0.4.0 speak the version `0` protocols; which protocols each SDK wraps is in the contract's [Official backend SDK](../contract/v1/README.md#official-backend-sdk) and in the [Node.js](../sdk/node/README.md), [Java](../sdk/java/README.md), and [Rust](../sdk/rust/README.md) READMEs. Downloads are on the [Lithe-core-rust Releases](https://github.com/Lithe-IDEA/Lithe-core-rust/releases) page. diff --git a/shared/plugins/reference/domains/backend.md b/shared/plugins/reference/domains/backend.md new file mode 100644 index 00000000..501c8f39 --- /dev/null +++ b/shared/plugins/reference/domains/backend.md @@ -0,0 +1,441 @@ +# 插件后端与插件函数 / Plugin backend and plugin functions + +[中文](#中文) · [English](#english) + +## 中文 + +插件后端是插件自己的长驻进程,用来运行网页做不到的事:数据库驱动、网络客户端、文件格式解析、调用宿主服务。它只服务插件自己,不属于任何构建系统、语言或工作区,也不回答 `lithe/build/*`、VCS 等领域请求。后端与宿主之间说插件函数协议(`litheFunctions`),可以按权限请求宿主服务,可以启动由宿主拥有的 Run 会话和调试会话,也可以经 Core 调用其他插件导出的函数。 + +> **宿主支持。** Lithe 桌面端声明 `backend` 和 `pluginFunctions`(版本都是 `0`)。一次会话能调用哪些宿主方法,以运行时 `lithe/host/getCapabilities` 返回的 `services` 为准,不要按本页假定。 +> 桌面端的 `Declared()` 注释列出了已注册的后端宿主服务:工作区上下文(`workspace/listContexts`、`getContext`、`getFolders` 和 `workspace/didChangeContexts`)、`subscriptions/dispose`、`languageServer/*`、跨插件调用中转、`credentials/*`、`documents/list` 与 `getSnapshot`、`editor/getActive`、`getSelections`、`open`、`reveal`、`insertText`、`replaceSelections`、`applyEdits`、`window/*`、`browser/openExternal`(只接受 HTTP(S) 地址)、`terminal/*` 和 `documents/registerContentProvider`;与窗口绑定的方法在窗口附着后才注册。 +> 工作区文件与搜索(`workspace/readDirectory`、`stat`、`readFile`、`watch`、`findFiles`、`findText`)、`preview/openHTML`、`editor/registerActions` 和 `editor/registerProviders` 没有注册,申请对应权限的插件会被拒绝。Run 与调试会话方法(`run/start`、`run/stop`、`debug/start`)是否可用,同样以 `getCapabilities` 为准。 +> 说明见 [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) 中 `Declared()` 上方的注释;各能力的对照见[能力与协议版本对照](../capabilities-and-versions.md)。 +> 对契约有疑问,请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `backend`(与[启动进程](../../contract/v1/README.md#process-start)的字段相同,`launch` 加 `arguments` 等);跨插件调用另有 `pluginFunctions` 段(`exports`、`calls`) | +| 能力版本 | `backend` `0`;`ui` 的后端命令需要同时声明 `ui: 0` 和 `backend: 0`;跨插件调用需要 `pluginFunctions: 0` 和 `backend: 0`。`requires` 必须恰好列出清单用到的能力;启动进程是基础契约,不是能力,不写 `requires.serverProcess`。见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | `litheFunctions` `0`(版本就是 `backend` 的版本)、`litheLogging` `0`,见[进程协议版本](../capabilities-and-versions.md#进程协议版本) | +| API 编号 | API-01、04、05、06、07、08、20、21、22、23、24、35、37、40、41,见 [API 总表](../api-catalog.md#现在能用的-api) | +| 权限 | `backend.launch`;宿主服务按方法另加权限(`credentials.store`、`workspace.metadata`、`workspace.files.read`、`workspace.files.watch`、`workspace.search`、`terminal.create`、`run.start` 等),见[已公开的宿主方法](../api-catalog.md#已公开的宿主方法)。跨插件调用没有清单权限:调用方的 `calls` 和被调方的 `exports` 就是双方的同意 | +| 激活事件 | 至少要有一种启动方式:网页工具窗口第一次调用 `functions.*`;`ui.commands[]` 的命令(`onCommand:`);可选的 `onWorkspaceOpen`;或宿主查询的提供者(工具链或快捷键导入提供者,以及 `launch.kind: "backend"` 的语言服务器)。都没有是 `plugin.manifest.invalid`。只导出函数的插件不需要网页、命令或 `onWorkspaceOpen`,被调用时启动后端;只调用的插件仍要有其中一种 | +| 官方 SDK | [Node.js 0.5.0](../../sdk/node/README.md)、[Java 0.5.0](../../sdk/java/README.md)、[Rust 0.4.0](../../sdk/rust/README.md),发布标签 `sdk-node-v0.5.0`、`sdk-java-v0.5.0`、`sdk-rust-v0.4.0`,见[官方 SDK](../capabilities-and-versions.md#官方-sdk)。三个 SDK 都实现函数协议,封装凭据、工作区、文档与编辑器(含 `editor/registerActions`)、预览、浏览器、窗口消息、`functions/providers` 和 `functions/invoke`,被调函数能读到 `caller`,用法见各 SDK 的 README;`parentOperationID` 不传时,SDK 取正在处理的 `lithe/function/invoke` 的 `operationID`。`run/*`、`debug/start`、`languageServer/*`、`documents/registerContentProvider` 和 `editor/registerProviders` 没有专门的封装,用 SDK 的通用宿主请求(`request`)调用。原生终端有封装,见[从插件打开原生终端](../../guides/native-terminals.md) | + +### 后端的启动与工作区 + +- 每个插件最多一个后端进程,由所有打开的工作区和窗口共享,运行在运行 Lithe 的机器上,远程工作区也一样。工作区不是进程状态:每次调用都显式带工作区上下文,所以 `${workspaceStateDir}` 被拒绝;可写状态是 `${stateDir}`,宿主以它为根目录和工作目录启动后端。 +- 宿主在上面“激活事件”一行的第一个触发点启动后端;插件被停用、更新、卸载,或宿主退出时结束它。同意来自安装审查,不需要按工作区弹确认;审查里它显示为 `packagedBackend`、`systemCommandBackend` 或 `runtimeBackend`。 +- `onWorkspaceOpen` 不会拖慢打开工作区。宿主在窗口显示后一次启动一个这样的后端;在初始化时限内没有完成 `initialize` 的后端被停止,按普通的服务器启动失败处理。失败或被停用的后端从不阻塞工作区。 +- `workspace.metadata` 权限授予 `workspace` 宿主服务:开放工作区的名称、根目录和 ID、authority 以及插件在该工作区的状态目录;它不读任何文件内容。 +- 激活只取决于插件已安装、已启用且已审查。Lithe 目前没有工作区信任状态:会运行或修改内容的操作,像宿主自己的控件一样按操作询问用户。 +- 插件按平台发布一个包,所以包内路径不随 `${arch}` 变化;macOS 的包可以带通用二进制。 + +### 协议要点 + +| 项目 | 规则 | +| --- | --- | +| 协商 | 对插件拥有的会话,宿主在 `initialize` 的 `capabilities.experimental.litheFunctions` 提供 `{"versions": [0]}`,与 `litheBuild` 互不依赖;后端回答 `{"version": 0, "functions": [{"id": "…"}]}`。回答了宿主没给出的版本,或函数 ID 格式错误、重复,整个协议不启用 | +| 函数 ID | 以点分隔的小驼峰段(`snapshot.refresh`),不超过 128 字节,会话内唯一,每个会话最多 256 个函数 | +| 注册与撤回 | 初始化之后 `lithe/function/register`、`unregister`,整个请求要么全成功要么全失败(`-32602`、`plugin.function.invalidRegistration`) | +| 调用 | 宿主发 `lithe/function/invoke`,顶层有 `functionID`、`operationID`、`context`、`params`(JSON 对象,默认 `{}`);`operationID` 由 Core 分配,进度通知必须原样带回 | +| 结果 | 结果,或 `plugin.function.failed`(后端的消息最多 1024 字节,`pluginErrorCode` 来自 `error.data.code`),或 `plugin.function.invalidResult`(没有 `result`,或序列化后超过 1 MiB);取消和超时由宿主发 `$/cancelRequest` | +| 进度 | `lithe/function/progress {operationID, message?, percentage?}`,`message` 截到 1024 字节,`percentage` 为 0–100 的整数 | +| 禁止回环 | 后端不能请求宿主分发领域操作或函数:对 `lithe/function/invoke` 或任何 `lithe/build/*` 的请求以 `plugin.function.recursiveCall` 拒绝。跨插件调用是另一种请求,见下文 | +| 私有函数 | 不需要清单字段,增删它们不改变任何版本;只有可被其他插件调用的函数和对它的调用才声明在 `pluginFunctions` | +| 不回答协议 | 不回答 `litheFunctions` 的后端(例如只做构建的后端)照常服务它的语言和 `litheBuild` 请求,收不到函数调用,它的函数请求得到 `-32601` | + +### 宿主服务 + +后端向宿主发请求,前缀 `lithe/host/`;每个服务由一个清单权限授予,Core 在宿主看到请求之前检查。完整的方法、权限和限制见 [API 总表](../api-catalog.md#已公开的宿主方法)。 + +| 服务 | 方法 | 权限 | +| --- | --- | --- | +| 能力发现 | `getCapabilities`、`subscriptions/dispose` | 每个插件会话都有 | +| 凭据 | `credentials/get`、`set`、`delete` | `credentials.store` | +| 工作区上下文 | `workspace/listContexts`、`getContext`、`getFolders` | `workspace.metadata` | +| 文件与搜索 | `workspace/readDirectory`、`stat`、`readFile`、`watch`、`findFiles`、`findText` | `workspace.files.read`、`workspace.files.watch`、`workspace.search` | +| 文档与编辑器 | `documents/*`、`editor/*`,见[编辑器](editor.md) | `documents.read`、`editor.edit`、`editor.open`、`editor.actions`、`editor.providers` | +| 预览、浏览器、窗口消息 | `preview/openHTML`、`browser/openExternal`、`window/*` | `preview.open`、`browser.open`、`window.messages` | +| 原生终端 | `terminal/*` | `terminal.create` | +| Run 与调试会话 | `run/start`、`run/stop`、`debug/start` | `run.start`、`debug.start` | +| 语言服务器 | `languageServer/request`、`notify`、`setStatus` | `languageServer.request` | +| 内容提供者 | `documents/registerContentProvider` | `documents.contentProvider` | +| 跨插件调用 | `functions/providers`、`functions/invoke` | 无清单权限,见下文 | + +- 每个会话最多 16 个未回答的宿主请求;超出的请求以 `plugin.host.failed` 拒绝。Core 按固定顺序拒绝:协议没有定义的方法(`-32601`、`plugin.host.unavailable`)、没有授予的服务(`-32010`、`plugin.host.permissionDenied`)、参数格式错误(`-32602`、`plugin.host.invalidParams`),错误码在 `error.data.code`。宿主自己的失败是 `-32603`、`plugin.host.failed`。 +- 新增宿主服务、方法、通知、可选的请求字段、结果或 `limits` 里的字段、失败码,都不需要新版本;只有现有后端会误读的改动才给 `backend` 版本 `1`,协议随之变化。 +- 为了在每个宿主上都能工作,后端要遵守四条:只调用 `getCapabilities.services` 列出的方法(其他返回 `-32601`、`plugin.host.unavailable`);忽略不认识的结果、通知、`details`、`limits` 字段和不认识的 `lithe/host/*` 通知;Core 对方法没有定义的字段返回 `-32018`、`plugin.host.unsupportedOption`,`details.option` 指明字段名,后端可以去掉该选项重试;把不认识的 JSON-RPC 号当作宿主失败,认识 `error.data.code` 时按它分支。 +- 后端需要新的服务就声明新的权限;不认识这个权限的宿主在安装时拒绝这个包(`plugin.permission.unknown`),权限不会被悄悄授予或忽略。 +- 宿主通知(后端不回复):`workspace/didChangeContexts`、`subscriptions/notify`、`commands/didRun`、`run/didExit`、`languageServer/didChangeState`、`languageServer/didReceiveNotification`,授予条件见 API 总表。后端命令(不打开工具窗口的 `ui` 命令)是事件:运行中的后端按顺序收到 `commands/didRun`,已停止的后端只有声明了 `onCommand:` 才会为它启动。 + +### 跨插件调用 + +插件函数默认只属于插件自己。插件导出函数后,另一个已启用、且在清单里声明了这次调用的插件,可以经 Rust Core 调用它:调用方的后端碰不到被调方的进程,被调方的后端也只知道自己正在服务的那次调用;宿主只在被调方的后端没有运行时负责启动它。中转是 Core 的逻辑,在所有运行插件后端的宿主上一样。 + +#### 清单声明 + +| 项目 | 规则 | +| --- | --- | +| 前提 | 有 `backend`,`requires` 写 `pluginFunctions: 0` 和 `backend: 0`;`requires` 列出 `pluginFunctions` 当且仅当清单有 `pluginFunctions` 段 | +| 段内容 | `exports` 与 `calls` 至少有一个,各最多 32 项、按 `id` 排序、`id` 不重复;多余的字段是 `plugin.manifest.invalid` | +| 导出 | `{id, version, context}`。`id` 是函数 ID;`version` 是导出的契约版本(1–1000 的整数),参数或结果有不兼容变更就换新版本;`context` 目前只有 `"workspace"`,表示只在本地工作区上下文里运行,缺失或未知则整个清单被拒绝 | +| 调用 | `{id, version}`。只写函数 ID 和调用方对照的版本,不写被调方的插件 ID,运行时再发现提供者;两个版本必须相等,否则 `plugin.function.versionMismatch` | +| 注册 | 声明导出不等于注册:被调方的后端仍要 `lithe/function/register`,未注册或已撤回的函数是 `plugin.function.notExported`。清单没有导出的函数保持私有,引用它仍是 `plugin.function.wrongPlugin` | +| 宿主支持 | 宿主向声明了 `calls` 的插件开放 `functions` 宿主服务;`hostCapabilities` 里没有 `pluginFunctions` 的宿主拒绝这类清单(`plugin.api.unsupported`) | + +#### 宿主方法 + +只对声明了至少一个 `calls` 的后端开放,没有声明任何 call 的插件返回 `-32010`、`plugin.host.permissionDenied`。`getCapabilities` 在 `functions` 服务下列出这两个方法。 + +| 方法(前缀 `lithe/host/`) | 参数 | 结果 | +| --- | --- | --- | +| `functions/providers` | `{functionID}` | `{providers: [{pluginID, version}]}` | +| `functions/invoke` | `{pluginID, functionID, context: {contextID, generation}, params?, timeoutMilliseconds?, parentOperationID?}` | `{value}` | + +- `providers` 列出导出该函数、已启用、且不是调用方自己的插件,按插件 ID 排序,每项带导出的 `version`。调用方必须声明过该函数,否则 `plugin.function.notDeclared`。被调方的后端不必正在运行。 +- `invoke` 调用一个提供者。`context` 必填,是调用方从工作区服务得到的本地工作区上下文;`params` 是 JSON 对象,默认 `{}`;`timeoutMilliseconds` 是正整数,默认 60000,上限 600000;未定义的字段返回 `-32018`、`plugin.host.unsupportedOption`。`value` 是被调方的结果,`null` 也是结果。 +- 调用方用 `$/cancelRequest` 取消,得到 `plugin.host.cancelled`。 +- Core 按固定顺序检查(调用方声明、被调方启用、被调方导出、版本相等、上下文、本地工作区、参数大小、所属调用、调用链、深度、并发数),在向被调方写任何内容之前拒绝,第一个失败的检查决定错误码。 + +#### 被调方收到的请求 + +Core 把中转的调用以 `lithe/function/invoke` 转给被调方。和任何函数调用一样,请求顶层有 `functionID`、`operationID`、`context`、`params`;中转的调用另有顶层字段 `caller: {pluginID}`。 + +- `caller.pluginID` 由 Core 按调用会话的身份写入,在 `params` 之外,调用方无法提供:`functions/invoke` 请求带顶层 `caller` 会被 `-32018` 拒绝。要按调用方区分行为或保存状态的函数,读这个字段,不要读 `params` 里的插件 ID。调用方 `params` 里叫 `caller` 的键只是普通数据,原样传递。 +- 宿主自己发起的调用没有 `caller`,缺失表示宿主。 +- `operationID` 标识正在服务的这次调用。被调方在服务一次中转的调用时再发起 `functions/invoke`(嵌套调用),要把这个 `operationID` 作为 `parentOperationID` 传入,Core 才知道新调用属于哪一次。`parentOperationID` 是 1–128 字节的字符串,否则 `-32602`、`plugin.host.invalidParams`;不传时,Core 把新调用当作后端正在服务的每一次中转调用的子调用,所以后端应当总是传。 +- 调用链:新调用的链是父调用的链加上调用方。调用一个已在链上的插件(包括调用自己)是 `plugin.function.callCycle`,链最长 4 次调用。取消、超时或结束父调用,会取消它的子调用,各被调方收到 `$/cancelRequest`。`parentOperationID` 指向宿主直接发起的调用时,新调用开始一条新链;指向后端没有在服务的调用(已结束或不存在)时被拒绝,`plugin.host.cancelled`。 +- 被调方的后端没有运行时,Core 等待,并在调用方会话上发出运行时事件 `functionProviderStartRequired`,宿主按普通的插件启动流程启动它;30 秒内没有协商并注册好函数,调用以 `plugin.function.providerUnavailable` 结束。不能启动后端的宿主忽略这个事件,调用同样结束。 +- Core 为每个后端会话用各自的队列写入:一个后端不再读标准输入,只会拖慢发给它的调用和回复,其他插件的中转照常。向后端的写入在请求时限之后仍被阻塞时,Core 把该后端的会话当作传输失败结束(`transportFailed`,阶段 `relayWrite`)。 + +#### 信任不传递 + +- 被调方用自己的权限、状态目录和凭据运行;调用方不获得被调方的任何权限,被调方也不获得调用方的。调用方的插件 ID 只出现在 `caller` 字段里,不写进被调方的会话。 +- 导出的函数不得接受调用方给的可执行路径、命令行或脚本去启动进程,也不得读写调用方指定的路径而不核对自己的访问范围。调用方需要被调方运行什么,函数应当接收一个由被调方自己解释的请求(任务名、文档),由被调方决定可执行文件。 +- 这不是安装依赖:安装调用方不会连带安装被调方,停用被调方只会让调用找不到提供者;不参与安装闭包,不做版本求解。远程工作区的上下文被拒绝,`plugin.host.authorityUnsupported`;导出的 `context: "workspace"` 只指本地工作区,后端仍在运行 Lithe 的机器上。 +- 停用、启用、替换和卸载插件对中转立即生效,不需要重启;宿主用 Core 命令 `lsp.configureFunctionRelay` 发送插件和工作区上下文的快照。只在启动时注册后端插件的宿主上,新安装或更新的后端插件要重启 Lithe 后才会出现在 `providers` 里。 + +#### 错误码 + +| 错误码 | 何时 | +| --- | --- | +| `plugin.function.notDeclared` | 调用方没有声明该函数;调用进行中调用方更新后不再声明,也是它 | +| `plugin.function.providerDisabled` | 被调方没有安装或未启用;调用进行中被调方被停用、卸载,或更新后不再导出该函数或改了版本,也是它 | +| `plugin.function.notExported` | 被调方的后端没有注册该函数,或已撤回 | +| `plugin.function.versionMismatch` | 调用方声明的版本与被调方导出的版本不相等 | +| `plugin.function.callCycle` | 被调方已在调用链上,包括调用自己 | +| `plugin.function.depthExceeded` | 调用链超过 4 次调用 | +| `plugin.function.tooManyCalls` | 调用方已有 8 个中转调用在运行 | +| `plugin.function.payloadTooLarge` | 参数超过 256 KiB,或被调方的结果超过 256 KiB(超过 1 MiB 是 `invalidResult`) | +| `plugin.function.providerUnavailable` | 被调方的后端 30 秒内没有就绪;或调用期间后端退出、撤回了该函数 | + +被调方自己的失败保留原意:函数回答了错误时是 `plugin.function.failed`(`-32070`),消息截到 1024 字节,`details.pluginErrorCode` 是被调方的 `error.data.code`;没有 `result`,或结果序列化后超过 1 MiB,是 `plugin.function.invalidResult`(`-32071`)。这两个号都不是 `-32603`,后者仍是 Core 内部失败 `plugin.host.failed`。工作区上下文被关闭或换代是 `plugin.host.contextExpired`,截止时间到了是 `plugin.host.timeout`;被调方在截止时间后 2 秒仍未回答时,中转自己以 `plugin.host.timeout` 结束调用并取消被调方的操作。Core 不重启后端,也不重试调用。 + +### Run 与调试会话 + +后端可以启动宿主当作自己的会话呈现的运行或调试:后端决定启动什么,宿主拥有进程、控制台、停止按钮和会话的结束。Run 会话只增加宿主呈现(Run 窗口里的条目、控制台、退出状态),不授予其他能力。`run/start` 与 `debug/start` 带 `context: {contextID, generation}` 和对应权限;`run/stop` 不带 `context`,所以启动它的上下文关闭后仍能停止。 + +| 方法(前缀 `lithe/host/`) | 权限 | 参数 | 结果 | +| --- | --- | --- | --- | +| `run/start` | `run.start` | `context, label, process, temporaryFiles?, debug?, rerun?` | `{runID}` | +| `run/stop` | `run.start` | `{runID}` 或 `{sessionID}` | `null` | +| `debug/start` | `debug.start` | `context, label, adapter, request, configuration` | `{sessionID}`,见[调试适配器](debug-adapters.md#由插件后端发起调试会话) | + +通知(后端不回复):`lithe/host/run/didExit`,参数 `{runID, exitCode?, signal?}`,授予条件是 `run.start`。`run.start` 授予 `run/start`、`run/stop` 和 `run/didExit`。 + +`run/start` 的参数: + +- `label`:1–256 个字符,不含控制字符,作为 Run 窗口条目的标题。 +- `process`:`{executable, arguments, workingDirectory, environment}`,四个字段都必填,未知字段被拒绝。`executable` 是 `{kind: "tool", tool}`(`tool` 是清单 `requiresTools` 列出的工具定义 ID,宿主按[工具链](toolchains.md)的分层解析)或 `{kind: "packaged", path}`(插件包内文件的规范化相对路径)。参数、工作目录(相对工作区根、且是其中已存在的目录)和环境的上限同[运行准备](../../contract/v1/README.md#provider-run-preparation)。 +- `temporaryFiles`:最多 4 个 `{name, contents}`,每个文本最多 4 MiB。宿主在自己的临时目录写入,运行结束时删除,并把 `process.arguments` 和 `process.environment` 里的 `${temporaryFile:}` 替换成文件路径,插件不需要知道临时目录在哪。`name` 唯一,1–64 个字母、数字、`.`、`_`、`-`,以字母或数字开头。 +- `debug`:`{configurationType, target}`,让这次运行成为调试会话,见[调试适配器](debug-adapters.md#由插件后端发起调试会话)。`configurationType` 是公开的调试适配器别名;`target` 是 `{kind: "connection", port}`(进程在 `port`,1–65535,监听调试器,由后端写进参数,宿主等待并附加适配器)或 `{kind: "launch"}`(适配器自己启动进程,按 `targetBindings` 从 `process` 取程序、参数、目录和环境)。 +- `rerun`:`{function, data}`,插件已注册的函数和它的参数(最多 64 KiB 的 JSON 对象)。用户选“再次运行”时,宿主以 `data` 为参数、当前 `context` 调用该函数,后端再回答一次新的 `run/start`,所以重启由后端准备;没有 `rerun` 的条目不能再次运行。 + +`debug/start` 的参数: + +- `label` 同 `run/start`。`adapter` 是本插件自己的适配器,写本地 ID 或限定的 `/`;其他插件的适配器和本插件没有声明的适配器都是 `plugin.host.notFound`。 +- `request` 是 `launch` 或 `attach`,适配器必须支持它。 +- `configuration` 是最多 256 KiB 的 JSON 对象,宿主在适配器声明的默认值之上把它作为 DAP 的 launch 或 attach 参数传给适配器;`noDebug` 和所有以 `__` 开头的键归宿主所有,被拒绝。 + +其他规则: + +- `run/stop` 按 `runID` 停止运行,或按 `sessionID` 停止调试会话,已经结束时同样返回 `null`;取消宿主请求等同停止。后端只能停止自己启动的运行和会话。 +- 进程结束时宿主发 `run/didExit`,每次运行只发一次,只发给持有 `run.start` 的会话;`exitCode` 是退出状态,`signal`(1–127)是结束它的信号,宿主丢失进程时两个字段都没有。 +- 参数格式错误是 `plugin.host.invalidParams`,Core 的拒绝(`plugin.run.invalidStart`、`plugin.debug.invalidStart`)在 `details.code` 和 `details.field`;上下文已关闭是 `plugin.host.contextExpired`;`tool` 解析不到可执行文件是 `plugin.host.toolUnresolved`。 +- 插件被禁用或后端退出时,宿主保留运行中的进程,只是不再通知后端;重新启动的后端不会收到它没见过开始的运行的 `run/didExit`。 +- 想让 Run 控制台里的输出出现链接,用 `outputLinkMatchers` 声明匹配器:Core 负责匹配,`resolve` 函数在用户点击时才调用,见 [Output link matchers](../../contract/v1/README.md#output-link-matchers)。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Plugin backend](../../contract/v1/README.md#plugin-backend) | 后端声明、状态目录、启动与停止时机、按平台发布 | +| [Backend lifecycle and workspace contexts](../../contract/v1/README.md#backend-lifecycle-and-workspace-contexts) | 启动方式、`onWorkspaceOpen` 不阻塞工作区、`workspace.metadata` | +| [Plugin functions](../../contract/v1/README.md#plugin-functions) | 协议总览 | +| [Capability ownership](../../contract/v1/README.md#capability-ownership) | 插件函数不能代替领域契约 | +| [Declarative or backend implementation](../../contract/v1/README.md#declarative-or-backend-implementation) | 什么时候用声明式数据、什么时候写后端 | +| [Negotiation](../../contract/v1/README.md#negotiation) | `initialize` 里的版本协商 | +| [References and IDs](../../contract/v1/README.md#references-and-ids) | 函数引用和 ID 规则 | +| [Registration and withdrawal](../../contract/v1/README.md#registration-and-withdrawal) | 注册和撤回函数 | +| [Invocation](../../contract/v1/README.md#invocation)、[Progress](../../contract/v1/README.md#progress) | 调用、结果、进度、取消 | +| [Host services](../../contract/v1/README.md#host-services) | 凭据服务 | +| [Workspace contexts and capability discovery](../../contract/v1/README.md#workspace-contexts-and-capability-discovery) | `getCapabilities`、上下文 `contextID`/`generation`、宿主失败码 | +| [Host notifications](../../contract/v1/README.md#host-notifications) | `workspace/didChangeContexts`、`subscriptions/notify`、`commands/didRun`、`run/didExit`、语言服务器通知 | +| [Extending the host services](../../contract/v1/README.md#extending-the-host-services) | 不升版本地增加方法,后端要遵守的四条 | +| [Public document, file and window methods](../../contract/v1/README.md#public-document-file-and-window-methods) | 文件读取、监听、搜索与窗口消息(文档和编辑器方法见[编辑器](editor.md)) | +| [Plugin-owned native terminals](../../contract/v1/README.md#plugin-owned-native-terminals)、[Close, retained context and reopen](../../contract/v1/README.md#close-retained-context-and-reopen) | 插件自有原生终端 | +| [Run and debug sessions](../../contract/v1/README.md#run-and-debug-sessions) | `run/start`、`run/stop`、`debug/start`、`run/didExit`:参数、限制、失败码 | +| [Language server host methods](../../contract/v1/README.md#language-server-host-methods) | 后端与本插件自己的语言服务器对话 | +| [Content providers](../../contract/v1/README.md#content-providers) | 把 URI scheme 绑定到后端函数 | +| [Backend-only commands](../../contract/v1/README.md#backend-only-commands) | 不打开工具窗口的命令 | +| [Cross-plugin calls](../../contract/v1/README.md#cross-plugin-calls) | `pluginFunctions` 清单声明、`functions/providers`、`functions/invoke`、检查顺序、调用链、`caller` 字段、未运行的提供者、热生效 | +| [Output link matchers](../../contract/v1/README.md#output-link-matchers) | Run 与测试控制台里的输出链接,`resolve` 函数在点击时调用 | +| [Structured plugin diagnostics](../../contract/v1/README.md#structured-plugin-diagnostics) | `lithe/log` 的字段和限制 | +| [Compatibility](../../contract/v1/README.md#compatibility)、[Official backend SDK](../../contract/v1/README.md#official-backend-sdk) | 兼容规则与官方 SDK | +| [Outcomes and error codes](../../contract/v1/README.md#outcomes-and-error-codes) | 结果与错误码 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`backend/backend.plugin.json`](../../contract/v1/fixtures/backend/backend.plugin.json) | 网页工具窗口调用后端,使用凭据服务 | +| [`backend/backend-workspace.plugin.json`](../../contract/v1/fixtures/backend/backend-workspace.plugin.json) | `onWorkspaceOpen` 启动,`workspace.metadata` | +| [`backend/command-backend.plugin.json`](../../contract/v1/fixtures/backend/command-backend.plugin.json) | 后端加 `ui`:不打开工具窗口的命令,读文档并编辑 | +| [`backend/terminal-backend.plugin.json`](../../contract/v1/fixtures/backend/terminal-backend.plugin.json) | 插件自有原生终端的最小清单 | +| [`backend/function-protocol.json`](../../contract/v1/fixtures/backend/function-protocol.json) | 插件函数协议的标准报文:协商、注册、调用、结果、进度、宿主请求(`hostRequests`)、调用上下文(`invocationContexts`)、被中转调用的 `caller`(`invocationCallers`)、宿主失败码(`hostFailures`)、中转失败码(`relayErrors`)和宿主通知(`hostNotifications`) | +| [`backend/plugin-functions-exporter.plugin.json`](../../contract/v1/fixtures/backend/plugin-functions-exporter.plugin.json) | 只导出两个函数(`document.format` 版本 1、`symbols.list` 版本 2,`context` 为 `workspace`),没有网页、命令或 `onWorkspaceOpen` | +| [`backend/plugin-functions-caller.plugin.json`](../../contract/v1/fixtures/backend/plugin-functions-caller.plugin.json) | 声明调用 `document.format` 版本 1,`onWorkspaceOpen` 启动 | +| [`backend/plugin-logging.json`](../../contract/v1/fixtures/backend/plugin-logging.json) | `litheLogging`:提供与回答、通知、拒绝用例、级别 | +| [`run/run-start.json`](../../contract/v1/fixtures/run/run-start.json) | `run/start` 的参数和 `run.decodeStart` 对每个用例的返回(工具与包内可执行文件、`debug` 与 `rerun`、各种拒绝);临时文件依赖宿主的临时目录,由 Core 自己的测试覆盖 | +| [`run/tool-reference-launch.plugin.json`](../../contract/v1/fixtures/run/tool-reference-launch.plugin.json) | `run-start.json` 使用的清单:`requiresTools` 列出工具 `interpreter`,声明 `run.start`、`debug.start`,并带 `outputLinkMatchers` | +| [`run/output-link-matchers.json`](../../contract/v1/fixtures/run/output-link-matchers.json) | `outputLinks.match` 的请求与返回的链接,以及匹配器 `resolve` 函数的回复经 `outputLinks.decodeResolution` 得到的结果 | +| [`debug-adapters/debug-start.json`](../../contract/v1/fixtures/debug-adapters/debug-start.json) | `debug.decodeBackendConfiguration` 对 `debug/start` 每个用例的返回,含拒绝 | + +后端相关的拒绝用例在 [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json)。 + +### 相关指南 + +- [插件后端:工作区上下文与宿主服务](../../guides/plugin-backend-workspace.md) +- [从插件打开原生终端](../../guides/native-terminals.md) +- [插件接入统一日志](../../guides/plugin-logging.md) + +--- + +## English + +A plugin backend is the plugin's own long-lived process. It runs what a web page cannot: database drivers, network clients, file-format parsers, calls to host services. It serves only its plugin, belongs to no build system, language, or workspace, and never answers domain requests such as `lithe/build/*` or VCS. Backend and host speak the plugin-function protocol (`litheFunctions`); the backend may request the host services its permissions grant, may start Run and debug sessions that the host owns, and may call functions that other plugins export, through Core. + +> **Host support.** The Lithe desktop declares `backend` and `pluginFunctions` (both version `0`). Which host methods a session may call is decided by `services` in the run-time `lithe/host/getCapabilities` answer; do not assume it from this page. +> The comment above `Declared()` in the desktop lists the backend host services it registers: workspace contexts (`workspace/listContexts`, `getContext`, `getFolders`, and `workspace/didChangeContexts`), `subscriptions/dispose`, `languageServer/*`, the cross-plugin call relay, `credentials/*`, `documents/list` and `getSnapshot`, `editor/getActive`, `getSelections`, `open`, `reveal`, `insertText`, `replaceSelections`, `applyEdits`, `window/*`, `browser/openExternal` (HTTP(S) addresses only), `terminal/*`, and `documents/registerContentProvider`; methods bound to a window are registered once the window attaches. +> Workspace files and search (`workspace/readDirectory`, `stat`, `readFile`, `watch`, `findFiles`, `findText`), `preview/openHTML`, `editor/registerActions`, and `editor/registerProviders` are not registered, and a plugin that asks for those permissions is refused. Whether the Run and debug session methods (`run/start`, `run/stop`, `debug/start`) are available is likewise decided by `getCapabilities`. +> See the comment above `Declared()` in [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go); for a per-capability comparison see the [capability and protocol versions](../capabilities-and-versions.md). +> Questions about the contract go to [Lithe-core-rust Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +| Item | Value | +| --- | --- | +| Manifest field | `backend` (the same fields as [process start](../../contract/v1/README.md#process-start): `launch`, `arguments`, and so on); cross-plugin calls add a `pluginFunctions` section (`exports`, `calls`) | +| Capability versions | `backend` `0`; backend commands of `ui` need both `ui: 0` and `backend: 0`; cross-plugin calls need `pluginFunctions: 0` and `backend: 0`. `requires` must list exactly the capabilities the manifest uses; process start is base contract, not a capability, so do not write `requires.serverProcess`. See [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | `litheFunctions` `0` (the version is the version of `backend`), `litheLogging` `0`; see [process protocol versions](../capabilities-and-versions.md#process-protocol-versions) | +| API IDs | API-01, 04, 05, 06, 07, 08, 20, 21, 22, 23, 24, 35, 37, 40, 41; see the [API catalog](../api-catalog.md#apis-usable-now) | +| Permissions | `backend.launch`; host services need their own permission per method (`credentials.store`, `workspace.metadata`, `workspace.files.read`, `workspace.files.watch`, `workspace.search`, `terminal.create`, `run.start`, …); see [public host methods](../api-catalog.md#public-host-methods). Cross-plugin calls have no manifest permission: the caller's `calls` and the provider's `exports` are the consent of the two sides | +| Activation | At least one way to start is required: a web tool window's first `functions.*` call; a `ui.commands[]` command (`onCommand:`); the optional `onWorkspaceOpen`; or a provider the host queries (a toolchain or keymap import provider, or a language server with `launch.kind: "backend"`). None of them is `plugin.manifest.invalid`. A plugin that only exports functions needs no page, command, or `onWorkspaceOpen`: a call starts its backend; a plugin that only calls still needs one of them | +| Official SDKs | [Node.js 0.5.0](../../sdk/node/README.md), [Java 0.5.0](../../sdk/java/README.md), [Rust 0.4.0](../../sdk/rust/README.md), released as `sdk-node-v0.5.0`, `sdk-java-v0.5.0`, and `sdk-rust-v0.4.0`; see [official SDKs](../capabilities-and-versions.md#official-sdks). All three implement the function protocol and wrap credentials, workspaces, documents and editors (including `editor/registerActions`), preview, browser, window messages, `functions/providers`, and `functions/invoke`; a called function can read `caller`; see each SDK's README for usage. When `parentOperationID` is omitted, the SDK uses the `operationID` of the `lithe/function/invoke` being handled. `run/*`, `debug/start`, `languageServer/*`, `documents/registerContentProvider`, and `editor/registerProviders` have no dedicated wrappers; call them through the SDK's generic host request (`request`). Native terminals are wrapped; see [Open a native Lithe terminal from a plugin](../../guides/native-terminals.md) | + +### Backend start and workspaces + +- A plugin has at most one backend process, shared by every open workspace and window, on the machine that runs Lithe, also for remote workspaces. No workspace is process state: each call names its workspace context explicitly, so `${workspaceStateDir}` is refused; the writable state is `${stateDir}`, and the host starts the backend with it as root and working directory. +- The host starts the backend on the first trigger in the "Activation" row above, and ends it when the plugin is disabled, updated, or uninstalled, and when the host quits. Consent is the install review, which lists it as `packagedBackend`, `systemCommandBackend`, or `runtimeBackend`; no per-workspace prompt is needed. +- Starting on `onWorkspaceOpen` never delays opening the workspace. The host starts such backends after the window is shown, one at a time; a backend that does not finish `initialize` within its initialize timeout is stopped and reported like any failed server start. A failed or disabled backend never blocks the workspace. +- The `workspace.metadata` permission grants the `workspace` host service: the open workspaces' names, root folders and IDs, their authority, and the plugin's per-workspace state directory; it reads no file content. +- Activation is gated only by the plugin being installed, enabled, and reviewed. Lithe has no workspace trust state at the moment: an operation that runs or changes something asks the user per operation, as the host's own controls do. +- A plugin publishes one archive per platform, so its packaged path does not vary by `${arch}`; a macOS archive may carry a universal binary. + +### Protocol essentials + +| Item | Rule | +| --- | --- | +| Negotiation | For a plugin-owned session the host offers `{"versions": [0]}` as `capabilities.experimental.litheFunctions` in `initialize`, independent of `litheBuild`; the backend answers `{"version": 0, "functions": [{"id": "…"}]}`. An answer with a version the host did not offer, or a malformed or duplicate function ID, does not negotiate the protocol at all | +| Function ID | Dot-separated lowerCamelCase segments (`snapshot.refresh`), at most 128 bytes, unique within the session; a session holds at most 256 functions | +| Registration and withdrawal | After `initialize`, `lithe/function/register` and `unregister`; each request is all or nothing (`-32602`, `plugin.function.invalidRegistration`) | +| Invocation | The host sends `lithe/function/invoke` with top-level `functionID`, `operationID`, `context`, and `params` (a JSON object, default `{}`); Core assigns `operationID`, and progress notifications must echo it unchanged | +| Outcome | A result, or `plugin.function.failed` (the backend's message up to 1024 bytes, with `pluginErrorCode` from `error.data.code`), or `plugin.function.invalidResult` (no `result`, or over 1 MiB serialized); the host cancels and times out with `$/cancelRequest` | +| Progress | `lithe/function/progress {operationID, message?, percentage?}`; `message` is cut to 1024 bytes and `percentage` is a whole number from 0 to 100 | +| No loops | A backend never asks the host to dispatch a domain operation or a function: a request for `lithe/function/invoke` or any `lithe/build/*` method is refused with `plugin.function.recursiveCall`. Cross-plugin calls are a different request; see below | +| Private functions | They need no manifest field, and adding or removing them changes no version; only a function another plugin may call, and a call to it, is declared in `pluginFunctions` | +| Not answering the protocol | A backend that does not answer `litheFunctions` (a build-only backend, for example) keeps serving its language and its `litheBuild` requests and receives no function calls; its function requests get `-32601` | + +### Host services + +A backend sends requests to the host with the prefix `lithe/host/`. Each service is granted by one manifest permission, and Core checks every request before the host sees it. The full methods, permissions, and limits are in the [API catalog](../api-catalog.md#public-host-methods). + +| Service | Methods | Permission | +| --- | --- | --- | +| Capability discovery | `getCapabilities`, `subscriptions/dispose` | Every plugin session has it | +| Credentials | `credentials/get`, `set`, `delete` | `credentials.store` | +| Workspace contexts | `workspace/listContexts`, `getContext`, `getFolders` | `workspace.metadata` | +| Files and search | `workspace/readDirectory`, `stat`, `readFile`, `watch`, `findFiles`, `findText` | `workspace.files.read`, `workspace.files.watch`, `workspace.search` | +| Documents and editors | `documents/*`, `editor/*`; see [Editor](editor.md) | `documents.read`, `editor.edit`, `editor.open`, `editor.actions`, `editor.providers` | +| Preview, browser, window messages | `preview/openHTML`, `browser/openExternal`, `window/*` | `preview.open`, `browser.open`, `window.messages` | +| Native terminals | `terminal/*` | `terminal.create` | +| Run and debug sessions | `run/start`, `run/stop`, `debug/start` | `run.start`, `debug.start` | +| Language servers | `languageServer/request`, `notify`, `setStatus` | `languageServer.request` | +| Content providers | `documents/registerContentProvider` | `documents.contentProvider` | +| Cross-plugin calls | `functions/providers`, `functions/invoke` | No manifest permission; see below | + +- A session holds at most 16 unanswered host requests; a further one is refused with `plugin.host.failed`. Core refuses in this order: a method the protocol does not define (`-32601`, `plugin.host.unavailable`), a service the plugin is not granted (`-32010`, `plugin.host.permissionDenied`), and malformed params (`-32602`, `plugin.host.invalidParams`); the code is in `error.data.code`. A failure of the host itself is `-32603`, `plugin.host.failed`. +- A later host adds a service, a method, a notification, an optional request field, a field of a result or of `limits`, or a failure code without a new version; only a change an existing backend would misread gives `backend` version `1`, and the protocol moves with it. +- To work with every host, a backend follows four rules: it calls a method only when `getCapabilities.services` lists it (another is `-32601`, `plugin.host.unavailable`); it ignores result, notification, `details`, and `limits` fields it does not know, and any `lithe/host/*` notification it does not know; Core refuses a field the method does not define with `-32018`, `plugin.host.unsupportedOption`, and `details.option` names it, so the backend may retry without that option; and it treats a JSON-RPC number it does not know as a host failure, branching on `error.data.code` when it knows that code. +- A backend that needs a new service declares the new permission; a host that does not know the permission refuses the package at install (`plugin.permission.unknown`), so a permission is never granted or ignored silently. +- Host notifications (the backend does not reply): `workspace/didChangeContexts`, `subscriptions/notify`, `commands/didRun`, `run/didExit`, `languageServer/didChangeState`, and `languageServer/didReceiveNotification`; the grants are in the API catalog. Backend commands (a `ui` command that opens no tool window) are events: a running backend receives `commands/didRun` in order, and a stopped one is started for it only when it declares `onCommand:`. + +### Cross-plugin calls + +A plugin function is private to its plugin unless the plugin exports it. An exported function can be called, through Rust Core, by another enabled plugin whose manifest declares that call: the calling backend never reaches the provider's process, the provider's backend never learns more than the call it serves, and the host takes part only by starting a provider's backend that is not running. The relay is Core logic, the same on every host that runs plugin backends. + +#### Manifest declarations + +| Item | Rule | +| --- | --- | +| Prerequisites | A `backend`, and `requires` with `pluginFunctions: 0` and `backend: 0`; `requires` lists `pluginFunctions` exactly when the manifest has a `pluginFunctions` section | +| Section | At least one of `exports` and `calls`, each at most 32 entries, sorted by `id`, no `id` twice; any other field is `plugin.manifest.invalid` | +| Export | `{id, version, context}`. `id` is a function ID; `version` is the exported contract version (an integer from 1 to 1000), and an incompatible change to the params or result takes a new version; `context` has one value for now, `"workspace"`, meaning the function runs only for a local workspace context; a missing or unknown `context` refuses the whole manifest | +| Call | `{id, version}`. It names a function ID and the version the caller was written against, never the providing plugin's ID: the caller discovers providers at run time; the two versions must be equal, otherwise `plugin.function.versionMismatch` | +| Registration | Declaring an export does not register it: the provider's backend still calls `lithe/function/register`, and a function it has not registered, or has withdrawn, is `plugin.function.notExported`. A function the manifest does not export stays private, and a reference to it is still `plugin.function.wrongPlugin` | +| Host support | The host grants a plugin that declares `calls` the `functions` host service; a host without `pluginFunctions` in `hostCapabilities` refuses such a manifest (`plugin.api.unsupported`) | + +#### Host methods + +Only a backend whose plugin declares at least one call may use them; a plugin that declares no call gets `-32010`, `plugin.host.permissionDenied`. `getCapabilities` lists both methods under the `functions` service. + +| Method (prefix `lithe/host/`) | Params | Result | +| --- | --- | --- | +| `functions/providers` | `{functionID}` | `{providers: [{pluginID, version}]}` | +| `functions/invoke` | `{pluginID, functionID, context: {contextID, generation}, params?, timeoutMilliseconds?, parentOperationID?}` | `{value}` | + +- `providers` lists the enabled plugins other than the caller that export the function, sorted by plugin ID, each with its exported `version`. The caller must have declared the function, otherwise `plugin.function.notDeclared`. A provider's backend need not be running. +- `invoke` calls one provider. `context` is required: a local workspace context the caller learned from the workspace service. `params` is a JSON object and defaults to `{}`. `timeoutMilliseconds` is a positive integer, defaults to 60000, and is capped at 600000. A field the method does not define is `-32018`, `plugin.host.unsupportedOption`. `value` is the provider's result, and `null` is a result. +- The caller cancels a call with `$/cancelRequest`; it fails with `plugin.host.cancelled`. +- Core checks in a fixed order (the caller declared the function, the provider is enabled, the provider exports it, the versions are equal, the context, a local workspace, the params size, the call it belongs to, the call chain, the depth, the concurrency) and refuses before anything is written to the provider; the first failing check decides the code. + +#### What the provider receives + +Core forwards a relayed call to the provider as `lithe/function/invoke`. As for any function call, the request has top-level `functionID`, `operationID`, `context`, and `params`; a relayed call adds one more top-level field, `caller: {pluginID}`. + +- Core writes `caller.pluginID` from the identity of the calling session. It sits outside `params` and the caller cannot supply it: a `functions/invoke` request carrying a top-level `caller` is refused with `-32018`. A function that decides by who asks, or keeps per-caller state, reads this field, never a plugin ID in `params`. A key named `caller` inside the caller's own `params` is ordinary data and is passed unchanged. +- A call the host itself started has no `caller`; absent means the host. +- `operationID` identifies the call being served. A provider that makes a nested `functions/invoke` while serving a relayed call sets `parentOperationID` to this `operationID`, so Core knows which call the new one belongs to. `parentOperationID` is a string of 1 to 128 bytes, otherwise `-32602`, `plugin.host.invalidParams`. Without it, Core treats the new call as a child of every relayed call the backend is serving, so a backend should always set it. +- The call chain: the chain of a new call is the chain of its parent plus the caller. A call to a plugin already on the chain, including a plugin calling itself, is `plugin.function.callCycle`, and a chain has at most 4 calls. Cancelling, timing out, or ending the parent call cancels its children, and each provider receives `$/cancelRequest`. If `parentOperationID` names a call the host started directly, the new call starts a new chain; if it names a call this backend is not serving (finished, or never existed), the call is refused with `plugin.host.cancelled`. +- If the provider's backend is not running, Core waits for it and emits the runtime event `functionProviderStartRequired` on the calling session; the host starts the backend as an ordinary plugin start. If the provider has not negotiated the protocol and registered the function within 30 seconds, the call ends with `plugin.function.providerUnavailable`. A host that cannot start the backend ignores the event, and the call ends the same way. +- Core writes to each backend session through a queue of its own: a backend that stops reading its standard input only delays the calls and replies sent to it, and the relayed calls of other plugins continue. If a write to a backend stays blocked past the request timeout, Core ends that backend's session as a transport failure (`transportFailed`, stage `relayWrite`). + +#### Trust is not transitive + +- The provider runs with its own permissions, state directory, and credentials. The caller gains none of the provider's, and the provider gains none of the caller's. The caller's plugin ID appears only in the `caller` field, never in the provider's session. +- An exported function must not accept an executable path, command line, or script from its caller and start a process with it, nor read or write a path the caller chose without checking it against what the provider itself may access. If a caller needs the provider to run something, the function takes a request the provider interprets (a task name, a document) and the provider decides the executable. +- This is not an install dependency: installing the caller does not install a provider, and disabling a provider only leaves calls without one; it takes no part in the install closure and does no version solving. A call whose context is a remote workspace is refused with `plugin.host.authorityUnsupported`; the exported `context: "workspace"` means a local workspace only, and backends still run on the machine that runs Lithe. +- Disabling, enabling, replacing, and uninstalling a plugin take effect for the relay at once, with no restart; the host sends the snapshot of plugins and workspace contexts with the Core command `lsp.configureFunctionRelay`. On a host that registers backend plugins only when it launches, a newly installed or updated backend plugin appears in `providers` only after Lithe restarts. + +#### Error codes + +| Code | When | +| --- | --- | +| `plugin.function.notDeclared` | The caller did not declare the function; also when a caller update stops declaring it while the call runs | +| `plugin.function.providerDisabled` | The provider is not installed or not enabled; also when, while the call runs, the provider is disabled or uninstalled, or its update stops exporting the function or changes its version | +| `plugin.function.notExported` | The provider's backend has not registered the function, or has withdrawn it | +| `plugin.function.versionMismatch` | The version the caller declared differs from the version the provider exports | +| `plugin.function.callCycle` | The provider is already on the call chain, including the caller itself | +| `plugin.function.depthExceeded` | The chain would have more than 4 calls | +| `plugin.function.tooManyCalls` | The caller already has 8 relayed calls running | +| `plugin.function.payloadTooLarge` | The params are over 256 KiB, or the provider's result is over 256 KiB (over 1 MiB is `invalidResult`) | +| `plugin.function.providerUnavailable` | The provider's backend was not ready within 30 seconds, or it exited or withdrew the function while the call ran | + +A provider's own failure keeps its meaning: when its function answered with an error, the code is `plugin.function.failed` (`-32070`), the message is cut to 1024 bytes, and `details.pluginErrorCode` is the provider's own `error.data.code`; a reply with no `result`, or a result over 1 MiB serialized, is `plugin.function.invalidResult` (`-32071`). Neither number is `-32603`, which stays `plugin.host.failed`, a failure inside Core. A workspace context that is closed or replaced by a newer generation is `plugin.host.contextExpired`, and the deadline passing is `plugin.host.timeout`; if the provider has still not answered 2 seconds after the deadline, the relay ends the call with `plugin.host.timeout` itself and cancels the provider's operation. Core never restarts a backend or retries a call. + +### Run and debug sessions + +A backend can start a run or a debug session that the host presents as its own: the backend decides what to start, and the host owns the process, the console, the Stop button, and the end of the session. A Run session adds host presentation (a Run window entry, the console, the exit status) and grants no other capability. `run/start` and `debug/start` take `context: {contextID, generation}` and the permission shown; `run/stop` takes no `context`, so a run can still be stopped after the context it started in has closed. + +| Method (prefix `lithe/host/`) | Permission | Params | Result | +| --- | --- | --- | --- | +| `run/start` | `run.start` | `context, label, process, temporaryFiles?, debug?, rerun?` | `{runID}` | +| `run/stop` | `run.start` | `{runID}` or `{sessionID}` | `null` | +| `debug/start` | `debug.start` | `context, label, adapter, request, configuration` | `{sessionID}`; see [Debug adapters](debug-adapters.md#starting-a-debug-session-from-a-plugin-backend) | + +Notification (the backend does not reply): `lithe/host/run/didExit` with `{runID, exitCode?, signal?}`, granted by `run.start`. `run.start` grants `run/start`, `run/stop`, and `run/didExit`. + +`run/start` parameters: + +- `label`: 1 to 256 characters without control characters; it titles the Run window entry. +- `process`: `{executable, arguments, workingDirectory, environment}`, all four required; an unknown field is refused. `executable` is `{kind: "tool", tool}` (`tool` is a tool-definition ID that the manifest lists in `requiresTools`, which the host resolves through the layers described in [Toolchains](toolchains.md)) or `{kind: "packaged", path}` (a normalized relative path of a file inside the plugin package). Arguments, the working directory (relative to the workspace root and an existing directory inside it), and the environment have the bounds of [run preparation](../../contract/v1/README.md#provider-run-preparation). +- `temporaryFiles`: at most four `{name, contents}` files of up to 4 MiB of text each. The host writes them into its own temporary directory, removes them when the run ends, and replaces `${temporaryFile:}` in `process.arguments` and `process.environment` with the file's path, so the plugin never needs to know where the host keeps temporary files. A `name` is unique, 1 to 64 letters, digits, `.`, `_`, or `-`, and starts with a letter or digit. +- `debug`: `{configurationType, target}`, which makes the run a debug session; see [Debug adapters](debug-adapters.md#starting-a-debug-session-from-a-plugin-backend). `configurationType` is a public debug adapter alias; `target` is `{kind: "connection", port}` (the process listens for a debugger on `port`, 1 to 65535, which the backend puts into the arguments; the host waits for it and attaches the adapter) or `{kind: "launch"}` (the adapter starts the process itself, taking program, arguments, directory, and environment from `process` through its `targetBindings`). +- `rerun`: `{function, data}`, one of the plugin's registered functions and its params (a JSON object of up to 64 KiB). When the user chooses Run again, the host invokes the function with `data` as the params and the current `context`, and the backend answers with a new `run/start`, so the backend prepares the restart; without `rerun` the entry cannot be rerun. + +`debug/start` parameters: + +- `label` is as in `run/start`. `adapter` is one of the plugin's own adapters, by its local ID or its qualified `/`; an adapter of another plugin, and one the plugin does not declare, are both `plugin.host.notFound`. +- `request` is `launch` or `attach`, and the adapter must serve it. +- `configuration` is a JSON object of at most 256 KiB that the host passes to the adapter as the DAP launch or attach arguments, on top of the adapter's declared defaults; `noDebug` and every key that starts with `__` belong to the host and are refused. + +Other rules: + +- `run/stop` stops a run by `runID` or a debug session by `sessionID` and answers `null`, also when it already ended; cancelling the host request is the same as stopping. A backend can stop only runs and sessions it started. +- The host sends `run/didExit` when the process ends, once per run and only to a session that holds the `run.start` grant. `exitCode` is the exit status and `signal` (1 to 127) the signal that ended it; both are absent when the host lost the process. +- Malformed params are `plugin.host.invalidParams`, and Core's rejection (`plugin.run.invalidStart` or `plugin.debug.invalidStart`) is in `details.code` and `details.field`; a closed context is `plugin.host.contextExpired`; a `tool` that resolves to no executable is `plugin.host.toolUnresolved`. +- The host keeps running processes when the plugin is disabled or its backend exits; it only stops telling the backend. A backend that starts again does not receive `run/didExit` for a run it did not see begin. +- To make output in the Run console clickable, declare matchers with `outputLinkMatchers`: Core does the matching and the `resolve` function is called only when the user clicks; see [Output link matchers](../../contract/v1/README.md#output-link-matchers). + +### Contract sections + +| Section | Content | +| --- | --- | +| [Plugin backend](../../contract/v1/README.md#plugin-backend) | Declaration, state directory, when it starts and stops, per-platform archives | +| [Backend lifecycle and workspace contexts](../../contract/v1/README.md#backend-lifecycle-and-workspace-contexts) | Start routes, `onWorkspaceOpen` never blocks the workspace, `workspace.metadata` | +| [Plugin functions](../../contract/v1/README.md#plugin-functions) | Protocol overview | +| [Capability ownership](../../contract/v1/README.md#capability-ownership) | Plugin functions never replace a domain contract | +| [Declarative or backend implementation](../../contract/v1/README.md#declarative-or-backend-implementation) | When to use declarative data and when to write a backend | +| [Negotiation](../../contract/v1/README.md#negotiation) | Version negotiation in `initialize` | +| [References and IDs](../../contract/v1/README.md#references-and-ids) | Function references and ID rules | +| [Registration and withdrawal](../../contract/v1/README.md#registration-and-withdrawal) | Registering and withdrawing functions | +| [Invocation](../../contract/v1/README.md#invocation), [Progress](../../contract/v1/README.md#progress) | Calls, results, progress, cancellation | +| [Host services](../../contract/v1/README.md#host-services) | Credentials service | +| [Workspace contexts and capability discovery](../../contract/v1/README.md#workspace-contexts-and-capability-discovery) | `getCapabilities`, `contextID`/`generation` contexts, host failure codes | +| [Host notifications](../../contract/v1/README.md#host-notifications) | `workspace/didChangeContexts`, `subscriptions/notify`, `commands/didRun`, `run/didExit`, language server notifications | +| [Extending the host services](../../contract/v1/README.md#extending-the-host-services) | How services gain methods without a new version, and the four rules for a backend | +| [Public document, file and window methods](../../contract/v1/README.md#public-document-file-and-window-methods) | File reads, watches, search, and window messages (document and editor methods are on [Editor](editor.md)) | +| [Plugin-owned native terminals](../../contract/v1/README.md#plugin-owned-native-terminals), [Close, retained context and reopen](../../contract/v1/README.md#close-retained-context-and-reopen) | Plugin-owned native terminals | +| [Run and debug sessions](../../contract/v1/README.md#run-and-debug-sessions) | `run/start`, `run/stop`, `debug/start`, `run/didExit`: params, limits, failure codes | +| [Language server host methods](../../contract/v1/README.md#language-server-host-methods) | A backend talking to its own plugin's language servers | +| [Content providers](../../contract/v1/README.md#content-providers) | Binding a URI scheme to a backend function | +| [Backend-only commands](../../contract/v1/README.md#backend-only-commands) | Commands that open no tool window | +| [Cross-plugin calls](../../contract/v1/README.md#cross-plugin-calls) | The `pluginFunctions` manifest declarations, `functions/providers`, `functions/invoke`, the check order, the call chain, the `caller` field, providers that are not running, hot activation | +| [Output link matchers](../../contract/v1/README.md#output-link-matchers) | Links in Run and Test console output; the `resolve` function is called on click | +| [Structured plugin diagnostics](../../contract/v1/README.md#structured-plugin-diagnostics) | `lithe/log` fields and limits | +| [Compatibility](../../contract/v1/README.md#compatibility), [Official backend SDK](../../contract/v1/README.md#official-backend-sdk) | Compatibility rules and the official SDKs | +| [Outcomes and error codes](../../contract/v1/README.md#outcomes-and-error-codes) | Outcomes and error codes | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`backend/backend.plugin.json`](../../contract/v1/fixtures/backend/backend.plugin.json) | A web tool window calls the backend, which uses the credentials service | +| [`backend/backend-workspace.plugin.json`](../../contract/v1/fixtures/backend/backend-workspace.plugin.json) | Starts on `onWorkspaceOpen`, uses `workspace.metadata` | +| [`backend/command-backend.plugin.json`](../../contract/v1/fixtures/backend/command-backend.plugin.json) | A backend with `ui`: a command without a tool window that reads and edits documents | +| [`backend/terminal-backend.plugin.json`](../../contract/v1/fixtures/backend/terminal-backend.plugin.json) | The smallest manifest for plugin-owned native terminals | +| [`backend/function-protocol.json`](../../contract/v1/fixtures/backend/function-protocol.json) | Canonical plugin-function messages: negotiation, registration, invocation, results, progress, host requests (`hostRequests`), call contexts (`invocationContexts`), the `caller` of a relayed call (`invocationCallers`), host failure codes (`hostFailures`), relay failure codes (`relayErrors`), and host notifications (`hostNotifications`) | +| [`backend/plugin-functions-exporter.plugin.json`](../../contract/v1/fixtures/backend/plugin-functions-exporter.plugin.json) | Only exports two functions (`document.format` version 1, `symbols.list` version 2, `context` `workspace`), with no page, command, or `onWorkspaceOpen` | +| [`backend/plugin-functions-caller.plugin.json`](../../contract/v1/fixtures/backend/plugin-functions-caller.plugin.json) | Declares a call to `document.format` version 1, starts on `onWorkspaceOpen` | +| [`backend/plugin-logging.json`](../../contract/v1/fixtures/backend/plugin-logging.json) | `litheLogging`: offer and answer, notifications, rejections, levels | +| [`run/run-start.json`](../../contract/v1/fixtures/run/run-start.json) | The params of `run/start` and what `run.decodeStart` returns for each case (tool and packaged executables, `debug` and `rerun`, the refusals); temporary files depend on the host's temporary directory and are covered by Core's own tests | +| [`run/tool-reference-launch.plugin.json`](../../contract/v1/fixtures/run/tool-reference-launch.plugin.json) | The manifest `run-start.json` uses: `requiresTools` lists the tool `interpreter`, and it declares `run.start`, `debug.start`, and `outputLinkMatchers` | +| [`run/output-link-matchers.json`](../../contract/v1/fixtures/run/output-link-matchers.json) | The requests of `outputLinks.match` and the links it returns, plus what `outputLinks.decodeResolution` makes of a matcher's `resolve` reply | +| [`debug-adapters/debug-start.json`](../../contract/v1/fixtures/debug-adapters/debug-start.json) | What `debug.decodeBackendConfiguration` returns for each `debug/start` case, including the refusals | + +Backend rejections are in [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json). + +### Related guides + +- [Plugin backend: workspace contexts and host services](../../guides/plugin-backend-workspace.md) +- [Open a native Lithe terminal from a plugin](../../guides/native-terminals.md) +- [Plugin logging](../../guides/plugin-logging.md) diff --git a/shared/plugins/reference/domains/build-providers.md b/shared/plugins/reference/domains/build-providers.md new file mode 100644 index 00000000..906f429f --- /dev/null +++ b/shared/plugins/reference/domains/build-providers.md @@ -0,0 +1,239 @@ +# 构建提供者(litheBuild) / Build providers (litheBuild) + +[中文](#中文) · [English](#english) + +## 中文 + +有些构建工具只能通过自己的 API 给出项目模型(Gradle Tooling API、BEAM 里的 Mix)。这类插件在 `buildSystems[].provider` 声明一个构建提供者:一个和语言服务器同类的长驻 LSP 基础协议进程,回答 `lithe/build/*` 请求。模型、依赖、任务、测试、运行准备、调试准备、原生配置都可以交给它;宿主仍然负责启动和停止进程、显示界面、运行最终的程序。 + +> **宿主支持。** Lithe 桌面端声明 `buildSystems` 和 `buildProviders`(版本都是 `0`),不声明 `buildModelInputs`:`requires` 里有它的插件(只读模型输入的生产者和消费者)不会被提供安装。构建窗口发现构建实例、加载提供者模型、解析可执行文件候选并运行命令任务和提供者任务。 +> 构建提供者的运行准备、测试、调试准备、模型输入、原生配置、内联测试报告和运行时调试,桌面端尚未呈现;`Declared()` 上方的同一段注释还写到构建窗口会准备应用目标,具体以该注释为准。 +> 宿主只呈现它实现的功能,提供者不得依赖宿主一定会调用其中任何一个。说明见 [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go);各能力的对照见[能力与协议版本对照](../capabilities-and-versions.md#能力版本requires)。 +> 对契约有疑问,请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `buildSystems[].provider`,以及各声明里的 `source: "provider"` | +| 能力版本 | `buildProviders` `0`;任何 `buildSystems[].provider` 都要同时有 `buildSystems: 0` 和 `buildProviders: 0`,不论它自己启动进程还是共用语言服务器;只用模板的构建插件不写 `buildProviders`。`requires` 必须恰好列出清单用到的能力;启动进程是基础契约,不是能力,不写 `requires.serverProcess`。见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | `litheBuild` `0`:宿主在 `initialize` 里提供 `capabilities.experimental.litheBuild: {"versions": [0]}`,提供者回答 `{"version": 0}`。协议没有自己的版本,就是 `buildProviders` 的版本,见[进程协议版本](../capabilities-and-versions.md#进程协议版本) | +| API 编号 | [API-29](../api-catalog.md#现在能用的-api):构建、任务、测试、运行准备 | +| 权限 | `process.build`;自有进程的提供者加 `languageServer.launch`;原生调试准备加 `debug.launch` | +| 激活事件 | `onBuildSystem:`;提供者在第一次由用户操作触发的构建请求时启动 | +| 官方 SDK | [Node.js 0.5.0](../../sdk/node/README.md) 和 [Java 0.5.0](../../sdk/java/README.md) 有 `litheBuild` 的类型化处理器:模型、依赖、任务执行、测试执行、测试发现、运行准备、调试准备;原生配置(`lithe/build/configuration`、`editConfiguration`)没有类型化处理器。[Rust 0.4.0](../../sdk/rust/README.md) 没有构建提供者接口。发布标签 `sdk-node-v0.5.0`、`sdk-java-v0.5.0`、`sdk-rust-v0.4.0`,见[官方 SDK](../capabilities-and-versions.md#官方-sdk) | + +### 两种形式 + +用 `languageServer` 键区分两种形式,混用是 `plugin.manifest.invalid`。声明了 `provider` 却没有任何来源使用它,或者来源是 `provider` 却没有声明它,也是 `plugin.manifest.invalid`。 + +| 形式 | 字段 | 说明 | +| --- | --- | --- | +| 共享 | `{"languageServer": "", "executesProjectCode", "mayUseNetwork"}` | 同一插件里必须有该语言的语言服务器,请求发给它的会话;`requires` 里还要有 `languageServers: 0` 和 `languages: 0`。共享形式不能用原生配置 | +| 自有进程 | 语言服务器的启动字段(`launch`、`arguments`、`validationArguments`、`runtimeDependencies`、`initializationOptions`)加 `executesProjectCode`、`mayUseNetwork` | 校验、启动模板、设置和安装审查与语言服务器相同,需要 `languageServer.launch` 权限;审查里列为 `packagedBuildProvider`、`systemBuildProvider` 或 `runtimeBuildProvider`,并带 `buildSystemID` 和两个标志 | + +检测从不启动提供者:来源为 `provider` 的实例只报告 `modelSource: "provider"`,没有模型。宿主按 `buildSystemID` 用 `plugins.declarative.languageServerLaunchPlan` 启动进程。 + +### 提供者能做什么 + +所有功能都属于 `buildProviders` `0`,用同一个 `litheBuild` 协议。一次会话里能用到哪些,取决于清单声明了哪个来源,以及宿主实现了哪些。 + +| 功能 | 清单里的声明 | 方法 | 契约章节 | 示例(在 `fixtures/build-providers/`,另有说明的除外) | +| --- | --- | --- | --- | --- | +| 项目模型和依赖 | `model.source`、`dependencies.source` 为 `provider` | `lithe/build/model`、`lithe/build/dependencies` | [Build providers](../../contract/v1/README.md#build-providers) | `build-provider`、`build-provider-language-server` | +| 任务执行 | `tasks[].source: "provider"`,`arguments` 必须是 `[]` | `lithe/build/execute`、`lithe/build/progress` | [Provider task execution](../../contract/v1/README.md#provider-task-execution) | `build-provider-execution`、`build-execution-protocol.json` | +| 测试执行 | `test.source: "provider"`,`arguments` 必须是 `[]`,不能用 `hostIntegration` 报告 | `lithe/build/test` | [Provider test execution](../../contract/v1/README.md#provider-test-execution) | `build-provider-tests`、`build-test-protocol.json` | +| 测试发现 | provider 测试声明 `discovery: true`,同时需要 `suite` 和 `case` 过滤器 | `lithe/build/discoverTests` | [Test discovery](../../contract/v1/README.md#test-discovery) | `build-test-discovery.json` | +| 运行准备 | `run[].source: "provider"`,用 `fromTargets.tags`;`execution` 为 `application`(默认)、`service` 或 `task` | `lithe/build/prepareRun` | [Provider run preparation](../../contract/v1/README.md#provider-run-preparation) | `build-provider-run-preparation`、`build-run-preparation.json` | +| 类型化运行选项 | 服务和任务的准备带 `launchOptions`(`execution`、`runtimeArguments`、`arguments`、`environment`,可选 `entrypoint`、`workingDirectory`) | 扩展 `lithe/build/prepareRun` | [Typed run options and service/task preparation](../../contract/v1/README.md#typed-run-options-and-servicetask-preparation) | `build-provider-run-options`、`build-run-options.json` | +| 注册表连接调试 | 服务运行声明 `debug: {"configurationType": "…"}`,由宿主选择已启用、声明了该类型且支持 `attach` 的调试适配器 | 沿用 `lithe/build/prepareRun` | [Registry connection debugging](../../contract/v1/README.md#registry-connection-debugging) | `build-connection-debug-options.json`(没有单独的清单示例) | +| 原生调试准备 | 运行声明 `debug: {"adapter": "native"}`;`requires` 要有 `debugAdapters: 0`,并申请 `debug.launch`;适配器必须是本插件自己声明的 | `lithe/build/prepareDebug` | [Native debug preparation](../../contract/v1/README.md#native-debug-preparation) | `build-provider-debug-preparation`、`build-debug-preparation.json` | +| 只读模型输入 | 消费者声明 `modelInputs`(最多 8 个槽),`requires` 要有 `buildModelInputs: 0` 和 `buildProviders: 0`;生产者声明 `modelSharing` | 沿用模型、依赖和各计划请求,快照在 `params.modelInputs` | [Optional read-only model inputs](../../contract/v1/README.md#optional-read-only-model-inputs) | `build-systems/build-model-producer`、`build-systems/build-model-consumer` | +| 以来源为准的原生配置 | `configuration: {"source": "provider"}`,只限自有进程;提供者在服务器特性里声明 `litheBuildConfiguration: true` | `lithe/build/configuration`、`lithe/build/editConfiguration` | [Optional native configuration](../../contract/v1/README.md#optional-native-configuration) | `build-provider-configuration`、`build-configuration.json` | +| 内联测试报告 | `results: {"decoder": "provider"}`,回复里带 `testResults` | 沿用 `lithe/build/test`、`execute` | [Inline provider test reports](../../contract/v1/README.md#inline-provider-test-reports) | `build-provider-reports`、`build-test-durations.json`(重复用例耗时求和) | +| 不透明的语义测试选择器 | provider 测试声明 `selectorKinds`(最多 16 个命名空间),请求带 `selectorKind` 和 `selectors` | 沿用 `lithe/build/test` | [Opaque semantic test selectors](../../contract/v1/README.md#opaque-semantic-test-selectors) | `build-provider-selectors` | +| 声明式项目准备 | 项目任务的 `preparation: {purpose, sourceRoles}`;属于 `buildSystems`,提供者任务经 `lithe/build/execute` 运行 | 沿用 `lithe/build/execute` | [Declarative project preparation](../../contract/v1/README.md#declarative-project-preparation) | `build-provider-preparation` | + +- 提供者的结果必须符合[模型与输出](../../contract/v1/README.md#model-and-outputs)里的格式;SDK 为模型、依赖和测试发现补齐 `format` 和 `version`,处理器返回别的格式或版本会失败。 +- 提供者必须拒绝它无法精确满足的选择器、过滤器和陈旧的项目事实,不能放宽范围去猜;任务和测试的非零退出码是已完成的工具失败,不是协议错误,畸形回复是 `plugin.build.invalidResult`,JSON-RPC 错误是 `plugin.build.failed`。 +- 提供者永远不回退到宿主命令或插件私有函数;没有协商成功时,构建方法在进入处理器之前被拒绝。 + +### 协商与隔离 + +- 宿主发 `capabilities.experimental.litheBuild: {"versions": [0]}`,提供者回答自己选的 `{"version": 0}`。提供者没有回答版本,或回答了宿主没给出的版本,就不用于构建请求;共享形式的提供者仍然服务它的语言。双方都忽略这些对象和请求参数、结果里不认识的字段,所以任何一方都可以增加可选数据;只有对方不能忽略的改动,才会给 `buildProviders` 版本 `1`,协议随之变化。 +- 一个插件、一个构建系统、一个工作区一个进程。两个插件从不共享进程,宿主也从不在进程之间转发消息;每个进程只经自己的标准输入输出与宿主说 JSON-RPC。共享形式只复用同一插件的语言服务器会话。 +- 提供者在第一次构建请求时启动(只有用户操作才会发出),在工作区关闭、插件停用,或者包被替换、回滚、卸载之前停止。插件设置改变后,下一次请求重启它,所以启动参数总是反映当前值;退出的提供者由下一次请求重新启动。 +- 安装时拒绝这样的包:提供者的启动指向不存在的文件、包外路径或经符号链接的路径,或者打包的可执行文件和运行时不是常规可执行文件。 +- 可写状态在插件为该构建系统准备的状态目录里,不在插件包或宿主安装目录里。 +- 请求每次都带 `buildSystemID`、`root`、`options`、`optionArguments` 和 `environment`;`environment` 是所选运行时或工具的环境与选项贡献的合并(选项优先),宿主不转发它的完整环境,提供者对每次调用和缓存标识都用它。`refresh: true` 表示用户要求重新加载,提供者必须重新计算而不是从自己的缓存回答。可选的 `tool: {executable}` 是宿主按构建系统的 `invocation` 解析出的构建工具的绝对路径,未给出时由提供者自己查找;明确指定的工具链或路径解析失败时,请求不会发出。 + +### 限制 + +| 项目 | 上限 | +| --- | --- | +| 进度 `message` | 4 KiB UTF-8 | +| 进度 `output` | 每条通知 64 KiB UTF-8 | +| 任务和测试的最终 `output` | 32 MiB UTF-8;`exitCode` 是有符号 32 位整数 | +| 启动结果 | 参数最多 1,024 项、每项 64 KiB;环境最多 64 项(键 `[A-Za-z_][A-Za-z0-9_]*` 至多 128 字节,值至多 64 KiB);整个 JSON 至多 32 MiB | +| 测试发现 | 最多 10,000 项,32 MiB UTF-8 JSON | +| 内联测试报告 | 最多 10,000 个用例,8 MiB UTF-8 JSON | +| 语义选择器 | `selectorKinds` 最多 16 个(每个 120 字节);每次请求最多 64 个选择器,每个 4096 字节 | +| 原生配置 | 最多 8 个来源,每个来源最多 64 个字段 | +| 模型输入 | 最多 8 个槽,每个来源 64 个实例,每个实例 2,000 个项目和 10,000 个目标或内容根,解析后的 JSON 8 MiB | + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Build providers](../../contract/v1/README.md#build-providers) | 两种形式、请求参数、`initialize` 协商、进程隔离和生命周期 | +| [Provider task execution](../../contract/v1/README.md#provider-task-execution) | 任务执行、进度、取消 | +| [Provider test execution](../../contract/v1/README.md#provider-test-execution)、[Test discovery](../../contract/v1/README.md#test-discovery) | 测试执行、报告、测试发现 | +| [Provider run preparation](../../contract/v1/README.md#provider-run-preparation) | 运行准备、可移植启动计划 | +| [Typed run options and service/task preparation](../../contract/v1/README.md#typed-run-options-and-servicetask-preparation) | `launchOptions`、服务与任务准备 | +| [Registry connection debugging](../../contract/v1/README.md#registry-connection-debugging) | 经注册表选择 attach 适配器的连接调试 | +| [Native debug preparation](../../contract/v1/README.md#native-debug-preparation) | 原生调试准备和适配器绑定 | +| [Optional native configuration](../../contract/v1/README.md#optional-native-configuration) | 原生配置的读取和分阶段编辑 | +| [Optional read-only model inputs](../../contract/v1/README.md#optional-read-only-model-inputs) | 生产者与消费者之间的只读模型快照 | +| [Inline provider test reports](../../contract/v1/README.md#inline-provider-test-reports) | 回复里的 `litheTestResults` 报告 | +| [Opaque semantic test selectors](../../contract/v1/README.md#opaque-semantic-test-selectors) | 语义测试选择器 | +| [Declarative project preparation](../../contract/v1/README.md#declarative-project-preparation) | 运行语言启动之前的项目准备任务 | +| [Model and outputs](../../contract/v1/README.md#model-and-outputs) | 提供者结果要符合的模型、诊断、依赖格式 | + +### 文档格式 + +所有格式的版本都是 `0`,见[文档格式版本](../capabilities-and-versions.md#文档格式版本)。 + +| 格式 | 用在 | 示例 | +| --- | --- | --- | +| `litheBuildModel` | 模型结果 | [`build-plans.json`](../../contract/v1/fixtures/build-systems/build-plans.json) | +| `litheDependencies` | 依赖结果 | 契约正文,见 [Model and outputs](../../contract/v1/README.md#model-and-outputs) | +| `litheDiagnostics` | 输出行里的诊断 | 契约正文,见 [Model and outputs](../../contract/v1/README.md#model-and-outputs) | +| `litheTestResults` | 测试报告、内联报告 | 契约正文,见 [Inline provider test reports](../../contract/v1/README.md#inline-provider-test-reports);重复用例的耗时求和规则见 [`build-test-durations.json`](../../contract/v1/fixtures/build-providers/build-test-durations.json) | +| `litheTestDiscovery` | 测试发现 | [`build-test-discovery.json`](../../contract/v1/fixtures/build-providers/build-test-discovery.json) | +| `litheBuildConfiguration` | 原生配置的事实 | [`build-configuration.json`](../../contract/v1/fixtures/build-providers/build-configuration.json) | +| `litheConfigurationEdit` | 原生配置编辑的回执 | 契约正文,见 [Optional native configuration](../../contract/v1/README.md#optional-native-configuration) | +| `modelInputs` 快照 | 只读模型输入 | [`build-model-consumer.plugin.json`](../../contract/v1/fixtures/build-systems/build-model-consumer.plugin.json) | +| `launchOptions` 对象 | 服务、任务和连接调试的运行选项;示例文件外层标为 `format: "litheBuildRunOptions"`、`version: 0` | [`build-run-options.json`](../../contract/v1/fixtures/build-providers/build-run-options.json)、[`build-connection-debug-options.json`](../../contract/v1/fixtures/build-providers/build-connection-debug-options.json) | + +### 相关指南 + +- [原生构建、测试与运行准备](../../guides/native-build-workflows.md) +- [为语言插件接入构建、运行与测试](../../guides/build-tool-integration.md) +- [声明式构建系统](build-systems.md) + +--- + +## English + +Some build tools expose their project model only through their own API (the Gradle Tooling API, Mix inside the BEAM). Such a plugin declares a build provider in `buildSystems[].provider`: a long-lived LSP base-protocol process of the same kind as a language server, which answers `lithe/build/*` requests. Models, dependencies, tasks, tests, run preparation, debug preparation, and native configuration can all be handed to it. The host still starts and stops the process, draws the UI, and runs the final program. + +> **Host support.** The Lithe desktop declares `buildSystems` and `buildProviders` (both version `0`) and does not declare `buildModelInputs`: a plugin whose `requires` lists it (a producer or consumer of read-only model inputs) is not offered for install. The Build window detects build instances, loads provider models, resolves executable candidates, and runs command tasks and provider tasks. +> Build-provider run preparation, tests, debug preparation, model inputs, native configuration, inline test reports, and runtime debugging are not presented on the desktop yet; the same comment above `Declared()` also says the Build window prepares application targets, so read that comment for the current state. +> A host presents the features it implements, and a provider must not depend on a host calling any of them. See [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go); for a per-capability comparison see the [capability and protocol versions](../capabilities-and-versions.md#capability-versions-requires). +> Questions about the contract go to [Lithe-core-rust Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +| Item | Value | +| --- | --- | +| Manifest fields | `buildSystems[].provider`, and `source: "provider"` in individual declarations | +| Capability versions | `buildProviders` `0`; every `buildSystems[].provider` needs both `buildSystems: 0` and `buildProviders: 0`, whether it starts its own process or shares a language server; a template-only build plugin omits `buildProviders`. `requires` must list exactly the capabilities the manifest uses; process start is base contract, not a capability, so do not write `requires.serverProcess`. See [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | `litheBuild` `0`: the host offers `capabilities.experimental.litheBuild: {"versions": [0]}` in `initialize`, and the provider answers `{"version": 0}`. The protocol has no version of its own: it is the version of `buildProviders`; see [process protocol versions](../capabilities-and-versions.md#process-protocol-versions) | +| API IDs | [API-29](../api-catalog.md#apis-usable-now): build, tasks, tests, run preparation | +| Permissions | `process.build`; `languageServer.launch` for an own-process provider; `debug.launch` for native debug preparation | +| Activation events | `onBuildSystem:`; a provider starts on the first build request, which only a user action sends | +| Official SDKs | [Node.js 0.5.0](../../sdk/node/README.md) and [Java 0.5.0](../../sdk/java/README.md) have typed `litheBuild` handlers: model, dependencies, task execution, test execution, test discovery, run preparation, and debug preparation; native configuration (`lithe/build/configuration`, `editConfiguration`) has no typed handler. [Rust 0.4.0](../../sdk/rust/README.md) has no build-provider interface. Released as `sdk-node-v0.5.0`, `sdk-java-v0.5.0`, and `sdk-rust-v0.4.0`; see [official SDKs](../capabilities-and-versions.md#official-sdks) | + +### Two forms + +The `languageServer` key tells the two forms apart, and mixing them is `plugin.manifest.invalid`. A `provider` that no source uses, or a `provider` source without a provider, is `plugin.manifest.invalid` too. + +| Form | Fields | Notes | +| --- | --- | --- | +| Shared | `{"languageServer": "", "executesProjectCode", "mayUseNetwork"}` | The plugin must have a language server for that language, and requests go to its session; `requires` also needs `languageServers: 0` and `languages: 0`. The shared form cannot use native configuration | +| Own process | The language-server launch fields (`launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions`) plus `executesProjectCode` and `mayUseNetwork` | Validation, launch templates, settings, and the install review are those of a language server, and it needs the `languageServer.launch` permission; the review lists it as `packagedBuildProvider`, `systemBuildProvider`, or `runtimeBuildProvider` with its `buildSystemID` and the two flags | + +Detection never starts a provider: an instance with a `provider` source only reports `modelSource: "provider"`, with no model. The host starts the process with `plugins.declarative.languageServerLaunchPlan` and the `buildSystemID`. + +### What a provider can do + +Every feature belongs to `buildProviders` `0` and uses the same `litheBuild` protocol. Which of them a session uses depends on the sources the manifest declares and on what the host implements. + +| Feature | Declared in the manifest | Methods | Contract section | Example (in `fixtures/build-providers/` unless noted) | +| --- | --- | --- | --- | --- | +| Project model and dependencies | `model.source` and `dependencies.source` set to `provider` | `lithe/build/model`, `lithe/build/dependencies` | [Build providers](../../contract/v1/README.md#build-providers) | `build-provider`, `build-provider-language-server` | +| Task execution | `tasks[].source: "provider"`; `arguments` must be `[]` | `lithe/build/execute`, `lithe/build/progress` | [Provider task execution](../../contract/v1/README.md#provider-task-execution) | `build-provider-execution`, `build-execution-protocol.json` | +| Test execution | `test.source: "provider"`; `arguments` must be `[]`, and `hostIntegration` reports are refused | `lithe/build/test` | [Provider test execution](../../contract/v1/README.md#provider-test-execution) | `build-provider-tests`, `build-test-protocol.json` | +| Test discovery | A provider test declares `discovery: true`, which needs both `suite` and `case` filters | `lithe/build/discoverTests` | [Test discovery](../../contract/v1/README.md#test-discovery) | `build-test-discovery.json` | +| Run preparation | `run[].source: "provider"` with `fromTargets.tags`; `execution` is `application` (default), `service`, or `task` | `lithe/build/prepareRun` | [Provider run preparation](../../contract/v1/README.md#provider-run-preparation) | `build-provider-run-preparation`, `build-run-preparation.json` | +| Typed run options | Service and task preparation carry `launchOptions` (`execution`, `runtimeArguments`, `arguments`, `environment`, optional `entrypoint` and `workingDirectory`) | Extends `lithe/build/prepareRun` | [Typed run options and service/task preparation](../../contract/v1/README.md#typed-run-options-and-servicetask-preparation) | `build-provider-run-options`, `build-run-options.json` | +| Registry connection debugging | A service run declares `debug: {"configurationType": "…"}`, and the host picks an enabled debug adapter that advertises the type and supports `attach` | The same `lithe/build/prepareRun` | [Registry connection debugging](../../contract/v1/README.md#registry-connection-debugging) | `build-connection-debug-options.json` (no manifest example of its own) | +| Native debug preparation | A run declares `debug: {"adapter": "native"}`; `requires` needs `debugAdapters: 0`, the plugin requests `debug.launch`, and the adapter must be one the plugin declares itself | `lithe/build/prepareDebug` | [Native debug preparation](../../contract/v1/README.md#native-debug-preparation) | `build-provider-debug-preparation`, `build-debug-preparation.json` | +| Read-only model inputs | A consumer declares `modelInputs` (at most 8 slots), and `requires` needs `buildModelInputs: 0` and `buildProviders: 0`; a producer declares `modelSharing` | The model, dependency, and plan requests; the snapshot is in `params.modelInputs` | [Optional read-only model inputs](../../contract/v1/README.md#optional-read-only-model-inputs) | `build-systems/build-model-producer`, `build-systems/build-model-consumer` | +| Source-backed native configuration | `configuration: {"source": "provider"}`, own-process providers only; the provider advertises the server feature `litheBuildConfiguration: true` | `lithe/build/configuration`, `lithe/build/editConfiguration` | [Optional native configuration](../../contract/v1/README.md#optional-native-configuration) | `build-provider-configuration`, `build-configuration.json` | +| Inline test reports | `results: {"decoder": "provider"}`; replies carry `testResults` | The same `lithe/build/test` and `execute` | [Inline provider test reports](../../contract/v1/README.md#inline-provider-test-reports) | `build-provider-reports`, `build-test-durations.json` (summing repeated case durations) | +| Opaque semantic test selectors | A provider test declares `selectorKinds` (at most 16 namespaces); requests carry `selectorKind` and `selectors` | The same `lithe/build/test` | [Opaque semantic test selectors](../../contract/v1/README.md#opaque-semantic-test-selectors) | `build-provider-selectors` | +| Declarative project preparation | `preparation: {purpose, sourceRoles}` on a project task; it belongs to `buildSystems`, and a provider task runs through `lithe/build/execute` | The same `lithe/build/execute` | [Declarative project preparation](../../contract/v1/README.md#declarative-project-preparation) | `build-provider-preparation` | + +- A provider's results must follow the formats in [Model and outputs](../../contract/v1/README.md#model-and-outputs); the SDKs stamp `format` and `version` on models, dependencies, and test discovery, and a handler that returns another format or version fails. +- A provider rejects selectors, filters, and stale project facts it cannot honor exactly instead of widening the scope to guess; a nonzero exit code from a task or test is a completed tool failure, not a protocol error, a malformed reply is `plugin.build.invalidResult`, and a JSON-RPC error is `plugin.build.failed`. +- A provider never falls back to a host command or a plugin-private function; when the protocol was not negotiated, build methods are refused before they reach a handler. + +### Negotiation and isolation + +- The host sends `capabilities.experimental.litheBuild: {"versions": [0]}`, and the provider answers the `{"version": 0}` it chose. A provider that answers no version, or one the host did not offer, is not used for build requests; a shared provider still serves its language. Both sides ignore fields they do not know in these objects and in request params and results, so either side can add optional data; only a change the other side must not ignore gives `buildProviders` version `1`, and the protocol moves with it. +- One process per plugin, build system, and workspace. Two plugins never share a process, and the host never relays messages between processes; each speaks JSON-RPC only with the host, over its own stdio. The shared form reuses a language server session of the same plugin only. +- A provider starts on the first build request, which only a user action sends, and stops when its workspace closes, its plugin is disabled, or before its package is replaced, rolled back, or uninstalled. A change of the plugin's settings restarts it on the next request, so its launch arguments always reflect the current values; a provider that exits is started again by the next request. +- Installation refuses a package whose provider launch points at a missing file, a path outside the package or through a symbolic link, or a packaged executable or runtime that is not a regular executable file. +- Its writable state lives in the plugin's state directory for that build system, never in the plugin package or the host's installation. +- Every request carries `buildSystemID`, `root`, `options`, `optionArguments`, and `environment`; `environment` is the selected runtime or tool environment merged with the option contributions (options win), the host does not forward its complete environment, and the provider uses it for every invocation and cache identity. `refresh: true` means the user asked to reload, and the provider must recompute instead of answering from a cache of its own. The optional `tool: {executable}` is the absolute path of the build tool the host resolved from the build system's `invocation`, and the provider looks the tool up itself when it is absent; an explicitly selected toolchain or path that cannot resolve fails before the request is sent. + +### Limits + +| Item | Limit | +| --- | --- | +| Progress `message` | 4 KiB UTF-8 | +| Progress `output` | 64 KiB UTF-8 per notification | +| Final `output` of a task or test | 32 MiB UTF-8; `exitCode` is a signed 32-bit integer | +| Launch result | At most 1,024 arguments of 64 KiB each; at most 64 environment entries (keys `[A-Za-z_][A-Za-z0-9_]*` of at most 128 bytes, values of at most 64 KiB); the whole JSON at most 32 MiB | +| Test discovery | At most 10,000 items and 32 MiB of UTF-8 JSON | +| Inline test report | At most 10,000 cases and 8 MiB of UTF-8 JSON | +| Semantic selectors | At most 16 `selectorKinds` (120 bytes each); at most 64 selectors per request, 4096 bytes each | +| Native configuration | At most 8 sources, each with at most 64 fields | +| Model inputs | At most 8 slots, 64 instances per source, 2,000 projects and 10,000 targets or content roots per instance, and 8 MiB of resolved JSON | + +### Contract sections + +| Section | Content | +| --- | --- | +| [Build providers](../../contract/v1/README.md#build-providers) | The two forms, request params, `initialize` negotiation, process isolation and lifecycle | +| [Provider task execution](../../contract/v1/README.md#provider-task-execution) | Task execution, progress, cancellation | +| [Provider test execution](../../contract/v1/README.md#provider-test-execution), [Test discovery](../../contract/v1/README.md#test-discovery) | Test execution, reports, discovery | +| [Provider run preparation](../../contract/v1/README.md#provider-run-preparation) | Run preparation and the portable launch plan | +| [Typed run options and service/task preparation](../../contract/v1/README.md#typed-run-options-and-servicetask-preparation) | `launchOptions`, service and task preparation | +| [Registry connection debugging](../../contract/v1/README.md#registry-connection-debugging) | Connection debugging through an attach adapter chosen from the registry | +| [Native debug preparation](../../contract/v1/README.md#native-debug-preparation) | Native debug preparation and adapter bindings | +| [Optional native configuration](../../contract/v1/README.md#optional-native-configuration) | Reading and staged editing of native configuration | +| [Optional read-only model inputs](../../contract/v1/README.md#optional-read-only-model-inputs) | Read-only model snapshots between a producer and a consumer | +| [Inline provider test reports](../../contract/v1/README.md#inline-provider-test-reports) | `litheTestResults` reports in replies | +| [Opaque semantic test selectors](../../contract/v1/README.md#opaque-semantic-test-selectors) | Semantic test selectors | +| [Declarative project preparation](../../contract/v1/README.md#declarative-project-preparation) | Project preparation tasks that run before a language launch | +| [Model and outputs](../../contract/v1/README.md#model-and-outputs) | The model, diagnostics, and dependency formats a provider result must follow | + +### Document formats + +Every format has version `0`; see [document format versions](../capabilities-and-versions.md#document-format-versions). + +| Format | Used by | Example | +| --- | --- | --- | +| `litheBuildModel` | Model results | [`build-plans.json`](../../contract/v1/fixtures/build-systems/build-plans.json) | +| `litheDependencies` | Dependency results | In the contract, see [Model and outputs](../../contract/v1/README.md#model-and-outputs) | +| `litheDiagnostics` | Diagnostics in output lines | In the contract, see [Model and outputs](../../contract/v1/README.md#model-and-outputs) | +| `litheTestResults` | Test reports and inline reports | In the contract, see [Inline provider test reports](../../contract/v1/README.md#inline-provider-test-reports); the rule for summing repeated case durations is in [`build-test-durations.json`](../../contract/v1/fixtures/build-providers/build-test-durations.json) | +| `litheTestDiscovery` | Test discovery | [`build-test-discovery.json`](../../contract/v1/fixtures/build-providers/build-test-discovery.json) | +| `litheBuildConfiguration` | Native configuration facts | [`build-configuration.json`](../../contract/v1/fixtures/build-providers/build-configuration.json) | +| `litheConfigurationEdit` | The receipt of a native configuration edit | In the contract, see [Optional native configuration](../../contract/v1/README.md#optional-native-configuration) | +| `modelInputs` snapshot | Read-only model inputs | [`build-model-consumer.plugin.json`](../../contract/v1/fixtures/build-systems/build-model-consumer.plugin.json) | +| `launchOptions` object | Run options of services, tasks, and connection debugging; the fixture files wrap it as `format: "litheBuildRunOptions"`, `version: 0` | [`build-run-options.json`](../../contract/v1/fixtures/build-providers/build-run-options.json), [`build-connection-debug-options.json`](../../contract/v1/fixtures/build-providers/build-connection-debug-options.json) | + +### Related guides + +- [Native build, test, and run preparation](../../guides/native-build-workflows.md) +- [Build, run, and test for language plugins](../../guides/build-tool-integration.md) +- [Declarative build systems](build-systems.md) diff --git a/shared/plugins/reference/domains/build-systems.md b/shared/plugins/reference/domains/build-systems.md new file mode 100644 index 00000000..e78592dc --- /dev/null +++ b/shared/plugins/reference/domains/build-systems.md @@ -0,0 +1,195 @@ +# 声明式构建系统 / Declarative build systems + +[中文](#中文) · [English](#english) + +## 中文 + +`buildSystems` 用数据描述一个项目级构建工具(Cargo、Go、Gradle、Maven 等):怎么识别项目根目录、找哪个可执行文件、怎么得到项目模型、有哪些任务和运行入口、哪些文件变化要重新同步。所有构建工具共用同一套模型和命令:Core 校验声明、检测实例、解码模型、展开每一条命令行,宿主只执行 Core 规划好的进程并把输出交回。所以社区插件加一个构建工具不需要改宿主,宿主代码也不按构建系统 ID 分支。模型需要程序计算时,再加一个[构建提供者](build-providers.md)。 + +> **宿主支持。** Lithe 桌面端声明 `buildSystems` 和 `buildProviders`(版本都是 `0`):构建窗口发现构建实例、加载提供者模型、解析 Core 返回的有序可执行文件候选,并运行命令任务和提供者任务。桌面端不声明 `buildModelInputs`、`executionProviders` 和 `toolchainProviders`:`requires` 里有其中任何一个的插件不会被提供安装,所以 `modelSharing` 和 `modelInputs` 在桌面端用不上。 +> 构建系统的实例级选项(`scope: "instance"`)和项目任务的 `preparation`,桌面端尚未呈现:插件照常安装,这部分没有界面,也不会被调用。构建提供者的运行准备、测试、调试准备、模型输入、原生配置、内联测试报告和运行时调试同样尚未呈现,见[构建提供者](build-providers.md)。 +> 宿主只呈现它实现的功能,插件不得依赖宿主一定会调用其中任何一个。该注释还写到系统命令的工具发现目前只读 `PATH`,依赖项目工具链或用户覆盖的计划会被拒绝,它是否也约束可执行文件候选里的 `setting` 和 `toolchain` 来源,注释没有说明;依赖这两种来源前,先在桌面端实际试一遍。说明见 [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go);各能力的对照见[能力与协议版本对照](../capabilities-and-versions.md#能力版本requires)。 +> 对契约有疑问,请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `buildSystems[]`:`id`、`displayName`、`icon`、`detection`、`invocation`、`options`、`model`、`provider`、`tasks`、`run`、`test`、`diagnostics`、`dependencies`、`sync`、`layout`、`hostIntegration`,以及可选的 `modelSharing`、`modelInputs`(见下文)和 `configuration`(原生配置,见[构建提供者](build-providers.md)) | +| 能力版本 | `buildSystems` `0`。不带 `provider` 的构建系统只需要它;带 `provider` 还要 `buildProviders: 0`;`modelSharing`、`modelInputs` 另需 `buildModelInputs: 0`(消费者还需要 `buildProviders: 0`);可执行文件候选用 `setting` 来源时还要 `settings: 0`(见 `build-tool-path`)。可执行文件解析本身是基础契约,不是能力,不需要在 `requires` 里声明。`requires` 必须恰好列出清单用到的能力,见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | 没有进程协议;`model.source: "provider"` 时走 [`litheBuild`](build-providers.md) | +| API 编号 | [API-29](../api-catalog.md#现在能用的-api):构建、任务、测试、运行准备 | +| 权限 | `process.build` | +| 激活事件 | `onBuildSystem:` | +| 宿主支持 | Lithe 桌面端:见上面的说明 | + +### 一份声明由什么组成 + +构建系统、任务和运行入口的 ID 以小写字母开头,只用小写字母、数字和连字符;选项 ID 用 camelCase,和设置 ID 一样。声明里的路径都是相对路径,用 `/`,不能越出各自的基准目录(否则是 `plugin.path.invalid`)。完整字段表见[契约](../../contract/v1/README.md#build-systems)。 + +| 字段 | 决定什么 | +| --- | --- | +| `detection` | 用 `markers` 里的文件名识别候选根目录;`nesting` 决定深层候选并入外层实例(`outermost`,如 Gradle、Maven)还是各自独立(`separate`,默认,如 Cargo);`priority` 决定多个插件声明同一个 ID 时谁优先 | +| `invocation` | 可执行文件候选(`executables`,按偏好排序)、`globalArguments`、版本探测和 `minimumVersion` | +| `runtimeDependencies` | 构建工具依赖的运行时。`node`、`go` 由宿主管理;其他运行时要么命名同 ID 的[工具定义](toolchains.md),要么自己声明 `executables` 和版本探测 | +| `options` | 用户选择的构建维度:`boolean`、`singleSelect`、`multiSelect`、`text`、`file`、`directory`;每个选项在有值时贡献 `arguments` 和 `environment` | +| `model` | 怎么得到项目模型,见下表 | +| `provider` | 没有命令能打印模型时,由[构建提供者](build-providers.md)回答模型和依赖请求 | +| `tasks`、`run`、`test` | 任务目录、从模型目标或任务生成的运行入口、测试的范围、过滤器和结果文件 | +| `diagnostics`、`dependencies` | 诊断的行匹配或 `litheDiagnostics`;依赖列表或依赖树的来源 | +| `sync`、`layout` | 哪些文件变化会让模型过期;每个项目要作为 `excluded` 内容根加入的目录 | + +### 模型来源 + +| `model.source` | 怎么得到模型 | 示例 | +| --- | --- | --- | +| `static` | 读取项目里的 `toml` 文件,检测时完成,不启动进程 | `build-static-file`(Cargo) | +| `command`(标准输出) | 运行命令,解码标准输出(`jsonStream` 或规范格式) | `build-command-stdout`(Go) | +| `command`(输出文件) | 运行命令,解码它写到 `${outputFile}` 的文件 | `build-command-output-file`(Gradle) | +| `provider` | 由插件的构建提供者回答 `lithe/build/model` | [构建提供者](build-providers.md) | +| `hostIntegration` | 交给宿主集成:只限官方插件,目前只有 `maven`,其他是 `plugin.integration.unsupported`。新的构建系统用声明式契约或规范格式,不用它 | — | + +`command` 来源要声明 `executesProjectCode` 和 `mayUseNetwork`。命令只在宿主按请求启动时运行,静态文件在检测时读取。`map` 只负责选取值,不是转换语言:map 表达不了的(其他文件格式、从工具读出的任务、显式的父子关系、计算出的标签、依赖树)由插件自己的命令或构建提供者输出规范格式 `litheBuildModel`、`litheDependencies`、`litheTestResults` 或 `litheDiagnostics`(`version` 都是 `0`)。不符合解码器的输出是 `plugin.build.invalidOutput`。 + +构建系统的 `hostIntegration` 和语言服务器的 `hostIntegration` 是两个不同的声明:前者只限官方插件;后者任何插件都可以声明,见[语言服务器的宿主集成](../../contract/v1/README.md#language-server-host-integrations)。 + +### 选项、可执行文件与准备任务 + +- **选项范围。** 选项可以声明 `scope: "instance"`,按检测到的每个根目录分别保存:改动或重置 `a` 不会影响 `b`。省略范围(或 `"workspace"`)使用工作区和构建系统存储。显式的 `false`、`""`、`[]` 仍然是用户保存的值。实例值不能覆盖工作区选项,工作区值也不能悄悄覆盖实例选项。示例:`build-instance-options`。 +- **可执行文件解析。** `invocation.executables` 按偏好顺序列出来源:`projectFile`(项目里的包装脚本,总是运行项目代码)、`environment`、`command`(`PATH` 上的命令名)、`runtime`、`setting`(引用声明为 `file` 的全局设置)和 `toolchain`(工具定义 ID)。可以用 `id`、`priority`、`insertBefore`、`insertAfter` 调整同一个候选集内的顺序。用户配置的路径失败是终止性的,不会回退到后面的候选或提供者自己的查找。示例:`build-tool-path`(`setting`)、`build-toolchains`(`toolchain`)。规则见[可执行文件解析](../../contract/v1/README.md#executable-resolution)。 +- **展示。** 每个启用的构建系统有自己的工具窗口,由宿主按模型绘制;`options[].presentation` 把布尔选项放到工具栏(`toolbar`,需要 `icon`)或把多选选项放成树顶部的清单(`tree`),`tasks[].category` 给任务归类。这些字段不改变任何计划。 +- **项目准备任务。** 项目任务可以声明 `preparation: { purpose, sourceRoles }`,按用途(如 `run`)和内容根角色绑定运行前要执行的任务;宿主从当前模型选出最深的所属项目和内容根,要求恰好一个匹配的声明,不猜任务名,也不回退到约定的任务 ID。示例:`build-providers/build-provider-preparation`。 +- **只读模型输入。** 生产者用 `modelSharing: { options: [...] }` 公开规范化的项目、目标和内容根(以及明确列出的普通选项的有效值),消费者用 `modelInputs: [{ pluginID, buildSystemID }]` 读取,最多 8 个槽位。缺失、被禁用或不公开的生产者只会让该槽位 `unavailable`,从不被自动安装或激活;一个生产者不能同时是消费者。示例:`build-model-producer`、`build-model-consumer`。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Build systems](../../contract/v1/README.md#build-systems) | 字段表:识别、可执行文件、运行时依赖、选项、模型、任务、运行、测试、诊断、依赖、同步、布局,以及实例级选项 | +| [Presentation](../../contract/v1/README.md#presentation) | 宿主绘制的构建窗口,插件不需要自带界面 | +| [Templates](../../contract/v1/README.md#templates) | 命令行模板变量 | +| [Model and outputs](../../contract/v1/README.md#model-and-outputs) | 统一模型、诊断、测试结果和依赖的格式 | +| [Optional read-only model inputs](../../contract/v1/README.md#optional-read-only-model-inputs) | 插件之间共享只读模型 | +| [Declarative project preparation](../../contract/v1/README.md#declarative-project-preparation) | 运行前准备任务 | +| [Executable resolution](../../contract/v1/README.md#executable-resolution) | 可执行文件候选、排序、严格选择 | +| [Host integrations](../../contract/v1/README.md#host-integrations) | 构建系统的宿主集成(只限官方插件) | +| [Commands](../../contract/v1/README.md#commands) | `build.*` Core 命令:检测、规划、解码、同步指纹 | + +### 示例与用例 + +示例以 Cargo、Go、Gradle 为真实输入,但不是那些插件本身。 + +| 文件 | 内容 | +| --- | --- | +| [`build-systems/build-static-file.plugin.json`](../../contract/v1/fixtures/build-systems/build-static-file.plugin.json) | `static` 模型(Cargo) | +| [`build-systems/build-command-stdout.plugin.json`](../../contract/v1/fixtures/build-systems/build-command-stdout.plugin.json) | 命令标准输出模型(Go) | +| [`build-systems/build-command-output-file.plugin.json`](../../contract/v1/fixtures/build-systems/build-command-output-file.plugin.json) | 命令输出文件模型(Gradle) | +| [`build-systems/build-instance-options.plugin.json`](../../contract/v1/fixtures/build-systems/build-instance-options.plugin.json) | 按实例保存的选项 | +| [`build-systems/build-tool-path.plugin.json`](../../contract/v1/fixtures/build-systems/build-tool-path.plugin.json) | 用 `file` 设置选择构建工具 | +| [`build-systems/build-toolchains.plugin.json`](../../contract/v1/fixtures/build-systems/build-toolchains.plugin.json) | 可执行文件候选和运行时依赖用 `toolchain` 来源 | +| [`build-systems/build-model-producer.plugin.json`](../../contract/v1/fixtures/build-systems/build-model-producer.plugin.json) | 公开模型的生产者 | +| [`build-systems/build-model-consumer.plugin.json`](../../contract/v1/fixtures/build-systems/build-model-consumer.plugin.json) | 读取模型的消费者(带构建提供者) | +| [`build-systems/build-plans.json`](../../contract/v1/fixtures/build-systems/build-plans.json) | `build.*` 标准答案:每个宿主从上面的声明得到的计划和解码结果 | +| [`build-providers/build-provider-preparation.plugin.json`](../../contract/v1/fixtures/build-providers/build-provider-preparation.plugin.json) | 运行前准备任务 | + +构建相关的拒绝用例在 [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json)。 + +### 相关指南 + +- [为语言插件接入构建、运行与测试](../../guides/build-tool-integration.md) +- [原生构建、测试与运行准备](../../guides/native-build-workflows.md) + +--- + +## English + +`buildSystems` describes a project-level build tool (Cargo, Go, Gradle, Maven, …) as data: how to recognize a project root, which executable to use, how to get the project model, which tasks and run entries exist, and which file changes require a re-sync. Every build tool shares the same model and commands: Core validates the declaration, detects instances, decodes the model, and expands every command line; the host only runs the processes Core planned and passes their output back. A community plugin therefore adds a build tool without any host change, and no host code branches on a build-system ID. When the model must be computed by a program, add a [build provider](build-providers.md). + +> **Host support.** The Lithe desktop declares `buildSystems` and `buildProviders` (both version `0`): the Build window detects build instances, loads provider models, resolves the ordered executable candidates Core returns, and runs command and provider tasks. The desktop does not declare `buildModelInputs`, `executionProviders` or `toolchainProviders`: a plugin whose `requires` lists any of them is not offered for installation, so `modelSharing` and `modelInputs` are unusable there. +> Build-system instance options (`scope: "instance"`) and project-task `preparation` are not presented on the desktop yet: the plugin installs, and that part has no surface and is never called. Build-provider run preparation, tests, debug preparation, model inputs, native configuration, inline test reports and runtime debug are not presented either; see [Build providers](build-providers.md). +> A host presents the features it implements; a plugin must not depend on a host calling any of them. The same comment says tool discovery for system commands reads `PATH` only and that a plan depending on a project toolchain or a user override is refused. It does not say whether that also limits the `setting` and `toolchain` sources of executable candidates, so try those two on the desktop before relying on them. See [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go); the per-capability comparison is in [Capabilities and protocol versions](../capabilities-and-versions.md#capability-versions-requires). +> Questions about the contract go to [Lithe-core-rust Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues). + +| Item | Value | +| --- | --- | +| Manifest fields | `buildSystems[]`: `id`, `displayName`, `icon`, `detection`, `invocation`, `options`, `model`, `provider`, `tasks`, `run`, `test`, `diagnostics`, `dependencies`, `sync`, `layout`, `hostIntegration`, plus the optional `modelSharing`, `modelInputs` (below) and `configuration` (native configuration, see [Build providers](build-providers.md)) | +| Capability versions | `buildSystems` `0`. A build system without `provider` needs only it; with `provider` it also needs `buildProviders: 0`; `modelSharing` and `modelInputs` also need `buildModelInputs: 0` (a consumer also needs `buildProviders: 0`); a `setting` executable source also needs `settings: 0` (see `build-tool-path`). Executable resolution itself is base contract, not a capability, and is not declared in `requires`. `requires` must list exactly the capabilities the manifest uses; see [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | No process protocol; `model.source: "provider"` uses [`litheBuild`](build-providers.md) | +| API IDs | [API-29](../api-catalog.md#apis-usable-now): build, tasks, tests, run preparation | +| Permissions | `process.build` | +| Activation events | `onBuildSystem:` | +| Host support | The Lithe desktop: see the note above | + +### What a declaration contains + +IDs of build systems, tasks, and run entries start with a lowercase letter and use lowercase letters, digits, and hyphens; option IDs are camelCase like setting IDs. Paths in a declaration are relative, use `/`, and stay inside their base directory (`plugin.path.invalid` otherwise). The full field table is in the [contract](../../contract/v1/README.md#build-systems). + +| Field | What it decides | +| --- | --- | +| `detection` | Candidate roots are directories holding one of the `markers` file names; `nesting` folds deeper candidates into the outer instance (`outermost`, such as Gradle and Maven) or keeps each one (`separate`, the default, such as Cargo); `priority` decides between plugins declaring the same ID | +| `invocation` | Executable candidates (`executables`, in preference order), `globalArguments`, the version probe, and `minimumVersion` | +| `runtimeDependencies` | Runtimes the build tool needs. `node` and `go` are host-managed; any other runtime either names the [tool definition](toolchains.md) with the same ID or declares its own `executables` and version probe | +| `options` | Build dimensions the user chooses: `boolean`, `singleSelect`, `multiSelect`, `text`, `file`, `directory`; each contributes `arguments` and `environment` when it has a value | +| `model` | How the project model is obtained; see the table below | +| `provider` | The [build provider](build-providers.md) that answers model and dependency requests when no command prints them | +| `tasks`, `run`, `test` | The task catalog, run entries generated from model targets or tasks, and the scopes, filters, and result files of tests | +| `diagnostics`, `dependencies` | Line matchers or `litheDiagnostics` for diagnostics; the source of the dependency list or tree | +| `sync`, `layout` | Which file changes make the model stale; directories added to every project as `excluded` content roots | + +### Model sources + +| `model.source` | How the model is obtained | Example | +| --- | --- | --- | +| `static` | Read a `toml` file in the project, at detection time, with no process | `build-static-file` (Cargo) | +| `command` (stdout) | Run a command and decode its standard output (`jsonStream` or a canonical format) | `build-command-stdout` (Go) | +| `command` (output file) | Run a command and decode the file it writes to `${outputFile}` | `build-command-output-file` (Gradle) | +| `provider` | The plugin's build provider answers `lithe/build/model` | [Build providers](build-providers.md) | +| `hostIntegration` | Handed to a host integration: official plugins only, currently only `maven`, otherwise `plugin.integration.unsupported`. New build systems use the declarative contract or a canonical format instead | — | + +A `command` source declares `executesProjectCode` and `mayUseNetwork`. A command runs only when the host starts it on request; a static file is read during detection. A `map` only selects values and is not a transformation language: whatever a map cannot express (other file formats, tasks read from the tool, explicit parent links, computed tags, dependency trees) is the plugin's own command or build provider printing the canonical `litheBuildModel`, `litheDependencies`, `litheTestResults`, or `litheDiagnostics` (all `version` `0`). Output that does not fit its decoder is `plugin.build.invalidOutput`. + +A build system's `hostIntegration` and a language server's `hostIntegration` are two different declarations: the first is limited to official plugins; any plugin may declare the second, see [language server host integrations](../../contract/v1/README.md#language-server-host-integrations). + +### Options, executables, and preparation tasks + +- **Option scope.** An option may declare `scope: "instance"`, stored separately per detected root: changing or resetting `a` cannot change `b`. Omitting the scope (or `"workspace"`) uses the workspace and build-system storage. Explicit `false`, `""`, and `[]` remain stored user values. Instance values cannot override workspace options, and workspace values cannot silently override an instance option. Example: `build-instance-options`. +- **Executable resolution.** `invocation.executables` lists sources in preference order: `projectFile` (a wrapper inside the project; always runs project code), `environment`, `command` (a bare name on `PATH`), `runtime`, `setting` (references a declared global `file` setting), and `toolchain` (a tool-definition ID). `id`, `priority`, `insertBefore`, and `insertAfter` reorder candidates within one candidate set. A configured path that fails is terminal and never falls back to later candidates or to the provider's own lookup. Examples: `build-tool-path` (`setting`) and `build-toolchains` (`toolchain`). The rules are in [Executable resolution](../../contract/v1/README.md#executable-resolution). +- **Presentation.** Every enabled build system gets a tool window of its own, drawn by the host from the model; `options[].presentation` puts a boolean option on the toolbar (`toolbar`, needs an `icon`) or a multiSelect option as a checklist at the top of the tree (`tree`), and `tasks[].category` files a task under headings. None of these fields changes a plan. +- **Project preparation tasks.** A project task may declare `preparation: { purpose, sourceRoles }`, binding the task to run first by purpose (such as `run`) and content-root role. The host selects the deepest owning project and content root from the current model, requires exactly one matching declaration, never guesses a task name, and never falls back to a conventional task ID. Example: `build-providers/build-provider-preparation`. +- **Read-only model inputs.** A producer publishes normalized projects, targets, and content roots (and the effective values of explicitly listed ordinary options) with `modelSharing: { options: [...] }`; a consumer reads them with `modelInputs: [{ pluginID, buildSystemID }]`, up to 8 slots. A missing, disabled, or non-public producer only makes that slot `unavailable` and is never installed or activated automatically; a producer cannot also be a consumer. Examples: `build-model-producer` and `build-model-consumer`. + +### Contract sections + +| Section | Content | +| --- | --- | +| [Build systems](../../contract/v1/README.md#build-systems) | The field table: detection, executables, runtime dependencies, options, model, tasks, run, tests, diagnostics, dependencies, sync, layout, and instance options | +| [Presentation](../../contract/v1/README.md#presentation) | The host-drawn build window; the plugin brings no UI | +| [Templates](../../contract/v1/README.md#templates) | Command-line template variables | +| [Model and outputs](../../contract/v1/README.md#model-and-outputs) | The shared model, diagnostics, test results, and dependency formats | +| [Optional read-only model inputs](../../contract/v1/README.md#optional-read-only-model-inputs) | Sharing read-only models between plugins | +| [Declarative project preparation](../../contract/v1/README.md#declarative-project-preparation) | Tasks that run before a launch | +| [Executable resolution](../../contract/v1/README.md#executable-resolution) | Executable candidates, ordering, strict selection | +| [Host integrations](../../contract/v1/README.md#host-integrations) | Host integrations for build systems (official plugins only) | +| [Commands](../../contract/v1/README.md#commands) | `build.*` Core commands: detection, plans, decoding, sync fingerprints | + +### Examples and cases + +The examples use Cargo, Go, and Gradle as realistic inputs; they are not those plugins. + +| File | Content | +| --- | --- | +| [`build-systems/build-static-file.plugin.json`](../../contract/v1/fixtures/build-systems/build-static-file.plugin.json) | `static` model (Cargo) | +| [`build-systems/build-command-stdout.plugin.json`](../../contract/v1/fixtures/build-systems/build-command-stdout.plugin.json) | Command stdout model (Go) | +| [`build-systems/build-command-output-file.plugin.json`](../../contract/v1/fixtures/build-systems/build-command-output-file.plugin.json) | Command output-file model (Gradle) | +| [`build-systems/build-instance-options.plugin.json`](../../contract/v1/fixtures/build-systems/build-instance-options.plugin.json) | Per-instance options | +| [`build-systems/build-tool-path.plugin.json`](../../contract/v1/fixtures/build-systems/build-tool-path.plugin.json) | A `file` setting selects the build tool | +| [`build-systems/build-toolchains.plugin.json`](../../contract/v1/fixtures/build-systems/build-toolchains.plugin.json) | Executable candidates and a runtime dependency with `toolchain` sources | +| [`build-systems/build-model-producer.plugin.json`](../../contract/v1/fixtures/build-systems/build-model-producer.plugin.json) | A model producer | +| [`build-systems/build-model-consumer.plugin.json`](../../contract/v1/fixtures/build-systems/build-model-consumer.plugin.json) | A model consumer (with a build provider) | +| [`build-systems/build-plans.json`](../../contract/v1/fixtures/build-systems/build-plans.json) | Golden `build.*` results: the plans and decoded outputs every host gets from the declarations above | +| [`build-providers/build-provider-preparation.plugin.json`](../../contract/v1/fixtures/build-providers/build-provider-preparation.plugin.json) | Tasks that run before a launch | + +Build rejections are in [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json). + +### Related guides + +- [Build, run, and test for language plugins](../../guides/build-tool-integration.md) +- [Native build, test, and run preparation](../../guides/native-build-workflows.md) diff --git a/shared/plugins/reference/domains/debug-adapters.md b/shared/plugins/reference/domains/debug-adapters.md new file mode 100644 index 00000000..60c15308 --- /dev/null +++ b/shared/plugins/reference/domains/debug-adapters.md @@ -0,0 +1,277 @@ +# 调试适配器 / Debug adapters + +[中文](#中文) · [English](#english) + +## 中文 + +插件在 `debugAdapters[]` 里声明自己实现的调试适配器。每个适配器是一个 DAP 进程,宿主启动它,并通过 stdio 或本机回环 TCP 端口和它通信;也可以是插件语言服务器里内置的调试服务(例如运行在 JDT LS 里的 Java Debug Server)。声明只是数据:没有解析器、工作流、宿主集成或 `initializationOptions`,出现这些字段会被拒绝。调试界面、断点、变量、控制台都归宿主。插件后端还可以用宿主方法 `debug/start` 在自己声明的适配器上发起调试会话。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `debugAdapters[]` | +| 能力 | `requires.debugAdapters: 0`;`launch.process` 用到的[进程启动](../../contract/v1/README.md#process-start)是基础契约规则,不是能力;`runSelectedRuntime` 的 `tool` 要列在 `requiresTools` 里。见[能力与协议版本](../capabilities-and-versions.md) | +| 协议 | DAP(Debug Adapter Protocol),没有 Lithe 专用协议;后端发起会话用 `litheFunctions` 的宿主方法 `debug/start`,见[由插件后端发起调试会话](#由插件后端发起调试会话) | +| API 编号 | API-30:调试适配器,包括 `debug/start`,见 [API 总表](../api-catalog.md) | +| 权限 | `debug.launch`;任何适配器支持 `attach` 时加 `debug.attach`(远程 attach 不另设权限);后端调用 `debug/start` 时加 `debug.start`(需要 `backend` 和已声明的 `debugAdapters` 条目,只授予 `debug/start`);`tcpLanguageServer` 依赖同一插件的语言服务器,所以还要有 `languageServers` 和 `languageServer.launch` | +| 激活事件 | 每个适配器一个 `onDebug:` | +| Lithe 桌面端 | 已声明 `debugAdapters`,见[宿主现状](#宿主现状) | + +### 与其他能力的配合 + +| 配合 | 说明 | +| --- | --- | +| `buildProviders` | 构建提供者可以为原生调试准备目标:运行声明 `debug: {"adapter": "native"}`,适配器必须是本插件声明的,所以同一个清单也要声明 `debugAdapters`。服务运行可以声明 `debug: {"configurationType": "…"}`,宿主选一个已启用、支持 `attach` 和该类型的适配器连接。见[构建提供者](build-providers.md) | +| `languageServers` | `tcpLanguageServer` 传输使用同一插件的语言服务器 | +| `backend` | 后端用 `debug/start` 或 `run/start` 的 `debug` 字段发起会话 | +| `toolDefinitions` | `launch.process` 写 `runSelectedRuntime` 时,`tool` 是 `requiresTools` 列出的工具定义 ID,宿主用用户选中的运行时启动适配器 | + +### 传输方式 + +| `launch.transport` | 端点 | 需要 `launch.process` | +| --- | --- | --- | +| `stdio` | 适配器的标准输入输出 | 是 | +| `tcpAnnounced` | 适配器在输出流里宣布端口:`endpoint` 带 `stream`、单行 `prefix`、`value`(`port` 或 `hostPort`) | 是 | +| `tcpPort` | 固定端口 `endpoint.port` | 是 | +| `tcpLanguageServer` | 同一插件的语言服务器:`endpoint` 带 `languageID` 和 `command`;宿主每次会话发 `workspace/executeCommand`,服务器回答回环端口 | 否,且不能有 | + +所有 TCP 端点只能是本机回环地址。`tcpLanguageServer` 的命令是安装和更新审查的一部分,命令改变时会重新询问用户。 + +### 适配器行为 + +宿主从不按适配器 ID 识别适配器。适配器特有的行为由声明给出,共享的调试引擎按声明执行: + +| 声明 | 含义 | +| --- | --- | +| `features.hotCodeReplace.request` | 宿主向运行中的被调试程序应用变更代码时发送的 DAP 命令(标准的 `redefineClasses` 检查)。写了表示适配器支持热代码替换,没写表示不支持 | +| `features.hotCodeReplace.event` | 一个 DAP 事件,调试控制台把它的 `message` 显示为进度 | +| `features.hotCodeReplace.changedField`、`errorField` | 响应体里的字段:已替换单元的列表,以及适配器在成功响应里报告的失败消息。没有 `changedField` 的响应不会显示任何已替换内容 | +| `features.stepFilters.argument` | 适配器接收步进过滤器的启动参数,形状为 `{skipClasses, skipSynthetics, skipStaticInitializers, skipConstructors}`。用户配置里已设置的参数保持不变 | +| `defaults.stepFilters` | `classNameFilters`(最多 256 个单行模式,每个最多 256 个字符)、`skipSynthetics`、`skipStaticInitializers`、`skipConstructors`、`hideFilteredStackFrames`。启动和用户设置都没有给出过滤器时使用 | + +名称(`request`、`event`、各字段、`argument`)是一个字母加最多 63 个字母、数字、`.`、`_` 或 `-`。名称格式错误,或写了契约没有定义的字段,会被拒绝为 `plugin.manifest.invalid`,`field` 指明位置。启动计划的 `debugAdapter` 描述符同时携带两者,宿主据此传给 `debug.createSession`。两者都不声明的适配器没有这些行为。 + +### 目标绑定与控制台 + +| 声明 | 说明 | +| --- | --- | +| `configurations.launch`、`configurations.attach` | 默认 DAP `arguments`、包内的 `parametersSchema` 和 `targetBindings`:`program` ← `target.path`、`processId` ← `target.processID`、`cwd` ← `target.workingDirectory`,以及有界的 `args` ← `target.arguments`、`env` ← `target.environment`;只有 `attach` 能绑定 `connection`。为未声明的请求写配置会被拒绝 | +| `configurations.launch.executable` | 目标是适配器不能直接调试的源文件(C、C++、Swift)时:`setting` 指向插件的 `file` 设置,最多 8 个 `candidates[]`(以 `${workspaceFolder}/` 或 `${fileDirectory}/` 开头,不含 `..`)是用户没选时宿主查找的路径。必须与 `targetBindings.program: "target.executable"` 一起声明。用户在设置里选的路径永远优先,不是可运行文件就报错,不会改试候选 | +| `capabilities.disabled` | 宿主视为不存在的标准 DAP `supports*` 名称或 `exceptionBreakpointFilters` | +| `console.commandPrefix` | 1–8 个字符、不含空格的文本(lldb-dap 用反引号),适配器控制台把它读作“后面是调试器命令”;宿主只在请求补全时把它放在输入行前面,并把适配器的回答移回来,用户看不到 | +| `console.commands[]` | 调试控制台提供补全的调试器命令(`name`,可选单行 `description`)。能回答 DAP `completions` 的适配器实时补全,只为不能回答的适配器列出;有 `console` 时至少声明 `commandPrefix` 或 `commands[]` 之一 | + +### 远程 attach + +连接另一台机器上的被调试程序(带 `-agentlib:jdwp` 的 JVM、Node inspector、gdbserver)时,适配器仍在本机运行,只有被调试程序在远端。`attach` 配置的 `targetBindings.connection` 把用户确认的主机、端口、路径映射写进适配器的 `arguments`: + +| 字段 | 说明 | +| --- | --- | +| `host` + `port` | 一起声明,值是 `arguments` 里的路径(1–4 段,如 `connect.host`) | +| `address` | 单个 `host:port` 字符串,IPv6 用方括号;可以代替 `host` + `port`,也可以同时写 | +| `pathMappings.shape: "objects"` | 对象列表,`localKey`、`remoteKey` 命名每个对象的键 | +| `pathMappings.shape: "first"` | 只有一对根目录的适配器;目标多于一对时拒绝,不截断 | +| `pathMappings.shape: "dictionary"` | 一个对象,`keyedBy` 决定哪一侧做键 | +| `pathMappings.shape: "pairs"` | 二元数组列表,`order` 为 `remoteLocal` 或 `localRemote` | + +Core 只把 `localhost`、`127.0.0.0/8`、`::1` 判为本机;其他主机都是 `remote: true`,宿主每次都请用户确认,默认不记住。`connection` 不声明外部 DAP 端点或远程执行适配器(例如 `ssh host gdb -i=dap`),也不提供代理、隧道、端口转发。 + +### 由插件后端发起调试会话 + +后端可以让宿主启动一个调试会话:后端决定启动什么,调试界面、进程、控制台、停止按钮和会话结束都归宿主。有两条路径,都是 `litheFunctions` 的宿主方法,`run/stop` 之外都带 `context: {contextID, generation}`: + +| 方法(前缀 `lithe/host/`) | 权限 | 参数 | 结果 | +| --- | --- | --- | --- | +| `debug/start` | `debug.start` | `context, label, adapter, request, configuration` | `{sessionID}` | +| `run/start`,带 `debug` | `run.start` | `context, label, process, debug: {configurationType, target}`,另有 `temporaryFiles?`、`rerun?` | `{runID}` | +| `run/stop` | `run.start` | `{runID}` 或 `{sessionID}` | `null` | + +`debug/start`: + +- `label`:1 到 256 个字符,不含控制字符。 +- `adapter`:本插件自己的适配器,写本地 ID 或限定 ID `/`。别的插件的适配器,以及本插件没有声明的适配器,都返回 `plugin.host.notFound`。 +- `request`:`launch` 或 `attach`,适配器必须支持它。 +- `configuration`:最多 256 KiB 的 JSON 对象,宿主在适配器声明的默认值之上,把它作为 DAP 的 launch 或 attach 参数传给适配器。`noDebug` 和所有以 `__` 开头的键归宿主,会被拒绝。 + +`run/start` 的 `debug` 字段让一次宿主拥有的运行成为调试会话: + +- `configurationType` 是公开的调试适配器别名,和[按配置类型选择 attach 适配器](../../contract/v1/README.md#registry-connection-debugging)相同。 +- `target` 为 `{kind: "connection", port}` 时,进程在 `port`(1 到 65535,由后端写进参数)上监听调试器,宿主等它就绪后连接适配器;为 `{kind: "launch"}` 时,适配器自己启动进程,通过 `targetBindings` 从 `process` 取程序、参数、工作目录和环境。 + +`run/start`、`run/stop` 和 `run/didExit` 的其他规则见[插件后端](backend.md)。参数格式错误返回 `plugin.host.invalidParams`,Core 的拒绝(`plugin.debug.invalidStart` 或 `plugin.run.invalidStart`,带 `field`)在 `details.code` 和 `details.field` 里;已关闭的上下文是 `plugin.host.contextExpired`。 + +### 宿主现状 + +| 宿主 | 现状 | +| --- | --- | +| Lithe 桌面端 | 声明 `debugAdapters`:stdio 适配器,以及通过本机回环 TCP 连接插件语言服务器里的适配器;`launch` 和 `attach`(远程主机在用户确认后)、参数和环境绑定、`features` 和 `defaults`。后端发起的 `debug/start`、`run/start` 属于宿主服务,由后端通过 `getCapabilities` 在运行时发现,不在能力表里。构建提供者的原生调试准备和运行时调试在桌面端尚未呈现。细节见[桌面端源码](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Debug adapters](../../contract/v1/README.md#debug-adapters) | 全部字段、绑定、`executable` 候选、控制台、传输 | +| [Adapter features and defaults](../../contract/v1/README.md#adapter-features-and-defaults) | `features.hotCodeReplace`、`features.stepFilters` 和 `defaults.stepFilters` | +| [Run and debug sessions](../../contract/v1/README.md#run-and-debug-sessions) | `debug/start`、`run/start` 的 `debug` 字段、`run/stop`:参数、限制、失败码 | +| [Remote attach](../../contract/v1/README.md#remote-attach) | `connection` 绑定、四种路径映射、目标格式、本机判定、拒绝层次 | +| [Native debug preparation](../../contract/v1/README.md#native-debug-preparation) | 构建提供者准备原生调试目标 | +| [Registry connection debugging](../../contract/v1/README.md#registry-connection-debugging) | 服务运行按配置类型选择 attach 适配器 | +| [Permissions and activation](../../contract/v1/README.md#permissions-and-activation) | `debug.launch`、`debug.attach`、`debug.start`、`onDebug:` | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`debug-adapters/debug-adapter.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) | 合法示例:两个适配器(`example`、`native`),`file` 设置选择要调试的程序 | +| [`debug-adapters/debug-adapter-language-server.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json) | `tcpLanguageServer`:调试服务在插件的语言服务器里 | +| [`debug-adapters/debug-adapter-remote-attach.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json) | 六个适配器,分别演示 `connection` 的不同写法:`flat`(平铺的 `host`、`port`)、`nested`(嵌套路径加 `objects`)、`scalar`(`first`)、`joined`(`address`)、`keyed`(`dictionary`)、`paired`(`pairs`) | +| [`debug-adapters/debug-adapter-features.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json) | 声明 `backend` 和 `debugAdapters`,权限 `backend.launch`、`debug.launch`、`debug.start`:适配器 `script` 声明 `features`(`hotCodeReplace`、`stepFilters`)和 `defaults.stepFilters` | +| [`debug-adapters/debug-start.json`](../../contract/v1/fixtures/debug-adapters/debug-start.json) | `debug/start` 的参数,以及 `debug.decodeBackendConfiguration` 对 10 个用例返回的规范化启动或拒绝:本插件适配器按本地 ID 或限定 ID、别的插件的适配器、未声明的适配器、`noDebug` 和 `__` 键等;256 KiB 上限由 Core 自己的测试覆盖 | +| [`run/run-start.json`](../../contract/v1/fixtures/run/run-start.json) | `run/start` 的参数,以及 `run.decodeStart` 的结果;其中包含 `debug` 的 `connection` 和 `launch` 目标、端口越界等用例 | +| [`debug-adapters/debug-adapter-rejections.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json) | 74 个拒绝用例,每个写明稳定错误码、字段和报告层次(`schema`、`coreManifest`、`corePlan`、`coreInstall`) | +| [`build-providers/build-provider-debug-preparation.plugin.json`](../../contract/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json) | `buildProviders` 加 `debugAdapters`:原生调试准备 | +| [`build-providers/build-debug-preparation.json`](../../contract/v1/fixtures/build-providers/build-debug-preparation.json) | `lithe/build/prepareDebug` 的结果和拒绝用例 | +| [`build-providers/build-connection-debug-options.json`](../../contract/v1/fixtures/build-providers/build-connection-debug-options.json) | 服务运行的调试选项(按配置类型连接) | +| [Lithe-IDEA/lithe-plugin-lldb](https://github.com/Lithe-IDEA/lithe-plugin-lldb) | lldb-dap 调试插件,使用 `executable` 候选和 `console.commandPrefix` | + +### 相关指南 + +- [编写调试适配器插件](../../guides/debug-adapter-plugin.md) +- [原生构建、测试与运行准备](../../guides/native-build-workflows.md) + +--- + +## English + +A plugin declares the debug adapters it implements in `debugAdapters[]`. Each adapter is a DAP process the host starts and talks to over stdio or a loopback TCP port, or a debug server built into the plugin's language server (such as the Java Debug Server inside JDT LS). The declaration is data only: there is no resolver, workflow, host integration, or `initializationOptions`, and any such field is refused. The debug UI, breakpoints, variables, and console belong to the host. A plugin backend can also start a debug session on one of its own declared adapters with the host method `debug/start`. + +| Item | Value | +| --- | --- | +| Manifest field | `debugAdapters[]` | +| Capability | `requires.debugAdapters: 0`; the [process start](../../contract/v1/README.md#process-start) that `launch.process` uses is a base-contract rule, not a capability; the `tool` of `runSelectedRuntime` must be listed in `requiresTools`. See [capabilities and protocol versions](../capabilities-and-versions.md) | +| Protocols | DAP (Debug Adapter Protocol); no Lithe-specific protocol. A backend starts a session with the `litheFunctions` host method `debug/start`; see [Starting a debug session from a plugin backend](#starting-a-debug-session-from-a-plugin-backend) | +| API IDs | API-30: debug adapters, including `debug/start`; see the [API catalog](../api-catalog.md) | +| Permissions | `debug.launch`; `debug.attach` when any adapter serves `attach` (remote attach has no separate permission); `debug.start` when a backend calls `debug/start` (needs `backend` and a declared `debugAdapters` entry, and grants only `debug/start`); `tcpLanguageServer` relies on a language server of the same plugin, so it also needs `languageServers` and `languageServer.launch` | +| Activation events | One `onDebug:` per adapter | +| Lithe desktop | Declares `debugAdapters`; see [Host status](#host-status) | + +### Working with other capabilities + +| Pairing | Notes | +| --- | --- | +| `buildProviders` | A build provider can prepare a native debug target: a run declares `debug: {"adapter": "native"}`, and the adapter must be one this plugin declares, so the same manifest also declares `debugAdapters`. A service run can declare `debug: {"configurationType": "…"}`, and the host connects an enabled adapter that serves `attach` and that type. See [build providers](build-providers.md) | +| `languageServers` | The `tcpLanguageServer` transport uses a language server of the same plugin | +| `backend` | A backend starts sessions with `debug/start` or the `debug` field of `run/start` | +| `toolDefinitions` | When `launch.process` is `runSelectedRuntime`, `tool` is a tool-definition ID that `requiresTools` lists, and the host starts the adapter with the runtime the user selected | + +### Transports + +| `launch.transport` | Endpoint | Needs `launch.process` | +| --- | --- | --- | +| `stdio` | The adapter's standard input and output | Yes | +| `tcpAnnounced` | The adapter announces its port on an output stream: `endpoint` has `stream`, a one-line `prefix`, and `value` (`port` or `hostPort`) | Yes | +| `tcpPort` | A fixed `endpoint.port` | Yes | +| `tcpLanguageServer` | A language server of the same plugin: `endpoint` has `languageID` and `command`; for each session the host sends `workspace/executeCommand` and the server answers a loopback port | No, and it must not have one | + +Every TCP endpoint is loopback only. The `tcpLanguageServer` command is part of the install and update review, and changing it asks the user again. + +### Adapter behavior + +A host never recognizes an adapter by its ID. What is specific to an adapter is declared, and the shared debug engine follows the declaration: + +| Declaration | Meaning | +| --- | --- | +| `features.hotCodeReplace.request` | The DAP command the host sends to apply changed code to the running debuggee (the standard `redefineClasses` inspection). Present means the adapter supports hot code replace; absent means it does not | +| `features.hotCodeReplace.event` | A DAP event whose `message` the Debug Console shows as progress | +| `features.hotCodeReplace.changedField`, `errorField` | Fields of the response body: the list of replaced units, and a failure message the adapter reports in a successful response. A response without `changedField` replaces nothing visible | +| `features.stepFilters.argument` | The launch argument through which the adapter receives stepping filters, shaped `{skipClasses, skipSynthetics, skipStaticInitializers, skipConstructors}`. An argument the user's configuration already sets is kept | +| `defaults.stepFilters` | `classNameFilters` (up to 256 single-line patterns of at most 256 characters), `skipSynthetics`, `skipStaticInitializers`, `skipConstructors`, `hideFilteredStackFrames`. Used when neither the launch nor the user's settings name filters | + +Names (`request`, `event`, fields, `argument`) are a letter followed by up to 63 letters, digits, `.`, `_`, or `-`. A declaration with a malformed name, or a field the contract does not define, is `plugin.manifest.invalid` with the `field` that names it. The launch plan's `debugAdapter` descriptor carries both so the host can pass them to `debug.createSession`. An adapter that declares neither has none of these behaviors. + +### Target bindings and console + +| Declaration | Notes | +| --- | --- | +| `configurations.launch`, `configurations.attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings`: `program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, plus bounded `args` ← `target.arguments` and `env` ← `target.environment`; only `attach` may bind `connection`. A configuration for an undeclared request is refused | +| `configurations.launch.executable` | For a target that is a source file the adapter cannot debug directly (C, C++, Swift): `setting` names a `file` setting of the plugin, and up to 8 `candidates[]` (starting with `${workspaceFolder}/` or `${fileDirectory}/`, no `..`) are the paths the host looks for when the user chose nothing. Declare it together with `targetBindings.program: "target.executable"`. The path the user chose in the setting always wins: if it is not a runnable file that is an error, and the candidates are not tried instead | +| `capabilities.disabled` | Standard DAP `supports*` names or `exceptionBreakpointFilters` the host treats as absent | +| `console.commandPrefix` | Text of 1–8 characters without spaces (a backtick for lldb-dap) that the adapter's console reads as "what follows is a debugger command"; the host puts it before the typed line only when asking for completions and moves the adapter's answer back, so the user never sees it | +| `console.commands[]` | Debugger commands (`name`, optional one-line `description`) the Debug Console offers as completions. An adapter that answers the DAP `completions` request completes live, so list commands only for one that does not; when `console` is present, declare at least `commandPrefix` or `commands[]` | + +### Remote attach + +When the debuggee runs on another machine (a JVM with `-agentlib:jdwp`, a Node inspector, gdbserver), the adapter still runs locally and only the debuggee is remote. The `targetBindings.connection` of an `attach` configuration writes the host, port, and path mappings the user confirmed into the adapter's `arguments`: + +| Field | Notes | +| --- | --- | +| `host` + `port` | Declared together; values are paths into `arguments` (1–4 segments, such as `connect.host`) | +| `address` | One `host:port` string, IPv6 in brackets; replaces `host` + `port` or sits beside them | +| `pathMappings.shape: "objects"` | A list of objects; `localKey` and `remoteKey` name each object's keys | +| `pathMappings.shape: "first"` | For an adapter with a single pair of roots; a target with more is refused, not truncated | +| `pathMappings.shape: "dictionary"` | One object; `keyedBy` chooses which side is the key | +| `pathMappings.shape: "pairs"` | A list of two-element arrays; `order` is `remoteLocal` or `localRemote` | + +Core treats only `localhost`, `127.0.0.0/8`, and `::1` as local; every other host is `remote: true`, and the host asks the user to confirm it each time without remembering the answer by default. `connection` declares no external DAP endpoint or remote adapter execution (such as `ssh host gdb -i=dap`), and no proxy, tunnel, or port forwarding. + +### Starting a debug session from a plugin backend + +A backend can ask the host to start a debug session: the backend decides what to start, and the debug UI, the process, the console, the Stop button, and the end of the session belong to the host. There are two paths. Both are `litheFunctions` host methods, and all but `run/stop` take `context: {contextID, generation}`: + +| Method (prefix `lithe/host/`) | Permission | Params | Result | +| --- | --- | --- | --- | +| `debug/start` | `debug.start` | `context, label, adapter, request, configuration` | `{sessionID}` | +| `run/start` with `debug` | `run.start` | `context, label, process, debug: {configurationType, target}`, plus `temporaryFiles?` and `rerun?` | `{runID}` | +| `run/stop` | `run.start` | `{runID}` or `{sessionID}` | `null` | + +`debug/start`: + +- `label`: 1 to 256 characters without control characters. +- `adapter`: one of the plugin's own adapters, by its local ID or its qualified ID `/`. An adapter of another plugin, and one the plugin does not declare, both return `plugin.host.notFound`. +- `request`: `launch` or `attach`, and the adapter must serve it. +- `configuration`: a JSON object of at most 256 KiB that the host passes to the adapter as the DAP launch or attach arguments, on top of the adapter's declared defaults. `noDebug` and every key that starts with `__` belong to the host and are refused. + +The `debug` field of `run/start` makes a host-owned run a debug session: + +- `configurationType` is a public debug adapter alias, as in [registry connection debugging](../../contract/v1/README.md#registry-connection-debugging). +- With `target` `{kind: "connection", port}`, the process listens for a debugger on `port` (1 to 65535; the backend puts it into the arguments), and the host waits for it and attaches the adapter. With `{kind: "launch"}`, the adapter starts the process itself, taking program, arguments, directory, and environment from `process` through its `targetBindings`. + +The other rules of `run/start`, `run/stop`, and `run/didExit` are in [Plugin backend](backend.md). Malformed params are `plugin.host.invalidParams`, and Core's rejection (`plugin.debug.invalidStart` or `plugin.run.invalidStart`, with the `field`) is in `details.code` and `details.field`; a closed context is `plugin.host.contextExpired`. + +### Host status + +| Host | Status | +| --- | --- | +| Lithe desktop | Declares `debugAdapters`: stdio adapters and adapters inside the plugin's language server over loopback TCP; `launch` and `attach` (a remote host after the user confirms it), argument and environment bindings, `features`, and `defaults`. The backend-started `debug/start` and `run/start` are host services a backend discovers at runtime through `getCapabilities`, not through the capability table. A build provider's native debug preparation and runtime debug are not presented on the desktop yet. Details are in the [desktop source](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### Contract sections + +| Section | Content | +| --- | --- | +| [Debug adapters](../../contract/v1/README.md#debug-adapters) | Every field, bindings, `executable` candidates, console, transports | +| [Adapter features and defaults](../../contract/v1/README.md#adapter-features-and-defaults) | `features.hotCodeReplace`, `features.stepFilters`, and `defaults.stepFilters` | +| [Run and debug sessions](../../contract/v1/README.md#run-and-debug-sessions) | `debug/start`, the `debug` field of `run/start`, and `run/stop`: params, limits, failure codes | +| [Remote attach](../../contract/v1/README.md#remote-attach) | The `connection` binding, the four mapping shapes, the target format, local detection, refusal layers | +| [Native debug preparation](../../contract/v1/README.md#native-debug-preparation) | Build providers preparing native debug targets | +| [Registry connection debugging](../../contract/v1/README.md#registry-connection-debugging) | Service runs choosing an attach adapter by configuration type | +| [Permissions and activation](../../contract/v1/README.md#permissions-and-activation) | `debug.launch`, `debug.attach`, `debug.start`, `onDebug:` | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`debug-adapters/debug-adapter.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) | Valid example: two adapters (`example`, `native`), and a `file` setting picks the program to debug | +| [`debug-adapters/debug-adapter-language-server.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-language-server.plugin.json) | `tcpLanguageServer`: the debug server lives in the plugin's language server | +| [`debug-adapters/debug-adapter-remote-attach.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json) | Six adapters, one per way of taking a `connection`: `flat` (top-level `host` and `port`), `nested` (nested paths with `objects`), `scalar` (`first`), `joined` (`address`), `keyed` (`dictionary`), `paired` (`pairs`) | +| [`debug-adapters/debug-adapter-features.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json) | Declares `backend` and `debugAdapters`, with the permissions `backend.launch`, `debug.launch`, and `debug.start`: the adapter `script` declares `features` (`hotCodeReplace`, `stepFilters`) and `defaults.stepFilters` | +| [`debug-adapters/debug-start.json`](../../contract/v1/fixtures/debug-adapters/debug-start.json) | The params of `debug/start` and the normalized start or refusal `debug.decodeBackendConfiguration` returns for each of its 10 cases: the plugin's own adapter by local or qualified ID, an adapter of another plugin, an undeclared adapter, `noDebug`, `__` keys, and more; the 256 KiB limit is covered by Core's own tests | +| [`run/run-start.json`](../../contract/v1/fixtures/run/run-start.json) | The params of `run/start` and the `run.decodeStart` results; its cases include a `debug` `connection` target, a `launch` target, and a port out of range | +| [`debug-adapters/debug-adapter-rejections.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter-rejections.json) | 74 refusal cases, each with its stable code, field, and reporting layer (`schema`, `coreManifest`, `corePlan`, `coreInstall`) | +| [`build-providers/build-provider-debug-preparation.plugin.json`](../../contract/v1/fixtures/build-providers/build-provider-debug-preparation.plugin.json) | `buildProviders` with `debugAdapters`: native debug preparation | +| [`build-providers/build-debug-preparation.json`](../../contract/v1/fixtures/build-providers/build-debug-preparation.json) | `lithe/build/prepareDebug` results and refusal cases | +| [`build-providers/build-connection-debug-options.json`](../../contract/v1/fixtures/build-providers/build-connection-debug-options.json) | Service-run debug options (connecting by configuration type) | +| [Lithe-IDEA/lithe-plugin-lldb](https://github.com/Lithe-IDEA/lithe-plugin-lldb) | An lldb-dap debug plugin, using `executable` candidates and `console.commandPrefix` | + +### Related guides + +- [Writing a debug adapter plugin](../../guides/debug-adapter-plugin.md) +- [Native build, test, and run preparation](../../guides/native-build-workflows.md) diff --git a/shared/plugins/reference/domains/editor.md b/shared/plugins/reference/domains/editor.md new file mode 100644 index 00000000..902f646b --- /dev/null +++ b/shared/plugins/reference/domains/editor.md @@ -0,0 +1,179 @@ +# 编辑器、文档与预览 / Editor, documents, and previews + +[中文](#中文) · [English](#english) + +## 中文 + +插件后端通过插件函数协议读取打开的文档和选区、做版本化编辑、打开和定位文件、打开 HTML 预览或外部浏览器,并在编辑器里注册自己的动作和语言功能 Provider(定义、悬浮、补全等),还能把没有对应文件的文档(例如反编译出的库类源码)按 URI scheme 交给宿主打开。这些都是宿主方法,由 Rust Core 按权限校验、宿主执行;插件不直接操作编辑器。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | 没有专用字段;用 `backend` 加下面的权限 | +| 能力 | `requires.backend: 0`,见[能力与协议版本](../capabilities-and-versions.md) | +| 协议 | `litheFunctions`,版本 `0` | +| API 编号 | API-09、10、12、15、16、25、27,见 [API 总表](../api-catalog.md) | +| 权限 | `documents.read`、`editor.edit`、`editor.open`、`preview.open`、`browser.open`、`editor.actions`(同时需要 `documents.read`)、`editor.providers`(同时需要 `documents.read`)、`documents.contentProvider`(只授予 `documents/registerContentProvider`,不授予读写文档) | +| 激活事件 | 与后端相同:`onWorkspaceOpen`、`onCommand:` 或网页调用 | +| Lithe 桌面端 | 已声明 `backend`;各方法是否可用由后端用 `getCapabilities` 在运行时发现,见[宿主现状](#宿主现状) | + +### 方法对照 + +| 方法 | 权限 | API | 说明 | +| --- | --- | --- | --- | +| `documents/list`、`documents/getSnapshot`、`editor/getActive`、`editor/getSelections` | `documents.read` | API-09 | 包含未保存状态、版本、编码、换行符;快照最多 128 KiB | +| `editor/applyEdits`、`editor/replaceSelections`、`editor/insertText` | `editor.edit` | API-10 | 校验文档版本;输入法组字中和只读文档会被拒绝;一批编辑是一次撤销 | +| `editor/open`、`editor/reveal` | `editor.open` | API-15 | 返回 `documentID` 和版本;传入范围时只把光标放到起点 | +| `preview/openHTML` | `preview.open` | API-16 | `editorAndPreview` 或 `previewOnly` | +| `browser/openExternal` | `browser.open` | API-16 | HTTP(S) 地址或已保存的 HTML | +| `editor/registerActions` | `editor.actions` + `documents.read` | API-27 | 先用 `getCapabilities` 发现,再按工作区上下文注册;动作绑定普通函数和文件范围,不依赖 LSP、构建或运行。动作可以带多个图标、最多 4 个 `alternatives` 和点击结果 `reveal`,可用图标由宿主在 `services.editor.options.actionIcons` 中声明 | +| `editor/registerProviders` | `editor.providers` + `documents.read` | API-25 | 先用 `getCapabilities` 发现方法和 `services.editor.options.providerTypes`,再按工作区上下文注册;把 `definition`、`hover`、`completion`、`codeLens`、`foldingRange`、`semanticTokens`、`documentLink` 这 7 种类型绑定到普通函数,不在 `providerTypes` 中的类型返回 `unsupportedOption`;语言服务器的结果优先,插件结果只补充;预算由宿主决定 | + +所有方法都要带 `context: {contextID, generation}`;除 `documents/registerContentProvider` 外,远程工作区上下文返回 `plugin.host.authorityUnsupported`。`documents/registerContentProvider` 见下一节。 + +### 编辑器动作与语言功能 Provider + +| 项目 | 规则 | +| --- | --- | +| 动作形状 | 后端的采集函数返回 `{actions: [{id, range, title, icon, invoke}]}`:位置是 1 起算的 UTF-16,范围半开且不能切开代理对;至多 1000 个动作、序列化后至多 1 MiB,格式错误的结果整体被拒绝;同一行的动作共用一个菜单 | +| 图标 | `icon` 是宿主声明的展示标记,图标只表示一张图,不表示操作:测试图标不会让动作变成测试,宿主也不会因为图标启动任何东西 | +| 采集与点击 | 采集只读,不能发网络请求或执行业务;编辑会立即让旧动作失效并取消旧的采集;点击时宿主再次校验文档、上下文、会话和注册。点击要运行或调试代码,应在 `getCapabilities` 列出 `run/start`、`debug/start` 后通过这两个方法发起,让运行出现在宿主的 Run 和 Debug 窗口里,见[调试适配器](debug-adapters.md) | +| 动作校验 | 共享的 Core 命令 `plugins.editorActions.decode`,请求带 `version`(必须为 `0`)、`kind`(`registration`、`actions` 或 `invokeResult`)、宿主声明的 `icons` 和 `sourceBytes` | +| Provider 合并 | 语言服务器的结果在先:悬浮段落和补全项跟在语言服务器之后,定义把插件位置加到服务器的位置上,代码镜头、折叠范围和链接直接加入,语义标记只用于语言服务器没有语义标记的文件;合并由 Core 命令 `plugins.editorProviders.decode`(`kind: "mergeDefinitions"`)完成,所以每个宿主合并结果一致 | +| 预算 | 悬浮和补全 400 ms,定义 1500 ms,其余 2000 ms;迟到的或属于旧文档版本的答案被无声丢弃;注册不能自定预算 | +| 没有这些方法的宿主 | `getCapabilities` 没有列出 `editor/registerActions` 或 `editor/registerProviders` 时宿主不呈现对应功能,插件仍保留自己的命令和窗口入口;不认识 `editor.actions` 或 `editor.providers` 权限的宿主拒绝安装该包,不会悄悄授予 | + +### 内容 Provider(API-12) + +语言服务器可能给出没有文件的文档(例如库类的反编译源码)。插件把这些文档当作一个 URI scheme 提供,用 `documents/registerContentProvider`,权限 `documents.contentProvider`(需要 `backend`)。 + +| 方法 | 权限 | API | 参数 | 结果 | +| --- | --- | --- | --- | --- | +| `documents/registerContentProvider` | `documents.contentProvider` | API-12 | `{context, scheme, function}` | `{subscriptionID}` | + +- 注册只记录绑定,不会调用后端;`function` 必须已经在当前会话里注册。用已有的 `subscriptions/dispose` 撤销。 +- `scheme` 是 1 到 64 个小写 ASCII 字母、数字、`+`、`-` 或 `.`,以字母开头。宿主或平台自己解析的 scheme 会被拒绝:`about`、`blob`、`data`、`file`、`ftp`、`http`、`https`、`javascript`、`lithe`、`mailto`、`untitled`、`ws`、`wss`。scheme 不合法、保留或 `function` 不是函数 ID,返回 `plugin.host.invalidParams`。 +- 每个工作区窗口里每个 scheme 只能有一个 Provider;再注册同一个 scheme 返回 `plugin.host.preconditionFailed`。 +- 打开 URI 带这个 scheme 的文档时,宿主用工作区 `context` 和 `{uri}` 通过 `lithe/function/invoke` 调用该函数。函数返回字符串或 `{content: string}`,最多 1 MiB,文档以只读方式打开;其他返回、失败或超时(宿主等 30 秒)都会向用户显示错误,不打开任何文档。 +- Provider 是文本的唯一来源:文档关闭后宿主不保留副本,重新打开时再问一次。会话结束或上下文关闭会撤销 Provider。 +- 只有 `documents.contentProvider` 授予这个方法;聚合服务名 `documents` 和其他授权都不会放行,返回 `plugin.host.permissionDenied`(`-32010`)。 + +### 宿主现状 + +| 宿主 | 现状 | +| --- | --- | +| Lithe 桌面端 | 声明 `backend`。后端能调用的方法以 `getCapabilities` 的返回为准,它只列出权限允许、且宿主注册了处理器的方法。桌面端提供文档、编辑器读写和打开定位、`browser/openExternal`(仅 HTTP 和 HTTPS)、内容 Provider、编辑器动作和语言功能 Provider;不提供 `preview/openHTML`。能力清单见[桌面端源码](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Public document, file and window methods](../../contract/v1/README.md#public-document-file-and-window-methods) | 文档、编辑、打开、预览、浏览器方法的参数、限制和失败码 | +| [Independent editor actions](../../contract/v1/README.md#independent-editor-actions) | `editor/registerActions` 的注册、范围、调用和撤回,以及图标、`alternatives` 和点击结果 | +| [Editor language-feature providers](../../contract/v1/README.md#editor-language-feature-providers) | `editor/registerProviders` 的注册、选择器、结果形状、预算和合并规则 | +| [Content providers](../../contract/v1/README.md#content-providers) | `documents/registerContentProvider` 的 scheme 规则、调用、只读文档和撤回 | +| [Workspace contexts and capability discovery](../../contract/v1/README.md#workspace-contexts-and-capability-discovery) | `contextID`/`generation` 与 `getCapabilities` | +| [Outcomes and error codes](../../contract/v1/README.md#outcomes-and-error-codes) | 结果与错误码 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`editor/editor-actions.plugin.json`](../../contract/v1/fixtures/editor/editor-actions.plugin.json) | `backend` 加 `editor.actions`、`documents.read`:`onWorkspaceOpen` 启动后注册编辑器动作 | +| [`editor/editor-actions.json`](../../contract/v1/fixtures/editor/editor-actions.json) | `plugins.editorActions.decode` 的 8 个 Core 用例:宿主声明的图标和源码上限、`alternatives`、点击结果 `reveal`,以及整个结果被拒绝的情形 | +| [`editor/editor-providers.plugin.json`](../../contract/v1/fixtures/editor/editor-providers.plugin.json) | `backend` 加 `editor.providers`、`documents.read`:`onWorkspaceOpen` 启动后注册编辑器语言功能 Provider | +| [`editor/editor-providers.json`](../../contract/v1/fixtures/editor/editor-providers.json) | 注册与结果的 Core 用例:6 个注册用例(选择器和类型校验、`unsupportedOption`)和 11 个结果用例(7 种类型的结果形状与被丢弃的结果) | +| [`backend/command-backend.plugin.json`](../../contract/v1/fixtures/backend/command-backend.plugin.json) | 无工具窗口的 `ui` 命令读取文档并编辑 | +| [`backend/function-protocol.json`](../../contract/v1/fixtures/backend/function-protocol.json) | `editor/applyEdits`、`editor/registerActions` 的标准请求与回复,以及 `versionConflict`、`readOnly` 等宿主失败码;还有 `documents/registerContentProvider` 的用例:需要 `documents.contentProvider` 授权、聚合名 `documents` 不够、保留 scheme、大写或数字开头的 scheme、非法函数名、缺少 `context` | + +### 相关指南 + +- [插件后端:工作区上下文与宿主服务](../../guides/plugin-backend-workspace.md) + +--- + +## English + +Through the plugin-function protocol, a plugin backend reads open documents and selections, applies versioned edits, opens and reveals files, opens an HTML preview or an external browser, and registers its own editor actions and language-feature providers (definition, hover, completion, and so on). It can also serve documents that have no file (such as the decompiled source of a library class) to the host as a URI scheme. These are host methods: Rust Core checks permissions and the host executes them. The plugin never drives the editor directly. + +| Item | Value | +| --- | --- | +| Manifest field | None of its own; use `backend` with the permissions below | +| Capability | `requires.backend: 0`; see [capabilities and protocol versions](../capabilities-and-versions.md) | +| Protocol | `litheFunctions`, version `0` | +| API IDs | API-09, 10, 12, 15, 16, 25, 27; see the [API catalog](../api-catalog.md) | +| Permissions | `documents.read`, `editor.edit`, `editor.open`, `preview.open`, `browser.open`, `editor.actions` (also needs `documents.read`), `editor.providers` (also needs `documents.read`), `documents.contentProvider` (grants only `documents/registerContentProvider`, not reading or editing documents) | +| Activation | As for the backend: `onWorkspaceOpen`, `onCommand:`, or a page call | +| Lithe desktop | Declares `backend`; a backend finds which methods are usable with `getCapabilities` at runtime. See [Host status](#host-status) | + +### Methods compared + +| Method | Permission | API | Notes | +| --- | --- | --- | --- | +| `documents/list`, `documents/getSnapshot`, `editor/getActive`, `editor/getSelections` | `documents.read` | API-09 | Includes unsaved state, version, encoding, line endings; snapshots up to 128 KiB | +| `editor/applyEdits`, `editor/replaceSelections`, `editor/insertText` | `editor.edit` | API-10 | Checks the document version; refused during IME composition and on read-only documents; one batch is one undo step | +| `editor/open`, `editor/reveal` | `editor.open` | API-15 | Returns `documentID` and version; a range only places the caret at its start | +| `preview/openHTML` | `preview.open` | API-16 | `editorAndPreview` or `previewOnly` | +| `browser/openExternal` | `browser.open` | API-16 | An HTTP(S) URL or saved HTML | +| `editor/registerActions` | `editor.actions` + `documents.read` | API-27 | Discover it with `getCapabilities`, then register per workspace context; actions bind ordinary functions and file scopes, independent of LSP, build, or run. An action can carry multiple icons, up to 4 `alternatives`, and a click result `reveal`; the host declares the usable icons in `services.editor.options.actionIcons` | +| `editor/registerProviders` | `editor.providers` + `documents.read` | API-25 | Discover the method and `services.editor.options.providerTypes` with `getCapabilities`, then register per workspace context; binds the 7 types `definition`, `hover`, `completion`, `codeLens`, `foldingRange`, `semanticTokens`, and `documentLink` to ordinary functions, and a type outside `providerTypes` returns `unsupportedOption`; language-server results come first and plugin results only add to them; the host sets the budgets | + +Every method takes `context: {contextID, generation}`; except for `documents/registerContentProvider`, a remote workspace context returns `plugin.host.authorityUnsupported`. `documents/registerContentProvider` is described in the next section. + +### Editor actions and language-feature providers + +| Topic | Rule | +| --- | --- | +| Action shape | The backend's collection function returns `{actions: [{id, range, title, icon, invoke}]}`: positions are one-based UTF-16, ranges are half-open and cannot split a surrogate pair; at most 1000 actions and 1 MiB serialized, and a malformed answer is refused whole; actions on the same line share one menu | +| Icons | `icon` is a presentation token the host declares; an icon names a picture, not an operation: a test icon does not make the action a test, and the host never starts anything because of an icon | +| Collection and click | Collection is read-only, with no network request or business execution; an edit invalidates the old actions immediately and cancels older collection; a click makes the host validate document, context, session, and registration again. A click that runs or debugs code should use `run/start` and `debug/start` once `getCapabilities` lists them, so the run appears in the host's Run and Debug windows; see [Debug adapters](debug-adapters.md) | +| Action validation | The shared Core command `plugins.editorActions.decode`; its request carries `version` (must be `0`), `kind` (`registration`, `actions`, or `invokeResult`), and the host-declared `icons` and `sourceBytes` | +| Provider merging | Language-server results come first: hover sections and completion items follow the server's, definitions add plugin locations to the server's, code lenses, folding ranges, and links are added, and semantic tokens apply only to files whose language server has none; the merge is the Core command `plugins.editorProviders.decode` with `kind: "mergeDefinitions"`, so every host merges alike | +| Budgets | Hover and completion 400 ms, definition 1500 ms, the rest 2000 ms; a late answer, or one for an older document version, is dropped without an error; a registration cannot set its own budget | +| A host without these methods | A host whose `getCapabilities` does not list `editor/registerActions` or `editor/registerProviders` presents no such feature, and the plugin keeps its own command and window entry points; a host that does not recognize the `editor.actions` or `editor.providers` permission refuses the package instead of granting it silently | + +### Content providers (API-12) + +A language server may hand out documents that have no file (such as the decompiled source of a library class). The plugin serves them as a URI scheme with `documents/registerContentProvider`, granted by `documents.contentProvider` (needs `backend`). + +| Method | Permission | API | Params | Result | +| --- | --- | --- | --- | --- | +| `documents/registerContentProvider` | `documents.contentProvider` | API-12 | `{context, scheme, function}` | `{subscriptionID}` | + +- Registration only records the binding and never calls the backend; `function` must already be registered on the session. Withdraw it with the existing `subscriptions/dispose`. +- `scheme` is 1 to 64 lowercase ASCII letters, digits, `+`, `-`, or `.`, and starts with a letter. The schemes the host or the platform resolves are refused: `about`, `blob`, `data`, `file`, `ftp`, `http`, `https`, `javascript`, `lithe`, `mailto`, `untitled`, `ws`, and `wss`. A malformed or reserved scheme, or a `function` that is not a function ID, returns `plugin.host.invalidParams`. +- A scheme has one provider per workspace window; registering it again returns `plugin.host.preconditionFailed`. +- When a document whose URI has that scheme is opened, the host calls the function through `lithe/function/invoke` with the workspace `context` and `{uri}`. The function answers a string, or `{content: string}`, of at most 1 MiB, and the document opens read-only. Any other answer, a failure, or a timeout (the host waits 30 seconds) shows the user an error and opens nothing. +- The provider is the only source of the text: the host keeps no copy once the document closes and asks again when it is reopened. Ending the session or closing the context withdraws the provider. +- Only `documents.contentProvider` grants this method; the aggregate service name `documents` and other grants do not, and return `plugin.host.permissionDenied` (`-32010`). + +### Host status + +| Host | Status | +| --- | --- | +| Lithe desktop | Declares `backend`. The methods a backend can call are whatever `getCapabilities` returns, which lists only methods the permissions allow and the host has registered a handler for. The desktop provides documents, editor reads and edits, open and reveal, `browser/openExternal` (HTTP and HTTPS only), content providers, editor actions, and language-feature providers; it does not provide `preview/openHTML`. The capability list is in the [desktop source](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### Contract sections + +| Section | Content | +| --- | --- | +| [Public document, file and window methods](../../contract/v1/README.md#public-document-file-and-window-methods) | Parameters, limits, and failure codes for document, edit, open, preview, and browser methods | +| [Independent editor actions](../../contract/v1/README.md#independent-editor-actions) | Registering, scoping, invoking, and withdrawing `editor/registerActions`, plus icons, `alternatives`, and click results | +| [Editor language-feature providers](../../contract/v1/README.md#editor-language-feature-providers) | Registering `editor/registerProviders`, selectors, result shapes, budgets, and merge rules | +| [Content providers](../../contract/v1/README.md#content-providers) | Scheme rules, invoking, read-only documents, and withdrawing for `documents/registerContentProvider` | +| [Workspace contexts and capability discovery](../../contract/v1/README.md#workspace-contexts-and-capability-discovery) | `contextID`/`generation` and `getCapabilities` | +| [Outcomes and error codes](../../contract/v1/README.md#outcomes-and-error-codes) | Outcomes and error codes | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`editor/editor-actions.plugin.json`](../../contract/v1/fixtures/editor/editor-actions.plugin.json) | `backend` with `editor.actions` and `documents.read`: registers editor actions after `onWorkspaceOpen` | +| [`editor/editor-actions.json`](../../contract/v1/fixtures/editor/editor-actions.json) | 8 Core cases for `plugins.editorActions.decode`: the host-declared icons and source limit, `alternatives`, click-result `reveal`, and results refused whole | +| [`editor/editor-providers.plugin.json`](../../contract/v1/fixtures/editor/editor-providers.plugin.json) | `backend` with `editor.providers` and `documents.read`: registers editor language-feature providers after `onWorkspaceOpen` | +| [`editor/editor-providers.json`](../../contract/v1/fixtures/editor/editor-providers.json) | Core cases for registrations and results: 6 registration cases (selector and type validation, `unsupportedOption`) and 11 result cases (result shapes for the 7 types, and dropped results) | +| [`backend/command-backend.plugin.json`](../../contract/v1/fixtures/backend/command-backend.plugin.json) | A `ui` command without a tool window that reads and edits documents | +| [`backend/function-protocol.json`](../../contract/v1/fixtures/backend/function-protocol.json) | Canonical requests and replies for `editor/applyEdits` and `editor/registerActions`, plus host failure codes such as `versionConflict` and `readOnly`; also the `documents/registerContentProvider` cases: it needs the `documents.contentProvider` grant, the aggregate `documents` name is not enough, reserved schemes, an uppercase or digit-first scheme, a malformed function name, and a missing `context` | + +### Related guides + +- [Plugin backend: workspace contexts and host services](../../guides/plugin-backend-workspace.md) diff --git a/shared/plugins/reference/domains/keymap-import.md b/shared/plugins/reference/domains/keymap-import.md new file mode 100644 index 00000000..0513123b --- /dev/null +++ b/shared/plugins/reference/domains/keymap-import.md @@ -0,0 +1,99 @@ +# 快捷键导入 / Shortcut import + +[中文](#中文) · [English](#english) + +## 中文 + +插件可以把其他编辑器的快捷键配置转换成 Lithe 的快捷键。插件声明 1–8 个导入提供者,说明怎样识别某个编辑器的配置目录、要读哪些文件;用户在导入窗口里选中目录后,宿主只把声明过的文件按块交给插件后端,后端返回规范化的映射,由 Core 对照宿主的动作目录校验。插件拿不到绝对路径,也读不到用户没选中的目录。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `keymapImportProviders[]`:`{id, displayName, recognition, inputs}` | +| 能力版本 | `keymapImportProviders` 0,同时需要 `backend` 0。见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | `litheKeymapImport` 0:Core 只向声明了提供者的插件会话提供 `experimental.litheKeymapImport: {versions: [0], providers}`,后端回答 `{version: 0, providers}`(非空、无重复的子集),见[进程协议版本](../capabilities-and-versions.md#进程协议版本) | +| API 编号 | 不在 API-01–44 内(安装和导入层面的契约),见 [API 总表](../api-catalog.md#计划中的-api)下方的说明 | +| 权限 | `backend.launch`、`keymaps.provide`、`keymaps.readSelectedSources` | +| 激活事件 | 没有;宿主只在用户发起导入时启动后端 | +| 后端启动 | 包内可执行文件或包内运行时,不能依赖需要发现的运行时 | +| Lithe 桌面端 | 未声明 `keymapImportProviders`,也没有读取导入源的实现,安装时拒绝需要它的包 | +| 官方 SDK | [Node](../../sdk/node/README.md#shortcut-import-providers) 0.5.0、[Java](../../sdk/java/README.md#shortcut-import-providers) 0.5.0、[Rust](../../sdk/rust/README.md#shortcut-import-providers) 0.4.0 均实现 `litheKeymapImport` 0 | + +### 声明与读取 + +| 部分 | 规则 | +| --- | --- | +| `id` | 插件内唯一,小写字母开头,只含小写 ASCII 字母、数字和连字符,最多 64 字节 | +| `recognition` | 1–16 个互不相同的只看元数据的探针:`{kind: "file" \| "directory", path}` 匹配所选目录下的条目,`{kind: "root", base: "userConfig", components}` 匹配平台用户配置目录(macOS 的 Application Support、Windows 的 Roaming AppData)下的确切路径。`components` 为 1–8 个安全的字面名称,最后一段可以是 `*`(只匹配一层目录)。识别不授权读内容,也不向插件暴露绝对路径 | +| `inputs` | 1–16 个互不相同的选择器:`{kind: "file", path}` 选一个相对文件;`{kind: "directory", path, extension}` 选该目录下指定小写扩展名的直接子文件,不递归,最多 64 个匹配(最多扫描 4096 个目录条目)。路径最长 256 字符,不含空段、`.`、`..`、反斜杠、冒号、通配符和控制字符 | +| 读取限制 | 拒绝符号链接和离开所选目录的路径;单个文件最多 1 MiB,一次快照最多 8 MiB,每块最多 128 KiB | +| 宿主方法 | `lithe/host/keymapImport/listInputs` 返回 `{files: [{id, path, byteCount}]}`;`lithe/host/keymapImport/readChunk` 按 `fileID`、`offset`、`maximumBytes`(1–131072)返回 base64 的 `{data}`。两者都带 `snapshotID` 与 `providerID`;错误不暴露绝对路径 | +| 导入请求 | `lithe/keymap/import` 只带 `{providerID, snapshotID}`;快照是宿主持有的不透明引用,导入或会话结束时撤销,同一提供者的新快照会撤销旧快照,捕获后被替换或修改的文件会被拒绝 | + +提供者负责理解源格式,不能把有条件或多键序列的规则悄悄变成全局快捷键。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Shortcut import providers (Core foundation)](../../contract/v1/README.md#shortcut-import-providers-core-foundation) | 清单字段、识别、输入、协商、宿主读取方法 | +| [Normalized shortcut import validation](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md#normalized-shortcut-import-validation) | Core 命令、结果校验规则、限制和示例(Rust Core API 文档) | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`keymap-import/keymap-import-provider.plugin.json`](../../contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json) | 两个提供者:`first` 用文件探针和两个 `userConfig` 根探针(含 `*`)、读一个文件;`second` 用目录探针、读一个文件加一个目录下的 `.xml` | +| [Lithe-IDEA/lithe-plugin-keymap-import](https://github.com/Lithe-IDEA/lithe-plugin-keymap-import) | 官方快捷键导入插件(预发布) | + +### 相关指南 + +- [插件后端:工作区上下文与宿主服务](../../guides/plugin-backend-workspace.md) + +--- + +## English + +A plugin can convert another editor's shortcut configuration into Lithe shortcuts. The plugin declares one to eight import providers that say how to recognize an editor's configuration directory and which files to read. After the user selects a directory in the import window, the host passes only the declared files, in chunks, to the plugin backend; the backend returns normalized mappings, which Core validates against the host's action catalog. The plugin never sees absolute paths and cannot read a directory the user did not select. + +| Item | Value | +| --- | --- | +| Manifest field | `keymapImportProviders[]`: `{id, displayName, recognition, inputs}` | +| Capability versions | `keymapImportProviders` 0, together with `backend` 0. See [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | `litheKeymapImport` 0: Core offers `experimental.litheKeymapImport: {versions: [0], providers}` only to a plugin session that declares providers, and the backend answers `{version: 0, providers}` (a nonempty, duplicate-free subset); see [process protocol versions](../capabilities-and-versions.md#process-protocol-versions) | +| API IDs | Outside API-01–44 (an installation and import contract); see the note under the [API catalog](../api-catalog.md#planned-apis) | +| Permissions | `backend.launch`, `keymaps.provide`, `keymaps.readSelectedSources` | +| Activation events | None; the host starts the backend only when the user starts an import | +| Backend launch | A packaged executable or packaged runtime, without discovered runtime dependencies | +| Lithe desktop | Does not declare `keymapImportProviders` and has no import source reader; packages that require it are refused before installation | +| Official SDKs | [Node](../../sdk/node/README.md#shortcut-import-providers) 0.5.0, [Java](../../sdk/java/README.md#shortcut-import-providers) 0.5.0, and [Rust](../../sdk/rust/README.md#shortcut-import-providers) 0.4.0 implement `litheKeymapImport` 0 | + +### Declaration and reading + +| Part | Rules | +| --- | --- | +| `id` | Unique in the plugin; starts with a lowercase letter; lowercase ASCII letters, digits, and hyphens only; at most 64 bytes | +| `recognition` | 1–16 distinct metadata-only probes: `{kind: "file" \| "directory", path}` matches an entry beneath the selected directory, and `{kind: "root", base: "userConfig", components}` matches an exact path beneath the platform's user configuration directory (macOS Application Support, Windows roaming AppData). `components` are 1–8 safe literal names, and the last may be `*`, matching exactly one directory component. Recognition never authorizes reading contents and never exposes an absolute path to the provider | +| `inputs` | 1–16 distinct selectors: `{kind: "file", path}` selects one relative file; `{kind: "directory", path, extension}` selects that directory's immediate children with the given lowercase extension, never recursively, at most 64 matches (at most 4096 directory entries scanned). Paths are at most 256 characters and contain no empty, `.` or `..` segments, backslashes, colons, wildcards, or control characters | +| Read limits | Symlinks and paths leaving the selected directory are refused; at most 1 MiB per file, 8 MiB per snapshot, and 128 KiB per chunk | +| Host methods | `lithe/host/keymapImport/listInputs` returns `{files: [{id, path, byteCount}]}`; `lithe/host/keymapImport/readChunk` takes `fileID`, `offset`, and `maximumBytes` (1–131072) and returns base64 `{data}`. Both take `snapshotID` and `providerID`; errors never disclose absolute paths | +| Import request | `lithe/keymap/import` carries only `{providerID, snapshotID}`; the snapshot is an opaque host-owned reference, revoked when the import or session ends. A new snapshot for the same provider revokes the previous one, and files replaced or changed after capture are refused | + +Providers own the source formats and must not silently promote conditional or multi-stroke rules to global shortcuts. + +### Contract sections + +| Section | Content | +| --- | --- | +| [Shortcut import providers (Core foundation)](../../contract/v1/README.md#shortcut-import-providers-core-foundation) | Manifest fields, recognition, inputs, negotiation, host read methods | +| [Normalized shortcut import validation](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/rust-core-api.md#normalized-shortcut-import-validation) | Core commands, result validation, limits, and fixtures (the Rust Core API document) | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`keymap-import/keymap-import-provider.plugin.json`](../../contract/v1/fixtures/keymap-import/keymap-import-provider.plugin.json) | Two providers: `first` uses a file probe and two `userConfig` root probes (one with `*`) and reads one file; `second` uses a directory probe and reads one file plus the `.xml` files of a directory | +| [Lithe-IDEA/lithe-plugin-keymap-import](https://github.com/Lithe-IDEA/lithe-plugin-keymap-import) | The official shortcut import plugin (prerelease) | + +### Related guides + +- [Plugin backend: workspace contexts and host services](../../guides/plugin-backend-workspace.md) diff --git a/shared/plugins/reference/domains/languages.md b/shared/plugins/reference/domains/languages.md new file mode 100644 index 00000000..18eab641 --- /dev/null +++ b/shared/plugins/reference/domains/languages.md @@ -0,0 +1,529 @@ +# 语言、语言服务器与运行/测试 / Languages, language servers, run and test + +[中文](#中文) · [English](#english) + +## 中文 + +让 Lithe 认识一种语言:识别文件,用包内 TextMate 语法高亮,启动 LSP 语言服务器,输出链接,以及(如果插件提供执行提供者)“运行当前文件”和测试。这些都是数据声明,宿主负责进程生命周期、LSP 会话和停止进程,插件不需要写宿主代码。命令行要到启动时才能确定、或后端需要驱动自己的服务器时,可以让插件后端回答启动命令,并在自己声明的范围内向服务器发请求(见“后端决定启动方式”和“插件自有会话”)。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `languages`、`languageServers`、`executionProviders`、`outputLinkMatchers` | +| 能力 | `languages`、`languageServers`、`executionProviders`、`outputLinkMatchers`,版本都是 `0`。`languageServers` 的后端启动方式需要 `backend`;会话、输出链接的 `resolve`、内容提供者都需要 `backend` | +| 协议 | LSP。后端驱动服务器用的 `languageServer/*` 宿主方法和通知见下文,它们属于插件后端的 `litheFunctions` 协议(见[插件后端](backend.md)) | +| API 编号 | [API-25](../api-catalog.md#现在能用的-api):语言、语法、LSP。[API-12](../api-catalog.md#现在能用的-api):按 URI scheme 提供的只读文档(`documents/registerContentProvider`)。[API-20](../api-catalog.md#现在能用的-api):语言服务器按工作区的状态目录及其回收。[API-22](../api-catalog.md#现在能用的-api):语言服务器的准备状态(`languageServer/setStatus`)和 Run、测试控制台输出里的链接。插件函数提供的编辑器语言功能见[编辑器](editor.md) | +| 权限 | `languageServer.launch`;`executionProviders` 声明 `run`、`test` 时加 `process.run`、`process.test`;`languageServer.request`(服务器列出 `requestMethods` 或 `forwardNotifications` 时才可声明,需要 `backend`);`documents.contentProvider`(需要 `backend`) | +| 激活事件 | `onLanguage:`;`onRun:`、`onTest:`(执行提供者) | +| 桌面端 | 语言服务器启动、会话、输出链接、只读文档内容提供者均已接通;执行提供者未声明。详见下文“桌面端现状” | + +### 语言与语法(`languages`) + +`languages[]` 的 `id`、`fileExtensions`、`fileNames`、`projectFileNames` 和 `dependencies` 的规则见[契约的 Languages 一节](../../contract/v1/README.md#languages)。语法和编辑器配置是纯数据:宿主从包里读取,编辑器直接应用,不启动任何进程。只有语法的插件不需要语言服务器、后端或激活事件。 + +```json +{ + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": ["http", "rest"], + "grammar": { "kind": "textmate", "path": "grammars/http.tmLanguage.json" }, + "configuration": { + "lineComment": "#", + "blockComment": ["/*", "*/"], + "brackets": [["{", "}"], ["[", "]"]], + "autoClosingPairs": [["{", "}"], ["\"", "\""]] + } +} +``` + +- `grammar.kind` 只有 `textmate` 一种。`grammar.path` 指向包内的 `.tmLanguage.json`(plist 和 YAML 语法要先转成 JSON)。宿主只读地从已安装的包里读取它,交给 `plugins.declarative.decodeGrammar` 校验,然后用返回的 `scopeName` 注册校验过的 `source`。Core 拒绝的语法是 `plugin.grammar.invalid`,并带出错字段;该语言按纯文本打开,插件其余部分照常工作。 +- 上限:语法最大 2 MiB,规则(含每个 pattern、repository 项和捕获)最多 16384 条,嵌套 32 层,正则 32 KiB,捕获组 4096 个,`name`、`contentName` 1024 字节,`include` 目标和 repository 键 256 字节。 +- 正则使用 TextMate 语法所用的 Oniguruma 语法。编辑器的引擎编译不了的模式永不匹配,语法的其余部分照常生效。 +- `include` 可以是 `$self`、`$base`、`#name`(repository 项),或 `scope`、`scope#name`(另一个语法)。`injections` 和 `injectionSelector` 不支持,会被忽略。 +- 插件语法在启用期间替换编辑器里同一 `scopeName` 的语法。两个插件语言不能注册同一个 scope:先注册的保留,另一个报告后按纯文本打开。 +- `configuration` 包含 `lineComment`、`blockComment`(开始和结束)、`brackets`、`autoClosingPairs`。每个定界符 1–16 字节且不含控制字符,每个列表最多 32 对。 +- 插件语法从不在编辑器界面线程上运行。宿主限制每次正则搜索的回溯和每行的耗时,无法在预算内分词的行按纯文本绘制。 + +字段的完整规则和拒绝条件见[契约](../../contract/v1/README.md#grammars-and-editor-configuration)。 + +### 语言服务器(`languageServers`) + +| 字段 | 含义 | +| --- | --- | +| `languageID` | 已声明的语言。 | +| `languageIdentifier` | 文档的 LSP `languageId`,默认等于 `languageID`。 | +| `launch` | `{"kind": "packaged", "path"}` 运行包内可执行文件。`{"kind": "systemCommand", "commands"}` 列出裸命令名,由宿主的工具发现解析(PATH、项目工具链、用户覆盖)。`{"kind": "runtime", …}` 用包内或宿主发现的运行时启动包内入口文件。`{"kind": "backend", …}` 由插件后端在每次启动时回答命令行。启动字段的共同规则见[契约的 Process start](../../contract/v1/README.md#process-start)。 | +| `arguments` | 传给服务器的参数,是[启动模板](#启动模板)。 | +| `validationArguments` | 候选在使用前必须以状态 0 退出的探针;为空时宿主信任候选,由 LSP 握手决定。 | +| `runtimeDependencies` | 宿主要发现并传给服务器的工具,每一项是 `requiresTools` 列出的工具定义 ID。宿主把每个路径导出为 `LITHE__PATH`(用户设置的值优先),并把其目录加到服务器的 `PATH` 前面。项目本地副本不会被选中。 | +| `initializationOptions` | 作为 LSP `initializationOptions` 发送的 JSON 对象;其中每个字符串值都是启动模板。 | +| `hostIntegration` | 任何插件都可以声明,见下一节。 | +| `requestMethods`、`commands`、`serializedCommands`、`forwardNotifications`、`state`、`remote` | 后端可以向运行中的服务器发什么,以及宿主怎样保存它的状态,见“插件自有会话”。 | + +宿主拥有语言服务器进程的生命周期、LSP 会话,以及在提供者被停用时停止进程。 + +#### 宿主集成(`hostIntegration`) + +有些服务器需要数据表达不了的行为,例如从构建文件算出的项目模型、虚拟文档、测试和调试的接线。这些行为是宿主代码,称为宿主集成。服务器按名称选择一个集成,并说明它需要的文件在插件自己包里的哪个位置: + +```json +"hostIntegration": { + "id": "jdt", + "capabilities": ["debug", "run", "testing"], + "resources": { "configuration": "language-server/configuration/${arch}", "lombokAgent": "…" } +} +``` + +- 插件拥有文件,宿主拥有行为。宿主只对声明它的服务器启用集成,从不按语言 ID 匹配,也不在包外查找文件。 +- 集成是宿主代码,但只读取声明插件自己包里的资源,交给同一个插件启动的语言服务器或辅助进程。它不授予插件运行自己服务器进程之外的权限。 +- `capabilities` 列出语言服务器的哪些功能由集成提供,取值是 `run`、`testing`、`debug`。没有列出、又没有执行提供者提供的功能,对这种语言不可用。 +- 每个集成定义自己提供的能力和读取的资源。能力未提供、资源未声明、或在执行提供者已经提供的语言上又列出 `run` 或 `testing`,都是 `plugin.manifest.invalid`。 +- 契约目前只定义了 `jdt` 这一个集成 ID:它要求 `runtime` 启动(`java` 工具),`configuration`、`lombokAgent` 为必需资源。宿主没有实现的 ID 返回 `plugin.integration.unsupported`。桌面端源码中目前没有找到 `jdt` 的实现,因此声明它会被拒绝(见“桌面端现状”)。 + +#### 启动模板 + +`arguments`、`validationArguments`、`runtimeArguments` 和 `initializationOptions` 的字符串值可以使用下列变量。包里因此从不出现机器路径,宿主决定状态放在哪里。 + +| 变量 | 值 | +| --- | --- | +| `${pluginDir}` | 已安装的只读包的根。每个值最多用一次,后面必须是包内以 `/` 开头的路径,该路径只能因 `${arch}` 而变化。 | +| `${stateDir}` | 宿主为本插件服务器保留的可写目录,卸载插件时删除。不能用于 `validationArguments`。 | +| `${workspaceStateDir}` | 服务器在当前工作区的可写状态目录。不能用于 `validationArguments`,也不能用于插件后端。 | +| `${logDir}` | 本次会话的 `plugins//files/` 目录,由宿主作为 `logDirectory` 提供。只用于语言服务器和插件后端,不用于 `validationArguments`。 | +| `${arch}` | 宿主 CPU 架构:`arm64` 或 `x86_64`。 | +| `${setting:}` | 已声明的[插件设置](settings.md)的当前值。不能用于 `validationArguments`,也不能出现在包路径里。 | + +#### 工作区配置(`workspaceConfiguration`) + +语言服务器可以声明 `workspaceConfiguration`:一个对象,其中的字符串叶子可以是 `${setting:}`。Core 用合并后的设置展开它,按节路径回答服务器的 `workspace/configuration` 请求,并在 `initialized` 之后发送 `workspace/didChangeConfiguration`。`immediate` 设置变化时,宿主通过 `lsp.updateWorkspaceConfiguration` 推送新值,运行中的服务器无需重启即可收到。 + +### 后端决定启动方式(`launch.kind: "backend"`) + +服务器的命令行取决于启动时才知道的东西(解析出的版本、引导出来的工具、从工程推导的选项)时,不写死 `launch`,让插件后端每次回答: + +```json +"launch": {"kind": "backend", "function": "launch.resolve", "timeoutMilliseconds": 30000} +``` + +| 项目 | 规则 | +| --- | --- | +| 适用 | 只有语言服务器能用。插件需要声明 `backend`。`function` 是插件函数 ID;`timeoutMilliseconds` 为 1000–120000,默认 30000 | +| 其他字段 | 服务器不能再声明 `arguments`、`validationArguments`、`runtimeDependencies`、`initializationOptions` 和 `hostIntegration`:前四项由后端的应答携带,宿主集成是宿主代码,正是这种启动方式要避免的。`workspaceConfiguration` 仍可声明式给出,除非应答自带 | +| 启动计划 | `plugins.declarative.languageServerLaunchPlan` 返回第一步而不是命令行:`launch: {kind: "backend", function, timeoutMilliseconds}`、`resolveParams`、`requiredDirectories`(固定为 `state`、`workspaceState`、`log`、`temporary`)、`settings`、`sessionPolicy`。请求要带绝对路径的 `temporaryDirectory`,否则是 `plugin.launch.invalidRequest` | +| 调用 | 宿主建好四个目录,必要时启动插件后端,用 `lithe/function/invoke` 调用 `function`,参数是 `resolveParams`(`serverID`、`platform`、四个目录、`settings`)和工作区 `context`。调用失败、超时,或工作区代次已变,这次启动就不产生进程 | +| 应答 | `executable`(`{kind: "packaged", path}` 是包内只读文件;`{kind: "tool", tool}` 是插件自己定义的工具,由宿主按工具解析)、`arguments`、`environment`、`initializationOptions`、`workspaceConfiguration`、`gates`(最多 8 个)、`writablePaths`(最多 32 个,都在四个目录之内);不认识的字段会被拒绝 | +| 解码 | 宿主把 `{manifest, languageID, request, result}` 交给 `plugins.languageServers.decodeLaunch`,得到普通的启动计划(`launch` 为 `packaged` 或 `tool`),`sessionPolicy` 另带应答的 `gates` | +| 拒绝码 | `plugin.launch.invalidResult`、`plugin.launch.outsidePackage`、`plugin.launch.undeclaredTool`、`plugin.launch.unwritablePath`、`plugin.launch.invalidRequest`、`plugin.launch.unavailable`、`plugin.launch.notBackend` | + +### 插件自有会话(`languageServers` 的会话字段) + +后端只能通过服务器声明的内容驱动它自己的服务器。服务器可以声明: + +| 字段 | 含义 | +| --- | --- | +| `requestMethods` | 后端可以用 `languageServer/request`、`languageServer/notify` 发送的 LSP 方法,最多 64 个 | +| `commands` | 后端可以运行的 `workspace/executeCommand` 命令,最多 128 个;恰好在 `requestMethods` 列出 `workspace/executeCommand` 时声明 | +| `serializedCommands` | `commands` 的子集,一次只运行一个:同一命令已在运行时,新请求并入它,得到同一个答案 | +| `forwardNotifications` | 宿主转发给后端的服务器通知,最多 32 个 | +| `state` | `{scope: "workspace", retention: {maxEntries, maxAgeDays}}`,`maxEntries` 为 1–64,`maxAgeDays` 为 1–365 | +| `remote` | `supported`(默认)或 `unsupported`;`unsupported` 的服务器不为远程工作区启动 | + +- 名称是不含空白和控制字符、最多 256 字节的非空文本,在各自列表里唯一。宿主自己发送的方法(`initialize`、`initialized`、`shutdown`、`exit`、`textDocument/didOpen` 等文档同步方法、`workspace/didChangeWorkspaceFolders` 和所有 `$/` 方法)不能出现在 `requestMethods` 或 `forwardNotifications` 里。 +- 服务器列出 `requestMethods` 或 `forwardNotifications` 时必须声明 `languageServer.request` 权限;没有这样的服务器却声明该权限,是 `plugin.manifest.invalid`。 +- 启动计划的 `sessionPolicy` 就是这些字段(排序后的列表)加 `serverID`(即 `languageID`)和 `gates`。宿主原样交给 `lsp.startServer.sessionPolicy`,由 Core 的 LSP 引擎执行:宿主不能放宽,后端不能越界。 +- 请求必须在 `requestMethods` 里,`workspace/executeCommand` 的 `params.command` 必须在 `commands` 里,否则是 `plugin.host.methodNotDeclared`。通知可以是 `workspace/didChangeConfiguration`,或 `requestMethods` 里除 `workspace/executeCommand` 以外的方法;宿主自己的方法仍然被拒绝。 +- 会话必须属于调用方插件,并且正在运行;请求等待最多 `timeoutMilliseconds`(默认 60000,最大 300000),取消时向服务器发 `$/cancelRequest`。服务器的 JSON-RPC 错误不转发 `data`。 +- **gate** 是 `{id, until: "notification", method, match}`,由启动应答声明:服务器发出该 `method` 的通知、且参数包含 `match` 的所有成员时才打开。所有 gate 打开之前,`request` 按顺序等待,最多 128 个,第 129 个是 `plugin.host.busy`;通知从不被挡住。 +- 同一 `serializedCommands` 命令的后续请求在第一个运行或等待时并入它,并得到第一个请求的结果。取消第一个请求会结束所有并入的请求。 +- 服务器停止、崩溃、重启或插件被停用时,所有未完成的请求以 `plugin.host.languageServerUnavailable` 结束;后端在 `didChangeState` 报告新的 `generation` 的 `running` 之后再请求。 + +**状态目录与回收(API-20)。** 宿主在插件的存储目录下(不在插件包或应用包里)为每个服务器、每个工作区保留一个状态目录,并把它作为 `workspaceStateDirectory` 交给服务器;停用和更新插件都不会删除。目录内的子目录名由插件决定,每个工作区目录本身的名字由宿主决定,宿主从不查看里面的内容。宿主每次启动服务器时刷新该目录的修改时间,并在启动后于后台回收:先用 Core 的 `plugins.languageServers.stateRetention` 决定要删除的目录,即超过 `maxAgeDays` 的,以及其余目录里扣除打开的工作区占用的名额后只保留最新 `maxEntries` 个以外的。打开的工作区从不被回收,没有声明 `state.retention` 的插件保留全部。 + +**准备状态。** `languageServer/setStatus` 报告服务器的准备程度。它与 `lithe/function/progress` 不同:后者属于正在运行的调用,而导入工程、建索引的准备工作会超过任何一次调用。 + +### 宿主方法与通知 + +后端用这些方法和通知与自己的语言服务器交谈;方法名省略前缀 `lithe/host/`。`languageServer/*` 的每个方法都带 `context: {contextID, generation}` 和 `serverID`(本插件某个服务器的 `languageID`),别的插件的服务器返回 `plugin.host.foreignContribution`。 + +| API | 方法 | 用途 | 权限 | +| --- | --- | --- | --- | +| API-25 | `languageServer/request` | 向本插件自己的语言服务器发 JSON-RPC 请求,参数加 `method`、`params?`、`timeoutMilliseconds?`(1–300000,默认 60000),返回 `{value}`(服务器的 `result`,可以是 `null`);受会话策略检查 | `languageServer.request` | +| API-25 | `languageServer/notify` | 向本插件自己的语言服务器发通知,参数加 `method`、`params?`,返回 `{delivered}`;只有服务器初始化前发出的 `workspace/didChangeConfiguration` 是 `false`,这时值会随 `initialized` 送出 | `languageServer.request` | +| API-22、API-25 | `languageServer/setStatus` | 在宿主状态界面显示插件自己的准备状态:`state` 为 `preparing`、`ready`、`degraded` 或 `failed`,可带 `message?`(原样显示,截到 1024 字节)和 `percentage?`(0–100 的整数),返回 `{}`;服务器停止时宿主移除它 | `languageServer.request` | +| API-12 | `documents/registerContentProvider` | 把一个 URI scheme 绑定到后端函数:打开该 scheme 的文档时,宿主调用函数取只读内容(不超过 1 MiB,宿主等 30 秒);每个工作区窗口每个 scheme 只能有一个提供者,返回 `{subscriptionID}`。不带 `serverID`,用 `subscriptions/dispose` 撤销 | `documents.contentProvider` | + +| API | 通知 | 含义 | 权限 | +| --- | --- | --- | --- | +| API-25 | `languageServer/didChangeState` | `{context, serverID, state, generation}`;`state` 为 `starting`、`running`、`stopped` 或 `crashed` | `languageServer.request` | +| API-25 | `languageServer/didReceiveNotification` | `{context, serverID, generation, method, params?, truncated?}`:服务器发出且 `forwardNotifications` 列出的通知;超过 `limits.notificationBytes` 时只送一次 `truncated: true`,不带 `params` | `languageServer.request` | + +`generation` 是宿主每次为这个上下文启动服务器时递增的正整数,后端据此分辨状态或通知属于哪个进程;新进程启动后,宿主不再送旧进程的通知。 + +| 错误码 | JSON-RPC | 含义 | +| --- | --- | --- | +| `plugin.host.methodNotDeclared` | `-32024` | 方法或 `workspace/executeCommand` 命令不在服务器声明的会话里 | +| `plugin.host.languageServerUnavailable` | `-32022` | 服务器没有为这个上下文运行,或请求进行中服务器已停止 | +| `plugin.host.languageServerError` | `-32023` | 服务器以 JSON-RPC 错误应答;`details` 是 `{code, message}` | +| `plugin.host.foreignContribution` | `-32026` | 指名的服务器属于别的插件 | +| `plugin.host.timeout` | `-32015` | 超过宿主的截止时间 | +| `plugin.host.cancelled` | `-32800` | 请求被取消 | +| `plugin.host.busy` | `-32020` | 第 129 个等待中的请求(见 gate) | +| `plugin.host.limitExceeded` | `-32014` | 结果超过宿主的上限 | +| `plugin.host.preconditionFailed` | `-32019` | `documents/registerContentProvider`:这个 scheme 已有提供者 | + +内容提供者的 scheme 规则:1–64 个小写 ASCII 字母、数字、`+`、`-`、`.`,以字母开头;`about`、`file`、`http`、`https`、`lithe` 等由宿主或平台占用的 scheme 被拒绝。提供者只保存绑定,注册时不调用后端;打开文档时才调用函数,结果只读,每次重新打开都会重新请求。 + +### 运行与测试(`executionProviders`) + +执行提供者让用户运行当前文件、列出并运行测试。`run` 和 `test` 各自指定一个 `executable`:宿主工具路径上的裸 `command`,或宿主拥有的项目 `toolchain` ID(如 `project-go`)。工作目录永远是工作区根。 + +`test` 还声明 `frameworkID`、`workspaceLabel`、`projectMarkers`、`testFilePatterns` 和 `workspaceArguments`、`fileArguments`、`testCaseArguments`。参数模板是 argv 元素,从不是 shell 文本。元素可以是字符串,或 `{"value": …, "when": "fileSelected" | "noFileSelected"}`;条件元素只允许出现在 `testCaseArguments` 中。 + +| 变量 | 可用位置 | 值 | +| --- | --- | --- | +| `${file}` | 运行、文件、测试用例 | 工作区相对文件路径;以 `-` 开头的名字变为 `./<名字>` | +| `${fileDirectory}` | 运行、文件、测试用例 | 文件所在目录的 `./<目录>`,工作区根为 `.` | +| `${testName}` | 测试用例 | 去掉首尾空白的测试标识 | +| `${testName:regex}` | 测试用例 | 转义了正则元字符的测试标识 | +| `${userArguments}` | 运行 | 用户的程序参数;必须是整个元素,可以展开为零个元素 | + +发现测试时返回 `{id: "<语言>:workspace", kind: "workspace"}`,然后是按路径排序的 `{id: "<语言>:file:<路径>", kind: "file", relativeFilePath}`;没有项目标记时返回空列表。 + +### 输出链接(`outputLinkMatchers`) + +`outputLinkMatchers[]` 让插件把 Run 和测试控制台里的文字变成可点击的链接,例如回溯里的一帧或 panic 的位置。宿主不认识任何工具的输出格式:插件声明匹配规则,Core 找出匹配。只声明匹配器本身就是完整的插件;不带 `resolve` 的匹配器不需要后端。 + +```json +"outputLinkMatchers": [ + { "id": "traceback", "pattern": "File \"(?P[^\"]+)\", line (?P\\d+)", "scope": "run" } +] +``` + +| 字段 | 含义 | +| --- | --- | +| `id` | 插件内的 ID:小写字母开头,后面最多 63 个小写字母、数字、`.`、`_` 或 `-`,在插件内唯一。每个插件最多 16 个匹配器 | +| `pattern` | 对控制台的一行做匹配的正则,1–512 字节,使用 Rust `regex` 语法,所以没有反向引用和环视,编译后的大小也有上限。最多命名 8 个分组,每个是 camelCase 名称,且不能匹配空文本 | +| `scope` | `run` 或 `test`:匹配器作用的控制台。缺省表示两者 | +| `resolve.function` | 可选,插件 `backend` 进程里的函数 ID;需要 `backend` | + +- 不带 `resolve` 时,规则必须捕获 `path` 分组,可以捕获 `line` 和 `column`(从 1 开始),不接受其他分组名。相对 `path` 对每个工作区根生成一个候选,绝对路径只在某个工作区根之内有效,离开所有根的路径不生成链接:控制台里的一行不可能打开工作区之外的文件。 +- 带 `resolve` 时,规则可以命名任意分组。用户点击时宿主调用该函数,参数是 `{groups}`(按名称的捕获文本)和窗口的 `context`,后端返回 `{uri, range?}` 或 `null`。 +- 匹配由 Core 决定,所有宿主链接同样的文字:`outputLinks.match` 接收 `{scope, platform, caseSensitive?, workspaceRoots, sources: [{pluginID, matchers}], lines}`,返回 `{links}`。每个链接是 `{line, start, length, pluginID, matcherID}` 加 `location`(`{paths, line?, column?}`,宿主打开第一个存在的路径)或 `resolve`(`{function, groups}`)。`start` 和 `length` 按该行的 UTF-16 代码单元计数。按来源顺序、再按声明顺序找匹配,与前面链接重叠的匹配被跳过。一次请求最多 256 行、64 个来源、16 个根;每行最多扫描 4096 字节,最多 8 个链接。 +- `outputLinks.decodeResolution` 接收 `{reply}`(函数的应答),返回要打开的位置 `{path, line?, column?}`(从 1 开始)或 `null`。应答只会打开 `file:` URI,只接受 `uri` 和 `range` 两个成员;从 0 开始的 `range.start` 会被转换。 +- 宿主只对显示出来的行做匹配,不对没有启用该插件的输出做匹配,并忽略文件不存在的链接。 + +### 桌面端现状 + +| 功能 | 桌面端 | +| --- | --- | +| 语言、语法高亮与编辑器配置 | 已支持:语法由 Core 校验后注册到编辑器,分隔符进入注释和括号规则 | +| 语言服务器启动 | 已支持 `packaged`、包内运行时、`systemCommand`(只在 PATH 上查找,依赖项目工具链或用户覆盖的启动计划会被拒绝)和后端应答的命令行。后端应答 `{kind: "tool"}` 在启动时被拒绝,因为工具还没有经过工具链注册表解析 | +| 语言服务器呈现 | 只呈现诊断。补全、悬停、跳转、重命名、格式化没有编辑器界面,插件即使提供了也不会显示 | +| 插件自有会话与 `languageServer/*` 方法 | 已接通,策略由 Core 执行 | +| `jdt` 宿主集成 | 桌面端源码中未找到实现,声明会收到 `plugin.integration.unsupported` | +| 执行提供者(`run`、`test`、`executionProviders`) | 未声明。需要它的插件不会被提供安装 | +| 输出链接 | 已支持。Build、Run、Test 控制台的文字变成链接,带 `resolve` 的匹配器在点击时调用后端 | +| 内容提供者 | 已支持 `documents/registerContentProvider` | + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Languages](../../contract/v1/README.md#languages) | 语言 ID、扩展名、文件名、项目标记、依赖路径 | +| [Grammars and editor configuration](../../contract/v1/README.md#grammars-and-editor-configuration) | TextMate 语法的上限和拒绝规则、注释与括号配置、在后台运行 | +| [Language servers](../../contract/v1/README.md#language-servers) | 启动方式(含 `backend`)、参数、探测参数、运行时依赖 | +| [Language server host integrations](../../contract/v1/README.md#language-server-host-integrations) | 宿主集成与 `jdt` | +| [Workspace configuration](../../contract/v1/README.md#workspace-configuration) | `workspaceConfiguration` 的展开与推送 | +| [Plugin-owned sessions](../../contract/v1/README.md#plugin-owned-sessions) | 会话字段、会话策略、gate、状态目录与回收、准备状态 | +| [Runtime launch](../../contract/v1/README.md#runtime-launch) | 用 `java` 或 `node` 等工具启动包内入口文件 | +| [Tool references](../../contract/v1/README.md#tool-references) | 按工具定义 ID 引用运行时、启动计划里的 `tool` 启动方式 | +| [Launch templates](../../contract/v1/README.md#launch-templates) | `${pluginDir}`、`${stateDir}`、`${workspaceStateDir}` 等模板变量 | +| [Backend-resolved launch](../../contract/v1/README.md#backend-resolved-launch) | 后端回答命令行:`resolveParams`、应答字段、拒绝码 | +| [Language server host methods](../../contract/v1/README.md#language-server-host-methods) | `languageServer/request`、`notify`、`setStatus` 和两个通知 | +| [Content providers](../../contract/v1/README.md#content-providers) | `documents/registerContentProvider`:按 URI scheme 提供只读文档 | +| [Execution providers](../../contract/v1/README.md#execution-providers) | `run`、`test` 的可执行文件、参数模板、测试发现 | +| [Output link matchers](../../contract/v1/README.md#output-link-matchers) | Run 与测试控制台输出里的链接、`outputLinks.match`、`outputLinks.decodeResolution` | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`languages/language-packaged-server.plugin.json`](../../contract/v1/fixtures/languages/language-packaged-server.plugin.json) | 包内语言服务器加运行/测试提供者(PHP) | +| [`languages/language-system-command.plugin.json`](../../contract/v1/fixtures/languages/language-system-command.plugin.json) | 用系统命令启动的语言服务器加运行/测试提供者(Go) | +| [`languages/node-runtime.plugin.json`](../../contract/v1/fixtures/languages/node-runtime.plugin.json) | 用 `node` 运行时启动,带插件设置 | +| [`languages/language-grammar.plugin.json`](../../contract/v1/fixtures/languages/language-grammar.plugin.json) | 只有语法、不启动进程的插件 | +| [`languages/language-server-backend-launch.plugin.json`](../../contract/v1/fixtures/languages/language-server-backend-launch.plugin.json) | 命令行由后端回答、声明会话字段和 `languageServer.request` 的语言服务器 | +| [`languages/grammars/http.tmLanguage.json`](../../contract/v1/fixtures/languages/grammars/http.tmLanguage.json) | 完整的 TextMate 语法示例 | +| [`languages/command-plans.json`](../../contract/v1/fixtures/languages/command-plans.json) | 运行/测试模板和语言服务器启动计划的展开结果的标准答案 | +| [`languages/language-server-launch-resolution.json`](../../contract/v1/fixtures/languages/language-server-launch-resolution.json) | 后端解析启动的第一步(`languageServerLaunchPlan`)和解码应答(`plugins.languageServers.decodeLaunch`)得到的启动计划或拒绝码 | +| [`run/output-link-matchers.json`](../../contract/v1/fixtures/run/output-link-matchers.json) | `outputLinks.match` 的请求与返回的链接,以及 `outputLinks.decodeResolution` 的结果 | +| [`run/tool-reference-launch.plugin.json`](../../contract/v1/fixtures/run/tool-reference-launch.plugin.json) | 语言服务器的 `runtime` 启动按工具 ID `interpreter` 引用运行时,并带一个 `outputLinkMatchers` 条目 | + +语言相关的拒绝用例在 [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json)。 + +### 相关指南 + +- [从零构建一个完整生命周期的 LSP 插件](../../guides/lsp-plugin-lifecycle.md) +- [为语言插件接入构建、运行与测试](../../guides/build-tool-integration.md) + +--- + +## English + +Languages let Lithe recognize a language: identify its files, highlight them with a TextMate grammar shipped in the package, start an LSP language server, make output links clickable, and, when the plugin provides an execution provider, offer "run the current file" and tests. All of this is data. The host owns process lifecycle, the LSP session, and stopping processes, so a plugin writes no host code. When the command line is known only at start, or the backend must drive its own server, the plugin backend can answer the launch command and send requests to the server within the scope the server declares (see "Backend-resolved launch" and "Plugin-owned sessions"). + +| Item | Value | +| --- | --- | +| Manifest fields | `languages`, `languageServers`, `executionProviders`, `outputLinkMatchers` | +| Capabilities | `languages`, `languageServers`, `executionProviders`, `outputLinkMatchers`, each at version `0`. A `languageServers` backend launch needs `backend`; sessions, a matcher's `resolve`, and content providers need `backend` too | +| Protocols | LSP. The `languageServer/*` host methods and notifications below belong to the plugin backend's `litheFunctions` protocol (see [Plugin backend](backend.md)) | +| API IDs | [API-25](../api-catalog.md#apis-usable-now): languages, grammars, LSP. [API-12](../api-catalog.md#apis-usable-now): read-only documents by URI scheme (`documents/registerContentProvider`). [API-20](../api-catalog.md#apis-usable-now): per-workspace state directories of language servers, and their pruning. [API-22](../api-catalog.md#apis-usable-now): language server preparation status (`languageServer/setStatus`) and links in Run and Test output. Editor language features supplied by plugin functions are in [Editor](editor.md) | +| Permissions | `languageServer.launch`; `process.run`, `process.test` when an execution provider declares `run` or `test`; `languageServer.request` (only when a server lists `requestMethods` or `forwardNotifications`; needs `backend`); `documents.contentProvider` (needs `backend`) | +| Activation events | `onLanguage:`; `onRun:`, `onTest:` (execution providers) | +| Desktop | Language server launch, sessions, output links, and read-only content providers are wired; execution providers are not declared. See "Desktop status" below | + +### Languages and grammars (`languages`) + +The rules for `id`, `fileExtensions`, `fileNames`, `projectFileNames`, and `dependencies` are in [the contract's Languages section](../../contract/v1/README.md#languages). A grammar and editor configuration are pure data: the host reads them from the package and the editor applies them without starting any process. A plugin with only grammars needs no language server, backend, or activation event. + +```json +{ + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": ["http", "rest"], + "grammar": { "kind": "textmate", "path": "grammars/http.tmLanguage.json" }, + "configuration": { + "lineComment": "#", + "blockComment": ["/*", "*/"], + "brackets": [["{", "}"], ["[", "]"]], + "autoClosingPairs": [["{", "}"], ["\"", "\""]] + } +} +``` + +- `grammar.kind` is only `textmate`. `grammar.path` names a `.tmLanguage.json` file in the package (plist and YAML grammars are converted to JSON first). The host reads it read-only from the installed package, passes it to `plugins.declarative.decodeGrammar`, and registers the returned `source` under the returned `scopeName`. A grammar Core refuses is `plugin.grammar.invalid` with the failing field; the language then opens as plain text and the rest of the plugin keeps working. +- Limits: a grammar is at most 2 MiB; at most 16384 rules, counting every pattern, repository entry, and capture; nesting 32 deep; regexes of 32 KiB; 4096 capture groups; `name` and `contentName` of 1024 bytes; `include` targets and repository keys of 256 bytes. +- Regexes use the Oniguruma syntax TextMate grammars are written in. A pattern the editor's engine cannot compile never matches, and the rest of the grammar still applies. +- `include` is `$self`, `$base`, `#name` for a repository entry, or `scope` or `scope#name` for another grammar. `injections` and `injectionSelector` are not supported and are ignored. +- A plugin grammar replaces the editor's own grammar of the same `scopeName` while the plugin is enabled. Two plugin languages cannot register the same scope: the first keeps it, and the other opens as plain text. +- `configuration` sets `lineComment`, `blockComment` (open and close), `brackets`, and `autoClosingPairs`. Each delimiter is 1 to 16 bytes without control characters; each list has at most 32 pairs. +- Plugin grammars never run on the editor UI thread. The host bounds the backtracking of each regex search and the time one line may take; a line that cannot be tokenized within that budget is drawn as plain text. + +### Language servers (`languageServers`) + +| Field | Meaning | +| --- | --- | +| `languageID` | A declared language. | +| `languageIdentifier` | LSP `languageId` for documents; defaults to `languageID`. | +| `launch` | `{"kind": "packaged", "path"}` runs an executable inside the package. `{"kind": "systemCommand", "commands"}` lists bare command names the host resolves with its tool discovery (PATH, project toolchains, user overrides). `{"kind": "runtime", …}` starts a package entry file with a packaged or discovered runtime. `{"kind": "backend", …}` has the plugin backend answer the command line at each start. The shared launch rules are in [the contract's Process start](../../contract/v1/README.md#process-start). | +| `arguments` | Arguments passed to the server, written as [launch templates](#launch-templates). | +| `validationArguments` | A probe that must exit with status 0 before a candidate is used; empty means the host trusts the candidate and lets the LSP handshake decide. | +| `runtimeDependencies` | Tools the host discovers and passes to the server, each a tool-definition ID that `requiresTools` lists. The host exports each path as `LITHE__PATH` (a user-set value takes precedence) and prepends its directory to the server's `PATH`. A project-local copy is never chosen. | +| `initializationOptions` | A JSON object sent as the LSP `initializationOptions`. Every string value inside it is a launch template. | +| `hostIntegration` | Any plugin may declare one; see the next section. | +| `requestMethods`, `commands`, `serializedCommands`, `forwardNotifications`, `state`, `remote` | What the backend may ask of the running server and how the host keeps its state; see "Plugin-owned sessions". | + +The host owns the language server process lifecycle, the LSP session, and stopping the process when the provider is disabled. + +#### Host integrations (`hostIntegration`) + +Some servers need behavior data cannot express: a project model computed from build files, virtual documents, test and debug wiring. That behavior is host code, called a host integration. A server selects one by name and says where the files it needs are inside the plugin's own package: + +```json +"hostIntegration": { + "id": "jdt", + "capabilities": ["debug", "run", "testing"], + "resources": { "configuration": "language-server/configuration/${arch}", "lombokAgent": "…" } +} +``` + +- The plugin owns the files; the host owns the behavior. The host enables an integration only for the server that declares it, never matches on a language ID, and never looks for files outside the package. +- The integration is host code, but it reads only resources inside the declaring plugin's own package and hands them to the language server or helper process that same plugin starts. It grants no permission beyond running the plugin's own server process. +- `capabilities` lists which features the integration provides for the language: `run`, `testing`, or `debug`. A feature that is neither listed nor supplied by an execution provider is unavailable for the language. +- Each integration defines the capabilities it provides and the resources each one reads. A capability it does not provide, a capability whose resource is not declared, or `run` or `testing` for a language whose execution provider already supplies it, is `plugin.manifest.invalid`. +- The contract defines one integration ID, `jdt`: it requires a `runtime` launch with the `java` tool, and `configuration` and `lombokAgent` are required resources. An ID the host does not implement returns `plugin.integration.unsupported`. No implementation of `jdt` was found in the desktop source, so declaring it is refused (see "Desktop status"). + +#### Launch templates + +`arguments`, `validationArguments`, `runtimeArguments`, and the string values of `initializationOptions` may use these variables. A package therefore never contains a machine path, and the host decides where state lives. + +| Variable | Value | +| --- | --- | +| `${pluginDir}` | Root of the installed, read-only package. Used at most once per value and followed by a `/`-rooted path inside the package that may vary only by `${arch}`. | +| `${stateDir}` | Writable directory the host keeps for this plugin's server; removed when the plugin is uninstalled. Not available in `validationArguments`. | +| `${workspaceStateDir}` | Writable directory for the server's state in the current workspace. Not available in `validationArguments` or in a plugin backend. | +| `${logDir}` | The current session's `plugins//files/` directory, supplied by the host as `logDirectory`. Available to language servers and plugin backends, never in `validationArguments`. | +| `${arch}` | Host CPU architecture: `arm64` or `x86_64`. | +| `${setting:}` | Current value of a declared [plugin setting](settings.md). Not available in `validationArguments`, and never inside a package path. | + +#### Workspace configuration + +A language server may declare `workspaceConfiguration`, an object whose string leaves may be `${setting:}` templates. Core expands it with the merged settings, answers the server's `workspace/configuration` requests by section path, and sends `workspace/didChangeConfiguration` after `initialized`. When an `immediate` setting changes, the host sends the new value with `lsp.updateWorkspaceConfiguration`, and the running server receives it without a restart. + +### Backend-resolved launch (`launch.kind: "backend"`) + +When a server's command line depends on what is known only at start (a version it resolves, a tool it bootstraps, options derived from the project), do not write `launch`; let the plugin backend answer it each time: + +```json +"launch": {"kind": "backend", "function": "launch.resolve", "timeoutMilliseconds": 30000} +``` + +| Item | Rule | +| --- | --- | +| Scope | Only a language server can use it. The plugin declares `backend`. `function` is a plugin function ID; `timeoutMilliseconds` is 1000 to 120000 and defaults to 30000 | +| Other fields | The server declares none of `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions`, or `hostIntegration`: the answer carries the first four, and a host integration is host code, which this launch exists to avoid. `workspaceConfiguration` stays declarative unless the answer carries its own | +| Launch plan | `plugins.declarative.languageServerLaunchPlan` returns the first step, not a command line: `launch: {kind: "backend", function, timeoutMilliseconds}`, `resolveParams`, `requiredDirectories` (always `state`, `workspaceState`, `log`, and `temporary`), `settings`, and `sessionPolicy`. The request must carry an absolute `temporaryDirectory`, or the result is `plugin.launch.invalidRequest` | +| Call | The host creates the four directories, starts the plugin backend if needed, and calls `function` through `lithe/function/invoke` with `resolveParams` (`serverID`, `platform`, the four directories, `settings`) and the workspace `context`. A failed or timed-out call, or a changed workspace generation, ends the start without a process | +| Answer | `executable` (`{kind: "packaged", path}` is a read-only file in the package; `{kind: "tool", tool}` names a tool definition of the plugin, which the host resolves), `arguments`, `environment`, `initializationOptions`, `workspaceConfiguration`, `gates` (at most 8), and `writablePaths` (at most 32, all inside the four directories). An unknown field is refused | +| Decoding | The host passes `{manifest, languageID, request, result}` to `plugins.languageServers.decodeLaunch`, which returns an ordinary launch plan (`launch` is `packaged` or `tool`); its `sessionPolicy` also carries the answer's `gates` | +| Rejections | `plugin.launch.invalidResult`, `plugin.launch.outsidePackage`, `plugin.launch.undeclaredTool`, `plugin.launch.unwritablePath`, `plugin.launch.invalidRequest`, `plugin.launch.unavailable`, `plugin.launch.notBackend` | + +### Plugin-owned sessions + +A plugin backend drives its own server only through what the server declares: + +| Field | Meaning | +| --- | --- | +| `requestMethods` | LSP methods the backend may send with `languageServer/request` or `languageServer/notify`; at most 64 | +| `commands` | The `workspace/executeCommand` commands the backend may run; at most 128. Declared exactly when `requestMethods` lists `workspace/executeCommand` | +| `serializedCommands` | A subset of `commands` that runs one at a time: a request for a command already running joins it and receives the same answer | +| `forwardNotifications` | Server notifications the host forwards to the backend; at most 32 | +| `state` | `{scope: "workspace", retention: {maxEntries, maxAgeDays}}`; `maxEntries` is 1 to 64, `maxAgeDays` 1 to 365 | +| `remote` | `supported` (default) or `unsupported`; an `unsupported` server is not started for a remote workspace | + +- A name is non-empty text of at most 256 bytes without whitespace or control characters, unique within its list. The names the host sends itself (`initialize`, `initialized`, `shutdown`, `exit`, the document sync methods such as `textDocument/didOpen`, `workspace/didChangeWorkspaceFolders`, and every `$/` method) cannot appear in `requestMethods` or `forwardNotifications`. +- A server that lists `requestMethods` or `forwardNotifications` requires the `languageServer.request` permission; declaring the permission without such a server is `plugin.manifest.invalid`. +- The launch plan's `sessionPolicy` is these fields as sorted lists plus `serverID` (the `languageID`) and `gates`. The host passes it unchanged to `lsp.startServer.sessionPolicy`; Core's LSP engine enforces it, so the host cannot widen it and the backend cannot step outside it. +- A request must be in `requestMethods`, and a `workspace/executeCommand` must carry a `params.command` in `commands`; anything else is `plugin.host.methodNotDeclared`. A notification may be `workspace/didChangeConfiguration`, or a `requestMethods` entry other than `workspace/executeCommand`. The host's own methods stay refused. +- The session must belong to the calling plugin and be running. A request waits at most `timeoutMilliseconds` (default 60000, at most 300000); cancelling it sends the server `$/cancelRequest`. A server's JSON-RPC error does not forward its `data`. +- A **gate** is `{id, until: "notification", method, match}`, declared by the launch answer. It opens when the server sends a notification with that `method` whose params contain every member of `match`. Until every gate has opened, `request` calls wait in order, at most 128; the 129th is `plugin.host.busy`. Notifications are never held. +- The first request for a `serializedCommands` command runs; a later request for the same command, while the first is running or waiting, joins it and gets the first one's outcome. Cancelling the first request ends the joined requests with it. +- When the server stops, crashes, restarts, or the plugin is disabled, every pending request ends with `plugin.host.languageServerUnavailable`; the backend asks again after `didChangeState` reports `running` with a new `generation`. + +**State and retention (API-20).** The host keeps one state directory per workspace for each server, under the plugin's storage (never under the package or application bundle), and gives it to the server as `workspaceStateDirectory`. Disabling and updating the plugin do not delete it. The plugin chooses sub-directory names inside it; the host chooses the per-workspace directory names and never looks inside them. The host refreshes a workspace directory's modification time on every start of the server and sweeps in the background after a start, using `plugins.languageServers.stateRetention`: it deletes directories older than `maxAgeDays`, and of the rest, all but the newest `maxEntries` that open workspaces leave free. An open workspace is never pruned. A plugin that declares no `state.retention` keeps everything. + +**Preparation status.** `languageServer/setStatus` reports how ready the server is. It exists because `lithe/function/progress` belongs to a call that is running, while preparation (importing a build, indexing) outlives any call. + +### Host methods and notifications + +The backend uses these methods and notifications to talk to its own language servers; the names omit the `lithe/host/` prefix. Every `languageServer/*` method carries `context: {contextID, generation}` and `serverID` (the `languageID` of one of the plugin's servers). A server of another plugin returns `plugin.host.foreignContribution`. + +| API | Method | Purpose | Permission | +| --- | --- | --- | --- | +| API-25 | `languageServer/request` | Send a JSON-RPC request to the plugin's own language server. Parameters: `method`, `params?`, `timeoutMilliseconds?` (1 to 300000, default 60000). Returns `{value}` (the server's `result`, possibly `null`). Checked by the session policy | `languageServer.request` | +| API-25 | `languageServer/notify` | Send a notification to the plugin's own language server. Parameters: `method`, `params?`. Returns `{delivered}`; only a `workspace/didChangeConfiguration` sent before the server initializes is `false`, and its value is then sent with `initialized` | `languageServer.request` | +| API-22, API-25 | `languageServer/setStatus` | Show the plugin's preparation state in the host's status UI: `state` is `preparing`, `ready`, `degraded`, or `failed`, with optional `message` (shown as given, cut to 1024 bytes) and `percentage` (integer 0 to 100). Returns `{}`. The host removes it when the server stops | `languageServer.request` | +| API-12 | `documents/registerContentProvider` | Bind a URI scheme to a backend function: when a document of that scheme opens, the host calls the function for read-only content (at most 1 MiB; the host waits 30 seconds). One provider per scheme per workspace window. Returns `{subscriptionID}`; revoke with `subscriptions/dispose`. No `serverID` | `documents.contentProvider` | + +| API | Notification | Meaning | Permission | +| --- | --- | --- | --- | +| API-25 | `languageServer/didChangeState` | `{context, serverID, state, generation}`; `state` is `starting`, `running`, `stopped`, or `crashed` | `languageServer.request` | +| API-25 | `languageServer/didReceiveNotification` | `{context, serverID, generation, method, params?, truncated?}`: a notification the server sent and `forwardNotifications` lists. Above `limits.notificationBytes` it is delivered once with `truncated: true` and without `params` | `languageServer.request` | + +`generation` is a positive integer the host raises each time it starts the server for this context, so the backend can tell which process a state or notification belongs to. After the new process starts, the host never delivers the old one's notifications. + +| Error code | JSON-RPC | Meaning | +| --- | --- | --- | +| `plugin.host.methodNotDeclared` | `-32024` | The method, or the `workspace/executeCommand` command, is not in the server's declared session | +| `plugin.host.languageServerUnavailable` | `-32022` | The server is not running for this context, or it stopped while the request was in flight | +| `plugin.host.languageServerError` | `-32023` | The server answered with a JSON-RPC error; `details` is `{code, message}` | +| `plugin.host.foreignContribution` | `-32026` | The named server belongs to another plugin | +| `plugin.host.timeout` | `-32015` | The host's deadline passed | +| `plugin.host.cancelled` | `-32800` | The request was cancelled | +| `plugin.host.busy` | `-32020` | The 129th waiting request (see gate) | +| `plugin.host.limitExceeded` | `-32014` | The result exceeds the host's bound | +| `plugin.host.preconditionFailed` | `-32019` | `documents/registerContentProvider`: the scheme already has a provider | + +Content provider schemes are 1 to 64 lowercase ASCII letters, digits, `+`, `-`, or `.`, starting with a letter. Schemes the host or platform uses, such as `about`, `file`, `http`, `https`, and `lithe`, are refused. Registration only records the binding and never calls the backend; the function is called when a document opens, the content is read-only, and it is requested again each time the document is reopened. + +### Run and test (`executionProviders`) + +An execution provider lets the user run the current file and list and run tests. `run` and `test` each name an `executable`: exactly one of a bare `command` resolved on the host's tool path, or a host-owned project `toolchain` ID such as `project-go`. The working directory is always the workspace root. + +`test` also declares `frameworkID`, `workspaceLabel`, `projectMarkers`, `testFilePatterns`, and the argument templates `workspaceArguments`, `fileArguments`, and `testCaseArguments`. Argument templates are argv elements, never shell text. Each element is a string or `{"value": …, "when": "fileSelected" | "noFileSelected"}`; conditional elements are only allowed in `testCaseArguments`. + +| Variable | Available in | Value | +| --- | --- | --- | +| `${file}` | run, file, test case | Workspace-relative file. A name starting with `-` becomes `./` | +| `${fileDirectory}` | run, file, test case | `./` of the file, or `.` at the workspace root | +| `${testName}` | test case | Test identifier with surrounding whitespace removed | +| `${testName:regex}` | test case | The identifier with regular-expression metacharacters escaped | +| `${userArguments}` | run | The user's program arguments; must be a whole element and may expand to zero elements | + +Discovery returns `{id: ":workspace", kind: "workspace"}` followed by `{id: ":file:", kind: "file", relativeFilePath}` for matching files sorted by path, or an empty list when no project marker is present. + +### Output links (`outputLinkMatchers`) + +`outputLinkMatchers[]` makes text in the Run and Test consoles clickable, such as a traceback frame or a panic location. The host knows no tool's output format: the plugin declares the pattern and Core finds the matches. A plugin that declares only matchers is complete; matchers without `resolve` need no backend. + +```json +"outputLinkMatchers": [ + { "id": "traceback", "pattern": "File \"(?P[^\"]+)\", line (?P\\d+)", "scope": "run" } +] +``` + +| Field | Meaning | +| --- | --- | +| `id` | Plugin-local ID: a lowercase letter, then up to 63 lowercase letters, digits, `.`, `_`, or `-`; unique in the plugin. At most 16 matchers per plugin | +| `pattern` | A regular expression of 1 to 512 bytes matched against one console line. It uses Rust `regex` syntax (no backreferences, no look-around), and its compiled size is limited. At most 8 named groups, each camelCase, and it must not match empty text | +| `scope` | `run` or `test`: the console the matcher applies to. Absent means both | +| `resolve.function` | Optional ID of a function of the plugin's `backend` process; needs `backend` | + +- Without `resolve`, the pattern must capture a `path` group and may capture `line` and `column` (1-based); no other group name is accepted. A relative `path` becomes one candidate per workspace root; an absolute path counts only inside a workspace root, so a console line can never open a file outside the workspace. +- With `resolve`, the pattern may name any groups. When the user clicks, the host calls the function with `{groups}` (captured text by name) and the window's `context`; the backend answers `{uri, range?}` or `null`. +- Core owns the decision, so every host links the same text. `outputLinks.match` takes `{scope, platform, caseSensitive?, workspaceRoots, sources: [{pluginID, matchers}], lines}` and returns `{links}`. Each link is `{line, start, length, pluginID, matcherID}` plus either `location` (`{paths, line?, column?}`; the host opens the first existing path) or `resolve` (`{function, groups}`). `start` and `length` count UTF-16 code units of the line. Matches are found in source order and then declaration order, and a match overlapping an earlier link is skipped. A request carries at most 256 lines, 64 sources, and 16 roots; a line is scanned up to 4096 bytes and yields at most 8 links. +- `outputLinks.decodeResolution` takes `{reply}` (what the function answered) and returns the location to open, `{path, line?, column?}` with 1-based positions, or `null`. A reply opens only a `file:` URI and accepts only the members `uri` and `range`; a 0-based `range.start` is converted. +- The host matches only lines it displays, never output of a plugin that is not enabled, and ignores a link whose file does not exist. + +### Desktop status + +| Feature | Lithe desktop | +| --- | --- | +| Languages, grammar highlighting, editor configuration | Supported: Core validates the grammar and the host registers it with the editor; delimiters reach the comment and bracket rules | +| Language server launch | Supported for `packaged`, a packaged runtime, `systemCommand` (PATH only; a plan that depends on a project toolchain or a user override is refused), and a backend-answered command line. A backend answer of `{kind: "tool"}` is refused at start, because the tool is not yet resolved through the toolchain registry | +| Language server presentation | Only diagnostics are shown. Completion, hover, navigation, rename, and formatting have no editor surface; a plugin that provides them sees no effect | +| Plugin-owned sessions and `languageServer/*` methods | Wired; Core enforces the policy | +| `jdt` host integration | No implementation found in the desktop source; declaring it is refused with `plugin.integration.unsupported` | +| Execution providers (`run`, `test`, `executionProviders`) | Not declared. A plugin that needs them is not offered for installation | +| Output links | Supported. Console text in Build, Run, and Test becomes links; a matcher with `resolve` calls the backend on click | +| Content providers | `documents/registerContentProvider` supported | + +### Contract sections + +| Section | Content | +| --- | --- | +| [Languages](../../contract/v1/README.md#languages) | Language IDs, extensions, file names, project markers, dependency paths | +| [Grammars and editor configuration](../../contract/v1/README.md#grammars-and-editor-configuration) | TextMate grammar limits and refusal rules, comment and bracket configuration, background execution | +| [Language servers](../../contract/v1/README.md#language-servers) | Launch kinds (including `backend`), arguments, probe arguments, runtime dependencies | +| [Language server host integrations](../../contract/v1/README.md#language-server-host-integrations) | Host integrations and `jdt` | +| [Workspace configuration](../../contract/v1/README.md#workspace-configuration) | Expanding and pushing `workspaceConfiguration` | +| [Plugin-owned sessions](../../contract/v1/README.md#plugin-owned-sessions) | Session fields, session policy, gates, state directories and pruning, preparation status | +| [Runtime launch](../../contract/v1/README.md#runtime-launch) | Starting a package entry file with a tool such as `java` or `node` | +| [Tool references](../../contract/v1/README.md#tool-references) | Referencing runtimes by tool-definition ID; the `tool` launch in a launch plan | +| [Launch templates](../../contract/v1/README.md#launch-templates) | `${pluginDir}`, `${stateDir}`, `${workspaceStateDir}` and the other template variables | +| [Backend-resolved launch](../../contract/v1/README.md#backend-resolved-launch) | The backend answers the command line: `resolveParams`, answer fields, rejection codes | +| [Language server host methods](../../contract/v1/README.md#language-server-host-methods) | `languageServer/request`, `notify`, `setStatus`, and the two notifications | +| [Content providers](../../contract/v1/README.md#content-providers) | `documents/registerContentProvider`: read-only documents by URI scheme | +| [Execution providers](../../contract/v1/README.md#execution-providers) | `run` and `test` executables, argument templates, test discovery | +| [Output link matchers](../../contract/v1/README.md#output-link-matchers) | Links in Run and Test console output, `outputLinks.match`, `outputLinks.decodeResolution` | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`languages/language-packaged-server.plugin.json`](../../contract/v1/fixtures/languages/language-packaged-server.plugin.json) | A packaged language server plus run and test providers (PHP) | +| [`languages/language-system-command.plugin.json`](../../contract/v1/fixtures/languages/language-system-command.plugin.json) | A language server started by a system command, plus run and test providers (Go) | +| [`languages/node-runtime.plugin.json`](../../contract/v1/fixtures/languages/node-runtime.plugin.json) | Started with a `node` runtime, with plugin settings | +| [`languages/language-grammar.plugin.json`](../../contract/v1/fixtures/languages/language-grammar.plugin.json) | Grammars only; starts no process | +| [`languages/language-server-backend-launch.plugin.json`](../../contract/v1/fixtures/languages/language-server-backend-launch.plugin.json) | A language server whose command line the backend answers, declaring session fields and `languageServer.request` | +| [`languages/grammars/http.tmLanguage.json`](../../contract/v1/fixtures/languages/grammars/http.tmLanguage.json) | A complete TextMate grammar example | +| [`languages/command-plans.json`](../../contract/v1/fixtures/languages/command-plans.json) | Golden expansions of run and test templates and language server launch plans | +| [`languages/language-server-launch-resolution.json`](../../contract/v1/fixtures/languages/language-server-launch-resolution.json) | Golden results of the first backend-resolution step (`languageServerLaunchPlan`) and of decoding the answer (`plugins.languageServers.decodeLaunch`): a launch plan or a rejection code | +| [`run/output-link-matchers.json`](../../contract/v1/fixtures/run/output-link-matchers.json) | Requests and returned links of `outputLinks.match`, and the `outputLinks.decodeResolution` result for a matcher's `resolve` reply | +| [`run/tool-reference-launch.plugin.json`](../../contract/v1/fixtures/run/tool-reference-launch.plugin.json) | A language server whose `runtime` launch references the runtime by tool ID `interpreter`, with an `outputLinkMatchers` entry | + +Language-related rejection cases are in [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json). + +### Related guides + +- [Building an LSP plugin end to end](../../guides/lsp-plugin-lifecycle.md) +- [Build, run, and test for language plugins](../../guides/build-tool-integration.md) diff --git a/shared/plugins/reference/domains/manifest.md b/shared/plugins/reference/domains/manifest.md new file mode 100644 index 00000000..1571fcdf --- /dev/null +++ b/shared/plugins/reference/domains/manifest.md @@ -0,0 +1,225 @@ +# 清单与打包 / Manifest and packaging + +[中文](#中文) · [English](#english) + +## 中文 + +每个插件包的根目录有一份 `plugin.json`。它说明插件是谁、按哪个插件 API 和能力版本编写、需要哪些权限、在什么时候启动。宿主遇到不认识的字段会拒绝整个包,而不是忽略其中一部分。其他功能页只讲各自的清单字段,本页讲所有插件共用的部分。 + +| 项目 | 内容 | +| --- | --- | +| 文件 | `plugin.json`([`plugin-manifest.schema.json`](../../contract/v1/plugin-manifest.schema.json));发布者签名 `lithe-plugin-signature.json`(见[包信任](package-trust.md)) | +| 基础字段 | `schemaVersion`、`id`、`displayName`、`version`、`pluginAPIVersion`、`requires`、`requiresTools`、`vendor`、`permissions`、`activationEvents` | +| 版本 | `schemaVersion` 和 `pluginAPIVersion` 都固定为 `0`。`requires` 列出清单用到的每个能力,版本都是 `0`。新的能力版本只加给发生变化的那一个能力,其他能力仍写 `0`(规则见[版本与能力](../capabilities-and-versions.md)) | +| API 编号 | 不在 API-01–44 内,属于安装和打包层面的契约 | +| 宿主 | Lithe 桌面端。声明的能力见下文“能力与宿主” | + +### 清单字段归属 + +| 清单字段 | `requires` 里的能力 | 功能页 | +| --- | --- | --- | +| `languages` | `languages` | [语言](languages.md) | +| `languageServers` | `languageServers`;`launch.kind` 为 `backend` 时还需要 `backend` | [语言](languages.md) | +| `languageServers[].hostIntegration` | `languageServers`;所有插件都可以声明 | [语言](languages.md) | +| `executionProviders` | `executionProviders`(桌面端未声明) | [语言](languages.md) | +| `outputLinkMatchers` | `outputLinkMatchers`;匹配器带 `resolve` 时还需要 `backend` | [语言](languages.md) | +| `settings`、`settingActions` | `settings` | [设置](settings.md) | +| `ui` | `ui` | [界面](ui.md) | +| `buildSystems`(不含 `provider`) | `buildSystems`;`modelSharing` 与 `modelInputs` 还需要 `buildModelInputs`(桌面端未声明) | [声明式构建](build-systems.md) | +| `buildSystems[].provider` | `buildProviders` | [构建提供者](build-providers.md) | +| `buildSystems` 里的 `hostIntegration`(模型、测试结果、依赖来源) | `buildSystems`;只限官方插件,目前只有 `maven` | [声明式构建](build-systems.md) | +| `vcsProviders` | `vcsProviders`(桌面端未声明) | [VCS](vcs.md) | +| `backend` | `backend` | [插件后端](backend.md) | +| `pluginFunctions` | `pluginFunctions`、`backend`;不授予权限 | [插件后端](backend.md) | +| `projectGenerators` | `projectGenerators` | [项目生成器](project-generators.md) | +| `toolDefinitions` | `toolDefinitions` | [工具链](toolchains.md) | +| `toolchainProviders` | `toolchainProviders`、`backend`(桌面端未声明) | [工具链](toolchains.md) | +| `requiresTools` | 无;列出清单的启动和运行时依赖引用的全部工具定义 ID | [工具链](toolchains.md) | +| `keymapImportProviders` | `keymapImportProviders`、`backend`(桌面端未声明) | [快捷键导入](keymap-import.md) | +| `vendor.bundledSignatureRequirement` | `packageTrust`(桌面端未声明) | [包信任](package-trust.md) | +| `debugAdapters` | `debugAdapters` | [调试适配器](debug-adapters.md) | + +`requires` 必须写,并且恰好列出清单用到的能力:漏列或多列都是 `plugin.manifest.invalid`。能力名不是契约定义的,或版本不是 `0`,是 `plugin.api.unsupported`,`field` 为 `requires.<能力名>`。 + +`hostIntegration` 在两处是不同的声明。语言服务器的 `hostIntegration` 对所有插件开放,只读取声明插件自己包里的资源,不授予额外权限(见[语言](languages.md))。构建系统的 `hostIntegration` 仍只限官方插件,目前只有 `maven`(见[声明式构建](build-systems.md))。宿主没有实现的 ID 一律返回 `plugin.integration.unsupported`。 + +### 能力与宿主 + +Lithe 桌面端声明下列能力,版本都是 `0`:`backend`、`buildProviders`、`buildSystems`、`debugAdapters`、`languageServers`、`languages`、`outputLinkMatchers`、`pluginFunctions`、`projectGenerators`、`settings`、`toolDefinitions`、`ui`。 + +桌面端**未声明** `buildModelInputs`、`executionProviders`、`keymapImportProviders`、`packageTrust`、`toolchainProviders`、`vcsProviders`。需要这些能力的插件在安装审查时以 `plugin.api.unsupported` 被拒绝,不会被提供安装。 + +声明了能力不等于每个功能都可见。桌面端已呈现和未呈现的细节见 [`desktop/internal/pluginhost/pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) 中 `Declared()` 上方的注释,各功能页也会注明。 + +### 权限与激活事件 + +| 权限 | 什么时候必须声明 | 功能页 | +| --- | --- | --- | +| `languageServer.launch` | `languageServers` 或 `vcsProviders` 不为空,或构建提供者启动自己的进程 | [语言](languages.md)、[VCS](vcs.md)、[构建提供者](build-providers.md) | +| `process.run`、`process.test` | 有执行提供者声明了 `run`、`test`(桌面端未声明 `executionProviders`) | [语言](languages.md) | +| `process.build` | `buildSystems` 不为空 | [声明式构建](build-systems.md) | +| `process.vcs` | `vcsProviders` 不为空 | [VCS](vcs.md) | +| `debug.launch` | `debugAdapters` 不为空 | [调试适配器](debug-adapters.md) | +| `debug.attach` | 适配器在 `requests` 里列出 `attach` 时才需要;也覆盖连接远程主机和端口 | [调试适配器](debug-adapters.md) | +| `project.generate` | `projectGenerators` 不为空 | [项目生成器](project-generators.md) | +| `ui.contribute`、`ui.web` | 声明了 `ui`;工具窗口显示网页内容时加 `ui.web` | [界面](ui.md) | +| `backend.launch` | 声明了 `backend` | [插件后端](backend.md) | +| `keymaps.provide`、`keymaps.readSelectedSources` | `keymapImportProviders` 不为空;后者只读取显式选择的来源里声明的文件 | [快捷键导入](keymap-import.md) | +| `toolchains.provide` | 声明了 `toolchainProviders` | [工具链](toolchains.md) | +| `run.start` | 后端启动和停止宿主持有的 Run 会话(`run/start`、`run/stop`,通知 `run/didExit`);需要 `backend` | [插件后端](backend.md) | +| `debug.start` | 后端在自己的调试适配器上启动调试会话(`debug/start`);需要 `backend` 和已声明的 `debugAdapters` | [插件后端](backend.md)、[调试适配器](debug-adapters.md) | +| `languageServer.request` | `languageServers` 中有服务器列出 `requestMethods` 或 `forwardNotifications` 时才可声明;需要 `backend`。只授予对本插件自己服务器的 `languageServer/*` 宿主方法和两个通知 | [语言](languages.md) | +| `documents.contentProvider` | 后端注册只读文档内容提供者;需要 `backend`。只授予 `documents/registerContentProvider`,不授予读取或编辑文档 | [语言](languages.md) | +| `credentials.store`、`workspace.metadata`、`documents.read`、`editor.actions`、`editor.providers`、`preview.open`、`browser.open`、`window.messages`、`terminal.create` | 后端调用对应的宿主方法 | [API 总表](../api-catalog.md#已公开的宿主方法) | + +| 激活事件 | 含义 | +| --- | --- | +| `onLanguage:` | 项目里打开该语言的文档时启动语言服务器 | +| `onRun:` | 用户运行该语言的文件时激活运行提供者(桌面端未声明 `executionProviders`) | +| `onTest:` | 用户列出或运行测试时激活测试提供者(桌面端未声明 `executionProviders`) | +| `onBuildSystem:` | 构建系统第一次计划、建模或解码之前激活 | +| `onVcsRoot:` | 工作区里检测到该 VCS 的根目录后启动 VCS 提供者 | +| `onDebug:` | 调试会话需要该适配器时启动 | +| `onView:`、`onCommand:` | 打开工具窗口、执行命令时激活界面模块;后端命令的 `onCommand` 同时启动后端 | +| `onWorkspaceOpen` | 工作区打开后启动 `backend` 进程;唯一可选的事件,不带 ID | + +每个提供者都要有自己的事件,每个事件(`onWorkspaceOpen` 除外)都要对应一个提供者。没有“每次启动都运行”的事件(`onStartupFinished`、`*`),声明了会被拒绝为 `plugin.activation.invalid`。启用插件只是允许激活,事件发生之前什么都不会启动。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Package layout](../../contract/v1/README.md#package-layout) | 包的目录结构;路径规则;不允许符号链接;安装后的包只读 | +| [Manifest](../../contract/v1/README.md#manifest) | 全部顶层字段;未知字段一律拒绝(`plugin.manifest.invalid`) | +| [Versioning](../../contract/v1/README.md#versioning) | 版本规则、各文档的版本字段 | +| [Capabilities](../../contract/v1/README.md#capabilities) | `requires`、能力与清单章节的对应关系、运行协议 | +| [Host capabilities](../../contract/v1/README.md#host-capabilities) | 宿主声明能力版本的规则,以及 `validate`、`installReview` 的拒绝 | +| [Process start](../../contract/v1/README.md#process-start) | 语言服务器、构建提供者、VCS 提供者、调试适配器和插件后端共用的启动字段 | +| [Permissions and activation](../../contract/v1/README.md#permissions-and-activation) | 权限表与激活事件表 | +| [Trust](../../contract/v1/README.md#trust) | 包信任与签名要求 | +| [Outcomes and error codes](../../contract/v1/README.md#outcomes-and-error-codes) | 结果与稳定错误码 | +| [Existing community store](../../contract/v1/README.md#existing-community-store) | 社区商店(Lithe Store)与给社区包增加宿主渲染界面的步骤 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`manifest/hello-lithe.plugin.json`](../../contract/v1/fixtures/manifest/hello-lithe.plugin.json) | 最小的界面插件:一个工具窗口、一个命令。ID 在保留的测试命名空间里,不能直接发布 | +| [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json) | 189 个拒绝用例,覆盖全部功能。每个用例给某个合法 fixture 打补丁,并写明稳定的拒绝码和字段 | + +### 相关指南 + +- [从零构建一个完整生命周期的 LSP 插件](../../guides/lsp-plugin-lifecycle.md):清单、打包、状态目录、安装、发布 +- [插件接入统一日志](../../guides/plugin-logging.md) + +--- + +## English + +Every plugin package has a `plugin.json` at its root. It says who the plugin is, which plugin API and capability versions it was written against, which permissions it needs, and when it starts. A host that meets an unknown field refuses the whole package instead of ignoring part of it. The other domain pages cover their own manifest fields; this page covers what every plugin shares. + +| Item | Value | +| --- | --- | +| Files | `plugin.json` ([`plugin-manifest.schema.json`](../../contract/v1/plugin-manifest.schema.json)); publisher signature `lithe-plugin-signature.json` (see [Package trust](package-trust.md)) | +| Base fields | `schemaVersion`, `id`, `displayName`, `version`, `pluginAPIVersion`, `requires`, `requiresTools`, `vendor`, `permissions`, `activationEvents` | +| Versions | `schemaVersion` and `pluginAPIVersion` are always `0`. `requires` lists every capability the manifest uses, each at version `0`. A new capability version is added only to the one capability that changes; the others stay `0` (rules in [Versions and capabilities](../capabilities-and-versions.md)) | +| API IDs | Outside API-01–44; this is an installation and packaging contract | +| Host | Lithe desktop. The capabilities it declares are listed below under "Capabilities and host" | + +### Which page owns each manifest field + +| Manifest field | Capabilities in `requires` | Domain page | +| --- | --- | --- | +| `languages` | `languages` | [Languages](languages.md) | +| `languageServers` | `languageServers`; plus `backend` when `launch.kind` is `backend` | [Languages](languages.md) | +| `languageServers[].hostIntegration` | `languageServers`; any plugin may declare one | [Languages](languages.md) | +| `executionProviders` | `executionProviders` (not declared by the desktop host) | [Languages](languages.md) | +| `outputLinkMatchers` | `outputLinkMatchers`; plus `backend` when a matcher has `resolve` | [Languages](languages.md) | +| `settings`, `settingActions` | `settings` | [Settings](settings.md) | +| `ui` | `ui` | [UI](ui.md) | +| `buildSystems` (without `provider`) | `buildSystems`; `modelSharing` and `modelInputs` also need `buildModelInputs` (not declared by the desktop host) | [Declarative build systems](build-systems.md) | +| `buildSystems[].provider` | `buildProviders` | [Build providers](build-providers.md) | +| `hostIntegration` inside `buildSystems` (model, test-result, or dependency source) | `buildSystems`; official plugins only, and only `maven` exists | [Declarative build systems](build-systems.md) | +| `vcsProviders` | `vcsProviders` (not declared by the desktop host) | [VCS](vcs.md) | +| `backend` | `backend` | [Plugin backend](backend.md) | +| `pluginFunctions` | `pluginFunctions`, `backend`; grants no permission | [Plugin backend](backend.md) | +| `projectGenerators` | `projectGenerators` | [Project generators](project-generators.md) | +| `toolDefinitions` | `toolDefinitions` | [Toolchains](toolchains.md) | +| `toolchainProviders` | `toolchainProviders`, `backend` (not declared by the desktop host) | [Toolchains](toolchains.md) | +| `requiresTools` | None; lists every tool-definition ID the manifest's launches and runtime dependencies name | [Toolchains](toolchains.md) | +| `keymapImportProviders` | `keymapImportProviders`, `backend` (not declared by the desktop host) | [Shortcut import](keymap-import.md) | +| `vendor.bundledSignatureRequirement` | `packageTrust` (not declared by the desktop host) | [Package trust](package-trust.md) | +| `debugAdapters` | `debugAdapters` | [Debug adapters](debug-adapters.md) | + +`requires` is required and must list **exactly** the capabilities the manifest uses: a missing or unused one is `plugin.manifest.invalid`. A capability name the contract does not define, or a version other than `0`, is `plugin.api.unsupported` with `field` set to `requires.`. + +`hostIntegration` is two different declarations. A language server's `hostIntegration` is open to every plugin; it reads only resources inside the declaring plugin's own package and grants no extra permission (see [Languages](languages.md)). A build system's `hostIntegration` is still limited to official plugins, and only `maven` exists (see [Declarative build systems](build-systems.md)). An ID the host does not implement is always `plugin.integration.unsupported`. + +### Capabilities and host + +The Lithe desktop host declares these capabilities, each at version `0`: `backend`, `buildProviders`, `buildSystems`, `debugAdapters`, `languageServers`, `languages`, `outputLinkMatchers`, `pluginFunctions`, `projectGenerators`, `settings`, `toolDefinitions`, `ui`. + +The desktop host does **not** declare `buildModelInputs`, `executionProviders`, `keymapImportProviders`, `packageTrust`, `toolchainProviders`, or `vcsProviders`. A plugin that needs one of them is refused at install review with `plugin.api.unsupported` and is not offered for installation. + +Declaring a capability does not mean every feature of it is visible. What the desktop host presents and what it does not is described in the comment above `Declared()` in [`desktop/internal/pluginhost/pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go), and each domain page says so where it matters. + +### Permissions and activation events + +| Permission | Required when | Domain page | +| --- | --- | --- | +| `languageServer.launch` | `languageServers` or `vcsProviders` is not empty, or a build provider starts its own process | [Languages](languages.md), [VCS](vcs.md), [Build providers](build-providers.md) | +| `process.run`, `process.test` | an execution provider declares `run` or `test` (the desktop host does not declare `executionProviders`) | [Languages](languages.md) | +| `process.build` | `buildSystems` is not empty | [Declarative build systems](build-systems.md) | +| `process.vcs` | `vcsProviders` is not empty | [VCS](vcs.md) | +| `debug.launch` | `debugAdapters` is not empty | [Debug adapters](debug-adapters.md) | +| `debug.attach` | only when an adapter lists `attach` in `requests`; it also covers attaching to a remote host and port | [Debug adapters](debug-adapters.md) | +| `project.generate` | `projectGenerators` is not empty | [Project generators](project-generators.md) | +| `ui.contribute`, `ui.web` | `ui` is declared; `ui.web` when a tool window shows web content | [UI](ui.md) | +| `backend.launch` | `backend` is declared | [Plugin backend](backend.md) | +| `keymaps.provide`, `keymaps.readSelectedSources` | `keymapImportProviders` is not empty; the second permits reading only declared files in an explicitly selected source | [Shortcut import](keymap-import.md) | +| `toolchains.provide` | `toolchainProviders` is declared | [Toolchains](toolchains.md) | +| `run.start` | the backend starts and stops host-owned Run sessions (`run/start`, `run/stop`, notification `run/didExit`); needs `backend` | [Plugin backend](backend.md) | +| `debug.start` | the backend starts a debug session on one of its own adapters (`debug/start`); needs `backend` and a declared `debugAdapters` entry | [Plugin backend](backend.md), [Debug adapters](debug-adapters.md) | +| `languageServer.request` | a `languageServers` server lists `requestMethods` or `forwardNotifications`, and only then; needs `backend`. Grants the `languageServer/*` host methods for the plugin's own servers and their two notifications, nothing else | [Languages](languages.md) | +| `documents.contentProvider` | the backend registers a read-only document content provider; needs `backend`. Grants `documents/registerContentProvider` only, not reading or editing documents | [Languages](languages.md) | +| `credentials.store`, `workspace.metadata`, `documents.read`, `editor.actions`, `editor.providers`, `preview.open`, `browser.open`, `window.messages`, `terminal.create` | the backend calls the matching host methods | [API catalog](../api-catalog.md#public-host-methods) | + +| Activation event | Meaning | +| --- | --- | +| `onLanguage:` | Start the language server when a document of the language opens in a project | +| `onRun:` | Activate the run provider when the user runs a file of the language (the desktop host does not declare `executionProviders`) | +| `onTest:` | Activate the test provider when the user lists or runs tests (the desktop host does not declare `executionProviders`) | +| `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 | +| `onDebug:` | Start the debug adapter when a debug session needs it | +| `onView:`, `onCommand:` | Activate the UI module when a tool window opens or a command runs; an `onCommand` for a backend command also starts the backend | +| `onWorkspaceOpen` | Start a `backend` process once a workspace is open; the only optional event, and it names no ID | + +Each provider needs its event and each event (except `onWorkspaceOpen`) needs a provider. There is no event that starts a plugin on every launch (`onStartupFinished`, `*`); declaring one is `plugin.activation.invalid`. Enabling a plugin only permits activation; nothing starts until its event occurs. + +### Contract sections + +| Section | Content | +| --- | --- | +| [Package layout](../../contract/v1/README.md#package-layout) | Package structure, path rules, no symbolic links, installed packages are read-only | +| [Manifest](../../contract/v1/README.md#manifest) | Every top-level field; unknown fields are refused (`plugin.manifest.invalid`) | +| [Versioning](../../contract/v1/README.md#versioning) | Versioning rules and the version field of each document | +| [Capabilities](../../contract/v1/README.md#capabilities) | `requires`, the mapping from capabilities to manifest sections, and runtime protocols | +| [Host capabilities](../../contract/v1/README.md#host-capabilities) | How a host declares capability versions, and the refusals of `validate` and `installReview` | +| [Process start](../../contract/v1/README.md#process-start) | Launch fields shared by language servers, build providers, VCS providers, debug adapters and the plugin backend | +| [Permissions and activation](../../contract/v1/README.md#permissions-and-activation) | Permission and activation event tables | +| [Trust](../../contract/v1/README.md#trust) | Package trust and signature requirements | +| [Outcomes and error codes](../../contract/v1/README.md#outcomes-and-error-codes) | Outcomes and stable error codes | +| [Existing community store](../../contract/v1/README.md#existing-community-store) | The community store (Lithe Store) and the steps to give a community package a host-rendered view | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`manifest/hello-lithe.plugin.json`](../../contract/v1/fixtures/manifest/hello-lithe.plugin.json) | The smallest UI plugin: one tool window and one command. Its ID is in the reserved test namespace and cannot be published as is | +| [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json) | 189 rejection cases across every domain. Each patches a valid fixture and names the stable rejection code and field | + +### Related guides + +- [Building an LSP plugin end to end](../../guides/lsp-plugin-lifecycle.md): manifest, packaging, state directories, installation, publishing +- [Plugin logging](../../guides/plugin-logging.md) diff --git a/shared/plugins/reference/domains/marketplace.md b/shared/plugins/reference/domains/marketplace.md new file mode 100644 index 00000000..caf4c3e5 --- /dev/null +++ b/shared/plugins/reference/domains/marketplace.md @@ -0,0 +1,275 @@ +# 插件市场 / Marketplace + +[中文](#中文) · [English](#english) + +## 中文 + +插件市场是一份 JSON 索引,列出插件和各版本的发布压缩包。宿主负责获取、缓存和下载;索引是否有效、某个版本在当前平台能不能装、安装审查里列出哪些权限和程序,都由 Rust Core 的 `plugins.marketplace.*` 命令决定,所以各个宿主看到同样的目录和同样的审查。 + +| 项目 | 内容 | +| --- | --- | +| 索引格式 | [`plugin-index.schema.json`](../../contract/v1/plugin-index.schema.json),`schemaVersion` 为 `0`,根对象只有 `schemaVersion` 和 `plugins` | +| 社区索引文件名 | 仓库根目录的 `lithe-plugin-index.json` | +| Core 命令 | `plugins.marketplace.resolveSource`、`plugins.marketplace.validateIndex`、`plugins.marketplace.installReview` | +| 能力 | 没有单独的能力;版本能不能装按 `pluginAPIVersion` 和 `requires` 判断,见[能力与协议版本](../capabilities-and-versions.md) | +| API 编号 | 不在 API-01–44 内(安装和打包层面的契约),见 [API 总表](../api-catalog.md) | +| 信任 | 见[包信任](package-trust.md);社区包使用 `vendor.signatureRequirement: "none"` | +| Lithe 桌面端 | 把自己声明的能力作为 `hostCapabilities` 传给 `validateIndex` 和 `installReview`,见[宿主现状](#宿主现状) | +| 官方 SDK | 与 SDK 无关 | + +### 索引与可用性规则 + +| 规则 | 内容 | +| --- | --- | +| 索引 `schemaVersion` | 必须是 `0`;其他值整份拒绝(`plugin.index.schemaUnsupported`) | +| 条目 `pluginAPIVersion` | 必填;`0` 才可能可用,其他值只让该版本不可用(`plugin.index.apiUnsupported`),不让索引无效 | +| 条目 `requires` | 必填,至少一项,内容等于包清单的 `requires`;契约不认识的能力 ID 或版本允许出现,但该版本永远不可用。这样宿主不用下载就知道自己能不能运行它 | +| 宿主能力表 | `validateIndex` 只提供宿主和这版 Core 都支持 `requires` 里每一项的版本;没有 `hostCapabilities` 时不提供任何版本。不使用构建系统的插件因此仍会提供给不能运行构建系统的宿主 | +| 平台标识 | 已知的是 `darwin-arm64`、`darwin-x64`、`windows-arm64`、`windows-x64`;其他格式正确的标识可以出现,只是不会匹配 | + +### 来源 + +| 来源 | 地址 | 规则 | +| --- | --- | --- | +| 官方 | Lithe 仓库的 `Plugins/marketplace/index.json` | 应用内带一份只读副本供离线使用;刷新时从 `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json` 获取 | +| Lithe Store | [Lithe-IDEA/Lithe-store](https://github.com/Lithe-IDEA/Lithe-store) 的 `lithe-plugin-index.json` | 预置的社区来源,各方面都按第三方处理(社区信任、拒绝 `dev.lithe` ID);默认启用,可以删除,删除后宿主记住,不会再加回来 | +| 第三方 | 用户添加 | `resolveSource` 把用户粘贴的内容变成要获取的索引地址,见下表 | + +| 用户输入 | 结果 | +| --- | --- | +| https 索引地址 | 原样使用,去掉 `#` 片段 | +| `https://github.com//` | 默认分支(`HEAD`)根目录的 `lithe-plugin-index.json` | +| `…/blob//` | 对应的 raw 文件 | +| http、带账号密码的地址、其他 GitHub 页面 | `plugin.source.invalid` | + +获取到的索引缓存在平台缓存或 Application Support,不写进安装目录。 + +### 索引条目 + +| 字段 | 规则 | +| --- | --- | +| `id` | 插件 ID,必须等于包里 `plugin.json` 的 `id` | +| `name`、`author`、`description`、`license` | 展示信息,不能为空 | +| `localizedDescriptions` | 可选,`description` 的翻译,键是语言标签(如 `zh-Hans`);宿主按界面语言选,找不到时用 `description` | +| `repository` | `https://github.com//`,拥有该插件的发布和问题跟踪 | +| `homepage` | 可选,https 地址 | +| `tags` | 可选,单个词,Core 转小写并排序 | +| `icon` | 可选,同一仓库在 `raw.githubusercontent.com` 上的文件 | +| `versions[]` | `version`、`pluginAPIVersion`、`requires`、`releaseDate`(`YYYY-MM-DD`)、`downloads[]` | +| `downloads[]` | `platforms`(如 `darwin-arm64`、`windows-x64`)、`downloadURL`、压缩包的小写十六进制 `sha256`;一个版本里每个平台最多出现在一个下载项 | + +`downloadURL` 必须是条目自己仓库的 GitHub Release `.zip` 资源(`https://github.com///releases/download//.zip`,owner 和仓库名不区分大小写),索引不能把插件指向别的项目的发布。 + +### 校验与可用性 + +`validateIndex` 的输入是 `{index, sourceKind: "official" | "thirdParty", platform, hostCapabilities?}`。索引根对象格式错误或 `schemaVersion` 不是 `0` 时整份拒绝;否则返回 `{plugins, rejected}`: + +| 输出 | 内容 | +| --- | --- | +| `rejected` | 按索引顺序列出被拒绝的条目和错误:格式错误 `plugin.index.invalid`;第三方来源列出官方命名空间 ID `plugin.index.officialNamespace`;重复 ID 保留第一个,之后的 `plugin.index.duplicate` | +| `plugins` | 按 ID 排序;每个条目规范化,`versions` 从新到旧(各带自己的 `requires`),带 `platforms`、`latestVersion`、`latestReleaseDate` 和 `availability` | + +| `availability` | 条件 | +| --- | --- | +| `{status: "available", version, pluginAPIVersion, releaseDate, downloadURL, sha256}` | 当前平台有压缩包、宿主能运行的最新版本:`pluginAPIVersion` 为 `0`,且 `requires` 里每项能力都被宿主以它写的版本声明 | +| `{status: "unsupported", code: "plugin.index.apiUnsupported", platform, message}` | 列出当前平台的版本都需要宿主没有的能力、能力版本或 `pluginAPIVersion` | +| `{status: "unsupported", code: "plugin.index.platformUnsupported", platform, message}` | 没有版本列出当前平台;宿主显示“不支持此平台”而不是安装按钮 | + +### 信任与安装审查 + +| 安装来源 | 流程 | +| --- | --- | +| 官方 | 只有来自官方来源、并通过 Lithe 发布者签名的条目才算官方 | +| 社区市场 | 第三方来源的条目:用索引的 SHA-256 校验压缩包(`plugin.archive.checksumMismatch`)→ 校验声明和包 → `installReview` → 显示审查 → 用户确认后安装。索引校验和只证明与那份索引一致,不代表发布者身份 | +| 社区本地磁盘 | 用户选的 ZIP 或解压目录没有索引和仓库:先复制私有快照,再用同一个 Core 命令校验和审查快照;本地校验和不能显示成仓库匹配 | + +`installReview` 的输入是 `{manifest, expectedID?, expectedVersion?, approvedPrograms?, hostCapabilities?}`,返回 `{id, version, permissions, programs, newPrograms}`。ID 或版本与索引条目不符时返回 `plugin.package.mismatch`;包要求 `hostCapabilities` 里没有的能力版本时返回 `plugin.api.unsupported`。`programs` 列出宿主可能为插件启动的全部程序,按 `kind`、语言和工具排序: + +| `kind` | 含义 | +| --- | --- | +| `packagedLanguageServer`、`systemLanguageServer`、`runtimeLanguageServer` | 包内可执行文件、宿主发现的命令,或由运行时启动的包内入口,带启动参数和校验探针 | +| `buildSystem` | 构建系统能规划的每条命令;`provider` 是把构建请求发给语言服务器的共享形式 | +| `packagedBuildProvider`、`systemBuildProvider`、`runtimeBuildProvider` | 有独立进程的构建提供者,带 `buildSystemID`、`executesProjectCode`、`mayUseNetwork` | +| `packagedVcsProvider`、`systemVcsProvider`、`runtimeVcsProvider` | VCS 提供者进程,带 `vcs`、`vcsTool`、`mayUseNetwork` | +| `runtime` | 宿主发现并交给语言服务器、构建提供者或 VCS 提供者的运行时 | +| `run`、`test` | 运行和测试命令及参数模板 | +| `projectGenerator` | 项目生成器的 `command` 步骤;`files`、`archive` 步骤不启动程序 | + +每项都带决定实际运行内容的全部字段,所以只改参数、参数模板或校验探针的更新也算新程序。宿主显示全部权限和完整程序清单,并把每个批准的程序按规范 JSON 原样保存,不丢字段;显示可以用易读的标签,但必须保留全部规范内容,无法完整描述的种类要给出完整 JSON。 + +| 情形 | 是否需要用户确认 | +| --- | --- | +| 本地磁盘:首次安装、更新、重装、同版本替换 | 每次都要,即使程序和权限都没变;`newPrograms` 为空也不能免确认 | +| 市场:首次安装、没有之前的批准记录、`newPrograms` 非空 | 要 | +| 市场:更新且没有新程序 | 可以沿用记录的批准 | + +同一个 ID 出现在多个来源时,用户在安装时选择来源,之后只从这个来源更新。本地磁盘安装不是市场来源,也不会自动订阅更新。确认界面总是显示当前的完整审查;`installReview` 只描述和比较,不授予批准。 + +### 宿主现状 + +| 宿主 | 现状 | +| --- | --- | +| Lithe 桌面端 | 预置 Lithe Store 为第三方来源;调用 `validateIndex` 和 `installReview` 时传入自己声明的能力,所以需要它没有声明的能力(`buildModelInputs`、`executionProviders`、`keymapImportProviders`、`packageTrust`、`toolchainProviders`、`vcsProviders`)的插件版本不会被提供安装。能力清单见[桌面端源码](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Marketplace](../../contract/v1/README.md#marketplace) | 市场的分工 | +| [Sources](../../contract/v1/README.md#sources) | 官方、Lithe Store、第三方来源和 `resolveSource` | +| [Index entries](../../contract/v1/README.md#index-entries) | 条目字段和下载地址规则 | +| [Validation and availability](../../contract/v1/README.md#validation-and-availability) | `validateIndex` 的输入输出和可用性 | +| [Trust and install review](../../contract/v1/README.md#trust-and-install-review) | 三条安装路径、`installReview`、程序种类和确认规则 | +| [Host capabilities](../../contract/v1/README.md#host-capabilities) | `hostCapabilities` 怎样决定提供哪些版本 | +| [Existing community store](../../contract/v1/README.md#existing-community-store) | 给社区包加界面并发布到 Lithe Store 的步骤 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`marketplace/index.json`](../../contract/v1/fixtures/marketplace/index.json) | 5 个条目:两个版本的 Zig 插件、官方命名空间的插件(含 `pluginAPIVersion` 为 99 的版本)、只有 `pluginAPIVersion` 99 的插件、三个版本的 Elixir 插件、重复的 Zig 条目 | +| [`marketplace/index-cases.json`](../../contract/v1/fixtures/marketplace/index-cases.json) | 7 个 `validateIndex` 用例:没有宿主能力表的官方来源、第三方来源不能列官方命名空间、Windows 平台选自己的压缩包、宿主能运行语言服务器但不能运行构建系统、有构建系统但没有构建提供者时保留只含语言的版本、没有语言服务器时不提供语言插件、Core 无法校验的能力永远不可用 | +| [`marketplace/invalid-index-entries.json`](../../contract/v1/fixtures/marketplace/invalid-index-entries.json) | 26 个条目错误用例:25 个 `plugin.index.invalid`,1 个 `plugin.index.officialNamespace` | +| [`marketplace/install-reviews.json`](../../contract/v1/fixtures/marketplace/install-reviews.json) | 19 个 `installReview` 用例:首次安装、无变化更新、改了参数或探针需要重新批准、包与条目不符、官方命名空间的未签名包、构建插件的能力要求 | +| [`marketplace/source-urls.json`](../../contract/v1/fixtures/marketplace/source-urls.json) | 11 个 `resolveSource` 用例:5 个解析成功,6 个 `plugin.source.invalid` | + +### 相关指南 + +- [从零构建一个完整生命周期的 LSP 插件](../../guides/lsp-plugin-lifecycle.md):发布、更新与回退演练,社区索引 `lithe-plugin-index.json` + +--- + +## English + +A marketplace is a JSON index that lists plugins and the release archives of each version. Hosts fetch, cache, and download; Rust Core's `plugins.marketplace.*` commands decide whether an index is valid, whether a version can be installed on the current platform, and which permissions and programs the install review lists, so every host shows the same catalog and the same review. + +| Item | Details | +| --- | --- | +| Index format | [`plugin-index.schema.json`](../../contract/v1/plugin-index.schema.json); `schemaVersion` is `0`, and the root has only `schemaVersion` and `plugins` | +| Community index file name | `lithe-plugin-index.json` at the repository root | +| Core commands | `plugins.marketplace.resolveSource`, `plugins.marketplace.validateIndex`, `plugins.marketplace.installReview` | +| Capability | None of its own; whether a version can be installed follows its `pluginAPIVersion` and `requires`. See [capabilities and protocol versions](../capabilities-and-versions.md) | +| API number | Outside API-01–44 (an installation and packaging contract); see the [API catalog](../api-catalog.md) | +| Trust | See [Package trust](package-trust.md); community packages use `vendor.signatureRequirement: "none"` | +| Lithe desktop | Passes the capabilities it declares as `hostCapabilities` to `validateIndex` and `installReview`. See [Host status](#host-status) | +| Official SDKs | Not SDK-related | + +### Index and availability rules + +| Rule | Details | +| --- | --- | +| Index `schemaVersion` | Must be `0`; any other value rejects the whole index (`plugin.index.schemaUnsupported`) | +| Entry `pluginAPIVersion` | Required; only `0` can be available, and any other value makes that version unavailable (`plugin.index.apiUnsupported`) without invalidating the index | +| Entry `requires` | Required, at least one entry, equal to the package manifest's `requires`; a capability ID or version the contract does not define is allowed but the version is never available. A host therefore knows before downloading whether it can run it | +| Host capability table | `validateIndex` offers a version only when the host and this Core build both support every entry of its `requires`; without `hostCapabilities` it offers none. A plugin that uses no build systems is therefore still offered to a host that cannot run them | +| Platform identifiers | The known ones are `darwin-arm64`, `darwin-x64`, `windows-arm64`, and `windows-x64`; other well-formed identifiers may appear and simply never match | + +### Sources + +| Source | Location | Rules | +| --- | --- | --- | +| Official | `Plugins/marketplace/index.json` in the Lithe repository | A read-only copy ships with the application for offline use; refreshing fetches `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json` | +| Lithe Store | `lithe-plugin-index.json` in [Lithe-IDEA/Lithe-store](https://github.com/Lithe-IDEA/Lithe-store) | A preset community source treated as third-party in every respect (community trust, `dev.lithe` IDs refused); enabled by default and removable, and the host remembers the removal instead of adding it back | +| Third-party | Added by the user | `resolveSource` turns what the user pasted into the index URL to fetch; see the table below | + +| User input | Result | +| --- | --- | +| An https index URL | Used as is, with the `#` fragment removed | +| `https://github.com//` | `lithe-plugin-index.json` at the root of the default branch (`HEAD`) | +| `…/blob//` | The matching raw file | +| Plain http, embedded credentials, other GitHub pages | `plugin.source.invalid` | + +Fetched indexes are cached in platform cache or Application Support storage, never inside the installation. + +### Index entries + +| Field | Rules | +| --- | --- | +| `id` | Plugin ID; must equal `id` in the package's `plugin.json` | +| `name`, `author`, `description`, `license` | Display metadata; must not be empty | +| `localizedDescriptions` | Optional translations of `description`, keyed by language tag such as `zh-Hans`; hosts pick the interface language and fall back to `description` | +| `repository` | `https://github.com//` that owns the plugin's releases and issue tracker | +| `homepage` | Optional https URL | +| `tags` | Optional single words, lowercased and sorted by Core | +| `icon` | Optional `raw.githubusercontent.com` file of the same repository | +| `versions[]` | `version`, `pluginAPIVersion`, `requires`, `releaseDate` (`YYYY-MM-DD`), and `downloads[]` | +| `downloads[]` | `platforms` (for example `darwin-arm64`, `windows-x64`), `downloadURL`, and the archive's lowercase hex `sha256`; a platform may appear in at most one download of a version | + +A `downloadURL` must be a `.zip` GitHub Release asset of the entry's own repository (`https://github.com///releases/download//.zip`, owner and repository compared case-insensitively), so an index cannot point a plugin at another project's release. + +### Validation and availability + +`validateIndex` takes `{index, sourceKind: "official" | "thirdParty", platform, hostCapabilities?}`. A malformed index root or a `schemaVersion` other than `0` rejects the whole index; otherwise it returns `{plugins, rejected}`: + +| Output | Contents | +| --- | --- | +| `rejected` | Refused entries in index order with their error: malformed entries `plugin.index.invalid`; an official-namespace ID from a third-party source `plugin.index.officialNamespace`; a repeated ID keeps the first listing and refuses later ones with `plugin.index.duplicate` | +| `plugins` | Sorted by ID; each entry is normalized, lists `versions` newest first (each with its own `requires`), and carries `platforms`, `latestVersion`, `latestReleaseDate`, and `availability` | + +| `availability` | When | +| --- | --- | +| `{status: "available", version, pluginAPIVersion, releaseDate, downloadURL, sha256}` | The newest version with this platform's archive that the host can run: `pluginAPIVersion` `0`, and every capability in its `requires` declared by the host at the version it names | +| `{status: "unsupported", code: "plugin.index.apiUnsupported", platform, message}` | Every version listing this platform needs a capability, a capability version, or a `pluginAPIVersion` the host does not run | +| `{status: "unsupported", code: "plugin.index.platformUnsupported", platform, message}` | No version lists this platform; hosts show "not supported on " instead of an install action | + +### Trust and install review + +| Install route | Flow | +| --- | --- | +| Official | An entry is official only when it comes from the official source and its package passes the Lithe publisher signature | +| Community marketplace | Entries from third-party sources: check the archive against the index SHA-256 (`plugin.archive.checksumMismatch`) → validate the declaration and package → `installReview` → show the review → install after the user confirms. The index checksum proves a match to that index's bytes, not an authenticated publisher | +| Community local disk | A user-selected ZIP or unpacked directory has no index or repository: take a private snapshot first, then validate and review the snapshot with the same Core command; a local checksum is never shown as a repository match | + +`installReview` takes `{manifest, expectedID?, expectedVersion?, approvedPrograms?, hostCapabilities?}` and returns `{id, version, permissions, programs, newPrograms}`. An ID or version that differs from the index entry returns `plugin.package.mismatch`; a package that requires a capability version missing from `hostCapabilities` returns `plugin.api.unsupported`. `programs` lists everything the host may start for the plugin, sorted by `kind`, language, and tool: + +| `kind` | Meaning | +| --- | --- | +| `packagedLanguageServer`, `systemLanguageServer`, `runtimeLanguageServer` | An executable in the package, a command found by host discovery, or a package entry started by a runtime, with launch arguments and validation probe | +| `buildSystem` | Every command line the build system can plan; `provider` is the shared form that sends build requests to a language server | +| `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | A build provider with a process of its own, with `buildSystemID`, `executesProjectCode`, and `mayUseNetwork` | +| `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | A VCS provider process, with `vcs`, `vcsTool`, and `mayUseNetwork` | +| `runtime` | A runtime the host discovers and passes to a language server, build provider, or VCS provider | +| `run`, `test` | Run and test commands with their argument templates | +| `projectGenerator` | A `command` step of a project generator; `files` and `archive` steps start no program | + +Each entry carries every field that decides what actually runs, so an update that only changes arguments, argument templates, or the validation probe is a new program. Hosts show all permissions and the complete program list, and persist each approved program as canonical JSON without dropping fields; a display may use readable labels but must keep all canonical content, with the full JSON for any kind it cannot describe completely. + +| Case | Needs user confirmation | +| --- | --- | +| Local disk: first install, update, reinstall, same-version replacement | Always, even when programs and permissions are unchanged; an empty `newPrograms` never waives it | +| Marketplace: first install, no previous approval, or nonempty `newPrograms` | Yes | +| Marketplace: update with no new programs | May reuse the recorded approval | + +When several sources list the same ID, the user chooses the source at install time and later updates come only from it. A local disk installation is never a marketplace source or an automatic update subscription. A confirmation always shows the complete current review; `installReview` only describes and compares, and grants no approval. + +### Host status + +| Host | Status | +| --- | --- | +| Lithe desktop | Presets Lithe Store as a third-party source and passes the capabilities it declares to `validateIndex` and `installReview`, so a plugin version that needs a capability it does not declare (`buildModelInputs`, `executionProviders`, `keymapImportProviders`, `packageTrust`, `toolchainProviders`, `vcsProviders`) is not offered for installation. The capability list is in the [desktop source](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### Contract sections + +| Section | Covers | +| --- | --- | +| [Marketplace](../../contract/v1/README.md#marketplace) | Division of work between hosts and Core | +| [Sources](../../contract/v1/README.md#sources) | Official, Lithe Store, and third-party sources, and `resolveSource` | +| [Index entries](../../contract/v1/README.md#index-entries) | Entry fields and download URL rules | +| [Validation and availability](../../contract/v1/README.md#validation-and-availability) | `validateIndex` input, output, and availability | +| [Trust and install review](../../contract/v1/README.md#trust-and-install-review) | The three install routes, `installReview`, program kinds, and confirmation rules | +| [Host capabilities](../../contract/v1/README.md#host-capabilities) | How `hostCapabilities` decides which versions are offered | +| [Existing community store](../../contract/v1/README.md#existing-community-store) | Steps to add a view to a community package and publish it to Lithe Store | + +### Examples and cases + +| File | Contents | +| --- | --- | +| [`marketplace/index.json`](../../contract/v1/fixtures/marketplace/index.json) | 5 entries: a Zig plugin with two versions, an official-namespace plugin (one version with `pluginAPIVersion` 99), a plugin with only `pluginAPIVersion` 99, an Elixir plugin with three versions, and a duplicate Zig entry | +| [`marketplace/index-cases.json`](../../contract/v1/fixtures/marketplace/index-cases.json) | 7 `validateIndex` cases: the official source without a host capability table, a third-party source listing the official namespace, a Windows platform picking its own archive, a host that runs language servers but not build systems, a host with build systems but no build providers keeping the language-only version, a host without language servers offered no language plugin, and a capability Core cannot validate never being offered | +| [`marketplace/invalid-index-entries.json`](../../contract/v1/fixtures/marketplace/invalid-index-entries.json) | 26 entry error cases: 25 `plugin.index.invalid` and 1 `plugin.index.officialNamespace` | +| [`marketplace/install-reviews.json`](../../contract/v1/fixtures/marketplace/install-reviews.json) | 19 `installReview` cases: first install, an unchanged update, argument or probe changes needing new approval, a package that does not match its entry, an unsigned official-namespace package, and the capability requirements of build plugins | +| [`marketplace/source-urls.json`](../../contract/v1/fixtures/marketplace/source-urls.json) | 11 `resolveSource` cases: 5 resolved and 6 `plugin.source.invalid` | + +### Related guides + +- [Building a complete-lifecycle LSP plugin](../../guides/lsp-plugin-lifecycle.md): release, update, and rollback drills, and the community index `lithe-plugin-index.json` diff --git a/shared/plugins/reference/domains/package-trust.md b/shared/plugins/reference/domains/package-trust.md new file mode 100644 index 00000000..7fcea875 --- /dev/null +++ b/shared/plugins/reference/domains/package-trust.md @@ -0,0 +1,203 @@ +# 包信任 / Package trust + +[中文](#中文) · [English](#english) + +## 中文 + +这一页说明插件包的目录规则、清单里的签名要求,以及 `packageTrust` 能力。签名要求决定宿主用什么证据接受一个包:发布者签名、宿主自身安装目录的代码签名,或者社区包的用户确认。签名和信任由宿主验证,Core 校验清单里的声明是否合法。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `vendor.id`、`vendor.displayName`、`vendor.signatureRequirement`;官方副本可以再加 `vendor.bundledSignatureRequirement` | +| 能力 | `packageTrust` 0,只用于 `vendor.bundledSignatureRequirement`。见[能力与协议版本](../capabilities-and-versions.md) | +| 签名文件 | 包根目录的 `lithe-plugin-signature.json`,Ed25519 签名,覆盖包里其他所有文件 | +| API 编号 | 不在 API-01–44 内(安装和打包层面的契约),见 [API 总表](../api-catalog.md) | +| 权限、激活事件 | 没有 | +| 宿主信任的密钥 | 由宿主决定;宿主固定自己官方插件 ID 的签名要求,社区包使用 `none` | +| Lithe 桌面端 | 未声明 `packageTrust`:要求它的包不会被提供安装,见[宿主现状](#宿主现状) | +| 官方 SDK | 与 SDK 无关;签名属于打包和发布环节 | + +### 包目录规则 + +| 规则 | 内容 | +| --- | --- | +| 根目录 | `plugin.json`、`lithe-plugin-signature.json`(需要时)和清单引用的资源 | +| 路径 | `plugin.json` 里的每个路径都相对包根目录,用 `/` 分隔,不能含 `..`、开头的 `/`、反斜杠或 NUL | +| 符号链接 | 包里不能有符号链接 | +| 只读 | 宿主把已安装的包当作只读;插件的可写状态放在宿主给的状态目录,不能写回包目录 | + +### 三种签名要求 + +| `signatureRequirement` | 适用于 | 宿主怎样接受 | +| --- | --- | --- | +| `publisherPackage` | 官方插件和其他由宿主信任的发布者签名的包 | 签名文件必须有效,并由宿主信任的密钥签名。开发构建可以把未签名的包从磁盘装成本地开发包(`localDevelopment`),发布构建拒绝 | +| `sameTeamAsHost` | 随宿主安装包一起发布的包 | 只认宿主自身已签名安装目录里的副本,由宿主的代码签名封存 | +| `none` | 社区包 | 不要求 Lithe 签名。从第三方市场安装时先用索引里的 SHA-256 校验压缩包;从本地 ZIP 或目录安装时先复制私有快照。两条路都要用户确认安装审查。`dev.lithe` 和 `dev.lithe.*` 不能使用(`plugin.manifest.invalid`)。包里带了签名文件仍要验证,无效签名不会被忽略 | + +宿主固定自己官方插件 ID 的签名要求,包不能把自己降级。从磁盘选文件不能绕过这个固定值、命名空间限制、发布者密钥检查,也不能绕过 `sameTeamAsHost` 要求的安装封存。社区 `none` 包在开发构建和发布构建里都走自己的确认流程,不会被当成开发包。 + +### `packageTrust`:随宿主发布的官方副本 + +官方插件可以同时声明: + +- `requires.packageTrust: 0` +- `vendor.signatureRequirement: "publisherPackage"` +- `vendor.bundledSignatureRequirement: "sameTeamAsHost"` + +只有官方命名空间的 ID 可以这样声明,主要求和随宿主发布时的要求都必须与宿主固定的官方目录一致。只有实际路径在宿主封存的安装目录内、来源为 `bundled` 的副本可以不带发布者签名;单独安装、导入或回退的副本仍然需要发布者签名。只要带了签名就会验证,封存目录里也一样。这条声明本身不授予任何信任。 + +没有在 `hostCapabilities` 里声明 `packageTrust: 0` 的宿主,在下载前就过滤掉这样的版本,并拒绝它的清单(`plugin.api.unsupported`,`field` 为 `requires.packageTrust`)。没有这条声明的清单得不到随宿主发布的免签名待遇。 + +### 签名文件 + +`lithe-plugin-signature.json` 的字段: + +| 字段 | 内容 | +| --- | --- | +| `schemaVersion` | `1`(签名文件自己的格式,不是清单的 `schemaVersion`) | +| `algorithm` | `"ed25519"` | +| `keyID` | 签名密钥的 ID,只能用小写字母、数字、`.`、`-`、`_` | +| `pluginID`、`pluginVersion` | 必须与 `plugin.json` 的 `id`、`version` 一致 | +| `files` | 包里除签名文件以外每个文件的相对路径到 SHA-256(小写十六进制)的映射;文件集合必须与包里的实际文件完全一致 | +| `signature` | 对规范负载的 Ed25519 签名,base64,64 字节 | + +规范负载是 UTF-8 文本,每行以 `\n` 结尾: + +```text +schemaVersion=1 +algorithm=ed25519 +keyID= +pluginID= +pluginVersion= +file== # 每个文件一行,按路径排序 +``` + +`token` 是 UTF-8 字节的 base64url 编码,去掉末尾的 `=`。没有发布者签名的包,宿主可以记录内容摘要:把每个文件写成 `token(path)=\n`,按路径排序后拼接,再取 SHA-256,之后用它判断包文件有没有被改动。确切字节以 [`signature-vectors.json`](../../contract/v1/fixtures/package-trust/signature-vectors.json) 为准。 + +### 宿主现状 + +| 宿主 | 现状 | +| --- | --- | +| Lithe 桌面端 | 未声明 `packageTrust`。要求它的插件(例如带 `vendor.bundledSignatureRequirement` 的官方副本)不会被提供安装,`validate` 和 `installReview` 也拒绝它们。其他宿主能力见桌面端的[能力清单](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Package layout](../../contract/v1/README.md#package-layout) | 包目录、路径和符号链接规则 | +| [Trust](../../contract/v1/README.md#trust) | 三种签名要求、`packageTrust`、官方 ID 固定和开发包规则 | +| [Capabilities](../../contract/v1/README.md#capabilities) | `packageTrust` 在能力表里的位置 | +| [Host capabilities](../../contract/v1/README.md#host-capabilities) | 宿主用 `hostCapabilities` 声明能力,`validate` 和 `installReview` 据此拒绝 | +| [Trust and install review](../../contract/v1/README.md#trust-and-install-review) | 社区包的安装审查,以及本地磁盘安装的私有快照 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`package-trust/publisher-bundled.plugin.json`](../../contract/v1/fixtures/package-trust/publisher-bundled.plugin.json) | 官方语言服务器插件,`requires` 含 `packageTrust: 0`,同时声明 `publisherPackage` 和 `bundledSignatureRequirement: "sameTeamAsHost"` | +| [`package-trust/signature-vectors.json`](../../contract/v1/fixtures/package-trust/signature-vectors.json) | 跨平台签名向量:RFC 8032 测试密钥(不能签真实的包)、含非 ASCII 文件名的固定包、规范负载、确定性签名和内容摘要。CryptoKit 这类带随机数的签名器要用验证代替字节比对 | + +### 相关指南 + +- [从零构建一个完整生命周期的 LSP 插件](../../guides/lsp-plugin-lifecycle.md):社区包的信任、签名和发布 + +--- + +## English + +This page covers the package layout, the signature requirement in the manifest, and the `packageTrust` capability. The signature requirement decides what evidence a host uses to accept a package: a publisher signature, the code signature of the host's own installation, or the user's confirmation for a community package. Hosts verify signatures and trust; Core validates that the manifest declarations are legal. + +| Item | Details | +| --- | --- | +| Manifest fields | `vendor.id`, `vendor.displayName`, `vendor.signatureRequirement`; an official copy may add `vendor.bundledSignatureRequirement` | +| Capability | `packageTrust` 0, only for `vendor.bundledSignatureRequirement`. See [capabilities and protocol versions](../capabilities-and-versions.md) | +| Signature file | `lithe-plugin-signature.json` at the package root, an Ed25519 signature over every other file in the package | +| API number | Outside API-01–44 (an installation and packaging contract); see the [API catalog](../api-catalog.md) | +| Permissions, activation events | None | +| Keys hosts trust | The host decides; it pins the signature requirement of its official plugin IDs, and community packages use `none` | +| Lithe desktop | Does not declare `packageTrust`: packages that require it are not offered for installation. See [Host status](#host-status) | +| Official SDKs | Not SDK-related; signing is part of packaging and release | + +### Package layout + +| Rule | Details | +| --- | --- | +| Root | `plugin.json`, `lithe-plugin-signature.json` (when required), and the resources the manifest references | +| Paths | Every path in `plugin.json` is relative to the package root, uses `/`, and may not contain `..`, a leading `/`, a backslash, or NUL | +| Symbolic links | Packages may not contain symbolic links | +| Read-only | Hosts treat an installed package as read-only; a plugin's writable state goes in the state directory the host provides, never back into the package | + +### The three signature requirements + +| `signatureRequirement` | For | How a host accepts it | +| --- | --- | --- | +| `publisherPackage` | Official plugins and other packages signed by a publisher the host trusts | The signature file must be valid and signed by a key the host trusts. Development builds may install an unsigned package from disk as a local development package (`localDevelopment`); release builds refuse it | +| `sameTeamAsHost` | Packages shipped inside the host's installation | Honored only for a copy inside the host's own signed installation, sealed by the host's code signature | +| `none` | Community packages | No Lithe signature required. From a third-party marketplace the archive is first checked against the index SHA-256; from a local ZIP or directory a private snapshot is taken first. Both paths require the user to confirm the install review. `dev.lithe` and `dev.lithe.*` may not use it (`plugin.manifest.invalid`). A signature file that is present is still verified; an invalid signature is never ignored | + +A host pins the signature requirement of its official plugin IDs so a package cannot downgrade itself. Selecting a file from disk never overrides that pin, the namespace restrictions, the publisher-key checks, or the installation seal `sameTeamAsHost` requires. Community `none` packages use their own approval path in both development and release builds and are never treated as development packages. + +### `packageTrust`: official copies shipped with the host + +An official plugin may declare all of: + +- `requires.packageTrust: 0` +- `vendor.signatureRequirement: "publisherPackage"` +- `vendor.bundledSignatureRequirement: "sameTeamAsHost"` + +Only official-namespace IDs may do so, and both the primary and the bundled requirement must match the host's pinned official catalog. Only a copy whose actual path is inside the host's sealed installation and whose origin is `bundled` may omit the publisher signature; every independently installed, imported, or rolled-back copy still needs it. A present signature is always verified, inside the seal too. The declaration alone grants no trust. + +A host that does not declare `packageTrust: 0` in `hostCapabilities` filters such a version before download and refuses its manifest (`plugin.api.unsupported`, `field` `requires.packageTrust`). A manifest without the declaration gets no bundled exemption. + +### Signature file + +Fields of `lithe-plugin-signature.json`: + +| Field | Details | +| --- | --- | +| `schemaVersion` | `1` (the signature file's own format, not the manifest's `schemaVersion`) | +| `algorithm` | `"ed25519"` | +| `keyID` | ID of the signing key, limited to lowercase letters, digits, `.`, `-`, and `_` | +| `pluginID`, `pluginVersion` | Must equal `id` and `version` in `plugin.json` | +| `files` | Map from the relative path of every file except the signature file to its lowercase hex SHA-256; the set must equal the package's actual files exactly | +| `signature` | Base64 Ed25519 signature of the canonical payload, 64 bytes | + +The canonical payload is UTF-8 text with every line ending in `\n`: + +```text +schemaVersion=1 +algorithm=ed25519 +keyID= +pluginID= +pluginVersion= +file== # one line per file, sorted by path +``` + +`token` is the base64url encoding of the UTF-8 bytes with trailing `=` removed. For a package without a publisher signature, a host may record a content digest: each file becomes `token(path)=\n`, the lines are sorted by path and concatenated, and the result is hashed with SHA-256; the host then uses it to tell whether the package files changed. [`signature-vectors.json`](../../contract/v1/fixtures/package-trust/signature-vectors.json) is authoritative for the exact bytes. + +### Host status + +| Host | Status | +| --- | --- | +| Lithe desktop | Does not declare `packageTrust`. Plugins that require it (for example an official copy with `vendor.bundledSignatureRequirement`) are not offered for installation, and `validate` and `installReview` refuse them. The rest of the host's capabilities are listed in the desktop [capability list](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) | + +### Contract sections + +| Section | Covers | +| --- | --- | +| [Package layout](../../contract/v1/README.md#package-layout) | Package root, path, and symbolic-link rules | +| [Trust](../../contract/v1/README.md#trust) | The three signature requirements, `packageTrust`, official ID pinning, and development packages | +| [Capabilities](../../contract/v1/README.md#capabilities) | Where `packageTrust` sits in the capability table | +| [Host capabilities](../../contract/v1/README.md#host-capabilities) | A host declares capabilities with `hostCapabilities`; `validate` and `installReview` refuse by it | +| [Trust and install review](../../contract/v1/README.md#trust-and-install-review) | The install review of community packages, and the private snapshot of a local disk install | + +### Examples and cases + +| File | Contents | +| --- | --- | +| [`package-trust/publisher-bundled.plugin.json`](../../contract/v1/fixtures/package-trust/publisher-bundled.plugin.json) | An official language-server plugin whose `requires` includes `packageTrust: 0`, declaring both `publisherPackage` and `bundledSignatureRequirement: "sameTeamAsHost"` | +| [`package-trust/signature-vectors.json`](../../contract/v1/fixtures/package-trust/signature-vectors.json) | Cross-platform signature vectors: the RFC 8032 test key (never for real packages), a fixed package with non-ASCII file names, the canonical payload, a deterministic signature, and the content digest. Signers that add randomness, such as CryptoKit, check by verification instead of byte comparison | + +### Related guides + +- [Building a complete-lifecycle LSP plugin](../../guides/lsp-plugin-lifecycle.md): community package trust, signing, and release diff --git a/shared/plugins/reference/domains/project-generators.md b/shared/plugins/reference/domains/project-generators.md new file mode 100644 index 00000000..0f5b5bd4 --- /dev/null +++ b/shared/plugins/reference/domains/project-generators.md @@ -0,0 +1,169 @@ +# 项目生成器 / Project generators + +[中文](#中文) · [English](#english) + +## 中文 + +插件可以在宿主的"新建项目"窗口里加入口:某种语言的普通项目,或框架、脚手架生成器。窗口归宿主:入口列表和搜索、项目名称和位置、"创建 Git 仓库"选项、空项目、"创建"按钮。生成器只声明自己的字段、结果需要的语言和构建系统、生成项目的步骤。Core 计算表单和生成计划,宿主执行,宿主代码不按生成器 ID 分支。 + +> **宿主支持。** Lithe 桌面端声明 `projectGenerators`(版本 `0`):新建项目窗口列出生成器、通过 Core 计算它们的表单、用共享的工具发现为 `tool` 字段列出安装,并在暂存目录里执行生成计划。宿主源码 `Declared()` 上方注释的"尚未呈现"清单里没有项目生成器。 +> 对契约有疑问,请到 [Lithe-core-rust 的 Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues) 反馈;说明见 [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go)。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `projectGenerators[]`:`id`、`displayName`、`section`、`order`、`icon`、`fields`、`requirements`、`steps`、`openFiles` | +| 能力版本 | `projectGenerators` `0`。`requires` 必须恰好列出清单用到的能力;契约夹具里的生成器插件只需要这一项。见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | 没有进程协议;生成器只是数据,由 Core 命令计算 | +| API 编号 | [API-33](../api-catalog.md#现在能用的-api):项目生成器 | +| 权限 | `project.generate` | +| 激活事件 | 不需要 | +| 宿主支持 | Lithe 桌面端:见上面的说明 | + +### 字段、步骤与命令 + +| 部分 | 内容 | +| --- | --- | +| `section` | `project` 放在"新建项目"下(某种语言的普通项目),`generator` 放在"生成器"下(框架或脚手架);按分区、`order`(0–1000,默认 100)、名称排序 | +| `fields` | 生成器的输入,按声明顺序绘制和计算;字段只能读取排在它前面的字段 | +| 字段类型 | `text`(`default` 模板、`pattern`、`maxLength`、`placeholder`、`optional`、`maximumVersion`)、`enum`(1–16 个选项,`presentation` 可选 `segmented` 或 `menu`)、`boolean`、`multiSelect`(1–128 个选项,可分组)、`tool`(工具定义的一个已有安装,按主目录记录,见下文);其他类型是 `plugin.manifest.invalid` | +| 通用属性 | 每个字段都有 `id`(lowerCamelCase)、`title`、`description`、`advanced`(放进折叠的"高级设置")和 `visibleWhen`;隐藏字段不校验,模板和条件里视为不存在 | +| `maximumVersion` | 一个读取前面字段的模板,给出取值允许的最高版本,按点分数字比较。常见写法是 `"${field.jdk.major}"`:目标 Java 版本不能超过所选 JDK。展开为空串(还没选工具)时不检查 | +| 条件 | `when`、`visibleWhen`:`equals`、`notEquals`、`oneOf`;单个条件或必须全部成立的列表;隐藏字段上的条件为假 | +| 清单模板 | `${project.name}`、`${field.}`、`${field..version}`、`${field..major}`、`${field..executable}`(后三个只用于 `tool` 字段:所选安装的版本、主版本号、可执行文件路径,用在别的字段上校验清单时就被拒绝),修饰符 `path`、`package`、`lower` | +| 模板文件 | 用 `{{ … }}`,因为 POM、Gradle 脚本里本来就有 `${…}`;只有 `{{` 后面紧跟 `project.` 或 `field.` 才是变量;`dot-` 开头的路径段变成点文件(`dot-gitignore` → `.gitignore`);模板目录里的隐藏文件被跳过,符号链接被拒绝 | +| `files` 步骤 | 把包内模板目录写进项目,保留可执行位;最多 1024 个文件、单个 1 MiB、合计 8 MiB | +| `command` 步骤 | 像构建工具一样解析可执行文件,在暂存目录里运行;`timeoutSeconds` 默认 600、最多 1800;`collect` 把子目录内容上移到项目根;展开为空串的参数被省略 | +| `archive` 步骤 | 下载 zip 并用 `project.extractArchive` 解压;必须是 HTTPS(`localhost`、`127.0.0.1`、`[::1]` 可用 HTTP),不带凭据;最多 20,000 个条目、解压后 512 MiB;创建前宿主会显示服务器地址 | +| Core 命令 | `project.generatorCatalog`、`project.generatorForm`、`project.generationPlan`、`project.checkName`、`project.extractArchive` | + +文本值不会以 `-` 开头,也不含控制字符,所以一个值不会变成命令行选项或拆开一行。项目名称是一个目录名:非空、最多 128 个字符、不含 `/`、`\`、`:` 和控制字符、不是 `.` 或 `..`、不以 `-` 开头、首尾没有空格。 + +### `tool` 字段 + +`tool` 字段让用户从任意插件在 [`toolDefinitions`](toolchains.md) 里声明的工具中选一个已有安装,例如 Python 解释器、Node.js 或 JDK。生成器不指名任何宿主功能或语言:宿主通过通用的工具发现和用户的工具设置列出安装,和工具自己的设置页一样。 + +| 属性 | 含义 | +| --- | --- | +| `tool` | 必填。工具定义 ID:恰好由一个已启用插件定义的短 ID(如 `compiler`),或 `:`。没有封闭列表;没人声明的工具没有安装,几个已启用插件都定义的短 ID 也没有,因为它不属于任何一个 | +| `minimumVersion` | 可选,一到三个点分数字。低于它的安装不可选。只允许用在 `tool` 字段上 | +| `pinAs` | 可选。`project` 把所选安装保存为新项目自己的工具选择(和工具设置页编辑的是同一份按目录的固定),项目因此用这里选的安装构建和运行;不写则选择只供步骤使用 | +| `optional` | 可选。没有可用安装时,可选字段保持为空且不留固定;否则表单报 `required`。低于 `minimumVersion` 的安装不算可用 | + +`tool` 字段没有 `default`,初始值由宿主选择:Core 在宿主列出的安装里,优先保留用户的选择(如果它仍在列表中),否则选最新版本(版本相同时取宿主列表里靠前的)。版本按发布版本比较,末尾的 0 不计:`3.11`、`3.11.0`、`3.11.0.0` 相等。低于 `minimumVersion` 的安装在字段的 `installations` 里被标出,宿主据此把它显示为禁用。所选安装低于 `minimumVersion` 时表单报 `toolTooOld`;但用户没设置的可选字段保持为空,不会去选一个过旧的安装。 + +### 表单、计划与命令 + +- `form` 是 `{projectName, values, edited, tools}`。`values` 把字段 ID 映射到值;文本值只有 ID 在 `edited` 里才算用户填写,所以派生出来的默认值会继续跟随它读取的字段。`tools` 把每个 `tool` 字段 ID 映射到宿主为该工具找到的安装,按宿主偏好排序,形如 `[{home, executable, version}]`;字段的 `values` 项是用户所选安装的 `home`。 +- 结果里的 `tool` 字段带 `installations: [{home, executable, version, meetsMinimum}]`,宿主从 Core 的回答绘制选择器,`value` 是 Core 选中的安装。字段的 `problem` 是 `required`、`tooLong`、`invalidFormat`、`unsupportedValue`、`toolTooOld`、`versionTooHigh` 之一,宿主按代码本地化。 +- 计划的步骤是 `{kind: "writeFiles", files: [{path, contents, executable}]}`、`{kind: "command", command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}` 和 `{kind: "archive", url, host, format: "zip"}`。`toolPins` 列出每个带 `pinAs` 且有安装的可见 `tool` 字段所选的工具、`home` 和 `executable`,宿主把它们存为项目的固定。 +- 目录条目的 `tools` 列出每个 `tool` 字段对应工具的声明插件及其启用状态,和 `requirements` 一样:字段没有安装时,宿主可以提供启用其中一个,或打开市场。Core 只列出声明;定义是否可信、工具是否已安装,由宿主的工具注册表和发现决定。 +- `requirements` 是创建出的项目需要的 `languageID` 或 `buildSystemID`(可带 `when`)。目录会列出提供它们的已安装插件及其启用状态,宿主据此提供启用或打开市场;它只是提示,不阻止创建。 + +步骤在暂存目录里按顺序执行,结束后宿主把它移到最终位置(目标已存在时拒绝)、把 `toolPins` 存为项目的工具选择(和工具设置页用的是同一个存储)、按需创建 Git 仓库并打开项目;`openFiles` 里的文件在创建后由宿主打开。失败或取消会删掉暂存目录,不留下任何东西(`plugin.project.generationFailed`)。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Project generators](../../contract/v1/README.md#project-generators) | 宿主和生成器各自负责的部分、清单字段 | +| [Fields](../../contract/v1/README.md#fields) | 字段类型、属性、`maximumVersion`、项目名称规则 | +| [Tool fields](../../contract/v1/README.md#tool-fields) | `tool` 字段的属性、初始选择、最低版本 | +| [Conditions](../../contract/v1/README.md#conditions) | `when`、`visibleWhen` | +| [Templates](../../contract/v1/README.md#templates-1) | 清单模板、模板文件、点文件 | +| [Steps](../../contract/v1/README.md#steps) | 三种步骤和限制 | +| [Commands](../../contract/v1/README.md#commands-1) | 五个 Core 命令、表单与计划格式、失败清理 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`project-generators/project-generator.plugin.json`](../../contract/v1/fixtures/project-generators/project-generator.plugin.json) | 三个生成器:`app`(`project` 分区,含 `java` 工具字段,按构建系统条件写模板文件)、`archetype`(运行脚手架命令)、`initializr`(从可配置服务器下载压缩包) | +| [`project-generators/project-plans.json`](../../contract/v1/fixtures/project-generators/project-plans.json) | `project.*` 命令的标准答案:表单默认值与派生、计划写模板、工具过旧、运行命令、下载压缩包、拒绝非 HTTPS 服务器、项目名称检查;其中的工具字段用例用 `manifest` 指定 `project-generator-tool-field.plugin.json`(选最新安装、保留用户选择、低于最低版本、没有安装、展开并固定、可选字段不固定、末尾 0 视为同一版本等) | +| [`project-generators/project-generator-tool-field.plugin.json`](../../contract/v1/fixtures/project-generators/project-generator-tool-field.plugin.json) | 两个用 `tool` 字段的生成器:`python-app`(`project` 分区,必填 `python` 工具字段,最低 3.11、固定到项目,用 `${field.python.executable}` 运行 `uv venv`)、`node-app`(`generator` 分区,可选 `node` 工具字段,最低 18.0.0、固定到项目) | +| [`project-generators/templates/`](../../contract/v1/fixtures/project-generators/templates/) | 模板目录:`app/README.md`、`app/dot-gitignore`、`main/Main.java`(Java 示例)和 `python-app` 用的 `tool-field/README.md`;`.gitattributes` 关闭换行转换,保证模板逐字节输出 | + +生成器的清单拒绝用例在 [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json) 里,都和工具字段有关:`tool` 缺失或不是工具定义 ID、`minimumVersion` 不是点分数字、`pinAs` 取值未知、工具字段带 `default`、非工具字段用 `.executable`、非工具字段带 `tool` 属性。计划层面的拒绝(工具过旧、非 HTTPS 服务器、名称带斜杠)在 `project-plans.json` 里。 + +--- + +## English + +A plugin can add entries to the host's New Project window: a plain project of a language, or a framework or scaffolding generator. The window belongs to the host: the entry list and its search, the project name and location, the "create Git repository" option, the empty project, and the Create button. A generator declares only its own fields, the languages and build systems the result needs, and the steps that produce it. Core computes the form and the generation plan, the host executes it, and no host code branches on a generator ID. + +> **Host support.** The Lithe desktop declares `projectGenerators` (version `0`): the New Project window lists generators, evaluates their forms through Core, lists installations for `tool` fields through the shared tool discovery, and runs the generation plan in a staging directory. The "not presented yet" list in the comment above `Declared()` in the host source does not include project generators. +> Questions about the contract go to [Lithe-core-rust Issues](https://github.com/Lithe-IDEA/Lithe-core-rust/issues); see [`pluginhost.go`](https://github.com/1lck/Lithe-IDEA/blob/preview/desktop/internal/pluginhost/pluginhost.go) for the host's own description. + +| Item | Value | +| --- | --- | +| Manifest field | `projectGenerators[]`: `id`, `displayName`, `section`, `order`, `icon`, `fields`, `requirements`, `steps`, `openFiles` | +| Capability versions | `projectGenerators` `0`. `requires` must list exactly the capabilities the manifest uses; the generator plugins in the contract fixtures need only this one. See [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | No process protocol; a generator is data that Core commands evaluate | +| API IDs | [API-33](../api-catalog.md#apis-usable-now): project generators | +| Permissions | `project.generate` | +| Activation events | None needed | +| Host support | The Lithe desktop: see the note above | + +### Fields, steps, and commands + +| Part | Content | +| --- | --- | +| `section` | `project` lists the entry under "New Project" (a plain project of a language); `generator` lists it under "Generators" (a framework or scaffolding tool); sorted by section, `order` (0–1000, default 100), then name | +| `fields` | The generator's inputs, drawn and evaluated in order; a field may read only fields declared before it | +| Field types | `text` (`default` template, `pattern`, `maxLength`, `placeholder`, `optional`, `maximumVersion`), `enum` (1–16 options, `presentation` of `segmented` or `menu`), `boolean`, `multiSelect` (1–128 options, optionally grouped), `tool` (an installation of a tool definition, recorded by home directory; see below); any other type is `plugin.manifest.invalid` | +| Common properties | Every field takes `id` (lowerCamelCase), `title`, `description`, `advanced` (listed under the collapsed "Advanced Settings"), and `visibleWhen`; a hidden field is not validated and reads as absent in templates and conditions | +| `maximumVersion` | A template over earlier fields naming the highest version the value may be, compared numerically by dotted components. The usual form is `"${field.jdk.major}"`: a target Java release must not exceed the selected JDK. While the bound expands to an empty string (no tool chosen yet) nothing is checked | +| Conditions | `when` and `visibleWhen`: `equals`, `notEquals`, `oneOf`; one condition or a list that must all hold; a condition on a hidden field is false | +| Manifest templates | `${project.name}`, `${field.}`, `${field..version}`, `${field..major}`, `${field..executable}` (the last three for `tool` fields only: the chosen installation's version, major number, and executable path; using them on another field is rejected when the manifest is validated), with the modifiers `path`, `package`, and `lower` | +| Template files | Use `{{ … }}`, because POMs and Gradle scripts already contain `${…}`; only `{{` followed by `project.` or `field.` is a variable; a path segment starting with `dot-` becomes a dotfile (`dot-gitignore` → `.gitignore`); hidden files in a template directory are skipped and symbolic links are refused | +| `files` step | Writes a packaged template directory into the project, keeping the executable bit; at most 1024 files, 1 MiB each, 8 MiB in total | +| `command` step | Resolves the executable like a build tool and runs it in the staging directory; `timeoutSeconds` defaults to 600, at most 1800; `collect` moves a subdirectory's contents up into the project root; arguments that expand to an empty string are left out | +| `archive` step | Downloads a zip and extracts it with `project.extractArchive`; HTTPS only (HTTP allowed for `localhost`, `127.0.0.1`, `[::1]`), no credentials; at most 20,000 entries and 512 MiB extracted; the host shows the server before creating | +| Core commands | `project.generatorCatalog`, `project.generatorForm`, `project.generationPlan`, `project.checkName`, `project.extractArchive` | + +Text values never start with `-` and never contain control characters, so a value cannot become an option or split a line. A project name is one directory name: not empty, at most 128 characters, no `/`, `\`, `:`, or control characters, not `.` or `..`, no leading `-`, and no surrounding spaces. + +### `tool` fields + +A `tool` field lets the user pick one installation of any tool that a plugin declares in [`toolDefinitions`](toolchains.md), such as a Python interpreter, a Node.js runtime, or a JDK. The generator names no host feature and no language: the host lists installations through its generic tool discovery and the user's tool settings, exactly as the tool's own settings page does. + +| Property | Meaning | +| --- | --- | +| `tool` | Required. A tool definition ID: a short ID such as `compiler` defined by exactly one enabled plugin, or `:`. There is no closed list; a tool nobody declares has no installations, and neither has a short ID several enabled plugins define, because it belongs to none of them | +| `minimumVersion` | Optional, one to three dotted numbers. An installation below it is not selectable. Allowed only on a `tool` field | +| `pinAs` | Optional. `project` saves the selection as the created project's own tool choice (the same per-directory pin the tool's settings page edits), so the project builds and runs with the installation picked here. Without it the selection only feeds the steps | +| `optional` | Optional. Without a usable installation an optional field stays empty and leaves no pin; otherwise the form reports `required`. An installation below `minimumVersion` is not usable | + +A tool field has no `default`; the host chooses the initial value. Core picks, among the installations the host listed, the user's choice if it is still listed and otherwise the newest version (the host's first among equal versions). Versions compare as releases, so trailing zeros do not count: `3.11`, `3.11.0`, and `3.11.0.0` are equal. An installation below `minimumVersion` is marked in the field's `installations`, so the host can show it disabled. When the selection is below `minimumVersion` the form reports `toolTooOld`, except that an optional field the user has not set stays empty rather than picking an installation that is too old. + +### Forms, plans, and commands + +- `form` is `{projectName, values, edited, tools}`. `values` maps field IDs to values; a text value counts as user-entered only when its ID is in `edited`, so derived defaults keep following the fields they read. `tools` maps each `tool` field ID to the installations the host found for that tool, in the order the host prefers them, as `[{home, executable, version}]`; the field's `values` entry is the `home` of the user's choice. +- A tool field in the result carries `installations: [{home, executable, version, meetsMinimum}]`, so the host draws its picker from Core's answer, and its `value` is the installation Core selected. A field `problem` is one of `required`, `tooLong`, `invalidFormat`, `unsupportedValue`, `toolTooOld`, and `versionTooHigh`; hosts localize by code. +- Plan steps are `{kind: "writeFiles", files: [{path, contents, executable}]}`, `{kind: "command", command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}`, and `{kind: "archive", url, host, format: "zip"}`. `toolPins` lists, for each visible tool field with `pinAs` and an installation, the tool and the chosen `home` and `executable` that the host saves as the project's pin. +- A catalog entry's `tools` lists, for each tool field, the plugins that declare the field's tool and whether they are enabled, in the same way as `requirements`: when the field has no installation the host can offer to enable one, or open the marketplace. Core only names declarations; whether a definition is trusted, and whether the tool is installed, is decided by the host's tool registry and discovery. +- `requirements` are the `languageID` or `buildSystemID` (optionally with `when`) the created project needs. The catalog lists the installed plugins that provide each, with their enabled state, so the host can offer to enable one or open the marketplace; a requirement only prompts and never blocks creation. + +Steps run in order in a staging directory. Afterwards the host moves it to its final location (refusing an existing one), saves the `toolPins` as the project's tool choices (the same store the tool settings page edits), creates the Git repository when asked, and opens the project; the files in `openFiles` are opened by the host after creation. A failure or cancellation removes the staging directory and leaves nothing behind (`plugin.project.generationFailed`). + +### Contract sections + +| Section | Content | +| --- | --- | +| [Project generators](../../contract/v1/README.md#project-generators) | What the host and the generator each own; manifest fields | +| [Fields](../../contract/v1/README.md#fields) | Field types, properties, `maximumVersion`, project name rules | +| [Tool fields](../../contract/v1/README.md#tool-fields) | `tool` field properties, the initial selection, minimum versions | +| [Conditions](../../contract/v1/README.md#conditions) | `when` and `visibleWhen` | +| [Templates](../../contract/v1/README.md#templates-1) | Manifest templates, template files, dotfiles | +| [Steps](../../contract/v1/README.md#steps) | The three step kinds and their limits | +| [Commands](../../contract/v1/README.md#commands-1) | The five Core commands, form and plan shapes, cleanup on failure | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`project-generators/project-generator.plugin.json`](../../contract/v1/fixtures/project-generators/project-generator.plugin.json) | Three generators: `app` (the `project` section, with a `java` tool field, writing template files conditioned on the build system), `archetype` (running a scaffolding command), `initializr` (downloading an archive from a configurable server) | +| [`project-generators/project-plans.json`](../../contract/v1/fixtures/project-generators/project-plans.json) | Golden answers for `project.*` command cases: form defaults and derived values, a plan writing templates, a tool that is too old, running a command, downloading an archive, refusing a non-HTTPS server, project name checks; the tool field cases name `project-generator-tool-field.plugin.json` in `manifest` (the newest installation, keeping the user's choice, below the minimum, no installation, expanding and pinning, an optional field left unpinned, trailing zeros as the same release, and more) | +| [`project-generators/project-generator-tool-field.plugin.json`](../../contract/v1/fixtures/project-generators/project-generator-tool-field.plugin.json) | Two generators with `tool` fields: `python-app` (the `project` section; a required `python` tool field, at least 3.11, pinned to the project, running `uv venv` with `${field.python.executable}`) and `node-app` (the `generator` section; an optional `node` tool field, at least 18.0.0, pinned to the project) | +| [`project-generators/templates/`](../../contract/v1/fixtures/project-generators/templates/) | The template directories: `app/README.md`, `app/dot-gitignore`, `main/Main.java` (a Java example), plus `tool-field/README.md` for `python-app`; `.gitattributes` disables line-ending conversion so templates are emitted byte for byte | + +Generator manifest refusals are in [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json), all about tool fields: a missing `tool` or one that is not a tool definition ID, a `minimumVersion` that is not dotted numbers, an unknown `pinAs`, a `default` on a tool field, `.executable` on a non-tool field, and a `tool` property on a non-tool field. Plan-level refusals (a tool that is too old, a non-HTTPS server, a name with a slash) are in `project-plans.json`. diff --git a/shared/plugins/reference/domains/settings.md b/shared/plugins/reference/domains/settings.md new file mode 100644 index 00000000..00a0ff74 --- /dev/null +++ b/shared/plugins/reference/domains/settings.md @@ -0,0 +1,209 @@ +# 插件设置 / Plugin settings + +[中文](#中文) · [English](#english) + +## 中文 + +插件在清单里声明用户可以修改的选项。宿主负责显示控件、保存值、区分全局和项目两层,并把生效值传给 Core;Core 合并后展开到启动计划、`initializationOptions` 或语言服务器的 `workspaceConfiguration`。插件不写设置页代码,宿主也不需要认识插件。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `settings`、`settingActions` | +| 能力 | `settings`,版本 `0`。`workspaceConfiguration` 的展开和推送见[语言](languages.md) | +| 协议 | 没有专用协议;`settingActions` 调用的是插件自己的[插件函数](backend.md) | +| API 编号 | [API-19](../api-catalog.md#现在能用的-api):声明式设置。运行期读取、查看来源、修改设置和变化事件仍在计划中 | +| 权限 | 不需要额外权限。设置不会改变运行的程序,所以不进入安装审查的程序列表 | +| 激活事件 | 不需要。值按设置的 `effect` 在下一次启动、模型重载、下一次运行或 `immediate` 推送时生效 | +| 桌面端 | 已声明 `settings`:按语言和构建系统分页、分组、全部控件类型、项目层、生效方式和页面按钮都已呈现 | + +### 设置类型 + +| `type` | 字段 | 规则 | +| --- | --- | --- | +| `integer` | `minimum`、`maximum`、`unit`(可选) | `minimum` 和 `maximum` 必填 | +| `enum` | `options`,每项 `{value, title}` | 1–16 个选项;值只含字母、数字、`.`、`_`、`-` | +| `boolean` | — | 值是 `true` 或 `false` | +| `multiSelect` | `options`,每项 `{value, title}` | 1–32 个选项;值是互不相同的选项值数组 | +| `text` | `pattern`(必填)、`maxLength`(可选)、`placeholder`(可选) | `pattern` 必须匹配整个值;`maxLength` 默认 256,最多 1024 个 Unicode 标量 | +| `file`、`directory` | `placeholder`(可选) | 绝对路径,最多 1024 个 Unicode 标量;空值表示“未设置” | + +所有类型的 `title`、`description` 都是宿主显示的文字。`text`、`file` 和 `directory` 的值不以 `-` 开头,也不含控制字符。`default` 的 JSON 类型必须符合 `type`,并且被这个声明接受。 + +```json +"settings": [ + { "id": "heapMB", "type": "integer", "title": "Maximum memory", + "default": 2048, "minimum": 512, "maximum": 8192, "unit": "MB" }, + { "id": "logLevel", "type": "enum", "title": "Log level", "default": "info", + "options": [{ "value": "info", "title": "Information" }, { "value": "debug", "title": "Debug" }] }, + { "id": "telemetry", "type": "boolean", "title": "Send usage data", "default": false } +] +``` + +因为每个值都是数字、布尔值或已声明的选项值,设置永远不能注入额外参数,也不能改变运行的包内文件。存储的值如果已经不被声明接受(例如更新后缩小了范围),会被默认值替换,启动不会因此失败。未声明的 ID 的值被忽略。 + +### 放置、层级与生效方式 + +设置还可以声明它显示在哪里、什么时候生效。宿主只提供通用的注册点:每种语言和构建系统一个页面、分组、每种类型一个控件、项目/全局切换和按钮。页面上出现什么,由清单决定。 + +```json +"settings": [ + { "id": "dialyzer", "type": "boolean", "title": "Dialyzer", "default": true, + "scope": "project", "effect": "immediate", "page": { "languageID": "elixir" } }, + { "id": "mixEnv", "type": "text", "title": "MIX_ENV", "default": "dev", "pattern": "[a-z]+", + "scope": "project", "page": { "languageID": "elixir" }, "section": "Server" } +], +"settingActions": [ + { "id": "rebuildPlt", "title": "Rebuild PLT", "function": "dialyzer.rebuild", + "page": { "languageID": "elixir" }, "confirm": true } +] +``` + +| 字段 | 含义 | +| --- | --- | +| `scope` | `global`(默认):所有项目共用一个值。`project`:打开的项目可以覆盖它 | +| `effect` | 修改何时生效。`restart`(默认):插件进程下一次启动。`immediate`:通过 `workspaceConfiguration` 推送给运行中的语言服务器。`reload`:下一次构建模型重载,宿主会重载插件的构建模型。`nextRun`:下一次运行或构建命令 | +| `page` | `{ "languageID": … }` 或 `{ "buildSystemID": … }`:设置显示在哪个语言或构建系统的设置页上,与插件自己的页面并列。任何插件都可以往页面上加内容;只要有已启用的插件为它提供内容,宿主就显示该页面 | +| `section` | 设置在页面上所属分组的标题 | + +`settingActions` 最多 16 个,按唯一的 `id` 排序。每个按钮显示在插件自己的页面和它声明的 `page` 上;点击后调用本插件的插件函数 `function`,参数为空,由本插件为当前项目运行的提供者进程执行(见[插件函数](backend.md))。`confirm: true` 让宿主先询问用户。宿主只负责按钮本身,不知道函数做什么。声明按钮也算使用了 `settings`。 + +由 Core 在校验时检查的放置和使用规则: + +- `immediate` 设置只能出现在语言服务器的 `workspaceConfiguration` 里,它从不进入命令行或 `initializationOptions`,所以修改它从不需要新进程。 +- `text`、`file`、`directory` 设置只能是一个完整的模板值,或某个 `name=` 参数的值,不能是较长参数的一部分。 + +### 两层保存与生效顺序 + +宿主为每个插件的每个设置保存一个全局值,并把项目值保存在项目的 `.lithe/plugin-settings.json` 里(格式 `{pluginID: {settingID: value}}`,适合通过版本控制共享)。宿主把两者都作为 `settings` 和 `projectSettings` 传给 Core。Core 按这个顺序合并:项目值(只对 `scope: "project"` 有效)、全局值、默认值;存储的值如果不被声明接受,就跳过。 + +`plugins.declarative.resolveSettings` 返回每个生效值及其 `source`(`project`、`global` 或 `default`)。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Plugin settings](../../contract/v1/README.md#plugin-settings) | 设置的字段、类型、模板 `${setting:}` 和使用规则 | +| [Placement, scope, effect, and page buttons](../../contract/v1/README.md#placement-scope-effect-and-page-buttons) | 放置位置、层级、生效时机、页面按钮和校验规则 | +| [Workspace configuration](../../contract/v1/README.md#workspace-configuration) | `workspaceConfiguration` 的展开和推送 | +| [Executable resolution](../../contract/v1/README.md#executable-resolution) | `setting` 类型的可执行文件候选:引用全局 `file` 设置选择工具路径、候选顺序和严格选择 | +| [Launch templates](../../contract/v1/README.md#launch-templates) | 设置值在模板里的替换规则 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`settings/settings.plugin.json`](../../contract/v1/fixtures/settings/settings.plugin.json) | 放在 Elixir 语言页和 Mix 构建系统页上的设置、`immediate` 设置、`settingActions` 按钮 | +| [`languages/node-runtime.plugin.json`](../../contract/v1/fixtures/languages/node-runtime.plugin.json) | 带插件设置的语言服务器(`node` 运行时) | +| [`build-systems/build-tool-path.plugin.json`](../../contract/v1/fixtures/build-systems/build-tool-path.plugin.json) | 用 `file` 设置选择构建工具的可执行文件候选 | +| [`debug-adapters/debug-adapter.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) | 调试适配器用 `file` 设置(`nextRun`)选择要调试的程序 | + +设置相关的拒绝用例在 [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json)。 + +### 相关指南 + +- [从零构建一个完整生命周期的 LSP 插件](../../guides/lsp-plugin-lifecycle.md) +- [为语言插件接入构建、运行与测试](../../guides/build-tool-integration.md) + +--- + +## English + +A plugin declares, in its manifest, the options a user may change. The host draws the controls, stores the values, keeps the global and project layers apart, and passes the effective values to Core. Core merges them and expands them into launch plans, `initializationOptions`, or a language server's `workspaceConfiguration`. The plugin writes no settings-page code, and the host never needs to know the plugin. + +| Item | Value | +| --- | --- | +| Manifest fields | `settings`, `settingActions` | +| Capability | `settings`, version `0`. Expanding and pushing `workspaceConfiguration` is described in [Languages](languages.md) | +| Protocol | None of its own; `settingActions` call the plugin's own [plugin functions](backend.md) | +| API IDs | [API-19](../api-catalog.md#apis-usable-now): declarative settings. Runtime reads, source inspection, writes, and change events are still planned | +| Permissions | None. Settings never change which program runs, so they are not in the install review's program list | +| Activation | Not needed. Values take effect according to the setting's `effect`: at the next start, a model reload, the next run, or an `immediate` push | +| Desktop | Declares `settings`: pages per language and build system, sections, every control type, the project layer, effects, and page buttons are presented | + +### Setting types + +| `type` | Fields | Rule | +| --- | --- | --- | +| `integer` | `minimum`, `maximum`, `unit` (optional) | `minimum` and `maximum` are required | +| `enum` | `options`, each `{value, title}` | One to 16 options; values contain only letters, digits, `.`, `_`, `-` | +| `boolean` | — | The value is `true` or `false` | +| `multiSelect` | `options`, each `{value, title}` | One to 32 options; the value is an array of distinct option values | +| `text` | `pattern` (required), `maxLength` (optional), `placeholder` (optional) | `pattern` matches the whole value; `maxLength` defaults to 256 and is at most 1024 Unicode scalars | +| `file`, `directory` | `placeholder` (optional) | An absolute path of at most 1024 Unicode scalars, or empty for "not set" | + +`title` and `description` are text the host shows. Text and paths never start with `-` and contain no control characters. `default` must have the JSON type of `type` and must be accepted by the declaration. + +```json +"settings": [ + { "id": "heapMB", "type": "integer", "title": "Maximum memory", + "default": 2048, "minimum": 512, "maximum": 8192, "unit": "MB" }, + { "id": "logLevel", "type": "enum", "title": "Log level", "default": "info", + "options": [{ "value": "info", "title": "Information" }, { "value": "debug", "title": "Debug" }] }, + { "id": "telemetry", "type": "boolean", "title": "Send usage data", "default": false } +] +``` + +Because every value is a number, a boolean, or one of the declared option values, a setting can never inject extra arguments or change which package file runs. A stored value the declaration no longer accepts (for example after an update narrowed a range) is replaced by the default, and a launch never fails for it. Values for undeclared IDs are ignored. + +### Placement, scope, effect, and page buttons + +A setting may also say where it is shown and when a change takes effect. The host offers only generic registration points: a page per language and per build system, sections, one control per setting type, a project/global switch, and buttons. What appears there comes from the manifest. + +```json +"settings": [ + { "id": "dialyzer", "type": "boolean", "title": "Dialyzer", "default": true, + "scope": "project", "effect": "immediate", "page": { "languageID": "elixir" } }, + { "id": "mixEnv", "type": "text", "title": "MIX_ENV", "default": "dev", "pattern": "[a-z]+", + "scope": "project", "page": { "languageID": "elixir" }, "section": "Server" } +], +"settingActions": [ + { "id": "rebuildPlt", "title": "Rebuild PLT", "function": "dialyzer.rebuild", + "page": { "languageID": "elixir" }, "confirm": true } +] +``` + +| Field | Meaning | +| --- | --- | +| `scope` | `global` (default): one value for every project. `project`: the open project may override it | +| `effect` | When a change takes effect. `restart` (default): the next start of the plugin's processes. `immediate`: pushed to the running language server through `workspaceConfiguration`. `reload`: the next build-model reload, which the host performs for the plugin's build models. `nextRun`: the next run or build command | +| `page` | `{ "languageID": … }` or `{ "buildSystemID": … }`: the settings page that shows the setting beside the plugin's own page. Any plugin may add to a page; the host shows the page while an enabled plugin contributes to it | +| `section` | Heading the setting is grouped under on its page | + +`settingActions` (at most 16, sorted by unique `id`) are buttons the host draws on the plugin's page and on their `page`. Each invokes the plugin function `function` of the same plugin with empty params, served by the plugin's own provider process for the open project (see [plugin functions](backend.md)). `confirm: true` makes the host ask first. The host owns the button and knows nothing about what the function does. Declaring actions counts as using `settings`. + +Placement and use rules that Core checks at validation: + +- An `immediate` setting may appear only in a language server's `workspaceConfiguration`. It never reaches a command line or `initializationOptions`, so a change never needs a new process. +- A `text`, `file`, or `directory` setting is a whole template value, or the value of one `name=` argument; it is never part of a longer argument. + +### Two layers and their order + +The host stores one global value per plugin and setting, and a project value in the project's `.lithe/plugin-settings.json` (`{pluginID: {settingID: value}}`, meant to be shared through version control). It sends both as `settings` and `projectSettings`. Core merges them in this order: project (only for `scope: "project"`), then global, then default, skipping a stored value the declaration no longer accepts. + +`plugins.declarative.resolveSettings` returns each effective value with its `source` (`project`, `global`, or `default`). + +### Contract sections + +| Section | Content | +| --- | --- | +| [Plugin settings](../../contract/v1/README.md#plugin-settings) | Setting fields, types, the `${setting:}` template, and use rules | +| [Placement, scope, effect, and page buttons](../../contract/v1/README.md#placement-scope-effect-and-page-buttons) | Placement, layers, effect timing, page buttons, and validation rules | +| [Workspace configuration](../../contract/v1/README.md#workspace-configuration) | Expanding and pushing `workspaceConfiguration` | +| [Executable resolution](../../contract/v1/README.md#executable-resolution) | `setting` executable candidates: a global `file` setting selects the tool path, candidate order, and strict selection | +| [Launch templates](../../contract/v1/README.md#launch-templates) | How setting values are substituted in templates | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`settings/settings.plugin.json`](../../contract/v1/fixtures/settings/settings.plugin.json) | Settings on the Elixir language page and the Mix build-system page, an `immediate` setting, and `settingActions` buttons | +| [`languages/node-runtime.plugin.json`](../../contract/v1/fixtures/languages/node-runtime.plugin.json) | A language server with plugin settings (`node` runtime) | +| [`build-systems/build-tool-path.plugin.json`](../../contract/v1/fixtures/build-systems/build-tool-path.plugin.json) | A `file` setting selects the build tool's executable candidate | +| [`debug-adapters/debug-adapter.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) | A debug adapter uses a `file` setting (`nextRun`) to pick the program to debug | + +Settings rejections are in [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json). + +### Related guides + +- [Building an LSP plugin end to end](../../guides/lsp-plugin-lifecycle.md) +- [Build, run, and test for language plugins](../../guides/build-tool-integration.md) diff --git a/shared/plugins/reference/domains/toolchains.md b/shared/plugins/reference/domains/toolchains.md new file mode 100644 index 00000000..e13ea89d --- /dev/null +++ b/shared/plugins/reference/domains/toolchains.md @@ -0,0 +1,309 @@ +# 工具链 / Toolchains + +[中文](#中文) · [English](#english) + +## 中文 + +插件可以告诉 Lithe 一个工具(JDK、Maven、Node、Go 等)长什么样、去哪里找、怎么判断版本,也可以用后端回答“这台机器装了哪些版本、这个项目要哪个版本”。宿主没有内置任何工具定义、别名表或按工具名的分支:工具定义只来自已启用的插件,Core 负责校验和选择,宿主只做文件系统观察、运行版本探针和启动后端这些本机 I/O。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `toolDefinitions[]`(工具定义)、`toolchainProviders[]`(工具链提供者)、顶层 `requiresTools`(启动时引用的工具定义 ID) | +| 能力 | `toolDefinitions` 0;`toolchainProviders` 0(还需要 `backend` 0) | +| 协议 | 工具链提供者协议 `litheToolchains`,版本 `0`:宿主通过 `toolchains/query` 向提供者的后端查询。协商见[契约](../../contract/v1/toolchains/README.md#provider-backend-negotiation) | +| API 编号 | [API-23](../api-catalog.md#现在能用的-api):工具链与受管进程(部分完成) | +| 权限 | 工具定义不需要权限;提供者需要 `backend.launch` 和 `toolchains.provide` | +| 激活事件 | 不需要。提供者由宿主的第一次查询唤起,不需要页面或启动事件 | +| 后端启动 | 提供者的后端只能是包内可执行文件(`packaged`),或包内运行时(`runtime` 加 `packagedRuntime`),不能依赖需要发现的运行时 | +| 桌面端 | 声明 `toolDefinitions`;未声明 `toolchainProviders`,见“桌面端现状” | +| 官方 SDK | 三个官方 SDK 都还没有实现 `litheToolchains`,提供者后端要自己处理 `toolchains/query` | + +### 两条安装来源 + +一个工具可以走其中一条、两条都走,或者都不走(只靠固定、项目文件、全局默认和登录 shell 里的命令)。 + +| 来源 | 由谁声明 | 适合 | 谁执行 | +| --- | --- | --- | --- | +| 声明式发现 | 工具定义里的 `discovery`、`homeLayouts`、`homeVariable`、`commands`、`version` | 目录形状固定的安装:用户主目录、环境变量或系统绝对目录下的单段 `*` 通配,可选的主目录后缀,加上对版本探针输出的正则 | 宿主展开 `discoveryPlan` 的扫描,不运行插件代码 | +| 提供者查询 | `toolchainProviders[]` 回答 `toolchains/query` | 声明式原语表达不了的位置:注册表、属性列表、版本管理器数据库、命令输出、版本管理器自己的版本文件 | 提供者的包内后端 | + +声明式原语已经冻结:根目录只有 `home`、`environment`、`absolute` 三种,通配只有单段 `*`。新的安装形状由提供者回答,不再增加根目录类型或宿主解析器。没有 `watchFiles` 的提供者只报告安装,应答里不能有版本要求。 + +### 选择顺序 + +| 层 | 规则 | +| --- | --- | +| 固定(pin) | 最近的祖先目录的固定;只按完整路径段匹配,前缀相似的兄弟目录不继承 | +| 项目 | 最近的声明目录;同一深度 wrapper > 环境变量 > 声明;同一来源里原生读取器优先于兼容读取器。离得近的项目要求遮住更上层的要求 | +| 全局 | Lithe 的全局默认 > 没有歧义(或用户已选)的提供者全局默认 > 登录 shell | +| 自动 | 已安装、满足最低版本的最高版本。只用于固定的安装不参与自动选择。不同主目录有相同最高版本时算冲突,要求用户选择 | + +一次操作还可以带 `fixed: {home}` 或 `fixed: {executable}`,它排在目录固定之前。这个显式选择无效时直接失败,更低的层不能替代它。 + +没有安装的项目要求会带着原因保留为“未满足”,即使更低的层选中了别的安装。冲突必须由用户明确选择,不按插件 ID 或路径先后决定。 + +### 工具 ID 与冲突 + +- 任何插件都可以定义短 ID(例如 `compiler`)或限定 ID `<自己的插件 ID>:<本地 ID>`。限定 ID 的前缀必须是定义它的插件自己的 ID,所以不同插件的限定 ID 永远不会冲突。宿主不提供来源证明,插件自称的来源也不会被读取。提供者可以回答短 ID。 +- 同一个短 ID 被多个已启用插件定义时,它不归其中任何一方:注册表不登记这些定义,其余定义照常登记;`discoveryPlan` 不给它列扫描和提供者;解析它的消费者把它当作没有人声明。归属从不按到达顺序或插件 ID 顺序决定,否则恶意插件可以靠先加载或取更小的 ID 抢走工具。 +- 注册表的输出带 `conflicts: [{tool, pluginIDs}]`:每个被争用的工具一条,按 `tool` 排序,`pluginIDs` 是所有定义它的插件 ID(也排序)。宿主应当告诉用户是哪些插件在争用。 +- 同一个插件 ID 在一批输入里重复出现,仍然拒绝整批,不会替换先前登记的归属。 + +### Core 命令 + +所有命令都只处理 UTF-8 JSON,输入上限 4 MiB,不做任何本机 I/O。成功返回 `{status: "ok", <键>: 值}`,拒绝返回 `{status: "rejected", error: {code, message, field?}}`。 + +| 命令 | 输入 | 成功时的键 | 用途 | +| --- | --- | --- | --- | +| `toolchains.registry` | `{plugins: [{manifest}]}` | `registry` | 重新校验完整清单,汇总定义和提供者,返回 `{definitions, providers, conflicts}`;宿主不提供来源证明;被多个插件争用的短 ID 不登记,列在 `conflicts`;最多 128 个插件、512 个定义、256 个提供者 | +| `toolchains.decodeReply` | 查询请求、提供者应答、宿主观察到的文件系统事实 | `snapshot` | 校验一次 `toolchains/query` 应答,得到 `{installations, statements, watch, diagnostics}` | +| `toolchains.failureReply` | `{params, code}` | `reply` | 查询没有应答时生成对应的 JSON-RPC 错误;`code` 只能是 `plugin.toolchain.timeout` 或 `plugin.toolchain.providerUnavailable` | +| `toolchains.resolvePins` | `{document, directory, tool}` | `resolution` | 在 `local.json` 里找最近的固定,返回 `{pin, sourceDirectory}` | +| `toolchains.resolve` | 某个目录的完整解析输入 | `resolution` | 走四层选择,结果是 `resolved`、`skip` 或 `terminalFailure`,附带 `trace`、`unmet`、`conflicts` | +| `toolchains.checkVersion` | `{registry, tool, output, minimumVersion?}` | `version` | 用定义的版本正则取版本并比较;最多 8 段数字、64 字节,不解释别名 | +| `toolchains.instantiateLaunch` | `{registry, tool, executable, home?, entry?, runtimeArguments, arguments}` | `launch` | 按工具定义的 `launch` 模板,为宿主选中的可执行文件展开启动参数,返回 `{executable, arguments, versionArguments}`;见“按工具定义 ID 启动进程” | +| `toolchains.discoveryPlan` | `{registry, platform}` | `plan` | 每个工具的扫描计划和声明它的提供者;最多 256 个根目录、4096 个条目、64 层祖先 | +| `toolchains.candidates` | 设置页的发现输入 | `candidates` | 设置页显示的全局层和自动层:`{tool, shell, installations, skipped, selected, conflicts}`;第一次调用把待探测的可执行文件列为 `probeFailed`,宿主运行探针后再调用一次 | + +### 提供者应答的限制 + +| 项目 | 规则 | +| --- | --- | +| 应答内容 | `result: {operationID, generation, providerID, installations, statements, watch}`;安装是 `{tool, version, home}`,版本要求是 `{tool, scope, source, request, installation, kind, reader}` | +| 大小 | 最多 1 MiB、512 个安装、256 条要求、256 个监视路径 | +| 路径 | 文件系统事实来自宿主,不来自提供者;主目录不能在任何工作区根目录里,必须符合定义的可执行文件布局 | +| 身份 | JSON-RPC `id`、`operationID`、`generation`、`providerID` 必须和请求一致;数字 `1` 和字符串 `"1"` 是不同的 ID;远程 authority 一律拒绝 | +| 版本要求 | `request` 的写法对 Core 不透明(例如 `lts`),宿主不解释;`installation: null` 表示要求没有被满足 | + +提供者可以在 JSON-RPC 错误里使用下面的错误码,`decodeReply` 按这张表校验(一个数字只对应一个语义): + +| JSON-RPC | 语义码 | 含义 | +| --- | --- | --- | +| `-32601` | `plugin.toolchain.methodUnavailable` | 查询方法或能力没有协商 | +| `-32602` | `plugin.toolchain.invalidParams` | 请求格式错误或声明的输入无效 | +| `-32800` | `plugin.toolchain.cancelled` | 调用方取消了请求 | +| `-32070` | `plugin.toolchain.permissionDenied` | 缺少权限、审查或启用状态 | +| `-32071` | `plugin.toolchain.authorityUnsupported` | 没有这个 authority 的适配器 | +| `-32072` | `plugin.toolchain.timeout` | 超过本地截止时间 | +| `-32073` | `plugin.toolchain.providerUnavailable` | 后端启动失败、断开或上游查询失败 | +| `-32074` | `plugin.toolchain.invalidReply` | 应答格式错误,或安装引用了别的工具 | +| `-32075` | `plugin.toolchain.limitExceeded` | 超过应答、扫描或监视预算 | +| `-32076` | `plugin.toolchain.unsafePath` | 绝对路径、包含关系或可执行文件证据不安全 | +| `-32077` | `plugin.toolchain.operationExpired` | 发出查询时上下文或代次已经失效 | + +### 按工具定义 ID 启动进程 + +插件用工具定义 ID 启动进程时(`launch.kind` 为 `tool` 的启动计划,或后端的 `run/start` 带 `{kind: "tool"}`),从不自己写这个工具的 argv。定义的 `launch` 模板说明工具怎样接收入口文件、运行时参数和程序参数,宿主和插件都不按工具名分支。 + +| 项目 | 规则 | +| --- | --- | +| 声明 | 清单顶层 `requiresTools` 列出插件启动进程用到的所有工具定义 ID:最多 32 个,短 ID 或限定 ID,不重复,保存时排序。定义来自插件自己或别的已启用插件的 `toolDefinitions` | +| 引用位置 | `runtime` 启动的 `launch.tool`、`runtimeDependencies[].tool`、调试适配器的 `runSelectedRuntime.tool`。引用 `requiresTools` 没列出的 ID 是 `plugin.runtime.undeclared`,带 `field` | +| 启动计划 | 返回各部分而不是命令行:`launch: {kind: "tool", tool, entry, runtimeArguments, minimumVersion?, packagedRuntime?}`;`arguments` 只含插件自己的参数;`runtimeRequirements` 列出宿主要解析的每个工具。`packagedRuntime` 是插件自带运行时的路径,宿主直接把这个文件当作工具的可执行文件,不再查找 | +| `run/start` | `process.executable` 可以是 `{kind: "tool", tool}`(`tool` 必须在清单 `requiresTools` 里,否则是 `plugin.run.invalidStart`,`field` 为 `process.executable.tool`),或 `{kind: "packaged", path}`(插件包内文件);见[插件后端](backend.md) | +| 宿主做什么 | 按四层选择解析每个工具,用 `toolchains.checkVersion` 检查 `minimumVersion`,再用 `toolchains.instantiateLaunch` 展开,然后启动 | +| 解析不到 | 工具解析不到可执行文件是 `plugin.host.toolUnresolved`(JSON-RPC `-32025`);`details` 是 `{tool, reason}`,`reason` 为 `notFound`(四层里没有候选满足定义和最低版本)或 `unusable`(选中的可执行文件不能用) | + +`toolchains.instantiateLaunch` 的 `executable` 必填;`home` 是解析出的工具主目录,`entry` 是绝对文件路径,`runtimeArguments` 和 `arguments` 是字符串列表。文本最多 4096 字节,列表最多 1024 项、每项最多 64 KiB,都不含 NUL。模板里的 `${executable}`、`${home}`、`${entry}`、`${runtimeArguments}`、`${arguments}` 被对应的值替换,列表原位展开;其他占位符原样保留。调用方给了模板带不动的值(给不启动入口的工具一个 `entry`,或给不接受参数的工具参数),或漏了模板需要的 `${home}`、`${entry}`,是 `plugin.toolchain.launchMismatch`;未知或已停用的 `tool` 是 `plugin.toolchain.invalidParams`,`field` 为 `tool`。这个命令不做任何本机 I/O:选可执行文件、探测版本、启动进程都由宿主完成。 + +### 本机文档 + +`.lithe/toolchains/local.json` 的 `version` 仍为 `1`,这是 Core 当前校验的格式。`toolchains.resolvePins` 校验 `{version: 1, toolchains, pins?}`,`pins` 按目录记录 `{home, version, provider}`。这个文档只读取,不授予执行权限,也不写文件;保存固定尚未实现。 + +### 桌面端现状 + +| 用途 | Lithe 桌面端 | +| --- | --- | +| 工具定义(`toolDefinitions`) | 已声明。设置页的发现结果,以及构建和运行选择工具,都通过共享 Core 解析器完成 | +| 工具链提供者(`toolchainProviders`) | 未声明。提供者查询路线不可用,需要它的插件不会被提供安装 | +| 语言服务器的 `{kind: "tool"}` 启动应答 | 启动时被拒绝,因为工具还没有经过工具链注册表解析(见[语言](languages.md)) | +| 保存固定(pin)、`toolchainConsumers`、通用运行时启动、提供者热激活 | 契约尚未提供 | + +`toolchains.provide` 可以影响某个已定义工具启动哪个程序,但不授权替换任意命令、安装软件、访问网络或执行工作区代码。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [Plugin-owned toolchains (Core foundation)](../../contract/v1/README.md#plugin-owned-toolchains-core-foundation) | 契约总入口里的工具链说明和当前宿主状态 | +| [Manifest admission and registry](../../contract/v1/toolchains/README.md#manifest-admission-and-registry) | 能力和权限要求、定义字段、提供者字段、ID 规则(短 ID 与限定 ID) | +| [Two installation routes](../../contract/v1/toolchains/README.md#two-installation-routes) | 声明式发现和提供者查询 | +| [Commands and envelopes](../../contract/v1/toolchains/README.md#commands-and-envelopes) | 九个 Core 命令、返回格式、注册表限制、`conflicts` | +| [Launching a tool definition](../../contract/v1/toolchains/README.md#launching-a-tool-definition) | `toolchains.instantiateLaunch`:按定义的 `launch` 模板展开启动参数、`launchMismatch` | +| [Tool references](../../contract/v1/README.md#tool-references) | 清单 `requiresTools`、引用位置、启动计划的各部分、`plugin.host.toolUnresolved` | +| [Run and debug sessions](../../contract/v1/README.md#run-and-debug-sessions) | `run/start` 的 `process.executable`:`{kind: "tool"}` 或 `{kind: "packaged"}` | +| [Provider reply validation](../../contract/v1/toolchains/README.md#provider-reply-validation) | `decodeReply`、应答格式与限制 | +| [Error replies](../../contract/v1/toolchains/README.md#error-replies) | 错误回复的格式,以及 11 个稳定失败码的数字码对照 | +| [Pins and snapshot resolution](../../contract/v1/toolchains/README.md#pins-and-snapshot-resolution) | `local.json`、`resolve`、四层选择、`checkVersion`、`discoveryPlan` | +| [Settings candidates](../../contract/v1/toolchains/README.md#settings-candidates) | `candidates` 和两次调用的探测流程 | +| [Executable resolution](../../contract/v1/README.md#executable-resolution) | 可执行文件候选的排序和严格选择 | +| [Provider backend negotiation](../../contract/v1/toolchains/README.md#provider-backend-negotiation) | `litheToolchains` 协商、`lsp.toolchainRequest`、超时 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`toolchains/tool-definition.plugin.json`](../../contract/v1/fixtures/toolchains/tool-definition.plugin.json) | 生产形态的工具定义(`java`):项目和全局设置、工具链请求、`JAVA_HOME`、`java` 命令等候选,以及 macOS 的 `JavaVirtualMachines` 目录发现 | +| [`toolchains/toolchain-provider.plugin.json`](../../contract/v1/fixtures/toolchains/toolchain-provider.plugin.json) | 生产形态的版本管理器提供者(`mise`):覆盖多个工具,监视项目版本文件(`.tool-versions` 用兼容读取器),后端是包内可执行文件 | +| [`run/tool-reference-launch.plugin.json`](../../contract/v1/fixtures/run/tool-reference-launch.plugin.json) | 清单顶层 `requiresTools` 列出插件自己定义的 `interpreter`,语言服务器的 `runtime` 启动按这个工具定义 ID 引用运行时 | + +Core 的验收测试在主仓库的 [`toolchain_definitions.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_definitions.rs) 和同目录的 `toolchain_pins.rs`、`toolchain_queries.rs`、`toolchain_resolution.rs`。这三个测试共用的固定值文档、查询往返和每个稳定失败码的错误回复内联在 [`toolchain_vectors.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_vectors.rs) 里。 + +### 相关指南 + +- [为语言插件接入构建、运行与测试](../../guides/build-tool-integration.md):构建候选集里的 `toolchain` 可执行文件 +- [原生构建、测试与运行准备](../../guides/native-build-workflows.md):运行准备返回的 `executable.kind: "toolchain"` + +--- + +## English + +A plugin can tell Lithe what a tool (a JDK, Maven, Node, Go and so on) looks like, where to find it, and how to read its version. A backend can also answer which versions this machine has and which version a project asks for. The host has no built-in tool definitions, alias table, or tool-name branches. Definitions come only from enabled plugins; Core validates and selects; the host performs only native I/O such as filesystem observation, version probes, and backend launches. + +| Item | Value | +| --- | --- | +| Manifest fields | `toolDefinitions[]`, `toolchainProviders[]`, and the top-level `requiresTools` (tool-definition IDs that launches reference) | +| Capabilities | `toolDefinitions` 0; `toolchainProviders` 0 (also needs `backend` 0) | +| Protocol | The toolchain provider protocol `litheToolchains`, version `0`: the host queries the provider's backend with `toolchains/query`. Negotiation is in the [contract](../../contract/v1/toolchains/README.md#provider-backend-negotiation) | +| API IDs | [API-23](../api-catalog.md#apis-usable-now): toolchains and managed processes (partly complete) | +| Permissions | None for definitions; a provider needs `backend.launch` and `toolchains.provide` | +| Activation | None needed. The host's first query starts a provider; no page or startup event is needed | +| Backend launch | The provider's backend must be `packaged`, or `runtime` with `packagedRuntime`, and must not depend on a discovered runtime | +| Desktop | Declares `toolDefinitions`; does not declare `toolchainProviders`. See "Desktop status" | +| Official SDKs | None of the three official SDKs implements `litheToolchains` yet; a provider backend handles `toolchains/query` itself | + +### Two installation routes + +A tool may use one route, both, or neither (relying only on pins, project files, global defaults, and commands from the login shell). + +| Route | Declared by | Fits | Runs | +| --- | --- | --- | --- | +| Declarative discovery | The definition's `discovery`, `homeLayouts`, `homeVariable`, `commands`, and `version` | Installations with a fixed directory shape: a single-component `*` glob under the user home, an environment variable, or an absolute system directory, an optional home suffix, and a regular expression over the version probe's output | The host expands the `discoveryPlan` scans; no plugin code runs | +| Provider query | `toolchainProviders[]` answering `toolchains/query` | Locations those primitives cannot express: registries, property lists, manager databases, command output, manager-specific version files | The provider's packaged backend | + +The declarative primitives are frozen: root kinds are `home`, `environment`, and `absolute`, and patterns are single-component `*` globs. A new installation shape is answered by a provider, not by a new root kind or host parser. A provider without `watchFiles` only reports installations, and its replies may not contain version requirements. + +### Selection order + +| Layer | Rule | +| --- | --- | +| Fixed (pin) | The nearest ancestor directory's pin. Matched by whole path segments only; a sibling with a similar prefix does not inherit it | +| Project | The nearest declaring directory; at the same depth, wrapper > environment variable > declaration; within one source, the native reader beats the compatible reader. A nearer project requirement masks a higher one | +| Global | Lithe's global default > an unambiguous (or user-chosen) provider global default > the login shell | +| Automatic | The highest installed version that meets the minimum. Installations added only as pins never take part in automatic selection. Equal highest versions in different homes are a conflict that the user must resolve | + +A single operation may pass `fixed: {home}` or `fixed: {executable}`. It precedes directory pins. An invalid explicit choice fails terminally, and lower layers cannot replace it. + +A requirement with no installation stays "unmet", with its reason, even when a lower layer selects another installation. Conflicts require an explicit user choice; they are never decided by plugin ID or path order. + +### Tool IDs and conflicts + +- Any plugin may define a short ID (for example `compiler`) or a qualified ID `:`. The prefix of a qualified ID must be the defining plugin's own ID, so qualified IDs of different plugins never collide. The host supplies no provenance evidence, and a plugin's claim of origin is not read. A provider may answer a short ID. +- A short ID defined by more than one enabled plugin belongs to none of them. The registry omits those definitions and registers the others as usual; `discoveryPlan` lists no scans or providers for it; a consumer that resolves it treats it as declared by nobody. Ownership is never decided by arrival order or plugin ID order, since a hostile plugin could otherwise take a tool by loading first or by choosing a smaller ID. +- The registry returns `conflicts: [{tool, pluginIDs}]`: one entry per contested tool, sorted by `tool`, with every defining plugin ID, sorted. A host should tell the user which plugins contest the tool. +- A plugin ID repeated within one input still rejects the whole batch, without replacing earlier owners. + +### Core commands + +Every command accepts UTF-8 JSON, has a 4 MiB input budget, and performs no native I/O. Success is `{status: "ok", : value}`; refusal is `{status: "rejected", error: {code, message, field?}}`. + +| Command | Input | Success key | Purpose | +| --- | --- | --- | --- | +| `toolchains.registry` | `{plugins: [{manifest}]}` | `registry` | Revalidates complete manifests and returns `{definitions, providers, conflicts}`. The host supplies no provenance proof. Short IDs contested by several plugins are not registered and are listed in `conflicts`. Limits: 128 plugins, 512 definitions, 256 providers | +| `toolchains.decodeReply` | The query request, the provider's reply, and the host's observed filesystem facts | `snapshot` | Validates one `toolchains/query` reply and returns `{installations, statements, watch, diagnostics}` | +| `toolchains.failureReply` | `{params, code}` | `reply` | Builds the JSON-RPC error when a query gets no reply. `code` is `plugin.toolchain.timeout` or `plugin.toolchain.providerUnavailable` | +| `toolchains.resolvePins` | `{document, directory, tool}` | `resolution` | Finds the nearest pin in `local.json`; returns `{pin, sourceDirectory}` | +| `toolchains.resolve` | The full resolution input for one directory | `resolution` | Runs the four layers. The result is `resolved`, `skip`, or `terminalFailure`, with `trace`, `unmet`, and `conflicts` | +| `toolchains.checkVersion` | `{registry, tool, output, minimumVersion?}` | `version` | Reads the version with the definition's regular expression and compares it. At most 8 numeric parts and 64 bytes; aliases are not interpreted | +| `toolchains.instantiateLaunch` | `{registry, tool, executable, home?, entry?, runtimeArguments, arguments}` | `launch` | Expands the definition's `launch` template for the executable the host selected, returning `{executable, arguments, versionArguments}`; see "Launching a process by tool-definition ID" | +| `toolchains.discoveryPlan` | `{registry, platform}` | `plan` | Each tool's scan plan and the providers that declare it. Limits: 256 roots, 4096 entries, 64 ancestors | +| `toolchains.candidates` | The settings page's discovery input | `candidates` | The global and automatic layers shown on a settings page: `{tool, shell, installations, skipped, selected, conflicts}`. The first call lists executables to probe as `probeFailed`; the host runs the probes and calls again | + +### Limits on provider replies + +| Item | Rule | +| --- | --- | +| Content | `result: {operationID, generation, providerID, installations, statements, watch}`. An installation is `{tool, version, home}`; a requirement is `{tool, scope, source, request, installation, kind, reader}` | +| Size | At most 1 MiB, 512 installations, 256 requirements, and 256 watch paths | +| Paths | Filesystem facts come from the host, not the provider. A home may not lie inside any workspace root and must match the definition's executable layout | +| Identity | The JSON-RPC `id`, `operationID`, `generation`, and `providerID` must match the request. The number `1` and the string `"1"` are different IDs. Remote authorities are always refused | +| Version requirements | `request` is opaque to Core (for example `lts`) and the host does not interpret it. `installation: null` means the requirement is unmet | + +A provider may use these codes in its JSON-RPC errors; `decodeReply` validates against this table, and each number has one meaning: + +| JSON-RPC | Semantic code | Meaning | +| --- | --- | --- | +| `-32601` | `plugin.toolchain.methodUnavailable` | The query method or capability was not negotiated | +| `-32602` | `plugin.toolchain.invalidParams` | Malformed request or invalid declared input | +| `-32800` | `plugin.toolchain.cancelled` | The caller cancelled the request | +| `-32070` | `plugin.toolchain.permissionDenied` | A permission, review, or enabled-state prerequisite is missing | +| `-32071` | `plugin.toolchain.authorityUnsupported` | The provider has no adapter for this authority | +| `-32072` | `plugin.toolchain.timeout` | The local deadline passed | +| `-32073` | `plugin.toolchain.providerUnavailable` | The backend failed to start or disconnected, or its upstream query failed | +| `-32074` | `plugin.toolchain.invalidReply` | Malformed reply, or an installation refers to another tool | +| `-32075` | `plugin.toolchain.limitExceeded` | A reply, scan, or watch budget was exceeded | +| `-32076` | `plugin.toolchain.unsafePath` | An unsafe absolute path, containment relation, or executable evidence | +| `-32077` | `plugin.toolchain.operationExpired` | The context or generation was already stale when the query was sent | + +### Launching a process by tool-definition ID + +When a plugin starts a process by tool-definition ID (a launch plan with `launch.kind` `tool`, or a backend `run/start` with `{kind: "tool"}`), it never writes that tool's argv. The definition's `launch` template says how the tool receives the entry file, runtime arguments, and program arguments, so neither the host nor the plugin branches on a tool name. + +| Item | Rule | +| --- | --- | +| Declaration | The top-level `requiresTools` lists every tool-definition ID the plugin's process launches use: at most 32, short or qualified, without duplicates, stored sorted. Definitions come from the plugin itself or another enabled plugin's `toolDefinitions` | +| References | The `launch.tool` of a `runtime` launch, `runtimeDependencies[].tool`, and a debug adapter's `runSelectedRuntime.tool`. An ID that `requiresTools` does not list is `plugin.runtime.undeclared`, with `field` | +| Launch plan | Returns parts, not a command line: `launch: {kind: "tool", tool, entry, runtimeArguments, minimumVersion?, packagedRuntime?}`; `arguments` holds only the plugin's own arguments; `runtimeRequirements` lists every tool the host resolves. `packagedRuntime` is the path of a runtime the plugin ships; the host uses that file as the tool's executable and does not look the tool up | +| `run/start` | `process.executable` may be `{kind: "tool", tool}` (`tool` must be in `requiresTools`, or the result is `plugin.run.invalidStart` with `field` `process.executable.tool`) or `{kind: "packaged", path}` (a file in the plugin package); see [Plugin backend](backend.md) | +| Host steps | Resolve each tool through the four layers, check `minimumVersion` with `toolchains.checkVersion`, expand the start with `toolchains.instantiateLaunch`, then start | +| Unresolved | A tool with no executable is `plugin.host.toolUnresolved` (JSON-RPC `-32025`); `details` is `{tool, reason}`, where `reason` is `notFound` (no candidate in the four layers meets the definition and minimum version) or `unusable` (the selected executable cannot be used) | + +`toolchains.instantiateLaunch` requires `executable`. `home` is the resolved tool home, `entry` an absolute file path, and `runtimeArguments` and `arguments` string lists. A text is at most 4096 bytes; a list has at most 1024 items of 64 KiB each; none may hold a NUL. `${executable}`, `${home}`, `${entry}`, `${runtimeArguments}`, and `${arguments}` are replaced by their values, lists expanding in place; other tokens are kept as written. A value the template cannot carry (an `entry` for a tool that launches none, or arguments for one that takes none), or a missing `${home}` or `${entry}`, is `plugin.toolchain.launchMismatch`. An unknown or disabled `tool` is `plugin.toolchain.invalidParams` with `field` `tool`. The command performs no native I/O: selecting the executable, probing its version, and starting the process are the host's work. + +### Local document + +`.lithe/toolchains/local.json` keeps `version` `1`, which is the format Core validates today. `toolchains.resolvePins` validates `{version: 1, toolchains, pins?}`, where `pins` maps directory keys to `{home, version, provider}`. The document is read, not written; it grants no execution authority; saving pins is not implemented. + +### Desktop status + +| Use | Lithe desktop | +| --- | --- | +| Tool definitions (`toolDefinitions`) | Declared. Settings discovery results, and Build and Run tool selection, go through the shared Core resolver | +| Toolchain providers (`toolchainProviders`) | Not declared. The provider query route is unavailable, and a plugin that needs it is not offered for installation | +| Language server `{kind: "tool"}` launch answer | Refused at start, because the tool is not yet resolved through the toolchain registry (see [Languages](languages.md)) | +| Saving pins, `toolchainConsumers`, generic runtime launches, live provider activation | Not provided by the contract yet | + +`toolchains.provide` can influence which program a defined tool launches. It does not grant arbitrary command replacement, installation, network access, or execution of workspace code. + +### Contract sections + +| Section | Content | +| --- | --- | +| [Plugin-owned toolchains (Core foundation)](../../contract/v1/README.md#plugin-owned-toolchains-core-foundation) | The toolchain overview in the contract entry point and the current host status | +| [Manifest admission and registry](../../contract/v1/toolchains/README.md#manifest-admission-and-registry) | Capability and permission requirements, definition and provider fields, ID rules (short and qualified IDs) | +| [Two installation routes](../../contract/v1/toolchains/README.md#two-installation-routes) | Declarative discovery and provider query | +| [Commands and envelopes](../../contract/v1/toolchains/README.md#commands-and-envelopes) | The nine Core commands, return shapes, registry limits, `conflicts` | +| [Launching a tool definition](../../contract/v1/toolchains/README.md#launching-a-tool-definition) | `toolchains.instantiateLaunch`: expanding the launch template, `launchMismatch` | +| [Tool references](../../contract/v1/README.md#tool-references) | The manifest's `requiresTools`, reference sites, the parts of a launch plan, `plugin.host.toolUnresolved` | +| [Run and debug sessions](../../contract/v1/README.md#run-and-debug-sessions) | `process.executable` of `run/start`: `{kind: "tool"}` or `{kind: "packaged"}` | +| [Provider reply validation](../../contract/v1/toolchains/README.md#provider-reply-validation) | `decodeReply`, reply shape and limits | +| [Error replies](../../contract/v1/toolchains/README.md#error-replies) | The error reply format and the numeric codes of the 11 stable failure codes | +| [Pins and snapshot resolution](../../contract/v1/toolchains/README.md#pins-and-snapshot-resolution) | `local.json`, `resolve`, the four layers, `checkVersion`, `discoveryPlan` | +| [Settings candidates](../../contract/v1/toolchains/README.md#settings-candidates) | `candidates` and its two-call probe flow | +| [Executable resolution](../../contract/v1/README.md#executable-resolution) | Ordering and strict selection of executable candidates | +| [Provider backend negotiation](../../contract/v1/toolchains/README.md#provider-backend-negotiation) | `litheToolchains` negotiation, `lsp.toolchainRequest`, timeouts | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`toolchains/tool-definition.plugin.json`](../../contract/v1/fixtures/toolchains/tool-definition.plugin.json) | A production-shaped tool definition (`java`): candidates from project and global settings, toolchain requests, `JAVA_HOME`, the `java` command, and discovery of `*.jdk` under macOS's `JavaVirtualMachines` directory | +| [`toolchains/toolchain-provider.plugin.json`](../../contract/v1/fixtures/toolchains/toolchain-provider.plugin.json) | A production-shaped version-manager provider (`mise`): several tools, watched project version files (`.tool-versions` through the compatible reader), with a packaged backend | +| [`run/tool-reference-launch.plugin.json`](../../contract/v1/fixtures/run/tool-reference-launch.plugin.json) | `requiresTools` lists the plugin's own `interpreter` definition, and a language server's `runtime` launch references the runtime by that tool-definition ID | + +The Core acceptance tests are in [`toolchain_definitions.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_definitions.rs), with `toolchain_pins.rs`, `toolchain_queries.rs`, and `toolchain_resolution.rs` beside it. The fixture documents, query round trips, and the error reply for each stable failure code shared by these tests are inlined in [`toolchain_vectors.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/tests/toolchain_vectors.rs). + +### Related guides + +- [Build, run, and test for language plugins](../../guides/build-tool-integration.md): a `toolchain` executable in a build candidate set +- [Native build, test, and run preparation](../../guides/native-build-workflows.md): `executable.kind: "toolchain"` in run preparation results diff --git a/shared/plugins/reference/domains/ui.md b/shared/plugins/reference/domains/ui.md new file mode 100644 index 00000000..db1e0135 --- /dev/null +++ b/shared/plugins/reference/domains/ui.md @@ -0,0 +1,113 @@ +# 界面:工具窗口、命令、网页与原生内容 / UI: tool windows, commands, web and native content + +[中文](#中文) · [English](#english) + +## 中文 + +插件可以在工作台里放工具窗口和命令。窗口的外壳(停靠、标题、活动栏、顺序、布局、主题、生命周期)始终归宿主;插件只提供窗口内容:纯文本、包内网页,或由宿主绘制的原生树、列表、表单。命令可以打开插件自己的窗口,也可以不打开窗口,只通知后端。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `ui.toolWindows`、`ui.commands`(可选 `icon`) | +| 能力版本 | `ui` 0;原生内容另有[原生 schema](../../contract/v1/native-ui.schema.json) 0。见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | 网页通过 `functions.*` 调用[插件后端](backend.md);没有窗口的命令通过 `litheFunctions` 0 的 `lithe/host/commands/didRun` 通知后端 | +| API 编号 | API-02、03、13、14、17,见 [API 总表](../api-catalog.md#现在能用的-api) | +| 权限 | `ui.contribute`;网页加 `ui.web`;没有窗口的命令加 `backend.launch`;后端弹出消息用 `window.messages` | +| 激活事件 | 每个窗口 `onView:`,每个命令 `onCommand:` | +| Lithe 桌面端 | 声明 `ui` 0,呈现文本、网页和原生工具窗口,以及打开工具窗口的命令。不呈现不属于任何工具窗口的命令:这类包照常安装,但这些命令没有入口 | + +### 规则 + +- **工具窗口**:1–64 个。每个窗口有稳定的 `id`、`title`、初始 `placement`(`left`、`bottom` 或 `right`)、整数 `order`(默认 0),以及且只能有一种内容:`text`(纯文本)、`web`(包内网页)或 `native`(原生内容)。 +- **命令**:最多 128 个,`placements` 非空,取值为 `commandPalette`、`toolbar`、`menu`。带 `toolWindowID` 的命令打开同一插件的一个窗口;省略 `toolWindowID` 的命令只通知后端,此时 `toolWindows` 可以为空或省略。 +- **网页窗口**:`web.entry` 是包内 `.html` 文件的相对路径,需要 `ui.web` 权限。网页的桥接和生命周期见[网页内容](../../contract/v1/README.md#web-content)。 +- **原生窗口**:`native.kind` 为 `tree`、`list` 或 `form`。全部节点最多 2000 个、深度 16;表单最多 32 个控件、16 个按钮。 +- **没有窗口的命令**:需要 `backend`(清单 `requires`)、`ui.contribute` 和 `backend.launch` 权限,以及 `onCommand:` 激活事件。`commands/didRun` 只是事件,不代表命令的业务动作成功。 +- 所有 ID 以插件 ID 加点开头。标题最多 256 字节,窗口文本最多 65536 字节,按原样显示,不作为 HTML 或脚本执行。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [宿主负责的工作台界面](../../contract/v1/README.md#host-owned-workbench-ui) | 工具窗口、命令、ID、限制、宿主负责的部分 | +| [网页内容](../../contract/v1/README.md#web-content) | 包内网页、桥接、状态和生命周期 | +| [宿主绘制的原生内容](../../contract/v1/README.md#host-rendered-native-content) | 原生树、列表、表单,控件种类与限制 | +| [不打开窗口的命令](../../contract/v1/README.md#backend-only-commands) | 没有工具窗口的命令与 `commands/didRun` | +| [UI 图标](../../contract/v1/README.md#ui-icons) | 宿主图标 ID(如 `{"host": "database"}`)与自带 SVG/PNG | +| [公共文档、文件和窗口方法](../../contract/v1/README.md#public-document-file-and-window-methods) | 后端弹出的消息 `window/showInformation`、`showWarning`、`showError` | +| [现有社区商店的窗口](../../contract/v1/README.md#existing-community-store) | 给已有社区包增加宿主绘制视图的步骤 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`manifest/hello-lithe.plugin.json`](../../contract/v1/fixtures/manifest/hello-lithe.plugin.json) | `ui` 0:一个文本窗口和打开它的命令 | +| [`ui/web-panel.plugin.json`](../../contract/v1/fixtures/ui/web-panel.plugin.json) | 两个网页窗口和一个命令 | +| [`ui/native-panel.plugin.json`](../../contract/v1/fixtures/ui/native-panel.plugin.json) | 原生树面板 | +| [`ui/native-form.plugin.json`](../../contract/v1/fixtures/ui/native-form.plugin.json) | 原生表单 | +| [`ui/native-controls.plugin.json`](../../contract/v1/fixtures/ui/native-controls.plugin.json) | 表单的各种控件 | +| [`backend/backend.plugin.json`](../../contract/v1/fixtures/backend/backend.plugin.json) | 网页窗口调用 `backend` 0 | +| [`backend/command-backend.plugin.json`](../../contract/v1/fixtures/backend/command-backend.plugin.json) | 不打开窗口的后端命令 | +| [`native-ui.schema.json`](../../contract/v1/native-ui.schema.json) | 原生内容的 JSON Schema | + +界面相关的拒绝用例在 [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json)。可运行的原生面板示例是 [Node SDK 的 Sample Explorer](../../sdk/node/examples/sample-build/plugin.json)。 + +### 相关指南 + +- [插件后端:工作区上下文与宿主服务](../../guides/plugin-backend-workspace.md) + +--- + +## English + +A plugin can place tool windows and commands in the workbench. The window shell (docking, title, activity bar, order, layout, theme, lifecycle) always belongs to the host; the plugin supplies only the content: plain text, a packaged web page, or a native tree, list, or form drawn by the host. A command can open one of the plugin's windows, or open none and only notify the backend. + +| Item | Value | +| --- | --- | +| Manifest fields | `ui.toolWindows`, `ui.commands` (optional `icon`) | +| Capability versions | `ui` 0; native content also has the [native schema](../../contract/v1/native-ui.schema.json) 0. See [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | Pages call the [plugin backend](backend.md) through `functions.*`; a command without a window notifies the backend with `lithe/host/commands/didRun` (`litheFunctions` 0) | +| API IDs | API-02, 03, 13, 14, 17; see the [API catalog](../api-catalog.md#apis-usable-now) | +| Permissions | `ui.contribute`; `ui.web` for web pages; `backend.launch` for a command without a window; `window.messages` for messages shown by the backend | +| Activation events | `onView:` per window, `onCommand:` per command | +| Lithe desktop | Declares `ui` 0 and presents text, web, and native tool windows, and commands that open a tool window. It does not present commands that belong to no tool window: such packages install, but those commands have no entry point | + +### Rules + +- **Tool windows**: 1–64. Each has a stable `id`, `title`, initial `placement` (`left`, `bottom`, or `right`), integer `order` (default 0), and exactly one kind of content: `text` (plain text), `web` (a packaged page), or `native` (native content). +- **Commands**: up to 128. `placements` is nonempty and takes `commandPalette`, `toolbar`, or `menu`. A command with `toolWindowID` opens one of the same plugin's windows. A command without `toolWindowID` only notifies the backend; `toolWindows` may then be empty or omitted. +- **Web windows**: `web.entry` is a package-relative `.html` path and requires the `ui.web` permission. The bridge and lifecycle are in [Web content](../../contract/v1/README.md#web-content). +- **Native windows**: `native.kind` is `tree`, `list`, or `form`. The whole node set is limited to 2000 nodes and depth 16; a form has at most 32 controls and 16 buttons. +- **Commands without a window**: require `backend` (in `requires`), `ui.contribute`, and `backend.launch`, plus the `onCommand:` activation event. `commands/didRun` is an event; it does not show that the command's business action succeeded. +- Every ID starts with the plugin ID and a dot. Titles are at most 256 bytes and window text at most 65536 bytes; text is shown literally and never evaluated as HTML or script. + +### Contract sections + +| Section | Content | +| --- | --- | +| [Host-owned workbench UI](../../contract/v1/README.md#host-owned-workbench-ui) | Tool windows, commands, IDs, limits, and what the host owns | +| [Web content](../../contract/v1/README.md#web-content) | Packaged pages, bridge, state, and lifecycle | +| [Host-rendered native content](../../contract/v1/README.md#host-rendered-native-content) | Native trees, lists, forms; control kinds and limits | +| [Backend-only commands](../../contract/v1/README.md#backend-only-commands) | Commands without a tool window, and `commands/didRun` | +| [UI icons](../../contract/v1/README.md#ui-icons) | Host icon IDs (such as `{"host": "database"}`) and packaged SVG/PNG | +| [Public document, file and window methods](../../contract/v1/README.md#public-document-file-and-window-methods) | Backend messages `window/showInformation`, `showWarning`, `showError` | +| [Existing community store](../../contract/v1/README.md#existing-community-store) | Steps for adding a host-rendered view to an existing community package | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`manifest/hello-lithe.plugin.json`](../../contract/v1/fixtures/manifest/hello-lithe.plugin.json) | `ui` 0: one text window and the command that opens it | +| [`ui/web-panel.plugin.json`](../../contract/v1/fixtures/ui/web-panel.plugin.json) | Two web windows and a command | +| [`ui/native-panel.plugin.json`](../../contract/v1/fixtures/ui/native-panel.plugin.json) | A native tree panel | +| [`ui/native-form.plugin.json`](../../contract/v1/fixtures/ui/native-form.plugin.json) | A native form | +| [`ui/native-controls.plugin.json`](../../contract/v1/fixtures/ui/native-controls.plugin.json) | Every form control kind | +| [`backend/backend.plugin.json`](../../contract/v1/fixtures/backend/backend.plugin.json) | A web window calling `backend` 0 | +| [`backend/command-backend.plugin.json`](../../contract/v1/fixtures/backend/command-backend.plugin.json) | A backend command without a window | +| [`native-ui.schema.json`](../../contract/v1/native-ui.schema.json) | JSON Schema for native content | + +UI rejections are in [`manifest/invalid-manifests.json`](../../contract/v1/fixtures/manifest/invalid-manifests.json). A runnable native panel is the [Node SDK Sample Explorer](../../sdk/node/examples/sample-build/plugin.json). + +### Related guides + +- [Plugin backend: workspace contexts and host services](../../guides/plugin-backend-workspace.md) diff --git a/shared/plugins/reference/domains/vcs.md b/shared/plugins/reference/domains/vcs.md new file mode 100644 index 00000000..1ecb34f1 --- /dev/null +++ b/shared/plugins/reference/domains/vcs.md @@ -0,0 +1,109 @@ +# 版本控制提供者 / VCS providers + +[中文](#中文) · [English](#english) + +## 中文 + +插件可以为 Git 以外的版本控制系统(第一个是 Subversion)实现一个提供者。提供者是一个 LSP 基础协议进程,和[构建提供者](build-providers.md)同类,回答统一 VCS 模型的请求:根目录、文件状态、基准内容、历史、注解、提交、回滚、更新。版本控制界面、差异视图、提交窗口都归宿主;界面按提供者协商的能力决定显示什么,从不按 VCS 名字分支。Git 由内置支持负责,ID `git` 保留。 + +| 项目 | 内容 | +| --- | --- | +| 清单字段 | `vcsProviders[]`:`id`、`displayName`、`detection.markers`、`metadataPaths`、`tool`(VCS 客户端命令和校验参数)、进程启动字段(`launch`、`arguments` 等,与语言服务器相同)、`mayUseNetwork` | +| 能力版本 | `vcsProviders` 0,需要 `languageServer.launch` 与 `process.vcs` 权限。见[版本对照](../capabilities-and-versions.md#能力版本requires) | +| 协议 | `litheVcs` 0:宿主在 `initialize` 里提供 `capabilities.experimental.litheVcs: {"versions": [0]}`,提供者回答 `{"version": 0, "vcs", "capabilities"}`,见[进程协议版本](../capabilities-and-versions.md#进程协议版本) | +| API 编号 | [API-31](../api-catalog.md#现在能用的-api):VCS 提供者。读取内置 Git 的公共接口是另一项(API-42,计划中) | +| 权限 | `process.vcs`、`languageServer.launch` | +| 激活事件 | `onVcsRoot:`;根目录检测只看标记文件,不启动进程,找到该 VCS 的根目录后才启动提供者 | +| Lithe 桌面端 | 未声明 `vcsProviders`,安装时拒绝需要它的包 | +| 官方 SDK | [Node SDK](../../sdk/node/README.md#a-vcs-provider) 0.5.0 实现 `litheVcs` 0;Java、Rust SDK 不实现 | + +### 能力与操作 + +提供者在 `initialize` 的回复里协商自己支持的能力,每个能力都写版本 0。 + +| 能力 | 必选 | 回复 | +| --- | --- | --- | +| `status` | 是 | 根目录的文件状态 | +| `baseContent` | 是 | `{content, binary}`,基准版本的内容,最多 32 MiB;新增或未纳入版本控制的文件为 `null` | +| `checkin` | 是 | `{revision}`;VCS 不报告新版本时为 `null` | +| `rollback` | 是 | `{}` | +| `history`、`fileHistory` | 否 | `{entries, nextCursor}`,分页(每页 1–500 条,默认 100),保持提供者的顺序(新的在前) | +| `annotate` | 否 | `{revisions, lines}`,最多 500,000 行 | +| `update` | 否 | `{revision, conflicts}` | +| `fileOperations`、`conflicts`、`cleanup` | 否 | `fileOperation`、`resolve`、`cleanup` 回复 `{}` | +| `push`、`staging` | 否 | 只影响界面:`push` 只是声明,表示提交是本地的、发布是另一步;协议 0 没有发布操作。没有 `push` 时宿主不提供"提交并推送" | + +路径离开根目录、缺必填字段、超出大小限制的回复都无效。 + +### 契约章节 + +| 章节 | 内容 | +| --- | --- | +| [VCS providers](../../contract/v1/README.md#vcs-providers) | 清单字段、保留 ID、检测、安装审查 | +| [VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md) | 字段、Core 命令、协商、回复格式、提供者消息、错误码、Subversion 说明 | + +### 示例与用例 + +| 文件 | 内容 | +| --- | --- | +| [`vcs/vcs-provider.plugin.json`](../../contract/v1/fixtures/vcs/vcs-provider.plugin.json) | Subversion 提供者:`.svn` 目录标记、监视 `.svn/wc.db`、`svn` 客户端、Node 运行时启动、`mayUseNetwork` | + +共享示例里没有 VCS 拒绝用例;错误码(包括 `plugin.vcs.reserved`)见 VCS 契约的 [Error codes](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md#error-codes)。 + +### 相关指南 + +- [Node SDK:A VCS provider](../../sdk/node/README.md#a-vcs-provider) + +--- + +## English + +A plugin can implement a provider for a version control system other than Git (Subversion is the first). A provider is an LSP base-protocol process, the same kind as a [build provider](build-providers.md), and answers requests of one VCS-neutral model: roots, file status, base content, history, annotation, check-in, rollback, and update. The version control UI, diff views, and commit window belong to the host; the UI decides what to show from the capabilities the provider negotiated, never from the VCS name. Git is served by built-in support, and the ID `git` is reserved. + +| Item | Value | +| --- | --- | +| Manifest field | `vcsProviders[]`: `id`, `displayName`, `detection.markers`, `metadataPaths`, `tool` (the VCS client commands and validation arguments), the process launch fields (`launch`, `arguments`, as for language servers), `mayUseNetwork` | +| Capability version | `vcsProviders` 0; requires the `languageServer.launch` and `process.vcs` permissions. See [versions](../capabilities-and-versions.md#capability-versions-requires) | +| Protocols | `litheVcs` 0: the host offers `capabilities.experimental.litheVcs: {"versions": [0]}` in `initialize`, and the provider answers `{"version": 0, "vcs", "capabilities"}`; see [process protocol versions](../capabilities-and-versions.md#process-protocol-versions) | +| API IDs | [API-31](../api-catalog.md#apis-usable-now): VCS providers. Public reads of the built-in Git are a separate item (API-42, planned) | +| Permissions | `process.vcs`, `languageServer.launch` | +| Activation events | `onVcsRoot:`; root detection reads marker files only and starts no process, and a provider starts only after a root of its VCS is found | +| Lithe desktop | Does not declare `vcsProviders`; packages that require it are refused before installation | +| Official SDKs | The [Node SDK](../../sdk/node/README.md#a-vcs-provider) 0.5.0 implements `litheVcs` 0; the Java and Rust SDKs do not | + +### Capabilities and operations + +The provider negotiates the capabilities it supports in its `initialize` answer; every capability is named at version 0. + +| Capability | Required | Reply | +| --- | --- | --- | +| `status` | Yes | File status of a root | +| `baseContent` | Yes | `{content, binary}`, the content at the base revision, at most 32 MiB; `null` for added or unversioned files | +| `checkin` | Yes | `{revision}`; `null` when the VCS reports none | +| `rollback` | Yes | `{}` | +| `history`, `fileHistory` | No | `{entries, nextCursor}`, paged (1–500 entries per page, 100 by default), in provider order (newest first) | +| `annotate` | No | `{revisions, lines}`, at most 500,000 lines | +| `update` | No | `{revision, conflicts}` | +| `fileOperations`, `conflicts`, `cleanup` | No | `fileOperation`, `resolve`, and `cleanup` reply `{}` | +| `push`, `staging` | No | UI only. `push` is a declaration: check-in is local and publishing is a separate step; protocol 0 has no publishing operation. Without `push` the host offers no "Commit and Push" | + +A reply with a path outside the root, a missing required field, or an exceeded size limit is invalid. + +### Contract sections + +| Section | Content | +| --- | --- | +| [VCS providers](../../contract/v1/README.md#vcs-providers) | Manifest fields, the reserved ID, detection, install review | +| [VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md) | Fields, Core commands, negotiation, reply shapes, provider messages, error codes, Subversion notes | + +### Examples and cases + +| File | Content | +| --- | --- | +| [`vcs/vcs-provider.plugin.json`](../../contract/v1/fixtures/vcs/vcs-provider.plugin.json) | A Subversion provider: a `.svn` directory marker, watching `.svn/wc.db`, the `svn` client, a Node runtime launch, `mayUseNetwork` | + +The shared fixtures have no VCS refusal cases; the error codes (including `plugin.vcs.reserved`) are in [Error codes](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md#error-codes) of the VCS contract. + +### Related guides + +- [Node SDK: A VCS provider](../../sdk/node/README.md#a-vcs-provider) diff --git a/shared/plugins/reference/protocol-changelog.md b/shared/plugins/reference/protocol-changelog.md new file mode 100644 index 00000000..b0c1dab3 --- /dev/null +++ b/shared/plugins/reference/protocol-changelog.md @@ -0,0 +1,150 @@ +# 插件协议更新记录 + +本页记录插件协议和 API 的每次修改:哪天、哪个 PR、谁改的、改了什么。只有中文。 + +- **收录范围**:Lithe-core-rust 仓库 `main` 分支上的 PR,只要它修改了下面三处中的任意一处:插件契约 `shared/plugins/contract/v1/`、Core 能力表 [`capabilities.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/plugins/capabilities.rs)、插件函数协议 [`plugin_function.rs`](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/lithe-core/src/lsp/interface/plugin_function.rs)。三处都没改的 PR 不收录。 +- **日期**:PR 合入 `main` 的日期。 +- **修改人**:PR 的作者(GitHub 账号)。 +- **每一版的内容和宿主现状**:见[能力与协议版本对照](capabilities-and-versions.md)。字段和行为以 [`contract/v1/README.md`](../contract/v1/README.md) 为准。 + +时间线的两列这样读: + +- **API 编号**:这个 PR 涉及的 API-01–44 规划编号,来自 [API 实施计划](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md),和协议版本、能力版本分开记录。— 表示没有改到具体 API,例如清单、市场、包信任、SDK 或契约文档整理。 +- **协议 / 能力**:**加粗**的是这个 PR 新增的版本号;没有加粗的,是同一版本内的补充、宿主接入或说明修正。 + +## 按协议查首次出现 + +所有版本都从 `0` 开始。以后一个 PR 给某个能力加版本 `1` 时,在对应的表里为它加一行。 + +### 插件 API 版本与文档格式 + +| 名称 | 版本 | 日期 | PR | +| --- | --- | --- | --- | +| 插件 API 版本 `pluginAPIVersion` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| 插件清单 `schemaVersion` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| 市场索引 `schemaVersion` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| 原生 UI 文档 `schemaVersion` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `litheBuildModel`、`litheDependencies`、`litheTestResults`、`litheTestDiscovery`、`litheBuildConfiguration`、`litheConfigurationEdit` 和 `modelInputs` 快照的 `version` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `plugins.editorActions.decode` 请求的 `version` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| 插件网页桥 `window.lithe.version` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | + +### 能力版本(`requires`) + +18 个能力都从版本 `0` 开始。 + +| 能力 | 版本 | 日期 | PR | +| --- | --- | --- | --- | +| `backend` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `buildModelInputs` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `buildProviders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `buildSystems` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `debugAdapters` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `executionProviders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `keymapImportProviders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `languageServers` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `languages` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `outputLinkMatchers` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `packageTrust` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `pluginFunctions` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `projectGenerators` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `settings` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `toolDefinitions` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `toolchainProviders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `ui` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `vcsProviders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | + +### 进程协议版本 + +进程协议没有独立的版本,它的版本就是拥有它的能力的版本(`litheLogging` 是 `pluginAPIVersion` 的版本)。 + +| 协议 | 版本 | 日期 | PR | +| --- | --- | --- | --- | +| `litheFunctions`(`backend`) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `litheBuild`(`buildProviders`) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `litheVcs`(`vcsProviders`) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `litheToolchains`(`toolchainProviders`) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `litheKeymapImport`(`keymapImportProviders`) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| `litheLogging`(`pluginAPIVersion`) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | + +### 宿主方法(按 API 编号) + +向后兼容地新增宿主方法、通知或可选字段时不升版本(见契约 [Extending the host services](../contract/v1/README.md#extending-the-host-services)),上面的版本表看不出某个方法是什么时候加的,所以宿主方法和通知在这里逐个登记。一个 API 编号下可以有多个方法,每个方法一行,记首次加入的日期和 PR。方法名省略前缀 `lithe/host/`;用途和权限见 [API 总表](api-catalog.md#已公开的宿主方法)。`keymapImport/*` 属于 `litheKeymapImport` 协议,不在 API-01–44 内。`functions/providers`、`functions/invoke` 是 `litheFunctions` 里的跨插件调用,只对声明了至少一个 `calls` 的后端开放。 + +| API | 方法 | 协议 | 日期 | PR | +| --- | --- | --- | --- | --- | +| API-01、API-37 | `getCapabilities` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-01 | `subscriptions/dispose` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-02 | `commands/didRun`(通知) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-04、API-05 | `workspace/didChangeContexts`(通知) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-05 | `workspace/listContexts` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-05 | `workspace/getContext` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-05 | `workspace/getFolders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-06 | `workspace/readDirectory` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-06 | `workspace/stat` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-06 | `workspace/readFile` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-07 | `workspace/watch` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-07、API-24 | `subscriptions/notify`(通知) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-08 | `workspace/findFiles` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-08 | `workspace/findText` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-09 | `documents/list` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-09 | `documents/getSnapshot` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-09 | `editor/getActive` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-09 | `editor/getSelections` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-10 | `editor/applyEdits` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-10 | `editor/replaceSelections` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-10 | `editor/insertText` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-12 | `documents/registerContentProvider` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-15 | `editor/open` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-15 | `editor/reveal` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-16 | `preview/openHTML` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-16 | `browser/openExternal` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-17 | `window/showInformation` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-17 | `window/showWarning` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-17 | `window/showError` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-21 | `credentials/get` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-21 | `credentials/set` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-21 | `credentials/delete` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-22、API-25 | `languageServer/setStatus` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-23 | `run/start` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-23 | `run/stop` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-23 | `run/didExit`(通知) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-24 | `terminal/create` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-24 | `terminal/show` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-24 | `terminal/close` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-24 | `terminal/reopen` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-24 | `terminal/dispose` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-24 | `terminal/onExit` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-25 | `editor/registerProviders` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-25 | `languageServer/request` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-25 | `languageServer/notify` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-25 | `languageServer/didChangeState`(通知) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-25 | `languageServer/didReceiveNotification`(通知) | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-27 | `editor/registerActions` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-30 | `debug/start` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-35 | `functions/providers` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| API-35 | `functions/invoke` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| — | `keymapImport/listInputs` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | +| — | `keymapImport/readChunk` | 0 | 2026-11-11 | [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | + +## 时间线(新的在上) + +### 2026-11-11 + +| PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | +| --- | --- | --- | --- | --- | +| [#17](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/17) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | **单一基线 `0`**:`pluginAPIVersion`、清单 `schemaVersion`、18 个能力、进程协议和文档格式 | 单一基线:插件 API、清单格式、全部能力、进程协议和文档格式从 0 开始;契约、指南和 SDK 并入 Core 仓库 `shared/plugins/`。 | + +## 维护 + +修改 `shared/plugins/contract/v1/`、`rust/lithe-core/src/plugins/capabilities.rs` 或 `rust/lithe-core/src/lsp/interface/plugin_function.rs` 的 Core PR,在**同一个 PR** 里更新本页: + +1. 把这个 PR 补到时间线最上面(按合入日期分组):PR、修改人、API 编号、协议 / 能力、改了什么。新增的版本号要加粗,并写进“按协议查首次出现”里对应的表:例如给 `buildProviders` 加版本 `1`,就在能力表里为它加一行,并在进程协议表里同步 `litheBuild`。一个 PR 只给一个能力加一个版本。 +2. “API 编号”填这个 PR 涉及的全部 API-01–44 编号,取自 PR 描述里的“原计划编号”。PR 没写时按 [API 实施计划](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md) 判断,不要自编新号;不属于任何编号的写 —。 +3. PR 新增或修改了宿主方法或通知时,每个方法在“宿主方法(按 API 编号)”表里加一行,写明 API 编号、协议、日期和 PR;同时在 [API 总表](api-catalog.md#已公开的宿主方法)的中英两张方法表里各加一行,写明用途、权限和协议。同一个 API 下的方法分几个 PR 加入时,各自登记自己的 PR。 +4. “修改人”填 PR 作者的 GitHub 账号,写成 `[@账号](https://github.com/账号)`。账号可以这样查: + + ```sh + gh pr view --repo Lithe-IDEA/Lithe-core-rust --json author --jq .author.login + ``` + +5. 同时更新[能力与协议版本对照](capabilities-and-versions.md)和受影响的功能页。 diff --git a/shared/plugins/scripts/sdk-e2e.mjs b/shared/plugins/scripts/sdk-e2e.mjs new file mode 100644 index 00000000..cfd2d330 --- /dev/null +++ b/shared/plugins/scripts/sdk-e2e.mjs @@ -0,0 +1,98 @@ +// Runs the SDK end-to-end scenario in sdk/e2e against the Rust Core of this +// repository. +// +// node shared/plugins/scripts/sdk-e2e.mjs +// +// The scenario files are Cargo integration tests of lithe-core, declared in +// rust/lithe-core/Cargo.toml behind the `plugin-sdk-e2e` feature, so a plain +// `cargo test` skips them. This script builds the sample backends and then +// runs those tests, which exercise the Node.js, Java and Rust SDKs, the native +// build workflows and shortcut import against Core at this working tree. +// +// The Node.js sample runs on the node running this script. The Java sample +// runs on LITHE_PLUGIN_SDK_JAVA_RUNTIME, or `java` on PATH, and is built with +// Maven unless LITHE_PLUGIN_SDK_JAVA_SAMPLE names a built sample-build.jar. +// The Rust sample backend is built with Cargo unless +// LITHE_PLUGIN_SDK_RUST_SAMPLE names a built sample-backend executable. +import { spawn } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +/** shared/plugins: the SDKs, their samples and the contract. */ +const plugins = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repository = resolve(plugins, '..', '..'); +const tests = ['plugin_sdk_node', 'plugin_sdk_java', 'plugin_sdk_rust', 'plugin_sdk_build']; + +function run(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { stdio: 'inherit', ...options }); + child.on('error', reject); + child.on('close', (code, signal) => { + if (code === 0) resolvePromise(); + else reject(new Error(`${command} ${args.join(' ')} failed (${signal ?? `exit ${code}`})`)); + }); + }); +} + +function which(command) { + return new Promise((resolvePromise, reject) => { + const child = spawn('sh', ['-c', 'command -v "$1"', 'sh', command], + { stdio: ['ignore', 'pipe', 'inherit'], timeout: 15000, killSignal: 'SIGKILL' }); + let text = ''; + child.stdout.on('data', chunk => { text += chunk; }); + child.on('error', reject); + child.on('close', code => code === 0 && text.trim() + ? resolvePromise(text.trim()) + : reject(new Error(`${command} is required to run the SDK end-to-end check.`))); + }); +} + +/** Builds both Java samples from the current SDK sources unless they are given. */ +async function javaSamples() { + const jar = process.env.LITHE_PLUGIN_SDK_JAVA_SAMPLE + ? resolve(process.env.LITHE_PLUGIN_SDK_JAVA_SAMPLE) + : join(plugins, 'sdk/java/examples/sample-build/target/sample-build.jar'); + const nativeJar = process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE + ? resolve(process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE) + : join(plugins, 'sdk/java/examples/sample-native-build/target/sample-native-build.jar'); + if (!process.env.LITHE_PLUGIN_SDK_JAVA_SAMPLE || !process.env.LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE) { + // `clean` keeps Shade from reusing a sample JAR that bundles an older SDK + // after only the reactor dependency changed. + await run(await which('mvn'), ['-B', '-ntp', '-q', '-f', join(plugins, 'sdk/java/pom.xml'), + '-DskipTests', 'clean', 'package']); + } + await access(jar); + await access(nativeJar); + return { jar, nativeJar }; +} + +async function rustSample() { + if (process.env.LITHE_PLUGIN_SDK_RUST_SAMPLE) return resolve(process.env.LITHE_PLUGIN_SDK_RUST_SAMPLE); + const manifest = join(plugins, 'sdk/rust/Cargo.toml'); + await run('cargo', ['build', '--locked', '--quiet', '--manifest-path', manifest, '-p', 'sample-backend']); + const executable = join(plugins, 'sdk/rust/target/debug', + process.platform === 'win32' ? 'sample-backend.exe' : 'sample-backend'); + await access(executable); + return executable; +} + +async function main() { + const java = process.env.LITHE_PLUGIN_SDK_JAVA_RUNTIME || await which('java'); + const { jar, nativeJar } = await javaSamples(); + const rust = await rustSample(); + await run('cargo', ['test', '--locked', '--manifest-path', join(repository, 'rust/Cargo.toml'), + '-p', 'lithe-core', '--features', 'plugin-sdk-e2e', ...tests.flatMap(test => ['--test', test])], { + env: { + ...process.env, + LITHE_PLUGIN_SDK_ROOT: plugins, + LITHE_PLUGIN_SDK_NODE: process.execPath, + LITHE_PLUGIN_SDK_JAVA: java, + LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar, + LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE: nativeJar, + LITHE_PLUGIN_SDK_RUST_SAMPLE: rust, + }, + }); +} + +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/shared/plugins/scripts/verify-docs.mjs b/shared/plugins/scripts/verify-docs.mjs new file mode 100644 index 00000000..9fa6b842 --- /dev/null +++ b/shared/plugins/scripts/verify-docs.mjs @@ -0,0 +1,75 @@ +// Offline checks for the plugin developer resources in shared/plugins. +// +// node shared/plugins/scripts/verify-docs.mjs +// +// - Every schema under contract/v1 names its own published URL as `$id`, so a +// plugin's `$schema` reference resolves to the file in this repository. +// - Every relative Markdown link resolves to a file or directory in this +// repository. +// - No link points at a file of the retired lithe-plugin-docs repository, +// whose contents moved here. Links to its existing SDK releases stay valid. +import { readdir, readFile, stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +/** Where GitHub serves the contract; every schema's `$id` is its URL here. */ +export const publishedContract = 'https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/'; +const retired = /https:\/\/(?:github\.com\/Lithe-IDEA\/lithe-plugin-docs\/(?:blob|tree)\/|raw\.githubusercontent\.com\/Lithe-IDEA\/lithe-plugin-docs\/)/; +const skipped = new Set(['node_modules', 'target', '.artifacts']); + +async function files(directory) { + const found = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (skipped.has(entry.name) || entry.name.startsWith('.')) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) found.push(...await files(path)); + else if (entry.isFile()) found.push(path); + } + return found; +} + +async function exists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +/** Returns the problems found under `plugins`; `repository` bounds relative links. */ +export async function verify(plugins, repository = resolve(plugins, '..', '..')) { + const problems = []; + const contract = join(plugins, 'contract', 'v1'); + for (const file of await files(plugins)) { + const shown = relative(repository, file).split(sep).join('/'); + if (file.endsWith('.schema.json') && file.startsWith(contract + sep)) { + const expected = publishedContract + relative(contract, file).split(sep).join('/'); + const id = JSON.parse(await readFile(file, 'utf8')).$id; + if (id !== expected) problems.push(`${shown}: $id is ${JSON.stringify(id)}, expected ${JSON.stringify(expected)}`); + } + if (!file.endsWith('.md')) continue; + const text = await readFile(file, 'utf8'); + for (const [, target] of text.matchAll(/\]\(([^)\s]+)\)/g)) { + if (retired.test(target)) { + problems.push(`${shown}: ${target} points at lithe-plugin-docs; link the file in this repository`); + continue; + } + if (target.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue; + const linked = resolve(dirname(file), decodeURIComponent(target.split('#')[0])); + if (relative(repository, linked).startsWith('..')) { + problems.push(`${shown}: ${target} leaves the repository; use an absolute URL`); + } else if (!await exists(linked)) { + problems.push(`${shown}: ${target} does not exist`); + } + } + } + return problems; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const problems = await verify(resolve(dirname(fileURLToPath(import.meta.url)), '..')); + for (const problem of problems) console.error(problem); + if (problems.length) process.exitCode = 1; + else console.log('Plugin docs and contract links are consistent.'); +} diff --git a/shared/plugins/scripts/verify-docs.test.mjs b/shared/plugins/scripts/verify-docs.test.mjs new file mode 100644 index 00000000..49b0253a --- /dev/null +++ b/shared/plugins/scripts/verify-docs.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { publishedContract, verify } from './verify-docs.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'plugin-docs-')); + t.after(() => rm(root, { recursive: true, force: true })); + const plugins = join(root, 'shared', 'plugins'); + await mkdir(join(plugins, 'contract', 'v1'), { recursive: true }); + await mkdir(join(plugins, 'guides')); + await writeFile(join(root, 'README.md'), '# Root\n'); + return { root, plugins }; +} + +test('consistent schemas and links pass', async (t) => { + const { root, plugins } = await fixture(t); + await writeFile(join(plugins, 'contract/v1/a.schema.json'), + JSON.stringify({ $id: `${publishedContract}a.schema.json` })); + await writeFile(join(plugins, 'guides/start.md'), + '[contract](../contract/v1/a.schema.json) [root](../../../README.md#root) [web](https://example.com) [anchor](#top)\n' + + '[old release](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.4.0)\n'); + assert.deepEqual(await verify(plugins, root), []); +}); + +test('a wrong $id, a missing target, an escaping link and a retired repository link are reported', async (t) => { + const { root, plugins } = await fixture(t); + await writeFile(join(plugins, 'contract/v1/a.schema.json'), JSON.stringify({ $id: 'https://example.com/a.schema.json' })); + await writeFile(join(plugins, 'guides/start.md'), + '[missing](missing.md) [outside](../../../../elsewhere.md)\n' + + '[moved](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/guides/start.md)\n'); + const problems = await verify(plugins, root); + assert.equal(problems.length, 4, problems.join('\n')); + assert.match(problems.join('\n'), /\$id is/); + assert.match(problems.join('\n'), /missing\.md does not exist/); + assert.match(problems.join('\n'), /leaves the repository/); + assert.match(problems.join('\n'), /points at lithe-plugin-docs/); +}); diff --git a/shared/plugins/sdk/e2e/plugin_sdk/backend.rs b/shared/plugins/sdk/e2e/plugin_sdk/backend.rs new file mode 100644 index 00000000..40bc6c9d --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk/backend.rs @@ -0,0 +1,644 @@ +//! End-to-end scenario of a plugin backend (`backend` in `plugin.json`), +//! run against Rust Core through the same JSON commands a host sends. +//! +//! The test plays the host: it expands the backend launch plan, starts the +//! sample under the plugin's backend provider ID with the granted host +//! services, and answers Core's `hostRequest` events from an in-memory +//! credential store the way the macOS host answers them from the Keychain. +//! The scenario drives functions with progress, a plugin failure, an +//! unexpected failure, cancellation, runtime registration, credentials, the +//! workspace, window and terminal host services, and state across a restart, +//! where the backend is granted no host service. + +use super::{core, listing, Fixture, Session}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +use std::time::Instant; + +/// Runs the scenario against the installed package at `package`, whose +/// `plugin.json` declares the sample backend. +pub fn run_backend_scenario(package: &Path) { + 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("backend"); + let state = fixture.root.join("state"); + let plan = launch_plan(&manifest, package, &state); + assert_eq!( + plan["backend"]["hostServices"], + json!([ + "credentials", + "lithe/host/terminal/close", + "lithe/host/terminal/create", + "lithe/host/terminal/dispose", + "lithe/host/terminal/onExit", + "lithe/host/terminal/reopen", + "lithe/host/terminal/show", + "lithe/host/window/showError", + "lithe/host/window/showInformation", + "lithe/host/window/showWarning", + "lithe/host/workspace/watch", + "workspace" + ]) + ); + assert_eq!(plan["requiredDirectories"], json!(["state"])); + fs::create_dir_all(&state).expect("host state directory"); + + let mut session = start(&plugin_id, package, &plan, &state); + assert_eq!( + session.functions(), + [ + "fail.unexpectedly", + "files.nextChange", + "note.read", + "note.save", + "secret.check", + "secret.forget", + "secret.remember", + "terminal.cycle", + "work.wait" + ] + ); + + // A function with progress, which registers note.clear on first use. + let saved = session.call( + "save", + "note.save", + json!({ "name": "first", "text": "héllo" }), + ); + assert_eq!( + saved["result"]["value"], + json!({ "name": "first", "bytes": 6 }), + "{saved}" + ); + let progress: Vec<_> = session + .events + .iter() + .filter(|event| event["type"] == "requestProgress" && event["operationId"] == "save") + .map(|event| event["result"]["message"].clone()) + .collect(); + assert_eq!(progress, ["Saving", "Saved"]); + session.await_event("note.clear to be registered", |event| { + event["type"] == "functionsChanged" + && event["result"]["functions"] + .as_array() + .is_some_and(|functions| functions.contains(&json!("note.clear"))) + }); + let read = session.call("read", "note.read", json!({ "name": "first" })); + assert_eq!(read["result"]["value"], json!({ "text": "héllo" })); + + // A plugin failure keeps Lithe's stable code and forwards the plugin's own. + let missing = session.call("missing", "note.read", json!({ "name": "other" })); + assert_eq!( + missing["error"]["code"], "plugin.function.failed", + "{missing}" + ); + assert_eq!(missing["error"]["pluginErrorCode"], "sample.noteMissing"); + + // An unexpected failure reaches the host without its details. + let failed = session.call("fail", "fail.unexpectedly", json!({})); + assert_eq!( + failed["error"]["code"], "plugin.function.failed", + "{failed}" + ); + assert!(failed["error"].get("pluginErrorCode").is_none(), "{failed}"); + assert!( + !failed.to_string().contains(&*state.to_string_lossy()), + "a machine path reached the host: {failed}" + ); + + super::assert_log_facts( + &plugin_id, + &[ + ("save", "plugins.operation.finished", "INFO"), + ("fail", "plugins.operation.failed", "ERROR"), + ], + ); + + // Credentials travel as host requests and never touch the state directory. + let mut store = BTreeMap::new(); + let remembered = serve_host( + &mut session, + &mut store, + "remember", + "secret.remember", + json!({ "key": "sample:password", "secret": "s3cr3t" }), + ); + assert_eq!(remembered["result"]["value"], json!({}), "{remembered}"); + assert_eq!( + store, + BTreeMap::from([("sample:password".to_string(), "s3cr3t".to_string())]) + ); + for (operation, secret, matches) in [("check", "s3cr3t", true), ("check-wrong", "nope", false)] + { + let checked = serve_host( + &mut session, + &mut store, + operation, + "secret.check", + json!({ "key": "sample:password", "secret": secret }), + ); + assert_eq!( + checked["result"]["value"], + json!({ "stored": true, "matches": matches }), + "{checked}" + ); + } + let forgotten = serve_host( + &mut session, + &mut store, + "forget", + "secret.forget", + json!({ "key": "sample:password" }), + ); + assert_eq!(forgotten["result"]["value"], Value::Null, "{forgotten}"); + assert!(store.is_empty()); + let requests: Vec<_> = session + .events + .iter() + .filter(|event| event["type"] == "hostRequest") + .map(|event| (event["method"].clone(), event["result"]["service"].clone())) + .collect(); + assert_eq!(requests.len(), 4, "{requests:?}"); + assert!(requests.iter().all(|(_, service)| service == "credentials")); + + // Cancelling a running call reaches the handler. + session.invoke("wait", "work.wait", json!({})); + wait_for_file(&mut session, &state.join("waiting")); + core( + "lsp.cancelOperation", + json!({ "sessionId": session.id, "operationId": "wait" }), + ); + assert_eq!( + session.completion("wait")["error"]["code"], + "requestCancelled" + ); + wait_for_file(&mut session, &state.join("cancelled")); + + run_host_services(&mut session); + run_terminal_services(&mut session); + + // State survives a restart. Without credentials.store the host grants no + // host service, and Core refuses the request before the host sees it. + session.stop(); + drop(session); + let mut ungranted = manifest.clone(); + ungranted["permissions"] = json!(["backend.launch", "ui.contribute", "ui.web"]); + let plan = launch_plan(&ungranted, package, &state); + assert_eq!(plan["backend"]["hostServices"], json!([])); + let mut restarted = start(&plugin_id, package, &plan, &state); + assert!(restarted.functions().contains(&"note.clear".to_string())); + let read = restarted.call("read-again", "note.read", json!({ "name": "first" })); + assert_eq!(read["result"]["value"], json!({ "text": "héllo" })); + let refused = restarted.call( + "remember-ungranted", + "secret.remember", + json!({ "key": "sample:password", "secret": "s3cr3t" }), + ); + assert_eq!( + refused["error"]["pluginErrorCode"], "sample.credentialsUnavailable", + "{refused}" + ); + // The capabilities Core reports leave out the watch, so the sample + // refuses before asking the host. + invoke_in( + &restarted, + "watch-ungranted", + "files.nextChange", + &workspace(), + ); + let unwatched = restarted.completion("watch-ungranted"); + assert_eq!( + unwatched["error"]["pluginErrorCode"], "sample.watchUnavailable", + "{unwatched}" + ); + assert!( + !restarted + .events + .iter() + .any(|event| event["type"] == "hostRequest"), + "an ungranted request reached the host" + ); + assert_eq!( + restarted.call("clear", "note.clear", json!({}))["result"]["value"], + Value::Null + ); + let cleared = restarted.call("read-cleared", "note.read", json!({ "name": "first" })); + assert_eq!(cleared["error"]["pluginErrorCode"], "sample.noteMissing"); + restarted.stop(); + drop(restarted); + + assert!( + listing(package) == package_before, + "the plugin package changed at runtime" + ); + let left: BTreeSet<_> = fs::read_dir(&state) + .expect("state directory") + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + left, + BTreeSet::from(["cancelled".to_string(), "waiting".to_string()]) + ); +} + +/// The workspace context of the window the scenario's pages and command +/// run in. +fn workspace() -> Value { + json!({ "contextID": "w-sample", "generation": 1 }) +} + +/// Workspace host services through Core: a call's workspace context, +/// capability discovery Core answers itself, a file watch whose event arrives +/// as a host notification, a typed host failure, a cancelled call cancelling +/// its host request on the wire, and notifications whose handlers send host +/// requests of their own. +fn run_host_services(session: &mut Session) { + let context = workspace(); + let folders = json!({ + "folders": [{ "rootID": "r-sample", "name": "orders", "path": "/work/orders" }] + }); + + // Without a context the sample asks the host nothing. + let nowhere = session.call("watch-nowhere", "files.nextChange", json!({})); + assert_eq!( + nowhere["error"]["pluginErrorCode"], "sample.noWorkspace", + "{nowhere}" + ); + + // The watch's event reaches the call through subscriptions/notify. + invoke_in(session, "watch", "files.nextChange", &context); + let request = take_host_request(session, "lithe/host/workspace/getFolders"); + assert_eq!(request["result"]["params"], json!({ "context": context })); + respond(session, &request, Ok(folders.clone())); + let request = take_host_request(session, "lithe/host/workspace/watch"); + assert_eq!( + request["result"]["params"], + json!({ + "context": context, + "resource": { "rootID": "r-sample", "path": "" }, + "recursive": true + }) + ); + respond( + session, + &request, + Ok(json!({ "subscriptionID": "watch-1" })), + ); + assert!(notify( + session, + "lithe/host/subscriptions/notify", + json!({ + "subscriptionID": "watch-1", + "sequence": 1, + "event": { "rootID": "r-sample", "changedPaths": ["notes.txt"], "requiresRescan": false } + }) + )); + let request = take_host_request(session, "lithe/host/subscriptions/dispose"); + assert_eq!( + request["result"]["params"], + json!({ "subscriptionID": "watch-1" }) + ); + respond(session, &request, Ok(Value::Null)); + let changed = session.completion("watch"); + assert_eq!( + changed["result"]["value"], + json!({ "changedPaths": ["notes.txt"] }), + "{changed}" + ); + + // A host failure keeps its code, and the sample turns it into its own. + invoke_in(session, "watch-stale", "files.nextChange", &context); + let request = take_host_request(session, "lithe/host/workspace/getFolders"); + respond( + session, + &request, + Err(json!({ "message": "The workspace changed.", "code": "plugin.host.contextExpired" })), + ); + let stale = session.completion("watch-stale"); + assert_eq!( + stale["error"]["pluginErrorCode"], "sample.workspaceChanged", + "{stale}" + ); + + // Cancelling the call cancels its unanswered host request on the wire. + invoke_in(session, "watch-cancel", "files.nextChange", &context); + let request = take_host_request(session, "lithe/host/workspace/getFolders"); + respond(session, &request, Ok(folders.clone())); + let pending = take_host_request(session, "lithe/host/workspace/watch"); + core( + "lsp.cancelOperation", + json!({ "sessionId": session.id, "operationId": "watch-cancel" }), + ); + assert_eq!( + session.completion("watch-cancel")["error"]["code"], + "requestCancelled" + ); + let cancelled = pending["result"]["hostRequestID"].clone(); + session.await_event("the watch request to be cancelled", |event| { + event["type"] == "hostRequestCancelled" && event["result"]["hostRequestID"] == cancelled + }); + + // Notifications reach their handlers, which ask the host for more. + assert!(notify( + session, + "lithe/host/workspace/didChangeContexts", + json!({ "revision": 2 }) + )); + let request = take_host_request(session, "lithe/host/workspace/listContexts"); + respond( + session, + &request, + Ok(json!({ + "revision": 2, + "contexts": [{ + "contextID": "w-sample", + "generation": 1, + "name": "orders", + "authority": { "kind": "local" }, + "folders": folders["folders"] + }] + })), + ); + assert!(notify( + session, + "lithe/host/commands/didRun", + json!({ "commandID": "org.example.sample-backend.greet", "context": context }) + )); + let request = take_host_request(session, "lithe/host/workspace/getFolders"); + assert_eq!(request["result"]["params"], json!({ "context": context })); + respond(session, &request, Ok(folders)); + let request = take_host_request(session, "lithe/host/window/showInformation"); + assert_eq!( + request["result"]["params"], + json!({ "context": context, "text": "Sample Backend sees orders." }) + ); + respond(session, &request, Ok(json!({ "selectedIndex": null }))); + + // Core answered capability discovery without the host. + assert!( + !session + .events + .iter() + .any(|event| event["type"] == "hostRequest" + && event["method"] == "lithe/host/getCapabilities"), + "the host was asked for capabilities" + ); +} + +/// This scenario plays the native host at the protocol boundary. Real PTY +/// rendering and history restoration are covered by Lithe's native tests. +fn run_terminal_services(session: &mut Session) { + invoke_in(session, "terminal-cycle", "terminal.cycle", &workspace()); + let create = take_host_request(session, "lithe/host/terminal/create"); + assert_eq!(create["result"]["params"]["retainedContextBytes"], 4096); + assert_eq!( + create["result"]["params"]["arguments"], + json!(["--interactive"]) + ); + respond( + session, + &create, + Ok(json!({ "terminalID":"terminal-1", "generation":1 })), + ); + let listen = take_host_request(session, "lithe/host/terminal/onExit"); + respond( + session, + &listen, + Ok(json!({ "subscriptionID":"terminal-sub" })), + ); + let close = take_host_request(session, "lithe/host/terminal/close"); + respond(session, &close, Ok(json!({ "retainedContextBytes":1024 }))); + assert!(notify( + session, + "lithe/host/subscriptions/notify", + json!({ + "subscriptionID":"terminal-sub", "sequence":1, + "event": { "type":"exit", "terminalID":"terminal-1", "generation":1, "exitCode":null, "reason":"closed" } + }) + )); + let reopen = take_host_request(session, "lithe/host/terminal/reopen"); + assert_eq!(reopen["result"]["params"]["terminalID"], "terminal-1"); + respond( + session, + &reopen, + Ok(json!({ "terminalID":"terminal-1", "generation":2, "restoredContextBytes":1024 })), + ); + let dispose = take_host_request(session, "lithe/host/terminal/dispose"); + respond(session, &dispose, Ok(Value::Null)); + let subscription = take_host_request(session, "lithe/host/subscriptions/dispose"); + respond(session, &subscription, Ok(Value::Null)); + let result = session.completion("terminal-cycle"); + assert!(result.get("error").is_none(), "{result}"); + assert_eq!(result["result"]["value"]["reopened"]["generation"], 2); +} + +/// Invokes `function` as a page of the window showing `context` does. +fn invoke_in(session: &Session, operation: &str, function: &str, context: &Value) { + core( + "lsp.functionRequest", + json!({ + "sessionId": session.id, + "operationId": operation, + "function": { "pluginID": session.plugin_id, "functionID": function }, + "params": {}, + "context": context, + }), + ); +} + +/// Waits for a `hostRequest` for `method` and takes it from the events, so +/// the next wait finds the next one. +fn take_host_request(session: &mut Session, method: &str) -> Value { + let request = session.await_event(&format!("a {method} host request"), |event| { + event["type"] == "hostRequest" && event["method"] == method + }); + let id = request["result"]["hostRequestID"].clone(); + session.events.retain(|event| { + !(event["type"] == "hostRequest" && event["result"]["hostRequestID"] == id) + }); + request +} + +/// Answers a host request as the host does: its result, or its failure. +fn respond(session: &Session, request: &Value, outcome: Result) { + let mut response = json!({ + "sessionId": session.id, + "hostRequestID": request["result"]["hostRequestID"], + }); + match outcome { + Ok(result) => response["result"] = result, + Err(error) => response["error"] = error, + } + core("lsp.hostResponse", response); +} + +/// Sends a host notification through Core; whether it reached the backend. +fn notify(session: &Session, method: &str, params: Value) -> bool { + core( + "lsp.hostNotify", + json!({ "sessionId": session.id, "method": method, "params": params }), + )["delivered"] + == true +} + +fn launch_plan(manifest: &Value, package: &Path, state: &Path) -> Value { + let plan = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": manifest, + "backend": true, + "request": { + "pluginDirectory": package, + "stateDirectory": state, + "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, + } + }), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["launch"]["kind"], "packaged"); + plan["plan"].clone() +} + +/// Starts the backend as the host does: the packaged executable, under the +/// plugin's backend provider ID, with the state directory as root and +/// working directory and the plan's host services. +fn start(plugin_id: &str, package: &Path, plan: &Value, state: &Path) -> Session { + let executable = package.join(plan["launch"]["path"].as_str().expect("packaged path")); + let started = core( + "lsp.startServer", + json!({ + "providerId": format!("pluginBackend:{plugin_id}"), + "pluginId": plugin_id, + "executablePath": executable, + "arguments": plan["arguments"], + "rootUri": format!("file://{}/", state.display()), + "workingDirectory": state, + "hostServices": plan["backend"]["hostServices"], + "initializeTimeoutMilliseconds": super::WAIT.as_millis() as u64, + "requestTimeoutMilliseconds": super::WAIT.as_millis() as u64, + "shutdownTimeoutMilliseconds": 5000, + }), + ); + let mut session = Session { + id: started["sessionId"] + .as_str() + .expect("session ID") + .to_string(), + plugin_id: plugin_id.to_string(), + events: Vec::new(), + }; + session.await_event("the backend to become ready", |event| { + event["type"] == "stateChanged" && event["state"] == "ready" + }); + session +} + +/// Invokes `function` and answers every host request of the call from +/// `store` until the call ends. +fn serve_host( + session: &mut Session, + store: &mut BTreeMap, + operation: &str, + function: &str, + params: Value, +) -> Value { + let request_id = |event: &Value| { + event["result"]["hostRequestID"] + .as_str() + .map(str::to_string) + }; + let mut answered: BTreeSet<_> = session + .events + .iter() + .filter(|event| event["type"] == "hostRequest") + .filter_map(request_id) + .collect(); + session.invoke(operation, function, params); + let deadline = Instant::now() + super::WAIT; + loop { + let pending: Vec<_> = session + .events + .iter() + .filter(|event| { + event["type"] == "hostRequest" + && request_id(event).is_some_and(|id| !answered.contains(&id)) + }) + .cloned() + .collect(); + for request in pending { + let id = request_id(&request).expect("host request ID"); + let params = &request["result"]["params"]; + let key = params["key"].as_str().expect("credential key").to_string(); + let result = match request["method"].as_str() { + Some("lithe/host/credentials/get") => { + json!({ "secret": store.get(&key) }) + } + Some("lithe/host/credentials/set") => { + store.insert(key, params["secret"].as_str().expect("secret").to_string()); + Value::Null + } + Some("lithe/host/credentials/delete") => { + store.remove(&key); + Value::Null + } + other => panic!("unexpected host request {other:?}"), + }; + core( + "lsp.hostResponse", + json!({ "sessionId": session.id, "hostRequestID": id, "result": result }), + ); + answered.insert(id); + } + if let Some(completed) = session + .events + .iter() + .find(|event| event["operationId"] == operation && event["type"] == "requestCompleted") + { + return completed.clone(); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!( + !remaining.is_zero(), + "timed out waiting for {operation}; events: {:#?}", + session.events + ); + let drained = core( + "lsp.waitEvents", + json!({ "sessionId": session.id, "timeoutMilliseconds": remaining.as_millis().min(200) as u64 }), + ); + session + .events + .extend(drained["events"].as_array().cloned().unwrap_or_default()); + } +} + +/// Waits for the backend to create `path`, draining events meanwhile. The +/// file is the only signal a handler blocked in the backend process gives. +fn wait_for_file(session: &mut Session, path: &Path) { + let deadline = Instant::now() + super::WAIT; + while !path.exists() { + assert!( + Instant::now() < deadline, + "the backend never created {}", + path.display() + ); + let drained = core( + "lsp.waitEvents", + json!({ "sessionId": session.id, "timeoutMilliseconds": 50 }), + ); + session + .events + .extend(drained["events"].as_array().cloned().unwrap_or_default()); + } +} diff --git a/shared/plugins/sdk/e2e/plugin_sdk/keymap.rs b/shared/plugins/sdk/e2e/plugin_sdk/keymap.rs new file mode 100644 index 00000000..e9ed90a7 --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk/keymap.rs @@ -0,0 +1,72 @@ +//! 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]) { + 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/shared/plugins/sdk/e2e/plugin_sdk/mod.rs b/shared/plugins/sdk/e2e/plugin_sdk/mod.rs new file mode 100644 index 00000000..959967c4 --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk/mod.rs @@ -0,0 +1,674 @@ +//! End-to-end scenario shared by the official plugin SDKs (`sdk/node`, +//! `sdk/java`), run against Rust Core through the same JSON commands a host +//! sends. The plugin backend scenario of `sdk/rust` is in `backend.rs`. +//! +//! Each SDK's sample backend is launched exactly as a host launches a build +//! provider: Core validates its manifest and expands the launch plan, the +//! runtime version is checked, and `lsp.startServer` starts it. The scenario +//! then drives the typed build requests, a multi-step plugin function with +//! progress, a plugin failure, cancellation of a running tool, runtime +//! registration, and state recovery across a restart. Both samples answer it +//! identically, which keeps the SDKs' behaviour aligned. +//! +//! `rust/lithe-core/Cargo.toml` declares these files as lithe-core +//! integration tests behind the `plugin-sdk-e2e` feature, and +//! `shared/plugins/scripts/sdk-e2e.mjs` builds the samples and runs them; +//! `LITHE_PLUGIN_SDK_ROOT` names the `shared/plugins` directory that holds +//! the samples. + +// Each test binary includes this module and uses only the helpers its SDK needs. +#![allow(dead_code)] + +pub mod backend; +pub mod keymap; +pub mod native_build; +pub mod vcs; + +use lithe_core::execute_json; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +const BUILD_SYSTEM: &str = "sample"; +/// Bound for any single wait; a broken backend fails here instead of in CI. +const WAIT: Duration = Duration::from_secs(20); + +fn core(command: &str, payload: Value) -> Value { + let request = json!({ "id": format!("sdk-{command}"), "command": command, "payload": payload }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Core response should be JSON"); + assert_eq!(response["ok"], true, "{command} failed: {response}"); + response["data"].clone() +} + +/// The `shared/plugins` directory named by `LITHE_PLUGIN_SDK_ROOT`. +fn repository_root() -> PathBuf { + let root = std::env::var_os("LITHE_PLUGIN_SDK_ROOT") + .expect("set LITHE_PLUGIN_SDK_ROOT to the shared/plugins directory"); + PathBuf::from(root) + .canonicalize() + .expect("shared/plugins directory") +} + +/// Every file of a directory with its contents, to prove the package is unchanged. +fn listing(root: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory).expect("readable directory") { + let path = entry.expect("directory entry").path(); + if path.is_dir() { + pending.push(path); + } else { + let contents = fs::read(&path).expect("readable file"); + files.insert(path.strip_prefix(root).unwrap().to_path_buf(), contents); + } + } + } + files +} + +/// Temporary workspace and host state directories, removed even when an +/// assertion fails. +struct Fixture { + root: PathBuf, +} + +impl Fixture { + fn new(label: &str) -> Self { + let root = + std::env::temp_dir().join(format!("lithe-plugin-sdk-{label}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("fixture directory"); + // The backend sees canonical paths (macOS /var is /private/var). + Self { + root: root.canonicalize().expect("fixture path"), + } + } + + fn workspace(&self) -> PathBuf { + self.root.join("workspace") + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +/// One running backend session. Dropping it stops the process and removes +/// the session, so a failed assertion leaves no backend behind. +struct Session { + id: String, + plugin_id: String, + events: Vec, +} + +impl Session { + fn start(plugin_id: &str, executable: &str, arguments: &[Value], workspace: &Path) -> Self { + 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": provider_id, + "pluginId": plugin_id, + "executablePath": executable, + "arguments": arguments, + // Directory URI with a trailing slash, as the macOS host sends it. + "rootUri": format!("file://{}/", workspace.display()), + "workingDirectory": workspace, + "initializeTimeoutMilliseconds": WAIT.as_millis() as u64, + "requestTimeoutMilliseconds": WAIT.as_millis() as u64, + "shutdownTimeoutMilliseconds": 5000, + }), + ); + let mut session = Self { + id: started["sessionId"] + .as_str() + .expect("session ID") + .to_string(), + plugin_id: plugin_id.to_string(), + events: Vec::new(), + }; + session.await_event("the session to become ready", |event| { + event["type"] == "stateChanged" && event["state"] == "ready" + }); + session + } + + /// Waits for the first event, already received or new, that `matches`. + fn await_event(&mut self, description: &str, matches: impl Fn(&Value) -> bool) -> Value { + let deadline = Instant::now() + WAIT; + loop { + if let Some(event) = self.events.iter().find(|event| matches(event)) { + return event.clone(); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!( + !remaining.is_zero(), + "timed out waiting for {description}; events: {:#?}", + self.events + ); + let drained = core( + "lsp.waitEvents", + json!({ "sessionId": self.id, "timeoutMilliseconds": remaining.as_millis().min(1000) as u64 }), + ); + self.events + .extend(drained["events"].as_array().cloned().unwrap_or_default()); + } + } + + fn completion(&mut self, operation: &str) -> Value { + self.await_event(&format!("operation {operation} to end"), |event| { + // Every terminal outcome, cancellation included, is one requestCompleted. + event["operationId"] == operation && event["type"] == "requestCompleted" + }) + } + + fn invoke(&mut self, operation: &str, function: &str, params: Value) { + core( + "lsp.functionRequest", + json!({ + "sessionId": self.id, + "operationId": operation, + "function": { "pluginID": self.plugin_id, "functionID": function }, + "params": params, + }), + ); + } + + fn call(&mut self, operation: &str, function: &str, params: Value) -> Value { + self.invoke(operation, function, params); + self.completion(operation) + } + + /// The registry after the latest `functionsChanged` event. + fn functions(&mut self) -> Vec { + let latest = self + .events + .iter() + .rev() + .find(|event| event["type"] == "functionsChanged") + .expect("a functionsChanged event"); + serde_json::from_value(latest["result"]["functions"].clone()).expect("function IDs") + } + + fn send_build_request(&mut self, operation: &str, plan: &Value) { + core( + "lsp.buildRequest", + json!({ + "sessionId": self.id, + "operationId": operation, + "method": plan["provider"]["method"], + "params": plan["provider"]["params"], + }), + ); + } + + fn build_request(&mut self, operation: &str, plan: &Value) -> Value { + self.send_build_request(operation, plan); + let completed = self.completion(operation); + assert!(completed.get("error").is_none(), "{completed}"); + completed["result"]["value"].clone() + } + + fn stop(&mut self) { + core("lsp.stopServer", json!({ "sessionId": self.id })); + self.await_event("the backend to stop", |event| { + event["type"] == "stateChanged" && event["state"] == "stopped" + }); + } +} + +impl Drop for Session { + fn drop(&mut self) { + let request = json!({ "id": "sdk-cleanup", "command": "lsp.stopServer", "payload": { "sessionId": self.id } }); + let _ = execute_json(&request.to_string()); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let destroy = json!({ "id": "sdk-cleanup", "command": "lsp.destroyServer", "payload": { "sessionId": self.id } }); + let response: Value = + serde_json::from_str(&execute_json(&destroy.to_string())).unwrap_or(Value::Null); + if response["ok"] == true { + return; + } + let wait = json!({ "id": "sdk-cleanup", "command": "lsp.waitEvents", "payload": { "sessionId": self.id, "timeoutMilliseconds": 200 } }); + let _ = execute_json(&wait.to_string()); + } + } +} + +#[cfg(unix)] +fn is_alive(pid: u32) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .is_ok_and(|status| status.success()) +} + +/// One SDK's sample backend, packaged as a host installs it. +pub struct Sample { + /// `launch.tool` of the sample's provider: `node` or `java`. + pub tool: &'static str, + /// The runtime executable the host would resolve for that tool. + pub runtime: String, + /// The installed plugin package: `plugin.json` and the launch entry. + pub package: PathBuf, + /// Node.js, which runs the sample workspace's build tools for both SDKs. + pub node: String, +} + +/// Node.js from `LITHE_PLUGIN_SDK_NODE`, or `None` with a skip notice. +pub fn node_from_environment(test: &str) -> Option { + let Some(node) = std::env::var_os("LITHE_PLUGIN_SDK_NODE") else { + eprintln!("skipped: set LITHE_PLUGIN_SDK_NODE to run {test}"); + return None; + }; + Some(node.into_string().expect("UTF-8 Node.js path")) +} + +pub fn repository_path(relative: &str) -> PathBuf { + repository_root().join(relative) +} + +/// Copies `files` (package-relative destination, source) into a fresh package +/// directory under the system temporary directory, removed on drop. +pub struct TemporaryPackage { + pub root: PathBuf, +} + +impl TemporaryPackage { + pub fn new(label: &str, files: &[(&str, PathBuf)]) -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-plugin-sdk-package-{label}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + for (destination, source) in files { + let target = root.join(destination); + fs::create_dir_all(target.parent().expect("package file parent")) + .expect("package directory"); + fs::copy(source, &target) + .unwrap_or_else(|error| panic!("copy {}: {error}", source.display())); + } + Self { + root: root.canonicalize().expect("package path"), + } + } +} + +impl Drop for TemporaryPackage { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +/// The enabled registry a host would hold: the `node` and `java` definitions of +/// `sdk/e2e/runtime-tools.plugin.json`, standing in for the runtime plugins. +fn runtime_registry() -> Value { + let tools: Value = serde_json::from_slice( + &fs::read(repository_path("sdk/e2e/runtime-tools.plugin.json")).expect("runtime tools"), + ) + .expect("runtime tools JSON"); + json!({ "plugins": [{ "manifest": tools }] }) +} + +/// Starts a plan's `tool` launch the way a host does and returns the argv after +/// `runtime`: Core expands the tool definition's launch template, the host +/// probes the runtime's version, and Core checks it against the plan's minimum. +pub fn host_launch_arguments(plan: &Value, runtime: &str) -> Vec { + let launch = &plan["launch"]; + assert_eq!(launch["kind"], "tool", "{plan}"); + let tool = launch["tool"].as_str().expect("launch tool"); + let registry = runtime_registry(); + let expanded = core( + "toolchains.instantiateLaunch", + json!({ + "registry": registry, + "tool": tool, + "executable": runtime, + "entry": launch["entry"], + "runtimeArguments": launch["runtimeArguments"], + "arguments": plan["arguments"], + }), + ); + assert_eq!(expanded["status"], "ok", "{expanded}"); + let expanded = &expanded["launch"]; + let version = Command::new(runtime) + .args( + expanded["versionArguments"] + .as_array() + .expect("version probe") + .iter() + .map(|argument| argument.as_str().unwrap()), + ) + .output() + .expect("runtime version probe"); + // Hosts capture stderr with stdout: `java -version` prints to stderr. + let output = format!( + "{}{}", + String::from_utf8_lossy(&version.stdout), + String::from_utf8_lossy(&version.stderr) + ); + let mut request = json!({ "registry": registry, "tool": tool, "output": output }); + if let Some(minimum) = launch["minimumVersion"].as_str() { + request["minimumVersion"] = json!(minimum); + } + let check = core("toolchains.checkVersion", request); + assert_eq!(check["version"]["satisfied"], true, "{check}"); + expanded["arguments"] + .as_array() + .expect("launch arguments") + .clone() +} + +/// Runs the shared scenario against `sample`. +pub fn run_sample_scenario(sample: &Sample) { + let package = &sample.package; + let node = &sample.node; + let runtime = &sample.runtime; + let manifest: Value = + serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) + .expect("sample manifest JSON"); + let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); + let package_before = listing(&package); + + // The manifest passes the same validation as an installed plugin. + let validated = core( + "plugins.declarative.validate", + json!({ "manifest": manifest }), + ); + assert_eq!(validated["status"], "ok", "{validated}"); + + let fixture = Fixture::new(sample.tool); + let workspace = fixture.workspace(); + for project in ["app", "broken", "slow"] { + fs::create_dir_all(workspace.join(project)).expect("project directory"); + } + let slow_pid = workspace.join("slow/slow.pid"); + fs::write( + workspace.join("lithe-sample.json"), + json!({ + "projects": [ + { + "name": "app", "path": "app", + "build": [node, "-e", "console.log('built app')"], + "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] + }, + { "name": "broken", "path": "broken", "build": [node, "-e", "process.exit(2)"] }, + { + "name": "slow", "path": "slow", + "build": [node, "-e", "require('fs').writeFileSync('slow.pid', String(process.pid)); setInterval(() => {}, 1000)"] + } + ] + }) + .to_string(), + ) + .expect("workspace file"); + + // The host expands the launch plan and checks the runtime it found. + let plan = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": manifest, + "buildSystemID": BUILD_SYSTEM, + "request": { + "pluginDirectory": package, + "stateDirectory": fixture.root.join("state"), + "workspaceStateDirectory": fixture.root.join("workspace-state"), + "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, + } + }), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["launch"]["tool"], sample.tool); + let arguments = host_launch_arguments(plan, runtime); + for directory in plan["requiredDirectories"].as_array().unwrap() { + let path = match directory.as_str() { + Some("state") => fixture.root.join("state"), + _ => fixture.root.join("workspace-state"), + }; + fs::create_dir_all(path).expect("host state directory"); + } + + let mut session = Session::start(&plugin_id, runtime, &arguments, &workspace); + assert_eq!(session.functions(), ["snapshot.read", "workspace.build"]); + + // Typed build requests: Core plans them and decodes the answers. + let build = |operation: &str, request: Value| { + core( + operation, + json!({ "manifest": manifest, "buildSystemID": BUILD_SYSTEM, "request": request }), + ) + }; + let model_plan = build("build.modelPlan", json!({ "root": "." }))["plan"].clone(); + assert_eq!(model_plan["provider"]["method"], "lithe/build/model"); + let model_result = session.build_request("model", &model_plan); + let model = build( + "build.decodeModel", + json!({ "workspaceRoot": workspace, "root": ".", "result": model_result }), + ); + let names: Vec<_> = model["model"]["projects"] + .as_array() + .expect("decoded projects") + .iter() + .map(|project| project["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, ["app", "broken", "slow"], "{model}"); + + let dependency_plan = build( + "build.dependencyPlan", + json!({ "root": ".", "project": { "name": "app", "path": "app" } }), + )["plan"] + .clone(); + let dependencies_result = session.build_request("dependencies", &dependency_plan); + let dependencies = build( + "build.decodeDependencies", + json!({ "workspaceRoot": workspace, "root": ".", "project": "app", "result": dependencies_result }), + ); + assert_eq!( + dependencies["dependencies"]["dependencies"][0]["name"], "left-pad", + "{dependencies}" + ); + assert_log_facts( + &plugin_id, + &[ + ("model", "plugins.operation.finished", "INFO"), + ("dependencies", "plugins.operation.finished", "INFO"), + ], + ); + + // A multi-step function: prepare, run the tool, record a snapshot. + let built = session.call( + "build-app", + "workspace.build", + json!({ "projects": ["app"] }), + ); + assert_eq!( + built["result"]["value"], + json!({ "generation": 1, "status": "succeeded", "projects": [{ "name": "app", "exitCode": 0 }] }), + "{built}" + ); + let progress: Vec<_> = session + .events + .iter() + .filter(|event| event["type"] == "requestProgress" && event["operationId"] == "build-app") + .map(|event| event["result"]["message"].clone()) + .collect(); + assert_eq!(progress, ["Preparing", "Building app", "Done"]); + // The backend registered snapshot.clear once a snapshot existed. + session.await_event("snapshot.clear to be registered", |event| { + event["type"] == "functionsChanged" + && event["result"]["functions"] + .as_array() + .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) + }); + + // A plugin failure keeps Lithe's stable code and forwards the plugin's own. + let failed = session.call( + "build-broken", + "workspace.build", + json!({ "projects": ["broken"] }), + ); + assert_eq!( + failed["error"]["code"], "plugin.function.failed", + "{failed}" + ); + assert_eq!(failed["error"]["pluginErrorCode"], "sample.buildFailed"); + + assert_log_facts( + &plugin_id, + &[ + ("build-app", "plugins.operation.finished", "INFO"), + ("build-broken", "plugins.operation.failed", "ERROR"), + ], + ); + + // Cancelling a running build ends the tool the backend started. + session.invoke( + "build-slow", + "workspace.build", + json!({ "projects": ["slow"] }), + ); + let deadline = Instant::now() + WAIT; + let pid = loop { + if let Some(pid) = fs::read_to_string(&slow_pid) + .ok() + .and_then(|text| text.trim().parse::().ok()) + { + break pid; + } + assert!(Instant::now() < deadline, "the slow tool never started"); + session.events.extend( + core( + "lsp.waitEvents", + json!({ "sessionId": session.id, "timeoutMilliseconds": 50 }), + )["events"] + .as_array() + .cloned() + .unwrap_or_default(), + ); + }; + core( + "lsp.cancelOperation", + json!({ "sessionId": session.id, "operationId": "build-slow" }), + ); + assert_eq!( + session.completion("build-slow")["error"]["code"], + "requestCancelled" + ); + #[cfg(unix)] + { + let deadline = Instant::now() + WAIT; + while is_alive(pid) { + assert!( + Instant::now() < deadline, + "the cancelled tool {pid} is still running" + ); + // test-stability: allow(rust-real-sleep) reason: the cancelled tool is a separate OS process that offers no notification channel to the test; the deadline above bounds the loop. + std::thread::sleep(Duration::from_millis(20)); + } + } + #[cfg(not(unix))] + let _ = pid; + + // State survives a restart: the new process reads the recorded snapshot. + session.stop(); + drop(session); + let mut restarted = Session::start(&plugin_id, runtime, &arguments, &workspace); + restarted.await_event("snapshot.clear to be offered again", |event| { + event["type"] == "functionsChanged" + && event["result"]["functions"] + .as_array() + .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) + }); + let snapshot = restarted.call("read", "snapshot.read", json!({})); + assert_eq!(snapshot["result"]["value"]["generation"], 2, "{snapshot}"); + assert_eq!(snapshot["result"]["value"]["status"], "failed"); + assert_eq!( + restarted.call("clear", "snapshot.clear", json!({}))["result"]["value"], + Value::Null + ); + assert_eq!( + restarted.call("read-again", "snapshot.read", json!({}))["result"]["value"], + Value::Null + ); + restarted.stop(); + drop(restarted); + + // The backend wrote only to the host's state directory, never the package. + assert!( + listing(&package) == package_before, + "the plugin package changed at runtime" + ); + let state: Vec<_> = fs::read_dir(fixture.root.join("workspace-state")) + .expect("workspace state directory") + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert!( + state.is_empty(), + "cleared state left files behind: {state:?}" + ); +} + +/// Facts drained from Core's process-global catalog queue but not yet claimed. +/// +/// Tests in one binary run in parallel and share that queue, so a drain can +/// return facts recorded for another test's plugin. Every drain happens under +/// this lock and lands here; each test then takes only its own plugin's facts. +static DRAINED_FACTS: Mutex> = Mutex::new(Vec::new()); + +/// Drains Core's queue and returns every unclaimed fact on the plugin's channel. +fn take_plugin_facts(plugin: &str) -> Vec { + let channel = format!("plugins/{plugin}/host"); + let mut facts = DRAINED_FACTS.lock().unwrap_or_else(PoisonError::into_inner); + let drained = core("diagnostics.drainCoreEvents", json!({})); + facts.extend(drained["events"].as_array().cloned().unwrap_or_default()); + let (mine, others): (Vec, Vec) = facts + .drain(..) + .partition(|fact| fact["channel"] == channel.as_str()); + *facts = others; + mine +} + +/// Typed SDK notifications reach the Core-owned queue, with host operation correlation. +fn assert_log_facts(plugin: &str, expected: &[(&str, &str, &str)]) { + let records = take_plugin_facts(plugin); + for (operation, event, level) in expected { + let matches: Vec<_> = records + .iter() + .filter(|fact| fact["event"] == *event && fact["op"] == *operation) + .collect(); + assert_eq!( + matches.len(), + 1, + "one terminal {event} for {operation}: {records:#?}" + ); + assert_eq!(matches[0]["level"], *level); + assert!(matches[0]["fields"]["duration_ms"].as_u64().is_some()); + assert!(!matches[0].to_string().contains("built app")); + assert!(!matches[0].to_string().contains("s3cr3t")); + } +} diff --git a/shared/plugins/sdk/e2e/plugin_sdk/native_build.rs b/shared/plugins/sdk/e2e/plugin_sdk/native_build.rs new file mode 100644 index 00000000..010bbdb7 --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk/native_build.rs @@ -0,0 +1,370 @@ +//! Real SDK processes answer native Core requests without a plugin page or a +//! private function. The fictional engine owns all build/test/run facts. +use super::{core, listing, Fixture, Sample, Session, BUILD_SYSTEM, WAIT}; +use serde_json::{json, Value}; +use std::fs; +use std::time::Instant; + +pub fn run(sample: &Sample) { + let manifest: Value = + serde_json::from_slice(&fs::read(sample.package.join("plugin.json")).unwrap()).unwrap(); + let before = listing(&sample.package); + let validation = core("plugins.declarative.validate", json!({"manifest":manifest})); + assert_eq!(validation["status"], "ok", "{validation}"); + let fixture = Fixture::new(&format!("native-{}", sample.tool)); + let workspace = fixture.workspace(); + let root = "nested"; + let instance = workspace.join(root); + fs::create_dir_all(instance.join("app")).unwrap(); + fs::create_dir_all(instance.join("broken")).unwrap(); + fs::create_dir_all(instance.join("slow")).unwrap(); + let config = json!({"projects":[ + {"name":"app","path":"app","build":[sample.node,"-e","const fs=require('fs'); const n=fs.existsSync('build-count')?Number(fs.readFileSync('build-count','utf8')):0; fs.writeFileSync('build-count',String(n+1)); console.log('native build')"], + "tests":[ + {"suite":"Suite","case":"exact # [case]","command":[sample.node,"-e","console.log(JSON.stringify(process.argv.slice(1)))"]}, + {"suite":"Suite","case":"other","command":[sample.node,"-e","process.exit(7)"]}], + "run":{"command":"node","arguments":["-e","console.log(JSON.stringify(process.argv.slice(1)))"],"debugProgram":"native-program"}}, + {"name":"broken","path":"broken","build":[sample.node,"-e","process.exit(2)"], + "run":{"command":"node","arguments":["-e","process.exit(99)"]}}, + {"name":"slow","path":"slow","build":[sample.node,"-e","require('fs').writeFileSync('started', String(process.pid)); setInterval(()=>{}, 1000)"],"run":{"command":"node","arguments":[],"debugProgram":"native-program"}} + ]}); + fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); + fs::write( + instance.join("app/native-program"), + "native debugger target fixture", + ) + .unwrap(); + let launch = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest":manifest,"buildSystemID":BUILD_SYSTEM,"request":{ + "pluginDirectory":sample.package,"stateDirectory":fixture.root.join("state"), + "workspaceStateDirectory":fixture.root.join("workspace-state"), + "architecture":if cfg!(target_arch="aarch64") {"arm64"} else {"x86_64"} + }}), + ); + assert_eq!(launch["status"], "ok", "{launch}"); + let arguments = super::host_launch_arguments(&launch["plan"], &sample.runtime); + let mut session = Session::start( + manifest["id"].as_str().unwrap(), + &sample.runtime, + &arguments, + &workspace, + ); + assert!( + session.functions().is_empty(), + "native workflows must not need private functions" + ); + let build = |command: &str, request: Value| { + core( + command, + json!({"manifest":manifest,"buildSystemID":BUILD_SYSTEM,"request":request}), + ) + }; + let app = json!({"name":"app","path":"nested/app"}); + let plan = build("build.modelPlan", json!({"root":root}))["plan"].clone(); + let result = session.build_request("model", &plan); + let model = build( + "build.decodeModel", + json!({"workspaceRoot":workspace,"root":root,"result":result}), + ); + assert_eq!(model["model"]["targets"][0]["name"], "Main", "{model}"); + let target = &model["model"]["targets"][0]; + assert_eq!(target["path"], "nested/app"); + assert_eq!(target["projectID"], model["model"]["projects"][0]["id"]); + + // A second real backend owns the producer model. Host/Core project its facts + // before the consumer receives them; neither process calls the other. + let mut producer = manifest.clone(); + producer["id"] = json!(format!("{}.source", manifest["id"].as_str().unwrap())); + producer["requires"]["buildModelInputs"] = json!(0); + producer["buildSystems"][0]["id"] = json!("shared-source"); + producer["activationEvents"] = json!(["onBuildSystem:shared-source", "onDebug:native"]); + producer["buildSystems"][0]["modelSharing"] = json!({}); + let mut source_session = Session::start( + producer["id"].as_str().unwrap(), + &sample.runtime, + &arguments, + &workspace, + ); + let source_plan = core( + "build.modelPlan", + json!({"manifest":producer,"buildSystemID":"shared-source","request":{"root":root}}), + ); + assert_eq!(source_plan["status"], "ok", "{source_plan}"); + let source_reply = source_session.build_request("source-model", &source_plan["plan"]); + let source_model = core( + "build.decodeModel", + json!({"manifest":producer,"buildSystemID":"shared-source", + "request":{"workspaceRoot":workspace,"root":root,"result":source_reply}}), + ); + assert_eq!(source_model["status"], "ok", "{source_model}"); + let mut consumer = manifest.clone(); + consumer["requires"]["buildModelInputs"] = json!(0); + consumer["buildSystems"][0]["modelInputs"] = + json!([{"pluginID":producer["id"],"buildSystemID":"shared-source"}]); + let resolved = core( + "build.resolveModelInputs", + json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM,"request":{ + "revision":19,"sources":[{"manifest":producer,"instances":[{ + "buildSystemID":"shared-source","root":root,"state":"ready","model":source_model["model"]}]}]}}), + ); + assert_eq!(resolved["status"], "ok", "{resolved}"); + let snapshot = &resolved["modelInputs"]; + assert!(snapshot["inputs"][0]["instances"][0]["model"] + .get("tasks") + .is_none()); + assert!(snapshot["inputs"][0]["instances"][0]["model"] + .get("run") + .is_none()); + let input_plan = core( + "build.modelPlan", + json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM, + "request":{"root":root,"modelInputs":snapshot}}), + ); + assert_eq!(input_plan["status"], "ok", "{input_plan}"); + let input_reply = session.build_request("consumer-model", &input_plan["plan"]); + let input_model = core( + "build.decodeModel", + json!({"manifest":consumer,"buildSystemID":BUILD_SYSTEM, + "request":{"workspaceRoot":workspace,"root":root,"result":input_reply}}), + ); + assert_eq!( + input_model["model"]["projects"][0]["properties"]["inputRevision"], "19", + "{input_model}" + ); + assert_eq!( + input_model["model"]["projects"][0]["properties"]["inputProjects"], "3", + "{input_model}" + ); + drop(source_session); + + for (project, exit) in [ + (app.clone(), 0), + (json!({"name":"broken","path":"nested/broken"}), 2), + ] { + let plan = build( + "build.taskPlan", + json!({"root":root,"task":"build","project":project}), + )["plan"] + .clone(); + let result = session.build_request(&format!("build-{exit}"), &plan); + assert_eq!(result["exitCode"], exit, "{result}"); + } + assert_eq!( + fs::read_to_string(instance.join("app/build-count")).unwrap(), + "1" + ); + let discovery_plan = build("build.testDiscoveryPlan", json!({"root":root}))["plan"].clone(); + let discovery = session.build_request("discover", &discovery_plan); + let decoded = build( + "build.decodeTestDiscovery", + json!({"root":root,"result":discovery}), + ); + let tests = decoded["discovery"]["tests"] + .as_array() + .expect("decoded native test selectors"); + assert_eq!(tests.len(), 2); + let selected = tests + .iter() + .find(|test| test["case"] == "exact # [case]") + .unwrap(); + let request = json!({"root":root,"project":selected["project"],"suite":selected["suite"], + "case":selected["case"],"userArguments":["a b","x#y"]}); + let test_plan = build("build.testPlan", request)["plan"].clone(); + let tested = session.build_request("test-exact", &test_plan); + assert_eq!(tested["exitCode"], 0, "{tested}"); + assert_eq!(tested["output"], "[\"a b\",\"x#y\"]\n"); + + // An externally removed selector cannot silently widen to all tests. + let mut changed = config.clone(); + changed["projects"][0]["tests"] = json!([]); + fs::write(instance.join("lithe-sample.json"), changed.to_string()).unwrap(); + session.send_build_request("stale-test", &test_plan); + assert_eq!( + session.completion("stale-test")["error"]["code"], + "plugin.build.failed" + ); + fs::write(instance.join("lithe-sample.json"), config.to_string()).unwrap(); + + let prepare = build("build.prepareRunPlan", json!({"root":root,"run":"application","project":app, + "target":{"name":target["name"],"path":target["path"],"tags":target["tags"]},"userArguments":["a b","x#y"]}))["plan"].clone(); + let result = session.build_request("prepare", &prepare); + let decoded = build( + "build.decodeRunPreparation", + json!({"root":root,"workspaceRoot":workspace,"result":result}), + ); + assert_eq!(decoded["status"], "ok", "{decoded}"); + assert_eq!(decoded["launch"]["workingDirectory"], "nested/app"); + assert_eq!( + fs::read_to_string(instance.join("app/build-count")).unwrap(), + "2", + "preparation skipped the upstream build" + ); + assert_eq!( + decoded["launch"]["executable"], + json!({"kind":"command","value":"node"}) + ); + assert_eq!( + decoded["launch"]["arguments"] + .as_array() + .unwrap() + .iter() + .rev() + .take(2) + .cloned() + .collect::>(), + json!(["x#y", "a b"]).as_array().unwrap().clone() + ); + assert!( + session + .events + .iter() + .any(|event| event["operationId"] == "prepare" && event["type"] == "requestProgress"), + "preparation must report its own progress" + ); + let debug = build("build.prepareDebugPlan", json!({"root":root,"run":"application","project":app, + "target":{"name":target["name"],"path":target["path"],"tags":target["tags"]},"userArguments":["a b","x#y"]}))["plan"].clone(); + assert_eq!(debug["provider"]["method"], "lithe/build/prepareDebug"); + let result = session.build_request("prepare-debug", &debug); + let decoded_debug = build( + "build.decodeDebugPreparation", + json!({"root":root,"workspaceRoot":workspace,"run":"application","result":result}), + ); + assert_eq!(decoded_debug["status"], "ok", "{decoded_debug}"); + assert_eq!(decoded_debug["preparation"]["adapter"], "native"); + assert_eq!( + decoded_debug["preparation"]["program"], + "nested/app/native-program" + ); + assert_eq!( + decoded_debug["preparation"]["arguments"], + json!(["a b", "x#y"]) + ); + assert_eq!( + fs::read_to_string(instance.join("app/build-count")).unwrap(), + "3", + "debug preparation skipped the upstream build" + ); + assert!( + session + .events + .iter() + .any(|event| event["operationId"] == "prepare-debug" + && event["type"] == "requestProgress") + ); + let failed = build( + "build.prepareRunPlan", + json!({"root":root,"run":"application", + "project":{"name":"broken","path":"nested/broken"}, + "target":{"name":"Main","path":"nested/broken","tags":["application"]}}), + )["plan"] + .clone(); + session.send_build_request("failed-preparation", &failed); + let failed = session.completion("failed-preparation"); + // Preparation retains the common LSP error category, rather than an + // execution exit-code result or a private-function pluginErrorCode. + assert_eq!(failed["error"]["code"], "serverError"); + assert!( + failed.get("result").is_none(), + "a failed build returned a launch plan" + ); + + let failed_debug = build("build.prepareDebugPlan", json!({"root":root,"run":"application", + "project":{"name":"broken","path":"nested/broken"},"target":{"name":"Main","path":"nested/broken","tags":["application"]}}))["plan"].clone(); + session.send_build_request("failed-debug-preparation", &failed_debug); + let failed_debug = session.completion("failed-debug-preparation"); + assert_eq!(failed_debug["error"]["code"], "serverError"); + assert!(failed_debug.get("result").is_none()); + + // The second workspace uses the same plugin with different current facts. + // Cancelling one instance must leave the other process and model usable. + let other_workspace = fixture.root.join("other"); + fs::create_dir_all(&other_workspace).unwrap(); + fs::write(other_workspace.join("lithe-sample.json"), json!({"projects":[{"name":"other","path":".","build":[sample.node,"-e","console.log('other workspace')"]}]}).to_string()).unwrap(); + let mut other = Session::start( + manifest["id"].as_str().unwrap(), + &sample.runtime, + &arguments, + &other_workspace, + ); + for (operation, command, request) in [ + ( + "slow-build", + "build.taskPlan", + json!({"root":root,"task":"build","project":{"name":"slow","path":"nested/slow"}}), + ), + ( + "slow-debug", + "build.prepareDebugPlan", + json!({"root":root,"run":"application","project":{"name":"slow","path":"nested/slow"}, + "target":{"name":"Main","path":"nested/slow","tags":["application"]}}), + ), + ] { + let _ = fs::remove_file(instance.join("slow/started")); + let slow = build(command, request)["plan"].clone(); + session.send_build_request(operation, &slow); + let deadline = Instant::now() + WAIT; + let pid = loop { + if let Some(pid) = fs::read_to_string(instance.join("slow/started")) + .ok() + .and_then(|text| text.trim().parse::().ok()) + { + break pid; + } + assert!(Instant::now() < deadline, "native tool never started"); + let events = core( + "lsp.waitEvents", + json!({"sessionId":session.id,"timeoutMilliseconds":50}), + ); + session + .events + .extend(events["events"].as_array().cloned().unwrap_or_default()); + }; + core( + "lsp.cancelOperation", + json!({"sessionId":session.id,"operationId":operation}), + ); + assert_eq!( + session.completion(operation)["error"]["code"], + "requestCancelled" + ); + #[cfg(unix)] + { + let deadline = Instant::now() + WAIT; + while super::is_alive(pid) { + assert!( + Instant::now() < deadline, + "cancelled native tool {pid} survived" + ); + let events = core( + "lsp.waitEvents", + json!({"sessionId":session.id,"timeoutMilliseconds":50}), + ); + session + .events + .extend(events["events"].as_array().cloned().unwrap_or_default()); + } + } + #[cfg(not(unix))] + let _ = pid; + } + let plan = build( + "build.taskPlan", + json!({"root":".","task":"build","project":{"name":"other","path":"."}}), + )["plan"] + .clone(); + assert_eq!( + other.build_request("other-build", &plan)["output"], + "other workspace\n" + ); + other.stop(); + session.stop(); + drop(other); + drop(session); + assert_eq!( + listing(&sample.package), + before, + "native workflows changed the plugin package" + ); +} diff --git a/shared/plugins/sdk/e2e/plugin_sdk/vcs.rs b/shared/plugins/sdk/e2e/plugin_sdk/vcs.rs new file mode 100644 index 00000000..c57f5348 --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk/vcs.rs @@ -0,0 +1,314 @@ +//! 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, + }), + ); + let completed = self.session.completion(&operation_id); + let (event, level) = if completed.get("error").is_some() { + ("plugins.operation.failed", "ERROR") + } else { + ("plugins.operation.finished", "INFO") + }; + super::assert_log_facts(&self.session.plugin_id, &[(&operation_id, event, level)]); + completed + } + + /// 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 = super::host_launch_arguments(plan, node); + 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": 0, "baseContent": 0, "checkin": 0, "cleanup": 0, "conflicts": 0, + "fileHistory": 0, "fileOperations": 0, "history": 0, "rollback": 0, "status": 0, + "update": 0 + }) + ); + 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/shared/plugins/sdk/e2e/plugin_sdk_build.rs b/shared/plugins/sdk/e2e/plugin_sdk_build.rs new file mode 100644 index 00000000..bb3bbe5a --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk_build.rs @@ -0,0 +1,68 @@ +//! Native build workflows run against the host contract of this checkout. +//! `shared/plugins/scripts/sdk-e2e.mjs` always runs them. +mod plugin_sdk; + +#[test] +fn node_sdk_serves_native_build_test_and_run_workflows() { + let node = plugin_sdk::node_from_environment("native Node build workflows") + .expect("native workflow checks require Node"); + // Package the SDK next to the entry point, as a distributed plugin does. + let package = plugin_sdk::TemporaryPackage::new( + "node-native", + &[( + "plugin.json", + plugin_sdk::repository_path("sdk/node/examples/sample-native-build/plugin.json"), + )], + ); + let backend = std::fs::read_to_string(plugin_sdk::repository_path( + "sdk/node/examples/sample-native-build/backend/main.mjs", + )) + .unwrap(); + let packaged = backend.replace("../../../src/index.mjs", "./vendor/index.mjs"); + assert_ne!( + packaged, backend, + "the packaged entry must use its vendored SDK" + ); + let vendor = package.root.join("backend/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + for entry in std::fs::read_dir(plugin_sdk::repository_path("sdk/node/src")).unwrap() { + let entry = entry.unwrap(); + assert!( + entry.file_type().unwrap().is_file(), + "update packaging for SDK subdirectories" + ); + std::fs::copy(entry.path(), vendor.join(entry.file_name())).unwrap(); + } + std::fs::write(package.root.join("backend/main.mjs"), packaged).unwrap(); + plugin_sdk::native_build::run(&plugin_sdk::Sample { + tool: "node", + runtime: node.clone(), + node, + package: package.root.clone(), + }); +} + +#[test] +fn java_sdk_serves_native_build_test_and_run_workflows() { + let node = plugin_sdk::node_from_environment("native Java build workflows") + .expect("native workflow checks require Node"); + let java = std::env::var("LITHE_PLUGIN_SDK_JAVA").expect("native workflow checks require Java"); + let jar = std::env::var_os("LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE") + .expect("build the native Java sample first"); + let package = plugin_sdk::TemporaryPackage::new( + "java-native", + &[ + ( + "plugin.json", + plugin_sdk::repository_path("sdk/java/examples/sample-native-build/plugin.json"), + ), + ("backend/sample-native-build.jar", jar.into()), + ], + ); + plugin_sdk::native_build::run(&plugin_sdk::Sample { + tool: "java", + runtime: java, + node, + package: package.root.clone(), + }); +} diff --git a/shared/plugins/sdk/e2e/plugin_sdk_java.rs b/shared/plugins/sdk/e2e/plugin_sdk_java.rs new file mode 100644 index 00000000..7b70f7f1 --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk_java.rs @@ -0,0 +1,58 @@ +//! End-to-end check of the official Java plugin SDK (`sdk/java`) against Rust +//! Core, with the scenario shared with the Node.js SDK in `plugin_sdk/mod.rs`. +//! +//! The sample is installed the way a plugin ships: `plugin.json` and the +//! shaded `backend/sample-build.jar` in one package. It runs only when +//! `LITHE_PLUGIN_SDK_JAVA` names a Java 8 or newer executable, +//! `LITHE_PLUGIN_SDK_JAVA_SAMPLE` the built sample JAR, and +//! `LITHE_PLUGIN_SDK_NODE` Node.js for the sample workspace's build tools; +//! `shared/plugins/scripts/sdk-e2e.mjs` sets all three. + +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)) = ( + std::env::var_os("LITHE_PLUGIN_SDK_JAVA"), + std::env::var_os("LITHE_PLUGIN_SDK_JAVA_SAMPLE"), + ) else { + eprintln!( + "skipped: set LITHE_PLUGIN_SDK_JAVA and LITHE_PLUGIN_SDK_JAVA_SAMPLE to run the Java SDK end-to-end check" + ); + return; + }; + let Some(node) = plugin_sdk::node_from_environment("the Java SDK end-to-end check") else { + return; + }; + let package = plugin_sdk::TemporaryPackage::new( + "java", + &[ + ( + "plugin.json", + plugin_sdk::repository_path("sdk/java/examples/sample-build/plugin.json"), + ), + ("backend/sample-build.jar", jar.into()), + ], + ); + plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { + tool: "java", + runtime: java.into_string().expect("UTF-8 Java path"), + package: package.root.clone(), + node, + }); +} diff --git a/shared/plugins/sdk/e2e/plugin_sdk_node.rs b/shared/plugins/sdk/e2e/plugin_sdk_node.rs new file mode 100644 index 00000000..e8de26ed --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk_node.rs @@ -0,0 +1,42 @@ +//! 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`. +//! 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; +//! `shared/plugins/scripts/sdk-e2e.mjs` sets it. + +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 { + return; + }; + plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { + tool: "node", + runtime: node.clone(), + package: plugin_sdk::repository_path("sdk/node/examples/sample-build"), + node, + }); +} + +#[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/shared/plugins/sdk/e2e/plugin_sdk_rust.rs b/shared/plugins/sdk/e2e/plugin_sdk_rust.rs new file mode 100644 index 00000000..8600436b --- /dev/null +++ b/shared/plugins/sdk/e2e/plugin_sdk_rust.rs @@ -0,0 +1,44 @@ +//! End-to-end check of the official Rust plugin SDK (`sdk/rust`) against Rust +//! Core, with the plugin backend scenario in `plugin_sdk/backend.rs`. +//! +//! The sample is installed the way a plugin ships: `plugin.json`, the +//! backend executable at its packaged path, and the web page that calls it. +//! It runs only when `LITHE_PLUGIN_SDK_RUST_SAMPLE` names the built +//! `sample-backend` executable; `shared/plugins/scripts/sdk-e2e.mjs` sets it. + +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 { + eprintln!("skipped: set LITHE_PLUGIN_SDK_RUST_SAMPLE to run the Rust SDK end-to-end check"); + return; + }; + let sample = |file: &str| { + ( + file.to_string(), + plugin_sdk::repository_path(&format!("sdk/rust/examples/sample-backend/{file}")), + ) + }; + let files = [ + sample("plugin.json"), + sample("ui/index.html"), + sample("ui/main.js"), + sample("ui/style.css"), + ("bin/sample-backend".to_string(), executable.into()), + ]; + let files: Vec<_> = files + .iter() + .map(|(destination, source)| (destination.as_str(), source.clone())) + .collect(); + let package = plugin_sdk::TemporaryPackage::new("rust", &files); + plugin_sdk::backend::run_backend_scenario(&package.root); +} diff --git a/shared/plugins/sdk/e2e/runtime-tools.plugin.json b/shared/plugins/sdk/e2e/runtime-tools.plugin.json new file mode 100644 index 00000000..58117de8 --- /dev/null +++ b/shared/plugins/sdk/e2e/runtime-tools.plugin.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-runtimes", + "displayName": "Sample runtimes", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "toolDefinitions": 0 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [], + "toolDefinitions": [ + { + "id": "node", + "displayName": "Node.js", + "executableLayouts": [ + { "path": "bin/node", "platforms": ["macos", "linux"] }, + { "path": "node.exe", "platforms": ["windows"] } + ], + "executables": [{ "id": "shell", "kind": "command", "command": "node" }], + "version": { "arguments": ["--version"], "pattern": "v(\\d+(?:\\.\\d+)*)" }, + "homeLayouts": [{ "match": "*", "home": "." }], + "commands": ["node"], + "launch": ["${executable}", "${runtimeArguments}", "${entry}", "${arguments}"] + }, + { + "id": "java", + "displayName": "JDK", + "executableLayouts": [ + { "path": "bin/java", "platforms": ["macos", "linux"] }, + { "path": "bin/java.exe", "platforms": ["windows"] } + ], + "executables": [{ "id": "shell", "kind": "command", "command": "java" }], + "version": { "arguments": ["-version"], "pattern": "version \"(?:1\\.)?(\\d+(?:\\.\\d+)*)" }, + "homeLayouts": [{ "match": "*", "home": "." }], + "commands": ["java"], + "launch": ["${executable}", "${runtimeArguments}", "-jar", "${entry}", "${arguments}"] + } + ] +} diff --git a/shared/plugins/sdk/java/README.md b/shared/plugins/sdk/java/README.md new file mode 100644 index 00000000..881e8ceb --- /dev/null +++ b/shared/plugins/sdk/java/README.md @@ -0,0 +1,422 @@ +# Lithe plugin backend SDK for Java + +`lithe-plugin-sdk-java` is the official way to write a plugin backend in Java: +the process a plugin starts through a `runtime` launch +(`{ "kind": "runtime", "tool": "java", "minimumVersion": "8", "entry": "backend/app.jar" }`). +It is the Java twin of the [Node.js SDK](../node/README.md). Both implement the +following protocols from the +[plugin contract](../../contract/v1/README.md), which lives in this repository +next to the SDK and changes with it, with the same rules and error mapping: + +- **`litheBuild`**: typed `lithe/build/*` requests, for a build system whose + `model` or `dependencies` source is `provider`: models and dependency trees, + bounded task execution, tests, run and debug preparation, and read-only + model inputs. +- **`litheFunctions`**: plugin functions, which are plugin-private actions + that Lithe invokes for the same plugin, with progress, cancellation, and + registration at runtime; [host services](#host-services) such as + credentials, workspace contexts, capability discovery, host notifications, + and the public document, file, editor, and window methods; and + [calls to other plugins' functions](#calling-other-plugins), which Core + relays. A function also learns which plugin called it. +- **`litheKeymapImport`**: independently negotiated shortcut import + providers reading only explicitly selected source snapshots. +- **`litheLogging`**: typed diagnostics, required in every session. + +The Java SDK has no VCS provider API; only the Node.js SDK has one. 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`). + +## What the Lithe desktop serves + +The Lithe desktop declares `backend`, `pluginFunctions`, `buildProviders` and +`buildSystems` (all version 0), so the function, host-service and build +protocols below run there. It does not declare `keymapImportProviders` or +`buildModelInputs`: a plugin whose manifest requires one of them is not offered +for installation there. The Core end-to-end tests still drive those protocols. +Which host services a session may call is reported at run time; see +[Host services](#host-services). + +## Shortcut import providers + +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. + +## A minimal backend + +```java +Backend backend = Backend.create("example-backend", "1.0.0"); + +backend.onModel((params, context) -> { + Map project = new LinkedHashMap<>(); + project.put("name", "app"); + project.put("path", Json.string(params, "root")); + Map model = new LinkedHashMap<>(); + model.put("projects", Collections.singletonList(project)); + return model; +}); + +backend.function("snapshot.refresh", (params, context) -> { + context.progress("Refreshing", 10.0); + Processes.Result result = context.run(Arrays.asList("example-tool", "refresh")); + if (result.exitCode() != 0) { + throw new PluginError("example.refreshFailed", "The refresh failed."); + } + return Collections.singletonMap("refreshedAt", System.currentTimeMillis()); +}); + +backend.listen(); +``` + +[`examples/sample-build`](examples/sample-build) is a complete plugin. It has +a build provider, functions, persistent state, a function registered at +runtime, and cancellation. From the root of this repository, +`node shared/plugins/scripts/sdk-e2e.mjs` drives it through a real Core session +with the scenario in [`sdk/e2e`](../e2e/plugin_sdk/mod.rs), the same one the +Node.js sample answers. + +## Getting the SDK + +The SDK is released from this repository, tagged `sdk-java-v`; the +current release is +[0.5.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-java-v0.5.0). +A release ships `lithe-plugin-sdk-java-.jar` and a `.sha256` file +holding the JAR's SHA-256. The SDK is not published to a Maven repository. Use +it like this: + +1. Download the JAR during your plugin's build, never at runtime. +2. Check the JAR against the SHA-256 you pinned in your repository. +3. Shade it into your backend JAR, as the sample's `pom.xml` does. + +A plugin package is installed read-only and must run offline, so the backend +JAR it starts must hold everything it loads. + +## Logging + +The SDK requires `experimental.litheLogging` at initialization and refuses a +host that does not offer it. +See [plugin logging](../../guides/plugin-logging.md) for the typed diagnostics +API, host-owned catalog, exact levels, operation correlation and `${logDir}`. +Use `diagnostics().degraded/recovered` for handled fallback or recovery facts. +Function and build terminal facts are automatic; do not log a failure before +returning it to the SDK. Suppress successful high-frequency reads with +`suppressSuccessDiagnostics(operation)`. There is no free-text `log` API, no +stderr fallback and no plugin-owned file writer. + +## API + +| API | Purpose | +| --- | --- | +| `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)` | A bounded provider-owned task, returning `{ exitCode, output }`. | +| `onTest(handler)`, `onDiscoverTests(handler)` | Exact test execution and runnable selectors; the discovery format and version are stamped. | +| `onPrepareRun(handler)`, `onPrepareDebug(handler)` | Prepare a portable run or debug plan; the host owns the application. | +| `negotiated().build()` | Whether the host offered `litheBuild` and the backend declared a build handler; build methods are refused before their handler runs when it is false. | +| `function(id, handler)` | Declares a function offered in `initialize`. | +| `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | +| `negotiated().functions()` | Whether the host offered plugin functions. `host().negotiated()` reports the same. | +| `host()` | [Host services](#host-services) for work outside a call, such as a command handler. Its calls to other plugins name no parent call. | +| `onCommand(commandId, handler)` | Runs `handler.handle(commandId, workspaceContext)` when the user runs one of the plugin's commands. | +| `onContextsChanged(handler)` | `lithe/host/workspace/didChangeContexts` with its `{ revision }`. | +| `onHostNotification(method, handler)` | A `lithe/host/*` notification a later host adds. The three this SDK routes itself are reserved. | +| `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | +| `onRequest(method, handler)`, `onNotification(method, handler)` | Ordinary LSP methods. Lifecycle and `lithe/*` methods are reserved. | +| `sendRequest`, `sendNotification` | Ordinary LSP messages to the host. `lithe/*` methods are reserved. | +| `diagnostics().degraded(operation, code[, count])`, `.recovered(...)` | Typed handled fallback/recovery; no payload text. | +| `listen()` | Serves stdio until `exit` or the end of input, then exits the JVM with the protocol's exit code. `listen(in, out, err)` serves other streams and returns the code instead. | +| `PluginError(code, message)` | A failure meant for your own UI. | +| `Processes.run(command[, options])` | Runs a process without a shell. Its stdin is closed. `Options` set the directory, environment, output limits, a cancellation, and output callbacks. | +| `StateStore.open(directory)` | A small atomic JSON store in a host-provided directory. | + +Handlers run on their own threads. Every handler receives a `CallContext`: + +| Member | Meaning | +| --- | --- | +| `cancellation()`, `isCancelled()` | Cancelled when any of these happens: Lithe cancels the call, its deadline passes, the function is withdrawn, the session ends, or the handler finishes. Finishing the handler stops the work it left running. | +| `throwIfCancelled()` | Throws `CancelledException` once the call is cancelled. | +| `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. | +| `diagnostics()` | Typed diagnostics correlated with this call. | +| `functionId()` | Functions only. | +| `host()` | [Host services](#host-services) bound to the call: ending the call cancels the requests it left unanswered. Its calls to other plugins name this call as their parent. | +| `workspaceContext()` | Functions: the `{ contextID, generation }` of the window that invoked the call, or null. | +| `caller()`, `callerPluginId()` | Functions: the plugin that called this function through the relay, as an unmodifiable `{ pluginID }` map and as its ID. Null when the host started the call. | +| `operationId()` | Opaque wire identity for functions and bounded build/test/run-preparation calls; null for other requests. It falls back to the JSON-RPC request ID, so never use it as a `parentOperationID`. | +| `progress(message, percentage[, output])` | Functions use 1024-byte messages. Build/test/run preparation use 4096-byte messages and optional output up to 64 KiB. Percentages are clamped to 0–100. Cancelled or settled calls cannot report. Model/dependency/discovery reports are ignored. | + +The typed handlers for task execution, tests, and run and debug preparation +require the corresponding host capabilities (`buildProviders`); the manifest +requirement and runtime negotiation are both checked, and registering a handler +never bypasses either boundary. See [native build workflows](../../guides/native-build-workflows.md) +and the [no-page Java sample](examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java). +Build tool failure is an exit-code result; throwing an error aborts preparation. + +Private function results and errors map exactly as in the Node.js SDK; typed +build operations follow their domain contract: + +- A returned value becomes `result.value`. +- `PluginError` becomes `plugin.function.failed` with your message and + `pluginErrorCode`. +- Any other exception is reported with a generic message; automatic diagnostics + keep a fixed failure category without its message, cause or stack. +- `CancelledException` ends the call as cancelled. +- A result larger than 1 MiB of JSON fails like an unexpected error. + +## Host services + +A backend may ask the host for services when the host offers plugin +functions (`litheFunctions`); each is granted by a manifest permission and +refused otherwise. When the host did not offer them, every host request fails +locally with `HostError` `plugin.host.unavailable` (-32601), the answer Core +would give, and nothing is sent. Every method returns a `CompletableFuture`; +wrong arguments fail it rather than throwing. + +```java +backend.function("query.run", (params, context) -> { + // Permission credentials.store: the platform's secret store, per plugin. + String password = context.host().credentials().get("connection.prod:password").get(); + if (password == null) { + throw new PluginError("query.noPassword", "Sign in first."); + } + // The window the page runs in. + Map workspace = context.workspaceContext(); + if (workspace != null && context.host().supports("lithe/host/workspace/getFolders").get()) { + Object folders = context.host().workspace().getFolders(workspace).get().get("folders"); + // ... + } + return runQuery(params, password); +}); + +backend.onCommand("db.refresh", (commandId, workspaceContext) -> { + if (workspaceContext != null) { + backend.host().window().showInformation(workspaceContext, Collections.singletonMap("text", "Refreshed.")); + } +}); +``` + +| `Host` member | Purpose | +| --- | --- | +| `negotiated()` | Whether the host offered plugin functions. When false, every other member fails or reports false locally. | +| `credentials().get(key)`, `.set(key, secret)`, `.delete(key)` | Secrets in the platform's store. `get` completes with null when nothing is stored. A key is 1–128 of `[A-Za-z0-9._:-]`, a secret at most 16 KiB; others fail locally. | +| `capabilities()`, `supports(method)` | What this session may call, from `lithe/host/getCapabilities` (asked once). `supports` is false when the host offered no plugin functions. | +| `workspace().listContexts()`, `.getContext(context)`, `.getFolders(context)` | Workspace contexts; only `getContext` returns, and creates, the context's `stateDirectory`. | +| `workspace().readDirectory`, `.stat`, `.readFile`, `.findFiles`, `.findText`, `.watch` | Files of a local context, addressed as `{ rootID, path }` from `getFolders`. | +| `documents().list`, `.getSnapshot`; `editor().getActive`, `.getSelections`, `.applyEdits`, `.replaceSelections`, `.insertText`, `.open`, `.reveal`, `.registerActions` | Live documents and editors. Edits name the `expectedVersion` they were computed against. | +| `preview().openHtml`, `browser().openExternal`, `window().showInformation`, `.showWarning`, `.showError` | Preview, browser, and messages. | +| `functions().providers(functionId)`, `.invoke(context, call)` | [Calls to other plugins' functions](#calling-other-plugins), relayed by Core. | +| `request(method, params)`, `subscribe(method, params, listener)` | Any `lithe/host/*` method, including ones a later host adds. | +| `subscriptions().dispose(id)` | Ends a subscription; `Subscription.dispose()` does the same. | +| `withCancellation(cancellation)` | The same services, cancelled together with `cancellation`. | + +A host registers only the services it implements, and `capabilities()` lists +exactly those; call `supports(method)` before depending on one of them. What +the Lithe desktop does not register yet is listed under +[Capability versions](../../reference/capabilities-and-versions.md#capability-versions-requires). + +Methods taking a workspace context accept `{ contextID, generation }` or a +whole context from `listContexts`; their `params` are the contract's, without +`context`. The full params and results are in +[the contract](../../contract/v1/README.md#public-document-file-and-window-methods). + +What the SDK does for you, and what it leaves to you: + +- **Refusals are `HostError`.** Branch on `code()`, such as + `plugin.host.contextExpired`; a code this SDK does not know is still a + `HostError`, and `details()` holds the host's object. On `versionConflict` + reread and recompute; on `contextExpired` resolve the original target + again, never the focused window; retry `busy` and `rateLimited` (after + `details().get("retryAfterMilliseconds")`) with a bound; `permissionDenied` + and `readOnly` need the user. An unknown param is + `plugin.host.unsupportedOption` naming it in `details().get("option")`, so + you can retry without it. +- **Cancellation has no rollback.** Cancelling the token given to + `withCancellation`, or ending the call that sent a request, fails it at + once with `CancelledException`, and the SDK also sends `$/cancelRequest` so + the host stops. An edit or an external open may already have taken effect, + and the SDK never sends a request again. Cancelling the returned future + itself does not reach the host. +- **At most 16 host requests are unanswered.** Further ones wait in the SDK + for a free slot instead of being refused. +- **Subscriptions route by ID.** `workspace().watch` and `subscribe` call + the `Listener` with each `Host.Event` in order, one at a time; `gap()` is + true when events were missed, so rescan. Nothing arrives after `dispose()` + or the end of the session. +- **Commands are events.** `onCommand` runs when the user runs one of the + plugin's commands; the host waits for no result. A stopped backend is + started for it only when the manifest declares `onCommand:`. + +## Calling other plugins + +A backend may call a function that another plugin exports. The plugin must +declare the call in its manifest, and Core relays it: the backend never talks +to the other plugin. A plugin that declares no call gets +`plugin.host.permissionDenied`. The exports, the declaration and the checks +Core makes are in the +[contract](../../contract/v1/README.md#cross-plugin-calls). + +```java +backend.function("project.sync", (params, context) -> { + Host.Functions functions = context.host().functions(); + Object providers = functions.providers("languageImport.settings").get().get("providers"); + // ... choose a provider ... + Map call = new LinkedHashMap<>(); + call.put("pluginID", "dev.example.go"); + call.put("functionID", "languageImport.settings"); + call.put("params", Collections.singletonMap("project", "app")); + call.put("timeoutMilliseconds", 5000L); + // Core decides who may be called for which workspace; use the one the user's call ran in. + Object settings = functions.invoke(context.workspaceContext(), call).get().get("value"); + return Collections.singletonMap("settings", settings); +}); +``` + +| Member | Purpose | +| --- | --- | +| `functions().providers(functionId)` | Completes with `{ providers: [{ pluginID, version }] }`: the enabled plugins other than yours that export the function, sorted by plugin ID. You must have declared the function. | +| `functions().invoke(context, call)` | Completes with `{ value }`, the provider's result (`value` may be null). `context` is a workspace context, `{ contextID, generation }` or a whole one from `listContexts`; the call runs for that workspace. `call` is a map with `pluginID` and `functionID`, and optionally `params` (a map, default `{}`), `timeoutMilliseconds` (a positive integer; Core defaults it to 60 000 and caps it at 600 000) and `parentOperationID`. | +| `CallContext.caller()`, `.callerPluginId()` | On the providing side: the plugin that called this function, as `{ pluginID }` and as its ID. Core writes it from the calling session. Null when the host started the call. | +| `HostError.pluginErrorCode()` | For `plugin.function.failed`, the provider's own error code (from `details.pluginErrorCode`); null otherwise. | + +What the SDK does for you, and what it leaves to you: + +- **The relay needs plugin functions.** When the host did not offer + `litheFunctions`, `providers`, `invoke`, and a `request` for either method + fail at once with `HostError` `plugin.host.unavailable` (-32601), the answer + Core would give, and nothing is sent. Check `negotiated().functions()` or + `supports(method)` first. +- **The parent call is filled in for you.** Inside a function, `context.host()` + sets `parentOperationID` to the wire `operationID` of the function call it + belongs to, so Core knows which call the new one is a child of. The ID is + used only when it is a string of 1 to 128 bytes; otherwise the field is + omitted. The SDK never uses `operationId()` for this, because it falls back + to the JSON-RPC request ID. A `parentOperationID` you put in `call` wins. + `withCancellation` keeps the parent. `Backend.host()`, which belongs to no + call, names none. Without a parent Core treats the new call as a child of + every relayed call the backend is serving. +- **Wrong arguments fail locally.** A malformed call completes the future + with `PluginSdkError` `sdk.functions.invalidCall` (a missing or wrong + workspace context is `sdk.host.invalidContext`) and sends nothing: a + `functionID` that is not a function ID, an empty `pluginID`, `params` that + is not a map, a `timeoutMilliseconds` that is not a positive integer, or a + `parentOperationID` that is not 1 to 128 bytes of text. A null optional + field counts as absent. Only the fields above are sent; any other key, + `caller` included, is dropped, because Core refuses a caller-chosen + `caller`. The SDK does not cap `timeoutMilliseconds`; Core does. +- **Refusals are `HostError`.** Besides the host codes, the relay adds + `plugin.function.notDeclared`, `.notExported`, `.providerDisabled`, + `.versionMismatch`, `.callCycle`, `.depthExceeded`, `.tooManyCalls`, + `.payloadTooLarge`, `.providerUnavailable`, `.failed` and `.invalidResult`. + `plugin.function.failed` carries the provider's own code in + `pluginErrorCode()`. A call to a context the host closed fails with + `plugin.host.contextExpired`; a remote workspace with + `plugin.host.authorityUnsupported`; a deadline with `plugin.host.timeout`. + Core never retries a call, and neither does the SDK. +- **Calls end with their caller.** Ending or cancelling the function call that + made a relayed call, or cancelling the token given to `withCancellation`, + fails the future with `CancelledException` and sends `$/cancelRequest`, so + Core cancels the provider's operation and the calls it made. +- **Limits are Core's.** A chain has at most 4 calls, and a plugin already on + it, the caller included, cannot be called (`callCycle`): a provider asked to + apply values must not call the asker back inside that call. A caller has at + most 8 relayed calls running; params and results are at most 256 KiB. Only + local workspaces are supported. A relayed call holds one of the 16 + unanswered host-request slots until it ends, which may be 600 seconds, so a + backend that starts many long calls delays its other host requests. +- **Decide by `caller()`, never by params.** A plugin ID in `params` is data + the caller wrote; `context.caller()` is the identity of the calling session. + +## Rules that keep Lithe in charge + +These are the [Node.js SDK's rules](../node/README.md#rules-that-keep-lithe-in-charge). +In Java: + +1. **Domain capabilities stay typed.** Build models and dependency trees go + through `onModel` and `onDependencies`, never through a function. +2. **No calls back into Lithe's dispatch.** `sendRequest` and + `sendNotification` refuse any `lithe/*` method. Host services go through + `host()`, which sends only `lithe/host/*`. +3. **stdout belongs to the protocol.** `listen()` keeps the real stdout for + protocol frames and points `System.out` at stderr. Never write to + `FileDescriptor.out`. +4. **The package is read-only.** Keep state under the directories Lithe + passes through launch templates (`${stateDir}`, `${workspaceStateDir}`). + `StateStore.open` refuses a directory inside the plugin package. It finds + the package by walking up from the SDK's own JAR to `plugin.json`. + Diagnostics use the SDK and host; native log files require an explicitly + supplied `${logDir}`. +5. **Work ends with its call.** Start processes with `context.run`, and check + `isCancelled()` in long loops. On cancellation the SDK first asks the + process to stop, then stops it forcibly after a grace period. + - **macOS and Linux, every Java version:** each tool leads its own process + group, as in the Node SDK. The SDK starts it through the system `perl`, + which calls `setpgrp` and then becomes the tool. A stop reaches the whole + group, and when the tool exits on its own, whatever it left running in + its group is killed. + - **Windows, or no `perl`:** on Java 9 or newer the SDK stops the + descendants still attached to the tool; Java 8 stops only the direct + child. + + A descendant that leaves the group (`setsid`) or that the fallback cannot + reach is ended by Lithe's process scope together with the backend. + A cancellation listener that throws is logged and does not keep the other + listeners or the reply from running. + + On `shutdown`, `exit`, or the end of input the SDK ends every process it + started. `Processes.run` keeps Lithe's process-scope markers even when + `Options.environment` replaces the environment. +6. **Errors stay presentable.** Throw `PluginError` for failures the user + should see; every other exception is reported generically. + +## Read-only build model inputs + +The build protocol accepts the host's optional `params.modelInputs` snapshot. +A consumer declares exact `modelInputs` producer slots and requires +`buildProviders: 0` plus `buildModelInputs: 0`. A producer opts into +`modelSharing` and selects any ordinary build option values it wants to share. +The host supplies normalized projects, targets and content roots; tasks, run +plans, settings and unselected option values are excluded. + +Treat `revision` as an opaque workspace snapshot identity. Recompute cached +work when it changes. A slot can be unavailable, and an instance can be loading, +stale or failed; only ready instances carry facts. Do not invoke another plugin +or install/activate a missing producer. Input sharing grants no process or +private-function authority. Producers cannot consume and re-export +inputs. Core validates the shape and the host revokes in-flight work when its +producer changes. + +The two native workflow samples demonstrate independent producer and consumer +backends in the Core end-to-end tests. Their engine is fictional. The Lithe +desktop does not declare `buildModelInputs`. + +## Native terminal API + +The SDK includes wrappers for plugin-owned native terminal create/show/close, +reopen with bounded display history, disposal and exit subscriptions. They +require `terminal.create`. See +[the native terminal guide](../../guides/native-terminals.md). + +## Testing + +Run these from the root of this repository: + +```bash +mvn -B -ntp -f shared/plugins/sdk/java/pom.xml verify + +# End to end: the sample backends of all three SDKs against this checkout's Core. +node shared/plugins/scripts/sdk-e2e.mjs +``` + +This builds the SDK and the sample for Java 8 with any newer JDK, then runs +the JUnit tests. Add `-Djvm=/path/to/java8/bin/java` to run the tests on a +Java 8 runtime, as CI does. + +The end-to-end script needs Rust, Node.js 20.3 or newer, Java +(`LITHE_PLUGIN_SDK_JAVA_RUNTIME` or `java` on `PATH`) and Maven; +`LITHE_PLUGIN_SDK_JAVA_SAMPLE` and `LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE` name +already built sample JARs. diff --git a/shared/plugins/sdk/java/examples/sample-build/plugin.json b/shared/plugins/sdk/java/examples/sample-build/plugin.json new file mode 100644 index 00000000..1a70def1 --- /dev/null +++ b/shared/plugins/sdk/java/examples/sample-build/plugin.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-build-java", + "displayName": "Sample Build (Java)", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { + "buildProviders": 0, + "buildSystems": 0 + }, + "requiresTools": ["java"], + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:sample" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "java" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "8", + "entry": "backend/sample-build.jar" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "-version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ] +} diff --git a/shared/plugins/sdk/java/examples/sample-build/pom.xml b/shared/plugins/sdk/java/examples/sample-build/pom.xml new file mode 100644 index 00000000..8bda3ac0 --- /dev/null +++ b/shared/plugins/sdk/java/examples/sample-build/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + io.github.lithe-idea + lithe-plugin-sdk-java-parent + 0.5.0 + ../../pom.xml + + + sample-build + Lithe sample build provider (Java) + + + + io.github.lithe-idea + lithe-plugin-sdk-java + ${project.version} + + + + + sample-build + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + false + + + org.example.samplebuild.SampleBuild + + + + + + + + + diff --git a/shared/plugins/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java b/shared/plugins/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java new file mode 100644 index 00000000..f29e44db --- /dev/null +++ b/shared/plugins/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java @@ -0,0 +1,248 @@ +package org.example.samplebuild; + +import io.github.litheidea.plugin.sdk.Backend; +import io.github.litheidea.plugin.sdk.CallContext; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import io.github.litheidea.plugin.sdk.Processes; +import io.github.litheidea.plugin.sdk.StateStore; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Sample Lithe plugin backend built on the official Java SDK; the Java twin of + * {@code sdk/node/examples/sample-build}. + * + *

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

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

It shows the three things a backend does: + *

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

A packaged plugin shades the SDK into its own JAR, as this module's POM does. + */ +public final class SampleBuild { + private final Backend backend = Backend.create("sample-build", "0.1.0"); + private final String stateDirectory; + private volatile Path workspaceRoot; + private volatile StateStore store; + + private SampleBuild(String stateDirectory) { + this.stateDirectory = stateDirectory; + } + + public static void main(String[] arguments) { + new SampleBuild(argument(arguments, "--state")).serve(); + } + + private void serve() { + // 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(); + // `--state ${workspaceStateDir}` in plugin.json: the host's writable + // directory for this workspace. The package itself is read-only. + store = StateStore.open(Paths.get(stateDirectory)); + if (store.read("snapshot", null) != null) { + offerClear(); + } + }); + + backend.onModel((params, context) -> { + List projects = new ArrayList<>(); + for (Map project : readConfig(Json.string(params, "root"))) { + projects.add(object("name", project.get("name"), "path", project.get("path"))); + } + return object("projects", projects); + }); + + backend.onDependencies((params, context) -> { + Object selected = params.get("project"); + String path = selected instanceof Map ? Json.string(Json.object(selected), "path") : null; + List dependencies = new ArrayList<>(); + for (Map project : readConfig(Json.string(params, "root"))) { + if (path != null && !path.equals(project.get("path"))) { + continue; + } + Object declared = project.get("dependencies"); + if (declared instanceof List) { + for (Object entry : (List) declared) { + Map dependency = Json.object(entry); + dependencies.add(object("name", dependency.get("name"), "version", dependency.get("version"), + "children", new ArrayList<>())); + } + } + } + return object("dependencies", dependencies); + }); + + backend.function("workspace.build", this::build); + backend.function("snapshot.read", (params, context) -> store.read("snapshot", null)); + backend.listen(); + } + + private Object build(Map params, CallContext context) throws IOException { + // Step 1: prepare. Read the workspace file and resolve the selection. + context.progress("Preparing", 0.0); + List> projects = selectProjects(readConfig("."), params.get("projects")); + + // Step 2: run each project's tool. `context.run` stops the tool when + // Lithe cancels the call, so a cancelled build leaves nothing behind. + List results = new ArrayList<>(); + for (int index = 0; index < projects.size(); index++) { + Map project = projects.get(index); + String name = (String) project.get("name"); + context.progress("Building " + name, 100.0 * index / projects.size()); + List command = new ArrayList<>(); + for (Object part : (List) project.get("build")) { + command.add((String) part); + } + Processes.Result result = context.run(command, new Processes.Options() + .directory(inside(workspaceRoot, (String) project.get("path")))); + results.add(object("name", name, "exitCode", result.exitCode())); + if (result.exitCode() != 0) { + record(results, "failed"); + throw new PluginError("sample.buildFailed", name + " failed with exit code " + result.exitCode() + "."); + } + } + + // Step 3: record the outcome, so `snapshot.read` returns it after a restart. + Object snapshot = record(results, "succeeded"); + context.progress("Done", 100.0); + return snapshot; + } + + /** + * Reads the latest snapshot when the build ends, not when it starts: two + * overlapping builds then get consecutive generations instead of the same + * one. Handlers run on their own threads, so the read and the write hold + * one lock. + */ + private synchronized Object record(List projects, String status) throws IOException { + Object previous = store.read("snapshot", null); + long generation = previous instanceof Map + ? ((Number) Json.object(previous).get("generation")).longValue() + 1 + : 1; + Map snapshot = object("generation", generation, "status", status, "projects", projects); + store.write("snapshot", snapshot); + offerClear(); + return snapshot; + } + + private void offerClear() { + if (backend.functionIds().contains("snapshot.clear")) { + return; + } + backend.registerFunction("snapshot.clear", (params, context) -> { + store.delete("snapshot"); + return null; + }).whenComplete((ignored, error) -> { + if (error != null) { + backend.diagnostics().degraded("initialize", "registration_failed"); + } + }); + } + + /** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ + private static List> selectProjects(List> projects, Object selected) { + if (selected == null) { + return projects; + } + if (!(selected instanceof List) || ((List) selected).isEmpty() + || !((List) selected).stream().allMatch(name -> name instanceof String)) { + throw new PluginError("sample.selection", "\"projects\" must be a non-empty list of project names."); + } + List unknown = new ArrayList<>(); + for (Object name : (List) selected) { + if (projects.stream().noneMatch(project -> name.equals(project.get("name")))) { + unknown.add((String) name); + } + } + if (!unknown.isEmpty()) { + throw new PluginError("sample.unknownProject", "Unknown project: " + String.join(", ", unknown) + "."); + } + List> chosen = new ArrayList<>(); + for (Map project : projects) { + if (((List) selected).contains(project.get("name"))) { + chosen.add(project); + } + } + return chosen; + } + + private List> readConfig(String root) { + Object config; + try { + Path file = inside(workspaceRoot, root == null ? "." : root).resolve("lithe-sample.json"); + config = Json.parse(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException error) { + if (error instanceof PluginError) { + throw (PluginError) error; + } + throw new PluginError("sample.config", "lithe-sample.json is missing or is not valid JSON."); + } + Object projects = config instanceof Map ? ((Map) config).get("projects") : null; + if (!(projects instanceof List)) { + throw new PluginError("sample.config", "lithe-sample.json needs a \"projects\" list."); + } + List> result = new ArrayList<>(); + for (Object project : (List) projects) { + result.add(Json.object(project)); + } + return result; + } + + /** {@code relative} resolved inside {@code base}; workspace files never name paths outside it. */ + private static Path inside(Path base, String relative) { + Path path = base.resolve(relative).normalize(); + if (Paths.get(relative).isAbsolute() || !path.startsWith(base)) { + throw new PluginError("sample.path", relative + " is outside the workspace."); + } + return path; + } + + private static Map object(Object... entries) { + Map map = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + map.put((String) entries[index], entries[index + 1]); + } + return map; + } + + private static String argument(String[] arguments, String name) { + for (int index = 0; index + 1 < arguments.length; index++) { + if (arguments[index].equals(name)) { + return arguments[index + 1]; + } + } + return null; + } +} diff --git a/shared/plugins/sdk/java/examples/sample-native-build/plugin.json b/shared/plugins/sdk/java/examples/sample-native-build/plugin.json new file mode 100644 index 00000000..1e15ec43 --- /dev/null +++ b/shared/plugins/sdk/java/examples/sample-native-build/plugin.json @@ -0,0 +1,156 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-native-build", + "displayName": "Sample Native Build", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { + "buildProviders": 0, + "buildSystems": 0, + "debugAdapters": 0 + }, + "requiresTools": ["java"], + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onDebug:native" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "java" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "8", + "entry": "backend/sample-native-build.jar" + }, + "arguments": [], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project" + ], + "source": "provider", + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/shared/plugins/sdk/java/examples/sample-native-build/pom.xml b/shared/plugins/sdk/java/examples/sample-native-build/pom.xml new file mode 100644 index 00000000..1950a201 --- /dev/null +++ b/shared/plugins/sdk/java/examples/sample-native-build/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + io.github.lithe-idea + lithe-plugin-sdk-java-parent + 0.5.0 + ../../pom.xml + + + sample-native-build + Lithe native build workflow sample (Java) + + + + io.github.lithe-idea + lithe-plugin-sdk-java + ${project.version} + + + + + sample-native-build + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + false + + + org.example.samplenativebuild.SampleNativeBuild + + + + + + + + + diff --git a/shared/plugins/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java b/shared/plugins/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java new file mode 100644 index 00000000..4773db16 --- /dev/null +++ b/shared/plugins/sdk/java/examples/sample-native-build/src/main/java/org/example/samplenativebuild/SampleNativeBuild.java @@ -0,0 +1,200 @@ +package org.example.samplenativebuild; + +import io.github.litheidea.plugin.sdk.Backend; +import io.github.litheidea.plugin.sdk.CallContext; +import io.github.litheidea.plugin.sdk.Json; +import io.github.litheidea.plugin.sdk.PluginError; +import io.github.litheidea.plugin.sdk.Processes; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Fictional build engine demonstrating native workflows without a plugin page. */ +public final class SampleNativeBuild { + private final Backend backend = Backend.create("sample-native-build", null); + private volatile Path workspace; + + public static void main(String[] arguments) { new SampleNativeBuild().serve(); } + + private void serve() { + backend.onInitialize((params, negotiated) -> workspace = Paths.get(URI.create(Json.string(params, "rootUri"))).toRealPath()); + backend.onModel((params, context) -> { + List model = new ArrayList<>(); + List targets = new ArrayList<>(); + for (Map project : projects(Json.string(params, "root"))) { + if (project.get("run") instanceof Map) targets.add(object("name", "Main", "path", project.get("modelPath"), + "project", project.get("modelPath"), "tags", Collections.singletonList("application"))); + Map row = object("name", project.get("name"), "path", project.get("modelPath")); + if (params.get("modelInputs") instanceof Map) { + Map inputs = Json.object(params.get("modelInputs")); + int count = 0; + for (Object slot : (List) inputs.get("inputs")) { + for (Object input : (List) Json.object(slot).get("instances")) { + Map instance = Json.object(input); + if ("ready".equals(instance.get("state"))) count += ((List) Json.object(instance.get("model")).get("projects")).size(); + } + } + row.put("properties", object("inputRevision", String.valueOf(inputs.get("revision")), "inputProjects", String.valueOf(count))); + } + model.add(row); + } + return object("projects", model, "targets", targets); + }); + backend.onDependencies((params, context) -> object("dependencies", Collections.emptyList())); + backend.onExecute((params, context) -> { + if (!"build".equals(params.get("task"))) throw new PluginError("sample.task", "Unknown build task."); + return build(select(params), strings(params.get("userArguments")), context); + }); + backend.onDiscoverTests((params, context) -> { + List discovered = new ArrayList<>(); + for (Map project : select(params)) { + for (Map test : tests(project)) discovered.add(object("suite", test.get("suite"), "case", test.get("case"), + "project", object("name", project.get("name"), "path", project.get("path")))); + } + return object("tests", discovered); + }); + backend.onTest(this::test); + backend.onPrepareRun((params, context) -> prepare(params, context, false)); + backend.onPrepareDebug((params, context) -> prepare(params, context, true)); + backend.listen(); + } + + private Object prepare(Map params, CallContext context, boolean debug) throws IOException { + List> selected = select(params); + Map target = Json.object(params.get("target")); + if (selected.size() != 1 || !"application".equals(params.get("run"))) throw new PluginError("sample.target", "Select one application."); + Map project = selected.get(0); + if (!(project.get("run") instanceof Map) || !"Main".equals(target.get("name")) || !project.get("path").equals(target.get("path"))) { + throw new PluginError("sample.target", "The selected application no longer exists."); + } + Map built = build(selected, Collections.emptyList(), context); + if (((Number) built.get("exitCode")).intValue() != 0) throw new PluginError("sample.buildFailed", "Build failed before launch."); + Map run = Json.object(project.get("run")); + if (debug) { + Object program = run.get("debugProgram"); + if (!(program instanceof String)) throw new PluginError("sample.target", "This application has no native debug program."); + String path = project.get("path").equals(".") ? (String) program : project.get("path") + "/" + program; + return object("program", path, "arguments", strings(params.get("userArguments")), + "workingDirectory", project.get("path"), "environment", object()); + } + List arguments = strings(run.get("arguments")); + arguments.addAll(strings(params.get("userArguments"))); + return object("executable", object("kind", "command", "value", run.get("command")), "arguments", arguments, + "workingDirectory", project.get("path"), "environment", object()); + } + + private Map build(List> projects, List arguments, CallContext context) throws IOException { + StringBuilder output = new StringBuilder(); + for (Map project : projects) { + context.progress("Building " + project.get("name"), null); + Processes.Result result = run(project.get("build"), project, arguments, context); + output.append(result.stdout()).append(result.stderr()); + if (result.exitCode() != 0) return object("exitCode", result.exitCode(), "output", output.toString()); + } + return object("exitCode", 0, "output", output.toString()); + } + + private Object test(Map params, CallContext context) throws IOException { + int matched = 0; + StringBuilder output = new StringBuilder(); + for (Map project : select(params)) { + for (Map test : tests(project)) { + if (params.containsKey("suite") && !params.get("suite").equals(test.get("suite"))) continue; + if (params.containsKey("case") && !params.get("case").equals(test.get("case"))) continue; + matched++; + Processes.Result result = run(test.get("command"), project, strings(params.get("userArguments")), context); + output.append(result.stdout()).append(result.stderr()); + if (result.exitCode() != 0) return object("exitCode", result.exitCode(), "output", output.toString()); + } + } + if (matched == 0 && (params.containsKey("suite") || params.containsKey("case"))) throw new PluginError("sample.test", "The selected test no longer exists."); + return object("exitCode", 0, "output", output.toString()); + } + + private Processes.Result run(Object declared, Map project, List arguments, CallContext context) throws IOException { + context.throwIfCancelled(); + List command = strings(declared); + if (command.isEmpty()) throw new PluginError("sample.command", "A tool command must be a nonempty argument array."); + command.addAll(arguments); + Processes.Result result = context.run(command, new Processes.Options().directory(inside(Json.string(project, "path"))) + .onStdout(output -> context.progress(null, null, output)) + .onStderr(output -> context.progress(null, null, output))); + context.throwIfCancelled(); + return result; + } + + private List> select(Map params) throws IOException { + List> all = projects(Json.string(params, "root")); + if (!(params.get("project") instanceof Map)) return all; + Map selected = Json.object(params.get("project")); + List> matches = new ArrayList<>(); + for (Map project : all) { + if (project.get("name").equals(selected.get("name")) && project.get("path").equals(selected.get("path"))) matches.add(project); + } + if (matches.size() != 1) throw new PluginError("sample.project", "The selected project no longer exists."); + return matches; + } + + private List> projects(String root) throws IOException { + Path base = inside(root); + String contents = new String(Files.readAllBytes(base.resolve("lithe-sample.json")), StandardCharsets.UTF_8); + List> result = new ArrayList<>(); + for (Map raw : objects(Json.object(Json.parse(contents)).get("projects"))) { + String declared = Json.string(raw, "path"); + if (declared == null || Paths.get(declared).isAbsolute()) throw new PluginError("sample.path", "A project path must be relative to its instance."); + Path directory = base.resolve(declared).toRealPath(); + if (!directory.startsWith(base)) throw new PluginError("sample.path", "A project is outside its instance."); + // The model is instance-relative; later request selectors are workspace-relative. + String modelPath = base.relativize(directory).toString().replace(java.io.File.separatorChar, '/'); + if (modelPath.isEmpty()) modelPath = "."; + Map project = new LinkedHashMap<>(raw); + project.put("modelPath", modelPath); + String workspacePath = Paths.get(root).resolve(modelPath).normalize().toString().replace(java.io.File.separatorChar, '/'); + project.put("path", workspacePath.isEmpty() ? "." : workspacePath); + result.add(project); + } + return result; + } + + private List> tests(Map project) { + return project.containsKey("tests") ? objects(project.get("tests")) : Collections.emptyList(); + } + + private Path inside(String relative) throws IOException { + if (relative == null || Paths.get(relative).isAbsolute()) throw new PluginError("sample.path", "A workspace-relative directory is required."); + Path path = workspace.resolve(relative).toRealPath(); + if (!path.startsWith(workspace)) throw new PluginError("sample.path", "The directory is outside the workspace."); + return path; + } + + private static List strings(Object value) { + if (!(value instanceof List)) throw new PluginError("sample.config", "A string array is required."); + List result = new ArrayList<>(); + for (Object part : (List) value) { + if (!(part instanceof String)) throw new PluginError("sample.config", "A string array is required."); + result.add((String) part); + } + return result; + } + + private static List> objects(Object value) { + if (!(value instanceof List)) throw new PluginError("sample.config", "A list of objects is required."); + List> result = new ArrayList<>(); + for (Object item : (List) value) result.add(Json.object(item)); + return result; + } + + private static Map object(Object... entries) { + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) result.put((String) entries[index], entries[index + 1]); + return result; + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/pom.xml b/shared/plugins/sdk/java/plugin-sdk/pom.xml new file mode 100644 index 00000000..01a4ac41 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + + io.github.lithe-idea + lithe-plugin-sdk-java-parent + 0.5.0 + + + lithe-plugin-sdk-java + Lithe plugin SDK for Java + Plugin functions and build providers over the LSP base protocol, for Lithe plugin backends. + + + + org.junit.jupiter + junit-jupiter + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + false + + lithe-plugin-sdk-java + ${project.version} + + + + + + + diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java new file mode 100644 index 00000000..1e0b49ea --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -0,0 +1,1556 @@ +package io.github.litheidea.plugin.sdk; + +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.Base64; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +/** + * The plugin backend session: one process the host starts from the plugin's + * manifest and talks to over stdio with the LSP base protocol. + * + *

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

Host services ({@code lithe/host/*}) are in {@link #host()}, or a + * handler's {@link CallContext#host()}. + * + *

Contract: {@code contract/v1/README.md} ("Build providers" + * and "Plugin functions"). Behaviour and error codes match the Node.js SDK. + */ +public final class Backend { + /** Answers a typed build request or a plugin function. */ + @FunctionalInterface + public interface Handler { + Object handle(Map params, CallContext context) throws Exception; + } + + /** Answers 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 { + Object handle(Object params, CallContext context) throws Exception; + } + + /** Handles a standard LSP notification. */ + @FunctionalInterface + public interface NotificationHandler { + void handle(Object params) throws Exception; + } + + /** + * Handles {@code lithe/host/commands/didRun} for one of the plugin's + * commands. {@code workspaceContext} is the {@code { contextID, generation }} + * of the window the command ran in, or null. + */ + @FunctionalInterface + public interface CommandHandler { + void handle(String commandId, Map workspaceContext) throws Exception; + } + + /** Runs before the {@code initialize} answer. */ + @FunctionalInterface + public interface InitializeHook { + void run(Map params, Negotiated negotiated) throws Exception; + } + + /** Runs on {@code shutdown}, after running calls were cancelled. */ + @FunctionalInterface + public interface ShutdownHook { + void run() throws Exception; + } + + /** Protocols the host agreed to in {@code initialize}. */ + public static final class Negotiated { + static final Negotiated NONE = new Negotiated(false, false); + + private final boolean functions; + private final boolean build; + + Negotiated(boolean functions, boolean build) { + this.functions = functions; + this.build = build; + } + + /** + * Whether the host negotiated plugin functions ({@code litheFunctions}), + * which also carry the host services, the workspace contexts, the host + * notifications, and the calls to other plugins' functions. + */ + public boolean functions() { + return functions; + } + + /** Whether the host negotiated typed build requests and this backend declared a build handler. */ + public boolean build() { + return build; + } + } + + private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED } + + /** The {@code version} every build result document carries. */ + private static final long BUILD_DOCUMENT_VERSION = 0; + + /** Canonical result format of each typed build request. */ + private static final Map BUILD_FORMATS; + + static { + Map formats = new HashMap<>(); + formats.put(Protocol.BUILD_MODEL, "litheBuildModel"); + formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); + formats.put(Protocol.BUILD_DISCOVER_TESTS, "litheTestDiscovery"); + BUILD_FORMATS = Collections.unmodifiableMap(formats); + } + + private volatile boolean logging; + private boolean loggingReady; + private final List> earlyDiagnostics = new ArrayList<>(); + private final java.util.Set quietSuccess = new java.util.HashSet<>(); + private final String name; + private final String version; + private final Map capabilities; + private final Object lock = new Object(); + private final Object writeLock = new Object(); + private final Map functions = new HashMap<>(); + private final Map 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<>(); + private final Map commandHandlers = new HashMap<>(); + private final Map hostNotificationHandlers = new HashMap<>(); + private NotificationHandler contextsHandler; + private final List initializeHooks = new ArrayList<>(); + private final List shutdownHooks = new ArrayList<>(); + /** Host requests being handled, by JSON-RPC ID. */ + private final Map incoming = new HashMap<>(); + /** Requests this backend sent to the host, by JSON-RPC ID. */ + private final Map outgoing = new HashMap<>(); + private final AtomicInteger nextRequestId = new AtomicInteger(1); + private final ExecutorService executor = Executors.newCachedThreadPool(new ThreadNamer()); + private final HostChannel channel = new HostChannel(new Wire()); + private final Host host = new Host(channel, Collections.emptyList(), null); + /** Serializes register/unregister so the host sees them in call order. */ + private CompletableFuture registrations = CompletableFuture.completedFuture(null); + private volatile Phase phase = Phase.IDLE; + private volatile Negotiated negotiated = Negotiated.NONE; + private volatile Map initializeParams; + private volatile OutputStream output; + private volatile PrintStream errorOutput = System.err; + private int exitCode = 1; + + private Backend(String name, String version, Map capabilities) { + Object experimental = capabilities.get("experimental"); + if (experimental instanceof Map + && (((Map) experimental).containsKey("litheBuild") + || ((Map) experimental).containsKey("litheFunctions") + || ((Map) experimental).containsKey("litheKeymapImport") + || ((Map) experimental).containsKey("litheLogging"))) { + throw new PluginSdkError("sdk.capabilities.reserved", + "litheBuild, litheFunctions, litheKeymapImport and litheLogging are negotiated by the SDK; declare handlers instead."); + } + this.name = name == null ? "lithe-plugin-backend" : name; + this.version = version; + this.capabilities = new LinkedHashMap<>(capabilities); + } + + /** + * Creates a backend. + * + * @param name reported as {@code serverInfo.name} and used as the log prefix + * @param version reported as {@code serverInfo.version}; may be null + */ + public static Backend create(String name, String version) { + return new Backend(name, version, Collections.emptyMap()); + } + + /** + * Creates a backend that also declares standard LSP server capabilities. + * {@code experimental.litheBuild} and {@code experimental.litheFunctions} + * are negotiated by the SDK and cannot be set here. + */ + public static Backend create(String name, String version, Map capabilities) { + return new Backend(name, version, capabilities == null ? Collections.emptyMap() : capabilities); + } + + /** Protocols the host agreed to in {@code initialize}; both false before it. */ + public Negotiated negotiated() { + return negotiated; + } + + /** + * Host services, usable once the session is ready. Prefer a handler's {@link CallContext#host()}, whose + * requests end with the call; this one belongs to no call, so its calls to + * other plugins name no parent call unless the call says one. + */ + public Host host() { + return host; + } + + /** The host's {@code initialize} params, or null before it arrives. */ + public Map initializeParams() { + return initializeParams; + } + + /** IDs of the functions this backend currently serves, sorted. */ + public List functionIds() { + synchronized (lock) { + List ids = new ArrayList<>(functions.keySet()); + Collections.sort(ids); + return ids; + } + } + + /** Answers {@code lithe/build/model} with a {@code litheBuildModel} document. */ + public Backend onModel(Handler handler) { + return declareBuild(Protocol.BUILD_MODEL, handler); + } + + /** Answers {@code lithe/build/dependencies} with a {@code litheDependencies} document. */ + public Backend onDependencies(Handler handler) { + return declareBuild(Protocol.BUILD_DEPENDENCIES, handler); + } + + /** Executes a bounded provider task. */ + public Backend onExecute(Handler handler) { + return declareBuild(Protocol.BUILD_EXECUTE, handler); + } + + /** Runs exact test selectors. */ + public Backend onTest(Handler handler) { + return declareBuild(Protocol.BUILD_TEST, handler); + } + + /** Returns the upstream engine's runnable test selectors, with the format stamped. */ + public Backend onDiscoverTests(Handler handler) { + return declareBuild(Protocol.BUILD_DISCOVER_TESTS, handler); + } + + /** Prepares native executable target facts. */ + public Backend onPrepareDebug(Handler handler) { + return declareBuild(Protocol.BUILD_PREPARE_DEBUG, handler); + } + + /** Prepares a portable application launch. */ + public Backend onPrepareRun(Handler handler) { + return declareBuild(Protocol.BUILD_PREPARE_RUN, handler); + } + + /** + * Declares a plugin function offered from the start, in the {@code initialize} + * answer. To add one later, use {@link #registerFunction}. + * + * @param id dot-separated lowerCamelCase segments, such as {@code snapshot.refresh} + */ + public Backend function(String id, Handler handler) { + synchronized (lock) { + if (!beforeAnswer()) { + throw new PluginSdkError("sdk.function.lateDeclaration", + "After initialize, add functions with registerFunction()."); + } + checkNewFunction(id, handler); + functions.put(id, handler); + } + return this; + } + + /** 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); + synchronized (lock) { + requestHandlers.put(method, handler); + } + return this; + } + + /** Handles a standard LSP notification. */ + public Backend onNotification(String method, NotificationHandler handler) { + checkDeclared(method, handler); + synchronized (lock) { + notificationHandlers.put(method, handler); + } + return this; + } + + /** + * Handles {@code lithe/host/commands/didRun} for one of the plugin's + * commands. It is an event: the host waits for no result. + * + * @param commandId the command's ID from the manifest, 1-128 of {@code [a-z0-9.-]} + */ + public Backend onCommand(String commandId, CommandHandler handler) { + if (!Protocol.isCommandId(commandId)) { + throw new PluginSdkError("sdk.command.invalidID", "Command ID \"" + commandId + "\" must be 1-128 of [a-z0-9.-]."); + } + if (handler == null) { + throw new IllegalArgumentException("A command handler must not be null."); + } + synchronized (lock) { + commandHandlers.put(commandId, handler); + } + return this; + } + + /** + * Handles {@code lithe/host/workspace/didChangeContexts} with + * {@code { revision }}; call {@code host().workspace().listContexts()} for + * the contexts. + */ + public Backend onContextsChanged(NotificationHandler handler) { + if (handler == null) { + throw new IllegalArgumentException("A handler must not be null."); + } + synchronized (lock) { + contextsHandler = handler; + } + return this; + } + + /** + * Handles a {@code lithe/host/*} notification a later host adds. Commands, + * context changes, and subscription events have their own APIs. + */ + public Backend onHostNotification(String method, NotificationHandler handler) { + if (method == null || !method.startsWith(Protocol.HOST_PREFIX)) { + throw new PluginSdkError("sdk.method.invalid", method + " is not a host notification."); + } + if (method.equals(Protocol.HOST_DID_CHANGE_CONTEXTS) || method.equals(Protocol.HOST_SUBSCRIPTION_NOTIFY) + || method.equals(Protocol.HOST_COMMAND_DID_RUN)) { + throw new PluginSdkError("sdk.method.reserved", + method + " is routed by the SDK; use onCommand, onContextsChanged, or Host.subscribe."); + } + if (handler == null) { + throw new IllegalArgumentException("A handler must not be null."); + } + synchronized (lock) { + hostNotificationHandlers.put(method, handler); + } + return this; + } + + /** Runs before the {@code initialize} answer, with the host's params and the negotiation. */ + public Backend onInitialize(InitializeHook hook) { + synchronized (lock) { + initializeHooks.add(hook); + } + return this; + } + + /** Runs on {@code shutdown}, after running calls were cancelled. */ + public Backend onShutdown(ShutdownHook hook) { + synchronized (lock) { + shutdownHooks.add(hook); + } + return this; + } + + /** + * Adds a function. Before the {@code initialize} answer (including from an + * {@link #onInitialize} hook) it joins the initial list; afterwards the host + * must accept it first. The future completes once the function is served. + */ + public CompletableFuture registerFunction(String id, Handler handler) { + synchronized (lock) { + if (beforeAnswer()) { + try { + checkNewFunction(id, handler); + functions.put(id, handler); + return CompletableFuture.completedFuture(null); + } catch (RuntimeException error) { + return failed(error); + } + } + } + return queueRegistration(() -> { + synchronized (lock) { + requireFunctions(); + checkNewFunction(id, handler); + // Installed before the request: the host may invoke it as soon as + // its registry changed, before this backend reads the reply. + functions.put(id, handler); + } + Map entry = new LinkedHashMap<>(); + entry.put("id", id); + Map params = new LinkedHashMap<>(); + params.put("functions", Collections.singletonList(entry)); + return request(Protocol.FUNCTION_REGISTER, params).handle((result, error) -> { + if (error != null) { + synchronized (lock) { + functions.remove(id); + quietSuccess.remove(id); + } + throw new CompletionException(unwrap(error)); + } + return null; + }); + }); + } + + /** Withdraws a function. The host ends its running calls as revoked. */ + public CompletableFuture unregisterFunction(String id) { + synchronized (lock) { + if (beforeAnswer()) { + if (functions.remove(id) == null) { + return failed(unknownFunction(id)); + } + quietSuccess.remove(id); + return CompletableFuture.completedFuture(null); + } + } + return queueRegistration(() -> { + synchronized (lock) { + requireFunctions(); + if (!functions.containsKey(id)) { + throw unknownFunction(id); + } + } + Map params = new LinkedHashMap<>(); + params.put("ids", Collections.singletonList(id)); + return request(Protocol.FUNCTION_UNREGISTER, params).thenApply(result -> { + List running = new ArrayList<>(); + synchronized (lock) { + functions.remove(id); + quietSuccess.remove(id); + for (Call call : incoming.values()) { + if (id.equals(call.functionId)) { + running.add(call); + } + } + } + for (Call call : running) { + call.cancellation.cancel("The function was withdrawn."); + } + return null; + }); + }); + } + + /** Sends a standard LSP request to the host. {@code lithe/*} methods are reserved. */ + public CompletableFuture sendRequest(String method, Object params) { + checkOutgoing(method); + return request(method, params); + } + + /** Sends a standard LSP notification to the host. {@code lithe/*} methods are reserved. */ + public void sendNotification(String method, Object params) { + checkOutgoing(method); + Map message = message(null, method); + if (params != null) { + message.put("params", params); + } + write(message); + } + + public Diagnostics diagnostics() { return new Diagnostics(this, null); } + + public Backend suppressSuccessDiagnostics(String operation) { + if (!loggingOperation(operation)) throw new PluginSdkError("sdk.logging.invalidOperation", "Suppress a registered operation."); + synchronized (lock) { quietSuccess.add(operation); } + return this; + } + + boolean loggingOperation(String operation) { + synchronized (lock) { return Diagnostics.OPERATIONS.contains(operation) || functions.containsKey(operation); } + } + + void logFact(Map params) { + synchronized (lock) { + if (!logging) return; + if (!loggingReady) { if (earlyDiagnostics.size() < 8) earlyDiagnostics.add(params); return; } + } + Map notification = message(null, "lithe/log"); + notification.put("params", params); + write(notification); + } + + /** + * Serves the host over stdio until it sends {@code exit} or closes stdin, + * then exits the process with the session's exit code: 0 after a + * {@code shutdown}, 1 otherwise. {@code System.out} is redirected to stderr + * first, because stdout carries the protocol and a stray line there would + * end the session. + */ + public void listen() { + OutputStream protocol = new FileOutputStream(FileDescriptor.out); + System.setOut(System.err); + int code = listen(System.in, protocol, System.err); + System.exit(code); + } + + /** + * Serves the host over the given streams and returns the exit code once the + * session ended, without exiting the process. Blocks the calling thread. + */ + public int listen(InputStream input, OutputStream output, PrintStream errorOutput) { + synchronized (lock) { + if (phase != Phase.IDLE) { + throw new PluginSdkError("sdk.listen.repeated", "A backend listens once."); + } + phase = Phase.LISTENING; + this.output = output; + this.errorOutput = errorOutput; + } + Framing.Decoder decoder = new Framing.Decoder(); + byte[] chunk = new byte[64 * 1024]; + try { + while (phase != Phase.EXITED) { + int count = input.read(chunk); + if (count < 0) { + finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); + break; + } + List messages; + try { + messages = decoder.push(chunk, 0, count); + } catch (Framing.FramingException error) { + diagnostics().terminal("notification", new IllegalStateException(), System.nanoTime()); + finish(1); + break; + } + for (Object message : messages) { + if (phase == Phase.EXITED) { + break; + } + dispatch(message); + } + } + } catch (IOException error) { + finish(1); + } + synchronized (lock) { + return exitCode; + } + } + + private void dispatch(Object message) { + if (message == Framing.MALFORMED) { + respond(null, null, error(Protocol.PARSE_ERROR, "The message is not valid JSON.")); + return; + } + // Valid JSON that is not a message object, such as `null` or a batch. + if (!(message instanceof Map)) { + respond(null, null, error(Protocol.INVALID_REQUEST, "The message is not a JSON-RPC object.")); + return; + } + Map object = Json.object(message); + Object id = object.get("id"); + Object method = object.get("method"); + if (!(method instanceof String)) { + if (id != null) { + settleOutgoing(object); + } + return; + } + if (id != null) { + handleRequest(id, (String) method, object.get("params")); + } else { + handleNotification((String) method, object.get("params")); + } + } + + private void handleRequest(Object id, String method, Object params) { + if (method.equals(Protocol.INITIALIZE)) { + initialize(id, params instanceof Map ? Json.object(params) : new LinkedHashMap<>()); + return; + } + if (method.equals(Protocol.SHUTDOWN)) { + shutdown(id); + return; + } + Phase current = phase; + if (current == Phase.LISTENING || current == Phase.INITIALIZING) { + respond(id, null, error(Protocol.SERVER_NOT_INITIALIZED, "The backend is not initialized.")); + return; + } + if (current != Phase.READY) { + respond(id, null, error(Protocol.INVALID_REQUEST, "The backend is shutting down.")); + return; + } + if (method.equals(Protocol.FUNCTION_INVOKE)) { + invokeFunction(id, params); + return; + } + if (method.equals("lithe/keymap/import")) { + invokeKeymap(id, params); + return; + } + Handler build; + RequestHandler handler; + synchronized (lock) { + build = negotiated.build() ? buildHandlers.get(method) : null; + handler = requestHandlers.get(method); + } + if (build != null) { + if (params != null && !(params instanceof Map)) { + respond(id, null, error(Protocol.INVALID_PARAMS, method + " params must be an object.")); + return; + } + Map arguments = Json.object(params); + invokeBuild(id, method, arguments, build); + return; + } + if (handler != null) { + run(id, null, null, context -> handler.handle(params, context), result -> result, null); + return; + } + respond(id, null, error(Protocol.METHOD_NOT_FOUND, "Unsupported method: " + method)); + } + + private void handleNotification(String method, Object params) { + if (method.equals(Protocol.EXIT)) { + finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); + return; + } + if (method.equals(Protocol.CANCEL_REQUEST)) { + Object id = params instanceof Map ? ((Map) params).get("id") : null; + Call call; + synchronized (lock) { + call = incoming.get(requestKey(id)); + } + if (call != null) { + call.cancellation.cancel("The request was cancelled."); + } + return; + } + if (phase != Phase.READY || method.equals(Protocol.INITIALIZED)) { + return; + } + if (method.startsWith(Protocol.HOST_PREFIX)) { + hostNotification(method, params); + return; + } + NotificationHandler handler; + synchronized (lock) { + handler = notificationHandlers.get(method); + } + if (handler == null) { + return; + } + notifyHandler(method, () -> handler.handle(params)); + } + + /** + * Routes a host notification. Core checked its shape and the session's + * grant; one this SDK does not know is ignored, as the contract asks. + */ + private void hostNotification(String method, Object raw) { + if (!negotiated.functions() || !(raw instanceof Map)) { + return; + } + Map params = Json.object(raw); + if (method.equals(Protocol.HOST_SUBSCRIPTION_NOTIFY)) { + channel.deliver(params); + return; + } + if (method.equals(Protocol.HOST_COMMAND_DID_RUN)) { + Object commandId = params.get("commandID"); + CommandHandler handler; + synchronized (lock) { + handler = commandId instanceof String ? commandHandlers.get(commandId) : null; + } + if (handler == null) { + diagnostics().degraded("notification", "command_unhandled"); + return; + } + Map context = workspaceContext(params.get("context")); + notifyHandler(method, () -> handler.handle((String) commandId, context)); + return; + } + NotificationHandler handler; + synchronized (lock) { + handler = method.equals(Protocol.HOST_DID_CHANGE_CONTEXTS) ? contextsHandler : hostNotificationHandlers.get(method); + } + if (handler != null) { + notifyHandler(method, () -> handler.handle(params)); + } + } + + private interface Notified { + void run() throws Exception; + } + + /** Runs a notification handler off the reader thread; a failure is logged. */ + private void notifyHandler(String method, Notified handler) { + executor.execute(() -> { + try { + handler.run(); + } catch (Throwable error) { + diagnostics().terminal("notification", error, System.nanoTime()); + } + }); + } + + private void initialize(Object id, Map params) { + Negotiated agreed; + List hooks; + synchronized (lock) { + if (phase != Phase.LISTENING) { + respond(id, null, error(Protocol.INVALID_REQUEST, "initialize was already received.")); + return; + } + phase = Phase.INITIALIZING; + initializeParams = params; + Map offered = experimental(params.get("capabilities")); + loggingReady = false; + earlyDiagnostics.clear(); + logging = offers(offered.get("litheLogging"), Protocol.LOGGING_VERSION); + if (!logging) { + phase = Phase.LISTENING; + respond(id, null, error(Protocol.INVALID_REQUEST, + "The backend requires litheLogging version " + Protocol.LOGGING_VERSION + ".")); + return; + } + agreed = new Negotiated( + offers(offered.get("litheFunctions"), Protocol.FUNCTIONS_VERSION), + !buildHandlers.isEmpty() && offers(offered.get("litheBuild"), Protocol.BUILD_VERSION)); + negotiated = agreed; + hooks = new ArrayList<>(initializeHooks); + } + executor.execute(() -> { + try { + for (InitializeHook hook : hooks) { + hook.run(params, agreed); + } + } catch (Throwable failure) { + synchronized (lock) { + if (phase == Phase.INITIALIZING) { + phase = Phase.LISTENING; + } + } + respond(id, null, toResponseError(failure, null)); + return; + } + synchronized (lock) { + // A shutdown or exit that arrived while a hook ran ended the + // session; finishing initialize must not reopen it. + if (phase != Phase.INITIALIZING) { + respond(id, null, error(Protocol.INVALID_REQUEST, "The session ended during initialize.")); + return; + } + if (!agreed.functions() && !functions.isEmpty()) { + diagnostics().degraded("initialize", "functions_unavailable"); + } + Map experimental = new LinkedHashMap<>(experimental(capabilities)); + experimental.put("litheLogging", singleton("version", Protocol.LOGGING_VERSION)); + Map offeredCapabilities = experimental(params.get("capabilities")); + Object keymap = offeredCapabilities.get("litheKeymapImport"); + if (offers(keymap, Protocol.KEYMAP_IMPORT_VERSION) && 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", Protocol.KEYMAP_IMPORT_VERSION); + entry.put("providers", new ArrayList<>(selectedKeymapProviders)); + experimental.put("litheKeymapImport", entry); + } + } + } + if (agreed.build()) { + experimental.put("litheBuild", singleton("version", Protocol.BUILD_VERSION)); + } + if (agreed.functions()) { + List offered = new ArrayList<>(); + for (String functionId : functionIds()) { + offered.add(singleton("id", functionId)); + } + Map entry = singleton("version", Protocol.FUNCTIONS_VERSION); + entry.put("functions", offered); + experimental.put("litheFunctions", entry); + } + Map answered = new LinkedHashMap<>(capabilities); + answered.put("experimental", experimental); + Map serverInfo = singleton("name", name); + if (version != null) { + serverInfo.put("version", version); + } + Map result = singleton("capabilities", answered); + result.put("serverInfo", serverInfo); + phase = Phase.READY; + respond(id, result, null); + loggingReady = true; + List> early = new ArrayList<>(earlyDiagnostics); + earlyDiagnostics.clear(); + for (Map fact : early) logFact(fact); + } + }); + } + + private void shutdown(Object id) { + List hooks; + synchronized (lock) { + if (phase != Phase.READY && phase != Phase.LISTENING && phase != Phase.INITIALIZING) { + respond(id, null, error(Protocol.INVALID_REQUEST, "shutdown was already received.")); + return; + } + phase = Phase.SHUTTING_DOWN; + hooks = new ArrayList<>(shutdownHooks); + } + cancelAll("The backend is shutting down."); + executor.execute(() -> { + for (ShutdownHook hook : hooks) { + try { + hook.run(); + } catch (Throwable error) { + diagnostics().terminal("shutdown", error, System.nanoTime()); + } + } + Processes.killAll(); + respond(id, null, null); + }); + } + + 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, null, context -> handler.handle(new KeymapSnapshot((String) provider, (String) snapshot, context.host()), context), + Backend::functionResult, null, null, "lithe/keymap/import", null, 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"); + Object operationId = invocation.get("operationID"); + Object params = invocation.get("params"); + Map workspaceContext = workspaceContext(invocation.get("context")); + Map caller = callerReference(invocation.get("caller")); + Handler handler; + synchronized (lock) { + handler = negotiated.functions() && functionId instanceof String ? functions.get(functionId) : null; + } + if (handler == null) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " is not registered.")); + return; + } + if (params != null && !(params instanceof Map)) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " params must be an object.")); + return; + } + String function = (String) functionId; + String operation = operationId instanceof String ? (String) operationId : null; + Map arguments = Json.object(params); + AtomicBoolean reporting = new AtomicBoolean(true); + CallContext.ProgressSink progress = (message, percentage) -> { + if (!reporting.get()) { + return; + } + Map report = new LinkedHashMap<>(); + report.put("operationID", operationId); + if (message != null) { + report.put("message", Protocol.boundedText(message)); + } + if (percentage != null && !percentage.isNaN() && !percentage.isInfinite()) { + report.put("percentage", Math.min(100L, Math.max(0L, Math.round(percentage)))); + } + Map notification = message(null, Protocol.FUNCTION_PROGRESS); + notification.put("params", report); + write(notification); + }; + // The call's wire operation ID is the parent of the calls it makes to other plugins. + String parentOperationId = Protocol.isParentOperationId(operation) ? operation : null; + run(id, function, operation, workspaceContext, context -> handler.handle(arguments, context), + Backend::functionResult, () -> reporting.set(false), progress, function, caller, parentOperationId); + } + + private void invokeBuild(Object id, String method, Map arguments, Handler handler) { + if (!Protocol.buildProgress(method)) { + run(id, null, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result), null, null, method, + null, null); + return; + } + Object wireId = arguments.get("operationID"); + if (!(wireId instanceof String)) { + respond(id, null, error(Protocol.INVALID_PARAMS, "A build request needs its wire operationID.")); + return; + } + AtomicBoolean reporting = new AtomicBoolean(true); + CallContext.ProgressSink progress = new CallContext.ProgressSink() { + @Override + public void report(String message, Double percentage) { report(message, percentage, null); } + @Override + public void report(String text, Double percentage, String output) { + if (!reporting.get()) return; + Map report = singleton("operationID", wireId); + if (text != null) report.put("message", Protocol.boundedText(text, Protocol.MAX_BUILD_MESSAGE_BYTES)); + if (output != null) report.put("output", Protocol.boundedText(output, Protocol.MAX_BUILD_OUTPUT_BYTES)); + if (percentage != null && !percentage.isNaN() && !percentage.isInfinite()) { + report.put("percentage", Math.min(100L, Math.max(0L, Math.round(percentage)))); + } + Map notification = message(null, Protocol.BUILD_PROGRESS); + notification.put("params", report); + write(notification); + } + }; + run(id, null, (String) wireId, null, context -> handler.handle(arguments, context), + result -> buildResult(method, result), () -> reporting.set(false), progress, method, null, null); + } + + private interface Invocation { + Object invoke(CallContext context) throws Exception; + } + + private interface Shape { + Object apply(Object result); + } + + private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, Runnable onSettled) { + run(id, functionId, operationId, null, invoke, shape, onSettled, null); + } + + /** Runs one handler with its own cancellation and answers exactly once. */ + private void run(Object id, String functionId, String operationId, Map workspaceContext, + Invocation invoke, Shape shape, Runnable onSettled, CallContext.ProgressSink progress) { + run(id, functionId, operationId, workspaceContext, invoke, shape, onSettled, progress, + functionId == null ? "function-call" : functionId, null, null); + } + + /** + * {@code caller} is the plugin that called a function through the relay and + * {@code parentOperationId} the wire operation ID calls to other plugins + * name as their parent; both are null for every call but a function call. + */ + private void run(Object id, String functionId, String operationId, Map workspaceContext, + Invocation invoke, Shape shape, Runnable onSettled, CallContext.ProgressSink progress, String diagnosticOperation, + Map caller, String parentOperationId) { + long started = System.nanoTime(); + String diagnosticOperationId = Diagnostics.requestOperationId(operationId, id); + Cancellation cancellation = new Cancellation(() -> diagnostics().degraded("notification", "cancellation_listener_failed")); + String key = requestKey(id); + synchronized (lock) { + incoming.put(key, new Call(cancellation, functionId)); + } + CallContext context = new CallContext(cancellation, this, functionId, diagnosticOperationId, progress, + new Host(channel, Collections.singletonList(cancellation), parentOperationId), workspaceContext, caller); + executor.execute(() -> { + Object result = null; + Map error = null; + try { + Object value = invoke.invoke(context); + cancellation.throwIfCancelled(); + result = shape.apply(value); + } catch (Throwable failure) { + if (!cancellation.isCancelled()) { + new Diagnostics(this, diagnosticOperationId).terminal(diagnosticOperation, failure, started); + } + error = toResponseError(failure, cancellation); + } + if (error == null && !cancellation.isCancelled()) { + boolean quiet; + synchronized (lock) { quiet = context.quietSuccess() || quietSuccess.contains(diagnosticOperation); } + if (!quiet) new Diagnostics(this, diagnosticOperationId).terminal(diagnosticOperation, null, started); + } + try { + if (onSettled != null) { + onSettled.run(); + } + } finally { + synchronized (lock) { + incoming.remove(key); + } + // Work belongs to its call: tools still running when the handler + // returned end now. Other calls have their own cancellation. + cancellation.cancel("The call ended."); + // Answered whatever the clean-up did, so Lithe never waits for + // its timeout. + respond(id, result, error); + } + }); + } + + private Backend declareBuild(String method, Handler handler) { + if (handler == null) { + throw new IllegalArgumentException("A build handler must not be null."); + } + synchronized (lock) { + if (phase != Phase.IDLE) { + throw new PluginSdkError("sdk.build.lateDeclaration", "Declare build handlers before listen()."); + } + buildHandlers.put(method, handler); + } + return this; + } + + private static void checkDeclared(String method, Object handler) { + if (method == null || Protocol.isReservedMethod(method)) { + throw new PluginSdkError("sdk.method.reserved", method + " is managed by the SDK and Lithe."); + } + if (handler == null) { + throw new IllegalArgumentException("A handler must not be null."); + } + } + + private void checkNewFunction(String id, Handler handler) { + if (!Protocol.isFunctionId(id)) { + throw new PluginSdkError("sdk.function.invalidID", "Function ID \"" + id + + "\" must be dot-separated lowerCamelCase segments of at most 128 bytes."); + } + if (handler == null) { + throw new IllegalArgumentException("A function handler must not be null."); + } + if (functions.containsKey(id)) { + throw new PluginSdkError("sdk.function.duplicate", "Function " + id + " is already declared."); + } + if (functions.size() >= Protocol.MAX_FUNCTIONS) { + throw new PluginSdkError("sdk.function.limit", + "A backend serves at most " + Protocol.MAX_FUNCTIONS + " functions."); + } + } + + /** True until the {@code initialize} answer fixes the initial function list. */ + private boolean beforeAnswer() { + return phase == Phase.IDLE || phase == Phase.LISTENING || phase == Phase.INITIALIZING; + } + + private void requireFunctions() { + if (phase != Phase.READY || !negotiated.functions()) { + throw new PluginSdkError("sdk.functions.unavailable", "The host has not negotiated plugin functions."); + } + } + + private static PluginSdkError unknownFunction(String id) { + return new PluginSdkError("sdk.function.unknown", "Function " + id + " is not registered."); + } + + private CompletableFuture queueRegistration(Supplier> change) { + synchronized (lock) { + CompletableFuture next = registrations.thenComposeAsync(ignored -> { + try { + return change.get(); + } catch (RuntimeException error) { + return failed(error); + } + }, executor); + registrations = next.handle((result, error) -> null); + return next; + } + } + + private void checkOutgoing(String method) { + if (method == null || Protocol.isReservedMethod(method)) { + throw new PluginSdkError("sdk.method.reserved", method + + " is reserved. A backend never asks the host to run a domain operation or a function."); + } + if (phase != Phase.READY) { + throw new PluginSdkError("sdk.session.notReady", "The session is not initialized."); + } + } + + private CompletableFuture request(String method, Object params) { + return send(method, params).response; + } + + /** Writes a request; params JSON cannot encode throw before anything is registered. */ + private HostChannel.Sent send(String method, Object params) { + String id = "sdk-" + nextRequestId.getAndIncrement(); + CompletableFuture future = new CompletableFuture<>(); + Map message = message(id, method); + if (params != null) { + message.put("params", params); + } + byte[] frame = Framing.encode(message); + synchronized (lock) { + if (phase == Phase.EXITED) { + future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); + return new HostChannel.Sent(id, future); + } + outgoing.put(id, new Pending(future, method)); + } + writeEncoded(frame); + return new HostChannel.Sent(id, future); + } + + private void settleOutgoing(Map message) { + Object id = message.get("id"); + Pending pending; + synchronized (lock) { + pending = id instanceof String ? outgoing.remove(id) : null; + } + if (pending == null) { + return; + } + Object error = message.get("error"); + // Completed on a handler thread: the reader thread never runs plugin code. + if (error instanceof Map) { + HostError rejection = HostError.fromResponse(pending.method, Json.object(error)); + executor.execute(() -> pending.future.completeExceptionally(rejection)); + } else { + Object result = message.get("result"); + executor.execute(() -> pending.future.complete(result)); + } + } + + private void cancelAll(String reason) { + List calls; + synchronized (lock) { + calls = new ArrayList<>(incoming.values()); + } + for (Call call : calls) { + call.cancellation.cancel(reason); + } + } + + private void finish(int code) { + List pending; + synchronized (lock) { + if (phase == Phase.EXITED) { + return; + } + phase = Phase.EXITED; + exitCode = code; + pending = new ArrayList<>(outgoing.values()); + outgoing.clear(); + } + channel.close(); + cancelAll("The session ended."); + Processes.killAll(); + for (Pending request : pending) { + request.future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); + } + // Running handlers finish on their own threads; nothing new starts. + executor.shutdown(); + } + + private void respond(Object id, Object result, Map error) { + if (phase == Phase.EXITED) { + return; + } + Map message = message(id, null); + if (error != null) { + message.put("error", error); + } else { + message.put("result", result); + } + try { + writeEncoded(Framing.encode(message)); + } catch (Json.JsonException failure) { + // A result JSON cannot encode must not end the session. + diagnostics().terminal("result-encoding", failure, System.nanoTime()); + Map fallback = message(id, null); + fallback.put("error", toResponseError(failure, null)); + write(fallback); + } + } + + private void write(Map message) { + writeEncoded(Framing.encode(message)); + } + + private void writeEncoded(byte[] frame) { + synchronized (writeLock) { + OutputStream stream = output; + if (stream == null || phase == Phase.EXITED) { + return; + } + try { + stream.write(frame); + stream.flush(); + } catch (IOException error) { + // The host closed the pipe; the input side ends the session. + // The closed protocol pipe leaves the host owning the terminal process fact. + } + } + } + + /** + * The JSON-RPC error object for a handler failure. Only {@link PluginError} + * carries a plugin code; any other exception becomes a generic internal + * error so that stack traces and machine paths never reach the host's UI. + */ + static Map toResponseError(Throwable failure, Cancellation cancellation) { + Throwable error = unwrap(failure); + if (cancellation != null && cancellation.isCancelled() && error instanceof CancelledException) { + return error(Protocol.REQUEST_CANCELLED, "The request was cancelled."); + } + if (error instanceof PluginError) { + Map response = error(Protocol.REQUEST_FAILED, Protocol.boundedText(error.getMessage())); + response.put("data", singleton("code", ((PluginError) error).code())); + return response; + } + // Unexpected exceptions often embed absolute paths or internals; the + // caller logs only a fixed failure category to stderr. + return error(Protocol.INTERNAL_ERROR, "The plugin backend failed unexpectedly."); + } + + /** + * Stamps the canonical format on a build result, so a handler can return the + * plain document. A result in another format is a backend bug: Core would + * refuse it as {@code plugin.build.invalidOutput}, so it fails here with a log. + */ + static Object buildResult(String method, Object result) { + String expected = BUILD_FORMATS.get(method); + if (!(result instanceof Map)) { + throw new PluginSdkError("sdk.build.invalidResult", method + " must return a reply object."); + } + Map document = new LinkedHashMap<>(Json.object(result)); + if (expected != null) { + Object format = document.get("format"); + if (format != null && !expected.equals(format)) { + throw new PluginSdkError("sdk.build.invalidResult", method + " returned another format."); + } + Object version = document.get("version"); + if (version != null && !(version instanceof Number && ((Number) version).doubleValue() == BUILD_DOCUMENT_VERSION)) { + throw new PluginSdkError("sdk.build.invalidResult", + method + " returned a version other than " + BUILD_DOCUMENT_VERSION + "."); + } + document.put("format", expected); + document.put("version", BUILD_DOCUMENT_VERSION); + } + if (Protocol.BUILD_EXECUTE.equals(method) || Protocol.BUILD_TEST.equals(method)) { + Object exit = document.get("exitCode"); + Object output = document.get("output"); + if (!(exit instanceof Number) || ((Number) exit).doubleValue() < Integer.MIN_VALUE + || ((Number) exit).doubleValue() > Integer.MAX_VALUE + || ((Number) exit).doubleValue() != ((Number) exit).longValue() + || !(output instanceof String) || Protocol.utf8Length((String) output) > Protocol.MAX_BUILD_RESULT_BYTES) { + throw new PluginSdkError("sdk.build.invalidResult", method + " needs an int32 exitCode and bounded output."); + } + document.put("exitCode", ((Number) exit).longValue()); + } + if (Protocol.BUILD_PREPARE_DEBUG.equals(method)) { + if (!(document.get("program") instanceof String) || !(document.get("workingDirectory") instanceof String) + || !stringList(document.get("arguments")) || !stringMap(document.get("environment")) + || document.size() != 4) { + throw new PluginSdkError("sdk.build.invalidResult", "Native debug preparation needs program, arguments, workingDirectory and environment only."); + } + } + if (Protocol.BUILD_PREPARE_RUN.equals(method)) { + Map executable = Json.object(document.get("executable")); + if (!("toolchain".equals(executable.get("kind")) || "command".equals(executable.get("kind"))) + || !(executable.get("value") instanceof String) || !(document.get("workingDirectory") instanceof String) + || !stringList(document.get("arguments")) || !stringMap(document.get("environment"))) { + throw new PluginSdkError("sdk.build.invalidResult", method + " needs a portable launch plan."); + } + } + // Workspace containment and domain facts remain Core/provider responsibilities. + if (Protocol.utf8Length(Json.stringify(document)) > Protocol.MAX_BUILD_RESULT_BYTES) { + throw new PluginSdkError("sdk.build.invalidResult", method + " exceeds the build-result size limit."); + } + return document; + } + + private static boolean stringList(Object value) { + if (!(value instanceof List)) return false; + for (Object item : (List) value) if (!(item instanceof String)) return false; + return true; + } + + private static boolean stringMap(Object value) { + if (!(value instanceof Map)) return false; + for (Map.Entry item : ((Map) value).entrySet()) { + if (!(item.getKey() instanceof String) || !(item.getValue() instanceof String)) return false; + } + return true; + } + + /** + * A function result as the JSON value Core accepts. A value JSON cannot + * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a + * log line, instead of breaking the response frame or being refused by Core. + */ + static Object functionResult(Object result) { + String encoded; + try { + encoded = Json.stringify(result); + } catch (Json.JsonException error) { + throw new PluginSdkError("sdk.function.invalidResult", "The result is not JSON: " + error.getMessage()); + } + if (encoded.getBytes(StandardCharsets.UTF_8).length > Protocol.MAX_RESULT_BYTES) { + throw new PluginSdkError("sdk.function.invalidResult", + "The result exceeds " + Protocol.MAX_RESULT_BYTES + " bytes."); + } + return result; + } + + private static boolean offers(Object entry, int version) { + if (!(entry instanceof Map)) { + return false; + } + Object versions = ((Map) entry).get("versions"); + if (!(versions instanceof List)) { + return false; + } + for (Object offered : (List) versions) { + if (offered instanceof Number && ((Number) offered).doubleValue() == version) { + return true; + } + } + return false; + } + + /** {@code capabilities.experimental}, or an empty map. */ + private static Map experimental(Object capabilities) { + Object experimental = capabilities instanceof Map ? ((Map) capabilities).get("experimental") : null; + return experimental instanceof Map ? Json.object(experimental) : Collections.emptyMap(); + } + + /** The JSON-RPC ID as a map key; numbers and strings never collide. */ + private static String requestKey(Object id) { + if (id instanceof Number) { + Number number = (Number) id; + return "number:" + (number.doubleValue() == number.longValue() ? Long.toString(number.longValue()) : number); + } + return (id instanceof String ? "string:" : "other:") + id; + } + + private static Map message(Object id, String method) { + Map message = new LinkedHashMap<>(); + message.put("jsonrpc", "2.0"); + if (id != null || method == null) { + message.put("id", id); + } + if (method != null) { + message.put("method", method); + } + return message; + } + + private static Map error(int code, String text) { + Map error = new LinkedHashMap<>(); + error.put("code", code); + error.put("message", text); + return error; + } + + private static Map singleton(String key, Object value) { + Map map = new LinkedHashMap<>(); + map.put(key, value); + return map; + } + + /** The failure inside {@code CompletableFuture}'s wrappers, from {@code join()} or {@code get()}. */ + static Throwable unwrap(Throwable error) { + Throwable current = error; + while ((current instanceof CompletionException || current instanceof ExecutionException) && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static CompletableFuture failed(Throwable error) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(error); + return future; + } + + /** The {@code { contextID, generation }} the host sent, or null. */ + private static Map workspaceContext(Object value) { + return Protocol.isContextReference(value) ? Host.contextReference(Json.object(value)) : null; + } + + /** + * The {@code { pluginID }} of the plugin that called a function through the + * relay, or null. Core adds it only to a relayed call, and only as exactly + * {@code { pluginID }}; anything else is not a caller. + */ + private Map callerReference(Object value) { + if (!negotiated.functions() || !(value instanceof Map)) { + return null; + } + Map caller = (Map) value; + Object pluginId = caller.get("pluginID"); + if (caller.size() != 1 || !(pluginId instanceof String) || ((String) pluginId).isEmpty()) { + return null; + } + return Collections.singletonMap("pluginID", pluginId); + } + + /** The session's wire, for host services. */ + private final class Wire implements HostChannel.Transport { + @Override + public boolean negotiated() { + return negotiated.functions(); + } + + @Override + public boolean ready() { + return phase == Phase.READY; + } + + @Override + public HostChannel.Sent send(String method, Object params) { + return Backend.this.send(method, params); + } + + @Override + public void notify(String method, Object params) { + Map notification = message(null, method); + notification.put("params", params); + write(notification); + } + + @Override + public void log(String message) { + Backend.this.diagnostics().degraded("notification", "subscription_queue_full"); + } + + @Override + public void log(String message, Throwable error) { + Backend.this.diagnostics().terminal("notification", error, System.nanoTime()); + } + + @Override + public void execute(Runnable task) { + executor.execute(task); + } + } + + /** One host request being handled. */ + private static final class Call { + final Cancellation cancellation; + final String functionId; + + Call(Cancellation cancellation, String functionId) { + this.cancellation = cancellation; + this.functionId = functionId; + } + } + + /** One request this backend sent to the host. */ + private static final class Pending { + final CompletableFuture future; + final String method; + + Pending(CompletableFuture future, String method) { + this.future = future; + this.method = method; + } + } + + /** Daemon threads, so a handler still running never keeps the process alive after exit. */ + private static final class ThreadNamer implements java.util.concurrent.ThreadFactory { + private final AtomicInteger next = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "lithe-sdk-call-" + next.getAndIncrement()); + thread.setDaemon(true); + return thread; + } + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java new file mode 100644 index 00000000..6eb8e012 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java @@ -0,0 +1,143 @@ +package io.github.litheidea.plugin.sdk; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * What a handler gets besides its params: the call's cancellation, tools and + * host requests that end with the call, the backend's log, and progress for + * plugin functions or bounded build operations. + */ +public final class CallContext { + /** Sends one progress report; absent for model, dependency and discovery requests. */ + interface ProgressSink { + void report(String message, Double percentage); + default void report(String message, Double percentage, String output) { report(message, percentage); } + } + + private final java.util.concurrent.atomic.AtomicBoolean quietSuccess = new java.util.concurrent.atomic.AtomicBoolean(); + private final Cancellation cancellation; + private final Backend backend; + private final String functionId; + private final String operationId; + private final ProgressSink progress; + private final Host host; + private final Map workspaceContext; + private final Map caller; + + CallContext(Cancellation cancellation, Backend backend, String functionId, String operationId, ProgressSink progress, + Host host, Map workspaceContext, Map caller) { + this.cancellation = cancellation; + this.backend = backend; + this.functionId = functionId; + this.operationId = operationId; + this.progress = progress; + this.host = host; + this.workspaceContext = workspaceContext; + this.caller = caller; + } + + /** + * Cancelled when the host cancels the call, the backend shuts down, or the + * call ends; work a handler leaves running after it returned is stopped. + */ + public Cancellation cancellation() { + return cancellation; + } + + public boolean isCancelled() { + return cancellation.isCancelled(); + } + + /** Throws {@link CancelledException} once the call was cancelled. */ + public void throwIfCancelled() { + cancellation.throwIfCancelled(); + } + + /** + * Runs a tool that belongs to this call: cancelling the call or ending it + * stops the tool, and the backend shutting down ends it. + */ + public Processes.Result run(List command, Processes.Options options) throws IOException { + Processes.Options settings = options == null ? new Processes.Options() : options.copy(); + settings.listenerFailure(() -> diagnostics().degraded("notification", "output_listener_failed")); + Cancellation own = settings.cancellation(); + settings.cancellation(own == null ? cancellation : cancellation.combinedWith(own)); + return Processes.run(command, settings); + } + + /** Runs a tool that belongs to this call with default options. */ + public Processes.Result run(List command) throws IOException { + return run(command, null); + } + + public void suppressSuccessDiagnostics() { quietSuccess.set(true); } + boolean quietSuccess() { return quietSuccess.get(); } + + public Diagnostics diagnostics() { return new Diagnostics(backend, operationId); } + + /** + * Reports progress of a function or bounded build call. Function messages + * are cut to 1024 bytes, build messages to 4096; percentages are rounded to + * 0-100. Reports after cancellation or completion are dropped. + */ + public void progress(String message, Double percentage) { progress(message, percentage, null); } + + /** Build calls may additionally stream up to 64 KiB of output per report. */ + public void progress(String message, Double percentage, String output) { + if (progress != null && !cancellation.isCancelled()) progress.report(message, percentage, output); + } + + /** The function this call runs, or null for a build or LSP request. */ + public String functionId() { + return functionId; + } + + /** + * The wire operation ID of a function or bounded build call, otherwise null. + * It identifies the call in diagnostics and, when the request has no usable + * operation ID, falls back to the JSON-RPC request ID: do not use it as a + * {@code parentOperationID}. {@link #host()} already sends the right one. + */ + public String operationId() { + return operationId; + } + + /** + * Host services that belong to this call: requests still unanswered when + * the call is cancelled or ends fail with {@link CancelledException} and + * are cancelled on the wire. A subscription created here outlives the call + * until it is disposed. + */ + public Host host() { + return host; + } + + /** + * The {@code { contextID, generation }} of the workspace a function was + * invoked for, or null: for other calls and for an invocation outside a + * workspace. Pass it to the + * {@link Host} methods that name a context. + */ + public Map workspaceContext() { + return workspaceContext; + } + + /** + * The plugin that called this function through the relay: an + * unmodifiable {@code { pluginID }}, written by Core from the calling + * session. Null when the host started the call, and for calls that are not + * function invocations. + * Decide by who asks from this, never from a plugin ID in the params, + * which any caller can write. + */ + public Map caller() { + return caller; + } + + /** The {@code pluginID} of {@link #caller()}, or null when there is no calling plugin. */ + public String callerPluginId() { + return caller == null ? null : (String) caller.get("pluginID"); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java new file mode 100644 index 00000000..d80cd7de --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java @@ -0,0 +1,112 @@ +package io.github.litheidea.plugin.sdk; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cancellation of one call, or of work a plugin manages itself. Listeners run + * once, on the thread that cancels; a listener added after cancellation runs + * immediately. A listener that throws is logged to stderr and does not keep + * the others, or the thread that cancelled, from running on. Cancelling never interrupts threads, because an interrupt + * closes NIO channels a handler may be writing state through; long work checks + * {@link #isCancelled()} or {@link #throwIfCancelled()} instead. + */ +public final class Cancellation { + private final Object lock = new Object(); + private final List listeners = new ArrayList<>(); + private String reason; + private final Runnable listenerFailure; + public Cancellation() { this(() -> {}); } + Cancellation(Runnable listenerFailure) { this.listenerFailure = listenerFailure; } + + /** Cancels with {@code reason}; later calls have no effect. */ + public void cancel(String reason) { + List pending; + synchronized (lock) { + if (this.reason != null) { + return; + } + this.reason = reason == null ? "The request was cancelled." : reason; + pending = new ArrayList<>(listeners); + listeners.clear(); + } + for (Runnable listener : pending) { + runIsolated(listener); + } + } + + public boolean isCancelled() { + synchronized (lock) { + return reason != null; + } + } + + /** Throws {@link CancelledException} once cancelled. */ + public void throwIfCancelled() { + String current; + synchronized (lock) { + current = reason; + } + if (current != null) { + throw new CancelledException(current); + } + } + + /** + * Runs {@code listener} on cancellation. The returned handle removes it, + * for work that finished first. + */ + public Registration onCancel(Runnable listener) { + synchronized (lock) { + if (reason == null) { + listeners.add(listener); + return () -> { + synchronized (lock) { + listeners.remove(listener); + } + }; + } + } + runIsolated(listener); + return () -> {}; + } + + /** + * A token cancelled when either this one or {@code other} is; for work that + * a plugin may also stop for its own reasons. + */ + public Cancellation combinedWith(Cancellation other) { + Cancellation combined = new Cancellation(listenerFailure); + Registration first = onCancel(() -> combined.cancel(reasonOrDefault())); + Registration second = other.onCancel(() -> combined.cancel(other.reasonOrDefault())); + combined.onCancel(() -> { + first.remove(); + second.remove(); + }); + return combined; + } + + private void runIsolated(Runnable listener) { + try { + listener.run(); + } catch (RuntimeException error) { + listenerFailure.run(); + } + } + + /** The reason it was cancelled with, or the default text before that. */ + String reason() { + return reasonOrDefault(); + } + + private String reasonOrDefault() { + synchronized (lock) { + return reason == null ? "The request was cancelled." : reason; + } + } + + /** Removes a cancellation listener. */ + public interface Registration { + void remove(); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java new file mode 100644 index 00000000..dd016767 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java @@ -0,0 +1,14 @@ +package io.github.litheidea.plugin.sdk; + +/** + * Thrown when the call a piece of work belongs to was cancelled or ended. + * A handler normally lets it propagate; the SDK then answers the host with + * {@code RequestCancelled}. + */ +public class CancelledException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public CancelledException(String message) { + super(message); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java new file mode 100644 index 00000000..c4d9bd4a --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Diagnostics.java @@ -0,0 +1,72 @@ +package io.github.litheidea.plugin.sdk; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Typed facts for the host's litheLogging 1 catalog; never accepts message or payload text. */ +public final class Diagnostics { + static final long MAX_INTEGER = 9007199254740991L; + static final Set OPERATIONS = new HashSet<>(Arrays.asList( + "initialize", "shutdown", "notification", "function-call", "result-encoding", + "lithe/build/model", "lithe/build/dependencies", "lithe/build/execute", "lithe/build/test", + "lithe/build/discoverTests", "lithe/build/prepareRun", "lithe/build/prepareDebug", + "lithe/vcs/status", "lithe/vcs/baseContent", "lithe/vcs/history", "lithe/vcs/fileHistory", + "lithe/vcs/annotate", "lithe/vcs/checkin", "lithe/vcs/rollback", "lithe/vcs/update", + "lithe/vcs/fileOperation", "lithe/vcs/resolve", "lithe/vcs/cleanup", + "lithe/keymap/import")); + private final Backend backend; + private final String operationId; + + Diagnostics(Backend backend, String operationId) { + this.backend = backend; + this.operationId = operationId; + } + + public void degraded(String operation, String code, Long count) { record("degraded", operation, code, count, null); } + public void recovered(String operation, String code, Long count) { record("recovered", operation, code, count, null); } + public void degraded(String operation, String code) { degraded(operation, code, null); } + public void recovered(String operation, String code) { recovered(operation, code, null); } + + static boolean token(String value, int maximum) { + return value != null && value.length() > 0 && value.length() <= maximum && value.matches("[A-Za-z0-9._:/\\-$]+"); + } + + static String requestOperationId(String operationId, Object requestId) { + if (token(operationId, 128)) return operationId; + String fallback = requestId instanceof String ? (String) requestId : null; + if (requestId instanceof Number) { + double number = ((Number) requestId).doubleValue(); + if (Double.isFinite(number) && Math.abs(number) <= MAX_INTEGER && number == Math.rint(number)) { + fallback = Long.toString(((Number) requestId).longValue()); + } + } + return token(fallback, 128) ? fallback : null; + } + + void terminal(String operation, Throwable failure, long started) { + String code = null; + if (failure != null) { + Throwable error = Backend.unwrap(failure); + code = error instanceof PluginError ? ((PluginError) error).code() : "internal_error"; + } + record(failure == null ? "operationFinished" : "operationFailed", operation, code, null, + Math.min(MAX_INTEGER, Math.max(0, (System.nanoTime() - started) / 1000000))); + } + + private void record(String kind, String operation, String code, Long count, Long duration) { + if (!backend.loggingOperation(operation)) throw new PluginSdkError("sdk.logging.invalidOperation", "Diagnostics need a registered operation."); + if (code != null && !token(code, 64)) throw new PluginSdkError("sdk.logging.invalidCode", "Diagnostics need a controlled code."); + if (count != null && (count < 0 || count > MAX_INTEGER)) throw new PluginSdkError("sdk.logging.invalidCount", "Diagnostics need a nonnegative safe count."); + Map params = new LinkedHashMap<>(); + params.put("kind", kind); + params.put("operation", operation.length() <= 64 ? operation : "function-call"); + if (code != null) params.put("code", code); + if (count != null) params.put("count", count); + if (duration != null) params.put("durationMs", duration); + if (token(operationId, 128)) params.put("operationId", operationId); + backend.logFact(params); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java new file mode 100644 index 00000000..36f76f63 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java @@ -0,0 +1,138 @@ +package io.github.litheidea.plugin.sdk; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * LSP base-protocol framing: {@code Content-Length} headers followed by a UTF-8 + * JSON body. Lithe talks to every plugin backend this way, so the SDK owns the + * framing and plugin code never writes to stdout itself. + */ +public final class Framing { + /** Largest header block accepted from the host. */ + static final int MAX_HEADER_BYTES = 8192; + + /** Largest body accepted from the host. Larger frames end the session. */ + public static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; + + /** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ + public static final Object MALFORMED = new Object() { + @Override + public String toString() { + return "MALFORMED"; + } + }; + + private static final byte[] HEADER_END = {'\r', '\n', '\r', '\n'}; + + private Framing() {} + + /** A framing error the session cannot recover from. */ + public static final class FramingException extends RuntimeException { + private static final long serialVersionUID = 1L; + + FramingException(String message) { + super(message); + } + } + + /** Encodes one JSON-RPC message as a complete frame. */ + public static byte[] encode(Object message) { + byte[] body = Json.stringify(message).getBytes(StandardCharsets.UTF_8); + byte[] header = ("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII); + byte[] frame = Arrays.copyOf(header, header.length + body.length); + System.arraycopy(body, 0, frame, header.length, body.length); + return frame; + } + + /** Incrementally splits a byte stream into decoded JSON-RPC messages. */ + public static final class Decoder { + private byte[] buffer = new byte[8192]; + private int length; + + /** + * Appends {@code count} bytes of {@code chunk} and returns every complete + * message they finished. A body that is not JSON is returned as + * {@link #MALFORMED} so the caller can report it without losing the + * framing position. + */ + public List push(byte[] chunk, int offset, int count) { + ensureCapacity(length + count); + System.arraycopy(chunk, offset, buffer, length, count); + length += count; + List messages = new ArrayList<>(); + int start = 0; + for (;;) { + int headerEnd = indexOfHeaderEnd(start); + int available = length - start; + // The same bound whether the header arrived whole or in pieces. + if ((headerEnd >= 0 && headerEnd - start > MAX_HEADER_BYTES) + || (headerEnd < 0 && available > MAX_HEADER_BYTES)) { + throw new FramingException("The message header is too long."); + } + if (headerEnd < 0) { + break; + } + int bodyLength = contentLength(new String(buffer, start, headerEnd - start, StandardCharsets.US_ASCII)); + int bodyStart = headerEnd + HEADER_END.length; + if ((long) length < (long) bodyStart + bodyLength) { + break; + } + String body = new String(buffer, bodyStart, bodyLength, StandardCharsets.UTF_8); + start = bodyStart + bodyLength; + try { + messages.add(Json.parse(body)); + } catch (Json.JsonException error) { + messages.add(MALFORMED); + } + } + if (start > 0) { + System.arraycopy(buffer, start, buffer, 0, length - start); + length -= start; + } + return messages; + } + + private int indexOfHeaderEnd(int from) { + for (int index = from; index + HEADER_END.length <= length; index++) { + if (buffer[index] == '\r' && buffer[index + 1] == '\n' + && buffer[index + 2] == '\r' && buffer[index + 3] == '\n') { + return index; + } + } + return -1; + } + + private void ensureCapacity(int required) { + if (required > buffer.length) { + buffer = Arrays.copyOf(buffer, Math.max(required, buffer.length * 2)); + } + } + } + + private static int contentLength(String header) { + for (String line : header.split("\r\n")) { + int separator = line.indexOf(':'); + if (separator < 0) { + continue; + } + if (!line.substring(0, separator).trim().equalsIgnoreCase("content-length")) { + continue; + } + String value = line.substring(separator + 1).trim(); + if (!value.matches("[0-9]+")) { + break; + } + // More than ten digits exceeds the limit whatever they are. + long length = value.length() > 10 ? Long.MAX_VALUE : Long.parseLong(value); + if (length > MAX_FRAME_BYTES) { + throw new FramingException("A message of " + value + " bytes exceeds the " + + MAX_FRAME_BYTES + "-byte limit."); + } + return (int) length; + } + throw new FramingException("A message has no valid Content-Length header."); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java new file mode 100644 index 00000000..fb0e9887 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java @@ -0,0 +1,637 @@ +package io.github.litheidea.plugin.sdk; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.BooleanSupplier; + +/** + * Host services ({@code lithe/host/*}): credentials; workspace contexts, + * files, documents, the editor, previews, the browser, messages, and + * capability discovery; calls to other plugins' functions. Every method + * answers with a future and never blocks; failures the host reports arrive as + * {@link HostError}. + * + *

{@link Backend#host()} is usable once the session is ready. A handler's + * {@link CallContext#host()} belongs to its call: requests still unanswered + * when the call is cancelled or ends fail with {@link CancelledException} and + * are cancelled on the wire. At most 16 requests are on the wire at once; the + * rest wait their turn. + * + *

Contract: {@code contract/v1/README.md}, "Host services". + */ +public final class Host { + /** Receives a subscription's events, one at a time and in order. */ + @FunctionalInterface + public interface Listener { + void onEvent(Event event) throws Exception; + } + + /** One {@code lithe/host/subscriptions/notify}. */ + public static final class Event { + private final String subscriptionId; + private final long sequence; + private final boolean gap; + private final Object event; + + Event(String subscriptionId, long sequence, boolean gap, Object event) { + this.subscriptionId = subscriptionId; + this.sequence = sequence; + this.gap = gap; + this.event = event; + } + + public String subscriptionId() { + return subscriptionId; + } + + /** Starts at 1 and grows by one per event of the subscription. */ + public long sequence() { + return sequence; + } + + /** + * True when events before this one were lost, so state built from + * earlier events should be refreshed, for example by rescanning. + */ + public boolean gap() { + return gap; + } + + /** The event, usually an object. */ + public Object event() { + return event; + } + } + + /** A host subscription, such as a file watch or registered editor actions. */ + public static final class Subscription { + private final String id; + private final BooleanSupplier live; + private final HostChannel channel; + + Subscription(String id, BooleanSupplier live, HostChannel channel) { + this.id = id; + this.live = live; + this.channel = channel; + } + + public String id() { + return id; + } + + /** True once disposed here or the session ended. */ + public boolean isDisposed() { + return !live.getAsBoolean(); + } + + /** Stops routing events at once and asks the host to dispose the subscription. */ + public CompletableFuture dispose() { + return channel.dispose(id); + } + } + + private final HostChannel channel; + private final List scope; + /** The wire operation ID of the function call this Host belongs to, or null; the default parent of {@link Functions#invoke}. */ + private final String parentOperationId; + private final Credentials credentials = new Credentials(); + private final Subscriptions subscriptions = new Subscriptions(); + private final Workspace workspace = new Workspace(); + private final Documents documents = new Documents(); + private final Editor editor = new Editor(); + private final Preview preview = new Preview(); + private final Browser browser = new Browser(); + private final Window window = new Window(); + private final Functions functions = new Functions(); + + Host(HostChannel channel, List scope, String parentOperationId) { + this.channel = channel; + this.scope = Collections.unmodifiableList(new ArrayList<>(scope)); + this.parentOperationId = parentOperationId; + } + + /** Whether the host offered plugin functions ({@code litheFunctions}); false before {@code initialize}. */ + public boolean negotiated() { + return channel.negotiated(); + } + + /** + * The same services, with requests also cancelled by {@code cancellation}, + * for work a plugin may stop for its own reasons. Calls to other plugins + * keep the parent call of this Host. + */ + public Host withCancellation(Cancellation cancellation) { + if (cancellation == null) { + throw new IllegalArgumentException("A cancellation must not be null."); + } + List combined = new ArrayList<>(scope); + combined.add(cancellation); + return new Host(channel, combined, parentOperationId); + } + + /** + * Sends any {@code lithe/host/*} request, including methods a later host + * adds; {@link #supports} says whether the session may call it. + */ + public CompletableFuture request(String method, Object params) { + return channel.request(method, params, scope); + } + + /** + * Sends a request that answers {@code { subscriptionID }} and routes the + * subscription's events to {@code listener}. + */ + public CompletableFuture subscribe(String method, Object params, Listener listener) { + return channel.subscribe(method, params, listener, scope); + } + + /** + * {@code { protocolVersion, backendAuthority, services, limits }}, asked + * once per session. + */ + public CompletableFuture> capabilities() { + return channel.capabilities(); + } + + /** Whether this session may call {@code method}, as {@link #capabilities} lists it. */ + public CompletableFuture supports(String method) { + return channel.supports(method); + } + + public Credentials credentials() { + return credentials; + } + + public Subscriptions subscriptions() { + return subscriptions; + } + + public Workspace workspace() { + return workspace; + } + + public Documents documents() { + return documents; + } + + public Editor editor() { + return editor; + } + + public Preview preview() { + return preview; + } + + public Browser browser() { + return browser; + } + + public Terminal terminal() { + return new Terminal(); + } + + public Window window() { + return window; + } + + /** Calls to functions other plugins export. */ + public Functions functions() { + return functions; + } + + /** + * {@code { contextID, generation }} of a context reference or of a full + * context from {@code listContexts} or {@code getContext}, so either can be + * passed on. + * + * @throws PluginSdkError {@code sdk.host.invalidContext} for anything else + */ + public static Map contextReference(Map context) { + Object id = context == null ? null : context.get("contextID"); + Object generation = context == null ? null : context.get("generation"); + if (!(id instanceof String) || !Protocol.isCredentialKey((String) id) || !Protocol.isGeneration(generation)) { + throw new PluginSdkError("sdk.host.invalidContext", + "A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, \".\", \"_\", \":\" or \"-\", and a positive generation."); + } + Map reference = new LinkedHashMap<>(); + reference.put("contextID", id); + reference.put("generation", ((Number) generation).longValue()); + return Collections.unmodifiableMap(reference); + } + + /** Secrets in the operating system's credential store, scoped to the plugin. */ + public final class Credentials { + private Credentials() {} + + /** The stored secret, or null when nothing is stored under {@code key}. */ + public CompletableFuture get(String key) { + if (!Protocol.isCredentialKey(key)) { + return failed(invalidKey()); + } + return channel.request(Protocol.HOST_CREDENTIALS_GET, params("key", key), scope).thenApply(result -> { + if (result == null) { + return null; + } + Object secret = result instanceof Map ? ((Map) result).get("secret") : null; + if (!(secret instanceof String)) { + throw new PluginSdkError("sdk.host.invalidResult", "The host answered credentials/get without a secret."); + } + return (String) secret; + }); + } + + public CompletableFuture set(String key, String secret) { + if (!Protocol.isCredentialKey(key)) { + return failed(invalidKey()); + } + if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length > Protocol.MAX_CREDENTIAL_SECRET_BYTES) { + return failed(new PluginSdkError("sdk.credentials.invalidSecret", "A secret is a string of at most 16 KiB.")); + } + Map params = params("key", key); + params.put("secret", secret); + return channel.request(Protocol.HOST_CREDENTIALS_SET, params, scope).thenApply(result -> null); + } + + /** Deletes the secret; also succeeds when nothing was stored. */ + public CompletableFuture delete(String key) { + if (!Protocol.isCredentialKey(key)) { + return failed(invalidKey()); + } + return channel.request(Protocol.HOST_CREDENTIALS_DELETE, params("key", key), scope).thenApply(result -> null); + } + } + + public final class Subscriptions { + private Subscriptions() {} + + /** Disposes a subscription by ID; also succeeds for an unknown or disposed one. */ + public CompletableFuture dispose(String subscriptionId) { + return channel.dispose(subscriptionId); + } + } + + /** + * Workspace contexts and files. Every method but + * {@link #listContexts} names a context: a reference from + * {@link CallContext#workspaceContext()}, or a context from + * {@link #listContexts}. + */ + public final class Workspace { + private Workspace() {} + + /** {@code { revision, contexts }}. */ + public CompletableFuture> listContexts() { + return object(Protocol.HOST_WORKSPACE_LIST_CONTEXTS, channel.request( + Protocol.HOST_WORKSPACE_LIST_CONTEXTS, new LinkedHashMap(), scope)); + } + + public CompletableFuture> getContext(Map context) { + return scoped(Protocol.HOST_WORKSPACE_GET_CONTEXT, context, null); + } + + /** {@code { folders }}. */ + public CompletableFuture> getFolders(Map context) { + return scoped(Protocol.HOST_WORKSPACE_GET_FOLDERS, context, null); + } + + /** {@code params}: {@code resource, cursor?, limit?}. */ + public CompletableFuture> readDirectory(Map context, Map params) { + return scoped(Protocol.HOST_WORKSPACE_READ_DIRECTORY, context, params); + } + + /** {@code params}: {@code resource}. */ + public CompletableFuture> stat(Map context, Map params) { + return scoped(Protocol.HOST_WORKSPACE_STAT, context, params); + } + + /** {@code params}: {@code resource, maximumBytes?}. */ + public CompletableFuture> readFile(Map context, Map params) { + return scoped(Protocol.HOST_WORKSPACE_READ_FILE, context, params); + } + + /** + * Watches {@code resource, recursive?}; events are + * {@code { rootID, changedPaths, requiresRescan }}. + */ + public CompletableFuture watch(Map context, Map params, Listener listener) { + Map message; + try { + message = withContext(context, params); + } catch (PluginSdkError error) { + return failed(error); + } + return channel.subscribe(Protocol.HOST_WORKSPACE_WATCH, message, listener, scope); + } + + /** {@code params}: root {@code resource, query, fileMask?, cursor?, limit?}. */ + public CompletableFuture> findFiles(Map context, Map params) { + return scoped(Protocol.HOST_WORKSPACE_FIND_FILES, context, params); + } + + /** {@code params}: root {@code resource, pattern}, and optional search options. */ + public CompletableFuture> findText(Map context, Map params) { + return scoped(Protocol.HOST_WORKSPACE_FIND_TEXT, context, params); + } + } + + /** Open documents, read from their live buffers. */ + public final class Documents { + private Documents() {} + + /** {@code { documents }}. */ + public CompletableFuture> list(Map context) { + return scoped(Protocol.HOST_DOCUMENTS_LIST, context, null); + } + + /** {@code params}: {@code documentID}; answers {@code { document, text }}. */ + public CompletableFuture> getSnapshot(Map context, Map params) { + return scoped(Protocol.HOST_DOCUMENTS_GET_SNAPSHOT, context, params); + } + } + + /** The text editor. */ + public final class Editor { + private Editor() {} + + /** The foreground text tab's document summary, or null. */ + public CompletableFuture> getActive(Map context) { + return scoped(Protocol.HOST_EDITOR_GET_ACTIVE, context, null); + } + + /** {@code params}: {@code documentID}. */ + public CompletableFuture> getSelections(Map context, Map params) { + return scoped(Protocol.HOST_EDITOR_GET_SELECTIONS, context, params); + } + + /** {@code params}: {@code documentID, expectedVersion, edits}. */ + public CompletableFuture> applyEdits(Map context, Map params) { + return scoped(Protocol.HOST_EDITOR_APPLY_EDITS, context, params); + } + + /** {@code params}: {@code documentID, expectedVersion, selections, replacements}. */ + public CompletableFuture> replaceSelections(Map context, Map params) { + return scoped(Protocol.HOST_EDITOR_REPLACE_SELECTIONS, context, params); + } + + /** {@code params}: {@code documentID, expectedVersion, at, text}. */ + public CompletableFuture> insertText(Map context, Map params) { + return scoped(Protocol.HOST_EDITOR_INSERT_TEXT, context, params); + } + + /** {@code params}: {@code resource, activation?, range?, tab?}. */ + public CompletableFuture> open(Map context, Map params) { + return scoped(Protocol.HOST_EDITOR_OPEN, context, params); + } + + /** {@code params}: {@code resource, activation?, range?, tab?}. */ + public CompletableFuture> reveal(Map context, Map params) { + return scoped(Protocol.HOST_EDITOR_REVEAL, context, params); + } + + /** + * Registers editor actions ({@code id, function, selector}) that invoke + * one of the backend's functions; disposing the subscription removes them. + */ + public CompletableFuture registerActions(Map context, Map params) { + Map message; + try { + message = withContext(context, params); + } catch (PluginSdkError error) { + return failed(error); + } + return channel.subscribe(Protocol.HOST_EDITOR_REGISTER_ACTIONS, message, null, scope); + } + } + + /** HTML previews. */ + public final class Preview { + private Preview() {} + + /** {@code params}: HTML {@code resource, presentation?, range?}. */ + public CompletableFuture> openHtml(Map context, Map params) { + return scoped(Protocol.HOST_PREVIEW_OPEN_HTML, context, params); + } + } + + /** The system browser. */ + public final class Browser { + private Browser() {} + + /** {@code params}: exactly one of an HTTP(S) {@code url} or an HTML {@code resource}. */ + public CompletableFuture> openExternal(Map context, Map params) { + return scoped(Protocol.HOST_BROWSER_OPEN_EXTERNAL, context, params); + } + } + + /** Native terminals private to the backend session and workspace. */ + public final class Terminal { + private Terminal() {} + /** Params: executable, arguments?, title?, cwd?, environment?, show?. */ + public CompletableFuture> create(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_CREATE, context, params); + } + public CompletableFuture> show(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_SHOW, context, params); + } + public CompletableFuture> close(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_CLOSE, context, params); + } + /** Restarts the saved argv, restoring only the chosen plain display suffix. */ + public CompletableFuture> reopen(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_REOPEN, context, params); + } + /** Forgets the handle and its retained display history. */ + public CompletableFuture> dispose(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_DISPOSE, context, params); + } + /** Params: terminalID; disposing the subscription does not close the terminal. */ + public CompletableFuture onExit(Map context, Map params) { + Map message; + try { message = withContext(context, params); } + catch (PluginSdkError error) { return failed(error); } + return channel.subscribe(Protocol.HOST_TERMINAL_ON_EXIT, message, null, scope); + } + } + + /** + * Messages in the window. {@code params}: {@code text, + * actions?}; answers {@code { selectedIndex }}, null on dismissal. + */ + public final class Window { + private Window() {} + + public CompletableFuture> showInformation(Map context, Map params) { + return scoped(Protocol.HOST_WINDOW_SHOW_INFORMATION, context, params); + } + + public CompletableFuture> showWarning(Map context, Map params) { + return scoped(Protocol.HOST_WINDOW_SHOW_WARNING, context, params); + } + + public CompletableFuture> showError(Map context, Map params) { + return scoped(Protocol.HOST_WINDOW_SHOW_ERROR, context, params); + } + } + + /** + * Calls to functions other plugins export, relayed by Core. The plugin's + * manifest must declare each function it calls. Both methods fail with a + * {@link HostError} {@code plugin.host.unavailable} when the host did not + * offer plugin functions, without sending anything, and a malformed + * argument fails with {@code sdk.functions.invalidCall} (a malformed + * context with {@code sdk.host.invalidContext}), also without sending + * anything. + * + *

A provider's own failure arrives as a {@link HostError} with code + * {@code plugin.function.failed}; {@link HostError#pluginErrorCode()} is + * its error code. Other relay failures have their own + * {@code plugin.function.*} codes, see {@link HostError#CODES}. + */ + public final class Functions { + private Functions() {} + + /** + * {@code { providers: [{ pluginID, version }] }}: the enabled plugins + * other than this one that export {@code functionId}, sorted by plugin + * ID. A listed provider's backend need not be running. + */ + public CompletableFuture> providers(String functionId) { + if (!Protocol.isFunctionId(functionId)) { + return failed(invalidCall("A functionID is dot-separated lowerCamelCase segments of at most 128 bytes.")); + } + return object(Protocol.HOST_FUNCTIONS_PROVIDERS, channel.request( + Protocol.HOST_FUNCTIONS_PROVIDERS, params("functionID", functionId), scope)); + } + + /** + * Calls one provider's exported function in the workspace {@code context} + * (a reference from {@link CallContext#workspaceContext()}, or a context + * from {@code workspace().listContexts()}) and answers {@code { value }}, + * the provider's result, where {@code value} may be null. Only the + * fields listed here are sent. {@code call}: + *

    + *
  • {@code pluginID}: the provider, a non-empty string; required;
  • + *
  • {@code functionID}: the exported function; required;
  • + *
  • {@code params}: an object, omitted when absent;
  • + *
  • {@code timeoutMilliseconds}: a positive integer; Core defaults to + * 60 000 and caps it at 600 000;
  • + *
  • {@code parentOperationID}: a string of 1-128 bytes. When absent + * and this Host belongs to a function call, that call's wire operation + * ID is sent, so Core knows the new call belongs to it; otherwise the + * field is omitted and Core counts the new call as a child of every + * relayed call this backend is serving.
  • + *
+ * When the call this Host belongs to is cancelled or ends, the relayed + * call is cancelled on the wire and the future fails with + * {@link CancelledException}. + */ + public CompletableFuture> invoke(Map context, Map call) { + Map message; + try { + message = relayParams(context, call); + } catch (PluginSdkError error) { + return failed(error); + } + return object(Protocol.HOST_FUNCTIONS_INVOKE, channel.request(Protocol.HOST_FUNCTIONS_INVOKE, message, scope)); + } + } + + /** The wire params of a relayed call: the listed fields of {@code call}, never another one. */ + private Map relayParams(Map context, Map call) { + if (call == null) { + throw invalidCall("A call is { pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }."); + } + Object pluginId = call.get("pluginID"); + if (!(pluginId instanceof String) || ((String) pluginId).isEmpty()) { + throw invalidCall("A call's pluginID is a non-empty string."); + } + Object functionId = call.get("functionID"); + if (!(functionId instanceof String) || !Protocol.isFunctionId((String) functionId)) { + throw invalidCall("A functionID is dot-separated lowerCamelCase segments of at most 128 bytes."); + } + Map message = new LinkedHashMap<>(); + message.put("pluginID", pluginId); + message.put("functionID", functionId); + message.put("context", contextReference(context)); + Object params = call.get("params"); + if (params != null) { + if (!(params instanceof Map)) { + throw invalidCall("A call's params is an object."); + } + message.put("params", params); + } + Object timeout = call.get("timeoutMilliseconds"); + if (timeout != null) { + if (!Protocol.isGeneration(timeout)) { + throw invalidCall("A call's timeoutMilliseconds is a positive integer."); + } + message.put("timeoutMilliseconds", ((Number) timeout).longValue()); + } + Object parent = call.get("parentOperationID"); + if (parent == null) { + parent = parentOperationId; + } else if (!Protocol.isParentOperationId(parent)) { + throw invalidCall("A call's parentOperationID is a string of 1-128 bytes."); + } + if (parent != null) { + message.put("parentOperationID", parent); + } + return message; + } + + private static PluginSdkError invalidCall(String message) { + return new PluginSdkError("sdk.functions.invalidCall", message); + } + + /** A method whose params name a workspace context. */ + private CompletableFuture> scoped(String method, Map context, Map params) { + Map message; + try { + message = withContext(context, params); + } catch (PluginSdkError error) { + return failed(error); + } + return object(method, channel.request(method, message, scope)); + } + + private static Map withContext(Map context, Map params) { + Map message = params == null ? new LinkedHashMap() : new LinkedHashMap(params); + message.put("context", contextReference(context)); + return message; + } + + /** The result as an object, or null; anything else is a host bug. */ + private static CompletableFuture> object(String method, CompletableFuture response) { + return response.thenApply(result -> { + if (result == null) { + return null; + } + if (!(result instanceof Map)) { + throw new PluginSdkError("sdk.host.invalidResult", method + " answered with something other than an object."); + } + return Json.object(result); + }); + } + + private static Map params(String key, Object value) { + Map params = new LinkedHashMap<>(); + params.put(key, value); + return params; + } + + private static PluginSdkError invalidKey() { + return new PluginSdkError("sdk.credentials.invalidKey", + "A credential key is 1-128 ASCII letters, digits, \".\", \"_\", \":\" or \"-\"."); + } + + private static CompletableFuture failed(Throwable error) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(error); + return future; + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java new file mode 100644 index 00000000..995e7e4c --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java @@ -0,0 +1,495 @@ +package io.github.litheidea.plugin.sdk; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.RejectedExecutionException; + +/** + * The session's host-request state. The backend owns the wire; this class + * owns what Core expects of a well-behaved client -- at most 16 unanswered + * host requests, cancellation through {@code $/cancelRequest}, and + * subscription events routed by ID, in order, off the reader thread. + * + *

Contract: {@code contract/v1/README.md}, "Host services". + */ +final class HostChannel { + /** Events kept for a subscription whose creating request is still unanswered. */ + static final int MAX_EARLY_EVENTS = 128; + + /** The backend's wire. */ + interface Transport { + /** Whether the host offered plugin functions ({@code litheFunctions}); false before {@code initialize}. */ + boolean negotiated(); + + boolean ready(); + + /** Writes a request; its response settles when the host answers or the session ends. */ + Sent send(String method, Object params); + + void notify(String method, Object params); + + void log(String message); + + void log(String message, Throwable error); + + /** Runs plugin code off the reader thread. */ + void execute(Runnable task); + } + + /** A request on the wire. */ + static final class Sent { + final String id; + final CompletableFuture response; + + Sent(String id, CompletableFuture response) { + this.id = id; + this.response = response; + } + } + + private enum State { QUEUED, SENDING, SENT } + + /** One host request; settles for its caller once. */ + private static final class Entry { + final String method; + final Object params; + final CompletableFuture future = new CompletableFuture<>(); + final List registrations = new ArrayList<>(); + State state = State.QUEUED; + boolean settled; + /** Cancelled while being written: {@code $/cancelRequest} follows the write. */ + boolean cancelAfterWrite; + String id; + + Entry(String method, Object params) { + this.method = method; + this.params = params; + } + } + + /** A live subscription. */ + private static final class Route { + final String id; + final Host.Listener listener; + final ArrayDeque pending = new ArrayDeque<>(); + volatile boolean live = true; + long lastSequence; + boolean draining; + + Route(String id, Host.Listener listener) { + this.id = id; + this.listener = listener; + } + + boolean isLive() { + return live; + } + } + + private static final class Early { + final String subscriptionId; + final long sequence; + final Object event; + + Early(String subscriptionId, long sequence, Object event) { + this.subscriptionId = subscriptionId; + this.sequence = sequence; + this.event = event; + } + } + + private final Transport transport; + private final Object lock = new Object(); + private final ArrayDeque queue = new ArrayDeque<>(); + private final Map routes = new HashMap<>(); + private final List early = new ArrayList<>(); + private int inFlight; + /** Subscribing requests not yet answered; early events wait only while there are some. */ + private int subscribing; + private CompletableFuture> capabilities; + private boolean closed; + + HostChannel(Transport transport) { + this.transport = transport; + } + + boolean negotiated() { + return transport.negotiated(); + } + + /** + * Sends {@code method} once a slot is free. Cancelling any of + * {@code scope} rejects the caller at once with {@link CancelledException}; + * a request already sent is cancelled on the wire, and never sent again, + * because a cancelled mutation may already have taken effect. + */ + CompletableFuture request(String method, Object params, List scope) { + if (method == null || !method.startsWith(Protocol.HOST_PREFIX)) { + return failed(new PluginSdkError("sdk.host.method", + method + " is not a host method; host requests are lithe/host/*.")); + } + if (!open()) { + return failed(notReady()); + } + if (!transport.negotiated()) { + // The answer Core would give, without the round trip. + return failed(unavailable(method, + "The host did not negotiate plugin functions (litheFunctions), which offer host services.")); + } + for (Cancellation cancellation : scope) { + try { + cancellation.throwIfCancelled(); + } catch (CancelledException error) { + return failed(error); + } + } + Entry entry = new Entry(method, params); + synchronized (lock) { + if (closed || !transport.ready()) { + return failed(notReady()); + } + queue.add(entry); + } + for (Cancellation cancellation : scope) { + Cancellation.Registration registration = cancellation.onCancel(() -> cancel(entry, cancellation)); + boolean settled; + synchronized (lock) { + settled = entry.settled; + if (!settled) { + entry.registrations.add(registration); + } + } + if (settled) { + registration.remove(); + } + } + drain(); + return entry.future; + } + + /** + * Sends a request whose result is {@code { subscriptionID }} and routes + * that subscription's events to {@code listener}, one at a time and in the + * order the host sent them. + */ + CompletableFuture subscribe(String method, Object params, Host.Listener listener, + List scope) { + synchronized (lock) { + subscribing++; + } + return request(method, params, scope).handle((result, error) -> { + try { + if (error != null) { + throw new CompletionException(Backend.unwrap(error)); + } + Object id = result instanceof Map ? ((Map) result).get("subscriptionID") : null; + if (!(id instanceof String) || !Protocol.isCredentialKey((String) id)) { + throw new PluginSdkError("sdk.host.invalidResult", method + " answered without a subscriptionID."); + } + String subscriptionId = (String) id; + Route route = new Route(subscriptionId, listener); + synchronized (lock) { + if (closed) { + throw new PluginSdkError("sdk.session.closed", "The session ended."); + } + routes.put(subscriptionId, route); + // Events the host sent before this answer was read. + for (Iterator events = early.iterator(); events.hasNext();) { + Early event = events.next(); + if (event.subscriptionId.equals(subscriptionId)) { + events.remove(); + emit(route, event.sequence, event.event); + } + } + } + return new Host.Subscription(subscriptionId, route::isLive, this); + } finally { + synchronized (lock) { + subscribing--; + if (subscribing == 0) { + early.clear(); + } + } + } + }); + } + + /** Stops routing a subscription's events at once and asks the host to dispose it. */ + CompletableFuture dispose(String subscriptionId) { + synchronized (lock) { + Route route = routes.remove(subscriptionId); + if (route != null) { + route.live = false; + route.pending.clear(); + } + if (closed || !transport.ready()) { + return CompletableFuture.completedFuture(null); + } + } + Map params = new LinkedHashMap<>(); + params.put("subscriptionID", subscriptionId); + return request(Protocol.HOST_SUBSCRIPTIONS_DISPOSE, params, Collections.emptyList()) + .thenApply(result -> null); + } + + /** Routes one {@code lithe/host/subscriptions/notify}. */ + void deliver(Map params) { + Object id = params.get("subscriptionID"); + if (!(id instanceof String)) { + return; + } + Object number = params.get("sequence"); + long sequence = number instanceof Number ? ((Number) number).longValue() : 0; + synchronized (lock) { + Route route = routes.get(id); + if (route != null) { + emit(route, sequence, params.get("event")); + } else if (subscribing > 0) { + if (early.size() < MAX_EARLY_EVENTS) { + early.add(new Early((String) id, sequence, params.get("event"))); + } else { + transport.log("Dropped an early subscription event: queue_full"); + } + } + // Otherwise the subscription was disposed; nothing follows a disposal. + } + } + + /** The cached {@code getCapabilities} answer; Core answers it itself. */ + CompletableFuture> capabilities() { + if (!transport.negotiated()) { + return failed(unavailable(Protocol.HOST_GET_CAPABILITIES, + "The host did not negotiate plugin functions (litheFunctions), which offer capability discovery.")); + } + CompletableFuture> answer; + synchronized (lock) { + if (capabilities != null) { + return capabilities.thenApply(result -> result); + } + answer = new CompletableFuture<>(); + capabilities = answer; + } + request(Protocol.HOST_GET_CAPABILITIES, null, Collections.emptyList()).whenComplete((result, error) -> { + if (error == null && result instanceof Map) { + answer.complete(Collections.unmodifiableMap(Json.object(result))); + return; + } + synchronized (lock) { + if (capabilities == answer) { + capabilities = null; + } + } + answer.completeExceptionally(error != null ? Backend.unwrap(error) + : new PluginSdkError("sdk.host.invalidResult", "getCapabilities answered without an object.")); + }); + return answer.thenApply(result -> result); + } + + /** + * Whether this session may call {@code method}, from + * {@code getCapabilities}; false when the host offered no plugin functions. + * A missing grant still fails the request. + */ + CompletableFuture supports(String method) { + if (!transport.negotiated()) { + return CompletableFuture.completedFuture(false); + } + return capabilities().thenApply(answer -> { + Object services = answer.get("services"); + if (!(services instanceof Map)) { + return false; + } + for (Object service : ((Map) services).values()) { + Object methods = service instanceof Map ? ((Map) service).get("methods") : null; + if (methods instanceof List && ((List) methods).contains(method)) { + return true; + } + } + return false; + }); + } + + /** Ends the channel with the session: queued requests fail, subscriptions end. */ + void close() { + List queued; + synchronized (lock) { + closed = true; + queued = new ArrayList<>(queue); + queue.clear(); + for (Route route : routes.values()) { + route.live = false; + route.pending.clear(); + } + routes.clear(); + early.clear(); + capabilities = null; + } + for (Entry entry : queued) { + settle(entry, null, new PluginSdkError("sdk.session.closed", "The session ended.")); + } + } + + private void drain() { + List starting = new ArrayList<>(); + synchronized (lock) { + while (!closed && inFlight < Protocol.MAX_PENDING_HOST_REQUESTS && !queue.isEmpty()) { + Entry entry = queue.poll(); + entry.state = State.SENDING; + inFlight++; + starting.add(entry); + } + } + for (Entry entry : starting) { + start(entry); + } + } + + /** Writes outside the lock, so a full pipe never blocks the reader thread's routing. */ + private void start(Entry entry) { + Sent sent; + try { + sent = transport.send(entry.method, entry.params); + } catch (RuntimeException error) { + // Params JSON cannot encode never reached the wire. + synchronized (lock) { + inFlight--; + } + settle(entry, null, error); + drain(); + return; + } + boolean cancelNow; + synchronized (lock) { + entry.id = sent.id; + entry.state = State.SENT; + cancelNow = entry.cancelAfterWrite; + } + if (cancelNow) { + transport.notify(Protocol.CANCEL_REQUEST, Collections.singletonMap("id", sent.id)); + } + sent.response.whenComplete((result, error) -> { + synchronized (lock) { + // The slot frees when Core answers, also after a cancellation: + // Core answers a cancelled request at once. + inFlight--; + } + settle(entry, result, error == null ? null : Backend.unwrap(error)); + drain(); + }); + } + + private void cancel(Entry entry, Cancellation cancellation) { + String id = null; + synchronized (lock) { + if (entry.settled) { + return; + } + if (entry.state == State.QUEUED) { + queue.remove(entry); + } else if (entry.state == State.SENDING) { + entry.cancelAfterWrite = true; + } else { + id = entry.id; + } + } + if (id != null) { + transport.notify(Protocol.CANCEL_REQUEST, Collections.singletonMap("id", id)); + } + settle(entry, null, new CancelledException(cancellation.reason())); + } + + /** Completes the caller's future once, on a handler thread. */ + private void settle(Entry entry, Object result, Throwable error) { + List registrations; + synchronized (lock) { + if (entry.settled) { + return; + } + entry.settled = true; + registrations = new ArrayList<>(entry.registrations); + entry.registrations.clear(); + } + for (Cancellation.Registration registration : registrations) { + registration.remove(); + } + Runnable complete = () -> { + if (error != null) { + entry.future.completeExceptionally(error); + } else { + entry.future.complete(result); + } + }; + try { + transport.execute(complete); + } catch (RejectedExecutionException ended) { + complete.run(); + } + } + + /** Queues one event; called with the lock held. */ + private void emit(Route route, long sequence, Object event) { + boolean gap = sequence != route.lastSequence + 1; + route.lastSequence = sequence; + route.pending.add(new Host.Event(route.id, sequence, gap, event)); + if (route.draining) { + return; + } + route.draining = true; + try { + transport.execute(() -> run(route)); + } catch (RejectedExecutionException ended) { + route.draining = false; + route.pending.clear(); + } + } + + /** Hands a subscription's events to its listener one at a time. */ + private void run(Route route) { + while (true) { + Host.Event event; + synchronized (lock) { + event = route.live ? route.pending.poll() : null; + if (event == null) { + route.draining = false; + route.pending.clear(); + return; + } + } + try { + if (route.listener != null) { + route.listener.onEvent(event); + } + } catch (Throwable error) { + transport.log("A subscription listener failed:", error); + } + } + } + + private boolean open() { + synchronized (lock) { + return !closed && transport.ready(); + } + } + + private static PluginSdkError notReady() { + return new PluginSdkError("sdk.session.notReady", "The session is not initialized."); + } + + private static HostError unavailable(String method, String message) { + return new HostError("plugin.host.unavailable", message, Protocol.METHOD_NOT_FOUND, null, method); + } + + private static CompletableFuture failed(Throwable error) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(error); + return future; + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostError.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostError.java new file mode 100644 index 00000000..3730a020 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostError.java @@ -0,0 +1,126 @@ +package io.github.litheidea.plugin.sdk; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The host refused or failed a request this backend sent. {@link #code()} is + * the stable {@code error.data.code}, such as {@code plugin.host.contextExpired}; + * {@link #rpcCode()} the JSON-RPC number; {@link #details()} the object a + * host may add, such as {@code { option }} or + * {@code { retryAfterMilliseconds }}. + */ +public final class HostError extends PluginSdkError { + private static final long serialVersionUID = 1L; + + /** + * Stable host failure codes and their JSON-RPC numbers, including the + * {@code plugin.function.*} codes of cross-plugin calls. A later host adds + * codes after the highest number here. + */ + public static final Map CODES; + private static final Map BY_RPC_CODE; + + static { + Map codes = new LinkedHashMap<>(); + codes.put("plugin.host.unavailable", -32601); + codes.put("plugin.host.invalidParams", -32602); + codes.put("plugin.host.failed", -32603); + codes.put("plugin.host.cancelled", -32800); + codes.put("plugin.host.permissionDenied", -32010); + codes.put("plugin.host.contextExpired", -32011); + codes.put("plugin.host.authorityUnsupported", -32012); + codes.put("plugin.host.versionConflict", -32013); + codes.put("plugin.host.limitExceeded", -32014); + codes.put("plugin.host.timeout", -32015); + codes.put("plugin.host.notFound", -32016); + codes.put("plugin.host.readOnly", -32017); + codes.put("plugin.host.unsupportedOption", -32018); + codes.put("plugin.host.preconditionFailed", -32019); + codes.put("plugin.host.busy", -32020); + codes.put("plugin.host.rateLimited", -32021); + codes.put("plugin.host.languageServerUnavailable", -32022); + codes.put("plugin.host.languageServerError", -32023); + codes.put("plugin.host.methodNotDeclared", -32024); + codes.put("plugin.host.toolUnresolved", -32025); + codes.put("plugin.host.foreignContribution", -32026); + codes.put("plugin.function.notDeclared", -32027); + codes.put("plugin.function.notExported", -32028); + codes.put("plugin.function.providerDisabled", -32029); + codes.put("plugin.function.versionMismatch", -32030); + codes.put("plugin.function.callCycle", -32065); + codes.put("plugin.function.depthExceeded", -32066); + codes.put("plugin.function.tooManyCalls", -32067); + codes.put("plugin.function.payloadTooLarge", -32068); + codes.put("plugin.function.providerUnavailable", -32069); + codes.put("plugin.function.failed", -32070); + codes.put("plugin.function.invalidResult", -32071); + CODES = Collections.unmodifiableMap(codes); + Map byNumber = new HashMap<>(); + for (Map.Entry entry : codes.entrySet()) { + byNumber.put(entry.getValue(), entry.getKey()); + } + BY_RPC_CODE = Collections.unmodifiableMap(byNumber); + } + + private final transient Map details; + private final String method; + + public HostError(String code, String message, Integer rpcCode, Map details, String method) { + super(code, message, rpcCode); + this.details = details == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(details)); + this.method = method; + } + + /** The failure's {@code error.data.details}, or null when the host sent none. */ + public Map details() { + return details; + } + + /** The request that failed. */ + public String method() { + return method; + } + + /** + * For {@code plugin.function.failed}, the provider's own + * {@code error.data.code} (from {@code details.pluginErrorCode}); null for + * any other failure or when the provider named none. + */ + public String pluginErrorCode() { + if (!"plugin.function.failed".equals(code()) || details == null) { + return null; + } + Object pluginErrorCode = details.get("pluginErrorCode"); + return pluginErrorCode instanceof String ? (String) pluginErrorCode : null; + } + + /** + * The failure for a JSON-RPC error answer. A host method without a known + * code is a host failure: a number names one code, and an unknown number + * is {@code plugin.host.failed}. + */ + static HostError fromResponse(String method, Map error) { + Object data = error.get("data"); + Map fields = data instanceof Map ? Json.object(data) : Collections.emptyMap(); + Object rpcCode = error.get("code"); + Integer number = rpcCode instanceof Number && ((Number) rpcCode).doubleValue() == ((Number) rpcCode).intValue() + ? ((Number) rpcCode).intValue() + : null; + String code = Json.string(fields, "code"); + if (code == null) { + if (method.startsWith(Protocol.HOST_PREFIX)) { + String known = number == null ? null : BY_RPC_CODE.get(number); + code = known == null ? "plugin.host.failed" : known; + } else { + code = "sdk.host.error"; + } + } + Object details = fields.get("details"); + String text = Json.string(error, "message"); + return new HostError(code, text == null ? method + " failed." : text, number, + details instanceof Map ? Json.object(details) : null, method); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java new file mode 100644 index 00000000..64ff3f59 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java @@ -0,0 +1,426 @@ +package io.github.litheidea.plugin.sdk; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Minimal JSON reader and writer for protocol messages and stored state. + * + *

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

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

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

Process trees: on macOS and Linux every tool leads its own process group, + * as in the Node SDK, on every Java version. Java has no API for that, so the + * tool starts through the system {@code perl}, which calls {@code setpgrp} and + * then replaces itself with the tool, keeping its PID. A stop signals the whole + * group, and once the tool exits on its own the members it left behind, such + * as a background worker, are killed too; descendants that leave the group + * with {@code setsid} are out of reach. Windows, and a system without + * {@code perl}, fall back to {@code ProcessHandle} on Java 9 and newer, found + * by reflection so the SDK still loads on Java 8; it reaches only the + * descendants still attached to the tool. Lithe starts every backend inside + * its own process scope ({@code LITHE_PLUGIN_BACKEND_SCOPE}) and ends whatever + * is left of it when the session ends; that remains the final boundary. + */ +public final class Processes { + /** + * Variables Lithe sets on the backend so it can find and end every process + * the backend started, even one that left its parent. A tool always + * inherits them, also when the plugin replaces the environment. + */ + static final String[] HOST_MARKERS = {"LITHE_PROCESS_SCOPE", "LITHE_PLUGIN_BACKEND_SCOPE"}; + + /** Every tool started through {@link #run} that has not exited or been stopped. */ + private static final Set RUNNING = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + private static final ScheduledExecutorService TIMERS = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "lithe-sdk-process-timer"); + thread.setDaemon(true); + return thread; + }); + + private static final ProcessTree TREE = ProcessTree.load(); + + private static final ProcessGroups GROUPS = ProcessGroups.load(); + + private Processes() {} + + /** Options of one {@link #run}; every setter returns this object. */ + public static final class Options { + private Path directory; + private Map environment; + private Cancellation cancellation; + private int maxOutputBytes = 1 << 20; + private long killGraceMillis = 2000; + private Consumer onStdout; + private Consumer onStderr; + private Runnable listenerFailure = () -> {}; + + /** Working directory; defaults to the backend's. */ + public Options directory(Path value) { + directory = value; + return this; + } + + /** Replaces the environment; the host's process markers are carried over regardless. */ + public Options environment(Map value) { + environment = value; + return this; + } + + /** Cancelling it stops the tool and makes {@link #run} throw {@link CancelledException}. */ + public Options cancellation(Cancellation value) { + cancellation = value; + return this; + } + + /** Captured bytes per stream; default 1 MiB. Listeners still see everything. */ + public Options maxOutputBytes(int value) { + maxOutputBytes = value; + return this; + } + + /** Delay between the polite and the forced stop; default 2000. */ + public Options killGraceMillis(long value) { + killGraceMillis = value; + return this; + } + + /** Receives stdout as it arrives, decoded as UTF-8 without splitting characters. */ + public Options onStdout(Consumer value) { + onStdout = value; + return this; + } + + /** Receives stderr as it arrives, decoded as UTF-8 without splitting characters. */ + public Options onStderr(Consumer value) { + onStderr = value; + return this; + } + + Options listenerFailure(Runnable value) { listenerFailure = value; return this; } + + Cancellation cancellation() { + return cancellation; + } + + Options copy() { + Options copy = new Options(); + copy.directory = directory; + copy.environment = environment; + copy.cancellation = cancellation; + copy.maxOutputBytes = maxOutputBytes; + copy.killGraceMillis = killGraceMillis; + copy.onStdout = onStdout; + copy.onStderr = onStderr; + copy.listenerFailure = listenerFailure; + return copy; + } + } + + /** Exit status and captured output of a tool. */ + public static final class Result { + private final int exitCode; + private final String stdout; + private final String stderr; + private final boolean truncated; + + Result(int exitCode, String stdout, String stderr, boolean truncated) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + this.truncated = truncated; + } + + /** The exit code; on POSIX a tool ended by signal N reports 128 + N. */ + public int exitCode() { + return exitCode; + } + + public String stdout() { + return stdout; + } + + public String stderr() { + return stderr; + } + + /** True when either stream exceeded {@code maxOutputBytes}. */ + public boolean truncated() { + return truncated; + } + } + + /** + * Runs {@code command} without a shell and returns its exit status and + * captured output. A non-zero exit returns normally; the caller decides + * what it means for its tool. Cancellation stops the tool and throws + * {@link CancelledException} once it ended. + */ + public static Result run(List command, Options options) throws IOException { + Options settings = options == null ? new Options() : options; + Cancellation cancellation = settings.cancellation; + if (cancellation != null) { + cancellation.throwIfCancelled(); + } + if (command.isEmpty()) { + throw new IllegalArgumentException("A command needs an executable."); + } + ProcessBuilder builder = new ProcessBuilder(new ArrayList<>(command)); + if (settings.directory != null) { + builder.directory(settings.directory.toFile()); + } + if (settings.environment != null) { + Map environment = builder.environment(); + environment.clear(); + environment.putAll(settings.environment); + for (String name : HOST_MARKERS) { + String value = System.getenv(name); + if (value == null) { + environment.remove(name); + } else { + environment.put(name, value); + } + } + } + boolean grouped = GROUPS.available(); + if (grouped) { + builder.command(GROUPS.launcher(command, builder.environment())); + } + Process process = builder.start(); + // Tools get no input; an open pipe would let one wait on it forever. + process.getOutputStream().close(); + Running entry = new Running(process, grouped ? GROUPS.leader(process) : 0); + RUNNING.add(entry); + Capture stdout = new Capture(process.getInputStream(), settings.maxOutputBytes, settings.onStdout, "stdout", settings.listenerFailure); + Capture stderr = new Capture(process.getErrorStream(), settings.maxOutputBytes, settings.onStderr, "stderr", settings.listenerFailure); + Cancellation.Registration registration = cancellation == null + ? () -> {} + : cancellation.onCancel(() -> entry.stop(settings.killGraceMillis)); + int exitCode; + try { + exitCode = waitFor(process, entry); + registration.remove(); + // The tool ended on its own; what it left running, such as a + // background worker, ends with it. A stopped tool stays registered + // until its forced stop, which reaches the same members. + entry.endLeftovers(); + // A descendant out of reach can keep the inherited pipes open after + // the tool exited; the output then ends at the grace period. + stdout.join(settings.killGraceMillis); + stderr.join(settings.killGraceMillis); + } finally { + registration.remove(); + if (!entry.stopping()) { + RUNNING.remove(entry); + } + } + if (cancellation != null) { + cancellation.throwIfCancelled(); + } + String errors = stderr.text(); + if (grouped && exitCode == ProcessGroups.EXEC_FAILED && errors.startsWith(ProcessGroups.EXEC_FAILURE)) { + // The launcher could not start the tool; report it as ProcessBuilder would. + throw new IOException("Cannot run program \"" + command.get(0) + "\": " + + errors.substring(ProcessGroups.EXEC_FAILURE.length()).trim()); + } + return new Result(exitCode, stdout.text(), errors, stdout.truncated() || stderr.truncated()); + } + + /** Runs {@code command} with default options. */ + public static Result run(List command) throws IOException { + return run(command, null); + } + + /** + * Forcibly ends every tool still running. The backend calls it on shutdown, + * on {@code exit}, and when the host closes stdin, so no tool outlives the + * session that started it. + */ + public static void killAll() { + for (Running entry : new ArrayList<>(RUNNING)) { + entry.forceStop(); + } + RUNNING.clear(); + } + + /** Number of tools still running or being stopped, for tests and diagnostics. */ + public static int runningCount() { + return RUNNING.size(); + } + + private static int waitFor(Process process, Running entry) { + boolean interrupted = false; + try { + for (;;) { + try { + return process.waitFor(); + } catch (InterruptedException error) { + // The tool still belongs to this call; end it before giving + // the interrupt back to the caller. + interrupted = true; + entry.forceStop(); + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + /** One started tool and its stop state. */ + private static final class Running { + private final Process process; + /** The tool's process group, or 0 when it has none of its own. */ + private final long group; + private final Object lock = new Object(); + private boolean stopping; + private List tree = Collections.emptyList(); + private ScheduledFuture forceTimer; + + Running(Process process, long group) { + this.process = process; + this.group = group; + } + + /** Kills what the tool left after exiting on its own, unless a stop owns that. */ + void endLeftovers() { + synchronized (lock) { + if (stopping) { + return; + } + } + if (group > 0) { + // While any member lives the group ID cannot be reused, so this + // reaches only what this tool started; an empty group is ignored. + GROUPS.signal(group, true); + } else { + // Windows keeps the parent link of an exited tool, so its + // descendants are still found there. + TREE.destroy(TREE.descendants(process), true); + } + } + + boolean stopping() { + synchronized (lock) { + return stopping; + } + } + + /** Asks the tree to stop, then forces it after {@code graceMillis}. */ + void stop(long graceMillis) { + synchronized (lock) { + if (stopping) { + return; + } + stopping = true; + if (group > 0) { + GROUPS.signal(group, false); + } else { + // Descendants are listed before the tool is signalled: once + // it exits they are reparented and no longer reachable from it. + tree = TREE.descendants(process); + TREE.destroy(tree, false); + } + // Also covers a tool that has not formed its group yet. + process.destroy(); + forceTimer = TIMERS.schedule(this::forceStop, graceMillis, TimeUnit.MILLISECONDS); + } + } + + void forceStop() { + List known; + synchronized (lock) { + stopping = true; + if (forceTimer != null) { + forceTimer.cancel(false); + } + known = new ArrayList<>(tree); + if (group <= 0) { + known.addAll(TREE.descendants(process)); + } + } + if (group > 0) { + GROUPS.signal(group, true); + } + TREE.destroy(known, true); + process.destroyForcibly(); + RUNNING.remove(this); + } + } + + /** Reads one output stream on its own thread, keeping at most {@code limit} bytes. */ + private static final class Capture { + private final ByteArrayOutputStream kept = new ByteArrayOutputStream(); + private final InputStream input; + private final int limit; + private final Consumer listener; + private final Runnable listenerFailure; + private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + private final Thread thread; + private volatile boolean truncated; + + Capture(InputStream input, int limit, Consumer listener, String name, Runnable listenerFailure) { + this.listenerFailure = listenerFailure; + this.input = input; + this.limit = limit; + this.listener = listener; + thread = new Thread(this::read, "lithe-sdk-" + name); + thread.setDaemon(true); + thread.start(); + } + + private void read() { + byte[] chunk = new byte[8192]; + // Holds a multi-byte character split across reads until it is whole. + ByteBuffer pending = ByteBuffer.allocate(chunk.length + 8); + try { + int count; + while ((count = input.read(chunk)) >= 0) { + synchronized (kept) { + int room = Math.max(0, limit - kept.size()); + kept.write(chunk, 0, Math.min(room, count)); + if (count > room) { + truncated = true; + } + } + if (listener != null) { + pending.put(chunk, 0, count); + pending.flip(); + deliver(pending, false); + pending.compact(); + } + } + if (listener != null) { + pending.flip(); + deliver(pending, true); + } + } catch (IOException error) { + // The stream was closed after the grace period; what was read is kept. + } + } + + private void deliver(ByteBuffer bytes, boolean end) { + CharBuffer characters = CharBuffer.allocate(bytes.remaining() + 1); + decoder.decode(bytes, characters, end); + if (end) { + decoder.flush(characters); + } + characters.flip(); + if (characters.hasRemaining()) { + try { + listener.accept(characters.toString()); + } catch (RuntimeException error) { + listenerFailure.run(); + } + } + } + + void join(long millis) { + try { + thread.join(Math.max(1, millis)); + if (thread.isAlive()) { + input.close(); + thread.join(millis); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } catch (IOException error) { + // Closing an output pipe that already failed changes nothing. + } + } + + String text() { + synchronized (kept) { + return new String(kept.toByteArray(), StandardCharsets.UTF_8); + } + } + + boolean truncated() { + return truncated; + } + } + + /** + * A process group per tool on macOS and Linux. The launcher is + * {@code perl -e SCRIPT -- command...}: it calls + * {@code setpgrp(0, 0)} and replaces itself with the tool, so the PID Java + * reports is the tool's and also its group ID. {@code PERL5OPT} would make + * perl load modules before the script runs, so it is withheld from perl and + * handed back to the tool. Signals go through {@code /bin/kill}, which + * Java 8 needs for a negative PID. + */ + private static final class ProcessGroups { + /** Exit code and stderr prefix of a launcher that could not start the tool. */ + static final int EXEC_FAILED = 127; + static final String EXEC_FAILURE = "lithe-sdk-exec-failed: "; + + private static final String SCRIPT = "my $has = shift; my $opt = shift;" + + " $ENV{PERL5OPT} = $opt if $has;" + + " setpgrp(0, 0);" + + " exec { $ARGV[0] } @ARGV;" + + " print STDERR '" + EXEC_FAILURE + "', \"$!\\n\"; exit " + EXEC_FAILED + ";"; + + /** A signal that has not run after this long is abandoned; the host scope remains. */ + private static final long SIGNAL_TIMEOUT_MILLIS = 2000; + + private final String perl; + private final String kill; + private final Method pid; + + private ProcessGroups(String perl, String kill, Method pid) { + this.perl = perl; + this.kill = kill; + this.pid = pid; + } + + static ProcessGroups load() { + if (System.getProperty("os.name", "").startsWith("Windows")) { + return new ProcessGroups(null, null, null); + } + Method pid; + try { + pid = Process.class.getMethod("pid"); + } catch (NoSuchMethodException error) { + // Java 8 keeps the PID in a private field of UNIXProcess. + pid = null; + } + return new ProcessGroups(executable("/usr/bin/perl", "/bin/perl"), + executable("/bin/kill", "/usr/bin/kill"), pid); + } + + private static String executable(String... candidates) { + for (String candidate : candidates) { + if (new File(candidate).canExecute()) { + return candidate; + } + } + return null; + } + + boolean available() { + return perl != null && kill != null; + } + + List launcher(List command, Map environment) { + String option = environment.remove("PERL5OPT"); + List launcher = new ArrayList<>(Arrays.asList(perl, "-e", SCRIPT, "--", + option == null ? "0" : "1", option == null ? "" : option)); + launcher.addAll(command); + return launcher; + } + + /** The tool's PID, which is its group ID; 0 when it cannot be read. */ + long leader(Process process) { + try { + if (pid != null) { + return ((Number) pid.invoke(process)).longValue(); + } + Field field = process.getClass().getDeclaredField("pid"); + field.setAccessible(true); + return field.getInt(process); + } catch (ReflectiveOperationException | RuntimeException error) { + return 0; + } + } + + /** Signals every member of {@code group}; an empty group is ignored. */ + void signal(long group, boolean forcibly) { + ProcessBuilder builder = new ProcessBuilder(kill, "-s", forcibly ? "KILL" : "TERM", "--", "-" + group); + File discard = new File("/dev/null"); + builder.redirectOutput(discard).redirectError(discard); + try { + Process signal = builder.start(); + signal.getOutputStream().close(); + if (!signal.waitFor(SIGNAL_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + signal.destroyForcibly(); + } + } catch (IOException error) { + // The host's process scope ends what this could not reach. + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * {@code ProcessHandle} through reflection: present from Java 9, absent on + * Java 8, where only the direct child can be stopped. + */ + private static final class ProcessTree { + private final Method descendants; + private final Method destroy; + private final Method destroyForcibly; + + private ProcessTree(Method descendants, Method destroy, Method destroyForcibly) { + this.descendants = descendants; + this.destroy = destroy; + this.destroyForcibly = destroyForcibly; + } + + static ProcessTree load() { + try { + Class handle = Class.forName("java.lang.ProcessHandle"); + return new ProcessTree( + Process.class.getMethod("descendants"), + handle.getMethod("destroy"), + handle.getMethod("destroyForcibly")); + } catch (ReflectiveOperationException error) { + return new ProcessTree(null, null, null); + } + } + + List descendants(Process process) { + if (descendants == null) { + return Collections.emptyList(); + } + try { + @SuppressWarnings("unchecked") + Stream stream = (Stream) descendants.invoke(process); + return stream.collect(Collectors.toList()); + } catch (ReflectiveOperationException | RuntimeException error) { + return Collections.emptyList(); + } + } + + void destroy(List handles, boolean forcibly) { + Method method = forcibly ? destroyForcibly : destroy; + if (method == null) { + return; + } + for (Object handle : handles) { + try { + method.invoke(handle); + } catch (ReflectiveOperationException | RuntimeException error) { + // The process already exited, or the platform refused; the + // host's process scope ends what is left. + } + } + } + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java new file mode 100644 index 00000000..3d0aeb15 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -0,0 +1,234 @@ +package io.github.litheidea.plugin.sdk; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Wire constants and validation shared with Lithe's Rust Core + * ({@code rust/lithe-core/src/lsp/interface/plugin_function.rs} in the Lithe + * repository) and the Node.js + * SDK. The SDK checks the same rules locally so a plugin author sees a mistake + * when registering a handler instead of as a refused session at runtime. + */ +public final class Protocol { + /** The version of each protocol this SDK speaks; the host offers it in {@code initialize}. */ + public static final int FUNCTIONS_VERSION = 0; + public static final int BUILD_VERSION = 0; + public static final int LOGGING_VERSION = 0; + public static final int KEYMAP_IMPORT_VERSION = 0; + + public static final String INITIALIZE = "initialize"; + public static final String INITIALIZED = "initialized"; + public static final String SHUTDOWN = "shutdown"; + public static final String EXIT = "exit"; + public static final String CANCEL_REQUEST = "$/cancelRequest"; + public static final String FUNCTION_INVOKE = "lithe/function/invoke"; + public static final String FUNCTION_REGISTER = "lithe/function/register"; + public static final String FUNCTION_UNREGISTER = "lithe/function/unregister"; + public static final String FUNCTION_PROGRESS = "lithe/function/progress"; + public static final String BUILD_MODEL = "lithe/build/model"; + public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; + public static final String BUILD_EXECUTE = "lithe/build/execute"; + public static final String BUILD_TEST = "lithe/build/test"; + public static final String BUILD_DISCOVER_TESTS = "lithe/build/discoverTests"; + public static final String BUILD_PREPARE_DEBUG = "lithe/build/prepareDebug"; + public static final String BUILD_PREPARE_RUN = "lithe/build/prepareRun"; + public static final String BUILD_PROGRESS = "lithe/build/progress"; + + /** + * Backend-to-host requests. The host may add methods; + * {@link Host#request} sends any {@code lithe/host/*} method, and + * {@code getCapabilities} says which ones the session may call. + */ + public static final String HOST_TERMINAL_CREATE = "lithe/host/terminal/create"; + public static final String HOST_TERMINAL_SHOW = "lithe/host/terminal/show"; + public static final String HOST_TERMINAL_CLOSE = "lithe/host/terminal/close"; + public static final String HOST_TERMINAL_REOPEN = "lithe/host/terminal/reopen"; + public static final String HOST_TERMINAL_DISPOSE = "lithe/host/terminal/dispose"; + public static final String HOST_TERMINAL_ON_EXIT = "lithe/host/terminal/onExit"; + public static final String HOST_PREFIX = "lithe/host/"; + public static final String HOST_CREDENTIALS_GET = "lithe/host/credentials/get"; + public static final String HOST_CREDENTIALS_SET = "lithe/host/credentials/set"; + public static final String HOST_CREDENTIALS_DELETE = "lithe/host/credentials/delete"; + public static final String HOST_GET_CAPABILITIES = "lithe/host/getCapabilities"; + public static final String HOST_SUBSCRIPTIONS_DISPOSE = "lithe/host/subscriptions/dispose"; + public static final String HOST_WORKSPACE_LIST_CONTEXTS = "lithe/host/workspace/listContexts"; + public static final String HOST_WORKSPACE_GET_CONTEXT = "lithe/host/workspace/getContext"; + public static final String HOST_WORKSPACE_GET_FOLDERS = "lithe/host/workspace/getFolders"; + public static final String HOST_WORKSPACE_READ_DIRECTORY = "lithe/host/workspace/readDirectory"; + public static final String HOST_WORKSPACE_STAT = "lithe/host/workspace/stat"; + public static final String HOST_WORKSPACE_READ_FILE = "lithe/host/workspace/readFile"; + public static final String HOST_WORKSPACE_WATCH = "lithe/host/workspace/watch"; + public static final String HOST_WORKSPACE_FIND_FILES = "lithe/host/workspace/findFiles"; + public static final String HOST_WORKSPACE_FIND_TEXT = "lithe/host/workspace/findText"; + public static final String HOST_DOCUMENTS_LIST = "lithe/host/documents/list"; + public static final String HOST_DOCUMENTS_GET_SNAPSHOT = "lithe/host/documents/getSnapshot"; + public static final String HOST_EDITOR_REGISTER_ACTIONS = "lithe/host/editor/registerActions"; + public static final String HOST_EDITOR_GET_ACTIVE = "lithe/host/editor/getActive"; + public static final String HOST_EDITOR_GET_SELECTIONS = "lithe/host/editor/getSelections"; + public static final String HOST_EDITOR_APPLY_EDITS = "lithe/host/editor/applyEdits"; + public static final String HOST_EDITOR_REPLACE_SELECTIONS = "lithe/host/editor/replaceSelections"; + public static final String HOST_EDITOR_INSERT_TEXT = "lithe/host/editor/insertText"; + public static final String HOST_EDITOR_OPEN = "lithe/host/editor/open"; + public static final String HOST_EDITOR_REVEAL = "lithe/host/editor/reveal"; + public static final String HOST_PREVIEW_OPEN_HTML = "lithe/host/preview/openHTML"; + public static final String HOST_BROWSER_OPEN_EXTERNAL = "lithe/host/browser/openExternal"; + public static final String HOST_WINDOW_SHOW_INFORMATION = "lithe/host/window/showInformation"; + public static final String HOST_WINDOW_SHOW_WARNING = "lithe/host/window/showWarning"; + public static final String HOST_WINDOW_SHOW_ERROR = "lithe/host/window/showError"; + + /** Cross-plugin calls Core relays. */ + public static final String HOST_FUNCTIONS_PROVIDERS = "lithe/host/functions/providers"; + public static final String HOST_FUNCTIONS_INVOKE = "lithe/host/functions/invoke"; + + /** Host-to-backend notifications. */ + public static final String HOST_DID_CHANGE_CONTEXTS = "lithe/host/workspace/didChangeContexts"; + public static final String HOST_SUBSCRIPTION_NOTIFY = "lithe/host/subscriptions/notify"; + public static final String HOST_COMMAND_DID_RUN = "lithe/host/commands/didRun"; + + /** JSON-RPC and LSP error codes the SDK answers with. */ + public static final int PARSE_ERROR = -32700; + public static final int INVALID_REQUEST = -32600; + public static final int METHOD_NOT_FOUND = -32601; + public static final int INVALID_PARAMS = -32602; + public static final int INTERNAL_ERROR = -32603; + public static final int SERVER_NOT_INITIALIZED = -32002; + public static final int REQUEST_FAILED = -32000; + public static final int REQUEST_CANCELLED = -32800; + + public static final int MAX_FUNCTIONS = 256; + public static final int MAX_FUNCTION_ID_BYTES = 128; + public static final int MAX_PLUGIN_ERROR_CODE_BYTES = 64; + public static final int MAX_MESSAGE_BYTES = 1024; + public static final int MAX_RESULT_BYTES = 1 << 20; + public static final int MAX_BUILD_RESULT_BYTES = 32 * 1024 * 1024; + public static final int MAX_BUILD_MESSAGE_BYTES = 4096; + public static final int MAX_BUILD_OUTPUT_BYTES = 65536; + /** Unanswered host requests Core allows a session; the SDK queues the rest. */ + public static final int MAX_PENDING_HOST_REQUESTS = 16; + public static final int MAX_CREDENTIAL_SECRET_BYTES = 16 * 1024; + /** Timeout Core gives a relayed call that names none. */ + public static final int RELAY_DEFAULT_TIMEOUT_MILLISECONDS = 60000; + /** Longest timeout of a relayed call; the SDK sends a longer one unchanged and leaves the cap to Core. */ + public static final int RELAY_MAX_TIMEOUT_MILLISECONDS = 600000; + public static final int MAX_PARENT_OPERATION_ID_BYTES = 128; + + static boolean buildProgress(String method) { + return BUILD_EXECUTE.equals(method) || BUILD_TEST.equals(method) || BUILD_PREPARE_RUN.equals(method) || BUILD_PREPARE_DEBUG.equals(method); + } + + private static final Pattern FUNCTION_SEGMENT = Pattern.compile("[a-z][A-Za-z0-9]*"); + private static final Pattern PLUGIN_ERROR_CODE = Pattern.compile("[A-Za-z][A-Za-z0-9._-]*"); + private static final Pattern KEY = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + private static final Pattern COMMAND_ID = Pattern.compile("[a-z0-9.-]{1,128}"); + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + + private Protocol() {} + + /** Dot-separated lowerCamelCase segments, for example {@code snapshot.refresh}. */ + public static boolean isFunctionId(String value) { + if (value == null || value.isEmpty() || utf8Length(value) > MAX_FUNCTION_ID_BYTES) { + return false; + } + for (String segment : value.split("\\.", -1)) { + if (!FUNCTION_SEGMENT.matcher(segment).matches()) { + return false; + } + } + return true; + } + + /** The plugin error code alphabet Core forwards as {@code pluginErrorCode}. */ + public static boolean isPluginErrorCode(String value) { + return value != null + && utf8Length(value) <= MAX_PLUGIN_ERROR_CODE_BYTES + && PLUGIN_ERROR_CODE.matcher(value).matches(); + } + + /** + * The key alphabet of host services: 1-128 ASCII letters, digits, + * {@code .}, {@code _}, {@code :}, or {@code -}. Credential keys, context + * IDs, and subscription IDs use it. + */ + public static boolean isCredentialKey(String value) { + return value != null && KEY.matcher(value).matches(); + } + + /** {@code { contextID, generation }}, exactly, naming one generation of one workspace context. */ + public static boolean isContextReference(Object value) { + if (!(value instanceof Map) || ((Map) value).size() != 2) { + return false; + } + Map reference = (Map) value; + Object id = reference.get("contextID"); + return id instanceof String && isCredentialKey((String) id) && isGeneration(reference.get("generation")); + } + + /** A {@code parentOperationID}: a string of 1-128 UTF-8 bytes. */ + static boolean isParentOperationId(Object value) { + if (!(value instanceof String)) { + return false; + } + int bytes = utf8Length((String) value); + return bytes >= 1 && bytes <= MAX_PARENT_OPERATION_ID_BYTES; + } + + /** A command ID from the plugin's manifest: 1-128 of {@code [a-z0-9.-]}. */ + public static boolean isCommandId(String value) { + return value != null && COMMAND_ID.matcher(value).matches(); + } + + /** A positive integer JSON can carry exactly. */ + static boolean isGeneration(Object value) { + if (value instanceof Long || value instanceof Integer || value instanceof Short || value instanceof Byte) { + long number = ((Number) value).longValue(); + return number >= 1 && number <= MAX_SAFE_INTEGER; + } + if (value instanceof Double || value instanceof Float) { + double number = ((Number) value).doubleValue(); + return number >= 1 && number <= MAX_SAFE_INTEGER && number == Math.rint(number); + } + return false; + } + + /** Cuts {@code value} to {@link #MAX_MESSAGE_BYTES} UTF-8 bytes without splitting a character. */ + public static String boundedText(String value) { + return boundedText(value, MAX_MESSAGE_BYTES); + } + + /** Cuts {@code value} to {@code limit} UTF-8 bytes without splitting a character. */ + public static String boundedText(String value, int limit) { + String text = value == null ? "" : value; + int used = 0; + int index = 0; + while (index < text.length()) { + int codePoint = text.codePointAt(index); + int size = codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4; + if (used + size > limit) { + return text.substring(0, index); + } + used += size; + index += Character.charCount(codePoint); + } + return text; + } + + /** + * Methods reserved for the SDK. A plugin cannot install its own handler for + * these, or send them to the host: the lifecycle belongs to the host's + * session management, and {@code lithe/*} belongs to Lithe's typed contracts. + */ + public static boolean isReservedMethod(String method) { + return method.equals(INITIALIZE) + || method.equals(INITIALIZED) + || method.equals(SHUTDOWN) + || method.equals(EXIT) + || method.equals(CANCEL_REQUEST) + || method.startsWith("lithe/"); + } + + static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java new file mode 100644 index 00000000..24761e25 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java @@ -0,0 +1,198 @@ +package io.github.litheidea.plugin.sdk; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.CodeSource; +import java.security.SecureRandom; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Durable plugin state, in a directory the host gives the backend. + * + *

The host passes writable directories through the {@code ${stateDir}} and + * {@code ${workspaceStateDir}} launch variables; the installed package and the + * host's installation are read-only, because signatures and delta updates are + * computed over their exact bytes. Values are JSON; each key is one file + * replaced atomically, so a crash leaves either the old or the new value, + * never half of one. The envelope matches the Node.js SDK's, so either SDK + * reads what the other wrote. + */ +public final class StateStore { + /** Version of the envelope each value is stored in. */ + private static final int STORE_FORMAT = 1; + private static final Pattern KEY = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final Path root; + + private StateStore(Path root) { + this.root = root; + } + + /** + * Opens a store in {@code directory}, creating the directory. Refuses a + * directory inside the plugin package, found as the nearest directory with + * a {@code plugin.json} above the JAR this class was loaded from. + * + * @param directory absolute path, normally the value of {@code ${workspaceStateDir}} + */ + public static StateStore open(Path directory) throws IOException { + return open(directory, findPackageDirectory()); + } + + /** + * Opens a store in {@code directory}, refusing one inside + * {@code packageDirectory}; null skips that check. + */ + public static StateStore open(Path directory, Path packageDirectory) throws IOException { + if (directory == null || !directory.isAbsolute()) { + throw new PluginSdkError("sdk.state.invalidDirectory", + "The state directory must be an absolute path passed by the host."); + } + // Checked before creating anything, so a refused path leaves the package untouched. + Path resolved = canonical(directory); + if (packageDirectory != null && resolved.startsWith(canonical(packageDirectory))) { + throw new PluginSdkError("sdk.state.readOnlyPackage", + "The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}."); + } + Files.createDirectories(resolved); + return new StateStore(resolved); + } + + /** The resolved directory, for a backend that keeps its own files next to the store. */ + public Path directory() { + return root; + } + + /** The stored value, or {@code fallback} when the key is missing or unreadable. */ + public Object read(String key, Object fallback) { + Path path = path(key); + if (!Files.isRegularFile(path)) { + return fallback; + } + try { + Object envelope = Json.parse(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); + if (!(envelope instanceof Map)) { + return fallback; + } + Map fields = (Map) envelope; + Object format = fields.get("litheStateStore"); + if (!(format instanceof Number) || ((Number) format).doubleValue() != STORE_FORMAT + || !fields.containsKey("value")) { + return fallback; + } + return fields.get("value"); + } catch (IOException | RuntimeException error) { + return fallback; + } + } + + /** Stores {@code value}, which must be JSON-encodable, replacing the previous one. */ + public void write(String key, Object value) throws IOException { + Path path = path(key); + Map envelope = new LinkedHashMap<>(); + envelope.put("litheStateStore", STORE_FORMAT); + envelope.put("value", value); + byte[] body; + try { + body = Json.stringify(envelope).getBytes(StandardCharsets.UTF_8); + } catch (Json.JsonException error) { + // Refused before touching the file, so the stored value stays. + throw new PluginSdkError("sdk.state.invalidValue", "The value for " + key + " is not JSON-encodable."); + } + Path temporary = root.resolve(key + ".json." + Long.toHexString(RANDOM.nextLong()) + ".tmp"); + try { + createPrivate(temporary); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(body); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException error) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException error) { + Files.deleteIfExists(temporary); + throw error; + } + } + + /** Removes the key; a missing key is not an error. */ + public void delete(String key) throws IOException { + Files.deleteIfExists(path(key)); + } + + private Path path(String key) { + if (key == null || !KEY.matcher(key).matches() || key.endsWith(".tmp")) { + throw new PluginSdkError("sdk.state.invalidKey", "State key \"" + key + + "\" must be 1-128 letters, digits, \".\", \"_\" or \"-\", start with a letter or digit, and not end with \".tmp\"."); + } + return root.resolve(key + ".json"); + } + + /** Creates an empty file only the user can read, where the platform supports it. */ + private static void createPrivate(Path file) throws IOException { + try { + Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + } catch (UnsupportedOperationException error) { + Files.createFile(file); + } catch (FileAlreadyExistsException error) { + throw new IOException("A temporary state file already exists: " + file.getFileName(), error); + } + } + + /** Resolves symbolic links of the longest existing prefix of {@code path}. */ + static Path canonical(Path path) throws IOException { + Path absolute = path.toAbsolutePath().normalize(); + Path existing = absolute; + while (existing != null && !Files.exists(existing)) { + existing = existing.getParent(); + } + if (existing == null) { + return absolute; + } + return existing.toRealPath().resolve(existing.relativize(absolute)); + } + + /** The nearest directory with a {@code plugin.json} above the JAR this class came from. */ + static Path findPackageDirectory() { + try { + CodeSource source = StateStore.class.getProtectionDomain().getCodeSource(); + URL location = source == null ? null : source.getLocation(); + if (location == null) { + return null; + } + Path current = Paths.get(location.toURI()).toAbsolutePath(); + if (Files.isRegularFile(current)) { + current = current.getParent(); + } + while (current != null) { + if (Files.isRegularFile(current.resolve("plugin.json"))) { + return current; + } + current = current.getParent(); + } + return null; + } catch (URISyntaxException | RuntimeException error) { + return null; + } + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java new file mode 100644 index 00000000..64d9aace --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java @@ -0,0 +1,607 @@ +package io.github.litheidea.plugin.sdk; + +import static io.github.litheidea.plugin.sdk.Fixtures.map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** Protocol behaviour of {@link Backend}; mirrors {@code sdk/node/test/backend.test.mjs}. */ +@Timeout(value = 30, unit = TimeUnit.SECONDS) +class BackendTest { + private static Backend functionBackend() { + return Backend.create("test-backend", "1.0.0") + .function("snapshot.refresh", (params, context) -> map("echoed", params)) + .function("install.prepare", (params, context) -> null); + } + + private static Map invoke(String functionId, String operationId, Object params) { + return map("functionID", functionId, "operationID", operationId, "params", params); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return (Map) value; + } + + private static Object errorCode(Map response) { + Object error = response.get("error"); + return error == null ? null : object(error).get("code"); + } + + @Test + void initializeAnswersTheOfferedProtocolsWithSortedFunctions() { + Backend backend = functionBackend().onModel((params, context) -> map("projects", Collections.emptyList())); + TestHost host = new TestHost(backend); + Map result = object(host.initialize().get("result")); + assertEquals( + map("litheLogging", map("version", 0L), "litheBuild", map("version", 0L), + "litheFunctions", map("version", 0L, "functions", + Arrays.asList(map("id", "install.prepare"), map("id", "snapshot.refresh")))), + object(result.get("capabilities")).get("experimental")); + assertEquals(map("name", "test-backend", "version", "1.0.0"), result.get("serverInfo")); + assertTrue(backend.negotiated().functions()); + assertTrue(backend.negotiated().build()); + assertEquals(1, host.close()); + } + + @Test + void aHostThatOffersNothingGetsNoLitheCapabilities() { + TestHost quiet = new TestHost(functionBackend()); + assertEquals(map("litheLogging", map("version", 0L)), object(object(quiet.initialize(map()).get("result")).get("capabilities")).get("experimental")); + quiet.close(); + + // No build handler: litheBuild is never claimed. Only a version the SDK does not speak: no functions. + TestHost other = new TestHost(functionBackend()); + Map answer = object(other.initialize(map( + "litheBuild", map("versions", Arrays.asList(0L)), + "litheFunctions", map("versions", Arrays.asList(99L)))).get("result")); + assertEquals(map("litheLogging", map("version", 0L)), object(answer.get("capabilities")).get("experimental")); + other.close(); + } + + @Test + void initializeIsRefusedUnlessTheHostOffersLitheLoggingVersionZero() { + TestHost host = new TestHost(functionBackend()); + for (Map experimental : Arrays.asList( + map(), map("litheLogging", map("versions", Arrays.asList(1L))))) { + Map response = host.request("initialize", + map("capabilities", map("experimental", experimental))); + assertEquals(-32600L, errorCode(response)); + assertEquals("The backend requires litheLogging version 0.", object(response.get("error")).get("message")); + } + assertNotNull(host.initialize().get("result")); + host.close(); + } + + @Test + void functionIdsFollowTheSharedContractIncludingTheFixtureCases() throws IOException { + for (String id : Arrays.asList("snapshot.refresh", "install.prepare", "a", "x1.y2Z")) { + assertTrue(Protocol.isFunctionId(id), id); + } + StringBuilder long129 = new StringBuilder(); + for (int index = 0; index < 129; index++) { + long129.append('a'); + } + for (String id : Arrays.asList("", "Not-Valid", "snapshot..refresh", "1abc", "a_b", "é", long129.toString(), "a.")) { + assertFalse(Protocol.isFunctionId(id), id); + } + // Every ID the fixture accepts is accepted locally, and the fixture's + // malformed ID refused, so the SDK never declares what Core rejects. + Map fixture = object(Json.parse(new String(Files.readAllBytes( + Paths.get("../../../contract/v1/fixtures/backend/function-protocol.json")), StandardCharsets.UTF_8))); + for (Object entry : (List) fixture.get("negotiation")) { + Map testCase = object(entry); + List ids = new ArrayList<>(); + Object experimental = object(testCase.get("capabilities")).get("experimental"); + Object functions = experimental == null ? null : object(experimental).get("litheFunctions"); + Object declared = functions == null ? null : object(functions).get("functions"); + if (declared != null) { + for (Object function : (List) declared) { + ids.add((String) object(function).get("id")); + } + } + String name = (String) testCase.get("name"); + if (name.contains("malformed")) { + assertTrue(ids.stream().anyMatch(id -> !Protocol.isFunctionId(id)), name); + } else if (object(testCase.get("expected")).get("functions") != null) { + assertTrue(ids.stream().allMatch(Protocol::isFunctionId), name); + } + } + PluginSdkError invalid = assertThrows(PluginSdkError.class, + () -> Backend.create(null, null).function("Bad-ID", (params, context) -> null)); + assertEquals("sdk.function.invalidID", invalid.code()); + PluginSdkError duplicate = assertThrows(PluginSdkError.class, () -> Backend.create(null, null) + .function("a", (params, context) -> null) + .function("a", (params, context) -> null)); + assertEquals("sdk.function.duplicate", duplicate.code()); + } + + @Test + void theLifecycleAndTheLitheNamespaceStayReservedForTheSdk() { + Backend backend = Backend.create(null, null); + for (String method : Arrays.asList("initialize", "shutdown", "exit", "$/cancelRequest", "lithe/build/model", "lithe/anything")) { + assertThrows(PluginSdkError.class, () -> backend.onRequest(method, (params, context) -> null), method); + } + PluginSdkError reserved = assertThrows(PluginSdkError.class, () -> Backend.create(null, null, + map("experimental", map("litheFunctions", map("version", 0))))); + assertEquals("sdk.capabilities.reserved", reserved.code()); + TestHost host = new TestHost(backend); + host.initialize(); + // A backend never asks the host to dispatch a domain operation or a function. + for (String method : Arrays.asList("lithe/function/invoke", "lithe/build/model", "lithe/host/process.run")) { + PluginSdkError error = assertThrows(PluginSdkError.class, () -> backend.sendRequest(method, map()), method); + assertEquals("sdk.method.reserved", error.code()); + } + host.close(); + } + + @Test + void requestsBeforeInitializeAreRefusedWithServerNotInitialized() { + TestHost host = new TestHost(functionBackend()); + Map response = host.request("lithe/function/invoke", invoke("snapshot.refresh", "op", map())); + assertEquals(-32002L, errorCode(response)); + host.close(); + } + + @Test + void anInvocationReturnsItsResultAndNullStaysNull() { + TestHost host = new TestHost(functionBackend()); + host.initialize(); + Map response = host.request("lithe/function/invoke", + invoke("snapshot.refresh", "op-1", map("project", "app"))); + assertEquals(map("echoed", map("project", "app")), response.get("result")); + Map empty = host.request("lithe/function/invoke", invoke("install.prepare", "op-2", null)); + assertTrue(empty.containsKey("result")); + assertNull(empty.get("result")); + Map unknown = host.request("lithe/function/invoke", invoke("missing", "op-3", null)); + assertEquals(-32602L, errorCode(unknown)); + host.close(); + } + + @Test + void progressEchoesTheOperationIsBoundedAndStopsOnceTheCallEnded() { + CountDownLatch release = new CountDownLatch(1); + AtomicReference late = new AtomicReference<>(); + StringBuilder longMessage = new StringBuilder(); + for (int index = 0; index < 2000; index++) { + longMessage.append('x'); + } + Backend backend = Backend.create(null, null).function("long.task", (params, context) -> { + context.progress(longMessage.toString(), 140.4); + context.progress("half", 49.6); + late.set(context); + Fixtures.await(release, "the test to release the call"); + return "done"; + }); + TestHost host = new TestHost(backend); + host.initialize(); + CompletableFuture> response = host.requestAsync("lithe/function/invoke", + invoke("long.task", "wire-7", null), 1001L); + Map first = object(host.waitFor( + message -> "lithe/function/progress".equals(message.get("method")), "progress").get("params")); + assertEquals("wire-7", first.get("operationID")); + assertEquals(1024, ((String) first.get("message")).length()); + assertEquals(100L, first.get("percentage")); + Map second = object(host.waitFor(message -> message.get("params") instanceof Map + && "half".equals(object(message.get("params")).get("message")), "the second report").get("params")); + assertEquals(50L, second.get("percentage")); + release.countDown(); + assertEquals("done", Fixtures.await(response, "the response").get("result")); + late.get().progress("too late", null); + host.close(); + assertTrue(host.messages().stream().noneMatch(message -> message.get("params") instanceof Map + && "too late".equals(object(message.get("params")).get("message")))); + } + + @Test + void aPluginErrorCarriesItsCodeAndOtherFailuresHideTheirDetails() { + Backend backend = Backend.create(null, null) + .function("locked", (params, context) -> { + throw new PluginError("maven.snapshotLocked", "Snapshot is locked."); + }) + .function("crash", (params, context) -> { + throw new IOException("No such file: /Users/someone/secret"); + }); + TestHost host = new TestHost(backend); + host.initialize(); + Map locked = host.request("lithe/function/invoke", invoke("locked", "a", null)); + assertEquals(map("code", -32000L, "message", "Snapshot is locked.", "data", map("code", "maven.snapshotLocked")), + locked.get("error")); + Map crash = object(host.request("lithe/function/invoke", invoke("crash", "b", null)).get("error")); + assertEquals(-32603L, crash.get("code")); + assertFalse(((String) crash.get("message")).contains("/Users/")); + assertFalse(crash.containsKey("data")); + assertEquals("", host.errors()); + List> facts = new ArrayList<>(); + for (Map message : host.messages()) { + if ("lithe/log".equals(message.get("method"))) facts.add(object(message.get("params"))); + } + assertEquals(2, facts.size()); + assertEquals("operationFailed", facts.get(0).get("kind")); + assertEquals("maven.snapshotLocked", facts.get(0).get("code")); + assertEquals("internal_error", facts.get(1).get("code")); + assertEquals("b", facts.get(1).get("operationId")); + assertThrows(IllegalArgumentException.class, () -> new PluginError("bad code!", "x")); + host.close(); + } + + @Test + void typedDiagnosticsCorrelateAndSuppressOnlySuccess() { + Backend backend = Backend.create(null, null).function("work.inspect", (params, context) -> { + context.diagnostics().degraded("work.inspect", "cache_unavailable", 2L); + context.diagnostics().recovered("work.inspect", "cache_restored"); + if (Boolean.TRUE.equals(params.get("fail"))) throw new PluginError("work.failed", "private explanation"); + return null; + }).suppressSuccessDiagnostics("work.inspect"); + TestHost host = new TestHost(backend); + try { + host.initialize(); + host.request("lithe/function/invoke", invoke("work.inspect", "host-success", map("fail", false))); + host.request("lithe/function/invoke", invoke("work.inspect", "host-failure", map("fail", true))); + List> facts = new ArrayList<>(); + for (Map message : host.messages()) { + if ("lithe/log".equals(message.get("method"))) facts.add(object(message.get("params"))); + } + assertEquals(5, facts.size()); + assertEquals("operationFailed", facts.get(4).get("kind")); + assertEquals("host-failure", facts.get(4).get("operationId")); + assertEquals("work.failed", facts.get(4).get("code")); + assertFalse(facts.toString().contains("private explanation")); + assertEquals("", host.errors()); + assertThrows(PluginSdkError.class, () -> backend.diagnostics().degraded("unknown", "failure")); + assertThrows(PluginSdkError.class, () -> backend.diagnostics().degraded("work.inspect", "raw message")); + } finally { host.close(); } + } + + @Test + void modelDiagnosticsReuseOriginalRpcIdWhenOperationIsAbsent() { + Backend backend = Backend.create(null, null).onModel((params, context) -> { + context.diagnostics().degraded("lithe/build/model", "snapshot_unavailable"); + return map("projects", Collections.emptyList()); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(); + host.request("lithe/build/model", map("root", "."), 42L); + List operations = new ArrayList<>(); + for (Map message : host.messages()) { + if ("lithe/log".equals(message.get("method"))) operations.add(object(message.get("params")).get("operationId")); + } + assertEquals(Arrays.asList("42", "42"), operations); + } finally { host.close(); } + } + + @Test + void initializeDiagnosticsWaitForCapabilityAnswerAndStayBounded() { + Backend backend = Backend.create(null, null); + backend.onInitialize((params, negotiated) -> { + for (long count = 0; count < 10; count++) { + backend.diagnostics().degraded("initialize", "snapshot_unavailable", count); + } + }); + TestHost host = new TestHost(backend); + try { + host.initialize(); + host.request("lithe/function/invoke", invoke("missing", "barrier", null)); + List> messages = host.messages(); + assertNotNull(messages.get(0).get("result"), "capability answer precedes diagnostics"); + List counts = new ArrayList<>(); + for (Map message : messages) { + if ("lithe/log".equals(message.get("method"))) counts.add(object(message.get("params")).get("count")); + } + assertEquals(Arrays.asList(0L, 1L, 2L, 3L, 4L, 5L, 6L, 7L), counts); + } finally { host.close(); } + } + + @Test + void aFailingCancellationListenerNeitherSkipsTheOthersNorTheReply() { + CountDownLatch laterListenerRan = new CountDownLatch(1); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch cancelled = new CountDownLatch(1); + Backend backend = Backend.create(null, null) + .function("listener.settles", (params, context) -> { + context.cancellation().onCancel(() -> { + throw new IllegalStateException("clean-up failed"); + }); + context.cancellation().onCancel(laterListenerRan::countDown); + return "ok"; + }) + .function("listener.cancelled", (params, context) -> { + context.cancellation().onCancel(() -> { + throw new IllegalStateException("clean-up failed"); + }); + context.cancellation().onCancel(cancelled::countDown); + started.countDown(); + Fixtures.await(cancelled, "the cancellation"); + context.throwIfCancelled(); + return "not reached"; + }); + TestHost host = new TestHost(backend); + host.initialize(); + // The call ending runs the listeners before the reply. + assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "s", null)).get("result")); + Fixtures.await(laterListenerRan, "the listener after the failing one"); + // $/cancelRequest runs them on the read loop, which must keep reading. + CompletableFuture> response = host.requestAsync("lithe/function/invoke", + invoke("listener.cancelled", "c", null), 42L); + Fixtures.await(started, "the handler to start"); + host.notify("$/cancelRequest", map("id", 42L)); + assertEquals(-32800L, errorCode(Fixtures.await(response, "the cancelled call"))); + assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "t", null)).get("result")); + host.close(); + } + + @Test + void cancelRequestCancelsTheHandlerAndAnswersRequestCancelled() { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch cancelled = new CountDownLatch(1); + Backend backend = Backend.create(null, null).function("wait.forever", (params, context) -> { + context.cancellation().onCancel(cancelled::countDown); + started.countDown(); + Fixtures.await(cancelled, "the cancellation"); + context.throwIfCancelled(); + return "not reached"; + }); + TestHost host = new TestHost(backend); + host.initialize(); + CompletableFuture> response = host.requestAsync("lithe/function/invoke", + invoke("wait.forever", "c", null), 41L); + Fixtures.await(started, "the handler to start"); + host.notify("$/cancelRequest", map("id", 41L)); + assertEquals(-32800L, errorCode(Fixtures.await(response, "the response"))); + host.close(); + } + + @Test + void registerAndUnregisterFunctionGoThroughTheHostAllOrNothing() throws Exception { + Backend backend = Backend.create(null, null); + TestHost host = new TestHost(backend); + host.initialize(); + + CompletableFuture registering = backend.registerFunction("late.one", (params, context) -> "late"); + Map register = host.waitFor( + message -> "lithe/function/register".equals(message.get("method")), "the registration"); + assertEquals(map("functions", Arrays.asList(map("id", "late.one"))), register.get("params")); + host.send(map("id", register.get("id"), "result", null)); + Fixtures.await(registering, "the registration to settle"); + assertEquals("late", host.request("lithe/function/invoke", invoke("late.one", "d", null)).get("result")); + + // A refused registration leaves no handler behind. + CompletableFuture refused = backend.registerFunction("other.one", (params, context) -> 0); + Map second = host.waitFor(message -> "lithe/function/register".equals(message.get("method")) + && object(message.get("params")).toString().contains("other.one"), "the second registration"); + host.send(map("id", second.get("id"), "error", + map("code", -32602L, "message", "No.", "data", map("code", "plugin.function.invalidRegistration")))); + ExecutionException failure = assertThrows(ExecutionException.class, + () -> refused.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); + assertEquals("plugin.function.invalidRegistration", ((PluginSdkError) unwrap(failure)).code()); + assertEquals(Arrays.asList("late.one"), backend.functionIds()); + + CompletableFuture unregistering = backend.unregisterFunction("late.one"); + Map unregister = host.waitFor( + message -> "lithe/function/unregister".equals(message.get("method")), "the withdrawal"); + assertEquals(map("ids", Arrays.asList("late.one")), unregister.get("params")); + host.send(map("id", unregister.get("id"), "result", null)); + Fixtures.await(unregistering, "the withdrawal to settle"); + assertEquals(Collections.emptyList(), backend.functionIds()); + host.close(); + } + + @Test + void buildHandlersGetTheCanonicalFormatStampedAndAWrongFormatFails() { + Backend backend = Backend.create(null, null) + .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", params.get("root"))))) + .onDependencies((params, context) -> map("format", "litheBuildModel", "projects", Collections.emptyList())); + TestHost host = new TestHost(backend); + host.initialize(); + Map model = host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")); + assertEquals(map("projects", Arrays.asList(map("name", "app", "path", ".")), "format", "litheBuildModel", "version", 0L), + model.get("result")); + Map dependencies = host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")); + assertEquals(-32603L, errorCode(dependencies)); + host.close(); + } + + @Test + void aBuildResultCannotReplaceTheVersionTheSdkStamps() { + Backend backend = Backend.create(null, null) + .onModel((params, context) -> map("version", 1, "projects", Collections.emptyList())) + .onDependencies((params, context) -> map("version", 0, "dependencies", Collections.emptyList())); + TestHost host = new TestHost(backend); + host.initialize(); + assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); + assertEquals(map("version", 0L, "dependencies", Collections.emptyList(), "format", "litheDependencies"), + host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")).get("result")); + host.close(); + } + + @Test + void aResultJsonCannotEncodeOrOneOverOneMebibyteFailsAndTheSessionContinues() { + Map cycle = map(); + cycle.put("self", cycle); + char[] large = new char[1 << 20]; + Arrays.fill(large, 'x'); + Backend backend = Backend.create(null, null) + .function("result.object", (params, context) -> new Object()) + .function("result.cycle", (params, context) -> cycle) + .function("result.nan", (params, context) -> Double.NaN) + .function("result.large", (params, context) -> new String(large)) + .function("result.fine", (params, context) -> map("ok", true)) + .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", ".", "size", new Object())))); + TestHost host = new TestHost(backend); + host.initialize(); + int operation = 0; + for (String functionId : Arrays.asList("result.object", "result.cycle", "result.nan", "result.large")) { + Map response = host.request("lithe/function/invoke", invoke(functionId, "op-" + operation++, null)); + assertEquals(-32603L, errorCode(response), functionId); + } + assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); + assertEquals(map("ok", true), host.request("lithe/function/invoke", invoke("result.fine", "op-last", null)).get("result")); + assertEquals(1, host.close()); + } + + @Test + void buildRequestsAreRefusedWhenTheHostDidNotNegotiateLitheBuild() { + Backend backend = Backend.create(null, null).onModel((params, context) -> map("projects", Collections.emptyList())); + TestHost host = new TestHost(backend); + host.initialize(map("litheFunctions", map("versions", Arrays.asList(0L)))); + assertEquals(-32601L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); + host.close(); + } + + @Test + void shutdownCancelsRunningCallsAndRunsHooksAndExitAfterShutdownIsZero() { + CountDownLatch started = new CountDownLatch(1); + List order = Collections.synchronizedList(new ArrayList<>()); + Backend backend = Backend.create(null, null) + .function("wait.forever", (params, context) -> { + CountDownLatch cancelled = new CountDownLatch(1); + context.cancellation().onCancel(cancelled::countDown); + started.countDown(); + Fixtures.await(cancelled, "the shutdown to cancel the call"); + context.throwIfCancelled(); + return null; + }) + .onShutdown(() -> order.add("hook")); + TestHost host = new TestHost(backend); + host.initialize(); + CompletableFuture> call = host.requestAsync("lithe/function/invoke", + invoke("wait.forever", "e", null), 2001L); + Fixtures.await(started, "the handler to start"); + Map shutdown = host.request("shutdown", null); + assertTrue(shutdown.containsKey("result")); + assertNull(shutdown.get("result")); + assertEquals(-32800L, errorCode(Fixtures.await(call, "the cancelled call"))); + assertEquals(Arrays.asList("hook"), order); + assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("wait.forever", "f", null)))); + host.notify("exit", null); + assertEquals(0, host.exitCode()); + } + + @Test + void exitWithoutShutdownOrAClosedInputEndsWithCodeOne() { + TestHost host = new TestHost(Backend.create(null, null)); + host.initialize(); + host.notify("exit", null); + assertEquals(1, host.exitCode()); + assertEquals(1, new TestHost(Backend.create(null, null)).close()); + } + + @Test + void aMalformedBodyIsAnsweredWithAParseErrorAndTheSessionContinues() { + TestHost host = new TestHost(functionBackend()); + byte[] body = "{not json".getBytes(StandardCharsets.UTF_8); + host.sendBytes(("Content-Length: " + body.length + "\r\n\r\n{not json").getBytes(StandardCharsets.UTF_8)); + Map parseError = host.waitFor(message -> message.get("error") instanceof Map + && Long.valueOf(-32700L).equals(object(message.get("error")).get("code")), "the parse error"); + assertTrue(parseError.containsKey("id")); + assertNull(parseError.get("id")); + assertNotNull(host.initialize().get("result")); + host.close(); + } + + @Test + void aJsonValueThatIsNotAMessageObjectIsRefusedAndTheSessionContinues() { + TestHost host = new TestHost(functionBackend()); + host.sendBytes(Framing.encode(Arrays.asList(1, 2))); + host.waitFor(message -> message.get("error") instanceof Map + && Long.valueOf(-32600L).equals(object(message.get("error")).get("code")), "the refusal"); + assertNotNull(host.initialize().get("result")); + host.close(); + } + + @Test + void registeringFromAnInitializeHookJoinsTheInitialListWithoutARequest() { + Backend backend = Backend.create(null, null).function("a.one", (params, context) -> 1); + backend.onInitialize((params, negotiated) -> { + backend.registerFunction("b.two", (arguments, context) -> 2); + backend.unregisterFunction("a.one"); + }); + TestHost host = new TestHost(backend); + Map experimental = object(object(object(host.initialize().get("result")).get("capabilities")).get("experimental")); + assertEquals(Arrays.asList(map("id", "b.two")), object(experimental.get("litheFunctions")).get("functions")); + assertTrue(host.messages().stream().noneMatch(message -> message.get("method") instanceof String + && ((String) message.get("method")).startsWith("lithe/function/"))); + host.close(); + } + + @Test + void runtimeRegistrationNeedsTheNegotiatedProtocol() { + Backend backend = Backend.create(null, null); + TestHost host = new TestHost(backend); + host.initialize(map()); + ExecutionException failure = assertThrows(ExecutionException.class, () -> backend + .registerFunction("late.one", (params, context) -> 1).get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); + assertEquals("sdk.functions.unavailable", ((PluginSdkError) unwrap(failure)).code()); + host.close(); + } + + @Test + void aShutdownDuringASlowInitializeHookKeepsTheSessionClosed() { + CountDownLatch hookStarted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Backend backend = Backend.create(null, null).onInitialize((params, negotiated) -> { + hookStarted.countDown(); + Fixtures.await(release, "the test to release initialize"); + }); + TestHost host = new TestHost(backend); + CompletableFuture> initialize = host.requestAsync("initialize", + map("capabilities", map("experimental", map("litheLogging", map("versions", Arrays.asList(0))))), 3001L); + Fixtures.await(hookStarted, "the initialize hook"); + assertNull(host.request("shutdown", null).get("error")); + release.countDown(); + assertEquals(-32600L, errorCode(Fixtures.await(initialize, "the initialize answer"))); + assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("x", "y", null)))); + host.notify("exit", null); + assertEquals(0, host.exitCode()); + } + + @Test + void notificationsReachTheirHandlerOnlyOnceReady() { + CountDownLatch received = new CountDownLatch(1); + AtomicBoolean early = new AtomicBoolean(); + Backend backend = Backend.create(null, null).onNotification("workspace/didChangeConfiguration", params -> { + if (!(params instanceof Map)) { + early.set(true); + } + received.countDown(); + }); + TestHost host = new TestHost(backend); + host.notify("workspace/didChangeConfiguration", "before"); + host.initialize(); + host.notify("workspace/didChangeConfiguration", map("settings", map())); + Fixtures.await(received, "the notification"); + assertFalse(early.get()); + host.close(); + } + + private static Throwable unwrap(Throwable error) { + Throwable current = error; + while ((current instanceof ExecutionException || current instanceof CompletionException) && current.getCause() != null) { + current = current.getCause(); + } + return current; + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java new file mode 100644 index 00000000..31665165 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BuildTest.java @@ -0,0 +1,224 @@ +package io.github.litheidea.plugin.sdk; + +import static io.github.litheidea.plugin.sdk.Fixtures.map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** Typed build operations use the same negotiation and ownership boundaries as Core. */ +@Timeout(value = 20, unit = TimeUnit.SECONDS) +class BuildTest { + private static final Map LAUNCH = map("executable", map("kind", "toolchain", "value", "project-jdk"), + "arguments", Arrays.asList("Main", "a b"), "workingDirectory", "app", "environment", map()); + + private static Map offered(Object... versions) { + return map("litheBuild", map("versions", Arrays.asList(versions))); + } + + private static Object errorCode(Map response) { + return Json.object(response.get("error")).get("code"); + } + + @Test + void versionZeroAdmitsEveryTypedMethod() { + List methods = Arrays.asList("execute", "test", "discoverTests", "prepareRun", "prepareDebug"); + List> replies = Arrays.asList(map("exitCode", 0, "output", "built\n"), + map("exitCode", 2, "output", "test failed\n"), + map("tests", Arrays.asList(map("suite", "Suite", "case", "exact # [case]"))), LAUNCH, map("program", "app/bin/main", "arguments", Arrays.asList("a b"), "workingDirectory", "app", "environment", map())); + Backend backend = Backend.create(null, null) + .onExecute((params, context) -> replies.get(0)) + .onTest((params, context) -> replies.get(1)) + .onDiscoverTests((params, context) -> replies.get(2)) + .onPrepareRun((params, context) -> replies.get(3)) + .onPrepareDebug((params, context) -> replies.get(4)); + TestHost host = new TestHost(backend); + try { + Map result = Json.object(host.initialize(offered(0, 99)).get("result")); + Map experimental = Json.object(Json.object(result.get("capabilities")).get("experimental")); + assertEquals(0L, Json.object(experimental.get("litheBuild")).get("version")); + assertTrue(backend.negotiated().build()); + for (int index = 0; index < methods.size(); index++) { + Map response = host.request("lithe/build/" + methods.get(index), map("operationID", "wire-" + index)); + // Compare JSON-normalized numeric types, as seen on the wire. + Map expected = Json.object(Json.parse(Json.stringify(replies.get(index)))); + if (index == 2) { expected.put("format", "litheTestDiscovery"); expected.put("version", 0L); } + assertEquals(expected, response.get("result")); + } + } finally { host.close(); } + } + + @Test + void modelInputsReachTheHandlerAndPreserveHostIdentity() { + Map inputs = map("version", 0L, "revision", 17L, "inputs", Arrays.asList( + map("pluginID", "dev.source", "buildSystemID", "source", "state", "unavailable", "instances", Collections.emptyList()))); + AtomicReference seen = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onModel((params, context) -> { + seen.set(params.get("modelInputs")); + return map("projects", Collections.emptyList(), "targets", Collections.emptyList(), "tasks", Collections.emptyList()); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(0)); + Map response = host.request("lithe/build/model", map("modelInputs", inputs)); + assertEquals(inputs, seen.get()); + assertTrue(response.containsKey("result")); + } finally { host.close(); } + } + + @Test + void negotiationRequiresTheHostToOfferVersionZeroAsAnInteger() { + for (Object[] versions : new Object[][] {{0}, {99, 0, 1}}) { + Backend backend = Backend.create(null, null).onExecute((params, context) -> map("exitCode", 0, "output", "")); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(versions)); + assertTrue(backend.negotiated().build()); + } finally { host.close(); } + } + for (Object[] versions : new Object[][] {{1, 4, 3, 2}, {99, "0", false}, {}}) { + Backend backend = Backend.create(null, null).onExecute((params, context) -> map("exitCode", 0, "output", "")); + TestHost host = new TestHost(backend); + try { + Map result = Json.object(host.initialize(offered(versions)).get("result")); + Map experimental = Json.object(Json.object(result.get("capabilities")).get("experimental")); + assertFalse(experimental.containsKey("litheBuild")); + assertFalse(backend.negotiated().build()); + assertEquals(-32601L, errorCode(host.request("lithe/build/execute", map("operationID", "wire-unoffered")))); + } finally { host.close(); } + } + } + + @Test + void progressUsesTheWireIdAndStopsWhenTheCallEnds() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference late = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onPrepareRun((params, context) -> { + assertEquals(params.get("operationID"), context.operationId()); + late.set(context); + context.progress(repeat("😀", 2000), 140.0, repeat("界", 30000)); + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS), "preparation was not released"); + return LAUNCH; + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(0)); + CompletableFuture> pending = host.requestAsync("lithe/build/prepareRun", map("operationID", "wire-42"), 42L); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Map report = Json.object(host.waitFor(message -> Protocol.BUILD_PROGRESS.equals(message.get("method")), "progress").get("params")); + assertEquals("wire-42", report.get("operationID")); + assertEquals(4096, Protocol.utf8Length((String) report.get("message"))); + assertEquals(65535, Protocol.utf8Length((String) report.get("output"))); + assertEquals(100L, report.get("percentage")); + release.countDown(); + assertEquals(LAUNCH, Fixtures.await(pending, "preparation to finish").get("result")); + late.get().progress("late", null, "late output"); + assertEquals(1L, host.messages().stream().filter(message -> Protocol.BUILD_PROGRESS.equals(message.get("method"))).count()); + } finally { release.countDown(); host.close(); } + } + + @Test + void nativeDebugProgressUsesWireIdentityAndStopsAfterCompletion() throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference late = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onPrepareDebug((params, context) -> { + assertEquals(params.get("operationID"), context.operationId()); + late.set(context); + context.progress(repeat("😀", 2000), 140.0, repeat("界", 30000)); + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS), "preparation was not released"); + return map("program", "app/bin/main", "arguments", Collections.emptyList(), "workingDirectory", "app", "environment", map()); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(0)); + CompletableFuture> pending = host.requestAsync("lithe/build/prepareDebug", map("operationID", "wire-42"), 42L); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Map report = Json.object(host.waitFor(message -> Protocol.BUILD_PROGRESS.equals(message.get("method")), "progress").get("params")); + assertEquals("wire-42", report.get("operationID")); + assertEquals(4096, Protocol.utf8Length((String) report.get("message"))); + assertEquals(65535, Protocol.utf8Length((String) report.get("output"))); + assertEquals(100L, report.get("percentage")); + release.countDown(); + assertTrue(Json.object(Fixtures.await(pending, "preparation to finish").get("result")).containsKey("program")); + late.get().progress("late", null, "late output"); + assertEquals(1L, host.messages().stream().filter(message -> Protocol.BUILD_PROGRESS.equals(message.get("method"))).count()); + } finally { release.countDown(); host.close(); } + } + + @Test + void cancellingOneBuildDoesNotCancelAnotherAndDropsCancelledProgress() throws Exception { + CountDownLatch entered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + Backend backend = Backend.create(null, null).onExecute((params, context) -> { + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + context.progress("cancelled progress", null); + return map("exitCode", 0, "output", ""); + }).onTest((params, context) -> { + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return map("exitCode", 3, "output", "independent failure"); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(0)); + CompletableFuture> pending = host.requestAsync("lithe/build/execute", map("operationID", "wire-cancel"), 42L); + CompletableFuture> sibling = host.requestAsync("lithe/build/test", map("operationID", "wire-other"), 43L); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + host.notify("$/cancelRequest", map("id", 42L)); + // The barrier response proves the read loop saw cancellation before + // releasing the first handler; no timing sleep chooses the race. + Map barrier = host.request("custom.barrier", map()); + assertEquals(-32601L, errorCode(barrier)); + release.countDown(); + assertEquals(-32800L, errorCode(Fixtures.await(pending, "cancelled execution"))); + assertEquals(map("exitCode", 3L, "output", "independent failure"), Fixtures.await(sibling, "sibling execution").get("result")); + assertFalse(host.messages().stream().anyMatch(message -> Protocol.BUILD_PROGRESS.equals(message.get("method")))); + } finally { release.countDown(); host.close(); } + } + + @Test + void invalidRepliesAndParametersDoNotEndTheSession() { + AtomicReference reply = new AtomicReference<>(); + Backend backend = Backend.create(null, null).onExecute((params, context) -> reply.get()) + .onDiscoverTests((params, context) -> map("format", "wrong", "tests", Collections.emptyList())) + .onPrepareRun((params, context) -> map("executable", false)); + TestHost host = new TestHost(backend); + try { + host.initialize(offered(0)); + assertEquals(-32602L, errorCode(host.request("lithe/build/execute", map()))); + assertEquals(-32602L, errorCode(host.request("lithe/build/execute", Collections.emptyList()))); + for (Object value : Arrays.asList(map(), map("exitCode", true, "output", ""), + map("exitCode", 2147483648L, "output", ""), map("exitCode", 0, "output", false))) { + reply.set(value); + assertEquals(-32603L, errorCode(host.request("lithe/build/execute", map("operationID", "wire-invalid")))); + } + for (String method : Arrays.asList("discoverTests", "prepareRun")) { + assertEquals(-32603L, errorCode(host.request("lithe/build/" + method, map("operationID", "wire-invalid")))); + } + reply.set(map("exitCode", 7, "output", "tool failure is a result")); + assertEquals(map("exitCode", 7L, "output", "tool failure is a result"), + host.request("lithe/build/execute", map("operationID", "wire-valid")).get("result")); + } finally { host.close(); } + } + + private static String repeat(String text, int count) { + StringBuilder result = new StringBuilder(); + for (int index = 0; index < count; index++) result.append(text); + return result.toString(); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java new file mode 100644 index 00000000..c7f13e43 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java @@ -0,0 +1,133 @@ +package io.github.litheidea.plugin.sdk; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BooleanSupplier; + +/** Small helpers shared by the SDK tests. */ +final class Fixtures { + private Fixtures() {} + + /** A map from alternating keys and values, in order. */ + static Map map(Object... entries) { + Map map = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + map.put((String) entries[index], entries[index + 1]); + } + return map; + } + + /** The future's value within {@link TestHost#WAIT_MILLIS}. */ + static T await(CompletableFuture future, String description) { + try { + return future.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS); + } catch (TimeoutException error) { + throw new AssertionError("Timed out waiting for " + description + ".", error); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted waiting for " + description + ".", error); + } catch (ExecutionException error) { + throw new AssertionError(description + " failed.", error.getCause()); + } + } + + /** Waits for {@code latch} within {@link TestHost#WAIT_MILLIS}. */ + static void await(CountDownLatch latch, String description) { + try { + if (!latch.await(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { + throw new AssertionError("Timed out waiting for " + description + "."); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted waiting for " + description + ".", error); + } + } + + /** + * Bounded poll of an OS-level fact, such as a process having exited, that + * offers the test no notification channel. + */ + static void eventually(BooleanSupplier check, String description) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(TestHost.WAIT_MILLIS); + while (!check.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Timed out waiting for " + description + "."); + } + try { + // test-stability: allow(real-sleep) reason: the polled fact is a separate OS process's state with no notification channel; the deadline above bounds the loop. + Thread.sleep(10); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted waiting for " + description + ".", error); + } + } + } + + /** True while the POSIX process {@code pid} exists. */ + static boolean isAlive(long pid) { + try { + Process probe = new ProcessBuilder("kill", "-0", Long.toString(pid)).start(); + if (!probe.waitFor(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { + probe.destroyForcibly(); + throw new AssertionError("kill -0 did not answer."); + } + return probe.exitValue() == 0; + } catch (IOException error) { + throw new UncheckedIOException(error); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new AssertionError(error); + } + } + + /** The PID a tool wrote to {@code file}, once it is there. */ + static long awaitPid(Path file) { + eventually(() -> { + try { + return Files.isRegularFile(file) && !new String(Files.readAllBytes(file)).trim().isEmpty(); + } catch (IOException error) { + return false; + } + }, "a PID in " + file.getFileName()); + try { + return Long.parseLong(new String(Files.readAllBytes(file)).trim()); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + static void deleteTree(Path root) { + if (!Files.exists(root)) { + return; + } + try { + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java new file mode 100644 index 00000000..8b51dd00 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java @@ -0,0 +1,877 @@ +package io.github.litheidea.plugin.sdk; + +import static io.github.litheidea.plugin.sdk.Fixtures.map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** Host services; mirrors {@code sdk/node/test/host.test.mjs}. */ +@Timeout(value = 30, unit = TimeUnit.SECONDS) +class HostTest { + private static final Map CONTEXT = Collections.unmodifiableMap(map("contextID", "w1", "generation", 2L)); + private static final Map FIXTURE = loadFixture(); + + private static Map loadFixture() { + try { + return object(Json.parse(new String(Files.readAllBytes( + Paths.get("../../../contract/v1/fixtures/backend/function-protocol.json")), StandardCharsets.UTF_8))); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return (Map) value; + } + + private static List> cases(String key) { + List> cases = new ArrayList<>(); + for (Object entry : (List) FIXTURE.get(key)) { + cases.add(object(entry)); + } + return cases; + } + + /** A host whose session negotiated plugin functions. */ + private static TestHost session(Backend backend) { + TestHost host = new TestHost(backend); + host.initialize(map("litheFunctions", map("versions", Arrays.asList(0L)))); + return host; + } + + /** The next host request the backend wrote that the test has not taken yet. */ + private static Map nextRequest(TestHost host, String method, Set taken) { + Map request = host.waitFor(message -> method.equals(message.get("method")) + && message.get("id") != null && !taken.contains(message.get("id")), method); + taken.add(request.get("id")); + return request; + } + + private static Map nextRequest(TestHost host, String method) { + return nextRequest(host, method, new HashSet<>()); + } + + private static T value(CompletableFuture future) { + return Fixtures.await(future, "the host request"); + } + + /** What {@code future} failed with. */ + private static Throwable failure(CompletableFuture future) { + try { + future.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS); + } catch (ExecutionException error) { + return error.getCause(); + } catch (InterruptedException | TimeoutException error) { + throw new AssertionError("The host request did not fail in time.", error); + } + return fail("The host request succeeded."); + } + + private static String code(CompletableFuture future) { + Throwable error = failure(future); + assertTrue(error instanceof PluginSdkError, String.valueOf(error)); + return ((PluginSdkError) error).code(); + } + + private static List> sent(TestHost host, String method) { + return host.messages().stream().filter(message -> method.equals(message.get("method"))).collect(Collectors.toList()); + } + + @Test + void nativeTerminalMethodsKeepContextAndLiteralArguments() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + try { + CompletableFuture> creating = backend.host().terminal().create(CONTEXT, + map("executable", "example-client", "arguments", Arrays.asList("literal;$(value)", "space value"))); + Map request = nextRequest(host, Protocol.HOST_TERMINAL_CREATE); + assertEquals(CONTEXT, object(request.get("params")).get("context")); + assertEquals(Arrays.asList("literal;$(value)", "space value"), object(request.get("params")).get("arguments")); + host.send(map("id", request.get("id"), "result", map("terminalID", "owned"))); + assertEquals("owned", value(creating).get("terminalID")); + CompletableFuture> closing = backend.host().terminal().close(CONTEXT, map("terminalID", "owned")); + Map close = nextRequest(host, Protocol.HOST_TERMINAL_CLOSE); + host.send(map("id", close.get("id"), "result", map("retainedContextBytes", 4096L))); + assertEquals(4096L, value(closing).get("retainedContextBytes")); + CompletableFuture> reopening = backend.host().terminal().reopen(CONTEXT, map("terminalID", "owned")); + Map reopen = nextRequest(host, Protocol.HOST_TERMINAL_REOPEN); + host.send(map("id", reopen.get("id"), "result", map("terminalID", "owned", "generation", 2L, "restoredContextBytes", 4096L))); + assertEquals(2L, value(reopening).get("generation")); + } finally { host.close(); } + } + + @Test + void theSdkAnswersPluginFunctionsOnlyWhenTheHostOffersVersionZero() { + Object[][] cases = {{Arrays.asList(0L), true}, {Arrays.asList(2L, 99L, 0L), true}, {Arrays.asList(1L), false}, + {Arrays.asList(99L), false}, {Collections.emptyList(), false}}; + for (Object[] testCase : cases) { + Backend backend = Backend.create(null, null).function("query.run", (params, context) -> null); + TestHost host = new TestHost(backend); + Map result = object(host.initialize(map("litheFunctions", map("versions", testCase[0]))).get("result")); + Map experimental = object(object(result.get("capabilities")).get("experimental")); + boolean expected = (Boolean) testCase[1]; + if (expected) { + assertEquals(map("version", 0L, "functions", Arrays.asList(map("id", "query.run"))), + experimental.get("litheFunctions")); + } else { + assertFalse(experimental.containsKey("litheFunctions")); + } + assertEquals(expected, backend.negotiated().functions()); + assertEquals(expected, backend.host().negotiated()); + host.close(); + } + } + + @Test + void credentialsRoundTripThroughTheHostWithTheContractShapes() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Host.Credentials credentials = backend.host().credentials(); + Set taken = new HashSet<>(); + + CompletableFuture reading = credentials.get("connection.prod"); + Map get = nextRequest(host, Protocol.HOST_CREDENTIALS_GET, taken); + assertEquals(map("key", "connection.prod"), get.get("params")); + host.send(map("id", get.get("id"), "result", map("secret", "s3cret"))); + assertEquals("s3cret", value(reading)); + + CompletableFuture missing = credentials.get("connection.prod"); + host.send(map("id", nextRequest(host, Protocol.HOST_CREDENTIALS_GET, taken).get("id"), "result", null)); + assertNull(value(missing)); + + CompletableFuture malformed = credentials.get("connection.prod"); + host.send(map("id", nextRequest(host, Protocol.HOST_CREDENTIALS_GET, taken).get("id"), "result", map())); + assertEquals("sdk.host.invalidResult", code(malformed)); + + CompletableFuture writing = credentials.set("connection.prod:password", "s3cret"); + Map set = nextRequest(host, Protocol.HOST_CREDENTIALS_SET, taken); + assertEquals(map("key", "connection.prod:password", "secret", "s3cret"), set.get("params")); + host.send(map("id", set.get("id"), "result", null)); + assertNull(value(writing)); + + CompletableFuture deleting = credentials.delete("connection.prod"); + Map remove = nextRequest(host, Protocol.HOST_CREDENTIALS_DELETE, taken); + assertEquals(map("key", "connection.prod"), remove.get("params")); + host.send(map("id", remove.get("id"), "result", null)); + value(deleting); + + // Malformed arguments fail locally and never reach the host. + int before = host.messages().size(); + StringBuilder longKey = new StringBuilder(); + for (int index = 0; index < 129; index++) { + longKey.append('k'); + } + for (String key : Arrays.asList("", "has space", longKey.toString(), null)) { + assertEquals("sdk.credentials.invalidKey", code(credentials.get(key)), String.valueOf(key)); + } + StringBuilder secret = new StringBuilder(); + for (int index = 0; index <= Protocol.MAX_CREDENTIAL_SECRET_BYTES; index++) { + secret.append('x'); + } + assertEquals("sdk.credentials.invalidSecret", code(credentials.set("key", null))); + assertEquals("sdk.credentials.invalidSecret", code(credentials.set("key", secret.toString()))); + assertEquals(before, host.messages().size()); + host.close(); + } + + @Test + void localKeyAndContextChecksAgreeWithTheFixtureCasesCoreRefusesOrForwards() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + for (Map testCase : cases("hostRequests")) { + String name = (String) testCase.get("name"); + String method = (String) testCase.get("method"); + Object rawParams = testCase.get("params"); + if (!(rawParams instanceof Map)) { + // Array params and absent params carry no key or context to compare. + continue; + } + Map params = object(rawParams); + Map expected = object(testCase.get("expected")); + if (method.startsWith("lithe/host/credentials/")) { + String key = (String) params.get("key"); + if (expected.get("forward") != null) { + assertTrue(Protocol.isCredentialKey(key), name); + } else if (name.contains("key")) { + assertEquals("sdk.credentials.invalidKey", code(backend.host().credentials().get(key)), name); + } + } + if (name.matches(".*(context (without|ID outside)|generation is positive).*")) { + assertEquals("sdk.host.invalidContext", + code(backend.host().workspace().getFolders(object(params.get("context")))), name); + } + } + // A full context from listContexts is passed on as its reference. + Map full = map("contextID", "w1", "generation", 2L, "name", "orders", "folders", Collections.emptyList()); + CompletableFuture> asking = backend.host().workspace().getFolders(full); + Map request = nextRequest(host, Protocol.HOST_WORKSPACE_GET_FOLDERS); + assertEquals(map("context", CONTEXT), request.get("params")); + host.send(map("id", request.get("id"), "result", map("folders", Collections.emptyList()))); + assertEquals(map("folders", Collections.emptyList()), value(asking)); + host.close(); + } + + @Test + void hostRefusalsBecomeHostErrorWithTheCodeNumberAndDetailsCoreSent() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Set taken = new HashSet<>(); + java.util.function.Function, HostError> answer = wire -> { + CompletableFuture pending = backend.host().request(Protocol.HOST_WORKSPACE_LIST_CONTEXTS, null); + Map request = nextRequest(host, Protocol.HOST_WORKSPACE_LIST_CONTEXTS, taken); + host.send(map("id", request.get("id"), "error", wire)); + Throwable error = failure(pending); + assertTrue(error instanceof HostError, String.valueOf(error)); + return (HostError) error; + }; + for (Map testCase : cases("hostRequests")) { + Map expected = object(testCase.get("expected")); + if (expected.get("code") == null) { + continue; + } + String name = (String) testCase.get("name"); + Map data = map("code", expected.get("code")); + if (expected.get("details") != null) { + data.put("details", expected.get("details")); + } + HostError error = answer.apply(map("code", expected.get("rpcCode"), "message", "Refused.", "data", data)); + assertEquals(expected.get("code"), error.code(), name); + assertEquals(((Number) expected.get("rpcCode")).intValue(), error.rpcCode().intValue(), name); + assertEquals(expected.get("details"), error.details(), name); + assertEquals(Protocol.HOST_WORKSPACE_LIST_CONTEXTS, error.method(), name); + } + for (Map testCase : cases("hostFailures")) { + Map expected = object(testCase.get("expected")); + String name = (String) testCase.get("name"); + int rpcCode = ((Number) expected.get("rpcCode")).intValue(); + assertEquals(Integer.valueOf(rpcCode), HostError.CODES.get(expected.get("code")), name); + // The number alone names the code. + assertEquals(expected.get("code"), answer.apply(map("code", (long) rpcCode, "message", "Failed.")).code(), name); + } + HostError unknown = answer.apply(map("code", -32050L, "message", "Later code.", + "data", map("details", Arrays.asList("not", "an", "object")))); + assertEquals("plugin.host.failed", unknown.code()); + assertNull(unknown.details()); + host.close(); + } + + @Test + void withoutPluginFunctionsTheHostOffersNoServiceAndHostRequestsStayWithinLitheHost() { + Backend backend = Backend.create(null, null); + TestHost host = new TestHost(backend); + host.initialize(map()); + assertFalse(backend.host().negotiated()); + int before = host.messages().size(); + Throwable unavailable = failure(backend.host().credentials().get("connection.prod")); + assertTrue(unavailable instanceof HostError); + assertEquals("plugin.host.unavailable", ((HostError) unavailable).code()); + assertEquals(Integer.valueOf(-32601), ((HostError) unavailable).rpcCode()); + assertEquals("plugin.host.unavailable", code(backend.host().capabilities())); + assertFalse(value(backend.host().supports(Protocol.HOST_CREDENTIALS_GET))); + assertEquals("sdk.host.method", code(backend.host().request("lithe/function/invoke", map()))); + assertEquals(before, host.messages().size()); + host.close(); + + Backend idle = Backend.create(null, null); + assertEquals("sdk.session.notReady", code(idle.host().credentials().get("key"))); + } + + @Test + void capabilitiesAreAskedForOnceAndSupportsReadsThem() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Object answer = null; + for (Map testCase : cases("hostRequests")) { + if (object(testCase.get("expected")).get("answer") != null) { + answer = object(testCase.get("expected")).get("answer"); + break; + } + } + CompletableFuture first = backend.host().supports(Protocol.HOST_WORKSPACE_GET_FOLDERS); + CompletableFuture second = backend.host().supports(Protocol.HOST_WORKSPACE_READ_FILE); + Map request = nextRequest(host, Protocol.HOST_GET_CAPABILITIES); + assertFalse(request.containsKey("params")); + host.send(map("id", request.get("id"), "result", answer)); + assertEquals(Arrays.asList(true, false), Arrays.asList(value(first), value(second))); + Map limits = object(value(backend.host().capabilities()).get("limits")); + assertEquals((long) Protocol.MAX_PENDING_HOST_REQUESTS, limits.get("pendingHostRequests")); + assertEquals(1, sent(host, Protocol.HOST_GET_CAPABILITIES).size()); + host.close(); + } + + @Test + void aCallCarriesItsWorkspaceContextToTheHandler() { + for (Map testCase : cases("invocationContexts")) { + Map expected = object(testCase.get("expected")); + if (expected.get("params") == null) { + continue; + } + String name = (String) testCase.get("name"); + Map params = object(expected.get("params")); + AtomicReference seen = new AtomicReference<>("unset"); + Backend backend = Backend.create(null, null).function((String) params.get("functionID"), (arguments, context) -> { + seen.set(context.workspaceContext()); + return null; + }); + TestHost host = session(backend); + Map response = host.request(Protocol.FUNCTION_INVOKE, params); + assertNull(response.get("error"), name); + assertEquals(params.get("context"), seen.get(), name); + host.close(); + } + } + + @Test + void cancellingACallCancelsItsHostRequestsAtCore() { + CountDownLatch started = new CountDownLatch(1); + AtomicReference outcome = new AtomicReference<>(); + Backend backend = Backend.create(null, null).function("secret.read", (params, context) -> { + CompletableFuture reading = context.host().credentials().get("connection.prod"); + started.countDown(); + try { + return reading.join(); + } catch (CompletionException error) { + outcome.set(error.getCause()); + throw error; + } + }); + TestHost host = session(backend); + CompletableFuture> call = host.requestAsync(Protocol.FUNCTION_INVOKE, + map("functionID", "secret.read", "operationID", "c"), 41L); + Fixtures.await(started, "the host request"); + Map request = nextRequest(host, Protocol.HOST_CREDENTIALS_GET); + host.notify(Protocol.CANCEL_REQUEST, map("id", 41L)); + Map response = Fixtures.await(call, "the cancelled call"); + assertEquals((long) Protocol.REQUEST_CANCELLED, object(response.get("error")).get("code")); + // The host request fails with the call's cancellation. + assertTrue(outcome.get() instanceof CancelledException, String.valueOf(outcome.get())); + List cancels = sent(host, Protocol.CANCEL_REQUEST).stream().map(message -> message.get("params")) + .collect(Collectors.toList()); + assertEquals(Arrays.asList(map("id", request.get("id"))), cancels); + // Core's late answer is dropped; the session continues. + host.send(map("id", request.get("id"), "error", + map("code", -32800L, "message", "Cancelled.", "data", map("code", "plugin.host.cancelled")))); + assertEquals(1, host.close()); + } + + @Test + void aCancelledTokenRejectsWithItsReasonAndIsNeverSentAgain() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Cancellation stop = new Cancellation(); + Host cancellable = backend.host().withCancellation(stop); + Map edit = map("documentID", "d1", "expectedVersion", 4L, "edits", Collections.emptyList()); + CompletableFuture> pending = cancellable.editor().applyEdits(CONTEXT, edit); + Map request = nextRequest(host, Protocol.HOST_EDITOR_APPLY_EDITS); + Map withContext = map("context", CONTEXT); + withContext.putAll(edit); + assertEquals(withContext, request.get("params")); + stop.cancel("user stopped"); + Throwable error = failure(pending); + assertTrue(error instanceof CancelledException); + assertEquals("user stopped", error.getMessage()); + assertEquals(Arrays.asList(map("id", request.get("id"))), + sent(host, Protocol.CANCEL_REQUEST).stream().map(message -> message.get("params")).collect(Collectors.toList())); + host.send(map("id", request.get("id"), "result", map("documentID", "d1", "version", 5L, "applied", true))); + assertTrue(failure(cancellable.request(Protocol.HOST_EDITOR_APPLY_EDITS, map())) instanceof CancelledException); + assertEquals(1, sent(host, Protocol.HOST_EDITOR_APPLY_EDITS).size()); + host.close(); + } + + @Test + void atMostSixteenHostRequestsAreUnansweredLaterOnesWaitForAFreeSlot() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + int max = Protocol.MAX_PENDING_HOST_REQUESTS; + List>> calls = new ArrayList<>(); + for (int index = 0; index < max + 2; index++) { + calls.add(backend.host().workspace().listContexts()); + } + // Requests with a free slot are written before listContexts returns. + List> first = sent(host, Protocol.HOST_WORKSPACE_LIST_CONTEXTS); + assertEquals(max, first.size()); + Set taken = new HashSet<>(); + for (Map message : first) { + taken.add(message.get("id")); + } + host.send(map("id", first.get(0).get("id"), "result", map("revision", 1L, "contexts", Collections.emptyList()))); + nextRequest(host, Protocol.HOST_WORKSPACE_LIST_CONTEXTS, taken); + assertEquals(max + 1, sent(host, Protocol.HOST_WORKSPACE_LIST_CONTEXTS).size()); + // Ending the session fails the queued request too. + host.close(); + assertEquals(map("revision", 1L, "contexts", Collections.emptyList()), value(calls.get(0))); + for (CompletableFuture> call : calls.subList(1, calls.size())) { + assertEquals("sdk.session.closed", code(call)); + } + } + + @Test + void subscriptionEventsReachTheirListenerInOrderAlsoWhenSentBeforeTheAnswer() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + List events = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch received = new CountDownLatch(3); + Map watched = map("resource", map("rootID", "r1", "path", ""), "recursive", true); + CompletableFuture watching = backend.host().workspace().watch(CONTEXT, watched, event -> { + events.add(event); + received.countDown(); + }); + Map request = nextRequest(host, Protocol.HOST_WORKSPACE_WATCH); + Map withContext = map("context", CONTEXT); + withContext.putAll(watched); + assertEquals(withContext, request.get("params")); + java.util.function.LongConsumer notify = sequence -> host.notify(Protocol.HOST_SUBSCRIPTION_NOTIFY, map( + "subscriptionID", "s1", "sequence", sequence, + "event", map("rootID", "r1", "changedPaths", Arrays.asList("f" + sequence), "requiresRescan", false))); + notify.accept(1); + host.send(map("id", request.get("id"), "result", map("subscriptionID", "s1"))); + Host.Subscription subscription = value(watching); + notify.accept(2); + notify.accept(4); + Fixtures.await(received, "three events"); + List> seen = new ArrayList<>(); + synchronized (events) { + for (Host.Event event : events) { + seen.add(Arrays.asList(event.subscriptionId(), event.sequence(), event.gap(), + object(event.event()).get("changedPaths"))); + } + } + assertEquals(Arrays.asList( + Arrays.asList("s1", 1L, false, Arrays.asList("f1")), + Arrays.asList("s1", 2L, false, Arrays.asList("f2")), + Arrays.asList("s1", 4L, true, Arrays.asList("f4"))), seen); + assertEquals("s1", subscription.id()); + assertFalse(subscription.isDisposed()); + + CompletableFuture disposing = subscription.dispose(); + assertTrue(subscription.isDisposed()); + Map dispose = nextRequest(host, Protocol.HOST_SUBSCRIPTIONS_DISPOSE); + assertEquals(map("subscriptionID", "s1"), dispose.get("params")); + notify.accept(5); + host.send(map("id", dispose.get("id"), "result", null)); + value(disposing); + // The reader handled event 5 before the dispose answer; it was dropped. + assertEquals(3, events.size()); + host.close(); + } + + @Test + void commandsContextChangesAndLaterHostNotificationsReachTheirHandlers() { + List> delivered = new ArrayList<>(); + for (Map testCase : cases("hostNotifications")) { + if (Boolean.TRUE.equals(object(testCase.get("expected")).get("delivered")) + && !Protocol.HOST_SUBSCRIPTION_NOTIFY.equals(testCase.get("method"))) { + delivered.add(testCase); + } + } + Set> expected = new HashSet<>(); + // Run exits and language server events have no dedicated API: onHostNotification routes them. + Set otherMethods = new HashSet<>(); + for (Map testCase : delivered) { + Map params = object(testCase.get("params")); + String deliveredMethod = (String) testCase.get("method"); + if (Protocol.HOST_COMMAND_DID_RUN.equals(deliveredMethod)) { + expected.add(Arrays.asList("command", params.get("commandID"), params.get("context"))); + } else if (Protocol.HOST_DID_CHANGE_CONTEXTS.equals(deliveredMethod)) { + expected.add(Arrays.asList("contexts", params)); + } else { + expected.add(Arrays.asList("host", deliveredMethod, params)); + otherMethods.add(deliveredMethod); + } + } + expected.add(Arrays.asList("later", map("value", 1L))); + Set> seen = Collections.synchronizedSet(new HashSet<>()); + CountDownLatch all = new CountDownLatch(expected.size()); + Backend backend = Backend.create(null, null) + .onCommand("db.refresh", (commandId, context) -> { + seen.add(Arrays.asList("command", commandId, context)); + all.countDown(); + }) + .onContextsChanged(params -> { + seen.add(Arrays.asList("contexts", params)); + all.countDown(); + }) + .onHostNotification("lithe/host/later/didSomething", params -> { + seen.add(Arrays.asList("later", params)); + all.countDown(); + }); + for (String otherMethod : otherMethods) { + backend.onHostNotification(otherMethod, params -> { + seen.add(Arrays.asList("host", otherMethod, params)); + all.countDown(); + }); + } + assertEquals("sdk.method.reserved", assertThrows(PluginSdkError.class, + () -> backend.onHostNotification(Protocol.HOST_COMMAND_DID_RUN, params -> {})).code()); + assertEquals("sdk.method.invalid", assertThrows(PluginSdkError.class, + () -> backend.onHostNotification("workspace/didChange", params -> {})).code()); + assertEquals("sdk.command.invalidID", assertThrows(PluginSdkError.class, + () -> backend.onCommand("DB_Refresh", (commandId, context) -> {})).code()); + TestHost host = session(backend); + for (Map testCase : delivered) { + host.notify((String) testCase.get("method"), testCase.get("params")); + } + // A notification this SDK does not know is ignored, as the contract asks. + host.notify("lithe/host/unknown/ignored", map("anything", true)); + host.notify("lithe/host/later/didSomething", map("value", 1L)); + Fixtures.await(all, "the notifications"); + // Handlers run on their own threads, so only the set is fixed. + assertEquals(expected, new HashSet<>(seen)); + assertEquals(expected.size(), seen.size()); + host.close(); + } + + /** {@code base} as a new map, with {@code overrides} (alternating keys and values) replacing or adding entries. */ + private static Map with(Map base, Object... overrides) { + Map copy = map(); + copy.putAll(base); + copy.putAll(map(overrides)); + return copy; + } + + private static String repeat(char character, int count) { + StringBuilder text = new StringBuilder(); + for (int index = 0; index < count; index++) { + text.append(character); + } + return text.toString(); + } + + /** Makes one relayed call, lets the host answer it, and returns the params the host received. */ + private static Map relayed(Backend backend, TestHost host, Set taken, Map call) { + CompletableFuture> pending = backend.host().functions().invoke(CONTEXT, call); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken); + host.send(map("id", request.get("id"), "result", map("value", null))); + assertEquals(map("value", null), value(pending)); + return object(request.get("params")); + } + + @Test + void aFunctionSeesTheCallingPluginCoreWrote() { + for (Map testCase : cases("invocationCallers")) { + String name = (String) testCase.get("name"); + // The wire message Core sends, and whether it holds a caller. + Map wire = object(object(testCase.get("expected")).get("params")); + AtomicReference seen = new AtomicReference<>("unset"); + AtomicReference seenPluginId = new AtomicReference<>("unset"); + Backend backend = Backend.create(null, null).function((String) wire.get("functionID"), (arguments, context) -> { + seen.set(context.caller()); + seenPluginId.set(context.callerPluginId()); + return null; + }); + TestHost host = session(backend); + assertNull(host.request(Protocol.FUNCTION_INVOKE, wire).get("error"), name); + Object expectedCaller = wire.get("caller"); + assertEquals(expectedCaller, seen.get(), name); + assertEquals(expectedCaller == null ? null : object(expectedCaller).get("pluginID"), seenPluginId.get(), name); + host.close(); + } + + // Only exactly { pluginID: non-empty string } is a caller, and it cannot be changed. + AtomicReference seen = new AtomicReference<>("unset"); + AtomicReference seenPluginId = new AtomicReference<>("unset"); + Backend backend = Backend.create(null, null).function("project.describe", (arguments, context) -> { + seen.set(context.caller()); + seenPluginId.set(context.callerPluginId()); + return null; + }); + TestHost host = session(backend); + List notCallers = Arrays.asList("dev.lithe.fixture.python", Collections.emptyList(), map(), + map("pluginID", ""), map("pluginID", 7L), map("plugin", "dev.lithe.fixture.python"), + map("pluginID", "dev.lithe.fixture.python", "name", "python")); + for (Object notCaller : notCallers) { + assertNull(host.request(Protocol.FUNCTION_INVOKE, + map("functionID", "project.describe", "operationID", "7", "params", map(), "caller", notCaller)).get("error")); + assertNull(seen.get(), String.valueOf(notCaller)); + assertNull(seenPluginId.get(), String.valueOf(notCaller)); + } + assertNull(host.request(Protocol.FUNCTION_INVOKE, map("functionID", "project.describe", "operationID", "7")).get("error")); + assertNull(seen.get()); + assertNull(host.request(Protocol.FUNCTION_INVOKE, map("functionID", "project.describe", "operationID", "7", + "caller", map("pluginID", "dev.lithe.fixture.python"))).get("error")); + Map caller = object(seen.get()); + assertEquals(map("pluginID", "dev.lithe.fixture.python"), caller); + assertEquals("dev.lithe.fixture.python", seenPluginId.get()); + assertThrows(UnsupportedOperationException.class, () -> caller.put("pluginID", "dev.lithe.fixture.other")); + // A caller key inside the params is ordinary data, not the calling plugin. + assertNull(host.request(Protocol.FUNCTION_INVOKE, map("functionID", "project.describe", "operationID", "7", + "params", map("caller", map("pluginID", "dev.lithe.fixture.other")))).get("error")); + assertNull(seen.get()); + host.close(); + } + + @Test + void relayRequestsCarryTheContractParamsAndAnswerTheHostsResultUnchanged() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Set taken = new HashSet<>(); + int relayCases = 0; + for (Map testCase : cases("hostRequests")) { + String method = (String) testCase.get("method"); + if (!Boolean.TRUE.equals(object(testCase.get("expected")).get("relay"))) { + continue; + } + String name = (String) testCase.get("name"); + Map wire = object(testCase.get("params")); + CompletableFuture> pending; + Object answer; + if (Protocol.HOST_FUNCTIONS_PROVIDERS.equals(method)) { + pending = backend.host().functions().providers((String) wire.get("functionID")); + answer = map("providers", Arrays.asList(map("pluginID", "dev.lithe.fixture.go", "version", 1L))); + } else { + assertEquals(Protocol.HOST_FUNCTIONS_INVOKE, method, name); + Map call = with(wire); + call.remove("context"); + pending = backend.host().functions().invoke(object(wire.get("context")), call); + answer = map("value", map("settings", Arrays.asList("a"))); + } + Map request = nextRequest(host, method, taken); + assertEquals(wire, request.get("params"), name); + host.send(map("id", request.get("id"), "result", answer)); + assertEquals(answer, value(pending), name); + relayCases++; + } + assertTrue(relayCases > 0, "The fixture has no relay case."); + // A provider's null result is a result. + CompletableFuture> nothing = backend.host().functions().invoke(CONTEXT, + map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings")); + host.send(map("id", nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken).get("id"), "result", map("value", null))); + assertEquals(map("value", null), value(nothing)); + host.close(); + } + + @Test + void withoutPluginFunctionsTheRelayFailsLocallyWithoutSendingAnything() { + Backend backend = Backend.create(null, null); + TestHost host = new TestHost(backend); + host.initialize(map()); + int before = host.messages().size(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + List> attempts = new ArrayList<>(); + attempts.add(backend.host().functions().providers("languageImport.settings")); + attempts.add(backend.host().functions().invoke(CONTEXT, call)); + attempts.add(backend.host().request(Protocol.HOST_FUNCTIONS_PROVIDERS, map("functionID", "languageImport.settings"))); + attempts.add(backend.host().request(Protocol.HOST_FUNCTIONS_INVOKE, with(call, "context", CONTEXT))); + for (CompletableFuture attempt : attempts) { + Throwable error = failure(attempt); + assertTrue(error instanceof HostError, String.valueOf(error)); + assertEquals("plugin.host.unavailable", ((HostError) error).code()); + assertEquals(Integer.valueOf(-32601), ((HostError) error).rpcCode()); + } + assertEquals(before, host.messages().size()); + host.close(); + } + + @Test + void relayFailuresKeepTheirCodeNumberAndTheProvidersOwnErrorCode() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Set taken = new HashSet<>(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + java.util.function.Function, HostError> refused = wire -> { + CompletableFuture> pending = backend.host().functions().invoke(CONTEXT, call); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken); + host.send(map("id", request.get("id"), "error", wire)); + Throwable error = failure(pending); + assertTrue(error instanceof HostError, String.valueOf(error)); + return (HostError) error; + }; + int failures = 0; + for (Map testCase : cases("relayErrors")) { + String name = (String) testCase.get("name"); + String code = (String) testCase.get("code"); + long rpcCode = ((Number) testCase.get("rpcCode")).longValue(); + assertEquals(Integer.valueOf((int) rpcCode), HostError.CODES.get(code), name); + HostError named = refused.apply(map("code", rpcCode, "message", "Refused.", "data", map("code", code))); + assertEquals(code, named.code(), name); + assertEquals((int) rpcCode, named.rpcCode().intValue(), name); + assertEquals(Protocol.HOST_FUNCTIONS_INVOKE, named.method(), name); + // The number alone names the code. + assertEquals(code, refused.apply(map("code", rpcCode, "message", "Refused.")).code(), name); + failures++; + } + assertTrue(failures > 0, "The fixture has no relay error case."); + + HostError providerFailed = refused.apply(map("code", -32070L, "message", "The provider failed.", + "data", map("code", "plugin.function.failed", "details", map("pluginErrorCode", "go.parse.failed")))); + assertEquals("plugin.function.failed", providerFailed.code()); + assertEquals("go.parse.failed", providerFailed.pluginErrorCode()); + assertEquals(map("pluginErrorCode", "go.parse.failed"), providerFailed.details()); + // Only a provider's failure has a provider error code, and only a text one. + assertNull(refused.apply(map("code", -32070L, "message", "The provider failed.", + "data", map("code", "plugin.function.failed"))).pluginErrorCode()); + assertNull(refused.apply(map("code", -32070L, "message", "The provider failed.", + "data", map("code", "plugin.function.failed", "details", map("pluginErrorCode", 7L)))).pluginErrorCode()); + assertNull(refused.apply(map("code", -32071L, "message", "No result.", + "data", map("code", "plugin.function.invalidResult", "details", map("pluginErrorCode", "go.parse.failed")))).pluginErrorCode()); + host.close(); + } + + @Test + void aCallToAnotherPluginNamesTheFunctionCallItIsMadeInAsItsParentUnlessTheCallerSaysOtherwise() { + Backend backend = Backend.create(null, null).function("relay.run", (arguments, context) -> { + Host used = Boolean.TRUE.equals(arguments.get("scoped")) + ? context.host().withCancellation(new Cancellation()) + : context.host(); + return used.functions().invoke(CONTEXT, object(arguments.get("call"))).join(); + }); + TestHost host = session(backend); + Set taken = new HashSet<>(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + String longest = repeat('p', Protocol.MAX_PARENT_OPERATION_ID_BYTES); + String tooLong = repeat('p', Protocol.MAX_PARENT_OPERATION_ID_BYTES + 1); + // The invoke request's operationID (null: none), the call, whether the Host is + // narrowed with withCancellation, and the parentOperationID the host must receive (null: none). + Object[][] cases = { + {"7", call, false, "7"}, + {"7", with(call, "parentOperationID", "17"), false, "17"}, + {"7", with(call, "parentOperationID", null), false, "7"}, + {"7", call, true, "7"}, + {longest, call, false, longest}, + {tooLong, call, false, null}, + {"", call, false, null}, + {7L, call, false, null}, + {null, call, false, null}, + }; + long id = 60; + for (Object[] testCase : cases) { + String name = "operationID " + testCase[0] + ", call " + testCase[1] + ", narrowed " + testCase[2]; + Map invocation = map("functionID", "relay.run", + "params", map("call", testCase[1], "scoped", testCase[2])); + if (testCase[0] != null) { + invocation.put("operationID", testCase[0]); + } + id++; + CompletableFuture> serving = host.requestAsync(Protocol.FUNCTION_INVOKE, invocation, id); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken); + Map expected = with(call, "context", CONTEXT); + if (testCase[3] != null) { + expected.put("parentOperationID", testCase[3]); + } + // The JSON-RPC request ID and the diagnostic operation ID are never the parent. + assertEquals(expected, request.get("params"), name); + host.send(map("id", request.get("id"), "result", map("value", map("ok", true)))); + Map response = Fixtures.await(serving, "the serving call"); + assertNull(response.get("error"), name); + assertEquals(map("value", map("ok", true)), response.get("result"), name); + } + // A Host that belongs to no function call has no parent to name; an explicit one is still sent. + assertFalse(relayed(backend, host, taken, call).containsKey("parentOperationID")); + assertEquals("17", relayed(backend, host, taken, with(call, "parentOperationID", "17")).get("parentOperationID")); + host.close(); + } + + @Test + void malformedRelayArgumentsFailLocallyAndNeverReachTheHost() { + Backend backend = Backend.create(null, null); + TestHost host = session(backend); + Host.Functions functions = backend.host().functions(); + int before = host.messages().size(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + + List> malformed = new ArrayList<>(); + for (Object pluginId : new Object[] {null, "", 7L}) { + malformed.add(with(call, "pluginID", pluginId)); + } + for (Object functionId : new Object[] {null, "", "Not-Valid", 7L}) { + malformed.add(with(call, "functionID", functionId)); + } + for (Object params : new Object[] {Arrays.asList(1L), "text"}) { + malformed.add(with(call, "params", params)); + } + for (Object timeout : new Object[] {0L, -1L, 1.5, "5000", 9007199254740992L, Double.NaN}) { + malformed.add(with(call, "timeoutMilliseconds", timeout)); + } + // 128 bytes is the longest parent ID: 65 two-byte characters are 130. + for (Object parent : new Object[] {17L, "", repeat('p', 129), repeat('é', 65)}) { + malformed.add(with(call, "parentOperationID", parent)); + } + for (Map bad : malformed) { + assertEquals("sdk.functions.invalidCall", code(functions.invoke(CONTEXT, bad)), String.valueOf(bad)); + } + assertEquals("sdk.functions.invalidCall", code(functions.invoke(CONTEXT, null))); + for (String functionId : new String[] {null, "", "Not-Valid"}) { + assertEquals("sdk.functions.invalidCall", code(functions.providers(functionId)), String.valueOf(functionId)); + } + assertEquals("sdk.host.invalidContext", code(functions.invoke(null, call))); + assertEquals("sdk.host.invalidContext", code(functions.invoke(map("contextID", "w1"), call))); + assertEquals(before, host.messages().size()); + + // What is well formed is sent as given: no cap on the timeout, which is Core's to apply. + Set taken = new HashSet<>(); + Map bare = relayed(backend, host, taken, with(call, "params", null, "timeoutMilliseconds", null, "parentOperationID", null)); + assertEquals(map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings", "context", CONTEXT), bare); + Map full = relayed(backend, host, taken, with(call, "params", map(), "timeoutMilliseconds", 700000L, + "parentOperationID", repeat('é', 64))); + assertEquals(map(), full.get("params")); + assertEquals(700000L, full.get("timeoutMilliseconds")); + assertEquals(repeat('é', 64), full.get("parentOperationID")); + assertEquals(5000L, relayed(backend, host, taken, with(call, "timeoutMilliseconds", 5000.0)).get("timeoutMilliseconds")); + assertEquals(5000L, relayed(backend, host, taken, with(call, "timeoutMilliseconds", 5000)).get("timeoutMilliseconds")); + assertEquals(repeat('p', 128), relayed(backend, host, taken, with(call, "parentOperationID", repeat('p', 128))).get("parentOperationID")); + // Only the fields of the method are sent; a caller cannot be chosen from here. + Map only = relayed(backend, host, taken, with(call, "caller", map("pluginID", "dev.lithe.fixture.other"), "extra", true)); + assertEquals(map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings", "context", CONTEXT), only); + host.close(); + } + + @Test + void cancellingTheCallingFunctionCancelsItsRelayedCallOnTheWire() { + CountDownLatch started = new CountDownLatch(1); + AtomicReference outcome = new AtomicReference<>(); + Backend backend = Backend.create(null, null).function("relay.run", (arguments, context) -> { + CompletableFuture> relayed = context.host().functions().invoke(CONTEXT, object(arguments.get("call"))); + started.countDown(); + try { + return relayed.join(); + } catch (CompletionException error) { + outcome.set(error.getCause()); + throw error; + } + }); + TestHost host = session(backend); + CompletableFuture> call = host.requestAsync(Protocol.FUNCTION_INVOKE, + map("functionID", "relay.run", "operationID", "c", + "params", map("call", map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"))), 51L); + Fixtures.await(started, "the relayed call"); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE); + assertEquals("c", object(request.get("params")).get("parentOperationID")); + host.notify(Protocol.CANCEL_REQUEST, map("id", 51L)); + Map response = Fixtures.await(call, "the cancelled call"); + assertEquals((long) Protocol.REQUEST_CANCELLED, object(response.get("error")).get("code")); + assertTrue(outcome.get() instanceof CancelledException, String.valueOf(outcome.get())); + assertEquals(Arrays.asList(map("id", request.get("id"))), + sent(host, Protocol.CANCEL_REQUEST).stream().map(message -> message.get("params")).collect(Collectors.toList())); + // Core's late answer is dropped; the session continues. + host.send(map("id", request.get("id"), "error", + map("code", -32800L, "message", "Cancelled.", "data", map("code", "plugin.host.cancelled")))); + assertEquals(1, host.close()); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java new file mode 100644 index 00000000..df1043f2 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java @@ -0,0 +1,103 @@ +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.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +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(0), "providers", Arrays.asList("first", "unknown")))); + Map capabilities = object(object(initialized.get("result")).get("capabilities")); + assertEquals(map("version", 0L, "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")); + // The terminal fact precedes the response and names the wire method Core accepts. + List> facts = new ArrayList<>(); + for (Map message : host.messages()) { + if ("lithe/log".equals(message.get("method"))) { + Map fact = object(message.get("params")); + facts.add(Arrays.asList(fact.get("kind"), fact.get("operation"))); + } + } + assertEquals(Collections.singletonList(Arrays.asList("operationFinished", "lithe/keymap/import")), facts); + 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(0)), "litheKeymapImport", + map("versions", Arrays.asList(0), "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(0), "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/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java new file mode 100644 index 00000000..ce9771eb --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java @@ -0,0 +1,370 @@ +package io.github.litheidea.plugin.sdk; + +import static io.github.litheidea.plugin.sdk.Fixtures.map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; + +/** Framing, JSON, tools, and state; mirrors {@code sdk/node/test/support.test.mjs}. */ +@Timeout(value = 30, unit = TimeUnit.SECONDS) +class SupportTest { + private Path directory; + + @BeforeEach + void createDirectory() throws IOException { + directory = Files.createTempDirectory("lithe-sdk-").toRealPath(); + } + + @AfterEach + void removeDirectory() { + Processes.killAll(); + Fixtures.deleteTree(directory); + } + + private static List shell(String script) { + return Arrays.asList("/bin/sh", "-c", script); + } + + /** The SDK gives each tool its own process group through the system perl. */ + private static boolean hasProcessGroups() { + return new java.io.File("/usr/bin/perl").canExecute() || new java.io.File("/bin/perl").canExecute(); + } + + private static boolean hasProcessHandle() { + try { + Class.forName("java.lang.ProcessHandle"); + return true; + } catch (ClassNotFoundException error) { + return false; + } + } + + @Test + void framesSplitAcrossChunksOrSeveralInOneChunkDecodeInOrder() { + Framing.Decoder decoder = new Framing.Decoder(); + byte[] first = Framing.encode(map("id", 1, "text", "é✓")); + byte[] second = Framing.encode(map("id", 2)); + byte[] bytes = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, bytes, first.length, second.length); + List messages = new ArrayList<>(); + for (int index = 0; index < bytes.length; index += 3) { + messages.addAll(decoder.push(bytes, index, Math.min(3, bytes.length - index))); + } + assertEquals(Arrays.asList(map("id", 1L, "text", "é✓"), map("id", 2L)), messages); + byte[] oversized = "Content-Length: 999999999999\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(oversized, 0, oversized.length)); + byte[] missing = "X-Other: 1\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(missing, 0, missing.length)); + } + + @Test + void aHeaderOverEightKibibytesIsRefusedAlsoWhenTheWholeFrameArrivesAtOnce() { + StringBuilder header = new StringBuilder("Content-Length: 2\r\nX-Padding: "); + while (header.length() <= 8200) { + header.append('x'); + } + byte[] frame = (header + "\r\n\r\n{}").getBytes(StandardCharsets.US_ASCII); + assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(frame, 0, frame.length)); + } + + @Test + void jsonRoundTripsProtocolValuesAndRefusesWhatItCannotRepresent() { + String text = "{\"a\":[1,-2.5,true,false,null,\"\\u00e9\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1e3}"; + Map value = Json.object(Json.parse(text)); + assertEquals(Arrays.asList(1L, -2.5, true, false, null, "é\n\""), value.get("a")); + assertEquals(new BigInteger("123456789012345678901234567890"), value.get("big")); + assertEquals(1000.0, value.get("e")); + // Integral doubles print without a fraction, as JavaScript prints them. + String encoded = Json.stringify(value); + assertEquals("{\"a\":[1,-2.5,true,false,null,\"é\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1000}", encoded); + assertEquals(encoded, Json.stringify(Json.parse(encoded))); + assertEquals("\"\\ud800x\"", Json.stringify("\ud800x")); + assertEquals("[1,2.5]", Json.stringify(Arrays.asList(1.0, 2.5))); + for (String malformed : Arrays.asList("", "{", "[1,]", "{\"a\" 1}", "01", "1.", "\"\u0001\"", "tru", "{} x")) { + assertThrows(Json.JsonException.class, () -> Json.parse(malformed), malformed); + } + Map numericKeys = new HashMap<>(); + numericKeys.put(1, "x"); + assertThrows(Json.JsonException.class, () -> Json.stringify(numericKeys)); + assertThrows(Json.JsonException.class, () -> Json.stringify(Double.POSITIVE_INFINITY)); + StringBuilder deep = new StringBuilder(); + for (int index = 0; index < 600; index++) { + deep.append('['); + } + assertThrows(Json.JsonException.class, () -> Json.parse(deep.toString())); + } + + @Test + void boundedTextCutsOnCharacterBoundaries() { + assertEquals("ab", Protocol.boundedText("abc", 2)); + assertEquals("é", Protocol.boundedText("éé", 3)); + assertEquals("", Protocol.boundedText("😀", 3)); + assertEquals("", Protocol.boundedText(null)); + assertTrue(Protocol.isPluginErrorCode("maven.snapshotLocked")); + assertFalse(Protocol.isPluginErrorCode("bad code")); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void runCapturesOutputAndReturnsANonZeroExit() throws IOException { + Processes.Result result = Processes.run(shell("printf out; printf err >&2; exit 3")); + assertEquals(3, result.exitCode()); + assertEquals("out", result.stdout()); + assertEquals("err", result.stderr()); + assertFalse(result.truncated()); + Processes.Result limited = Processes.run(shell("printf xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"), + new Processes.Options().maxOutputBytes(10)); + assertEquals("xxxxxxxxxx", limited.stdout()); + assertTrue(limited.truncated()); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void streamedOutputKeepsACharacterSplitAcrossReadsWhole() throws IOException { + // 3-byte characters never align with the 8 KiB reads, so reads split them. + String expected = String.join("", Collections.nCopies(50_000, "✓")); + Path file = directory.resolve("checks.txt"); + Files.write(file, expected.getBytes(StandardCharsets.UTF_8)); + StringBuilder streamed = new StringBuilder(); + Processes.Result result = Processes.run(Arrays.asList("cat", file.toString()), + new Processes.Options().onStdout(streamed::append)); + assertEquals(expected, streamed.toString()); + assertEquals(expected, result.stdout()); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void aReplacedEnvironmentStillCarriesTheHostProcessMarkers() throws IOException { + Map environment = new HashMap<>(); + environment.put("PATH", System.getenv("PATH")); + environment.put("LITHE_PROCESS_SCOPE", "forged"); + environment.put("OWN", "kept"); + Processes.Result result = Processes.run(shell("printf '%s|%s' \"${LITHE_PROCESS_SCOPE-unset}\" \"$OWN\""), + new Processes.Options().environment(environment)); + String inherited = System.getenv("LITHE_PROCESS_SCOPE"); + assertEquals((inherited == null ? "unset" : inherited) + "|kept", result.stdout()); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void cancellingACallEndsTheToolTreeItStarted() { + assumeTrue(hasProcessGroups() || hasProcessHandle(), + "without perl, Java 8 ends only the direct child; the host scope ends the rest"); + Path grandchildPid = directory.resolve("grandchild.pid"); + // The child starts a grandchild that ignores SIGTERM, so only the forced + // stop can end it. + String grandchild = "trap '' TERM; echo $$ > '" + grandchildPid + "'; while :; do sleep 1; done"; + String child = "/bin/sh -c \"" + grandchild.replace("$", "\\$") + "\" & wait"; + CountDownLatch started = new CountDownLatch(1); + Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { + started.countDown(); + return context.run(shell(child), new Processes.Options().killGraceMillis(50)).exitCode(); + }); + TestHost host = new TestHost(backend); + host.initialize(); + CompletableFuture> response = host.requestAsync("lithe/function/invoke", + map("functionID", "tool.run", "operationID", "g"), 7L); + Fixtures.await(started, "the tool to start"); + long pid = Fixtures.awaitPid(grandchildPid); + host.notify("$/cancelRequest", map("id", 7L)); + Map answer = Fixtures.await(response, "the cancelled call"); + assertEquals(-32800L, ((Map) answer.get("error")).get("code")); + Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the grandchild to be stopped"); + Fixtures.eventually(() -> Processes.runningCount() == 0, "the registry to drain"); + host.close(); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void aToolThatExitsTakesItsBackgroundWorkersButNotAnotherCallsTool() throws Exception { + assumeTrue(hasProcessGroups(), "without perl the tool has no process group of its own"); + Path workerPid = directory.resolve("worker.pid"); + Path otherPid = directory.resolve("other.pid"); + Cancellation other = new Cancellation(); + CompletableFuture otherCall = CompletableFuture.supplyAsync(() -> { + try { + return Processes.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600"), + new Processes.Options().cancellation(other).killGraceMillis(50)); + } catch (IOException | CancelledException error) { + return error; + } + }); + long otherTool = Fixtures.awaitPid(otherPid); + // The tool succeeds at once and leaves a worker that holds no pipe. + Processes.Result result = Processes.run(shell( + "sleep 600 >/dev/null 2>&1 & echo $! > '" + workerPid + "'; exit 0")); + assertEquals(0, result.exitCode()); + long worker = Fixtures.awaitPid(workerPid); + Fixtures.eventually(() -> !Fixtures.isAlive(worker), "the left worker to be killed"); + assertTrue(Fixtures.isAlive(otherTool), "a concurrent call's tool keeps running"); + other.cancel("done"); + assertTrue(Fixtures.await(otherCall, "the other call") instanceof CancelledException); + Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void aMissingExecutableFailsToStartAndPerlOptionsReachOnlyTheTool() throws IOException { + IOException missing = assertThrows(IOException.class, + () -> Processes.run(Arrays.asList(directory.resolve("missing").toString()))); + assertTrue(missing.getMessage().contains("missing"), missing.getMessage()); + Map environment = new HashMap<>(); + environment.put("PATH", System.getenv("PATH")); + // Perl would refuse to start with this; the tool only sees it. + environment.put("PERL5OPT", "-MLithe::Missing::Module"); + Processes.Result result = Processes.run(shell("printf %s \"$PERL5OPT\""), + new Processes.Options().environment(environment)); + assertEquals(0, result.exitCode(), result.stderr()); + assertEquals("-MLithe::Missing::Module", result.stdout()); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void closingTheSessionEndsToolsStillRunning() { + Path childPid = directory.resolve("child.pid"); + CountDownLatch started = new CountDownLatch(1); + Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { + started.countDown(); + // `exec` keeps the PID: the tool itself ignores SIGTERM. + return context.run(shell("trap '' TERM; echo $$ > '" + childPid + "'; exec sleep 600")).exitCode(); + }); + TestHost host = new TestHost(backend); + host.initialize(); + // No reply is awaited: the session ends while the call is still running. + host.send(map("id", 8L, "method", "lithe/function/invoke", + "params", map("functionID", "tool.run", "operationID", "h"))); + Fixtures.await(started, "the tool to start"); + long pid = Fixtures.awaitPid(childPid); + assertEquals(1, host.close()); + Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the tool to be killed"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void aCallThatSettlesStopsTheToolsItsHandlerLeftRunningAndOnlyThose() { + Path leftPid = directory.resolve("left.pid"); + Path otherPid = directory.resolve("other.pid"); + CountDownLatch otherStarted = new CountDownLatch(1); + CountDownLatch releaseOther = new CountDownLatch(1); + Backend backend = Backend.create(null, null) + .function("tool.leave", (params, context) -> { + Thread background = new Thread(() -> { + try { + context.run(shell("echo $$ > '" + leftPid + "'; exec sleep 600")); + } catch (IOException | CancelledException error) { + // Stopped with its call, as intended. + } + }); + background.setDaemon(true); + background.start(); + Fixtures.awaitPid(leftPid); + return "returned"; + }) + .function("tool.other", (params, context) -> { + Thread background = new Thread(() -> { + try { + context.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600")); + } catch (IOException | CancelledException error) { + // Ends with its own call. + } + }); + background.setDaemon(true); + background.start(); + otherStarted.countDown(); + Fixtures.await(releaseOther, "the test to release the other call"); + return null; + }); + TestHost host = new TestHost(backend); + host.initialize(); + CompletableFuture> other = host.requestAsync("lithe/function/invoke", + map("functionID", "tool.other", "operationID", "o"), 11L); + Fixtures.await(otherStarted, "the other call"); + long otherTool = Fixtures.awaitPid(otherPid); + assertEquals("returned", host.request("lithe/function/invoke", + map("functionID", "tool.leave", "operationID", "l")).get("result")); + long leftTool = Fixtures.awaitPid(leftPid); + Fixtures.eventually(() -> !Fixtures.isAlive(leftTool), "the left tool to be stopped"); + assertTrue(Fixtures.isAlive(otherTool), "the other call's tool keeps running"); + releaseOther.countDown(); + Fixtures.await(other, "the other call"); + Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); + host.close(); + } + + @Test + void theStateStoreWritesAtomicallyAndSurvivesReopening() throws IOException { + Path state = directory.resolve("workspace-state"); + StateStore store = StateStore.open(state, null); + assertEquals("none", store.read("snapshot", "none")); + store.write("snapshot", map("generation", 1)); + store.write("snapshot", map("generation", 2)); + assertEquals(map("generation", 2L), StateStore.open(state, null).read("snapshot", null)); + try (Stream files = Files.list(state)) { + assertEquals(Arrays.asList("snapshot.json"), + files.map(path -> path.getFileName().toString()).collect(Collectors.toList())); + } + // The same envelope as the Node.js SDK, so either reads the other's state. + assertEquals("{\"litheStateStore\":1,\"value\":{\"generation\":2}}", + new String(Files.readAllBytes(state.resolve("snapshot.json")), StandardCharsets.UTF_8)); + Files.write(state.resolve("broken.json"), "{".getBytes(StandardCharsets.UTF_8)); + assertNull(store.read("broken", null)); + store.delete("snapshot"); + store.delete("snapshot"); + assertNull(store.read("snapshot", null)); + for (String key : Arrays.asList("", "../escape", "a/b", "x.tmp", ".hidden")) { + PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write(key, 1), key); + assertEquals("sdk.state.invalidKey", error.code()); + } + } + + @Test + void theStateStoreRefusesAValueJsonCannotEncodeAndKeepsTheStoredOne() throws IOException { + StateStore store = StateStore.open(directory.resolve("state"), null); + store.write("kept", map("ok", true)); + PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write("kept", new Object())); + assertEquals("sdk.state.invalidValue", error.code()); + assertEquals(map("ok", true), store.read("kept", null)); + } + + @Test + void theStateStoreRefusesTheReadOnlyPluginPackage() throws IOException { + Path packageDirectory = directory.resolve("package"); + Files.createDirectories(packageDirectory); + Files.write(packageDirectory.resolve("plugin.json"), "{}".getBytes(StandardCharsets.UTF_8)); + PluginSdkError refused = assertThrows(PluginSdkError.class, + () -> StateStore.open(packageDirectory.resolve("state"), packageDirectory)); + assertEquals("sdk.state.readOnlyPackage", refused.code()); + // Refused before anything was created inside the package. + assertFalse(Files.exists(packageDirectory.resolve("state"))); + PluginSdkError relative = assertThrows(PluginSdkError.class, + () -> StateStore.open(java.nio.file.Paths.get("relative/state"), null)); + assertEquals("sdk.state.invalidDirectory", relative.code()); + StateStore.open(directory.resolve("state"), packageDirectory); + } +} diff --git a/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java new file mode 100644 index 00000000..70dd2282 --- /dev/null +++ b/shared/plugins/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java @@ -0,0 +1,217 @@ +package io.github.litheidea.plugin.sdk; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +/** + * A host for SDK tests: it writes framed messages to an in-memory backend and + * decodes the answers, with a bounded wait on each. + */ +final class TestHost { + /** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ + static final long WAIT_MILLIS = 5000; + + private final QueueInput input = new QueueInput(); + private final List> messages = new ArrayList<>(); + private final ByteArrayOutputStream errors = new ByteArrayOutputStream(); + private final CompletableFuture done = new CompletableFuture<>(); + private int nextId = 1; + + TestHost(Backend backend) { + Framing.Decoder decoder = new Framing.Decoder(); + OutputStream output = new OutputStream() { + @Override + public void write(int value) { + write(new byte[] {(byte) value}, 0, 1); + } + + @Override + public void write(byte[] bytes, int offset, int length) { + for (Object message : decoder.push(bytes, offset, length)) { + synchronized (messages) { + messages.add(Json.object(message)); + messages.notifyAll(); + } + } + } + }; + PrintStream errorOutput = new PrintStream(errors, true); + Thread thread = new Thread(() -> { + try { + done.complete(backend.listen(input, output, errorOutput)); + } catch (Throwable error) { + done.completeExceptionally(error); + } + }, "test-host-backend"); + thread.setDaemon(true); + thread.start(); + } + + /** Sends one message; {@code jsonrpc} is added. */ + void send(Map message) { + Map framed = new LinkedHashMap<>(); + framed.put("jsonrpc", "2.0"); + framed.putAll(message); + input.push(Framing.encode(framed)); + } + + /** Writes raw bytes, for framing tests. */ + void sendBytes(byte[] bytes) { + input.push(bytes); + } + + /** Sends a request and waits for the backend's response. */ + Map request(String method, Object params) { + return requestAsync(method, params, nextId()).join(); + } + + Map request(String method, Object params, Object id) { + return requestAsync(method, params, id).join(); + } + + /** Sends a request now and resolves with the response once it arrives. */ + CompletableFuture> requestAsync(String method, Object params, Object id) { + send(Fixtures.map("id", id, "method", method, "params", params)); + return CompletableFuture.supplyAsync(() -> waitFor( + message -> id.equals(message.get("id")) && !message.containsKey("method"), + "the response to " + method)); + } + + void notify(String method, Object params) { + send(Fixtures.map("method", method, "params", params)); + } + + Map initialize() { + return initialize(Fixtures.map( + "litheBuild", Fixtures.map("versions", Arrays.asList(0)), + "litheFunctions", Fixtures.map("versions", Arrays.asList(0)))); + } + + Map initialize(Map experimental) { + experimental = new LinkedHashMap<>(experimental); + experimental.put("litheLogging", Fixtures.map("versions", Arrays.asList(0))); + Map response = request("initialize", Fixtures.map( + "processId", null, + "rootUri", "file:///workspace", + "capabilities", Fixtures.map("experimental", experimental))); + notify("initialized", Fixtures.map()); + return response; + } + + /** The first message, past or future, that {@code matches}. */ + Map waitFor(Predicate> matches, String description) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WAIT_MILLIS); + synchronized (messages) { + for (;;) { + for (Map message : messages) { + if (matches.test(message)) { + return message; + } + } + long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); + if (remaining <= 0) { + throw new AssertionError("Timed out waiting for " + description + "; messages: " + messages + + "; stderr: " + errors()); + } + try { + messages.wait(remaining); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted waiting for " + description, error); + } + } + } + } + + List> messages() { + synchronized (messages) { + return new ArrayList<>(messages); + } + } + + String errors() { + return new String(errors.toByteArray(), StandardCharsets.UTF_8); + } + + /** The backend's exit code once the session ended. */ + int exitCode() { + return Fixtures.await(done, "the backend to exit"); + } + + /** Closes the input and returns the backend's exit code. */ + int close() { + input.close(); + return exitCode(); + } + + private Object nextId() { + synchronized (this) { + return (long) nextId++; + } + } + + /** An input stream fed by the test; closing it is the host closing stdin. */ + private static final class QueueInput extends InputStream { + private static final byte[] END = new byte[0]; + private final LinkedBlockingQueue chunks = new LinkedBlockingQueue<>(); + private byte[] current = new byte[0]; + private int position; + private boolean ended; + + void push(byte[] bytes) { + chunks.add(bytes.clone()); + } + + @Override + public void close() { + chunks.add(END); + } + + @Override + public int read() { + byte[] one = new byte[1]; + int count = read(one, 0, 1); + return count < 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (ended) { + return -1; + } + while (position >= current.length) { + byte[] next; + try { + // The backend's reader blocks here as it blocks on stdin; the + // test bounds every wait on the backend's answers instead. + next = chunks.take(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + return -1; + } + if (next == END) { + ended = true; + return -1; + } + current = next; + position = 0; + } + int count = Math.min(length, current.length - position); + System.arraycopy(current, position, buffer, offset, count); + position += count; + return count; + } + } +} diff --git a/shared/plugins/sdk/java/pom.xml b/shared/plugins/sdk/java/pom.xml new file mode 100644 index 00000000..81d87060 --- /dev/null +++ b/shared/plugins/sdk/java/pom.xml @@ -0,0 +1,78 @@ + + + + 4.0.0 + + io.github.lithe-idea + lithe-plugin-sdk-java-parent + 0.5.0 + pom + + + plugin-sdk + examples/sample-build + examples/sample-native-build + + + + UTF-8 + 8 + 5.14.0 + + 2026-11-02T00:00:00Z + + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + -Xlint:all,-options + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + 300 + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + + + diff --git a/shared/plugins/sdk/node/README.md b/shared/plugins/sdk/node/README.md new file mode 100644 index 00000000..fe4f907a --- /dev/null +++ b/shared/plugins/sdk/node/README.md @@ -0,0 +1,496 @@ +# Lithe plugin backend SDK for Node.js + +`@lithe/plugin-sdk` is the official way to write a plugin backend in +JavaScript: the process a plugin starts through a `runtime` launch +(`{ "kind": "runtime", "tool": "node", … }`). It speaks the LSP base protocol +on stdio and implements Lithe's backend protocols from the +[plugin contract](../../contract/v1/README.md), which lives in this +repository next to the SDK and changes with it: + +- **`litheBuild`**: typed `lithe/build/*` requests, for a build system + whose `model` or `dependencies` source is `provider`: models and dependency + trees, bounded task execution, tests, run and debug preparation, and + read-only model inputs. +- **`litheFunctions`**: plugin functions, which are plugin-private actions + that Lithe invokes for the same plugin, with progress, cancellation, and + registration at runtime; host services such as credentials; workspace + contexts, capability discovery, host notifications, and the public + document, file, editor, and window methods; and calls between plugins, + relayed by Core. See [Host services](#host-services) and + [Calling other plugins](#calling-other-plugins). +- **`litheVcs`**: typed `lithe/vcs/*` requests, for a plugin's + `vcsProviders` entry, from the + [VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md). +- **`litheKeymapImport`**: independently negotiated shortcut import + providers reading only explicitly selected source snapshots. +- **`litheLogging`**: typed diagnostics, required in every session. + +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`. + +## What the Lithe desktop serves + +The Lithe desktop declares `backend`, `pluginFunctions`, `buildProviders` and +`buildSystems` (all version 0), so the function, host-service and build +protocols below run there. It does not declare `vcsProviders`, +`keymapImportProviders` or `buildModelInputs`: a plugin whose manifest requires +one of them is not offered for installation there. The Core end-to-end tests +still drive those protocols. Which host services a session may call is reported +at run time; see [Host services](#host-services). + +## Shortcut import providers + +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. + +## A minimal backend + +```js +import { createBackend, PluginError } from './vendor/lithe-plugin-sdk/src/index.mjs'; + +const backend = createBackend({ name: 'example-backend', version: '1.0.0' }); + +backend.build.model(async ({ root }) => ({ + buildSystemID: 'example', + root, + projects: [{ name: 'app', path: root }], +})); + +backend.function('snapshot.refresh', async (params, context) => { + context.progress({ message: 'Refreshing', percentage: 10 }); + const result = await context.run('example-tool', ['refresh'], { cwd: params.root }); + if (result.exitCode !== 0) throw new PluginError('example.refreshFailed', 'The refresh failed.'); + return { refreshedAt: Date.now() }; +}); + +backend.listen(); +``` + +[`examples/sample-build`](examples/sample-build) is a complete plugin with a +build provider, functions, persistent state, a function registered at runtime, +and cancellation. From the root of this repository, +`node shared/plugins/scripts/sdk-e2e.mjs` drives it through a real 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. +[`sdk/e2e/plugin_sdk_node.rs`](../e2e/plugin_sdk_node.rs) drives every +operation through a real Core session. + +## Getting the SDK + +The SDK is released from this repository, tagged `sdk-node-v`; the +current release is +[0.5.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-node-v0.5.0). +A release ships `lithe-plugin-sdk-node-.tgz` and a `.sha256` file +holding the archive's SHA-256. The SDK is not published to the npm registry. +Use it like this: + +1. Download the archive during your plugin's build, never at runtime. +2. Check the archive against the SHA-256 you pinned in your repository. +3. Unpack it and copy its `package/` directory into your plugin package, for + example as `backend/vendor/lithe-plugin-sdk/`, then import + `src/index.mjs` from there by relative path. + +A plugin package is installed read-only, has no `node_modules` install step, +and must run offline, so everything the backend imports has to be inside the +package. The SDK has no dependencies. + +## Logging + +The SDK requires `experimental.litheLogging` at initialization and refuses a +host that does not offer it. +See [plugin logging](../../guides/plugin-logging.md) for the typed diagnostics +API, host-owned catalog, exact levels, operation correlation and `${logDir}`. +Use `diagnostics.degraded/recovered` (Rust: `diagnostics().degraded/recovered`) +for handled fallback or recovery facts. Function and build/VCS terminal facts +are automatic; do not log a failure before returning it to the SDK. Suppress +successful high-frequency reads with `suppressSuccessDiagnostics(operation)` +(Rust: `suppress_success_diagnostics`). There is no free-text `log` API, no +stderr fallback and no plugin-owned file writer. + +## API + +| API | Purpose | +| --- | --- | +| `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 version fails. Core validates the document. `litheBuild` is answered only when a handler exists. | +| `backend.build.execute(handler)` | Provider-owned bounded task, returning `{ exitCode, output }`. | +| `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 0`. | +| `backend.build.prepareRun(handler)`, `backend.build.prepareDebug(handler)` | Return a portable launch plan after upstream synchronization/build; the host owns the application process. | +| `backend.buildVersion` | `0` when the host offered `litheBuild` and a build handler exists, otherwise `null`; build methods are refused before their handler runs when it is `null`. | +| `backend.functionsVersion` | `0` when the host offered `litheFunctions`, otherwise `null`. | +| `backend.host` | [Host services](#host-services) (`lithe/host/*`), usable once the session is ready. | +| `backend.onCommand(commandID, handler)` | One of the plugin's commands ran; the handler gets `{ commandID, workspaceContext }`. | +| `backend.onContextsChanged(handler)` | Workspace contexts opened, closed, or changed; the handler gets `{ revision }`. | +| `backend.onHostNotification(method, handler)` | A `lithe/host/*` notification a later host adds. Unknown ones are ignored. | +| `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. | +| `backend.onRequest(method, handler)`, `backend.onNotification(method, handler)` | Ordinary LSP methods, such as `textDocument/hover` for a language server. Lifecycle and `lithe/*` methods are reserved. | +| `backend.sendRequest`, `backend.sendNotification` | Ordinary LSP messages to the host; `lithe/*` methods are reserved. | +| `backend.diagnostics.degraded(operation, code, count?)`, `.recovered(...)` | Typed handled fallback/recovery; no payload text. | +| `backend.listen()` | Serves stdio until `exit` or the end of input. | +| `PluginError(code, message)` | A failure meant for your own UI, or with a `VCS_ERRORS` code, for the host's. | +| `HostError` | The host refused or failed a host request: `code` (`error.data.code`), `rpcCode`, and `details`. `HOST_ERRORS` maps each code, including the `plugin.function.*` codes of the relay, to its number. | +| `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | +| `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | + +Every handler receives a context: + +| Member | Meaning | +| --- | --- | +| `signal` | Aborted when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends; also once the handler has settled, so work it left running stops. | +| `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | +| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. In VCS handlers it also reports the finished command unless `options.report` is `false`. | +| `diagnostics` | Typed diagnostics correlated with this call. | +| `host` | `backend.host` bound to the call: host requests it left unanswered are cancelled when the call ends. | +| `functionID` | Functions only. | +| `workspaceContext` | Functions only: `{ contextID, generation }` of the page's window, or `null`. | +| `caller` | Functions only: `{ pluginID }` of the plugin whose relayed call this is, or `null` for a call Lithe started or when the field is not exactly `{ pluginID }`. Core writes it; it is read-only. | +| `operationID` | Functions and bounded build/test/run-preparation calls. Opaque wire identity chosen by Core; never derive anything from it. | +| `progress({ message?, percentage?, output? })` | Functions use messages up to 1024 UTF-8 bytes. Build/test/run preparation use messages up to 4096 bytes and output up to 64 KiB per report. Percentages are clamped to 0–100; cancelled or settled build calls cannot report. Model/dependency/discovery contexts have no progress. | + +## Native build workflows + +The typed handlers for task execution, tests, run and debug preparation require +the corresponding host capabilities (`buildProviders`); the manifest +requirement and runtime negotiation are both checked. + +See [the native workflow guide](../../guides/native-build-workflows.md) and the +[no-page sample](examples/sample-native-build/backend/main.mjs). Core validates +workspace containment and upstream selections; the SDK does not implement a +second build engine. Build tool failures return their exit code, while a thrown +error fails preparation and cannot start an application. + +## Host services + +A backend may ask the host for services; each is granted by a manifest +permission and refused otherwise. They are available when the host offers +`litheFunctions` in `initialize`; without it, host requests fail locally with +`HostError` `plugin.host.unavailable` and send nothing. + +```js +backend.function('query.run', async (params, context) => { + // Permission credentials.store: the platform's secret store, per plugin. + const password = await context.host.credentials.get('connection.prod:password'); + if (password === null) throw new PluginError('query.noPassword', 'Sign in first.'); + + // The window the page runs in. + const workspace = context.workspaceContext; + if (workspace && await context.host.supports('lithe/host/workspace/getFolders')) { + const { folders } = await context.host.workspace.getFolders(workspace); + // ... + } + return runQuery(params, password); +}); + +backend.onCommand('db.refresh', async ({ workspaceContext }) => { + if (!workspaceContext) return; + await backend.host.window.showInformation(workspaceContext, { text: 'Refreshed.' }); +}); +``` + +| `backend.host` member | Purpose | +| --- | --- | +| `credentials.get(key)`, `.set(key, secret)`, `.delete(key)` | Secrets in the platform's store. `get` returns `null` when nothing is stored. A key is 1–128 of `[A-Za-z0-9._:-]`, a secret at most 16 KiB; others fail locally. | +| `capabilities()`, `supports(method)` | What this session may call, from `lithe/host/getCapabilities` (asked once). | +| `workspace.listContexts()`, `.getContext(context)`, `.getFolders(context)` | Workspace contexts; only `getContext` returns, and creates, the context's `stateDirectory`. | +| `workspace.readDirectory`, `.stat`, `.readFile`, `.findFiles`, `.findText`, `.watch` | Files of a local context, addressed as `{ rootID, path }` from `getFolders`. | +| `documents.list`, `.getSnapshot`; `editor.getActive`, `.getSelections`, `.applyEdits`, `.replaceSelections`, `.insertText`, `.open`, `.reveal`, `.registerActions` | Live documents and editors. Edits name the `expectedVersion` they were computed against. | +| `preview.openHTML`, `browser.openExternal`, `window.showInformation`, `.showWarning`, `.showError` | Preview, browser, and messages. | +| `functions.providers(functionID)` | The enabled plugins that export a function: `{ providers: [{ pluginID, version }] }`. See [Calling other plugins](#calling-other-plugins). | +| `functions.invoke(context, call)` | Calls one provider's exported function in a workspace context: `{ value }`. `call` is `{ pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }`. | +| `request(method, params?)`, `subscribe(method, params, onEvent?)` | Any `lithe/host/*` method, including ones a later host adds. | +| `subscriptions.dispose(id)` | Ends a subscription; `Subscription.dispose()` does the same. | +| `negotiated` | `true` when the host offered `litheFunctions` in this session. | + +A host registers only the services it implements, and `capabilities()` lists +exactly those; call `supports(method)` before depending on one of them. What +the Lithe desktop does not register yet is listed under +[Capability versions](../../reference/capabilities-and-versions.md#capability-versions-requires). + +Methods taking a workspace context accept `{ contextID, generation }` or a +whole context from `listContexts`; their params are the contract's, without +`context`. Every method takes `{ signal }` as its last argument. The full +params and results are in +[the contract](../../contract/v1/README.md#public-document-file-and-window-methods) +and in `src/index.d.ts`. + +What the SDK does for you, and what it leaves to you: + +- **Refusals are `HostError`.** Branch on `error.code`, such as + `plugin.host.contextExpired`; a code this SDK does not know is still a + `HostError`. On `versionConflict` reread and recompute; on `contextExpired` + resolve the original target again, never the focused window; retry `busy` + and `rateLimited` (after `details.retryAfterMilliseconds`) with a bound; + `permissionDenied` and `readOnly` need the user. An unknown param is + `plugin.host.unsupportedOption` naming it in `details.option`, so you can + retry without it. +- **Cancellation has no rollback.** Aborting a request's signal, or ending + the call that sent it, rejects it at once; the SDK also sends + `$/cancelRequest` so the host stops. An edit or an external open may + already have taken effect, and the SDK never sends a request again. +- **At most 16 host requests are unanswered.** Further ones wait in the SDK + for a free slot instead of being refused. +- **Subscriptions route by ID.** `workspace.watch` and `subscribe` call + `onEvent(event, { subscriptionID, sequence, gap })` in order; `gap` is + true when events were missed, so rescan. Nothing arrives after `dispose()` + or the end of the session. +- **Commands are events.** `onCommand` runs when the user runs one of the + plugin's commands; the host waits for no result. A stopped backend is + started for it only when the manifest declares `onCommand:`. + +### Calling other plugins + +A plugin can call a function another enabled plugin exports. Core relays +every call: your backend never reaches the provider's process, and the +provider learns only the call it serves. The rules of the relay are in +[the contract](../../contract/v1/README.md#cross-plugin-calls). + +- **Declare the call in the manifest.** The caller lists + `pluginFunctions.calls` (`{ id, version }`, the function and the contract + version it was written against) and `requires.pluginFunctions: 0`, with + `backend: 0`. The provider lists `pluginFunctions.exports` and registers + the function with `backend.function`. The calls are the consent; there is + no permission. +- **Check `supports()` first.** `await backend.host.supports('lithe/host/functions/invoke')` + is true only in a session of a plugin that declares a call. Otherwise + `functions.providers`, `functions.invoke`, and the same methods through + `host.request` fail at once with `HostError` `plugin.host.unavailable` + (-32601) and send nothing. + +```js +backend.function('report.build', async (params, context) => { + const workspace = context.workspaceContext; + if (!workspace || !(await context.host.supports('lithe/host/functions/invoke'))) { + throw new PluginError('report.noRelay', 'This Lithe cannot call other plugins.'); + } + const { providers } = await context.host.functions.providers('project.describe'); + if (providers.length === 0) throw new PluginError('report.noProvider', 'Install a plugin that describes projects.'); + const { value } = await context.host.functions.invoke(workspace, { + pluginID: providers[0].pluginID, + functionID: 'project.describe', + params: { project: params.project }, + timeoutMilliseconds: 10000, + }); + return summarize(value); +}); +``` + +- **Results are raw.** `providers` returns `{ providers: [{ pluginID, version }] }` + and `invoke` returns `{ value }`, where `null` is a result. A call is + `{ pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }`; + `params` is a JSON object, not sent when omitted (Core reads `{}`). + `timeoutMilliseconds` is a positive integer. Core defaults it to 60000 + and caps it at 600000 (`LIMITS.relayDefaultTimeoutMilliseconds`, + `LIMITS.relayMaxTimeoutMilliseconds`); the SDK passes it on as given. +- **Mistakes fail locally, with no frame sent.** A `call` that is not an + object, or a bad `pluginID`, `functionID`, `params`, `timeoutMilliseconds` + or `parentOperationID`, rejects with `PluginSdkError` + `sdk.functions.invalidCall`; a bad workspace context rejects with + `sdk.host.invalidContext`, as for every other host method. The SDK sends only + the fields above and drops any other, such as `caller`, which Core writes + itself and refuses in a request. +- **`parentOperationID` is filled in inside a function.** In a function + handler, `context.host` sets it to the `operationID` of the call being + served (when that is a string of 1–128 bytes), so Core knows which call the + new one belongs to and can cancel it with its parent, detect cycles, and + bound the chain at 4 calls. An explicit `parentOperationID` wins. Everything + else has no parent: `backend.host`, host notification callbacks + (`onCommand`, `onContextsChanged`, `onHostNotification`), build, VCS, and + shortcut import handlers, plain `onRequest` handlers, and async work left + running after a call ended. Without a parent Core treats the call as a + child of every relayed call the backend is serving at that moment. Never + pass the `operationID` of a call that has finished: Core refuses it with + `plugin.host.cancelled`. +- **A relayed call fills a host request slot.** It counts among the 16 + unanswered host requests until Core answers, which can take the whole + timeout, and a caller has at most 8 relayed calls running. Start calls + together only when they are independent. +- **Read the caller from `context.caller`.** In a provider's function, + `context.caller.pluginID` is the plugin that called, written by Core and + impossible to forge; `null` means the host started the call. Never decide by + a plugin ID in `params`, which any caller can write. Do not accept an + executable path, command line, or script from a caller and run it: take a + request you interpret, and choose the executable yourself. + +A failed relayed call rejects with a `HostError` whose `code` is one of these +(the `HOST_ERRORS` keys): + +| Code | Number | Meaning | +| --- | --- | --- | +| `plugin.function.notDeclared` | -32027 | The manifest does not declare this call. | +| `plugin.function.notExported` | -32028 | The provider does not export the function, or its backend has not registered it. | +| `plugin.function.providerDisabled` | -32029 | The provider is not installed or enabled. | +| `plugin.function.versionMismatch` | -32030 | The declared and exported contract versions differ. | +| `plugin.function.callCycle` | -32065 | The provider is already on the call chain. | +| `plugin.function.depthExceeded` | -32066 | The call chain is longer than 4 calls. | +| `plugin.function.tooManyCalls` | -32067 | The caller already has 8 relayed calls running. | +| `plugin.function.payloadTooLarge` | -32068 | The params, or a result, exceeds 256 KiB. | +| `plugin.function.providerUnavailable` | -32069 | The provider's backend exited, withdrew the function, or did not start in time. | +| `plugin.function.failed` | -32070 | The provider's function answered with an error. | +| `plugin.function.invalidResult` | -32071 | The provider answered without a result, or with one over 1 MiB. | + +The host codes `plugin.host.timeout`, `plugin.host.cancelled`, +`plugin.host.contextExpired`, `plugin.host.authorityUnsupported`, +`plugin.host.permissionDenied` and `plugin.host.invalidParams` also occur. On +`plugin.function.failed`, `error.message` is the provider's message and +`error.details.pluginErrorCode` is the code the provider's function threw; +only branch on a code its provider documents. + +## Results and errors + +The following mapping describes private functions. Typed build operations use +their domain result/error contract; see the native workflow guide above. + +| Handler outcome | What Lithe sees | +| --- | --- | +| Returns a value (including `null`) | `result.value` | +| Throws `PluginError(code, message)` | `plugin.function.failed` with your `message` and `pluginErrorCode`. The code is for your plugin's own UI; Lithe never branches on it. | +| Throws anything else | `plugin.function.failed` with a generic message, so paths and stack traces never reach the UI. Automatic diagnostics record only a fixed failure category. | +| Ends because of `signal` | `requestCancelled` (or Lithe's timeout/revocation outcome) | + +A result must serialize to at most 1 MiB of JSON. A value JSON cannot encode +(a `BigInt`, a cycle) or a larger one fails like any other unexpected error and +emits one typed failure fact; the session keeps running. A function ID is dot-separated +lowerCamelCase segments (`snapshot.refresh`), at most 128 bytes, and a session +holds at most 256 functions. A `PluginError` code matches +`[A-Za-z][A-Za-z0-9._-]*`, at most 64 bytes. + +## Rules that keep Lithe in charge + +The SDK leaves your plugin free to define its own functions, params, results, +UI, and state, but these rules keep Lithe's workflows, permissions, and +lifecycle unified. The SDK enforces the ones it can. + +1. **Domain capabilities stay typed.** Build models and dependency trees go + through `backend.build.*`, 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 `sendRequest` refuses every `lithe/*` + method. Host services go through `backend.host`, which sends only + `lithe/host/*`. 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. +4. **The package is read-only.** Keep caches and snapshots under the + directories Lithe passes through launch templates (`${stateDir}`, + `${workspaceStateDir}`). `openStateStore` refuses any directory inside the + plugin package. Diagnostics use the SDK and host; native log files require + an explicitly supplied `${logDir}`. Writing to the package breaks code + signing and update deltas. +5. **Work ends with its call.** Start processes with `context.run`, and check + `signal` in long loops. On cancellation the SDK stops the process tree + politely, then forcibly. When the handler returns or throws, tools of that + call still running (an unawaited `context.run`, the siblings of a failed + `Promise.all`) are stopped the same way. A tool that exits on its own takes + the workers it left in its process group with it. On `shutdown`, `exit`, or + the end of input the SDK ends every process it started. If the backend + itself is killed, no JavaScript runs; ending what it left is the host's + job, not the SDK's. Lithe finds those processes by the markers it puts in + the backend's environment, so `runProcess` carries them over even when + `env` replaces the environment. +6. **Errors stay presentable.** Throw `PluginError` for failures the user + should see; every other exception is reported generically. + +## Read-only build model inputs + +The build protocol accepts the host's optional `params.modelInputs` snapshot. +A consumer declares exact `modelInputs` producer slots and requires +`buildProviders: 0` plus `buildModelInputs: 0`. A producer opts into +`modelSharing` and selects any ordinary build option values it wants to share. +The host supplies normalized projects, targets and content roots; tasks, run +plans, settings and unselected option values are excluded. + +Treat `revision` as an opaque workspace snapshot identity. Recompute cached +work when it changes. A slot can be unavailable, and an instance can be loading, +stale or failed; only ready instances carry facts. Do not invoke another plugin +or install/activate a missing producer. Input sharing grants no process or +private-function authority. Producers cannot consume and re-export +inputs. Core validates the shape and the host revokes in-flight work when its +producer changes. + +The two native workflow samples demonstrate independent producer and consumer +backends in the Core end-to-end tests. Their engine is fictional. The Lithe +desktop does not declare `buildModelInputs`. + +## Native terminal API + +The SDK includes wrappers for plugin-owned native terminal create/show/close, +reopen with bounded display history, disposal and exit subscriptions. They +require `terminal.create`. See +[the native terminal guide](../../guides/native-terminals.md). + +## Testing + +Run these from the root of this repository: + +```bash +node --test shared/plugins/sdk/node/test/*.test.mjs + +# Development-only TypeScript compiler; no production dependencies are added. +npm ci --prefix shared/plugins/sdk/node --ignore-scripts +npm run --prefix shared/plugins/sdk/node test:types + +# End to end: the sample backends of all three SDKs against this checkout's Core. +node shared/plugins/scripts/sdk-e2e.mjs +``` + +The tests use a bounded in-memory host (`test/harness.mjs`), which plugin +authors can copy to test their own backends without starting Lithe. The +end-to-end script needs Rust, Node.js 20.3 or newer, Java +(`LITHE_PLUGIN_SDK_JAVA_RUNTIME` or `java` on `PATH`) and Maven. diff --git a/shared/plugins/sdk/node/examples/sample-build/backend/main.mjs b/shared/plugins/sdk/node/examples/sample-build/backend/main.mjs new file mode 100644 index 00000000..fa813ed7 --- /dev/null +++ b/shared/plugins/sdk/node/examples/sample-build/backend/main.mjs @@ -0,0 +1,155 @@ +// Sample Lithe plugin backend built on the official Node.js SDK. +// +// It is the build provider of the fictional "sample" build system, whose +// workspace file `lithe-sample.json` lists projects: +// +// { "projects": [{ "name": "app", "path": "app", +// "build": ["node", "build.mjs"], +// "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] } +// +// It shows the three things a backend does: +// - answers Lithe's typed build requests (`lithe/build/model`, +// `lithe/build/dependencies`), whose results Lithe itself understands; +// - offers plugin functions for its own UI: `workspace.build` runs a +// multi-step build (prepare, run each project's tool, record a snapshot), +// and `snapshot.read` returns the last snapshot, also after a restart; +// - registers `snapshot.clear` at runtime once there is a snapshot to clear. +// +// A packaged plugin vendors the SDK inside its package, for example as +// `backend/vendor/lithe-plugin-sdk/`, and imports it from there. + +import { readFile } from 'node:fs/promises'; +import { isAbsolute, join, normalize, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createBackend, openStateStore, PluginError } from '../../../src/index.mjs'; + +const backend = createBackend({ name: 'sample-build', version: '0.1.0' }); +// 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; + +backend.onInitialize(async ({ params }) => { + // Hosts may send a directory URI with a trailing slash (macOS does); + // `resolve` drops it so `inside` compares like paths. + workspaceRoot = resolve(fileURLToPath(params.rootUri)); + // `--state ${workspaceStateDir}` in plugin.json: the host's writable + // directory for this workspace. The package itself is read-only. + store = openStateStore(argument('--state')); + if (store.read('snapshot', null)) offerClear(); +}); + +backend.build.model(async ({ root }) => { + const config = await readConfig(root); + return { + projects: config.projects.map(({ name, path }) => ({ name, path })), + }; +}); + +backend.build.dependencies(async ({ root, project }) => { + const config = await readConfig(root); + const projects = project ? config.projects.filter(({ path }) => path === project.path) : config.projects; + return { + dependencies: projects.flatMap(({ dependencies = [] }) => + dependencies.map(({ name, version }) => ({ name, version, children: [] })), + ), + }; +}); + +backend.function('workspace.build', async ({ projects: selected }, context) => { + // Step 1: prepare. Read the workspace file and resolve the selection. + context.progress({ message: 'Preparing', percentage: 0 }); + const config = await readConfig('.'); + const projects = selectProjects(config, selected); + + // Step 2: run each project's tool. `context.run` ends the whole process + // tree when Lithe cancels the call, so a cancelled build leaves nothing behind. + const results = []; + for (const [index, project] of projects.entries()) { + context.progress({ message: `Building ${project.name}`, percentage: (100 * index) / projects.length }); + const [tool, ...args] = project.build; + const { exitCode } = await context.run(tool === 'node' ? process.execPath : tool, args, { + cwd: inside(workspaceRoot, project.path), + }); + results.push({ name: project.name, exitCode }); + if (exitCode !== 0) { + record(results, 'failed'); + throw new PluginError('sample.buildFailed', `${project.name} failed with exit code ${exitCode}.`); + } + } + + // Step 3: record the outcome, so `snapshot.read` returns it after a restart. + const snapshot = record(results, 'succeeded'); + context.progress({ message: 'Done', percentage: 100 }); + return snapshot; +}); + +backend.function('snapshot.read', () => store.read('snapshot', null)); + +// Reads the latest snapshot when the build ends, not when it starts: two +// overlapping builds then get consecutive generations instead of the same one. +// The read and the write run without an `await` between them. +function record(projects, status) { + const previous = store.read('snapshot', null); + const snapshot = { generation: (previous?.generation ?? 0) + 1, status, projects }; + store.write('snapshot', snapshot); + offerClear(); + return snapshot; +} + +function offerClear() { + if (backend.functionIDs.includes('snapshot.clear')) return; + backend + .registerFunction('snapshot.clear', () => { + store.delete('snapshot'); + return null; + }) + .catch(() => backend.diagnostics.degraded('initialize', 'registration_failed')); +} + +/** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ +function selectProjects(config, selected) { + if (selected === undefined || selected === null) return config.projects; + if (!Array.isArray(selected) || selected.length === 0 || !selected.every((name) => typeof name === 'string')) { + throw new PluginError('sample.selection', '"projects" must be a non-empty list of project names.'); + } + const unknown = selected.filter((name) => !config.projects.some((project) => project.name === name)); + if (unknown.length > 0) { + throw new PluginError('sample.unknownProject', `Unknown project: ${unknown.join(', ')}.`); + } + return config.projects.filter(({ name }) => selected.includes(name)); +} + +async function readConfig(root) { + let config; + try { + config = JSON.parse(await readFile(join(inside(workspaceRoot, root), 'lithe-sample.json'), 'utf8')); + } catch { + throw new PluginError('sample.config', 'lithe-sample.json is missing or is not valid JSON.'); + } + if (!Array.isArray(config.projects)) { + throw new PluginError('sample.config', 'lithe-sample.json needs a "projects" list.'); + } + return config; +} + +/** `relative` resolved inside `base`; workspace files never name paths outside it. */ +function inside(base, relative) { + const path = normalize(join(base, relative)); + if (isAbsolute(relative) || (path !== base && !path.startsWith(base + sep))) { + throw new PluginError('sample.path', `${relative} is outside the workspace.`); + } + return path; +} + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +await backend.listen(); diff --git a/shared/plugins/sdk/node/examples/sample-build/plugin.json b/shared/plugins/sdk/node/examples/sample-build/plugin.json new file mode 100644 index 00000000..c41ede06 --- /dev/null +++ b/shared/plugins/sdk/node/examples/sample-build/plugin.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-build", + "displayName": "Sample Build", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "buildProviders": 0, "buildSystems": 0 }, + "requiresTools": ["node"], + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": ["languageServer.launch", "process.build"], + "activationEvents": ["onBuildSystem:sample"], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { "markers": ["lithe-sample.json"], "nesting": "outermost" }, + "invocation": { + "executables": [{ "kind": "command", "command": "node" }] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": ["--state", "${workspaceStateDir}"], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { "source": "provider" }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": ["workspace"], + "arguments": ["--version"] + } + ], + "dependencies": { "source": "provider", "scopes": ["workspace", "project"] }, + "sync": { "watch": ["lithe-sample.json"] } + } + ] +} diff --git a/shared/plugins/sdk/node/examples/sample-native-build/backend/main.mjs b/shared/plugins/sdk/node/examples/sample-native-build/backend/main.mjs new file mode 100644 index 00000000..55ecd671 --- /dev/null +++ b/shared/plugins/sdk/node/examples/sample-native-build/backend/main.mjs @@ -0,0 +1,121 @@ +// A fictional build engine whose facts are supplied by lithe-sample.json. +// Native IDE operations use typed handlers; this plugin needs no web page. +import { readFile, realpath } from 'node:fs/promises'; +import { join, isAbsolute, resolve, sep, relative, posix } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createBackend, PluginError } from '../../../src/index.mjs'; + +const backend = createBackend({ name: 'sample-native-build' }); +let workspace; +backend.onInitialize(async ({ params }) => { workspace = await realpath(fileURLToPath(params.rootUri)); }); + +backend.build.model(async ({ root, modelInputs }) => { + const all = await projects(root); + return { projects: all.map(({ name, modelPath: path }) => ({ name, path, ...(modelInputs ? { properties: { inputRevision: String(modelInputs.revision), inputProjects: String(modelInputs.inputs.flatMap((slot) => slot.instances).filter((input) => input.state === "ready").flatMap((input) => input.model.projects).length) } } : {}) })), + targets: all.filter(({ run }) => run).map(({ modelPath: path }) => ({ name: 'Main', path, project: path, tags: ['application'] })) }; +}); +backend.build.dependencies(() => ({ dependencies: [] })); + +backend.build.execute(async (params, context) => { + if (params.task !== 'build') throw new PluginError('sample.task', 'Unknown build task.'); + return build(await select(params), params.userArguments, context); +}); + +backend.build.discoverTests(async (params) => ({ tests: (await select(params)).flatMap((project) => + (project.tests ?? []).map(({ suite, case: testCase }) => ({ suite, case: testCase, + project: { name: project.name, path: project.path } }))), +})); + +backend.build.test(async (params, context) => { + const selected = (await select(params)).flatMap((project) => (project.tests ?? []) + .filter((test) => (params.suite === undefined || params.suite === test.suite) + && (params.case === undefined || params.case === test.case)) + .map((test) => ({ project, test }))); + if (selected.length === 0 && (params.suite !== undefined || params.case !== undefined)) { + throw new PluginError('sample.test', 'The selected test no longer exists.'); + } + let output = ''; + for (const { project, test } of selected) { + const result = await run(test.command, project, params.userArguments, context); + output += result.stdout + result.stderr; + if (result.exitCode !== 0) return { exitCode: result.exitCode, output }; + } + return { exitCode: 0, output }; +}); + +backend.build.prepareRun((params, context) => prepare(params, context, false)); +backend.build.prepareDebug((params, context) => prepare(params, context, true)); +async function prepare(params, context, debug) { + const selected = await select(params); + const project = selected[0]; + if (selected.length !== 1 || params.run !== 'application' || !project.run + || params.target?.name !== 'Main' || params.target?.path !== project.path) { + throw new PluginError('sample.target', 'The selected application no longer exists.'); + } + const result = await build(selected, [], context); + if (result.exitCode !== 0) throw new PluginError('sample.buildFailed', 'Build failed before launch.'); + if (debug) { + if (typeof project.run.debugProgram !== 'string') throw new PluginError('sample.target', 'This application has no native debug program.'); + return { program: posix.join(project.path, project.run.debugProgram), arguments: params.userArguments, + workingDirectory: project.path, environment: {} }; + } + return { executable: { kind: 'command', value: project.run.command }, + arguments: [...project.run.arguments, ...params.userArguments], + workingDirectory: project.path, environment: {} }; +} + +async function build(selected, arguments_, context) { + let output = ''; + for (const project of selected) { + context.progress({ message: `Building ${project.name}` }); + const result = await run(project.build, project, arguments_, context); + output += result.stdout + result.stderr; + if (result.exitCode !== 0) return { exitCode: result.exitCode, output }; + } + return { exitCode: 0, output }; +} + +async function run(command, project, arguments_, context) { + context.throwIfCancelled(); + if (!Array.isArray(command) || command.length === 0 || !command.every((part) => typeof part === 'string')) { + throw new PluginError('sample.command', 'A tool command must be a nonempty argument array.'); + } + return context.run(command[0] === 'node' ? process.execPath : command[0], [...command.slice(1), ...arguments_], { + cwd: await inside(project.path), + onStdout: (output) => context.progress({ output }), + onStderr: (output) => context.progress({ output }), + }); +} + +async function select(params) { + const all = await projects(params.root); + if (!params.project) return all; + const selected = all.filter(({ name, path }) => name === params.project.name && path === params.project.path); + if (selected.length !== 1) throw new PluginError('sample.project', 'The selected project no longer exists.'); + return selected; +} + +async function projects(root) { + const base = await inside(root); + const config = JSON.parse(await readFile(join(base, 'lithe-sample.json'), 'utf8')); + if (!Array.isArray(config.projects)) throw new PluginError('sample.config', 'A projects array is required.'); + return Promise.all(config.projects.map(async (project) => { + if (typeof project.path !== 'string' || isAbsolute(project.path)) throw new PluginError('sample.path', 'A project path must be relative to its instance.'); + const directory = await realpath(resolve(base, project.path)); + if (directory !== base && !directory.startsWith(base + sep)) throw new PluginError('sample.path', 'A project is outside its instance.'); + // The model is instance-relative; later request selectors are workspace-relative. + const modelPath = relative(base, directory).split(sep).join('/') || '.'; + return { ...project, modelPath, path: posix.join(root, modelPath) }; + })); +} + +async function inside(path) { + if (typeof path !== 'string' || isAbsolute(path)) throw new PluginError('sample.path', 'A workspace-relative directory is required.'); + const directory = await realpath(resolve(workspace, path)); + if (directory !== workspace && !directory.startsWith(workspace + sep)) { + throw new PluginError('sample.path', 'The directory is outside the workspace.'); + } + return directory; +} + +await backend.listen(); diff --git a/shared/plugins/sdk/node/examples/sample-native-build/plugin.json b/shared/plugins/sdk/node/examples/sample-native-build/plugin.json new file mode 100644 index 00000000..6dd7b39b --- /dev/null +++ b/shared/plugins/sdk/node/examples/sample-native-build/plugin.json @@ -0,0 +1,156 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-native-build", + "displayName": "Sample Native Build", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { + "buildProviders": 0, + "buildSystems": 0, + "debugAdapters": 0 + }, + "requiresTools": ["node"], + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onDebug:native" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project" + ], + "source": "provider", + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/shared/plugins/sdk/node/examples/sample-vcs/plugin.json b/shared/plugins/sdk/node/examples/sample-vcs/plugin.json new file mode 100644 index 00000000..16604c10 --- /dev/null +++ b/shared/plugins/sdk/node/examples/sample-vcs/plugin.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-vcs", + "displayName": "Sample VCS", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "vcsProviders": 0 }, + "requiresTools": ["node"], + "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/shared/plugins/sdk/node/examples/sample-vcs/provider/main.mjs b/shared/plugins/sdk/node/examples/sample-vcs/provider/main.mjs new file mode 100644 index 00000000..37fc8e8a --- /dev/null +++ b/shared/plugins/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/shared/plugins/sdk/node/package-lock.json b/shared/plugins/sdk/node/package-lock.json new file mode 100644 index 00000000..a1784838 --- /dev/null +++ b/shared/plugins/sdk/node/package-lock.json @@ -0,0 +1,51 @@ +{ + "name": "@lithe/plugin-sdk", + "version": "0.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lithe/plugin-sdk", + "version": "0.5.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20.3" + } + }, + "node_modules/@types/node": { + "version": "20.19.9", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.9.tgz", + "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/shared/plugins/sdk/node/package.json b/shared/plugins/sdk/node/package.json new file mode 100644 index 00000000..4d4c1876 --- /dev/null +++ b/shared/plugins/sdk/node/package.json @@ -0,0 +1,29 @@ +{ + "name": "@lithe/plugin-sdk", + "version": "0.5.0", + "description": "Official SDK for Lithe plugin backends: plugin functions and build providers over the LSP base protocol.", + "license": "Apache-2.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20.3" + }, + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.mjs" + } + }, + "files": [ + "src", + "README.md" + ], + "scripts": { + "test": "node --test test/*.test.mjs", + "test:types": "tsc --project test/types/tsconfig.json" + }, + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" + } +} diff --git a/shared/plugins/sdk/node/src/backend.mjs b/shared/plugins/sdk/node/src/backend.mjs new file mode 100644 index 00000000..261c2b5a --- /dev/null +++ b/shared/plugins/sdk/node/src/backend.mjs @@ -0,0 +1,1081 @@ +// The plugin backend session: one process the host starts from the plugin's +// manifest and talks to over stdio with the LSP base protocol. The SDK owns +// everything Lithe manages uniformly -- the lifecycle, protocol negotiation, +// cancellation, error codes, and the `lithe/*` namespace -- and leaves the +// plugin free to decide what its handlers do. +// +// Contracts: contract/v1/README.md ("Build providers", "Plugin functions", +// "Host services" and "VCS providers"), and Lithe's +// shared/contracts/vcs-provider-v1.md. + +import { format } from 'node:util'; +import { performance } from 'node:perf_hooks'; +import { diagnostics, KNOWN_OPERATIONS, requestOperationId } from './diagnostics.mjs'; +import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; +import { HostError, PluginError, PluginSdkError, toResponseError } from './errors.mjs'; +import { createHost, HostChannel } from './host.mjs'; +import { killAllProcesses, runProcess } from './process.mjs'; +import { + BUILD_VERSION, + boundedText, + ERROR_CODES, + FUNCTIONS_VERSION, + HOST_NOTIFICATIONS, + isContextReference, + isFunctionId, + isReservedMethod, + isVcsId, + KEYMAP_IMPORT_VERSION, + LIMITS, + LOGGING_VERSION, + METHODS, + VCS_OPERATIONS, + VCS_REQUIRED_OPERATIONS, + VCS_VERSION, +} from './protocol.mjs'; + +/** The `version` every build result document carries. */ +const BUILD_DOCUMENT_VERSION = 0; + +/** Canonical result format of each typed build request. */ +const BUILD_FORMATS = Object.freeze({ + [METHODS.buildModel]: 'litheBuildModel', + [METHODS.buildDependencies]: 'litheDependencies', + [METHODS.buildDiscoverTests]: 'litheTestDiscovery', +}); + +/** Host notifications the SDK routes itself; see `onCommand`, `onContextsChanged`, and `host.subscribe`. */ +const ROUTED_NOTIFICATIONS = Object.freeze(Object.values(HOST_NOTIFICATIONS)); + +/** 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()`. + * + * @param {object} [options] + * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. + * @param {string} [options.version] Reported as `serverInfo.version`. + * @param {object} [options.capabilities] Standard LSP server capabilities, for a + * backend that is also a language server. `experimental.litheBuild`, + * `experimental.litheFunctions`, `experimental.litheVcs`, and `experimental.litheKeymapImport` are negotiated by + * the SDK and cannot be set here. + */ +export function createBackend(options = {}) { + return new Backend(options); +} + +class Backend { + #logging = false; + #loggingReady = false; + #earlyDiagnostics = []; + #quietSuccess = new Set(); + #name; + #version; + #capabilities; + #functions = new Map(); + #keymapProviders = new Map(); + #selectedKeymapProviders = new Set(); + #buildHandlers = new Map(); + #buildVersion = null; + #functionsVersion = null; + /** The declared VCS provider: `{ id, handlers: Map, capabilities }`. */ + #vcs = null; + #requestHandlers = new Map(); + #notificationHandlers = new Map(); + #commandHandlers = new Map(); + #contextsHandler = null; + #hostNotificationHandlers = new Map(); + #channel; + #initializeHooks = []; + #shutdownHooks = []; + /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ + #phase = 'idle'; + #negotiated = Object.freeze({ functions: false, build: false, vcs: false }); + #initializeParams = null; + /** Host requests being handled, by JSON-RPC ID. */ + #incoming = new Map(); + /** Requests this backend sent to the host, by JSON-RPC ID. */ + #outgoing = new Map(); + #nextRequestId = 1; + /** Serializes register/unregister so the host sees them in call order. */ + #registrations = Promise.resolve(); + #output = null; + #errorOutput = process.stderr; + #finish = null; + + /** Typed build-provider handlers (`lithe/build/*`). */ + build; + + /** + * Host services (`lithe/host/*`), usable once the session is ready. A handler's `context.host` is the + * same object bound to its call. + */ + host; + + constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { + const experimental = capabilities.experimental ?? {}; + if ('litheLogging' in experimental || 'litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental || 'litheKeymapImport' in experimental) { + throw new PluginSdkError( + 'sdk.capabilities.reserved', + 'litheLogging, litheBuild, litheFunctions, litheVcs, and litheKeymapImport are negotiated by the SDK; declare handlers instead.', + ); + } + this.#name = String(name); + this.#version = version === undefined ? undefined : String(version); + this.#capabilities = capabilities; + this.build = Object.freeze({ + /** Answers `lithe/build/model` with a `litheBuildModel` document. */ + model: (handler) => this.#declareBuild(METHODS.buildModel, handler), + /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ + dependencies: (handler) => this.#declareBuild(METHODS.buildDependencies, handler), + /** Runs a bounded task in the provider; the host owns its operation. */ + execute: (handler) => this.#declareBuild(METHODS.buildExecute, handler), + test: (handler) => this.#declareBuild(METHODS.buildTest, handler), + discoverTests: (handler) => this.#declareBuild(METHODS.buildDiscoverTests, handler), + /** Prepares a plan; the application itself belongs to the host. */ + prepareRun: (handler) => this.#declareBuild(METHODS.buildPrepareRun, handler), + prepareDebug: (handler) => this.#declareBuild(METHODS.buildPrepareDebug, handler), + }); + this.#channel = new HostChannel({ + negotiated: () => this.#negotiated.functions, + ready: () => this.#phase === 'ready', + send: (method, params) => this.#send(method, params), + notify: (method, params) => this.#write({ jsonrpc: '2.0', method, params }), + log: () => this.#diagnostics().degraded('notification', 'event_handler_failed'), + }); + this.host = createHost(this.#channel); + } + + /** Protocols the host agreed to in `initialize`; all false before it. */ + get negotiated() { + return this.#negotiated; + } + + /** The build protocol version in use (0), or null when unavailable. */ + get buildVersion() { return this.#buildVersion; } + + /** The plugin-function protocol version in use (0), or null when unavailable. */ + get functionsVersion() { return this.#functionsVersion; } + + /** The host's `initialize` params, or null before it arrives. */ + get initializeParams() { + return this.#initializeParams; + } + + /** IDs of the functions this backend currently serves, sorted. */ + get functionIDs() { + return [...this.#functions.keys()].sort(); + } + + /** + * Declares a plugin function offered from the start, in the `initialize` + * answer. To add one later, use `registerFunction`. + * + * @param {string} id Dot-separated lowerCamelCase segments, such as `snapshot.refresh`. + * @param {(params: object, context: object) => unknown} handler + */ + function(id, handler) { + if (!this.#beforeAnswer()) { + throw new PluginSdkError('sdk.function.lateDeclaration', 'After initialize, add functions with registerFunction().'); + } + this.#checkNewFunction(id, handler); + this.#functions.set(id, handler); + return this; + } + + /** 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 + * `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); + return this; + } + + /** Handles a standard LSP notification. */ + onNotification(method, handler) { + this.#declareHandler(this.#notificationHandlers, method, handler); + return this; + } + + /** + * Handles `lithe/host/commands/didRun` for one of the plugin's commands + * The handler gets `{ commandID, workspaceContext }`, the + * context of the window it ran in or null. It is an event: the host waits + * for no result. + */ + onCommand(commandID, handler) { + if (typeof commandID !== 'string' || !/^[a-z0-9.-]{1,128}$/.test(commandID)) { + throw new PluginSdkError('sdk.command.invalidID', `Command ID ${JSON.stringify(commandID)} must be 1-128 of [a-z0-9.-].`); + } + if (typeof handler !== 'function') throw new TypeError('A command handler must be a function.'); + this.#commandHandlers.set(commandID, handler); + return this; + } + + /** + * Handles `lithe/host/workspace/didChangeContexts` with + * `{ revision }`; call `host.workspace.listContexts()` for the contexts. + */ + onContextsChanged(handler) { + if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); + this.#contextsHandler = handler; + return this; + } + + /** + * Handles a `lithe/host/*` notification a later host adds. Commands, + * context changes, and subscription events have their own APIs. + */ + onHostNotification(method, handler) { + if (typeof method !== 'string' || !method.startsWith('lithe/host/')) { + throw new PluginSdkError('sdk.method.invalid', `${method} is not a host notification.`); + } + if (ROUTED_NOTIFICATIONS.includes(method)) { + throw new PluginSdkError('sdk.method.reserved', `${method} is routed by the SDK; use onCommand, onContextsChanged, or host.subscribe.`); + } + if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); + this.#hostNotificationHandlers.set(method, handler); + return this; + } + + /** Runs before the `initialize` answer, with `{ params, negotiated }`. */ + onInitialize(hook) { + this.#initializeHooks.push(hook); + return this; + } + + /** Runs on `shutdown`, after running calls were cancelled. */ + onShutdown(hook) { + this.#shutdownHooks.push(hook); + return this; + } + + /** + * Adds a function. Before the `initialize` answer (including from an + * `onInitialize` hook) it joins the initial list; afterwards the host must + * accept it first. + */ + registerFunction(id, handler) { + if (this.#beforeAnswer()) { + return settle(() => { + this.#checkNewFunction(id, handler); + this.#functions.set(id, handler); + }); + } + return this.#queueRegistration(async () => { + this.#requireFunctions(); + this.#checkNewFunction(id, handler); + // Installed before the request: the host may invoke it as soon as its + // registry changed, before this backend reads the reply. + this.#functions.set(id, handler); + try { + await this.#request(METHODS.functionRegister, { functions: [{ id }] }); + } catch (error) { + this.#functions.delete(id); + this.#quietSuccess.delete(id); + throw error; + } + }); + } + + /** Withdraws a function. The host ends its running calls as revoked. */ + unregisterFunction(id) { + if (this.#beforeAnswer()) { + return settle(() => { + if (!this.#functions.delete(id)) { + throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); + } + this.#quietSuccess.delete(id); + }); + } + return this.#queueRegistration(async () => { + this.#requireFunctions(); + if (!this.#functions.has(id)) { + throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); + } + await this.#request(METHODS.functionUnregister, { ids: [id] }); + this.#functions.delete(id); + this.#quietSuccess.delete(id); + for (const call of this.#incoming.values()) { + if (call.functionID === id) call.controller.abort(); + } + }); + } + + /** Sends a standard LSP request to the host. `lithe/*` methods are reserved. */ + sendRequest(method, params) { + this.#checkOutgoing(method); + return this.#request(method, params); + } + + /** Sends a standard LSP notification to the host. `lithe/*` methods are reserved. */ + sendNotification(method, params) { + this.#checkOutgoing(method); + this.#write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }); + } + + get diagnostics() { return this.#diagnostics(); } + + suppressSuccessDiagnostics(operation) { + if (!KNOWN_OPERATIONS.has(operation) && !this.#functions.has(operation)) throw new PluginSdkError('sdk.logging.invalidOperation', 'Suppress a registered operation.'); + this.#quietSuccess.add(operation); + return this; + } + + #diagnostics(operationId) { + return diagnostics((params) => { + if (!this.#logging) return; + if (!this.#loggingReady) { if (this.#earlyDiagnostics.length < 8) this.#earlyDiagnostics.push(params); return; } + this.#write({ jsonrpc: '2.0', method: 'lithe/log', params }); + }, (id) => this.#functions.has(id), operationId); + } + + /** + * Serves the host until it sends `exit` or closes the input stream, and + * resolves with the process exit code. With the default `exit`, the process + * then exits with that code. + * + * @param {object} [io] + * @param {NodeJS.ReadableStream} [io.input] Defaults to stdin. + * @param {NodeJS.WritableStream} [io.output] Defaults to stdout. + * @param {NodeJS.WritableStream} [io.errorOutput] Defaults to stderr. + * @param {((code: number) => void) | null} [io.exit] Defaults to `process.exit`; null only resolves. + */ + listen({ input = process.stdin, output = process.stdout, errorOutput = process.stderr, exit = (code) => process.exit(code) } = {}) { + if (this.#phase !== 'idle') { + throw new PluginSdkError('sdk.listen.repeated', 'A backend listens once.'); + } + this.#phase = 'listening'; + this.#output = output; + this.#errorOutput = errorOutput; + if (output === process.stdout) redirectConsole(errorOutput); + + return new Promise((resolve) => { + this.#finish = (code) => { + if (this.#phase === 'exited') return; + this.#phase = 'exited'; + this.#abortAll(); + killAllProcesses(); + // Before the pending requests fail, so a freed slot starts nothing. + this.#channel.close(); + for (const pending of this.#outgoing.values()) { + pending.reject(new PluginSdkError('sdk.session.closed', 'The session ended.')); + } + this.#outgoing.clear(); + input.off?.('data', onData); + resolve(code); + exit?.(code); + }; + const decoder = new FrameDecoder(); + const onData = (chunk) => { + let messages; + try { + messages = decoder.push(chunk); + } catch (error) { + if (!(error instanceof FramingError)) throw error; + this.#diagnostics().terminal('notification', {code:'framing_error'}, 0); + this.#finish(1); + return; + } + for (const message of messages) this.#dispatch(message); + }; + input.on('data', onData); + input.once('end', () => this.#finish(this.#phase === 'shuttingDown' ? 0 : 1)); + input.once('error', () => this.#finish(1)); + }); + } + + #dispatch(message) { + if (message === MALFORMED) { + this.#respond(null, undefined, { code: ERROR_CODES.parseError, message: 'The message is not valid JSON.' }); + return; + } + // Valid JSON that is not a message object, such as `null` or a batch. + if (typeof message !== 'object' || message === null || Array.isArray(message)) { + this.#respond(null, undefined, { code: ERROR_CODES.invalidRequest, message: 'The message is not a JSON-RPC object.' }); + return; + } + const hasID = message.id !== undefined && message.id !== null; + if (typeof message.method !== 'string') { + if (hasID) this.#settleOutgoing(message); + return; + } + if (hasID) { + this.#handleRequest(message); + } else { + this.#handleNotification(message); + } + } + + #handleRequest({ id, method, params }) { + if (method === METHODS.initialize) { + void this.#initialize(id, params ?? {}); + return; + } + if (method === METHODS.shutdown) { + void this.#shutdown(id); + return; + } + if (this.#phase === 'listening' || this.#phase === 'initializing') { + this.#respond(id, undefined, { code: ERROR_CODES.serverNotInitialized, message: 'The backend is not initialized.' }); + return; + } + if (this.#phase !== 'ready') { + this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The backend is shutting down.' }); + return; + } + if (method === METHODS.functionInvoke) { + this.#invokeFunction(id, params ?? {}); + return; + } + if (method === 'lithe/keymap/import') { + this.#invokeKeymap(id, params); + return; + } + const build = this.#negotiated.build ? this.#buildHandlers.get(method) : undefined; + if (build) { + this.#invokeBuild(id, method, params ?? {}, build); + 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), undefined, method, params?.operationID); + return; + } + const handler = this.#requestHandlers.get(method); + if (handler) { + this.#run(id, null, (context) => handler(params, context), (result) => result ?? null); + return; + } + this.#respond(id, undefined, { code: ERROR_CODES.methodNotFound, message: `Unsupported method: ${method}` }); + } + + #handleNotification({ method, params }) { + if (method === METHODS.exit) { + this.#finish(this.#phase === 'shuttingDown' ? 0 : 1); + return; + } + if (method === METHODS.cancelRequest) { + this.#incoming.get(requestKey(params?.id))?.controller.abort(); + return; + } + if (this.#phase !== 'ready' || method === METHODS.initialized) return; + if (typeof method === 'string' && method.startsWith('lithe/host/')) { + this.#hostNotification(method, params); + return; + } + const handler = this.#notificationHandlers.get(method); + if (!handler) return; + Promise.resolve() + .then(() => handler(params)) + .catch((error) => this.#diagnostics().terminal('notification', { code: failureKind(error) }, 0)); + } + + /** + * Routes a host notification. Core checked its shape and the session's + * grant; one this SDK does not know is ignored, as the contract asks. + */ + #hostNotification(method, params) { + if (!this.#negotiated.functions || !isObject(params)) return; + if (method === HOST_NOTIFICATIONS.subscriptionNotify) { + this.#channel.deliver(params); + return; + } + let handler; + let event = params; + if (method === HOST_NOTIFICATIONS.commandDidRun) { + handler = this.#commandHandlers.get(params.commandID); + event = { commandID: params.commandID, workspaceContext: isContextReference(params.context) ? params.context : null }; + if (!handler) { + this.#diagnostics().degraded('notification', 'command_unhandled'); + return; + } + } else if (method === HOST_NOTIFICATIONS.didChangeContexts) { + handler = this.#contextsHandler; + } else { + handler = this.#hostNotificationHandlers.get(method); + } + if (!handler) return; + Promise.resolve() + .then(() => handler(event)) + .catch((error) => this.#diagnostics().terminal('notification', { code: failureKind(error) }, 0)); + } + + async #initialize(id, params) { + if (this.#phase !== 'listening') { + this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'initialize was already received.' }); + return; + } + this.#phase = 'initializing'; + this.#initializeParams = params; + const offered = params.capabilities?.experimental ?? {}; + this.#loggingReady = false; + this.#earlyDiagnostics = []; + const offers = (entry, version) => Array.isArray(entry?.versions) && entry.versions.includes(version); + this.#logging = offers(offered.litheLogging, LOGGING_VERSION); + if (!this.#logging) { + this.#phase = 'listening'; + this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: `The backend requires litheLogging version ${LOGGING_VERSION}.` }); + return; + } + this.#buildVersion = this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION) ? BUILD_VERSION : null; + this.#functionsVersion = offers(offered.litheFunctions, FUNCTIONS_VERSION) ? FUNCTIONS_VERSION : null; + this.#negotiated = Object.freeze({ + functions: this.#functionsVersion !== null, + build: this.#buildVersion !== null, + vcs: this.#vcs !== null && offers(offered.litheVcs, VCS_VERSION), + }); + try { + for (const hook of this.#initializeHooks) { + await hook({ params, negotiated: this.#negotiated }); + } + } catch (error) { + if (this.#phase === 'initializing') this.#phase = 'listening'; + this.#respond(id, undefined, toResponseError(error)); + return; + } + // A shutdown or exit that arrived while a hook ran ended the session; + // finishing initialize must not reopen it. + if (this.#phase !== 'initializing') { + this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The session ended during initialize.' }); + return; + } + if (!this.#negotiated.functions && this.#functions.size > 0) { + this.#diagnostics().degraded('initialize', 'functions_unavailable'); + } + const experimental = { ...(this.#capabilities.experimental ?? {}), litheLogging: { version: LOGGING_VERSION } }; + if (offers(offered.litheKeymapImport, KEYMAP_IMPORT_VERSION) && 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: KEYMAP_IMPORT_VERSION, 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 }; + } else if (this.#vcs) { + this.#diagnostics().degraded('initialize', 'vcs_unavailable'); + } + if (this.#negotiated.functions) { + experimental.litheFunctions = { + version: this.#functionsVersion, + functions: this.functionIDs.map((functionID) => ({ id: functionID })), + }; + } + this.#phase = 'ready'; + this.#respond(id, { + capabilities: { ...this.#capabilities, experimental }, + serverInfo: { name: this.#name, ...(this.#version ? { version: this.#version } : {}) }, + }); + this.#loggingReady = true; + for (const params of this.#earlyDiagnostics.splice(0)) this.#write({ jsonrpc: '2.0', method: 'lithe/log', params }); + } + + async #shutdown(id) { + if (this.#phase !== 'ready' && this.#phase !== 'listening' && this.#phase !== 'initializing') { + this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'shutdown was already received.' }); + return; + } + this.#phase = 'shuttingDown'; + this.#abortAll(); + for (const hook of this.#shutdownHooks) { + try { + await hook(); + } catch (error) { + this.#diagnostics().terminal('shutdown', { code: failureKind(error) }, 0); + } + } + killAllProcesses(); + this.#respond(id, null); + } + + #invokeBuild(id, method, params, handler) { + const progressing = [METHODS.buildExecute, METHODS.buildTest, METHODS.buildPrepareRun, METHODS.buildPrepareDebug].includes(method); + if (!params || typeof params !== 'object' || Array.isArray(params) + || (progressing && typeof params.operationID !== 'string')) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'A build request needs object params and its wire operationID.' }); + return; + } + const operationID = params.operationID; + let reporting = true; + this.#run(id, null, (context) => handler(params, progressing ? { + ...context, + operationID, + progress: ({ message, percentage, output } = {}) => { + if (!reporting || context.signal.aborted) return; + const report = { operationID }; + if (message !== undefined) report.message = boundedText(message, LIMITS.maxBuildMessageBytes); + if (output !== undefined) report.output = boundedText(output, LIMITS.maxBuildOutputBytes); + if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); + this.#write({ jsonrpc: '2.0', method: METHODS.buildProgress, params: report }); + }, + } : context), (result) => buildResult(method, result), () => { reporting = false; }, method, operationID); + } + + #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, undefined, 'lithe/keymap/import'); + } + + #invokeFunction(id, { functionID, operationID, params, context: workspaceContext, caller }) { + const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; + if (!handler) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: `Function ${functionID} is not registered.` }); + return; + } + let reporting = true; + const progress = ({ message, percentage } = {}) => { + if (!reporting) return; + const report = { operationID }; + if (message !== undefined) report.message = boundedText(message); + if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); + this.#write({ jsonrpc: '2.0', method: METHODS.functionProgress, params: report }); + }; + // Calls this handler makes through the relay belong to this call. Core + // needs a string of 1-128 bytes; without one the calls carry no parent. + const parentOperationId = typeof operationID === 'string' && operationID.length > 0 + && Buffer.byteLength(operationID) <= LIMITS.maxParentOperationIdBytes ? operationID : undefined; + this.#run( + id, + functionID, + (context) => handler(params ?? {}, { + ...context, + functionID, + operationID, + // The page's window; null for a call without one. + workspaceContext: isContextReference(workspaceContext) ? workspaceContext : null, + // The calling plugin, only for a relayed call; null otherwise. + caller: callerReference(caller), + host: createHost(this.#channel, context.signal, parentOperationId), + progress, + }), + functionResult, + () => { + reporting = false; + }, + functionID, + operationID, + ); + } + + /** Runs one handler with its own cancellation and answers exactly once. */ + #run(id, functionID, invoke, shape, onSettled, operation = functionID ?? 'function-call', operationId) { + const started = performance.now(); + const diagnostic = this.#diagnostics(requestOperationId(operationId, id)); + let quietSuccess = false; + const controller = new AbortController(); + const key = requestKey(id); + this.#incoming.set(key, { controller, functionID }); + const { signal } = controller; + const context = { + signal, + diagnostics: diagnostic, + suppressSuccessDiagnostics: () => { quietSuccess = true; }, + throwIfCancelled: () => signal.throwIfAborted(), + run: (command, args, options = {}) => { + const running = runProcess(command, args, { + ...options, + signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal, + }); + // A tool the handler did not await is stopped when the call ends; its + // rejection then must not end the backend as an unhandled rejection. + running.catch(() => {}); + return running; + }, + host: createHost(this.#channel, signal), + }; + Promise.resolve() + .then(() => invoke(context)) + .then( + (value) => { + signal.throwIfAborted(); + return { result: shape(value) }; + }, + (error) => { + return { error: toResponseError(error, signal) }; + }, + ) + .catch((error) => { + // Shaping the result failed, for example a build result in another format. + return { error: toResponseError(error, signal) }; + }) + .then(({ result, error }) => { + if (!signal.aborted && (error || (!quietSuccess && !this.#quietSuccess.has(operation)))) diagnostic.terminal(operation, error ? { code: error.data?.code ?? 'internal_error' } : null, performance.now() - started); + onSettled?.(); + this.#incoming.delete(key); + // Work belongs to its call: tools still running when the handler + // settled, such as the siblings of a failed `Promise.all`, end now. + // Other calls have their own controllers and keep running. + controller.abort(new PluginSdkError('sdk.call.ended', 'The call ended.')); + this.#respond(id, result, error); + }); + } + + /** + * 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().'); + } + if (typeof handler !== 'function') throw new TypeError('A build handler must be a function.'); + this.#buildHandlers.set(method, handler); + return this; + } + + #declareHandler(handlers, method, handler) { + if (typeof method !== 'string' || isReservedMethod(method)) { + throw new PluginSdkError('sdk.method.reserved', `${method} is managed by the SDK and Lithe.`); + } + if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); + handlers.set(method, handler); + } + + #checkNewFunction(id, handler) { + if (!isFunctionId(id)) { + throw new PluginSdkError( + 'sdk.function.invalidID', + `Function ID ${JSON.stringify(id)} must be dot-separated lowerCamelCase segments of at most 128 bytes.`, + ); + } + if (typeof handler !== 'function') throw new TypeError('A function handler must be a function.'); + if (this.#functions.has(id)) { + throw new PluginSdkError('sdk.function.duplicate', `Function ${id} is already declared.`); + } + if (this.#functions.size >= LIMITS.maxFunctions) { + throw new PluginSdkError('sdk.function.limit', `A backend serves at most ${LIMITS.maxFunctions} functions.`); + } + } + + /** True until the `initialize` answer fixes the initial function list. */ + #beforeAnswer() { + return this.#phase === 'idle' || this.#phase === 'listening' || this.#phase === 'initializing'; + } + + #requireFunctions() { + if (this.#phase !== 'ready' || !this.#negotiated.functions) { + throw new PluginSdkError('sdk.functions.unavailable', 'The host has not negotiated plugin functions.'); + } + } + + #queueRegistration(change) { + const next = this.#registrations.then(change); + this.#registrations = next.catch(() => {}); + return next; + } + + #checkOutgoing(method) { + if (typeof method !== 'string' || isReservedMethod(method)) { + throw new PluginSdkError( + 'sdk.method.reserved', + `${method} is reserved. A backend never asks the host to run a domain operation or a function.`, + ); + } + if (this.#phase !== 'ready') { + throw new PluginSdkError('sdk.session.notReady', 'The session is not initialized.'); + } + } + + #request(method, params) { + return this.#send(method, params).response; + } + + /** Writes a request to the host; `response` settles with its answer. */ + #send(method, params) { + const id = `sdk-${this.#nextRequestId++}`; + const response = new Promise((resolve, reject) => { + this.#outgoing.set(id, { resolve, reject, method }); + this.#write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }); + }); + return { id, response }; + } + + #settleOutgoing({ id, result, error }) { + const pending = this.#outgoing.get(id); + if (!pending) return; + this.#outgoing.delete(id); + if (error) { + pending.reject(HostError.fromResponse(pending.method, error)); + } else { + pending.resolve(result ?? null); + } + } + + #abortAll() { + for (const call of this.#incoming.values()) call.controller.abort(); + } + + #respond(id, result, error) { + if (this.#phase === 'exited') return; + try { + this.#write(error ? { jsonrpc: '2.0', id, error } : { jsonrpc: '2.0', id, result: result ?? null }); + } catch (failure) { + // A result JSON cannot encode, such as a BigInt or a cycle, must not + // become an unhandled rejection that ends the whole session. + this.#diagnostics().terminal('result-encoding', { code: 'result_encoding_failed' }, 0); + this.#write({ jsonrpc: '2.0', id, error: toResponseError(failure) }); + } + } + + #write(message) { + if (!this.#output || this.#phase === 'exited') return; + this.#output.write(encodeFrame(message)); + } +} + +// Errors can include request bodies, credentials and paths. Only the category +// crosses the diagnostic boundary; the response keeps its existing error contract. +function failureKind(error) { + return error instanceof PluginError ? 'plugin_error' : 'internal_error'; +} + +/** Runs `change` now and reports its outcome as a promise. */ +function settle(change) { + try { + change(); + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } +} + +/** The JSON-RPC ID as a map key; numbers and strings never collide. */ +function requestKey(id) { + return `${typeof id}:${id}`; +} + +/** + * Stamps the canonical format on a build result, so a handler can return the + * plain document. A result in another format is a backend bug: Core would + * refuse it as `plugin.build.invalidOutput`, so it fails here with a clear log. + */ +function buildResult(method, result) { + const expected = BUILD_FORMATS[method]; + if (!isObject(result)) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a reply object.`); + } + let document = result; + if (expected) { + if (result.format !== undefined && result.format !== expected) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); + } + if (result.version !== undefined && result.version !== BUILD_DOCUMENT_VERSION) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not ${BUILD_DOCUMENT_VERSION}.`); + } + document = { ...result, format: expected, version: BUILD_DOCUMENT_VERSION }; + } + if ([METHODS.buildExecute, METHODS.buildTest].includes(method) + && (!Number.isInteger(result.exitCode) || result.exitCode < -2147483648 || result.exitCode > 2147483647 + || typeof result.output !== 'string' || Buffer.byteLength(result.output) > LIMITS.maxBuildResultBytes)) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs an int32 exitCode and bounded output.`); + } + if (method === METHODS.buildPrepareRun + && (!isObject(result.executable) || !['toolchain', 'command'].includes(result.executable.kind) + || typeof result.executable.value !== 'string' || typeof result.workingDirectory !== 'string' + || !Array.isArray(result.arguments) || !result.arguments.every((value) => typeof value === 'string') + || !isObject(result.environment) || !Object.values(result.environment).every((value) => typeof value === 'string'))) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs a portable launch plan.`); + } + if (method === METHODS.buildPrepareDebug + && (typeof result.program !== 'string' || typeof result.workingDirectory !== 'string' + || !Array.isArray(result.arguments) || !result.arguments.every((value) => typeof value === 'string') + || !isObject(result.environment) || !Object.values(result.environment).every((value) => typeof value === 'string') + || Object.keys(result).some((key) => !['program', 'arguments', 'workingDirectory', 'environment'].includes(key)))) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} needs native debug target facts, without an adapter or DAP arguments.`); + } + // Workspace containment and domain facts remain Core/provider responsibilities. + if (Buffer.byteLength(JSON.stringify(document)) > LIMITS.maxBuildResultBytes) { + throw new PluginSdkError('sdk.build.invalidResult', `${method} exceeds the build-result size limit.`); + } + return document; +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** `{ pluginID }` for the exact top-level `caller` Core writes into a relayed call, else null. */ +function callerReference(value) { + if (!isObject(value) || Object.keys(value).length !== 1) return null; + return typeof value.pluginID === 'string' && value.pluginID.length > 0 + ? Object.freeze({ pluginID: value.pluginID }) + : null; +} + +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. + */ +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 + * log line, instead of breaking the response frame or being refused by Core. + */ +function functionResult(result) { + let encoded; + try { + encoded = JSON.stringify(result ?? null); + } catch (error) { + throw new PluginSdkError('sdk.function.invalidResult', `The result is not JSON: ${error.message}`); + } + if (encoded === undefined) { + throw new PluginSdkError('sdk.function.invalidResult', 'The result is not a JSON value.'); + } + if (Buffer.byteLength(encoded) > LIMITS.maxResultBytes) { + throw new PluginSdkError('sdk.function.invalidResult', `The result exceeds ${LIMITS.maxResultBytes} bytes.`); + } + return result ?? null; +} + +/** + * Sends `console.log`, `info`, and `debug` to stderr. Stdout carries the + * protocol; a stray line there would corrupt the stream and end the session. + */ +function redirectConsole(errorOutput) { + const write = (...parts) => errorOutput.write(`${format(...parts)}\n`); + console.log = write; + console.info = write; + console.debug = write; +} diff --git a/shared/plugins/sdk/node/src/diagnostics.mjs b/shared/plugins/sdk/node/src/diagnostics.mjs new file mode 100644 index 00000000..8fcdc99a --- /dev/null +++ b/shared/plugins/sdk/node/src/diagnostics.mjs @@ -0,0 +1,34 @@ +// Typed plugin diagnostics use the host's catalog and writer. No payload text crosses this API. +import { PluginSdkError } from './errors.mjs'; + +export const KNOWN_OPERATIONS = new Set([ + 'initialize', 'shutdown', 'notification', 'function-call', 'result-encoding', + ...['model', 'dependencies', 'execute', 'test', 'discoverTests', 'prepareRun', 'prepareDebug'].map((name) => `lithe/build/${name}`), + ...['status', 'baseContent', 'history', 'fileHistory', 'annotate', 'checkin', 'rollback', 'update', 'fileOperation', 'resolve', 'cleanup'].map((name) => `lithe/vcs/${name}`), + 'lithe/keymap/import', +]); +const token = (value, maximum) => typeof value === 'string' && value.length > 0 && value.length <= maximum && /^[A-Za-z0-9._:/\-$]+$/.test(value); +export function requestOperationId(operationId, requestId) { + if (token(operationId, 128)) return operationId; + const fallback = typeof requestId === 'string' ? requestId : Number.isSafeInteger(requestId) ? String(requestId) : undefined; + return token(fallback, 128) ? fallback : undefined; +} +export function diagnostics(send, registered, operationId) { + const record = (kind, operation, code, count, durationMs) => { + if (!KNOWN_OPERATIONS.has(operation) && !registered(operation)) throw new PluginSdkError('sdk.logging.invalidOperation', 'Diagnostics need a registered operation.'); + if (code !== undefined && !token(code, 64)) throw new PluginSdkError('sdk.logging.invalidCode', 'Diagnostics need a controlled code.'); + if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) throw new PluginSdkError('sdk.logging.invalidCount', 'Diagnostics need a nonnegative safe count.'); + const params = { kind, operation: operation.length <= 64 ? operation : 'function-call' }; + if (code !== undefined) params.code = code; + if (count !== undefined) params.count = count; + if (durationMs !== undefined) params.durationMs = Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.floor(durationMs))); + if (token(operationId, 128)) params.operationId = operationId; + send(params); + }; + return Object.freeze({ + degraded: (operation, code, count) => record('degraded', operation, code, count), + recovered: (operation, code, count) => record('recovered', operation, code, count), + terminal: (operation, error, durationMs = 0) => record(error ? 'operationFailed' : 'operationFinished', operation, + error ? (token(error.code, 64) ? error.code : 'internal_error') : undefined, undefined, durationMs), + }); +} diff --git a/shared/plugins/sdk/node/src/errors.mjs b/shared/plugins/sdk/node/src/errors.mjs new file mode 100644 index 00000000..31166486 --- /dev/null +++ b/shared/plugins/sdk/node/src/errors.mjs @@ -0,0 +1,101 @@ +import { boundedText, ERROR_CODES, HOST_ERRORS, isPluginErrorCode } from './protocol.mjs'; + +/** + * A failure a handler reports on purpose. Core ends the call with the stable + * `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so + * the plugin's own UI can tell its failures apart. Lithe never branches on it. + */ +export class PluginError extends Error { + /** + * @param {string} code Identifier of at most 64 bytes, such as `maven.snapshotLocked`. + * @param {string} message User-facing text; Core keeps the first 1024 bytes. + * @param {object} [options] `{ cause }`, retained in memory and omitted from automatic diagnostics. + */ + constructor(code, message, options) { + super(message, options); + if (!isPluginErrorCode(code)) { + throw new TypeError( + `Plugin error code ${JSON.stringify(code)} must start with a letter, use letters, digits, ".", "_" or "-", and be at most 64 bytes.`, + ); + } + this.name = 'PluginError'; + this.code = code; + } +} + +/** An SDK usage error, raised synchronously to the plugin author. */ +export class PluginSdkError extends Error { + constructor(code, message) { + super(message); + this.name = 'PluginSdkError'; + this.code = code; + } +} + +const HOST_CODES = new Map(Object.entries(HOST_ERRORS).map(([code, rpcCode]) => [rpcCode, code])); + +/** + * The host refused or failed a request this backend sent. `code` is the + * stable `error.data.code`, such as `plugin.host.contextExpired`; `rpcCode` + * the JSON-RPC number; `details` the object a host may add, such as + * `{ option }` or `{ retryAfterMilliseconds }`. + */ +export class HostError extends PluginSdkError { + constructor(code, message, { rpcCode, details, method } = {}) { + super(code, message); + this.name = 'HostError'; + this.rpcCode = rpcCode; + this.details = details; + this.method = method; + } + + /** + * The failure for a JSON-RPC error answer. A host method without a known + * code is a host failure: a number names one code, and an unknown number + * is `plugin.host.failed`. + */ + static fromResponse(method, error) { + const data = error?.data; + const isHost = method.startsWith('lithe/host/'); + const code = typeof data?.code === 'string' + ? data.code + : isHost + ? HOST_CODES.get(error?.code) ?? 'plugin.host.failed' + : 'sdk.host.error'; + const details = data?.details !== null && typeof data?.details === 'object' && !Array.isArray(data.details) + ? data.details + : undefined; + return new HostError(code, typeof error?.message === 'string' ? error.message : `${method} failed.`, { + rpcCode: error?.code, + details, + method, + }); + } +} + +/** True for the rejection an aborted `AbortSignal` produces. */ +export function isAbort(error, signal) { + return Boolean(signal?.aborted) && (error === signal.reason || error?.name === 'AbortError'); +} + +/** + * The JSON-RPC error object for a handler failure. Only `PluginError` carries + * a plugin code; any other exception becomes a generic internal error so that + * stack traces and machine paths never reach the host's UI. A `PluginError` + * message is shown to the user as written, so it must not contain them either. + */ +export function toResponseError(error, signal) { + if (isAbort(error, signal)) { + return { code: ERROR_CODES.requestCancelled, message: 'The request was cancelled.' }; + } + if (error instanceof PluginError) { + return { + code: ERROR_CODES.requestFailed, + message: boundedText(error.message), + data: { code: error.code }, + }; + } + // Unexpected exceptions often embed absolute paths (ENOENT) or internals; + // the caller logs only a fixed failure category to stderr. + return { code: ERROR_CODES.internalError, message: 'The plugin backend failed unexpectedly.' }; +} diff --git a/shared/plugins/sdk/node/src/framing.mjs b/shared/plugins/sdk/node/src/framing.mjs new file mode 100644 index 00000000..ccfe7742 --- /dev/null +++ b/shared/plugins/sdk/node/src/framing.mjs @@ -0,0 +1,74 @@ +// LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 +// JSON body. Lithe talks to every plugin backend this way, so the SDK owns +// the framing and plugin code never writes to stdout itself. + +const HEADER_END = Buffer.from('\r\n\r\n', 'ascii'); + +/** Largest header block accepted from the host. */ +const MAX_HEADER_BYTES = 8192; + +/** Largest body accepted from the host. Larger frames end the session. */ +export const MAX_FRAME_BYTES = 64 * 1024 * 1024; + +/** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ +export const MALFORMED = Object.freeze({ malformed: true }); + +/** A framing error the session cannot recover from. */ +export class FramingError extends Error {} + +/** Incrementally splits a byte stream into decoded JSON-RPC messages. */ +export class FrameDecoder { + #buffer = Buffer.alloc(0); + + /** + * Appends `chunk` and returns every complete message it finished. A body + * that is not JSON is returned as `MALFORMED` so the caller can + * report it without losing the framing position. + */ + push(chunk) { + this.#buffer = this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]); + const messages = []; + for (;;) { + const headerEnd = this.#buffer.indexOf(HEADER_END); + // The same bound whether the header arrived whole or in pieces. + if (headerEnd > MAX_HEADER_BYTES || (headerEnd < 0 && this.#buffer.length > MAX_HEADER_BYTES)) { + throw new FramingError('The message header is too long.'); + } + if (headerEnd < 0) return messages; + const length = contentLength(this.#buffer.subarray(0, headerEnd).toString('ascii')); + const bodyStart = headerEnd + HEADER_END.length; + if (this.#buffer.length < bodyStart + length) { + return messages; + } + const body = this.#buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); + this.#buffer = this.#buffer.subarray(bodyStart + length); + try { + messages.push(JSON.parse(body)); + } catch { + messages.push(MALFORMED); + } + } + } +} + +function contentLength(header) { + for (const line of header.split('\r\n')) { + const separator = line.indexOf(':'); + if (separator < 0) continue; + if (line.slice(0, separator).trim().toLowerCase() !== 'content-length') continue; + const value = line.slice(separator + 1).trim(); + if (!/^\d+$/.test(value)) break; + const length = Number(value); + if (length > MAX_FRAME_BYTES) { + throw new FramingError(`A message of ${length} bytes exceeds the ${MAX_FRAME_BYTES}-byte limit.`); + } + return length; + } + throw new FramingError('A message has no valid Content-Length header.'); +} + +/** Encodes one JSON-RPC message as a complete frame. */ +export function encodeFrame(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8'); + return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]); +} diff --git a/shared/plugins/sdk/node/src/host.mjs b/shared/plugins/sdk/node/src/host.mjs new file mode 100644 index 00000000..962644f4 --- /dev/null +++ b/shared/plugins/sdk/node/src/host.mjs @@ -0,0 +1,413 @@ +// Host services of the plugin-function protocol: the `lithe/host/*` +// requests a backend sends to Lithe, the subscriptions some of them create, +// and the `host` object handlers use. The backend owns the wire; this module +// owns what Core expects of a well-behaved client -- at most 16 unanswered +// host requests, cancellation through `$/cancelRequest`, and subscription +// events routed by ID. +// +// Contract: contract/v1/README.md, "Plugin functions" and "Host services". + +import { HostError, PluginSdkError } from './errors.mjs'; +import { + HOST_METHODS, + isContextReference, + isCredentialKey, + isFunctionId, + LIMITS, + METHODS, +} from './protocol.mjs'; + +/** Events kept for a subscription whose creating request is still unanswered. */ +const MAX_EARLY_EVENTS = 128; + +/** + * The session's host-request state. `transport` is the backend's wire: + * `{ negotiated(), ready(), send(method, params) -> { id, response }, notify(method, params), log(...) }`. + */ +export class HostChannel { + #transport; + #inFlight = 0; + /** Requests waiting for a free slot, in call order: `{ start, fail }`. */ + #queue = []; + /** Live subscriptions by ID: `{ onEvent, lastSequence }`. */ + #subscriptions = new Map(); + /** Subscribing requests not yet answered; early events wait only while there are some. */ + #subscribing = 0; + #early = []; + #capabilities = null; + #closed = false; + + constructor(transport) { + this.#transport = transport; + } + + /** Whether the host negotiated plugin functions, and with them host services, in this session. */ + get negotiated() { + return this.#transport.negotiated(); + } + + request(method, params, { signal } = {}) { + if (typeof method !== 'string' || !method.startsWith('lithe/host/')) { + return Promise.reject(new PluginSdkError('sdk.host.method', `${method} is not a host method; host requests are lithe/host/*.`)); + } + if (this.#closed || !this.#transport.ready()) { + return Promise.reject(new PluginSdkError('sdk.session.notReady', 'The session is not initialized.')); + } + if (!this.negotiated) { + // The answer Core would give, without the round trip. + return Promise.reject(new HostError( + 'plugin.host.unavailable', + 'The host did not negotiate plugin functions (litheFunctions), which offer host services.', + { rpcCode: -32601, method }, + )); + } + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + // `queued` → `sent` → `done`; a request settles for its caller once. + let state = 'queued'; + let id; + const done = () => { + state = 'done'; + signal?.removeEventListener('abort', onAbort); + }; + const entry = { + start: () => { + state = 'sent'; + this.#inFlight += 1; + const sent = this.#transport.send(method, params); + id = sent.id; + sent.response + .then( + (result) => { + if (state === 'done') return; + done(); + resolve(result); + }, + (error) => { + if (state === 'done') return; + done(); + reject(error); + }, + ) + .finally(() => { + // The slot frees when Core answers, also after a cancellation: + // Core answers a cancelled request at once. + this.#inFlight -= 1; + this.#drain(); + }); + }, + fail: (error) => { + if (state === 'done') return; + done(); + reject(error); + }, + }; + const onAbort = () => { + if (state === 'done') return; + if (state === 'queued') { + this.#queue.splice(this.#queue.indexOf(entry), 1); + } else { + this.#transport.notify(METHODS.cancelRequest, { id }); + } + // A cancelled mutation may already have taken effect; it is never + // sent again. + done(); + reject(signal.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + this.#queue.push(entry); + this.#drain(); + }); + } + + /** + * Sends a request whose result is `{ subscriptionID }` and routes that + * subscription's events to `onEvent(event, { subscriptionID, sequence, gap })`. + */ + async subscribe(method, params, onEvent, options) { + if (onEvent !== undefined && typeof onEvent !== 'function') { + throw new TypeError('A subscription event handler must be a function.'); + } + this.#subscribing += 1; + try { + const result = await this.request(method, params, options); + const subscriptionID = result?.subscriptionID; + if (!isCredentialKey(subscriptionID)) { + throw new PluginSdkError('sdk.host.invalidResult', `${method} answered without a subscriptionID.`); + } + const subscription = { onEvent: onEvent ?? (() => {}), lastSequence: 0, live: true }; + this.#subscriptions.set(subscriptionID, subscription); + // Events the host sent before this answer was read. + const early = this.#early.filter((event) => event.subscriptionID === subscriptionID); + this.#early = this.#early.filter((event) => event.subscriptionID !== subscriptionID); + for (const event of early) this.#emit(subscriptionID, subscription, event); + return Object.freeze({ + subscriptionID, + get disposed() { + return !subscription.live; + }, + dispose: () => this.dispose(subscriptionID), + }); + } finally { + this.#subscribing -= 1; + if (this.#subscribing === 0) this.#early = []; + } + } + + /** Stops routing a subscription's events at once and asks the host to dispose it. */ + async dispose(subscriptionID) { + const subscription = this.#subscriptions.get(subscriptionID); + if (subscription) subscription.live = false; + this.#subscriptions.delete(subscriptionID); + if (this.#closed || !this.#transport.ready()) return; + await this.request(HOST_METHODS.subscriptionsDispose, { subscriptionID }); + } + + /** Routes one `lithe/host/subscriptions/notify`. */ + deliver({ subscriptionID, sequence, event }) { + const subscription = this.#subscriptions.get(subscriptionID); + if (subscription) { + this.#emit(subscriptionID, subscription, { sequence, event }); + } else if (this.#subscribing > 0) { + if (this.#early.length < MAX_EARLY_EVENTS) { + this.#early.push({ subscriptionID, sequence, event }); + } else { + this.#transport.log('Dropped an early subscription event: queue_full'); + } + } + // Otherwise the subscription was disposed; nothing follows a disposal. + } + + /** The cached `getCapabilities` answer; Core answers it itself. */ + capabilities() { + this.#capabilities ??= this.request(HOST_METHODS.getCapabilities).catch((error) => { + this.#capabilities = null; + throw error; + }); + return this.#capabilities; + } + + /** Whether this session may call `method`, from the `getCapabilities` answer. */ + async supports(method) { + if (!this.negotiated) return false; + const { services } = await this.capabilities(); + return Object.values(services ?? {}).some((service) => Array.isArray(service?.methods) && service.methods.includes(method)); + } + + /** Ends the channel with the session: queued requests fail, subscriptions end. */ + close() { + this.#closed = true; + for (const entry of this.#queue.splice(0)) { + entry.fail(new PluginSdkError('sdk.session.closed', 'The session ended.')); + } + for (const subscription of this.#subscriptions.values()) subscription.live = false; + this.#subscriptions.clear(); + this.#early = []; + this.#capabilities = null; + } + + #emit(subscriptionID, subscription, { sequence, event }) { + const gap = sequence !== subscription.lastSequence + 1; + subscription.lastSequence = sequence; + Promise.resolve() + .then(() => subscription.onEvent(event, { subscriptionID, sequence, gap })) + .catch(() => this.#transport.log('A subscription listener failed: internal_error')); + } + + #drain() { + while (!this.#closed && this.#inFlight < LIMITS.maxPendingHostRequests && this.#queue.length > 0) { + this.#queue.shift().start(); + } + } +} + +/** + * The `host` object: typed host services over `channel`. A handler's + * `context.host` passes the call's signal, so its unanswered host requests + * are cancelled when the call ends. It also passes the `operationID` of the + * `lithe/function/invoke` being served as `parentOperationId`, which + * `functions.invoke` sends as the new call's parent unless the caller names + * another. A host object without them (`backend.host`) has no default parent. + */ +export function createHost(channel, callSignal, parentOperationId) { + const withSignal = (options = {}) => { + if (!callSignal) return options; + return { ...options, signal: options.signal ? AbortSignal.any([callSignal, options.signal]) : callSignal }; + }; + const request = (method, params, options) => channel.request(method, params, withSignal(options)); + const subscribe = (method, params, onEvent, options) => channel.subscribe(method, params, onEvent, withSignal(options)); + /** A method whose params name a workspace context. */ + const scoped = (method) => async (context, params = {}, options) => + request(method, { ...params, context: contextReference(context) }, options); + + return Object.freeze({ + get negotiated() { + return channel.negotiated; + }, + request: async (method, params, options) => request(method, params, options), + subscribe: async (method, params, onEvent, options) => subscribe(method, params, onEvent, options), + capabilities: () => channel.capabilities(), + supports: (method) => channel.supports(method), + credentials: Object.freeze({ + async get(key, options) { + checkCredentialKey(key); + const result = await request(HOST_METHODS.credentialsGet, { key }, options); + if (result === null) return null; + if (typeof result?.secret !== 'string') { + throw new PluginSdkError('sdk.host.invalidResult', 'The host answered credentials/get without a secret.'); + } + return result.secret; + }, + async set(key, secret, options) { + checkCredentialKey(key); + if (typeof secret !== 'string' || Buffer.byteLength(secret) > LIMITS.maxCredentialSecretBytes) { + throw new PluginSdkError('sdk.credentials.invalidSecret', 'A secret is a string of at most 16 KiB.'); + } + await request(HOST_METHODS.credentialsSet, { key, secret }, options); + }, + async delete(key, options) { + checkCredentialKey(key); + await request(HOST_METHODS.credentialsDelete, { key }, options); + }, + }), + subscriptions: Object.freeze({ + dispose: async (subscriptionID) => channel.dispose(subscriptionID), + }), + workspace: Object.freeze({ + listContexts: async (options) => request(HOST_METHODS.workspaceListContexts, {}, options), + getContext: async (context, options) => + request(HOST_METHODS.workspaceGetContext, { context: contextReference(context) }, options), + getFolders: async (context, options) => + request(HOST_METHODS.workspaceGetFolders, { context: contextReference(context) }, options), + readDirectory: scoped(HOST_METHODS.workspaceReadDirectory), + stat: scoped(HOST_METHODS.workspaceStat), + readFile: scoped(HOST_METHODS.workspaceReadFile), + watch: async (context, params, onEvent, options) => + subscribe(HOST_METHODS.workspaceWatch, { ...params, context: contextReference(context) }, onEvent, options), + findFiles: scoped(HOST_METHODS.workspaceFindFiles), + findText: scoped(HOST_METHODS.workspaceFindText), + }), + functions: Object.freeze({ + providers: async (functionID, options) => { + checkRelayFunctionId(functionID); + return request(HOST_METHODS.functionsProviders, { functionID }, options); + }, + invoke: async (context, call, options) => + request(HOST_METHODS.functionsInvoke, relayInvokeParams(context, call, parentOperationId), options), + }), + documents: Object.freeze({ + list: scoped(HOST_METHODS.documentsList), + getSnapshot: scoped(HOST_METHODS.documentsGetSnapshot), + }), + editor: Object.freeze({ + getActive: scoped(HOST_METHODS.editorGetActive), + getSelections: scoped(HOST_METHODS.editorGetSelections), + applyEdits: scoped(HOST_METHODS.editorApplyEdits), + replaceSelections: scoped(HOST_METHODS.editorReplaceSelections), + insertText: scoped(HOST_METHODS.editorInsertText), + open: scoped(HOST_METHODS.editorOpen), + reveal: scoped(HOST_METHODS.editorReveal), + registerActions: async (context, params, options) => + subscribe(HOST_METHODS.editorRegisterActions, { ...params, context: contextReference(context) }, undefined, options), + }), + preview: Object.freeze({ + openHTML: scoped(HOST_METHODS.previewOpenHTML), + }), + browser: Object.freeze({ + openExternal: scoped(HOST_METHODS.browserOpenExternal), + }), + terminal: Object.freeze({ + create: scoped(HOST_METHODS.terminalCreate), + show: scoped(HOST_METHODS.terminalShow), + close: scoped(HOST_METHODS.terminalClose), + reopen: scoped(HOST_METHODS.terminalReopen), + dispose: scoped(HOST_METHODS.terminalDispose), + onExit: async (context, terminalID, onEvent, options) => + subscribe(HOST_METHODS.terminalOnExit, { context: contextReference(context), terminalID }, onEvent, options), + }), + window: Object.freeze({ + showInformation: scoped(HOST_METHODS.windowShowInformation), + showWarning: scoped(HOST_METHODS.windowShowWarning), + showError: scoped(HOST_METHODS.windowShowError), + }), + }); +} + +/** + * `{ contextID, generation }` of a context reference or of a full context + * from `listContexts` or `getContext`, so either can be passed on. + */ +export function contextReference(value) { + const reference = { contextID: value?.contextID, generation: value?.generation }; + if (!isContextReference(reference)) { + throw new PluginSdkError( + 'sdk.host.invalidContext', + 'A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, ".", "_", ":" or "-", and a positive generation.', + ); + } + return reference; +} + +function checkCredentialKey(key) { + if (!isCredentialKey(key)) { + throw new PluginSdkError( + 'sdk.credentials.invalidKey', + 'A credential key is 1-128 ASCII letters, digits, ".", "_", ":" or "-".', + ); + } +} + +function invalidRelayCall(message) { + return new PluginSdkError('sdk.functions.invalidCall', message); +} + +function checkRelayFunctionId(functionID) { + if (!isFunctionId(functionID)) { + throw invalidRelayCall('A function ID is dot-separated lowerCamelCase segments of at most 128 bytes, such as "project.describe".'); + } +} + +function isParentOperationId(value) { + return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= LIMITS.maxParentOperationIdBytes; +} + +/** + * The params of one `lithe/host/functions/invoke`, built from the whitelisted + * fields of `call` only, so a caller cannot add a field of its own (Core + * refuses `caller` and writes it itself). Checks what Core checks first; a + * mistake throws before any frame is written. `params` stays absent unless + * given. The parent is the call's own `parentOperationID`, else the operation + * being served (`defaultParent`), else absent. + */ +function relayInvokeParams(context, call, defaultParent) { + if (call === null || typeof call !== 'object' || Array.isArray(call)) { + throw invalidRelayCall('A relayed call is { pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }.'); + } + const { pluginID, functionID, params, timeoutMilliseconds, parentOperationID } = call; + if (typeof pluginID !== 'string' || pluginID.length === 0) { + throw invalidRelayCall('pluginID is the ID of the plugin that provides the function, a non-empty string.'); + } + checkRelayFunctionId(functionID); + const wire = { pluginID, functionID, context: contextReference(context) }; + if (params !== undefined) { + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + throw invalidRelayCall('params are a JSON object.'); + } + wire.params = params; + } + if (timeoutMilliseconds !== undefined) { + if (!Number.isSafeInteger(timeoutMilliseconds) || timeoutMilliseconds < 1) { + throw invalidRelayCall('timeoutMilliseconds is a positive integer.'); + } + wire.timeoutMilliseconds = timeoutMilliseconds; + } + if (parentOperationID !== undefined) { + if (!isParentOperationId(parentOperationID)) { + throw invalidRelayCall('parentOperationID is a string of 1-128 bytes.'); + } + wire.parentOperationID = parentOperationID; + } else if (defaultParent !== undefined) { + wire.parentOperationID = defaultParent; + } + return wire; +} diff --git a/shared/plugins/sdk/node/src/index.d.ts b/shared/plugins/sdk/node/src/index.d.ts new file mode 100644 index 00000000..003443d5 --- /dev/null +++ b/shared/plugins/sdk/node/src/index.d.ts @@ -0,0 +1,713 @@ +// Type declarations for the Lithe plugin backend SDK. Keep in sync with the +// `.mjs` sources; `sdk/node/test/support.test.mjs` checks the exported names. + +/** Any value JSON can carry. */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +export type JsonObject = { [key: string]: JsonValue }; + +export interface RunProcessOptions { + cwd?: string; + /** Replaces the environment when given; Lithe's process markers are carried over regardless. */ + env?: Record; + /** Aborting it ends the whole process tree and rejects with its reason. */ + signal?: AbortSignal; + /** Captured bytes per stream; default 1 MiB. */ + maxOutputBytes?: number; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + /** Delay between the polite and forced stop; default 2000. */ + killGraceMilliseconds?: number; +} + +export interface ProcessResult { + exitCode: number | null; + signal: string | null; + stdout: string; + stderr: string; + truncated: boolean; +} + +/** Typed facts for the host-owned litheLogging 1 event catalog. */ +export interface Diagnostics { + degraded(operation: string, code: string, count?: number): void; + recovered(operation: string, code: string, count?: number): void; +} + +/** Shared by every handler. */ +export interface RequestContext { + /** Aborted when the host cancels the call, the call times out, the session ends, or the call has settled. */ + signal: AbortSignal; + throwIfCancelled(): void; + /** `runProcess` bound to this call: its tree ends when the call is cancelled or settles. */ + run(command: string, args?: string[], options?: RunProcessOptions): Promise; + /** Fixed facts, correlated with the host operation; never accepts payload text. */ + readonly diagnostics: Diagnostics; + /** Omits only this call's successful fact; failures remain visible. */ + suppressSuccessDiagnostics(): void; + /** `backend.host` bound to this call: its unanswered host requests are cancelled when the call ends. */ + host: Host; +} + +/** `{ contextID, generation }`: one generation of one workspace context. */ +export interface ContextReference { + contextID: string; + generation: number; +} + +export interface WorkspaceFolder { + /** Stable per plugin and location; reveals nothing about the path. */ + rootID: string; + name: string; + /** Present only for a local authority. */ + path?: string; +} + +/** One open workspace in one window. */ +export interface WorkspaceContext extends ContextReference { + name: string; + authority: { kind: 'local' | 'remote'; id?: string }; + folders: WorkspaceFolder[]; + /** Private to the plugin and the workspace; only `getContext` returns, and creates, it. */ + stateDirectory?: string; + [key: string]: unknown; +} + +export interface HostCapabilities { + protocolVersion: number; + backendAuthority: 'local'; + /** Each service this session may use, with the requests it may send and the notifications it may receive. */ + services: Record; + limits: { + pendingHostRequests: number; + functionResultBytes: number; + hostRequestBytes: number; + hostResultBytes: number; + errorMessageBytes: number; + errorDetailsBytes: number; + notificationBytes: number; + [key: string]: number; + }; + [key: string]: unknown; +} + +export interface HostRequestOptions { + /** Aborting it rejects with its reason, and the host is told to stop. Never retried. */ + signal?: AbortSignal; +} + +export interface SubscriptionEventInfo { + subscriptionID: string; + sequence: number; + /** True when `sequence` does not follow the previous event: events were missed. */ + gap: boolean; +} + +export type SubscriptionHandler = (event: JsonObject, info: SubscriptionEventInfo) => unknown; + +export interface Subscription { + readonly subscriptionID: string; + readonly disposed: boolean; + /** Stops routing events at once and asks the host to dispose the subscription. */ + dispose(): Promise; +} + +/** A file or folder under a root of `getFolders`; `path` uses `/` and is relative to the root. */ +export interface WorkspaceResource { + rootID: string; + path: string; +} + +/** One-based UTF-16 position. */ +export interface TextPosition { + line: number; + column: number; +} + +/** Half-open range. */ +export interface TextRange { + start: TextPosition; + end: TextPosition; +} + +export interface TextSelection { + anchor: TextPosition; + active: TextPosition; +} + +export interface DocumentSummary { + documentID: string; + version: number; + /** Workspace-relative, or null outside the root. */ + path: string | null; + languageID: string | null; + isReadOnly: boolean; + isDirty: boolean; + encoding: string; + lineEnding: string; + [key: string]: unknown; +} + +export interface EditResult { + documentID: string; + version: number; + applied: boolean; + [key: string]: unknown; +} + +export interface OpenParams { + resource: WorkspaceResource; + activation?: 'select' | 'background'; + range?: TextRange; + tab?: 'regular'; +} + +export interface FindPage { + matches: T[]; + nextCursor: string | null; + isTruncated: boolean; + source: 'disk'; + [key: string]: unknown; +} + +type Scoped = (context: ContextReference, params: P, options?: HostRequestOptions) => Promise; +type ScopedOptional = (context: ContextReference, params?: P, options?: HostRequestOptions) => Promise; + +/** One enabled plugin that exports a function, from `host.functions.providers`. */ +export interface FunctionProvider { + pluginID: string; + /** The contract version of the exported function. */ + version: number; +} + +export interface FunctionProvidersResult { + /** Enabled plugins other than the caller, sorted by plugin ID. */ + providers: FunctionProvider[]; +} + +/** One cross-plugin call, the argument of `host.functions.invoke`. */ +export interface FunctionCall { + /** The providing plugin, one of `providers(functionID)`. */ + pluginID: string; + functionID: string; + /** A JSON object; not sent when omitted, which Core reads as `{}`. */ + params?: JsonObject; + /** A positive integer. Core defaults it to 60000 and caps it at 600000. */ + timeoutMilliseconds?: number; + /** + * The `operationID` of the call this one belongs to, a string of 1-128 bytes. + * Inside a function handler `context.host` fills it with the call being + * served; `backend.host` has no default. + */ + parentOperationID?: string; +} + +export interface FunctionInvokeResult { + /** The provider's result; `null` is a result. */ + value: JsonValue; +} + +/** + * Host services of the plugin-function protocol. Every method rejects with + * `HostError` when the host refuses; check `supports(method)` instead of + * assuming a method exists. A full `WorkspaceContext` may be passed wherever + * a reference is expected. + */ +export interface Host { + /** Whether the host negotiated plugin functions, and with them host services, in this session. */ + readonly negotiated: boolean; + /** Sends any `lithe/host/*` request, including ones a later host adds. */ + request(method: string, params?: JsonObject, options?: HostRequestOptions): Promise; + /** Sends a request answering `{ subscriptionID }` and routes that subscription's events. */ + subscribe(method: string, params: JsonObject, onEvent?: SubscriptionHandler, options?: HostRequestOptions): Promise; + /** The session's capabilities, cached. */ + capabilities(): Promise; + /** Whether this session may call `method`, from the session's capabilities. */ + supports(method: string): Promise; + /** Permission `credentials.store`. Kept in the platform's secret store, per plugin. */ + readonly credentials: { + /** The stored secret, or null. */ + get(key: string, options?: HostRequestOptions): Promise; + set(key: string, secret: string, options?: HostRequestOptions): Promise; + delete(key: string, options?: HostRequestOptions): Promise; + }; + readonly subscriptions: { + dispose(subscriptionID: string): Promise; + }; + readonly workspace: { + /** Permission `workspace.metadata`. Entries carry no `stateDirectory`. */ + listContexts(options?: HostRequestOptions): Promise<{ revision: number; contexts: WorkspaceContext[] }>; + getContext(context: ContextReference, options?: HostRequestOptions): Promise; + getFolders(context: ContextReference, options?: HostRequestOptions): Promise<{ folders: WorkspaceFolder[] }>; + /** Permission `workspace.files.read`. */ + readDirectory: Scoped<{ resource: WorkspaceResource; cursor?: string; limit?: number }, { + entries: { name: string; resource: WorkspaceResource; kind: string }[]; + nextCursor: string | null; + }>; + stat: Scoped<{ resource: WorkspaceResource }, { resource: WorkspaceResource; kind: string; size: number; modifiedMilliseconds: number }>; + readFile: Scoped<{ resource: WorkspaceResource; maximumBytes?: number }, { + resource: WorkspaceResource; + source: 'disk'; + byteCount: number; + isTruncated: boolean; + identity: JsonValue; + text?: string; + encoding?: string; + base64?: string; + }>; + /** Permission `workspace.files.watch`. Events carry `{ rootID, changedPaths, requiresRescan }`. */ + watch( + context: ContextReference, + params: { resource: WorkspaceResource; recursive?: boolean }, + onEvent: SubscriptionHandler, + options?: HostRequestOptions, + ): Promise; + /** Permission `workspace.search`; `resource` names the root (`path: ""`). */ + findFiles: Scoped<{ resource: WorkspaceResource; query: string; fileMask?: string; cursor?: string; limit?: number }, FindPage>; + findText: Scoped<{ + resource: WorkspaceResource; + pattern: string; + fileMask?: string; + caseSensitive?: boolean; + wholeWords?: boolean; + regularExpression?: boolean; + cursor?: string; + limit?: number; + }, FindPage<{ resource: WorkspaceResource; line: number; preview: string; range: TextRange | null }>>; + }; + /** Permission `documents.read`. */ + readonly documents: { + list: ScopedOptional; + getSnapshot: Scoped<{ documentID: string }, { document: DocumentSummary; text: string }>; + }; + readonly editor: { + getActive: ScopedOptional; + getSelections: Scoped<{ documentID: string }, { documentID: string; version: number; selections: TextSelection[]; primaryIndex: number }>; + /** Permission `editor.edit`. Not replayed on cancellation or timeout: the edit may have committed. */ + applyEdits: Scoped<{ documentID: string; expectedVersion: number; edits: { range: TextRange; text: string }[] }, EditResult>; + replaceSelections: Scoped<{ documentID: string; expectedVersion: number; selections: TextSelection[]; replacements: string[] }, EditResult>; + insertText: Scoped<{ documentID: string; expectedVersion: number; at: TextPosition; text: string }, EditResult>; + /** Permission `editor.open`. */ + open: Scoped; + reveal: Scoped; + /** Permission `editor.actions`; the host later calls `function` to collect actions. */ + registerActions( + context: ContextReference, + params: { id: string; function: string; selector: { fileExtensions?: string[]; fileNames?: string[] } }, + options?: HostRequestOptions, + ): Promise; + }; + /** Permission `preview.open`. */ + readonly preview: { + openHTML: Scoped<{ resource: WorkspaceResource; presentation?: 'editorAndPreview' | 'previewOnly'; range?: TextRange }, JsonObject>; + }; + /** Permission `browser.open`; exactly one of `url` or `resource`. */ + readonly browser: { + openExternal: Scoped< + { url: string } | { resource: WorkspaceResource; unsaved?: 'openSavedFile' | 'requireSaved' }, + { opened: true; source: 'web' | 'disk'; hasUnsavedChanges?: boolean } + >; + }; + /** + * The manifest's `pluginFunctions.calls`. Calls between plugins, relayed + * through Core. Argument mistakes reject with `PluginSdkError` + * `sdk.functions.invalidCall`, or `sdk.host.invalidContext` for the + * context, before anything is sent. + */ + readonly functions: { + /** The enabled plugins other than this one that export `functionID`. */ + providers(functionID: string, options?: HostRequestOptions): Promise; + /** Calls one provider's function in a workspace context. A provider's own failure is `plugin.function.failed`. */ + invoke(context: ContextReference, call: FunctionCall, options?: HostRequestOptions): Promise; + }; + /** Permission `terminal.create`; only terminals owned by this backend session and context. */ + readonly terminal: { + create: Scoped<{ executable: string; arguments?: string[]; title?: string; cwd?: WorkspaceResource; + environment?: Record; show?: boolean; retainedContextBytes?: number }, { terminalID: string; generation: number }>; + show: Scoped<{ terminalID: string }, null>; + close: Scoped<{ terminalID: string; retainedContextBytes?: number }, { retainedContextBytes: number }>; + reopen: Scoped<{ terminalID: string; show?: boolean }, { terminalID: string; generation: number; restoredContextBytes: number }>; + dispose: Scoped<{ terminalID: string }, null>; + onExit(context: ContextReference, terminalID: string, + onEvent: (event: { type: 'exit'; terminalID: string; exitCode: number | null; reason: 'exited' | 'closed'; generation: number }, metadata: SubscriptionEventInfo) => void, + options?: HostRequestOptions): Promise; + }; + /** Permission `window.messages`. `selectedIndex` is zero-based, or null on dismissal. */ + readonly window: { + showInformation: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; + showWarning: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; + showError: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; + }; +} + +export interface CommandEvent { + commandID: string; + /** The context of the window the command ran in, or null. */ + workspaceContext: ContextReference | null; +} + +export interface ProgressReport { + /** Cut to 1024 bytes. */ + message?: string; + /** Rounded and clamped to 0-100. */ + percentage?: number; +} + +export interface FunctionContext extends RequestContext { + functionID: string; + /** Opaque per-call ID chosen by Lithe. */ + operationID: string; + /** The context of the page's window, or null. */ + workspaceContext: ContextReference | null; + /** + * The plugin that called this function through the relay, or null: the + * host started the call. + * Decide by this field, never by a plugin ID inside `params`. + */ + readonly caller: { readonly pluginID: string } | null; + /** Reports progress; ignored once the call has ended. */ + progress(report: ProgressReport): void; +} + +export type FunctionHandler = (params: JsonObject, context: FunctionContext) => unknown | Promise; +export type BuildHandler = (params: BuildRequestParams, context: RequestContext) => JsonObject | Promise; + +/** 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. */ + modelInputs?: BuildModelInputs; + buildSystemID: string; + root: string; + options: JsonObject; + optionArguments: string[]; + environment: Record; + /** Dependencies only: the selected project, or null for the workspace. */ + project?: { name: string; path: string } | null; + /** True when the user or a sync-file change asked to reload; skip any snapshot. */ + refresh?: boolean; + /** The build tool the host resolved; absent when it could not, so look it up yourself. */ + tool?: { executable: string }; + [key: string]: unknown; +} + +/** Public normalized facts. Tasks, run plans, settings and optionValues never cross this boundary. */ +export interface BuildSharedModel { + projects: { id: string; name: string; path: string; displayName?: string; parentID?: string; properties: Record }[]; + targets: { id: string; projectID: string; name: string; path: string; tags: string[]; properties: Record }[]; + contentRoots: { projectID: string; path: string; kind: string }[]; +} +export type BuildModelInputInstance = + | { root: string; state: 'ready'; model: BuildSharedModel; configurationFingerprint: string; options: JsonObject } + | { root: string; state: 'loading' | 'stale' | 'failed' }; +export interface BuildModelInputs { + version: 0; + /** Opaque workspace-activation revision; discard cached work after it changes. */ + revision: number; + inputs: { pluginID: string; buildSystemID: string; state: 'available' | 'unavailable'; instances: BuildModelInputInstance[] }[]; +} + +/** Current upstream model selection, preserved as data rather than shell text. */ +export interface BuildSelection { + name: string; + path: string; + tags?: string[]; + properties?: JsonObject; +} + +export interface BuildExecutionParams extends BuildRequestParams { + task: string; + project?: BuildSelection | null; + target?: BuildSelection | null; + userArguments: string[]; + operationID: string; +} + +export interface BuildTestParams extends BuildRequestParams { + project?: BuildSelection | null; + target?: BuildSelection | null; + suite?: string; + case?: string; + userArguments: string[]; + operationID: string; +} + +export interface BuildRunParams extends BuildRequestParams { + run: string; + project: BuildSelection; + target: BuildSelection; + userArguments: string[]; + operationID: string; +} + +export interface BuildProgressReport extends ProgressReport { + /** Cut to 64 KiB of UTF-8 per notification. Build messages are cut to 4096 bytes. */ + output?: string; +} + +export interface BuildContext extends RequestContext { + /** The Core-assigned wire identity, never a plugin-selected value. */ + operationID: string; + /** Dropped after cancellation or completion. */ + progress(report: BuildProgressReport): void; +} + +export interface BuildExecutionResult { exitCode: number; output: string; } +export interface BuildTestDiscovery { + format?: 'litheTestDiscovery'; + version?: 0; + tests: { suite: string; case: string; project?: BuildSelection; target?: BuildSelection }[]; +} +export interface BuildRunPreparation { + executable: { kind: 'toolchain' | 'command'; value: string }; + arguments: string[]; + workingDirectory: string; + environment: Record; +} +export type BuildExecutionHandler = (params: BuildExecutionParams, context: BuildContext) => BuildExecutionResult | Promise; +export type BuildTestHandler = (params: BuildTestParams, context: BuildContext) => BuildExecutionResult | Promise; +export type BuildDiscoveryHandler = (params: BuildRequestParams, context: RequestContext) => BuildTestDiscovery | Promise; +export interface BuildDebugPreparation { + program: string; + arguments: string[]; + workingDirectory: string; + environment: Record; +} +export type BuildDebugHandler = (params: BuildRunParams, context: BuildContext) => BuildDebugPreparation | Promise; +export type BuildRunHandler = (params: BuildRunParams, context: BuildContext) => BuildRunPreparation | Promise; + +/** 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; the SDK negotiates its experimental protocols. */ + capabilities?: JsonObject; +} + +export interface ListenOptions { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + errorOutput?: NodeJS.WritableStream; + /** Defaults to `process.exit`; null only resolves the returned promise. */ + exit?: ((code: number) => void) | null; +} + +export interface Backend { + readonly negotiated: Negotiated; + readonly initializeParams: JsonObject | null; + /** The build protocol version in use (0), or null before/without negotiation. */ + readonly buildVersion: number | null; + /** The plugin-function protocol version in use (0), or null before/without negotiation. */ + readonly functionsVersion: number | null; + readonly functionIDs: string[]; + /** Host services; usable once the session is ready. */ + readonly host: Host; + readonly build: { + model(handler: BuildHandler): Backend; + dependencies(handler: BuildHandler): Backend; + execute(handler: BuildExecutionHandler): Backend; + test(handler: BuildTestHandler): Backend; + discoverTests(handler: BuildDiscoveryHandler): Backend; + prepareRun(handler: BuildRunHandler): Backend; + prepareDebug(handler: BuildDebugHandler): 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; + onNotification(method: string, handler: (params: unknown) => unknown): Backend; + /** Handles `lithe/host/commands/didRun` for one of the plugin's commands. */ + onCommand(commandID: string, handler: (event: CommandEvent) => unknown): Backend; + /** Handles `lithe/host/workspace/didChangeContexts`. */ + onContextsChanged(handler: (event: { revision: number }) => unknown): Backend; + /** Handles a `lithe/host/*` notification a later host adds. */ + onHostNotification(method: string, handler: (params: JsonObject) => unknown): Backend; + onInitialize(hook: (event: { params: JsonObject; negotiated: Negotiated }) => unknown): Backend; + onShutdown(hook: () => unknown): Backend; + sendRequest(method: string, params?: unknown): Promise; + sendNotification(method: string, params?: unknown): void; + readonly diagnostics: Diagnostics; + suppressSuccessDiagnostics(operation: string): this; + listen(options?: ListenOptions): Promise; +} + +export function createBackend(options?: BackendOptions): Backend; + +export class PluginError extends Error { + constructor(code: string, message: string, options?: { cause?: unknown }); + readonly code: string; +} + +export class PluginSdkError extends Error { + readonly code: string; +} + +/** The host refused or failed a request this backend sent. */ +export class HostError extends PluginSdkError { + /** `error.data.code`, such as `plugin.host.contextExpired`. */ + readonly code: string; + /** The JSON-RPC error number. */ + readonly rpcCode: number | undefined; + /** Details the host may add, such as `{ option }` or `{ retryAfterMilliseconds }`. */ + readonly details: JsonObject | undefined; + readonly method: string | undefined; +} + +export function runProcess(command: string, args?: string[], options?: RunProcessOptions): Promise; + +export interface StateStore { + readonly directory: string; + read(key: string, fallback?: T): T | undefined; + write(key: string, value: JsonValue): void; + delete(key: string): void; +} + +export function openStateStore(directory: string, options?: { packageDirectory?: string | null }): StateStore; + +export function isFunctionId(value: unknown): boolean; + +/** + * Host failure codes and the JSON-RPC number each has. The + * `plugin.function.*` codes belong to the relay. + */ +export const HOST_ERRORS: Readonly<{ + 'plugin.host.unavailable': -32601; + 'plugin.host.invalidParams': -32602; + 'plugin.host.failed': -32603; + 'plugin.host.cancelled': -32800; + 'plugin.host.permissionDenied': -32010; + 'plugin.host.contextExpired': -32011; + 'plugin.host.authorityUnsupported': -32012; + 'plugin.host.versionConflict': -32013; + 'plugin.host.limitExceeded': -32014; + 'plugin.host.timeout': -32015; + 'plugin.host.notFound': -32016; + 'plugin.host.readOnly': -32017; + 'plugin.host.unsupportedOption': -32018; + 'plugin.host.preconditionFailed': -32019; + 'plugin.host.busy': -32020; + 'plugin.host.rateLimited': -32021; + 'plugin.host.languageServerUnavailable': -32022; + 'plugin.host.languageServerError': -32023; + 'plugin.host.methodNotDeclared': -32024; + 'plugin.host.toolUnresolved': -32025; + 'plugin.host.foreignContribution': -32026; + 'plugin.function.notDeclared': -32027; + 'plugin.function.notExported': -32028; + 'plugin.function.providerDisabled': -32029; + 'plugin.function.versionMismatch': -32030; + 'plugin.function.callCycle': -32065; + 'plugin.function.depthExceeded': -32066; + 'plugin.function.tooManyCalls': -32067; + 'plugin.function.payloadTooLarge': -32068; + 'plugin.function.providerUnavailable': -32069; + 'plugin.function.failed': -32070; + 'plugin.function.invalidResult': -32071; +}>; + +/** Host request method names. */ +export const HOST_METHODS: Readonly>; + +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; + maxBuildResultBytes: number; + maxBuildMessageBytes: number; + maxBuildOutputBytes: number; + maxVcsIdBytes: number; + maxExecutionErrorOutputBytes: number; + maxCredentialKeyBytes: number; + maxCredentialSecretBytes: number; + maxPendingHostRequests: number; + relayDefaultTimeoutMilliseconds: number; + relayMaxTimeoutMilliseconds: number; + maxParentOperationIdBytes: number; +}>; diff --git a/shared/plugins/sdk/node/src/index.mjs b/shared/plugins/sdk/node/src/index.mjs new file mode 100644 index 00000000..6c1d1dd9 --- /dev/null +++ b/shared/plugins/sdk/node/src/index.mjs @@ -0,0 +1,8 @@ +// Official Node.js SDK for Lithe plugin backends. +// Guide: sdk/node/README.md. Wire contract: contract/v1/README.md. + +export { createBackend } from './backend.mjs'; +export { HostError, PluginError, PluginSdkError } from './errors.mjs'; +export { runProcess } from './process.mjs'; +export { openStateStore } from './state.mjs'; +export { HOST_ERRORS, HOST_METHODS, isFunctionId, isVcsId, LIMITS, VCS_ERRORS } from './protocol.mjs'; diff --git a/shared/plugins/sdk/node/src/process.mjs b/shared/plugins/sdk/node/src/process.mjs new file mode 100644 index 00000000..d107fd12 --- /dev/null +++ b/shared/plugins/sdk/node/src/process.mjs @@ -0,0 +1,188 @@ +// Child processes a backend starts for one call. Lithe's host offers no +// process capability to a backend, so a backend runs its tools +// itself; this helper makes sure that the call ending, cancelling it, or the +// backend shutting down also ends the whole process tree it started. When the +// backend itself is killed no JavaScript runs; the host then ends what is left +// through its own process lifecycle, which owns that boundary. + +import { spawn } from 'node:child_process'; +import { StringDecoder } from 'node:string_decoder'; + +const isWindows = process.platform === 'win32'; + +/** Every process tree started through `runProcess` that has not exited. */ +const running = new Set(); + +/** + * Variables Lithe sets on the backend so it can find and end every process + * the backend started, even one that left its process group. A tool always + * inherits them, also when the plugin replaces the environment. + */ +const HOST_MARKERS = ['LITHE_PROCESS_SCOPE', 'LITHE_PLUGIN_BACKEND_SCOPE']; + +function withHostMarkers(env) { + const result = { ...env }; + for (const name of HOST_MARKERS) { + if (process.env[name] === undefined) delete result[name]; + else result[name] = process.env[name]; + } + return result; +} + +/** + * Runs `command` without a shell and resolves with its exit status and + * captured output. Aborting `signal` ends the process tree and rejects with + * the signal's reason. A non-zero exit resolves; the caller decides what it + * means for its tool. + * + * @param {string} command Executable path or name. + * @param {string[]} [args] + * @param {object} [options] + * @param {string} [options.cwd] + * @param {Record} [options.env] Replaces the environment when given; the + * host's process markers are carried over regardless. + * @param {AbortSignal} [options.signal] + * @param {number} [options.maxOutputBytes] Captured bytes per stream; default 1 MiB. + * @param {(chunk: string) => void} [options.onStdout] + * @param {(chunk: string) => void} [options.onStderr] + * @param {number} [options.killGraceMilliseconds] Delay between the polite and forced stop; default 2000. + */ +export function runProcess(command, args = [], options = {}) { + const { cwd, env, signal, onStdout, onStderr } = options; + const maxOutputBytes = options.maxOutputBytes ?? 1 << 20; + const killGraceMilliseconds = options.killGraceMilliseconds ?? 2000; + if (signal?.aborted) return Promise.reject(signal.reason); + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: env && withHostMarkers(env), + shell: false, + windowsHide: true, + // Its own process group on POSIX, so a stop reaches grandchildren too. + detached: !isWindows, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const entry = { child, stopping: false, forceTimer: null }; + running.add(entry); + const stdout = capture(maxOutputBytes, onStdout); + const stderr = capture(maxOutputBytes, onStderr); + child.stdout.on('data', stdout.push); + child.stderr.on('data', stderr.push); + + const onAbort = () => stopTree(entry, killGraceMilliseconds); + signal?.addEventListener('abort', onAbort, { once: true }); + + let settled = false; + const finish = (outcome) => { + if (settled) return; + settled = true; + // A stopped tree stays registered until its forced stop: the leader + // can exit on SIGTERM while a grandchild in its group ignores it. + if (!entry.stopping) { + // The leader ended on its own; members it left behind in its group, + // such as a background worker, end with it. + signalTree(child, 'SIGKILL', { evenAfterExit: true }); + running.delete(entry); + } + signal?.removeEventListener('abort', onAbort); + stdout.end(); + stderr.end(); + outcome(); + }; + child.once('error', (error) => finish(() => reject(error))); + child.once('close', (exitCode, exitSignal) => + finish(() => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + resolve({ + exitCode, + signal: exitSignal, + stdout: stdout.text(), + stderr: stderr.text(), + truncated: stdout.truncated() || stderr.truncated(), + }); + }), + ); + }); +} + +/** + * Forcibly ends every process tree still running. The backend calls it on + * shutdown, on `exit`, and when the host closes stdin, so no tool outlives + * the session that started it. + */ +export function killAllProcesses() { + for (const entry of running) { + clearTimeout(entry.forceTimer); + signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); + } + running.clear(); +} + +/** Number of process trees still running or being stopped, for tests and diagnostics. */ +export function runningProcessCount() { + return running.size; +} + +function stopTree(entry, graceMilliseconds) { + if (entry.stopping) return; + entry.stopping = true; + signalTree(entry.child, 'SIGTERM'); + entry.forceTimer = setTimeout(() => { + running.delete(entry); + signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); + }, graceMilliseconds); + entry.forceTimer.unref?.(); +} + +/** + * Signals the child's whole tree. `evenAfterExit` still signals the POSIX + * process group after its leader exited, which reaches members that survived + * the polite stop. While any member lives the group ID cannot be reused; + * once all exited the signal fails with ESRCH, which is ignored. + */ +function signalTree(child, signalName, { evenAfterExit = false } = {}) { + if (child.pid === undefined) return; + const exited = child.exitCode !== null || child.signalCode !== null; + if (exited && (isWindows || !evenAfterExit)) return; + try { + if (isWindows) { + // taskkill /T is the only stock way to end a Windows process tree. + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); + } else { + process.kill(-child.pid, signalName); + } + } catch { + // The group already exited between the check and the signal. + } +} + +function capture(limit, listener) { + const chunks = []; + // Keeps a multi-byte character split across chunks whole in `listener`. + const decoder = new StringDecoder('utf8'); + let size = 0; + let truncated = false; + const deliver = (text) => { + if (text) listener?.(text); + }; + return { + push(chunk) { + deliver(decoder.write(chunk)); + if (size >= limit) { + truncated = true; + return; + } + const kept = chunk.subarray(0, limit - size); + truncated ||= kept.length < chunk.length; + chunks.push(kept); + size += kept.length; + }, + end: () => deliver(decoder.end()), + text: () => Buffer.concat(chunks).toString('utf8'), + truncated: () => truncated, + }; +} diff --git a/shared/plugins/sdk/node/src/protocol.mjs b/shared/plugins/sdk/node/src/protocol.mjs new file mode 100644 index 00000000..df13e4da --- /dev/null +++ b/shared/plugins/sdk/node/src/protocol.mjs @@ -0,0 +1,270 @@ +// Wire constants and validation shared with Lithe's Rust Core +// (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. + +/** The protocol versions this SDK speaks. */ +export const FUNCTIONS_VERSION = 0; +export const BUILD_VERSION = 0; +export const VCS_VERSION = 0; +export const LOGGING_VERSION = 0; +export const KEYMAP_IMPORT_VERSION = 0; + +export const METHODS = Object.freeze({ + initialize: 'initialize', + initialized: 'initialized', + shutdown: 'shutdown', + exit: 'exit', + cancelRequest: '$/cancelRequest', + functionInvoke: 'lithe/function/invoke', + functionRegister: 'lithe/function/register', + functionUnregister: 'lithe/function/unregister', + functionProgress: 'lithe/function/progress', + buildModel: 'lithe/build/model', + buildDependencies: 'lithe/build/dependencies', + buildExecute: 'lithe/build/execute', + buildTest: 'lithe/build/test', + buildDiscoverTests: 'lithe/build/discoverTests', + buildPrepareRun: 'lithe/build/prepareRun', + buildPrepareDebug: 'lithe/build/prepareDebug', + buildProgress: 'lithe/build/progress', + vcsExecution: 'lithe/vcs/execution', +}); + +/** + * Backend-to-host requests of the plugin-function protocol. The host adds + * methods without a new version; `host.request` sends any `lithe/host/*` + * method, and `getCapabilities` says which ones this session may call. + */ +export const HOST_METHODS = Object.freeze({ + credentialsGet: 'lithe/host/credentials/get', + credentialsSet: 'lithe/host/credentials/set', + credentialsDelete: 'lithe/host/credentials/delete', + getCapabilities: 'lithe/host/getCapabilities', + subscriptionsDispose: 'lithe/host/subscriptions/dispose', + workspaceListContexts: 'lithe/host/workspace/listContexts', + workspaceGetContext: 'lithe/host/workspace/getContext', + workspaceGetFolders: 'lithe/host/workspace/getFolders', + workspaceReadDirectory: 'lithe/host/workspace/readDirectory', + workspaceStat: 'lithe/host/workspace/stat', + workspaceReadFile: 'lithe/host/workspace/readFile', + workspaceWatch: 'lithe/host/workspace/watch', + workspaceFindFiles: 'lithe/host/workspace/findFiles', + workspaceFindText: 'lithe/host/workspace/findText', + documentsList: 'lithe/host/documents/list', + documentsGetSnapshot: 'lithe/host/documents/getSnapshot', + editorRegisterActions: 'lithe/host/editor/registerActions', + editorGetActive: 'lithe/host/editor/getActive', + editorGetSelections: 'lithe/host/editor/getSelections', + editorApplyEdits: 'lithe/host/editor/applyEdits', + editorReplaceSelections: 'lithe/host/editor/replaceSelections', + editorInsertText: 'lithe/host/editor/insertText', + editorOpen: 'lithe/host/editor/open', + editorReveal: 'lithe/host/editor/reveal', + previewOpenHTML: 'lithe/host/preview/openHTML', + browserOpenExternal: 'lithe/host/browser/openExternal', + windowShowInformation: 'lithe/host/window/showInformation', + windowShowWarning: 'lithe/host/window/showWarning', + windowShowError: 'lithe/host/window/showError', + terminalCreate: 'lithe/host/terminal/create', + terminalShow: 'lithe/host/terminal/show', + terminalClose: 'lithe/host/terminal/close', + terminalReopen: 'lithe/host/terminal/reopen', + terminalDispose: 'lithe/host/terminal/dispose', + terminalOnExit: 'lithe/host/terminal/onExit', + functionsProviders: 'lithe/host/functions/providers', + functionsInvoke: 'lithe/host/functions/invoke', +}); + +/** Host-to-backend notifications. */ +export const HOST_NOTIFICATIONS = Object.freeze({ + didChangeContexts: 'lithe/host/workspace/didChangeContexts', + subscriptionNotify: 'lithe/host/subscriptions/notify', + commandDidRun: 'lithe/host/commands/didRun', +}); + +/** + * Host failure codes (`error.data.code`) and the JSON-RPC number Core sends + * with each to a backend. A number never changes meaning; a later host adds + * codes after the last one listed here. The `plugin.function.*` codes are the + * relay's own. + */ +export const HOST_ERRORS = Object.freeze({ + 'plugin.host.unavailable': -32601, + 'plugin.host.invalidParams': -32602, + 'plugin.host.failed': -32603, + 'plugin.host.cancelled': -32800, + 'plugin.host.permissionDenied': -32010, + 'plugin.host.contextExpired': -32011, + 'plugin.host.authorityUnsupported': -32012, + 'plugin.host.versionConflict': -32013, + 'plugin.host.limitExceeded': -32014, + 'plugin.host.timeout': -32015, + 'plugin.host.notFound': -32016, + 'plugin.host.readOnly': -32017, + 'plugin.host.unsupportedOption': -32018, + 'plugin.host.preconditionFailed': -32019, + 'plugin.host.busy': -32020, + 'plugin.host.rateLimited': -32021, + 'plugin.host.languageServerUnavailable': -32022, + 'plugin.host.languageServerError': -32023, + 'plugin.host.methodNotDeclared': -32024, + 'plugin.host.toolUnresolved': -32025, + 'plugin.host.foreignContribution': -32026, + 'plugin.function.notDeclared': -32027, + 'plugin.function.notExported': -32028, + 'plugin.function.providerDisabled': -32029, + 'plugin.function.versionMismatch': -32030, + 'plugin.function.callCycle': -32065, + 'plugin.function.depthExceeded': -32066, + 'plugin.function.tooManyCalls': -32067, + 'plugin.function.payloadTooLarge': -32068, + 'plugin.function.providerUnavailable': -32069, + 'plugin.function.failed': -32070, + 'plugin.function.invalidResult': -32071, +}); + +/** + * 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. */ +export const ERROR_CODES = Object.freeze({ + parseError: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internalError: -32603, + serverNotInitialized: -32002, + requestFailed: -32000, + requestCancelled: -32800, +}); + +export const LIMITS = Object.freeze({ + maxFunctions: 256, + maxFunctionIdBytes: 128, + maxPluginErrorCodeBytes: 64, + maxMessageBytes: 1024, + maxResultBytes: 1 << 20, + maxBuildResultBytes: 32 * 1024 * 1024, + maxBuildMessageBytes: 4096, + maxBuildOutputBytes: 65536, + maxVcsIdBytes: 32, + maxExecutionErrorOutputBytes: 16 * 1024, + maxCredentialKeyBytes: 128, + maxCredentialSecretBytes: 16 * 1024, + maxPendingHostRequests: 16, + // The relay's bounds are Core's to enforce; the SDK only names them. + relayDefaultTimeoutMilliseconds: 60000, + relayMaxTimeoutMilliseconds: 600000, + maxParentOperationIdBytes: 128, +}); + +/** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ +export function isFunctionId(value) { + return ( + typeof value === 'string' && + value.length > 0 && + Buffer.byteLength(value) <= LIMITS.maxFunctionIdBytes && + value.split('.').every((segment) => /^[a-z][A-Za-z0-9]*$/.test(segment)) + ); +} + +/** 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 key alphabet of host services: 1-128 ASCII letters, digits, `.`, `_`, + * `:`, or `-`. Credential keys, context IDs, and subscription IDs use it. + */ +export function isCredentialKey(value) { + return typeof value === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(value); +} + +/** `{ contextID, generation }`, exactly, naming one generation of one workspace context. */ +export function isContextReference(value) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 2 && + isCredentialKey(value.contextID) && + Number.isSafeInteger(value.generation) && + value.generation >= 1 + ); +} + +/** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ +export function isPluginErrorCode(value) { + return ( + typeof value === 'string' && + Buffer.byteLength(value) <= LIMITS.maxPluginErrorCodeBytes && + /^[A-Za-z][A-Za-z0-9._-]*$/.test(value) + ); +} + +/** Cuts `value` to `limit` UTF-8 bytes without splitting a character. */ +export function boundedText(value, limit = LIMITS.maxMessageBytes) { + const text = String(value ?? ''); + if (Buffer.byteLength(text) <= limit) return text; + let used = 0; + let end = 0; + for (const character of text) { + const size = Buffer.byteLength(character); + if (used + size > limit) break; + used += size; + end += character.length; + } + return text.slice(0, end); +} + +/** + * Methods reserved for the SDK. A plugin cannot install its own handler for + * these, or send them to the host: the lifecycle belongs to the host's + * session management, and `lithe/*` belongs to Lithe's typed contracts. + */ +export function isReservedMethod(method) { + return ( + method === METHODS.initialize || + method === METHODS.initialized || + method === METHODS.shutdown || + method === METHODS.exit || + method === METHODS.cancelRequest || + method.startsWith('lithe/') + ); +} diff --git a/shared/plugins/sdk/node/src/state.mjs b/shared/plugins/sdk/node/src/state.mjs new file mode 100644 index 00000000..f648bb3d --- /dev/null +++ b/shared/plugins/sdk/node/src/state.mjs @@ -0,0 +1,148 @@ +// Durable plugin state. The host gives every backend writable directories +// through the `${stateDir}` and `${workspaceStateDir}` launch variables; the +// installed package and the host's installation are read-only, because +// signatures and delta updates are computed over their exact bytes. + +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { PluginSdkError } from './errors.mjs'; + +/** Version of the envelope each value is stored in. */ +const STORE_FORMAT = 1; +const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +/** + * Opens a key-value store in `directory`, creating the directory. Values are + * JSON; each key is one file replaced atomically, so a crash leaves either the + * old or the new value, never half of one. + * + * @param {string} directory Absolute path, normally the value of `${workspaceStateDir}`. + * @param {object} [options] + * @param {string | null} [options.packageDirectory] The installed plugin package, + * which the store refuses to write into. Defaults to the nearest directory + * containing `plugin.json` above the running entry script. + */ +export function openStateStore(directory, options = {}) { + if (typeof directory !== 'string' || !isAbsolute(directory)) { + throw new PluginSdkError('sdk.state.invalidDirectory', 'The state directory must be an absolute path passed by the host.'); + } + const packageDirectory = options.packageDirectory === undefined ? findPackageDirectory() : options.packageDirectory; + // Checked before creating anything, so a refused path leaves the package untouched. + const root = canonical(directory); + if (packageDirectory && isInside(root, canonical(packageDirectory))) { + throw new PluginSdkError( + 'sdk.state.readOnlyPackage', + 'The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}.', + ); + } + mkdirSync(root, { recursive: true }); + return new StateStore(root); +} + +class StateStore { + #root; + + constructor(root) { + this.#root = root; + } + + /** The resolved directory, for a backend that keeps its own files next to the store. */ + get directory() { + return this.#root; + } + + /** The stored value, or `fallback` when the key is missing or unreadable. */ + read(key, fallback = undefined) { + const path = this.#path(key); + if (!existsSync(path)) return fallback; + try { + const envelope = JSON.parse(readFileSync(path, 'utf8')); + if (envelope?.litheStateStore !== STORE_FORMAT || !('value' in envelope)) return fallback; + return envelope.value; + } catch { + return fallback; + } + } + + /** Stores `value`, which must be JSON-serializable, replacing the previous one. */ + write(key, value) { + const path = this.#path(key); + // `undefined`, a function, or a symbol would vanish from the envelope and + // silently replace the stored value with nothing; refuse it and keep the file. + if (JSON.stringify(value) === undefined) { + throw new PluginSdkError('sdk.state.invalidValue', `The value for ${key} is not JSON-serializable.`); + } + const body = JSON.stringify({ litheStateStore: STORE_FORMAT, value }); + const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; + try { + writeFileSync(temporary, body, { encoding: 'utf8', mode: 0o600 }); + const handle = openSync(temporary, 'r'); + try { + fsyncSync(handle); + } finally { + closeSync(handle); + } + renameSync(temporary, path); + } catch (error) { + rmSync(temporary, { force: true }); + throw error; + } + } + + /** Removes the key; a missing key is not an error. */ + delete(key) { + rmSync(this.#path(key), { force: true }); + } + + #path(key) { + if (typeof key !== 'string' || !KEY.test(key) || key.endsWith('.tmp')) { + throw new PluginSdkError( + 'sdk.state.invalidKey', + `State key ${JSON.stringify(key)} must be 1-128 letters, digits, ".", "_" or "-", start with a letter or digit, and not end with ".tmp".`, + ); + } + return join(this.#root, `${key}.json`); + } +} + +function isInside(path, parent) { + const difference = relative(parent, path); + return difference === '' || (!difference.startsWith(`..${sep}`) && difference !== '..' && !isAbsolute(difference)); +} + +/** Resolves symbolic links of the longest existing prefix of `path`. */ +function canonical(path) { + const missing = []; + let current = resolve(path); + while (!existsSync(current)) { + const parent = dirname(current); + if (parent === current) break; + missing.unshift(basename(current)); + current = parent; + } + return join(realpathSync(current), ...missing); +} + +function findPackageDirectory() { + const entry = process.argv[1]; + if (!entry) return null; + let current = dirname(resolve(entry)); + for (;;) { + if (existsSync(join(current, 'plugin.json'))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} diff --git a/shared/plugins/sdk/node/test/backend.test.mjs b/shared/plugins/sdk/node/test/backend.test.mjs new file mode 100644 index 00000000..3292bc41 --- /dev/null +++ b/shared/plugins/sdk/node/test/backend.test.mjs @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { createBackend, isFunctionId, PluginError, PluginSdkError } from '../src/index.mjs'; +import { gate, TestHost, withTimeout } from './harness.mjs'; + +const fixture = JSON.parse( + readFileSync(new URL('../../../contract/v1/fixtures/backend/function-protocol.json', import.meta.url), 'utf8'), +); + +function functionBackend() { + return createBackend({ name: 'test-backend', version: '1.0.0' }) + .function('snapshot.refresh', (params) => ({ echoed: params })) + .function('install.prepare', () => undefined); +} + +test('initialize answers the protocols the host offered, with sorted functions', async () => { + const backend = functionBackend(); + backend.build.model(() => ({ projects: [] })); + const host = new TestHost(backend); + const { result } = await host.initialize(); + assert.deepEqual(result.capabilities.experimental, { + litheLogging: { version: 0 }, + litheBuild: { version: 0 }, + litheFunctions: { version: 0, 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, vcs: false }); + assert.equal(await host.close(), 1); +}); + +test('a host that offers nothing gets no Lithe capabilities, and a backend without build handlers never claims litheBuild', async () => { + const quiet = new TestHost(functionBackend()); + assert.deepEqual((await quiet.initialize({})).result.capabilities.experimental, { litheLogging: { version: 0 } }); + await quiet.close(); + + const other = new TestHost(functionBackend()); + const answer = (await other.initialize({ litheBuild: { versions: [0] }, litheFunctions: { versions: [99] } })).result; + assert.deepEqual(answer.capabilities.experimental, { litheLogging: { version: 0 } }); + await other.close(); +}); + +test('function IDs follow the shared contract, including the fixture cases', () => { + for (const id of ['snapshot.refresh', 'install.prepare', 'a', 'x1.y2Z']) assert.ok(isFunctionId(id), id); + for (const id of ['', 'Not-Valid', 'snapshot..refresh', '1abc', 'a_b', 'é', 'a'.repeat(129)]) { + assert.ok(!isFunctionId(id), id); + } + // Every ID the fixture accepts must also be accepted locally, and the + // fixture's malformed ID refused, so the SDK never declares what Core rejects. + for (const testCase of fixture.negotiation) { + const ids = testCase.capabilities.experimental?.litheFunctions?.functions?.map(({ id }) => id) ?? []; + if (testCase.name.includes('malformed')) { + assert.ok(ids.some((id) => !isFunctionId(id)), testCase.name); + } else if (testCase.expected.functions) { + assert.ok(ids.every(isFunctionId), testCase.name); + } + } + assert.throws(() => createBackend().function('Bad-ID', () => {}), (error) => error.code === 'sdk.function.invalidID'); + assert.throws( + () => createBackend().function('a', () => {}).function('a', () => {}), + (error) => error.code === 'sdk.function.duplicate', + ); +}); + +test('the lifecycle and the lithe namespace stay reserved for the SDK', async () => { + const backend = createBackend(); + for (const method of ['initialize', 'shutdown', 'exit', '$/cancelRequest', 'lithe/build/model', 'lithe/anything']) { + assert.throws(() => backend.onRequest(method, () => {}), PluginSdkError, method); + } + assert.throws( + () => createBackend({ capabilities: { experimental: { litheFunctions: { version: 0 } } } }), + (error) => error.code === 'sdk.capabilities.reserved', + ); + const host = new TestHost(backend); + await host.initialize(); + // A backend never asks the host to dispatch a domain operation or a function. + for (const method of ['lithe/function/invoke', 'lithe/build/model', 'lithe/host/process.run']) { + assert.throws(() => backend.sendRequest(method, {}), (error) => error.code === 'sdk.method.reserved', method); + } + await host.close(); +}); + +test('requests before initialize are refused with ServerNotInitialized', async () => { + const host = new TestHost(functionBackend()); + const response = await host.request('lithe/function/invoke', { functionID: 'snapshot.refresh', operationID: 'op', params: {} }); + assert.equal(response.error.code, -32002); + await host.close(); +}); + +test('an invocation returns its result; undefined becomes null', async () => { + const host = new TestHost(functionBackend()); + await host.initialize(); + const response = await host.request('lithe/function/invoke', { + functionID: 'snapshot.refresh', + operationID: 'op-1', + params: { project: 'app' }, + }); + assert.deepEqual(response.result, { echoed: { project: 'app' } }); + const empty = await host.request('lithe/function/invoke', { functionID: 'install.prepare', operationID: 'op-2' }); + assert.equal(empty.result, null); + const unknown = await host.request('lithe/function/invoke', { functionID: 'missing', operationID: 'op-3' }); + assert.equal(unknown.error.code, -32602); + await host.close(); +}); + +test('progress echoes the operation ID, is bounded, and stops once the call ended', async () => { + const release = gate(); + let late; + const backend = createBackend().function('long.task', async (_params, context) => { + context.progress({ message: 'x'.repeat(2000), percentage: 140.4 }); + context.progress({ message: 'half', percentage: 49.6 }); + late = context.progress; + await release.opened; + return 'done'; + }); + const host = new TestHost(backend); + await host.initialize(); + const response = host.request('lithe/function/invoke', { functionID: 'long.task', operationID: 'wire-7' }); + const first = await host.waitFor((message) => message.method === 'lithe/function/progress'); + assert.equal(first.params.operationID, 'wire-7'); + assert.equal(first.params.message.length, 1024); + assert.equal(first.params.percentage, 100); + const second = await host.waitFor((message) => message.params?.message === 'half'); + assert.equal(second.params.percentage, 50); + release.open(); + assert.equal((await response).result, 'done'); + late({ message: 'too late' }); + await host.close(); + assert.ok(!host.messages.some((message) => message.params?.message === 'too late')); +}); + +test('a PluginError carries its code; automatic diagnostics omit failure contents', async (t) => { + const backend = createBackend() + .function('locked', () => { + throw new PluginError('maven.snapshotLocked', 'Snapshot is locked.'); + }) + .function('crash', () => { + throw new Error('ENOENT: no such file, open /Users/someone/secret'); + }); + const host = new TestHost(backend); + t.after(() => host.close()); + const diagnostics = []; + host.errorOutput.on('data', (chunk) => diagnostics.push(chunk.toString())); + await host.initialize(); + const locked = await host.request('lithe/function/invoke', { functionID: 'locked', operationID: 'a' }); + assert.deepEqual(locked.error, { code: -32000, message: 'Snapshot is locked.', data: { code: 'maven.snapshotLocked' } }); + const crash = await host.request('lithe/function/invoke', { functionID: 'crash', operationID: 'b' }); + assert.equal(crash.error.code, -32603); + assert.ok(!crash.error.message.includes('/Users/')); + assert.equal(crash.error.data, undefined); + assert.equal(diagnostics.join(''), ''); + const records = host.messages.filter((message) => message.method === 'lithe/log').map(({ params }) => params); + assert.equal(records.length, 2); + assert.deepEqual(records.map(({ kind, operation, code, operationId }) => ({ kind, operation, code, operationId })), [ + { kind: 'operationFailed', operation: 'locked', code: 'maven.snapshotLocked', operationId: 'a' }, + { kind: 'operationFailed', operation: 'crash', code: 'internal_error', operationId: 'b' }, + ]); + assert.ok(records.every(({ durationMs }) => Number.isSafeInteger(durationMs) && durationMs >= 0)); + assert.throws(() => new PluginError('bad code!', 'x'), TypeError); + await host.close(); +}); + +test('$/cancelRequest aborts the handler and answers RequestCancelled', async () => { + const started = gate(); + let observed; + const backend = createBackend().function('wait.forever', (_params, { signal }) => { + started.open(); + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + observed = true; + reject(signal.reason); + }); + }); + }); + const host = new TestHost(backend); + await host.initialize(); + const response = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'c' }, 41); + await withTimeout(started.opened, 'the handler to start'); + host.notify('$/cancelRequest', { id: 41 }); + assert.equal((await response).error.code, -32800); + assert.ok(observed); + await host.close(); +}); + +test('registerFunction and unregisterFunction go through the host, all or nothing', async () => { + const backend = createBackend(); + const host = new TestHost(backend); + await host.initialize(); + + const registering = backend.registerFunction('late.one', () => 'late'); + const register = await host.waitFor((message) => message.method === 'lithe/function/register'); + assert.deepEqual(register.params, { functions: [{ id: 'late.one' }] }); + host.send({ id: register.id, result: null }); + await registering; + const call = await host.request('lithe/function/invoke', { functionID: 'late.one', operationID: 'd' }); + assert.equal(call.result, 'late'); + + // A refused registration leaves no handler behind. + const refused = backend.registerFunction('other.one', () => 0); + const second = await host.waitFor((message) => message.params?.functions?.[0]?.id === 'other.one'); + host.send({ id: second.id, error: { code: -32602, message: 'No.', data: { code: 'plugin.function.invalidRegistration' } } }); + await assert.rejects(refused, (error) => error.code === 'plugin.function.invalidRegistration'); + assert.deepEqual(backend.functionIDs, ['late.one']); + + const unregistering = backend.unregisterFunction('late.one'); + const unregister = await host.waitFor((message) => message.method === 'lithe/function/unregister'); + assert.deepEqual(unregister.params, { ids: ['late.one'] }); + host.send({ id: unregister.id, result: null }); + await unregistering; + assert.deepEqual(backend.functionIDs, []); + await host.close(); +}); + +test('build handlers get the canonical format stamped, and a wrong format fails', async () => { + const backend = createBackend(); + backend.build.model(({ root }) => ({ projects: [{ name: 'app', path: root }] })); + backend.build.dependencies(() => ({ format: 'litheBuildModel', projects: [] })); + const host = new TestHost(backend); + await host.initialize(); + const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); + assert.deepEqual(model.result, { format: 'litheBuildModel', version: 0, projects: [{ name: 'app', path: '.' }] }); + const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); + assert.equal(dependencies.error.code, -32603); + await host.close(); +}); + +test('a build result cannot replace the version the SDK stamps', async () => { + const backend = createBackend(); + backend.build.model(() => ({ version: 1, projects: [] })); + backend.build.dependencies(() => ({ version: 0, dependencies: [] })); + const host = new TestHost(backend); + await host.initialize(); + const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); + assert.equal(model.error.code, -32603); + const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); + assert.deepEqual(dependencies.result, { format: 'litheDependencies', version: 0, dependencies: [] }); + await host.close(); +}); + +test('a result JSON cannot encode, or one over 1 MiB, fails the call and the session continues', async () => { + const cycle = {}; + cycle.self = cycle; + const backend = createBackend() + .function('result.bigint', () => 1n) + .function('result.cycle', () => cycle) + .function('result.symbol', () => Symbol('value')) + .function('result.large', () => 'x'.repeat(1 << 20)) + .function('result.fine', () => ({ ok: true })); + backend.build.model(() => ({ projects: [{ name: 'app', path: '.', size: 1n }] })); + const host = new TestHost(backend); + await host.initialize(); + let operation = 0; + for (const functionID of ['result.bigint', 'result.cycle', 'result.symbol', 'result.large']) { + const response = await host.request('lithe/function/invoke', { functionID, operationID: `op-${operation++}` }); + assert.equal(response.error?.code, -32603, functionID); + } + const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); + assert.equal(model.error?.code, -32603); + const fine = await host.request('lithe/function/invoke', { functionID: 'result.fine', operationID: 'op-last' }); + assert.deepEqual(fine.result, { ok: true }); + assert.equal(await host.close(), 1); +}); + +test('build requests are refused when the host did not negotiate litheBuild', async () => { + const backend = createBackend(); + backend.build.model(() => ({ projects: [] })); + const host = new TestHost(backend); + await host.initialize({ litheFunctions: { versions: [0] } }); + const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); + assert.equal(model.error.code, -32601); + await host.close(); +}); + +test('shutdown cancels running calls and runs hooks; exit after shutdown is code 0', async () => { + const started = gate(); + const order = []; + const backend = createBackend() + .function('wait.forever', (_params, { signal }) => { + started.open(); + return new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason))); + }) + .onShutdown(() => order.push('hook')); + const host = new TestHost(backend); + await host.initialize(); + const call = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'e' }); + await withTimeout(started.opened, 'the handler to start'); + const shutdown = await host.request('shutdown'); + assert.equal(shutdown.result, null); + assert.equal((await call).error.code, -32800); + assert.deepEqual(order, ['hook']); + const after = await host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'f' }); + assert.equal(after.error.code, -32600); + host.notify('exit'); + assert.equal(await withTimeout(host.done, 'exit'), 0); +}); + +test('exit without shutdown, or a closed input, ends with code 1', async () => { + const host = new TestHost(createBackend()); + await host.initialize(); + host.notify('exit'); + assert.equal(await withTimeout(host.done, 'exit'), 1); + assert.equal(await new TestHost(createBackend()).close(), 1); +}); + +test('a malformed body is answered with a parse error and the session continues', async () => { + const host = new TestHost(functionBackend()); + const body = Buffer.from('{not json'); + host.input.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body])); + const parseError = await host.waitFor((message) => message.error?.code === -32700); + assert.equal(parseError.id, null); + assert.ok((await host.initialize()).result); + await host.close(); +}); + +test('registering from an initialize hook joins the initial list without a request', async () => { + const backend = createBackend().function('a.one', () => 1); + backend.onInitialize(() => { + void backend.registerFunction('b.two', () => 2); + void backend.unregisterFunction('a.one'); + }); + const host = new TestHost(backend); + const { result } = await host.initialize(); + assert.deepEqual(result.capabilities.experimental.litheFunctions.functions, [{ id: 'b.two' }]); + assert.ok(!host.messages.some((message) => message.method?.startsWith('lithe/function/'))); + await host.close(); +}); + +test('runtime registration needs the negotiated protocol', async () => { + const backend = createBackend(); + const host = new TestHost(backend); + await host.initialize({}); + await assert.rejects(backend.registerFunction('late.one', () => 1), (error) => error.code === 'sdk.functions.unavailable'); + await host.close(); +}); diff --git a/shared/plugins/sdk/node/test/build.test.mjs b/shared/plugins/sdk/node/test/build.test.mjs new file mode 100644 index 00000000..6313ad71 --- /dev/null +++ b/shared/plugins/sdk/node/test/build.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createBackend, LIMITS, PluginError } from '../src/index.mjs'; +import { gate, TestHost, withTimeout } from './harness.mjs'; + +const launch = { + executable: { kind: 'toolchain', value: 'project-jdk' }, + arguments: ['Main', 'a b'], workingDirectory: 'app', environment: {}, +}; +const methods = [ + ['execute', { exitCode: 0, output: 'built\n' }], + ['test', { exitCode: 2, output: 'test failed\n' }], + ['discoverTests', { tests: [{ suite: 'Suite', case: 'exact # [case]' }] }], + ['prepareRun', launch], + ['prepareDebug', { program: 'app/bin/main', arguments: [], workingDirectory: 'app', environment: {} }], +]; + +test('the build protocol answers version 0 and routes each of its typed methods', { timeout: 15000 }, async (t) => { + const backend = createBackend(); + for (const [method, reply] of methods) backend.build[method](() => reply); + const host = new TestHost(backend); + t.after(() => host.close()); + const { result } = await host.initialize({ litheBuild: { versions: [0, 99] } }); + assert.equal(result.capabilities.experimental.litheBuild.version, 0); + assert.equal(backend.buildVersion, 0); + for (const [method, reply] of methods) { + const response = await host.request(`lithe/build/${method}`, { operationID: `wire-${method}` }); + assert.deepEqual(response.result, method === 'discoverTests' + ? { ...reply, format: 'litheTestDiscovery', version: 0 } : reply); + } +}); + +test('build negotiation needs the host to offer version 0', { timeout: 15000 }, async (t) => { + for (const versions of [[0], [99, 0], [1, 4], [99, '0', true], []]) { + const backend = createBackend(); + backend.build.execute(() => ({ exitCode: 0, output: '' })); + const host = new TestHost(backend); + t.after(() => host.close()); + const offered = versions.includes(0); + const response = await host.initialize({ litheBuild: { versions } }); + assert.equal(backend.buildVersion, offered ? 0 : null, JSON.stringify(versions)); + assert.deepEqual(response.result.capabilities.experimental, + offered ? { litheLogging: { version: 0 }, litheBuild: { version: 0 } } : { litheLogging: { version: 0 } }); + await host.close(); + } +}); + +test('build progress preserves wire identity, UTF-8 limits and call ownership', { timeout: 15000 }, async (t) => { + const release = gate(); + const entered = gate(); + let late; + const backend = createBackend(); + backend.build.prepareRun(async (params, context) => { + assert.equal(params.operationID, context.operationID); + params.operationID = 'plugin-cannot-replace-the-wire-id'; + assert.equal(context.operationID, 'wire-42'); + late = context.progress; + context.progress({ message: '😀'.repeat(2000), output: '界'.repeat(30000), percentage: 120 }); + entered.open(); + await release.opened; + return launch; + }); + const host = new TestHost(backend); + t.after(async () => { release.open(); await host.close(); }); + await host.initialize({ litheBuild: { versions: [0] } }); + const pending = host.request('lithe/build/prepareRun', { operationID: 'wire-42' }); + await withTimeout(entered.opened, 'preparation to begin'); + const report = (await host.waitFor((message) => message.method === 'lithe/build/progress')).params; + assert.equal(report.operationID, 'wire-42'); + assert.equal(Buffer.byteLength(report.message), 4096); + assert.equal(Buffer.byteLength(report.output), 65535); + assert.equal(report.percentage, 100); + release.open(); + assert.deepEqual((await pending).result, launch); + late({ message: 'late output', output: 'late' }); + assert.equal(host.messages.filter((message) => message.method === 'lithe/build/progress').length, 1); +}); + +test('native debug progress preserves wire identity, UTF-8 limits and call ownership', { timeout: 15000 }, async (t) => { + const release = gate(); + const entered = gate(); + let late; + const backend = createBackend(); + backend.build.prepareDebug(async (params, context) => { + assert.equal(params.operationID, context.operationID); + params.operationID = 'plugin-cannot-replace-the-wire-id'; + assert.equal(context.operationID, 'wire-42'); + late = context.progress; + context.progress({ message: '😀'.repeat(2000), output: '界'.repeat(30000), percentage: 120 }); + entered.open(); + await release.opened; + return methods.at(-1)[1]; + }); + const host = new TestHost(backend); + t.after(async () => { release.open(); await host.close(); }); + await host.initialize({ litheBuild: { versions: [0] } }); + const pending = host.request('lithe/build/prepareDebug', { operationID: 'wire-42' }); + await withTimeout(entered.opened, 'preparation to begin'); + const report = (await host.waitFor((message) => message.method === 'lithe/build/progress')).params; + assert.equal(report.operationID, 'wire-42'); + assert.equal(Buffer.byteLength(report.message), 4096); + assert.equal(Buffer.byteLength(report.output), 65535); + assert.equal(report.percentage, 100); + release.open(); + assert.deepEqual((await pending).result, methods.at(-1)[1]); + late({ message: 'late output', output: 'late' }); + assert.equal(host.messages.filter((message) => message.method === 'lithe/build/progress').length, 1); +}); + +test('cancelling one build drops its progress while a sibling call completes', { timeout: 15000 }, async (t) => { + const entered = gate(); + const siblingEntered = gate(); + const release = gate(); + const backend = createBackend(); + backend.build.execute(async (_params, context) => { + entered.open(); + await release.opened; + context.progress({ message: 'cancelled progress' }); + return { exitCode: 0, output: '' }; + }); + backend.build.test(async () => { + siblingEntered.open(); + await release.opened; + return { exitCode: 3, output: 'independent failure' }; + }); + const host = new TestHost(backend); + t.after(async () => { release.open(); await host.close(); }); + await host.initialize({ litheBuild: { versions: [0] } }); + const pending = host.request('lithe/build/execute', { operationID: 'wire-cancel' }, 42); + const sibling = host.request('lithe/build/test', { operationID: 'wire-other' }, 43); + await withTimeout(entered.opened, 'execution to begin'); + await withTimeout(siblingEntered.opened, 'sibling execution to begin'); + host.notify('$/cancelRequest', { id: 42 }); + release.open(); + assert.equal((await pending).error.code, -32800); + assert.deepEqual((await sibling).result, + { exitCode: 3, output: 'independent failure' }); + assert.ok(!host.messages.some((message) => message.method === 'lithe/build/progress')); +}); + +test('invalid build replies fail without terminating the session or falling back', { timeout: 15000 }, async (t) => { + let reply; + const backend = createBackend(); + backend.build.execute(() => reply); + backend.build.discoverTests(() => ({ tests: [], format: 'wrong' })); + backend.build.prepareRun(() => ({ ...launch, executable: false })); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [0] } }); + for (reply of [{}, { exitCode: true, output: '' }, { exitCode: 2147483648, output: '' }, + { exitCode: 0, output: false }, { exitCode: 0, output: 'x'.repeat(LIMITS.maxBuildResultBytes + 1) }]) { + assert.equal((await host.request('lithe/build/execute', { operationID: 'wire-invalid' })).error.code, -32603); + } + for (const method of ['discoverTests', 'prepareRun']) { + assert.equal((await host.request(`lithe/build/${method}`, { operationID: 'wire-invalid' })).error.code, -32603); + } + reply = { exitCode: 7, output: 'tool failure is a result' }; + assert.deepEqual((await host.request('lithe/build/execute', { operationID: 'wire-valid' })).result, reply); +}); + +test('build parameters need a wire operation ID and backend errors keep their category', { timeout: 15000 }, async (t) => { + const backend = createBackend(); + backend.build.execute(() => { throw new PluginError('sample.failed', 'Build rejected'); }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [0] } }); + assert.equal((await host.request('lithe/build/execute', {})).error.code, -32602); + assert.equal((await host.request('lithe/build/execute', [])).error.code, -32602); + const failed = await host.request('lithe/build/execute', { operationID: 'wire-failed' }); + assert.deepEqual(failed.error, { code: -32000, message: 'Build rejected', data: { code: 'sample.failed' } }); +}); + +test('native debug refuses adapter, process and arbitrary DAP result fields', { timeout: 15000 }, async (t) => { + for (const field of ['adapter', 'launch', 'dapArguments']) { + const backend = createBackend(); + backend.build.prepareDebug(() => ({ ...methods.at(-1)[1], [field]: 'untrusted' })); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [0] } }); + assert.ok((await host.request('lithe/build/prepareDebug', { operationID: 'wire' })).error); + } +}); + +test('read-only model inputs reach the model handler with the host identity', { timeout: 15000 }, async (t) => { + const modelInputs = { version: 0, revision: 17, inputs: [ + { pluginID: 'dev.source', buildSystemID: 'source', state: 'unavailable', instances: [] }, + ] }; + const seen = []; + const backend = createBackend(); + backend.build.model((params) => { seen.push(params.modelInputs); return { projects: [], targets: [], tasks: [] }; }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize({ litheBuild: { versions: [0] } }); + const response = await host.request('lithe/build/model', { modelInputs }); + assert.deepEqual(seen, [modelInputs]); + assert.ok(response.result); +}); diff --git a/shared/plugins/sdk/node/test/diagnostics.test.mjs b/shared/plugins/sdk/node/test/diagnostics.test.mjs new file mode 100644 index 00000000..402b7da6 --- /dev/null +++ b/shared/plugins/sdk/node/test/diagnostics.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createBackend, PluginError } from '../src/index.mjs'; +import { TestHost } from './harness.mjs'; + +test('typed diagnostics validate facts, correlate the host operation, and suppress only successful polls', async (t) => { + const backend = createBackend().function('work.run', ({ fail }, context) => { + context.diagnostics.degraded('work.run', 'cache_unavailable', 2); + context.diagnostics.recovered('work.run', 'cache_restored'); + if (fail) throw new PluginError('work.failed', 'Private user-facing explanation.'); + return null; + }).suppressSuccessDiagnostics('work.run'); + const host = new TestHost(backend); + t.after(() => host.close()); + const stderr = []; + host.errorOutput.on('data', (chunk) => stderr.push(chunk.toString())); + await host.initialize(); + for (const [operationID, fail] of [['host-success', false], ['host-failure', true]]) { + await host.request('lithe/function/invoke', { functionID: 'work.run', operationID, params: { fail } }); + } + const records = host.messages.filter(({ method }) => method === 'lithe/log').map(({ params }) => params); + assert.deepEqual(records.map(({ kind }) => kind), ['degraded', 'recovered', 'degraded', 'recovered', 'operationFailed']); + assert.equal(records.at(-1).operationId, 'host-failure'); + assert.equal(records.at(-1).code, 'work.failed'); + assert.ok(Number.isSafeInteger(records.at(-1).durationMs)); + assert.equal(stderr.join(''), ''); + assert.ok(!JSON.stringify(records).includes('Private user-facing')); + for (const change of [ + () => backend.diagnostics.degraded('unknown.input', 'failure'), + () => backend.diagnostics.degraded('work.run', 'body with spaces'), + () => backend.diagnostics.degraded('work.run', 'failure', Number.MAX_SAFE_INTEGER + 1), + ]) assert.throws(change); +}); + +test('the SDK requires litheLogging version 0 and never silently falls back to stderr', async (t) => { + const host = new TestHost(createBackend()); + t.after(() => host.close()); + const reply = await host.request('initialize', { capabilities: {} }); + assert.equal(reply.error.code, -32600); + assert.match(reply.error.message, /litheLogging version 0/); + assert.equal(host.messages.filter(({ method }) => method === 'lithe/log').length, 0); +}); + +test('per-call suppression keeps user actions and failures', async (t) => { + const backend = createBackend().function('database.call', ({ readOnly, fail }, context) => { + if (readOnly) context.suppressSuccessDiagnostics(); + if (fail) throw new Error('private failure'); + return null; + }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize(); + for (const [id, params] of [['read', {readOnly:true}], ['query', {}], ['failure', {readOnly:true,fail:true}]]) { + await host.request('lithe/function/invoke', {functionID:'database.call',operationID:id,params}); + } + const facts = host.messages.filter(({method}) => method === 'lithe/log').map(({params}) => params); + assert.deepEqual(facts.map(({kind,operationId}) => [kind,operationId]), [['operationFinished','query'],['operationFailed','failure']]); +}); + +test('initialize diagnostics wait for the capability answer and keep only eight facts', async (t) => { + const backend = createBackend(); + backend.onInitialize(() => { + for (let count = 0; count < 10; count++) backend.diagnostics.degraded('initialize', 'snapshot_unavailable', count); + }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize(); + assert.equal(host.messages[0].result.capabilities.experimental.litheLogging.version, 0); + const facts = host.messages.filter(({method}) => method === 'lithe/log'); + assert.deepEqual(facts.map(({params}) => params.count), [0, 1, 2, 3, 4, 5, 6, 7]); +}); + +test('domain diagnostics reuse the original RPC id when no wire operation is present', async (t) => { + const backend = createBackend(); + backend.build.model((_params, context) => { + context.diagnostics.degraded('lithe/build/model', 'snapshot_unavailable'); + return { projects: [] }; + }); + const host = new TestHost(backend); + t.after(() => host.close()); + await host.initialize(); + await host.request('lithe/build/model', { root: '.' }, 42); + await host.request('lithe/build/model', { root: '.', operationID: 'wire-original' }, 'rpc-second'); + const facts = host.messages.filter(({method}) => method === 'lithe/log').map(({params}) => params); + assert.deepEqual(facts.map(({operationId}) => operationId), ['42', '42', 'wire-original', 'wire-original']); +}); diff --git a/shared/plugins/sdk/node/test/harness.mjs b/shared/plugins/sdk/node/test/harness.mjs new file mode 100644 index 00000000..f83cde02 --- /dev/null +++ b/shared/plugins/sdk/node/test/harness.mjs @@ -0,0 +1,106 @@ +// A host for SDK tests: it writes framed messages to a backend, in memory or +// in a child process, and decodes the answers with a bounded wait on each. + +import { ChildProcess } from 'node:child_process'; +import { PassThrough } from 'node:stream'; +import { encodeFrame, FrameDecoder } from '../src/framing.mjs'; + +/** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ +export const WAIT_MILLISECONDS = 5000; + +export class TestHost { + messages = []; + #waiters = []; + #nextID = 1; + exitCode; + + /** Hosts `backend` in memory, or talks to a backend child process over its stdio as Lithe does. */ + constructor(backend) { + if (backend instanceof ChildProcess) { + this.input = backend.stdin; + this.output = backend.stdout; + this.errorOutput = backend.stderr; + this.done = new Promise((resolve) => backend.once('exit', (code) => resolve(code))); + } else { + this.input = new PassThrough(); + this.output = new PassThrough(); + this.errorOutput = new PassThrough(); + this.done = backend.listen({ input: this.input, output: this.output, errorOutput: this.errorOutput, exit: null }); + } + const decoder = new FrameDecoder(); + this.output.on('data', (chunk) => { + for (const message of decoder.push(chunk)) { + this.messages.push(message); + this.#waiters = this.#waiters.filter((waiter) => !waiter(message)); + } + }); + this.errorOutput.resume(); + } + + send(message) { + this.input.write(encodeFrame({ jsonrpc: '2.0', ...message })); + } + + /** Sends a request and resolves with the backend's response. */ + request(method, params, id = this.#nextID++) { + this.send({ id, method, params }); + return this.waitFor((message) => message.id === id && message.method === undefined, `response to ${method}`); + } + + notify(method, params) { + this.send({ method, params }); + } + + async initialize( + experimental = { litheBuild: { versions: [0] }, litheFunctions: { versions: [0] } }, + rootUri = 'file:///workspace', + ) { + const response = await this.request('initialize', { + processId: null, + rootUri, + capabilities: { experimental: { litheLogging: { versions: [0] }, ...experimental } }, + }); + this.notify('initialized', {}); + return response; + } + + /** Resolves with the first message, past or future, that `matches`. */ + waitFor(matches, description = 'message') { + const existing = this.messages.find(matches); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); + this.#waiters.push((message) => { + if (!matches(message)) return false; + clearTimeout(timer); + resolve(message); + return true; + }); + }); + } + + /** Ends the session and resolves with the backend's exit code. */ + async close() { + this.input.end(); + return withTimeout(this.done, 'the backend to exit'); + } +} + +export function withTimeout(promise, description) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** A promise with its resolver, for handlers a test releases explicitly. */ +export function gate() { + let open; + const opened = new Promise((resolve) => { + open = resolve; + }); + return { open, opened }; +} diff --git a/shared/plugins/sdk/node/test/host.test.mjs b/shared/plugins/sdk/node/test/host.test.mjs new file mode 100644 index 00000000..5f80be02 --- /dev/null +++ b/shared/plugins/sdk/node/test/host.test.mjs @@ -0,0 +1,720 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { createBackend, HOST_ERRORS, HostError, LIMITS, PluginSdkError } from '../src/index.mjs'; +import { gate, TestHost, withTimeout } from './harness.mjs'; + +const fixture = JSON.parse( + readFileSync(new URL('../../../contract/v1/fixtures/backend/function-protocol.json', import.meta.url), 'utf8'), +); + +const CONTEXT = Object.freeze({ contextID: 'w1', generation: 2 }); + +/** A backend and host whose session negotiated plugin functions. */ +async function session(backend = createBackend()) { + const host = new TestHost(backend); + await host.initialize({ litheFunctions: { versions: [0] } }); + return { backend, host }; +} + +/** The next host request the backend wrote that the test has not taken yet. */ +function nextRequest(host, method, taken = new Set()) { + return host + .waitFor((message) => message.method === method && message.id !== undefined && !taken.has(message.id), method) + .then((message) => { + taken.add(message.id); + return message; + }); +} + +test('the SDK answers litheFunctions only when the host offers version 0', async () => { + const backend = createBackend().function('query.run', () => null); + const host = new TestHost(backend); + const { result } = await host.initialize({ litheFunctions: { versions: [0] } }); + assert.deepEqual(result.capabilities.experimental.litheFunctions, { version: 0, functions: [{ id: 'query.run' }] }); + assert.equal(backend.functionsVersion, 0); + assert.equal(backend.host.negotiated, true); + await host.close(); + + for (const versions of [[1], [99], []]) { + const other = createBackend().function('query.run', () => null); + const otherHost = new TestHost(other); + const answer = await otherHost.initialize({ litheFunctions: { versions } }); + assert.equal(answer.result.capabilities.experimental.litheFunctions, undefined, JSON.stringify(versions)); + assert.equal(other.functionsVersion, null); + assert.equal(other.host.negotiated, false); + await otherHost.close(); + } +}); + +test('credentials round-trip through the host, with the contract shapes', async () => { + const { backend, host } = await session(); + const taken = new Set(); + + const reading = backend.host.credentials.get('connection.prod'); + const get = await nextRequest(host, 'lithe/host/credentials/get', taken); + assert.deepEqual(get.params, { key: 'connection.prod' }); + host.send({ id: get.id, result: { secret: 's3cret' } }); + assert.equal(await reading, 's3cret'); + + const missing = backend.host.credentials.get('connection.prod'); + host.send({ id: (await nextRequest(host, 'lithe/host/credentials/get', taken)).id, result: null }); + assert.equal(await missing, null); + + const malformed = backend.host.credentials.get('connection.prod'); + host.send({ id: (await nextRequest(host, 'lithe/host/credentials/get', taken)).id, result: {} }); + await assert.rejects(malformed, (error) => error.code === 'sdk.host.invalidResult'); + + const writing = backend.host.credentials.set('connection.prod:password', 's3cret'); + const set = await nextRequest(host, 'lithe/host/credentials/set', taken); + assert.deepEqual(set.params, { key: 'connection.prod:password', secret: 's3cret' }); + host.send({ id: set.id, result: null }); + assert.equal(await writing, undefined); + + const deleting = backend.host.credentials.delete('connection.prod'); + const remove = await nextRequest(host, 'lithe/host/credentials/delete', taken); + assert.deepEqual(remove.params, { key: 'connection.prod' }); + host.send({ id: remove.id, result: null }); + await deleting; + + // Malformed arguments fail locally and never reach the host. + const before = host.messages.length; + await assert.rejects(backend.host.credentials.get(''), (error) => error.code === 'sdk.credentials.invalidKey'); + await assert.rejects(backend.host.credentials.get('has space'), (error) => error.code === 'sdk.credentials.invalidKey'); + await assert.rejects(backend.host.credentials.get('k'.repeat(129)), (error) => error.code === 'sdk.credentials.invalidKey'); + await assert.rejects(backend.host.credentials.set('key', 7), (error) => error.code === 'sdk.credentials.invalidSecret'); + await assert.rejects( + backend.host.credentials.set('key', 'x'.repeat(LIMITS.maxCredentialSecretBytes + 1)), + (error) => error.code === 'sdk.credentials.invalidSecret', + ); + assert.equal(host.messages.length, before); + await host.close(); +}); + +test('local key and context checks agree with the fixture cases Core refuses or forwards', async () => { + const { backend, host } = await session(); + const key = (testCase) => testCase.params?.key; + for (const testCase of fixture.hostRequests.filter((testCase) => testCase.method.startsWith('lithe/host/credentials/'))) { + if (testCase.expected.forward) { + assert.match(key(testCase), /^[A-Za-z0-9._:-]{1,128}$/, testCase.name); + } else if (testCase.name.includes('key')) { + await assert.rejects(backend.host.credentials.get(key(testCase)), (error) => error.code === 'sdk.credentials.invalidKey', testCase.name); + } + } + for (const testCase of fixture.hostRequests.filter((testCase) => /context (without|ID outside)|generation is positive/.test(testCase.name))) { + await assert.rejects( + backend.host.workspace.getFolders(testCase.params.context), + (error) => error.code === 'sdk.host.invalidContext', + testCase.name, + ); + } + // A full context from listContexts is passed on as its reference. + const asking = backend.host.workspace.getFolders({ ...CONTEXT, name: 'orders', folders: [] }); + const request = await nextRequest(host, 'lithe/host/workspace/getFolders'); + assert.deepEqual(request.params, { context: CONTEXT }); + host.send({ id: request.id, result: { folders: [] } }); + assert.deepEqual(await asking, { folders: [] }); + await host.close(); +}); + +test('host refusals become HostError with the code, number, and details Core sent', async () => { + const { backend, host } = await session(); + const taken = new Set(); + const answer = async (wire) => { + const pending = backend.host.request('lithe/host/workspace/listContexts'); + const request = await nextRequest(host, 'lithe/host/workspace/listContexts', taken); + host.send({ id: request.id, error: wire }); + return pending.then(() => assert.fail('resolved'), (error) => error); + }; + for (const testCase of fixture.hostRequests.filter(({ expected }) => expected.code)) { + const { code, rpcCode, details } = testCase.expected; + const error = await answer({ code: rpcCode, message: 'Refused.', data: { code, ...(details ? { details } : {}) } }); + assert.ok(error instanceof HostError && error instanceof PluginSdkError, testCase.name); + assert.deepEqual({ code: error.code, rpcCode: error.rpcCode, details: error.details }, { code, rpcCode, details }, testCase.name); + assert.equal(error.method, 'lithe/host/workspace/listContexts'); + } + for (const testCase of fixture.hostFailures) { + const { code, rpcCode } = testCase.expected; + assert.equal(HOST_ERRORS[code], rpcCode, testCase.name); + // The number alone names the code. + assert.equal((await answer({ code: rpcCode, message: 'Failed.' })).code, code, testCase.name); + } + const unknown = await answer({ code: -32050, message: 'Later code.', data: { details: ['not', 'an', 'object'] } }); + assert.deepEqual({ code: unknown.code, details: unknown.details }, { code: 'plugin.host.failed', details: undefined }); + await host.close(); +}); + +test('without litheFunctions the host offers no service, and host requests stay within lithe/host/', async () => { + const backend = createBackend(); + const host = new TestHost(backend); + await host.initialize({}); + const before = host.messages.length; + await assert.rejects( + backend.host.credentials.get('connection.prod'), + (error) => error instanceof HostError && error.code === 'plugin.host.unavailable' && error.rpcCode === -32601, + ); + await assert.rejects(backend.host.capabilities(), (error) => error.code === 'plugin.host.unavailable'); + assert.equal(await backend.host.supports('lithe/host/credentials/get'), false); + await assert.rejects(backend.host.request('lithe/function/invoke', {}), (error) => error.code === 'sdk.host.method'); + assert.equal(host.messages.length, before); + await host.close(); + + const idle = createBackend(); + await assert.rejects(idle.host.credentials.get('key'), (error) => error.code === 'sdk.session.notReady'); +}); + +test('capabilities are asked for once, and supports() reads them', async () => { + const { backend, host } = await session(); + const answer = fixture.hostRequests.find((testCase) => testCase.expected.answer).expected.answer; + const first = backend.host.supports('lithe/host/workspace/getFolders'); + const second = backend.host.supports('lithe/host/workspace/readFile'); + const request = await nextRequest(host, 'lithe/host/getCapabilities'); + assert.equal(request.params, undefined); + host.send({ id: request.id, result: answer }); + assert.deepEqual([await first, await second], [true, false]); + assert.equal((await backend.host.capabilities()).limits.pendingHostRequests, LIMITS.maxPendingHostRequests); + assert.equal(host.messages.filter((message) => message.method === 'lithe/host/getCapabilities').length, 1); + await host.close(); +}); + +test('a call carries its workspace context to the handler', async () => { + for (const testCase of fixture.invocationContexts.filter(({ expected }) => expected.params)) { + let seen; + const backend = createBackend().function(testCase.params.functionID, (_params, context) => { + seen = context.workspaceContext; + }); + const { host } = await session(backend); + const response = await host.request('lithe/function/invoke', testCase.expected.params); + assert.equal(response.error, undefined, testCase.name); + assert.deepEqual(seen, testCase.expected.params.context ?? null, testCase.name); + await host.close(); + } +}); + +test('cancelling a call cancels its host requests at Core', async () => { + const started = gate(); + let outcome; + const backend = createBackend().function('secret.read', async (_params, context) => { + const reading = context.host.credentials.get('connection.prod'); + started.open(); + outcome = await reading.then(() => 'resolved', (error) => error); + throw outcome; + }); + const { host } = await session(backend); + const call = host.request('lithe/function/invoke', { functionID: 'secret.read', operationID: 'c' }, 41); + await withTimeout(started.opened, 'the host request'); + const request = await nextRequest(host, 'lithe/host/credentials/get'); + host.notify('$/cancelRequest', { id: 41 }); + assert.equal((await call).error.code, -32800); + // The host request rejects with the call's abort reason. + assert.equal(outcome?.name, 'AbortError'); + const cancels = host.messages.filter((message) => message.method === '$/cancelRequest'); + assert.deepEqual(cancels.map((message) => message.params), [{ id: request.id }]); + // Core's late answer is dropped; the session continues. + host.send({ id: request.id, error: { code: -32800, message: 'Cancelled.', data: { code: 'plugin.host.cancelled' } } }); + assert.equal(await host.close(), 1); +}); + +test('an aborted signal rejects with its reason and is never sent again', async () => { + const { backend, host } = await session(); + const controller = new AbortController(); + const pending = backend.host.editor.applyEdits(CONTEXT, { documentID: 'd1', expectedVersion: 4, edits: [] }, { signal: controller.signal }); + const request = await nextRequest(host, 'lithe/host/editor/applyEdits'); + assert.deepEqual(request.params, { documentID: 'd1', expectedVersion: 4, edits: [], context: CONTEXT }); + controller.abort(new Error('user stopped')); + await assert.rejects(pending, /user stopped/); + host.send({ id: request.id, result: { documentID: 'd1', version: 5, applied: true } }); + await assert.rejects(backend.host.request('lithe/host/editor/applyEdits', {}, { signal: controller.signal }), /user stopped/); + assert.equal(host.messages.filter((message) => message.method === 'lithe/host/editor/applyEdits').length, 1); + await host.close(); +}); + +test('at most 16 host requests are unanswered; later ones wait for a free slot', async () => { + const { backend, host } = await session(); + const max = LIMITS.maxPendingHostRequests; + const calls = Array.from({ length: max + 2 }, () => backend.host.workspace.listContexts()); + const sent = () => host.messages.filter((message) => message.method === 'lithe/host/workspace/listContexts'); + await host.waitFor(() => sent().length === max, 'the first 16 requests'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(sent().length, max); + host.send({ id: sent()[0].id, result: { revision: 1, contexts: [] } }); + await host.waitFor(() => sent().length === max + 1, 'the 17th request'); + assert.equal(sent().length, max + 1); + // Ending the session fails the queued request too. + await host.close(); + const outcomes = await Promise.allSettled(calls); + assert.equal(outcomes[0].status, 'fulfilled'); + assert.ok(outcomes.slice(1).every((outcome) => outcome.status === 'rejected' && outcome.reason.code === 'sdk.session.closed')); +}); + +test('subscription events reach their handler in order, also when sent before the answer', async () => { + const { backend, host } = await session(); + const events = []; + const received = gate(); + const watching = backend.host.workspace.watch(CONTEXT, { resource: { rootID: 'r1', path: '' }, recursive: true }, (event, info) => { + events.push({ event, ...info }); + if (events.length === 3) received.open(); + }); + const request = await nextRequest(host, 'lithe/host/workspace/watch'); + assert.deepEqual(request.params, { resource: { rootID: 'r1', path: '' }, recursive: true, context: CONTEXT }); + const notify = (sequence) => host.notify('lithe/host/subscriptions/notify', { + subscriptionID: 's1', sequence, event: { rootID: 'r1', changedPaths: [`f${sequence}`], requiresRescan: false }, + }); + notify(1); + host.send({ id: request.id, result: { subscriptionID: 's1' } }); + const subscription = await watching; + notify(2); + notify(4); + await withTimeout(received.opened, 'three events'); + assert.deepEqual(events.map(({ sequence, gap, subscriptionID }) => ({ sequence, gap, subscriptionID })), [ + { sequence: 1, gap: false, subscriptionID: 's1' }, + { sequence: 2, gap: false, subscriptionID: 's1' }, + { sequence: 4, gap: true, subscriptionID: 's1' }, + ]); + assert.equal(subscription.subscriptionID, 's1'); + assert.equal(subscription.disposed, false); + + const disposing = subscription.dispose(); + assert.equal(subscription.disposed, true); + const dispose = await nextRequest(host, 'lithe/host/subscriptions/dispose'); + assert.deepEqual(dispose.params, { subscriptionID: 's1' }); + notify(5); + host.send({ id: dispose.id, result: null }); + await disposing; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(events.length, 3); + await host.close(); +}); + +test('commands, context changes, and later host notifications reach their handlers', async () => { + const COMMAND_DID_RUN = 'lithe/host/commands/didRun'; + const CONTEXTS_DID_CHANGE = 'lithe/host/workspace/didChangeContexts'; + const delivered = fixture.hostNotifications.filter(({ expected, method }) => expected.delivered && method !== 'lithe/host/subscriptions/notify'); + // Commands and context changes have their own API. The other + // notifications (a run's exit, a language server's state and notifications) + // have no wrapper; the generic handler delivers each with its params as sent. + const expected = [ + ...delivered.map(({ method, params }) => { + if (method === COMMAND_DID_RUN) return ['command', { commandID: params.commandID, workspaceContext: params.context ?? null }]; + if (method === CONTEXTS_DID_CHANGE) return ['contexts', params]; + return [method, params]; + }), + ['later', { value: 1 }], + ]; + const backend = createBackend(); + const seen = []; + const all = gate(); + const record = (entry) => { + seen.push(entry); + if (seen.length === expected.length) all.open(); + }; + backend + .onCommand('db.refresh', (event) => record(['command', event])) + .onContextsChanged((event) => record(['contexts', event])) + .onHostNotification('lithe/host/later/didSomething', (params) => record(['later', params])); + // A method gets one generic handler, however many fixture cases use it. + for (const method of new Set(delivered.map((entry) => entry.method))) { + if (method === COMMAND_DID_RUN || method === CONTEXTS_DID_CHANGE) continue; + backend.onHostNotification(method, (params) => record([method, params])); + } + assert.throws(() => backend.onHostNotification('lithe/host/commands/didRun', () => {}), (error) => error.code === 'sdk.method.reserved'); + assert.throws(() => backend.onHostNotification('workspace/didChange', () => {}), (error) => error.code === 'sdk.method.invalid'); + assert.throws(() => backend.onCommand('DB_Refresh', () => {}), (error) => error.code === 'sdk.command.invalidID'); + const { host } = await session(backend); + for (const { method, params } of delivered) host.notify(method, params); + // A notification this SDK does not know is ignored, as the contract asks. + host.notify('lithe/host/unknown/ignored', { anything: true }); + host.notify('lithe/host/later/didSomething', { value: 1 }); + await withTimeout(all.opened, 'the notifications'); + assert.deepEqual(seen, expected); + await host.close(); +}); + +test('native terminal wrappers keep argv and context, and subscribe to one owned exit', { timeout: 5000 }, async () => { + const { backend, host } = await session(); + try { + const params = { executable: 'example-client', arguments: ['literal;$(value)', 'space value'], show: true }; + const creating = backend.host.terminal.create(CONTEXT, params); + const create = await nextRequest(host, 'lithe/host/terminal/create'); + assert.deepEqual(create.params, { ...params, context: CONTEXT }); + host.send({ id: create.id, result: { terminalID: 'owned', generation: 1 } }); + assert.deepEqual(await creating, { terminalID: 'owned', generation: 1 }); + const seen = gate(); + let event; + const listening = backend.host.terminal.onExit(CONTEXT, 'owned', value => { event = value; seen.open(); }); + const listen = await nextRequest(host, 'lithe/host/terminal/onExit'); + host.send({ id: listen.id, result: { subscriptionID: 'terminal-exit' } }); + const subscription = await listening; + host.notify('lithe/host/subscriptions/notify', { subscriptionID: 'terminal-exit', sequence: 1, + event: { type: 'exit', terminalID: 'owned', exitCode: 0, reason: 'exited' } }); + await withTimeout(seen.opened, 'terminal exit event'); + assert.equal(event.exitCode, 0); + const closing = backend.host.terminal.close(CONTEXT, { terminalID: 'owned' }); + const close = await nextRequest(host, 'lithe/host/terminal/close'); + host.send({ id: close.id, result: { retainedContextBytes: 0 } }); + assert.deepEqual(await closing, { retainedContextBytes: 0 }); + const reopening = backend.host.terminal.reopen(CONTEXT, { terminalID: 'owned' }); + const reopen = await nextRequest(host, 'lithe/host/terminal/reopen'); + host.send({ id: reopen.id, result: { terminalID: 'owned', generation: 2, restoredContextBytes: 0 } }); + assert.equal((await reopening).generation, 2); + const disposing = subscription.dispose(); + const dispose = await nextRequest(host, 'lithe/host/subscriptions/dispose'); + host.send({ id: dispose.id, result: null }); + await disposing; + } finally { await host.close(); } +}); + +const RELAY_PROVIDERS = 'lithe/host/functions/providers'; +const RELAY_INVOKE = 'lithe/host/functions/invoke'; +/** The provider and function the fixture's relay cases name. */ +const PROVIDER = Object.freeze({ pluginID: 'dev.lithe.fixture.go', functionID: 'languageImport.settings' }); + +/** The fixture's host requests for one method. */ +const relayCases = (method) => fixture.hostRequests.filter((testCase) => testCase.method === method); + +/** A fixture invoke case as the SDK takes it: the context, and the call without it. */ +function splitInvoke({ params }) { + const { context, ...call } = params; + return { context, call }; +} + +test('context.caller is the relayed caller, read only from the top-level field', async () => { + // The fixture's cases: the wire message Core sends, and whether it holds a caller. + for (const testCase of fixture.invocationCallers) { + const seen = []; + const backend = createBackend().function(testCase.params.functionID, (_params, context) => { + seen.push(context.caller); + }); + const { host } = await session(backend); + const response = await host.request('lithe/function/invoke', testCase.expected.params); + assert.equal(response.error, undefined, testCase.name); + assert.deepEqual(seen, [testCase.expected.params.caller ?? null], testCase.name); + if (seen[0] !== null) assert.ok(Object.isFrozen(seen[0]), testCase.name); + await host.close(); + } + + // Anything but exactly { pluginID: non-empty string } at the top level is no caller. + const wire = { functionID: 'languageImport.settings', operationID: '7', params: {} }; + const seen = []; + const backend = createBackend().function('languageImport.settings', (params, context) => { + seen.push({ caller: context.caller, params }); + }); + const { host } = await session(backend); + const notCallers = [ + { caller: { pluginID: '' } }, + { caller: { pluginID: 7 } }, + { caller: {} }, + { caller: { pluginID: 'dev.lithe.fixture.python', extra: true } }, + { caller: 'dev.lithe.fixture.python' }, + { caller: null }, + { caller: ['dev.lithe.fixture.python'] }, + // A caller inside params is the calling plugin's own data. + { params: { caller: { pluginID: 'dev.lithe.fixture.other' } } }, + ]; + for (const extra of notCallers) { + const response = await host.request('lithe/function/invoke', { ...wire, ...extra }); + assert.equal(response.error, undefined, JSON.stringify(extra)); + } + assert.deepEqual(seen.map((entry) => entry.caller), notCallers.map(() => null)); + assert.deepEqual(seen.at(-1).params, { caller: { pluginID: 'dev.lithe.fixture.other' } }); + await host.close(); +}); + +test('functions.providers sends the function ID and returns the host answer unwrapped', async () => { + const { backend, host } = await session(); + const taken = new Set(); + const testCase = relayCases(RELAY_PROVIDERS).find(({ expected }) => expected.relay); + const listing = backend.host.functions.providers(testCase.params.functionID); + const request = await nextRequest(host, RELAY_PROVIDERS, taken); + assert.deepEqual(request.params, testCase.params); + const answer = { providers: [{ pluginID: 'dev.lithe.fixture.go', version: 1 }, { pluginID: 'dev.lithe.fixture.rust', version: 1 }] }; + host.send({ id: request.id, result: answer }); + assert.deepEqual(await listing, answer); + + const none = backend.host.functions.providers(testCase.params.functionID); + host.send({ id: (await nextRequest(host, RELAY_PROVIDERS, taken)).id, result: { providers: [] } }); + assert.deepEqual(await none, { providers: [] }); + await host.close(); +}); + +test('functions.invoke sends the fixture params and returns the host answer unwrapped', async () => { + const { backend, host } = await session(); + const taken = new Set(); + const cases = relayCases(RELAY_INVOKE).filter(({ expected }) => expected.relay); + assert.ok(cases.some(({ params }) => params.parentOperationID !== undefined)); + assert.ok(cases.some(({ params }) => params.parentOperationID === undefined)); + for (const testCase of cases) { + const { context, call } = splitInvoke(testCase); + for (const value of [{ answer: 42 }, null]) { + const calling = backend.host.functions.invoke(context, call); + const request = await nextRequest(host, RELAY_INVOKE, taken); + // backend.host serves no call, so only an explicit parent is sent. + assert.deepEqual(request.params, testCase.params, testCase.name); + host.send({ id: request.id, result: { value } }); + assert.deepEqual(await calling, { value }, testCase.name); + } + } + // A full context from listContexts is passed on as its reference. + const calling = backend.host.functions.invoke({ ...CONTEXT, name: 'orders', folders: [] }, PROVIDER); + const request = await nextRequest(host, RELAY_INVOKE, taken); + assert.deepEqual(request.params, { ...PROVIDER, context: CONTEXT }); + host.send({ id: request.id, result: { value: 'ok' } }); + assert.deepEqual(await calling, { value: 'ok' }); + await host.close(); +}); + +test('inside a function, relayed calls default to the served call as their parent', async () => { + const backend = createBackend().function('project.describe', async ({ via, call }, context) => { + const caller = via === 'backend' ? backend.host : context.host; + await caller.functions.invoke(CONTEXT, call); + return null; + }); + const { host } = await session(backend); + const taken = new Set(); + const cases = [ + ['the served call', { operationID: 'op-1' }, 'context', PROVIDER, 'op-1'], + ['an explicit parent wins', { operationID: 'op-1' }, 'context', { ...PROVIDER, parentOperationID: 'explicit' }, 'explicit'], + ['backend.host has no served call', { operationID: 'op-1' }, 'backend', PROVIDER, undefined], + ['no wire operationID', {}, 'context', PROVIDER, undefined], + ['an operationID that is not a string', { operationID: 7 }, 'context', PROVIDER, undefined], + ['an empty operationID', { operationID: '' }, 'context', PROVIDER, undefined], + ['an operationID of 129 bytes', { operationID: 'o'.repeat(129) }, 'context', PROVIDER, undefined], + ['an operationID of 128 bytes', { operationID: 'o'.repeat(128) }, 'context', PROVIDER, 'o'.repeat(128)], + ['an operationID of 128 bytes in 64 characters', { operationID: 'é'.repeat(64) }, 'context', PROVIDER, 'é'.repeat(64)], + ['an operationID of 130 bytes in 65 characters', { operationID: 'é'.repeat(65) }, 'context', PROVIDER, undefined], + ]; + for (const [name, wire, via, call, parent] of cases) { + const done = host.request('lithe/function/invoke', { functionID: 'project.describe', params: { via, call }, ...wire }); + const request = await nextRequest(host, RELAY_INVOKE, taken); + if (parent === undefined) { + assert.ok(!('parentOperationID' in request.params), name); + } else { + assert.equal(request.params.parentOperationID, parent, name); + } + host.send({ id: request.id, result: { value: null } }); + assert.equal((await done).error, undefined, name); + } + await host.close(); +}); + +test('relayed calls from build and request handlers carry no default parent', async () => { + const backend = createBackend(); + backend.build.execute(async (_params, context) => { + await context.host.functions.invoke(CONTEXT, PROVIDER); + return { exitCode: 0, output: 'built\n' }; + }); + backend.onRequest('example/relay', async (_params, context) => { + await context.host.functions.invoke(CONTEXT, PROVIDER); + return null; + }); + const host = new TestHost(backend); + try { + await host.initialize({ litheFunctions: { versions: [0] }, litheBuild: { versions: [0] } }); + const taken = new Set(); + for (const [method, params] of [['lithe/build/execute', { operationID: 'wire-execute' }], ['example/relay', { operationID: 'wire-relay' }]]) { + const done = host.request(method, params); + const request = await nextRequest(host, RELAY_INVOKE, taken); + assert.ok(!('parentOperationID' in request.params), method); + host.send({ id: request.id, result: { value: null } }); + assert.equal((await done).error, undefined, method); + } + } finally { await host.close(); } +}); + +test('supports() tells a plugin whether the host lists the relay methods', async () => { + const listed = await session(); + const asking = listed.backend.host.supports(RELAY_INVOKE); + const request = await nextRequest(listed.host, 'lithe/host/getCapabilities'); + listed.host.send({ id: request.id, result: { services: { functions: { methods: [RELAY_PROVIDERS, RELAY_INVOKE] } } } }); + assert.equal(await asking, true); + assert.equal(await listed.backend.host.supports(RELAY_PROVIDERS), true); + await listed.host.close(); + + const unlisted = await session(); + const unlistedAsking = unlisted.backend.host.supports(RELAY_INVOKE); + const unlistedRequest = await nextRequest(unlisted.host, 'lithe/host/getCapabilities'); + unlisted.host.send({ + id: unlistedRequest.id, + result: { services: { workspace: { methods: ['lithe/host/workspace/listContexts'] } } }, + }); + assert.equal(await unlistedAsking, false); + await unlisted.host.close(); +}); + +test('relayed calls are checked locally, and a mistake sends no frame', async () => { + const { backend, host } = await session(); + const before = host.messages.length; + const refusedWith = (code) => (error) => error instanceof PluginSdkError && error.code === code; + + // The fixture's calls that Core would answer -32602. + const refusals = relayCases(RELAY_INVOKE).filter(({ expected }) => expected.code === 'plugin.host.invalidParams'); + assert.ok(refusals.length >= 5); + for (const testCase of refusals) { + const { context, call } = splitInvoke(testCase); + const code = context === undefined ? 'sdk.host.invalidContext' : 'sdk.functions.invalidCall'; + await assert.rejects(backend.host.functions.invoke(context, call), refusedWith(code), testCase.name); + } + + const good = { ...PROVIDER }; + const badCalls = [ + ['no call', undefined], + ['a null call', null], + ['an array for a call', []], + ['an empty plugin ID', { ...good, pluginID: '' }], + ['a plugin ID that is not a string', { ...good, pluginID: 7 }], + ['no plugin ID', { functionID: good.functionID }], + ['an empty function ID', { ...good, functionID: '' }], + ['a function ID over 128 bytes', { ...good, functionID: 'a'.repeat(129) }], + ['null params', { ...good, params: null }], + ['string params', { ...good, params: 'x' }], + ['array params', { ...good, params: [] }], + ['a fractional timeout', { ...good, timeoutMilliseconds: 1.5 }], + ['a negative timeout', { ...good, timeoutMilliseconds: -1 }], + ['a timeout of NaN', { ...good, timeoutMilliseconds: Number.NaN }], + ['a timeout of Infinity', { ...good, timeoutMilliseconds: Number.POSITIVE_INFINITY }], + ['a timeout string', { ...good, timeoutMilliseconds: '5000' }], + ['a timeout beyond a safe integer', { ...good, timeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['a null parent', { ...good, parentOperationID: null }], + ['an empty parent', { ...good, parentOperationID: '' }], + ['a parent of 129 bytes', { ...good, parentOperationID: 'p'.repeat(129) }], + ['a parent of 130 bytes in 65 characters', { ...good, parentOperationID: 'é'.repeat(65) }], + ]; + for (const [name, call] of badCalls) { + await assert.rejects(backend.host.functions.invoke(CONTEXT, call), refusedWith('sdk.functions.invalidCall'), name); + } + for (const context of [undefined, null, {}, { contextID: 'w1' }, { contextID: 'w1', generation: 0 }, { contextID: '../w', generation: 1 }]) { + await assert.rejects(backend.host.functions.invoke(context, good), refusedWith('sdk.host.invalidContext'), `context ${JSON.stringify(context)}`); + } + for (const functionID of [undefined, 7, '', 'Not-Valid', 'a..b', 'a'.repeat(129)]) { + await assert.rejects(backend.host.functions.providers(functionID), refusedWith('sdk.functions.invalidCall'), `function ID ${String(functionID)}`); + } + assert.equal(host.messages.length, before); + + // Limits Core enforces are not enforced twice: a long timeout is passed on as given. + const taken = new Set(); + const plain = backend.host.functions.invoke(CONTEXT, good); + const plainRequest = await nextRequest(host, RELAY_INVOKE, taken); + assert.deepEqual(Object.keys(plainRequest.params).sort(), ['context', 'functionID', 'pluginID']); + host.send({ id: plainRequest.id, result: { value: null } }); + await plain; + const long = backend.host.functions.invoke(CONTEXT, { ...good, timeoutMilliseconds: LIMITS.relayMaxTimeoutMilliseconds + 1 }); + const longRequest = await nextRequest(host, RELAY_INVOKE, taken); + assert.equal(longRequest.params.timeoutMilliseconds, LIMITS.relayMaxTimeoutMilliseconds + 1); + host.send({ id: longRequest.id, result: { value: null } }); + await long; + assert.deepEqual( + [LIMITS.relayDefaultTimeoutMilliseconds, LIMITS.relayMaxTimeoutMilliseconds, LIMITS.maxParentOperationIdBytes], + [60000, 600000, 128], + ); + await host.close(); +}); + +test('the wire params of a relayed call hold only the fields the contract defines', async () => { + const { backend, host } = await session(); + // The fixture case where a caller tries to choose who the callee thinks called. + const injection = relayCases(RELAY_INVOKE).find(({ params }) => 'caller' in params); + const { context, call } = splitInvoke(injection); + assert.deepEqual(call.caller, { pluginID: 'dev.lithe.fixture.other' }); + const calling = backend.host.functions.invoke(context, { ...call, unknown: true }); + const request = await nextRequest(host, RELAY_INVOKE); + assert.deepEqual(request.params, { pluginID: call.pluginID, functionID: call.functionID, context }); + host.send({ id: request.id, result: { value: null } }); + await calling; + await host.close(); +}); + +test('relay failures become HostError by code, by number alone, and with the provider details', async () => { + const { backend, host } = await session(); + const taken = new Set(); + const failWith = async (wire) => { + const calling = backend.host.functions.invoke(CONTEXT, PROVIDER); + const request = await nextRequest(host, RELAY_INVOKE, taken); + host.send({ id: request.id, error: wire }); + return calling.then(() => assert.fail('resolved'), (error) => error); + }; + assert.ok(fixture.relayErrors.length > 0); + for (const { name, code, rpcCode } of fixture.relayErrors) { + assert.equal(HOST_ERRORS[code], rpcCode, name); + const byCode = await failWith({ code: rpcCode, message: 'Refused.', data: { code } }); + const byNumber = await failWith({ code: rpcCode, message: 'Refused.' }); + for (const error of [byCode, byNumber]) { + assert.ok(error instanceof HostError && error instanceof PluginSdkError, name); + assert.deepEqual({ code: error.code, rpcCode: error.rpcCode, method: error.method }, { code, rpcCode, method: RELAY_INVOKE }, name); + } + } + + // The provider's own failure keeps its message and its code in the details. + const failed = await failWith({ + code: -32070, + message: 'The snapshot is locked.', + data: { code: 'plugin.function.failed', details: { pluginErrorCode: 'maven.snapshotLocked' } }, + }); + assert.equal(failed.code, 'plugin.function.failed'); + assert.equal(failed.message, 'The snapshot is locked.'); + assert.deepEqual(failed.details, { pluginErrorCode: 'maven.snapshotLocked' }); + await host.close(); +}); + +test('an aborted signal cancels a relayed call at Core, and an aborted one is never sent', async () => { + const { backend, host } = await session(); + const controller = new AbortController(); + const pending = backend.host.functions.invoke(CONTEXT, PROVIDER, { signal: controller.signal }); + const request = await nextRequest(host, RELAY_INVOKE); + controller.abort(new Error('user stopped')); + await assert.rejects(pending, /user stopped/); + const cancel = await host.waitFor( + (message) => message.method === '$/cancelRequest' && message.params?.id === request.id, + '$/cancelRequest', + ); + assert.deepEqual(cancel.params, { id: request.id }); + // Core's late answer is dropped. + host.send({ id: request.id, error: { code: -32800, message: 'Cancelled.', data: { code: 'plugin.host.cancelled' } } }); + const before = host.messages.length; + await assert.rejects(backend.host.functions.invoke(CONTEXT, PROVIDER, { signal: controller.signal }), /user stopped/); + assert.equal(host.messages.length, before); + await host.close(); +}); + +test('cancelling the served call cancels its relayed call', async () => { + const started = gate(); + let outcome; + const backend = createBackend().function('project.describe', async (_params, context) => { + const calling = context.host.functions.invoke(CONTEXT, PROVIDER); + started.open(); + outcome = await calling.then(() => 'resolved', (error) => error); + throw outcome; + }); + const { host } = await session(backend); + const call = host.request('lithe/function/invoke', { functionID: 'project.describe', operationID: 'c' }, 41); + await withTimeout(started.opened, 'the relayed call'); + const request = await nextRequest(host, RELAY_INVOKE); + assert.equal(request.params.parentOperationID, 'c'); + host.notify('$/cancelRequest', { id: 41 }); + assert.equal((await call).error.code, -32800); + assert.equal(outcome?.name, 'AbortError'); + const cancels = host.messages.filter((message) => message.method === '$/cancelRequest'); + assert.deepEqual(cancels.map((message) => message.params), [{ id: request.id }]); + // Core's late answer is dropped; the session continues. + host.send({ id: request.id, error: { code: -32800, message: 'Cancelled.', data: { code: 'plugin.host.cancelled' } } }); + await host.close(); +}); + +test('a relayed call holds one of the 16 host request slots until Core answers', async () => { + const { backend, host } = await session(); + const max = LIMITS.maxPendingHostRequests; + const calls = Array.from({ length: max + 2 }, () => backend.host.functions.invoke(CONTEXT, PROVIDER)); + // Settled before the session ends, so no rejection goes unobserved. + const settled = Promise.allSettled(calls); + const sent = () => host.messages.filter((message) => message.method === RELAY_INVOKE); + await host.waitFor(() => sent().length === max, 'the first 16 relayed calls'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(sent().length, max); + host.send({ id: sent()[0].id, result: { value: null } }); + await host.waitFor(() => sent().length === max + 1, 'the 17th relayed call'); + assert.equal(sent().length, max + 1); + // Ending the session fails the queued call too. + await host.close(); + const outcomes = await settled; + assert.equal(outcomes[0].status, 'fulfilled'); + assert.ok(outcomes.slice(1).every((outcome) => outcome.status === 'rejected' && outcome.reason.code === 'sdk.session.closed')); +}); diff --git a/shared/plugins/sdk/node/test/keymap.test.mjs b/shared/plugins/sdk/node/test/keymap.test.mjs new file mode 100644 index 00000000..29e1b130 --- /dev/null +++ b/shared/plugins/sdk/node/test/keymap.test.mjs @@ -0,0 +1,53 @@ +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: [0] }, + litheKeymapImport: { versions: [0], providers: ['first', 'unknown'] }, + }); + assert.deepEqual(result.capabilities.experimental.litheKeymapImport, { version: 0, 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: [] }] }] }); + // The terminal fact precedes the response and names the wire method Core accepts. + const facts = host.messages.filter((message) => message.method === 'lithe/log').map(({ params }) => [params.kind, params.operation]); + assert.deepEqual(facts, [['operationFinished', 'lithe/keymap/import']]); + 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: [0], 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/shared/plugins/sdk/node/test/sample-build.test.mjs b/shared/plugins/sdk/node/test/sample-build.test.mjs new file mode 100644 index 00000000..cc66be2c --- /dev/null +++ b/shared/plugins/sdk/node/test/sample-build.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { TestHost } from './harness.mjs'; + +const sampleBackend = fileURLToPath(new URL('../examples/sample-build/backend/main.mjs', import.meta.url)); + +/** Starts the sample backend on a workspace with two quick projects, as Lithe would. */ +async function startSample(t) { + const root = mkdtempSync(join(tmpdir(), 'lithe-sample-build-')); + const workspace = join(root, 'workspace'); + const state = join(root, 'state'); + const quick = ['node', '-e', 'process.exit(0)']; + for (const name of ['app', 'lib']) mkdirSync(join(workspace, name), { recursive: true }); + mkdirSync(state); + writeFileSync( + join(workspace, 'lithe-sample.json'), + JSON.stringify({ projects: [{ name: 'app', path: 'app', build: quick }, { name: 'lib', path: 'lib', build: quick }] }), + ); + const child = spawn(process.execPath, [sampleBackend, '--state', state], { stdio: 'pipe' }); + t.after(() => { + child.kill(); + rmSync(root, { recursive: true, force: true }); + }); + const host = new TestHost(child); + await host.initialize(undefined, pathToFileURL(workspace).href); + return host; +} + +function build(host, operationID, params) { + return host.request('lithe/function/invoke', { functionID: 'workspace.build', operationID, params }); +} + +test('overlapping workspace builds record consecutive snapshot generations', async (t) => { + const host = await startSample(t); + // Both calls are in flight before either tool finishes. + const [first, second] = await Promise.all([build(host, 'op-1', {}), build(host, 'op-2', {})]); + assert.deepEqual([first.result.generation, second.result.generation].sort(), [1, 2]); + const latest = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-3' }); + assert.equal(latest.result.generation, 2); + await host.close(); +}); + +test('a selection naming an unknown project fails instead of building nothing', async (t) => { + const host = await startSample(t); + for (const [operationID, projects, code] of [ + ['op-1', ['typo'], 'sample.unknownProject'], + ['op-2', ['app', 'typo'], 'sample.unknownProject'], + ['op-3', [], 'sample.selection'], + ['op-4', 'app', 'sample.selection'], + ]) { + const response = await build(host, operationID, { projects }); + assert.equal(response.error?.data?.code, code, JSON.stringify(projects)); + } + const snapshot = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-5' }); + assert.equal(snapshot.result, null); + const selected = await build(host, 'op-6', { projects: ['lib'] }); + assert.deepEqual(selected.result.projects, [{ name: 'lib', exitCode: 0 }]); + await host.close(); +}); diff --git a/shared/plugins/sdk/node/test/support.test.mjs b/shared/plugins/sdk/node/test/support.test.mjs new file mode 100644 index 00000000..0700b526 --- /dev/null +++ b/shared/plugins/sdk/node/test/support.test.mjs @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { encodeFrame, FrameDecoder, FramingError } from '../src/framing.mjs'; +import { createBackend, openStateStore, runProcess } from '../src/index.mjs'; +import { runningProcessCount } from '../src/process.mjs'; +import { gate, TestHost, WAIT_MILLISECONDS, withTimeout } from './harness.mjs'; + +function temporaryDirectory(t) { + const directory = mkdtempSync(join(tmpdir(), 'lithe-sdk-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +/** Bounded poll of an OS-level fact the test cannot be notified about. */ +async function eventually(check, description) { + const deadline = Date.now() + WAIT_MILLISECONDS; + while (!check()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${description}.`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +test('frames split across chunks, or several in one chunk, decode in order', () => { + const decoder = new FrameDecoder(); + const bytes = Buffer.concat([encodeFrame({ id: 1, text: 'é✓' }), encodeFrame({ id: 2 })]); + const messages = []; + for (let index = 0; index < bytes.length; index += 3) { + messages.push(...decoder.push(bytes.subarray(index, index + 3))); + } + assert.deepEqual(messages, [{ id: 1, text: 'é✓' }, { id: 2 }]); + assert.throws(() => new FrameDecoder().push(Buffer.from('Content-Length: 999999999999\r\n\r\n')), FramingError); + assert.throws(() => new FrameDecoder().push(Buffer.from('X-Other: 1\r\n\r\n')), FramingError); +}); + +test('runProcess captures output and resolves a non-zero exit', async () => { + const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)']); + assert.deepEqual( + { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, truncated: result.truncated }, + { exitCode: 3, stdout: 'out', stderr: 'err', truncated: false }, + ); + const limited = await runProcess(process.execPath, ['-e', 'process.stdout.write("x".repeat(100))'], { maxOutputBytes: 10 }); + assert.equal(limited.stdout, 'xxxxxxxxxx'); + assert.ok(limited.truncated); +}); + +test('cancelling a call ends the process tree it started', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { + const directory = temporaryDirectory(t); + const grandchildPid = join(directory, 'grandchild.pid'); + // The child starts a grandchild that ignores SIGTERM, so only the group's + // forced stop can end it. + const grandchild = `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(grandchildPid)}, String(process.pid)); setInterval(() => {}, 1000);`; + const child = `require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(grandchild)}], { stdio: "ignore" }); setInterval(() => {}, 1000);`; + const started = gate(); + const backend = createBackend().function('tool.run', async (_params, context) => { + const running = context.run(process.execPath, ['-e', child], { killGraceMilliseconds: 50 }); + started.open(); + return running; + }); + const host = new TestHost(backend); + await host.initialize(); + const response = host.request('lithe/function/invoke', { functionID: 'tool.run', operationID: 'g' }, 7); + await withTimeout(started.opened, 'the tool to start'); + await eventually(() => existsSync(grandchildPid) && readFileSync(grandchildPid, 'utf8').length > 0, 'the grandchild'); + const pid = Number(readFileSync(grandchildPid, 'utf8')); + host.notify('$/cancelRequest', { id: 7 }); + assert.equal((await response).error.code, -32800); + await eventually(() => !isAlive(pid), 'the grandchild to be stopped'); + await eventually(() => runningProcessCount() === 0, 'the registry to drain'); + await host.close(); +}); + +test('closing the session ends tools still running', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { + const directory = temporaryDirectory(t); + const childPid = join(directory, 'child.pid'); + const started = gate(); + const backend = createBackend().function('tool.run', (_params, context) => { + const running = context.run(process.execPath, [ + '-e', + `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(childPid)}, String(process.pid)); setInterval(() => {}, 1000);`, + ]); + started.open(); + return running; + }); + const host = new TestHost(backend); + await host.initialize(); + // No reply is awaited: the session ends while the call is still running. + host.send({ id: 8, method: 'lithe/function/invoke', params: { functionID: 'tool.run', operationID: 'h' } }); + await withTimeout(started.opened, 'the tool to start'); + await eventually(() => existsSync(childPid) && readFileSync(childPid, 'utf8').length > 0, 'the child'); + const pid = Number(readFileSync(childPid, 'utf8')); + assert.equal(await host.close(), 1); + await eventually(() => !isAlive(pid), 'the child to be killed'); +}); + +test('the state store writes atomically and survives reopening', (t) => { + const directory = join(temporaryDirectory(t), 'workspace-state'); + const store = openStateStore(directory, { packageDirectory: null }); + assert.equal(store.read('snapshot', 'none'), 'none'); + store.write('snapshot', { generation: 1 }); + store.write('snapshot', { generation: 2 }); + assert.deepEqual(openStateStore(directory, { packageDirectory: null }).read('snapshot'), { generation: 2 }); + assert.deepEqual(readdirSync(directory), ['snapshot.json']); + writeFileSync(join(directory, 'broken.json'), '{'); + assert.equal(store.read('broken', null), null); + store.delete('snapshot'); + store.delete('snapshot'); + assert.equal(store.read('snapshot'), undefined); + for (const key of ['', '../escape', 'a/b', 'x.tmp', '.hidden']) { + assert.throws(() => store.write(key, 1), (error) => error.code === 'sdk.state.invalidKey', key); + } +}); + +test('the state store refuses the read-only plugin package', (t) => { + const root = temporaryDirectory(t); + const packageDirectory = join(root, 'package'); + mkdirSync(packageDirectory); + writeFileSync(join(packageDirectory, 'plugin.json'), '{}'); + assert.throws( + () => openStateStore(join(packageDirectory, 'state'), { packageDirectory }), + (error) => error.code === 'sdk.state.readOnlyPackage', + ); + // Refused before anything was created inside the package. + assert.ok(!existsSync(join(packageDirectory, 'state'))); + assert.throws(() => openStateStore('relative/state'), (error) => error.code === 'sdk.state.invalidDirectory'); + assert.ok(openStateStore(join(root, 'state'), { packageDirectory })); +}); + +test('the type declarations name exactly the runtime exports', async () => { + const runtime = Object.keys(await import('../src/index.mjs')).sort(); + const declarations = readFileSync(new URL('../src/index.d.ts', import.meta.url), 'utf8'); + const declared = [...declarations.matchAll(/^export (?:function|class|const) (\w+)/gm)].map((match) => match[1]); + assert.deepEqual([...new Set(declared)].sort(), runtime); +}); + +const posixOnly = { skip: process.platform === 'win32' && 'POSIX process groups' }; + +/** A Node one-liner that records its PID in `file` and runs until stopped. */ +function recordsPidAndWaits(file) { + return `require("fs").writeFileSync(${JSON.stringify(file)}, String(process.pid)); setInterval(() => {}, 1000);`; +} + +/** Every PID a test tool recorded, so cleanup still finds it after the temporary directory is gone. */ +const recordedPids = new Set(); + +function recordedPid(file) { + if (!existsSync(file) || readFileSync(file, 'utf8').length === 0) return undefined; + const pid = Number(readFileSync(file, 'utf8')); + recordedPids.add(pid); + return pid; +} + +/** Kills recorded tools still alive, so a failed assertion leaves nothing behind. */ +function killRecordedOnExit(t) { + t.after(() => { + for (const pid of recordedPids) { + if (isAlive(pid)) process.kill(pid, 'SIGKILL'); + } + }); +} + +test('a call that settles stops the tools its handler left running, and only those', posixOnly, async (t) => { + const directory = temporaryDirectory(t); + const failedSibling = join(directory, 'failed-sibling.pid'); + const floating = join(directory, 'floating.pid'); + const other = join(directory, 'other.pid'); + killRecordedOnExit(t); + const startTool = (context, file) => + context.run(process.execPath, ['-e', recordsPidAndWaits(file)], { killGraceMilliseconds: 50 }); + const backend = createBackend() + .function('parallel.fail', async (_params, context) => { + const slow = startTool(context, failedSibling); + await eventually(() => recordedPid(failedSibling) !== undefined, 'the sibling tool'); + await Promise.all([slow, context.run(join(directory, 'missing-tool'))]); + }) + .function('floating.succeed', async (_params, context) => { + startTool(context, floating); + await eventually(() => recordedPid(floating) !== undefined, 'the floating tool'); + return 'done'; + }) + .function('other.run', (_params, context) => startTool(context, other)); + const host = new TestHost(backend); + await host.initialize(); + const otherCall = host.request('lithe/function/invoke', { functionID: 'other.run', operationID: 'o' }, 40); + await eventually(() => recordedPid(other) !== undefined, 'the other call’s tool'); + + const failed = await host.request('lithe/function/invoke', { functionID: 'parallel.fail', operationID: 'f' }); + assert.equal(failed.error?.code, -32603); + await eventually(() => !isAlive(recordedPid(failedSibling)), 'the failed call’s sibling tool to stop'); + const succeeded = await host.request('lithe/function/invoke', { functionID: 'floating.succeed', operationID: 's' }); + assert.equal(succeeded.result, 'done'); + await eventually(() => !isAlive(recordedPid(floating)), 'the unawaited tool to stop'); + + // Another call's tool was not touched; it ends with its own call. + assert.ok(isAlive(recordedPid(other))); + host.notify('$/cancelRequest', { id: 40 }); + assert.equal((await otherCall).error.code, -32800); + await eventually(() => runningProcessCount() === 0, 'the registry to drain'); + assert.equal(await host.close(), 1); +}); + +test('a tool that exits normally also ends the workers it left in its group', posixOnly, async (t) => { + const directory = temporaryDirectory(t); + const worker = join(directory, 'worker.pid'); + killRecordedOnExit(t); + // The leader starts a background worker in its own group, waits until it + // runs, and exits 0 without stopping it. + const leader = ` + const { existsSync, readFileSync } = require("fs"); + const child = require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(recordsPidAndWaits(worker))}], { stdio: "ignore" }); + child.unref(); + const deadline = Date.now() + 5000; + const wait = () => existsSync(${JSON.stringify(worker)}) && readFileSync(${JSON.stringify(worker)}, "utf8") ? process.exit(0) + : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); + wait();`; + const result = await runProcess(process.execPath, ['-e', leader]); + assert.equal(result.exitCode, 0); + assert.equal(runningProcessCount(), 0); + await eventually(() => !isAlive(recordedPid(worker)), 'the worker to stop'); +}); + + +test('streamed output keeps a character split across chunks whole', async (t) => { + const directory = temporaryDirectory(t); + const released = join(directory, 'released'); + // The child writes "a" and the first byte of "中", waits until the parent + // saw that chunk, then writes the other two bytes, so "中" spans two chunks. + const child = ` + const { existsSync } = require("fs"); + process.stdout.write(Buffer.from([0x61, 0xe4])); + const deadline = Date.now() + 5000; + const wait = () => existsSync(${JSON.stringify(released)}) ? process.stdout.write(Buffer.from([0xb8, 0xad])) + : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); + wait();`; + const streamed = []; + const result = await runProcess(process.execPath, ['-e', child], { + onStdout: (text) => { + streamed.push(text); + writeFileSync(released, ''); + }, + }); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, 'a中'); + assert.deepEqual(streamed, ['a', '中']); +}); + +test('the state store refuses a value JSON would drop and keeps the stored one', (t) => { + const store = openStateStore(join(temporaryDirectory(t), 'state'), { packageDirectory: null }); + store.write('snapshot', { generation: 8 }); + for (const value of [undefined, () => 1, Symbol('value'), 1n]) { + assert.throws(() => store.write('snapshot', value), (error) => error.code === 'sdk.state.invalidValue' || error instanceof TypeError); + } + assert.deepEqual(store.read('snapshot'), { generation: 8 }); +}); + +test('a header over 8 KiB is refused also when the whole frame arrives at once', () => { + const header = `Content-Length: 2\r\nX-Padding: ${'x'.repeat(9000)}\r\n\r\n{}`; + assert.throws(() => new FrameDecoder().push(Buffer.from(header)), FramingError); +}); + +test('a JSON value that is not a message object is refused and the session continues', async () => { + const host = new TestHost(createBackend().function('ping', () => 'pong')); + await host.initialize(); + host.input.write(encodeFrame(null)); + const refused = await host.waitFor((message) => message.id === null && message.error, 'the refusal'); + assert.equal(refused.error.code, -32600); + const answered = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'p' }); + assert.equal(answered.result, 'pong'); + await host.close(); +}); + +test('a shutdown during a slow initialize hook keeps the session closed', async () => { + const hook = gate(); + const backend = createBackend() + .function('ping', () => 'pong') + .onInitialize(() => hook.opened); + const host = new TestHost(backend); + const initializing = host.request('initialize', { + processId: null, + rootUri: 'file:///workspace', + capabilities: { experimental: { litheLogging: { versions: [0] }, litheFunctions: { versions: [0] } } }, + }); + const early = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'early' }); + assert.equal(early.error.code, -32002); + assert.equal((await host.request('shutdown')).result, null); + hook.open(); + assert.equal((await initializing).error?.code, -32600); + const late = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'late' }); + assert.equal(late.error.code, -32600); + host.notify('exit'); + assert.equal(await withTimeout(host.done, 'the backend to exit'), 0); +}); + +test('a replaced environment still carries the host process markers', async (t) => { + const saved = { scope: process.env.LITHE_PROCESS_SCOPE, backend: process.env.LITHE_PLUGIN_BACKEND_SCOPE }; + t.after(() => { + for (const [name, value] of [['LITHE_PROCESS_SCOPE', saved.scope], ['LITHE_PLUGIN_BACKEND_SCOPE', saved.backend]]) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + process.env.LITHE_PROCESS_SCOPE = 'instance-1'; + process.env.LITHE_PLUGIN_BACKEND_SCOPE = 'backend-1'; + const print = 'process.stdout.write(JSON.stringify([process.env.LITHE_PROCESS_SCOPE, process.env.LITHE_PLUGIN_BACKEND_SCOPE, process.env.PLUGIN_VALUE]))'; + // A plugin can neither drop nor forge the markers. + const result = await runProcess(process.execPath, ['-e', print], { + env: { PLUGIN_VALUE: 'kept', LITHE_PLUGIN_BACKEND_SCOPE: 'forged' }, + }); + assert.deepEqual(JSON.parse(result.stdout), ['instance-1', 'backend-1', 'kept']); +}); diff --git a/shared/plugins/sdk/node/test/types/build.mts b/shared/plugins/sdk/node/test/types/build.mts new file mode 100644 index 00000000..dfa0ffdf --- /dev/null +++ b/shared/plugins/sdk/node/test/types/build.mts @@ -0,0 +1,64 @@ +// Check the published package's exports, including contextual return types. +import { createBackend, type BuildContext, type BuildRequestParams } from '@lithe/plugin-sdk'; + +const backend = createBackend(); +const version: number | null = backend.buildVersion; +void version; +backend.build.execute((params, context) => { + const task: string = params.task; + const operation: string = context.operationID; + context.progress({ message: task, output: operation, percentage: 50 }); + return { exitCode: 0, output: params.userArguments.join(' ') }; +}); +backend.build.test(async (params, context) => { + const suite: string | undefined = params.suite; + context.progress({ output: suite }); + return { exitCode: 2, output: 'test failure' }; +}); +backend.build.discoverTests(async (params: BuildRequestParams) => ({ + tests: [{ suite: 'Suite', case: 'exact # [case]', project: { name: 'app', path: params.root } }], +})); +backend.build.prepareRun(async (params, context: BuildContext) => { + context.throwIfCancelled(); + return { executable: { kind: 'toolchain', value: 'project-jdk' }, + arguments: ['Main', ...params.userArguments], workingDirectory: params.project.path, environment: {} }; +}); +backend.build.model((_params, context) => { + // @ts-expect-error Model requests have no bounded execution progress. + context.progress({ message: 'invalid' }); + return { projects: [] }; +}); +// @ts-expect-error The exit code is a number, not a shell/string result. +backend.build.execute(() => ({ exitCode: '0', output: '' })); +// @ts-expect-error Every discovered selector needs an exact case. +backend.build.discoverTests(() => ({ tests: [{ suite: 'incomplete' }] })); +// @ts-expect-error Arbitrary executable kinds are not portable launch plans. +backend.build.prepareRun(() => ({ executable: { kind: 'absolute', value: '/fixture/tool' }, + arguments: [], workingDirectory: '.', environment: {} })); + +backend.build.prepareDebug((params, context) => { + context.progress({ output: params.run }); + return { program: 'app/bin/main', arguments: params.userArguments, + workingDirectory: params.project.path, environment: {} }; +}); +// @ts-expect-error A native debug target requires a program path. +backend.build.prepareDebug(() => ({ arguments: [], workingDirectory: '.', environment: {} })); + +backend.build.model((params) => { + for (const slot of params.modelInputs?.inputs ?? []) { + const producer: string = slot.pluginID; + void producer; + for (const instance of slot.instances) { + if (instance.state === 'ready') { + const projects: string[] = instance.model.projects.map((project) => project.id); + void projects; + // @ts-expect-error Public inputs never carry executable tasks. + void instance.model.tasks; + } else { + // @ts-expect-error Old facts are absent while inputs are stale/loading/failed. + void instance.model; + } + } + } + return { projects: [] }; +}); diff --git a/shared/plugins/sdk/node/test/types/host.mts b/shared/plugins/sdk/node/test/types/host.mts new file mode 100644 index 00000000..2d91a997 --- /dev/null +++ b/shared/plugins/sdk/node/test/types/host.mts @@ -0,0 +1,139 @@ +// Check the host-service declarations the way a plugin uses them. +import { createBackend, HOST_ERRORS, HostError, type ContextReference, type JsonValue, type Subscription } from '@lithe/plugin-sdk'; + +const backend = createBackend(); +const version: number | null = backend.functionsVersion; +void version; + +backend.function('query.run', async (_params, context) => { + const secret: string | null = await context.host.credentials.get('connection.prod'); + await context.host.credentials.set('connection.prod', secret ?? ''); + const workspace: ContextReference | null = context.workspaceContext; + if (!workspace) return null; + const { folders } = await context.host.workspace.getFolders(workspace); + const root = folders[0]; + if (!root) return null; + const file = await context.host.workspace.readFile(workspace, { resource: { rootID: root.rootID, path: 'README.md' }, maximumBytes: 4096 }); + const truncated: boolean = file.isTruncated; + const { selectedIndex } = await context.host.window.showInformation(workspace, { text: 'Done', actions: ['Open'] }); + const choice: number | null = selectedIndex; + return { truncated, choice }; +}); + +backend.function('project.describe', async (_params, context) => { + // The calling plugin of a relayed call, or null. + const caller: { readonly pluginID: string } | null = context.caller; + const callerID: string | undefined = caller?.pluginID; + void callerID; + if (caller) { + // @ts-expect-error The caller is read-only. + caller.pluginID = 'dev.example.other'; + } + const workspace = context.workspaceContext; + if (!workspace) return null; + const { providers } = await context.host.functions.providers('languageImport.settings'); + const provider: { pluginID: string; version: number } | undefined = providers[0]; + if (!provider) return null; + const { value } = await context.host.functions.invoke(workspace, { + pluginID: provider.pluginID, + functionID: 'languageImport.settings', + params: { project: 'app' }, + timeoutMilliseconds: 5000, + parentOperationID: 'op-1', + }, { signal: new AbortController().signal }); + const result: JsonValue = value; + // The minimum a call needs is the provider and the function. + await backend.host.functions.invoke(workspace, { pluginID: provider.pluginID, functionID: 'languageImport.settings' }); + // @ts-expect-error A relayed call names the plugin that provides the function. + await context.host.functions.invoke(workspace, { functionID: 'languageImport.settings' }); + // @ts-expect-error A relayed call's params are an object. + await context.host.functions.invoke(workspace, { pluginID: provider.pluginID, functionID: 'languageImport.settings', params: 'x' }); + // @ts-expect-error A parent operation ID is a string. + await context.host.functions.invoke(workspace, { pluginID: provider.pluginID, functionID: 'languageImport.settings', parentOperationID: 17 }); + // @ts-expect-error A relayed call takes the workspace context first. + await context.host.functions.invoke({ pluginID: provider.pluginID, functionID: 'languageImport.settings' }); + // @ts-expect-error The providers of a function are asked by its function ID. + await context.host.functions.providers({ functionID: 'languageImport.settings' }); + return result; +}); + +backend.onCommand('db.refresh', async ({ commandID, workspaceContext }) => { + const id: string = commandID; + void id; + if (!workspaceContext) return; + if (await backend.host.supports('lithe/host/workspace/watch')) { + const subscription: Subscription = await backend.host.workspace.watch( + workspaceContext, + { resource: { rootID: 'r1', path: '' }, recursive: true }, + (event, { sequence, gap }) => { + const next: number = sequence; + const missed: boolean = gap; + void event; void next; void missed; + }, + ); + await subscription.dispose(); + } +}); + +backend.onContextsChanged(async ({ revision }) => { + const { contexts } = await backend.host.workspace.listContexts(); + const authority: 'local' | 'remote' = contexts[0]!.authority.kind; + void revision; void authority; +}); + +async function edit(context: ContextReference) { + try { + const result = await backend.host.editor.applyEdits(context, { + documentID: 'd1', + expectedVersion: 3, + edits: [{ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } }, text: 'x' }], + }); + const applied: boolean = result.applied; + void applied; + } catch (error) { + if (error instanceof HostError && error.rpcCode === HOST_ERRORS['plugin.host.versionConflict']) { + const details = error.details; + void details; + } + } + // @ts-expect-error An edit needs the version it was computed against. + await backend.host.editor.applyEdits(context, { documentID: 'd1', edits: [] }); + // @ts-expect-error A credential secret is a string. + await backend.host.credentials.set('key', 42); +} +void edit; + +async function relayFailure(context: ContextReference) { + const failedNumber: -32070 = HOST_ERRORS['plugin.function.failed']; + const cycleNumber: -32065 = HOST_ERRORS['plugin.function.callCycle']; + void failedNumber; void cycleNumber; + try { + await backend.host.functions.invoke(context, { pluginID: 'dev.example.go', functionID: 'languageImport.settings' }); + } catch (error) { + if (error instanceof HostError && error.code === 'plugin.function.failed') { + // The provider's own error code, set by the provider's function. + const pluginErrorCode: JsonValue | undefined = error.details?.pluginErrorCode; + void pluginErrorCode; + } + } + // @ts-expect-error Only the codes in the contract are keys of HOST_ERRORS. + void HOST_ERRORS['plugin.function.unknown']; +} +void relayFailure; + +async function nativeTerminal(context: ContextReference) { + const created = await backend.host.terminal.create(context, { + executable: 'example-client', arguments: ['--interactive'], environment: { MODE: 'dev', OLD_MODE: null }, + }); + await backend.host.terminal.show(context, { terminalID: created.terminalID }); + const subscription = await backend.host.terminal.onExit(context, created.terminalID, (event, info) => { + const code: number | null = event.exitCode; + const reason: 'exited' | 'closed' = event.reason; + void code; void reason; void info; + }); + await subscription.dispose(); + await backend.host.terminal.close(context, { terminalID: created.terminalID, retainedContextBytes: 4096 }); + await backend.host.terminal.reopen(context, { terminalID: created.terminalID }); + await backend.host.terminal.dispose(context, { terminalID: created.terminalID }); +} +void nativeTerminal; diff --git a/shared/plugins/sdk/node/test/types/keymap.mts b/shared/plugins/sdk/node/test/types/keymap.mts new file mode 100644 index 00000000..32545758 --- /dev/null +++ b/shared/plugins/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/shared/plugins/sdk/node/test/types/tsconfig.json b/shared/plugins/sdk/node/test/types/tsconfig.json new file mode 100644 index 00000000..da86e3e4 --- /dev/null +++ b/shared/plugins/sdk/node/test/types/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "types": ["node"] + }, + "files": ["build.mts", "host.mts"] +} diff --git a/shared/plugins/sdk/node/test/vcs.test.mjs b/shared/plugins/sdk/node/test/vcs.test.mjs new file mode 100644 index 00000000..dc74f4dc --- /dev/null +++ b/shared/plugins/sdk/node/test/vcs.test.mjs @@ -0,0 +1,167 @@ +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 Core's +// rust/fixtures/vcs/model-v1.json (repository root), which is not part of the +// plugin contract; 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: [0] } }; +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: 0 } } } }), + 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: 0, + vcs: 'svn', + capabilities: { baseContent: 0, checkin: 0, conflicts: 0, fileOperations: 0, history: 0, rollback: 0, status: 0 }, + }); + 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: 0, checkin: 0, push: 0, rollback: 0, status: 0 }); + 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: [1] } }); + assert.deepEqual(result.capabilities.experimental, { litheLogging: { version: 0 } }); + 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(); +}); diff --git a/shared/plugins/sdk/rust/Cargo.lock b/shared/plugins/sdk/rust/Cargo.lock new file mode 100644 index 00000000..57d08c5f --- /dev/null +++ b/shared/plugins/sdk/rust/Cargo.lock @@ -0,0 +1,120 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lithe-plugin-sdk" +version = "0.4.0" +dependencies = [ + "base64", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sample-backend" +version = "0.4.0" +dependencies = [ + "lithe-plugin-sdk", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/shared/plugins/sdk/rust/Cargo.toml b/shared/plugins/sdk/rust/Cargo.toml new file mode 100644 index 00000000..9d60b189 --- /dev/null +++ b/shared/plugins/sdk/rust/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +members = ["plugin-sdk", "examples/sample-backend"] +resolver = "2" + +[workspace.package] +version = "0.4.0" +edition = "2021" +license = "Apache-2.0" +# std::io::pipe, used by the tests. +rust-version = "1.87" + +[workspace.dependencies] +serde_json = "1" diff --git a/shared/plugins/sdk/rust/README.md b/shared/plugins/sdk/rust/README.md new file mode 100644 index 00000000..a4d532e0 --- /dev/null +++ b/shared/plugins/sdk/rust/README.md @@ -0,0 +1,452 @@ +# Lithe plugin backend SDK for Rust + +`lithe-plugin-sdk` is the official way to write a +[plugin backend](../../contract/v1/README.md#plugin-backend) in Rust: the one +long-lived process a plugin declares under `backend` in `plugin.json`, which +Lithe starts on demand (a page's first call, a command, workspace open; see +[Backend lifecycle and workspace contexts](../../contract/v1/README.md#backend-lifecycle-and-workspace-contexts)). +The backend is a packaged executable, so a plugin ships one archive per +platform. It speaks the LSP base protocol on stdio and implements, from the +[plugin contract](../../contract/v1/README.md), which lives in this repository +next to the SDK and changes with it: + +- **`litheFunctions`**: plugin functions with progress, cancellation, and + registration at runtime. +- **Host services**: the plugin's own credentials, kept by the host in the + platform's secret store, workspace contexts, files, + documents, editors, preview, the browser, window messages, commands, and + subscriptions, and [calls to the functions other plugins export](#calling-other-plugins), + relayed by Core, with the `Caller` a function called that way sees. +- **`litheKeymapImport`**: independently negotiated shortcut import providers + reading only explicitly selected source snapshots. +- **`litheLogging`**: typed diagnostics, required at initialization; see + [Logging](#logging). + +The SDK owns the wire format, negotiation, cancellation, and error mapping. +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. + +This SDK has no build provider or VCS provider API; write build providers with +the [Node.js](../node/README.md) or [Java](../java/README.md) SDK, and VCS +providers with the Node.js SDK. + +## What the Lithe desktop serves + +The Lithe desktop declares `backend` and `pluginFunctions` (both version 0), so +the function and host-service protocols below run there. It does not declare +`keymapImportProviders`: a plugin whose manifest requires it is not offered for +installation there, although the Core end-to-end tests still drive the protocol. +Which host services a session may call is reported at run time; see +[Host services](#host-services). + +## Shortcut import providers + +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. + +## A minimal backend + +```json +"pluginAPIVersion": 0, +"requires": { "backend": 0, "ui": 0 }, +"permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], +"backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] } +``` + +```rust +use lithe_plugin_sdk::{Backend, PluginError}; +use serde_json::json; + +fn main() { + let mut backend = Backend::new("example-backend").version(env!("CARGO_PKG_VERSION")); + backend + .function("connection.test", |params, context| { + context.progress("Connecting", Some(10)); + let key = params["connection"].as_str().ok_or_else(|| { + PluginError::new("example.invalidParams", "\"connection\" must be a string.") + })?; + let password = context.credentials().get(&format!("connection:{key}"))?; + context.check_cancelled()?; + Ok(json!({ "hasPassword": password.is_some() })) + }) + .expect("a valid function ID"); + std::process::exit(backend.listen()); +} +``` + +The page calls it through the host bridge, without an `instance`: + +```js +const { value } = await lithe.request('functions.invoke', { function: 'connection.test', params: { connection: 'main' } }); +``` + +[`examples/sample-backend`](examples/sample-backend) is a complete plugin: +the manifest, a web page, notes kept in `${stateDir}` with progress, a +function registered at runtime, a secret in the credential store, +cancellation, a watch on the page's workspace, a command answered with a +message in its window, and a handler for workspace changes. +From the root of this repository, +`node shared/plugins/scripts/sdk-e2e.mjs` drives it through a real Core session +with the scenario in +[`sdk/e2e/plugin_sdk/backend.rs`](../e2e/plugin_sdk/backend.rs). + +## Getting the SDK + +The SDK is released from this repository, tagged `sdk-rust-v`; the +current release is +[0.4.0](https://github.com/Lithe-IDEA/Lithe-core-rust/releases/tag/sdk-rust-v0.4.0). +The SDK is not published to crates.io. Either way below fetches it at build +time, never at runtime, and pins its exact contents: + +- **Git dependency.** Name the tag; `Cargo.lock` records the commit, and + `cargo build --locked` refuses anything else: + + ```toml + [dependencies] + lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/Lithe-core-rust", tag = "sdk-rust-v0.4.0" } + ``` + +- **Vendored archive.** A release ships `lithe-plugin-sdk-rust-.crate` + (a gzipped tarball, as `cargo package` makes it) and a `.sha256` file. + Check the archive against the SHA-256 you pinned, unpack it into your + repository, and use it as a `path` dependency. + +## Logging + +The SDK requires `experimental.litheLogging` at initialization and refuses a +host that does not offer it. +See [plugin logging](../../guides/plugin-logging.md) for the typed diagnostics +API, host-owned catalog, exact levels, operation correlation and `${logDir}`. +Use `diagnostics().degraded/recovered` for handled fallback or recovery facts. +Function terminal facts are automatic; do not log a failure before returning +it to the SDK. Suppress successful high-frequency reads with +`suppress_success_diagnostics`. There is no free-text `log` API, no stderr +fallback and no plugin-owned file writer. + +## API + +| API | Purpose | +| --- | --- | +| `Backend::new(name).version(version)` | Creates the backend; `name` and `version` are reported as `serverInfo`, and `name` prefixes log lines. | +| `backend.function(id, handler)` | Declares a function offered in `initialize`. | +| `backend.on_initialize(hook)` | Runs before the `initialize` answer with an `Initialize`: the host's `params()`, `functions_version()` (`Some(0)` when the host offered `litheFunctions`, otherwise `None`), `root_path()` (the plugin's `${stateDir}`), and `function(id, handler)` to add functions that depend on state found at startup. An error refuses `initialize`. | +| `backend.on_shutdown(hook)` | Runs on `shutdown`, after running calls were cancelled. | +| `backend.handle()` | A clonable `BackendHandle` for other threads. | +| `backend.listen()` | Serves stdio until `exit` or the end of input and returns the exit code: `0` after `shutdown` and `exit`, `1` otherwise. | +| `backend.serve(input, output)` | The same on other streams, for tests. | +| `handle.register_function(id, handler)`, `handle.unregister_function(id)` | Adds or withdraws a function after `initialize` and waits until the host accepted it. Withdrawing revokes and cancels running calls; a handler must not withdraw its own function. | +| `handle.function_ids()`, `handle.functions_version()` | What the backend serves, and `Some(0)` when the host offered `litheFunctions` (otherwise `None`). | +| `backend.on_command(id, handler)` | Runs `Fn(&str, Option)` when the user runs one of the plugin's commands. | +| `backend.on_contexts_changed(handler)`, `backend.on_host_notification(method, handler)` | Handle `lithe/host/workspace/didChangeContexts` and any other `lithe/host/*` notification a later host adds. | +| `handle.host()`, `context.host()` | The [host services](#host-services). `handle.host()` is bound to no call. | +| `handle.credentials()`, `context.credentials()` | Shorthand for `host().credentials()`. | +| `PluginError::new(code, message)` | A failure meant for your own UI. | + +A handler is `Fn(Value, &Context) -> Result`. Its +context offers: + +| Member | Meaning | +| --- | --- | +| `cancellation()` | A `Cancellation`, fired when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends: `is_cancelled()`, `check()`, `wait_timeout(duration)` (use it instead of `thread::sleep`), and `on_cancel(callback)`, for example to cancel a running database query from the driver. | +| `check_cancelled()` | `Err(Cancelled)` once cancelled, for `?`. | +| `progress(message, percentage)` | The message is cut to 1024 bytes and the percentage to 100; reports after the call ended are dropped. | +| `host()` | Host services bound to the call: a request still waiting for the host when the call is cancelled ends at once. `credentials()` is `host().credentials()`. | +| `workspace_context()` | The `WorkspaceContext` of the window whose page made the call; `None` when no window made it. | +| `caller()` | The `Caller` (`plugin_id()`) of the plugin whose backend called this function through Core's relay; `None` when the host started the call and when the host's `caller` is not exactly `{ pluginID }` with a non-empty string. | +| `backend()`, `function_id()`, `diagnostics()` | The handle, the function's ID, and typed diagnostics correlated with this call. | + +## Host services + +A backend may ask the host for services; each is granted by a manifest +permission and refused otherwise. They are available when the host offers +`litheFunctions` in `initialize`; without it, host requests fail locally with +`plugin.host.unavailable` and send nothing. Every method blocks its thread +until the host answers and returns `Result<_, SdkError>`; wrong arguments fail +locally and send nothing. + +```rust +backend + .function("query.run", |params, context| { + // Permission credentials.store: the platform's secret store, per plugin. + let password = context + .host() + .credentials() + .get("connection.prod:password")? + .ok_or_else(|| PluginError::new("query.noPassword", "Sign in first."))?; + // The window the page runs in. + let mut folders = None; + if let Some(workspace) = context.workspace_context() { + if context.host().supports("lithe/host/workspace/getFolders")? { + folders = Some(context.host().workspace().get_folders(workspace)?); + } + } + run_query(¶ms, &password, folders) + }) + .expect("a valid function ID"); + +let host = backend.handle().host(); +backend + .on_command("db.refresh", move |_command_id, workspace| { + if let Some(workspace) = workspace { + host.window() + .show_information(&workspace, json!({ "text": "Refreshed." }))?; + } + Ok(()) + }) + .expect("a valid command ID"); +``` + +| `Host` member | Purpose | +| --- | --- | +| `credentials().get(key)`, `.set(key, secret)`, `.delete(key)` | Secrets in the platform's store; see [Credentials](#credentials). | +| `capabilities()`, `supports(method)` | What this session may call, from `lithe/host/getCapabilities` (asked once). | +| `workspace().list_contexts()`, `.get_context(&context)`, `.get_folders(&context)` | Workspace contexts; only `get_context` returns, and creates, the context's `stateDirectory`. | +| `workspace().read_directory`, `.stat`, `.read_file`, `.find_files`, `.find_text`, `.watch` | Files of a local context, addressed as `{ rootID, path }` from `get_folders`. | +| `documents().list`, `.get_snapshot`; `editor().get_active`, `.get_selections`, `.apply_edits`, `.replace_selections`, `.insert_text`, `.open`, `.reveal`, `.register_actions` | Live documents and editors. Edits name the `expectedVersion` they were computed against. | +| `preview().open_html`, `browser().open_external`, `window().show_information`, `.show_warning`, `.show_error` | Preview, browser, and messages. | +| `functions().providers(function_id)`, `.invoke(&context, call)` | The plugins that export a function, and a call to one of them; see [Calling other plugins](#calling-other-plugins). | +| `request(method, params)`, `subscribe(method, params)` | Any `lithe/host/*` method, including ones a later host adds. | +| `subscriptions().dispose(id)` | Ends a subscription; `Subscription::dispose()` does the same. | +| `with_cancellation(&cancellation)` | The same services, cancelled together with `cancellation`. | +| `negotiated()` | `true` when the host offered `litheFunctions` in this session; `false` before `initialize`. | + +A host registers only the services it implements, and `capabilities()` lists +exactly those; call `supports(method)` before depending on one of them. What +the Lithe desktop does not register yet is listed under +[Capability versions](../../reference/capabilities-and-versions.md#capability-versions-requires). + +Methods taking a workspace context take a `&WorkspaceContext`: one from +`context.workspace_context()`, `WorkspaceContext::new(id, generation)`, or +`WorkspaceContext::from_value` on a whole context from `list_contexts`. Their +`params` are the contract's, without `context`, and they return the +contract's result as a `Value`. The full params and results are in +[the contract](../../contract/v1/README.md#public-document-file-and-window-methods). + +What the SDK does for you, and what it leaves to you: + +- **Refusals keep the host's code.** Branch on `code()`, such as + `plugin.host.contextExpired`; `rpc_code()` is the JSON-RPC number and + `details()` the host's object, if any. A code this SDK does not know is + kept as sent. On `versionConflict` reread and recompute; on + `contextExpired` resolve the original target again, never the focused + window; retry `busy` and `rateLimited` (after the `retryAfterMilliseconds` detail) with a + bound; `permissionDenied` and `readOnly` need the user. An unknown param is + `plugin.host.unsupportedOption` naming it in the `option` detail, so you can + retry without it. +- **Cancellation has no rollback.** Cancelling the token given to + `with_cancellation`, or ending the call whose `context.host()` sent a + request, fails it at once with `sdk.call.cancelled`; the SDK also sends + `$/cancelRequest` so the host stops. An edit or an external open may + already have taken effect, and the SDK never sends a request again. +- **At most 16 host requests are unanswered.** Further ones wait in the SDK + for a free slot instead of being refused. +- **Subscriptions route by ID.** `workspace().watch`, + `editor().register_actions`, and `subscribe` return a `Subscription`; + `recv()` and `recv_timeout(duration)` give each `Event` in order, with + `sequence()`, `event()`, and `gap()`, which is true when events were + missed, so rescan. Events the host sends before its answer are kept. No + event is delivered after `dispose()` or the end of the session; once the + queued ones are read, `recv()` returns `None`. +- **Commands are events.** `on_command` runs on a thread of its own when the + user runs one of the plugin's commands, with the window's + `WorkspaceContext` when the host sent one. The host waits for no result, + and a failure is only logged. A stopped backend is started for it only + when the manifest declares `onCommand:`. Host notifications + without a handler are ignored. + +### Credentials + +A key is 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`; a secret is at +most 16 KiB. Credentials are private to your plugin ID, survive disabling and +updating the plugin, and are deleted when it is uninstalled. They are never +written to `${stateDir}` or the package. `get` returns `Ok(None)` when +nothing is stored. + +| Failure (`SdkError::code()`) | Meaning | +| --- | --- | +| `plugin.host.unavailable` | The host did not offer `litheFunctions`, so it has no host services. Nothing was sent. | +| `plugin.host.permissionDenied` | The plugin lacks the `credentials.store` permission. | +| `plugin.host.invalidParams`, `sdk.credentials.invalidKey`, `sdk.credentials.invalidSecret` | A malformed key or secret. | +| `plugin.host.failed` | The host's secret store failed, for example a locked secret store; the message says why and never contains the secret. | +| `sdk.call.cancelled` | The call was cancelled while waiting; `?` answers as cancelled. | +| `sdk.session.notReady`, `sdk.session.closed` | The session has not finished `initialize`, or it ended while waiting. | + +### Calling other plugins + +A backend may call a function another plugin exports; Core relays the call and +the provider's backend never learns more than the call it serves. The +manifest declares both sides: the caller lists `{ id, version }` under +`pluginFunctions.calls`, and the provider lists +`{ id, version, context: "workspace" }` under `pluginFunctions.exports` and +registers the function with `backend.function(id, handler)`. See +[cross-plugin calls](../../contract/v1/README.md#cross-plugin-calls) +for the manifest, the order of Core's checks, and the call chain. + +```rust +backend + .function("project.options", |_params, context| { + let workspace = context + .workspace_context() + .ok_or_else(|| PluginError::new("example.noWorkspace", "Open a folder first."))?; + let host = context.host(); + if !host.supports("lithe/host/functions/invoke")? { + return Ok(json!({ "options": [] })); + } + let providers = host.functions().providers("project.describe")?; + let Some(provider) = providers["providers"][0]["pluginID"].as_str() else { + return Ok(json!({ "options": [] })); + }; + let answer = host.functions().invoke( + workspace, + json!({ + "pluginID": provider, + "functionID": "project.describe", + "params": { "project": "app" }, + "timeoutMilliseconds": 5000, + }), + )?; + Ok(json!({ "options": answer["value"] })) + }) + .expect("a valid function ID"); + +backend + .function("project.describe", |params, context| { + // Core wrote the caller from the calling session; it cannot be forged. + let caller = context.caller().map(|caller| caller.plugin_id()); + Ok(json!({ "project": params["project"], "askedBy": caller })) + }) + .expect("a valid function ID"); +``` + +- **Check before calling.** `supports("lithe/host/functions/invoke")` and + `supports("lithe/host/functions/providers")` tell whether this session may + call. Host services alone are not enough: a plugin that declares no `calls` + gets `plugin.host.permissionDenied`. When the host did not offer + `litheFunctions`, `providers`, `invoke`, and `host().request` with either + method fail locally with `plugin.host.unavailable` and send nothing. +- **`providers(function_id)`** answers `{ "providers": [{ "pluginID", + "version" }] }`: the enabled plugins other than this one that export the + function, sorted by plugin ID. This plugin must have declared the function, + or the host answers `plugin.function.notDeclared`. A provider's backend need + not be running. +- **`invoke(&context, call)`** answers `{ "value": ... }`, where `value` is the + provider's result and may be `null`. `context` is a `WorkspaceContext` this + backend learned from the workspace service or `context.workspace_context()`; + the relay accepts local workspaces only. `call` is an object with + `pluginID` and `functionID`, and optionally `params` (an object, `{}` by + default), `timeoutMilliseconds` (a positive integer; the host defaults it to + 60000 and caps it at 600000, and the SDK does not clamp it), and + `parentOperationID`. Any other key, such as `context` or `caller`, is not + sent; the host would refuse it as `plugin.host.unsupportedOption`. A call + that is not an object, lacks `pluginID` or `functionID`, or has a field of + the wrong type fails locally with `sdk.functions.invalidCall`, and a + `function_id` that is not a function ID does the same for `providers`. +- **Limits are the host's.** A chain has at most 4 calls and does not return + to a plugin already on it (`plugin.function.callCycle`); a caller has at + most 8 relayed calls running; params and a result are at most 256 KiB. A + provider that is asked to apply values must not call the plugin that asked + inside that call. +- **`parentOperationID` is filled in for you inside a handler.** A call made + with `context.host().functions().invoke(...)` while a function runs names + that call's `operationID`, so Core makes it a child: cancelling or ending + the handler's call cancels the relayed call. An explicit + `parentOperationID` in `call` overrides it (1 to 128 bytes). A + `handle.host()` serves no call and a generic `request` is sent as written, + so neither fills it in. An ID that names a call this backend is no longer + serving fails with `plugin.host.cancelled`. +- **A relayed call occupies a request slot.** It counts among the 16 + unanswered host requests until the provider answers, which can take up to + 600 seconds; further requests wait in the SDK. Keep relayed calls bounded + with `timeoutMilliseconds`. +- **`context.caller()` names the calling plugin.** It is `Some(Caller)` only + for a call Core relayed, and `None` for a call the host started. The SDK + accepts only a `caller` that is exactly + `{ "pluginID": "" }`; any other shape is read as no + caller. It names who called, not what they may do: authorize with the + plugin's own rules. + +| Failure (`SdkError::code()`) | Meaning | +| --- | --- | +| `sdk.functions.invalidCall` | The `call` or the function ID was malformed. Nothing was sent. | +| `plugin.host.unavailable` | The host did not offer `litheFunctions`. Nothing was sent. | +| `plugin.host.permissionDenied` | The plugin declares no `pluginFunctions.calls`. | +| `plugin.function.notDeclared`, `.providerDisabled`, `.notExported`, `.versionMismatch` | The call is not declared, the provider is missing or disabled, does not export the function now, or exports another version. | +| `plugin.function.callCycle`, `.depthExceeded`, `.tooManyCalls`, `.payloadTooLarge` | The chain, the caller's running calls, or a payload exceeded a limit. | +| `plugin.function.providerUnavailable`, `.invalidResult` | The provider's backend stopped or did not start in time, or answered with no result or one over 1 MiB. | +| `plugin.function.failed` | The provider's function returned an error. The `pluginErrorCode` entry of `details()` is the provider's own `PluginError` code, and `message()` is its message. | +| `plugin.host.contextExpired`, `plugin.host.authorityUnsupported`, `plugin.host.timeout`, `plugin.host.cancelled` | The context was closed or replaced, is not a local workspace, the deadline passed, or the call (or its parent) was cancelled. | +| `sdk.host.invalidResult` | The host answered with something other than an object. | + +## Results and errors + +| Handler outcome | What Lithe sees | +| --- | --- | +| `Ok(value)` (including `Value::Null`) | `result.value` | +| `Err(PluginError)` | `plugin.function.failed` with your message and `pluginErrorCode`. The code is for your plugin's own UI; Lithe never branches on it. | +| Any other error, or a panic | `plugin.function.failed` with a generic message, so paths, secrets, and stack traces never reach the UI. Automatic SDK diagnostics record only a fixed failure category. A panic ends only its call; SDK catch boundaries suppress raw payload printing. | +| `Err(Cancelled)`, or any outcome after cancellation | `requestCancelled` (or Lithe's timeout or revocation outcome) | + +`?` converts `PluginError`, `Cancelled`, `SdkError`, `std::io::Error`, and +`serde_json::Error` into `FunctionError`; `FunctionError::internal(details)` +wraps anything else. A result must serialize to at most 1 MiB of JSON. A +function ID is dot-separated lowerCamelCase segments (`snapshot.refresh`), at +most 128 bytes, and a session holds at most 256 functions. A `PluginError` +code matches `[A-Za-z][A-Za-z0-9._-]*`, at most 64 bytes; `PluginError::new` +panics on any other code. + +## Rules that keep Lithe in charge + +1. **stdout belongs to the protocol.** On Unix, `listen()` moves the + protocol to a duplicate of the stdout descriptor and points stdout at + stderr, so a stray `println!` in your code or a dependency lands in the + host's log. Elsewhere, never print to stdout. +2. **The package is read-only.** Keep caches and databases under `${stateDir}`, + which Lithe also passes as the root and working directory. Diagnostics use + the SDK and host; native log files require an explicitly supplied `${logDir}`. + Writing to the package breaks code signing and update deltas. +3. **Secrets go to the credentials service.** Never write a password or + token to `${stateDir}`, to a log line, or to a `PluginError` message. +4. **Work ends with its call.** Check the cancellation in long loops, use + `wait_timeout` between retries, and register an `on_cancel` callback for + blocking driver calls. Lithe has already ended the call when it cancels; + the SDK answers `requestCancelled` whatever the handler returns. +5. **No calls back into Lithe's dispatch.** The SDK never sends + `lithe/function/invoke` or `lithe/build/*`; Lithe would refuse them as + `plugin.function.recursiveCall`. `Host::request` sends only + `lithe/host/*` and refuses anything else as `sdk.host.method`. To call + another plugin, use [`host().functions()`](#calling-other-plugins), which + sends `lithe/host/functions/invoke` and lets Core relay it. + +## Native terminal API + +The SDK includes wrappers for plugin-owned native terminal create/show/close, +reopen with bounded display history, disposal and exit subscriptions. They are +host methods discovered through `capabilities()`; they require a host that +offers them and the `terminal.create` permission. See +[the native terminal guide](../../guides/native-terminals.md). + +## Testing + +Run these from the root of this repository: + +```sh +cargo test --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml +cargo fmt --manifest-path shared/plugins/sdk/rust/Cargo.toml --all -- --check +cargo clippy --locked --manifest-path shared/plugins/sdk/rust/Cargo.toml --all-targets -- -D warnings +node shared/plugins/scripts/sdk-e2e.mjs +``` + +The unit tests drive a session through in-process pipes with a scripted +host; `plugin-sdk/tests/host_services.rs` checks the host services against the +contract's fixture. The end-to-end check builds the sample, installs it as a +package, and runs it through a real Core session of this checkout. It needs +Rust, Node.js 20.3 or newer, Java (`LITHE_PLUGIN_SDK_JAVA_RUNTIME` or `java` on +`PATH`) and Maven; `LITHE_PLUGIN_SDK_RUST_SAMPLE` names an already built +`sample-backend`. diff --git a/shared/plugins/sdk/rust/examples/sample-backend/Cargo.toml b/shared/plugins/sdk/rust/examples/sample-backend/Cargo.toml new file mode 100644 index 00000000..b6e173f4 --- /dev/null +++ b/shared/plugins/sdk/rust/examples/sample-backend/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "sample-backend" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +lithe-plugin-sdk = { path = "../../plugin-sdk" } +serde_json.workspace = true diff --git a/shared/plugins/sdk/rust/examples/sample-backend/plugin.json b/shared/plugins/sdk/rust/examples/sample-backend/plugin.json new file mode 100644 index 00000000..04324601 --- /dev/null +++ b/shared/plugins/sdk/rust/examples/sample-backend/plugin.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 0, + "id": "org.example.sample-backend", + "displayName": "Sample Backend", + "version": "0.1.0", + "pluginAPIVersion": 0, + "requires": { "backend": 0, "ui": 0 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "terminal.create", + "credentials.store", + "ui.contribute", + "ui.web", + "window.messages", + "workspace.files.watch", + "workspace.metadata" + ], + "activationEvents": [ + "onView:org.example.sample-backend.notes", + "onCommand:org.example.sample-backend.greet" + ], + "languages": [], + "ui": { + "toolWindows": [ + { + "id": "org.example.sample-backend.notes", + "title": "Sample Notes", + "placement": "right", + "web": { "entry": "ui/index.html" } + } + ], + "commands": [ + { + "id": "org.example.sample-backend.greet", + "title": "Greet from Sample Backend", + "toolWindowID": "org.example.sample-backend.notes", + "placements": ["commandPalette"] + } + ] + }, + "backend": { + "launch": { "kind": "packaged", "path": "bin/sample-backend" }, + "arguments": ["--state", "${stateDir}"] + } +} diff --git a/shared/plugins/sdk/rust/examples/sample-backend/src/main.rs b/shared/plugins/sdk/rust/examples/sample-backend/src/main.rs new file mode 100644 index 00000000..825989fd --- /dev/null +++ b/shared/plugins/sdk/rust/examples/sample-backend/src/main.rs @@ -0,0 +1,381 @@ +//! Sample plugin backend for the Rust SDK: the `backend` process of the +//! sample plugin in this directory. Lithe starts it when the plugin's web +//! page first calls a function or the user runs its command, with +//! `--state ${stateDir}`. It: +//! +//! - keeps notes in its state directory, with progress (`note.save`, +//! `note.read`), and registers `note.clear` once there is a note to clear; +//! - keeps a secret in the host's credential store, never in its state +//! (`secret.remember`, `secret.check`, `secret.forget`); +//! - waits until it is cancelled (`work.wait`), and fails unexpectedly +//! without leaking details to the page (`fail.unexpectedly`); +//! - watches the files of the page's workspace through the host +//! (`files.nextChange`), answers its command with a message in the window +//! it ran in, and lists the open workspaces when they change. + +use lithe_plugin_sdk::{ + BackendHandle, Cancelled, Context, FunctionError, PluginError, SdkError, Value, +}; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::RecvTimeoutError; +use std::sync::Arc; +use std::time::Duration; + +/// The command `plugin.json` declares. +const GREET: &str = "org.example.sample-backend.greet"; +const WATCH: &str = "lithe/host/workspace/watch"; + +fn main() { + let state = match state_directory() { + Ok(state) => Arc::new(state), + Err(message) => { + eprintln!("{message}"); + std::process::exit(2); + } + }; + 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| { + let name = note_name(¶ms)?; + let text = params["text"].as_str().ok_or_else(|| { + PluginError::new("sample.invalidParams", "\"text\" must be a string.") + })?; + context.progress("Saving", Some(0)); + let directory = notes.join("notes"); + fs::create_dir_all(&directory)?; + // Written beside the note and renamed, so a reader never sees half a note. + let temporary = directory.join(format!(".{name}.tmp")); + fs::write(&temporary, text)?; + fs::rename(&temporary, directory.join(format!("{name}.txt")))?; + context.progress("Saved", Some(100)); + offer_clear(&context.backend(), ¬es); + Ok(json!({ "name": name, "bytes": text.len() })) + }) + .expect("valid function"); + + let notes = Arc::clone(&state); + backend + .function("note.read", move |params, _| { + let name = note_name(¶ms)?; + match fs::read_to_string(notes.join("notes").join(format!("{name}.txt"))) { + Ok(text) => Ok(json!({ "text": text })), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err(PluginError::new( + "sample.noteMissing", + format!("There is no note named {name}."), + ) + .into()) + } + Err(error) => Err(error.into()), + } + }) + .expect("valid function"); + + backend + .function("secret.remember", |params, context| { + let (key, secret) = (credential_key(¶ms)?, secret(¶ms)?); + context + .credentials() + .set(key, secret) + .map_err(credentials_failure)?; + Ok(json!({})) + }) + .expect("valid function"); + + backend + .function("secret.check", |params, context| { + let (key, secret) = (credential_key(¶ms)?, secret(¶ms)?); + let stored = context + .credentials() + .get(key) + .map_err(credentials_failure)?; + // The page learns whether its input matches, never the secret. + Ok(json!({ "stored": stored.is_some(), "matches": stored.as_deref() == Some(secret) })) + }) + .expect("valid function"); + + backend + .function("secret.forget", |params, context| { + let key = credential_key(¶ms)?; + context + .credentials() + .delete(key) + .map_err(credentials_failure)?; + Ok(Value::Null) + }) + .expect("valid function"); + + let markers = Arc::clone(&state); + backend + .function("work.wait", move |_, context| { + fs::write(markers.join("waiting"), "")?; + // Blocks without spinning until the host cancels the call. + if context + .cancellation() + .wait_timeout(Duration::from_secs(600)) + { + fs::write(markers.join("cancelled"), "")?; + return Err(Cancelled.into()); + } + Ok(json!("finished")) + }) + .expect("valid function"); + + let hidden = Arc::clone(&state); + backend + .function("fail.unexpectedly", move |_, _| { + Err(FunctionError::internal(format!( + "could not open {}", + hidden.join("missing").display() + ))) + }) + .expect("valid function"); + + backend + .function("files.nextChange", |_, context| next_change(context)) + .expect("valid function"); + + // The terminal API is a host service. The lifecycle sample deliberately + // uses a harmless interactive client and display-only history; it never + // executes retained text as input. + backend + .function("terminal.cycle", |_, context| { + let workspace = context + .workspace_context() + .ok_or_else(|| PluginError::new("sample.noWorkspace", "Open a workspace first."))?; + let host = context.host(); + if !host.supports("lithe/host/terminal/reopen")? { + return Err(PluginError::new( + "sample.terminalUnavailable", + "The host has no native terminal API.", + ) + .into()); + } + let terminal = host.terminal(); + let created = terminal.create( + workspace, + json!({ "executable": "example-client", "arguments": ["--interactive"], + "retainedContextBytes": 4096, "show": true }), + )?; + let id = created["terminalID"] + .as_str() + .ok_or_else(|| FunctionError::internal("missing terminalID"))?; + let subscription = terminal.on_exit(workspace, id)?; + let closed = terminal.close(workspace, id, Some(4096))?; + let event = subscription + .recv_timeout(Duration::from_secs(5)) + .map_err(|_| FunctionError::internal("terminal exit did not arrive"))?; + let reopened = terminal.reopen(workspace, id)?; + terminal.dispose(workspace, id)?; + subscription.dispose()?; + Ok(json!({ "closed": closed, "reopened": reopened, "event": event.event() })) + }) + .expect("valid function"); + + // Notifications are events: each handler runs on a thread of its own and + // may ask the host for more, outside any call. + let handle = backend.handle(); + backend + .on_command(GREET, move |_, context| { + let Some(context) = context else { + return Ok(()); + }; + let host = handle.host(); + let folders = host.workspace().get_folders(&context)?; + let names: Vec<&str> = folders["folders"] + .as_array() + .into_iter() + .flatten() + .filter_map(|folder| folder["name"].as_str()) + .collect(); + let text = if names.is_empty() { + "Sample Backend sees no folders.".to_string() + } else { + format!("Sample Backend sees {}.", names.join(", ")) + }; + host.window() + .show_information(&context, json!({ "text": text }))?; + Ok(()) + }) + .expect("valid command"); + + // A backend that keeps state per workspace would refresh it here. + let handle = backend.handle(); + backend.on_contexts_changed(move |_| { + let _contexts = handle.host().workspace().list_contexts()?; + Ok(()) + }); + + let notes = Arc::clone(&state); + backend.on_initialize(move |initialize| { + if has_notes(¬es) { + let notes = Arc::clone(¬es); + initialize.function("note.clear", move |_, context| clear(¬es, context))?; + } + Ok(()) + }); + + std::process::exit(backend.listen()); +} + +fn state_directory() -> Result { + let mut arguments = std::env::args().skip(1); + match (arguments.next().as_deref(), arguments.next()) { + (Some("--state"), Some(path)) if arguments.next().is_none() => Ok(PathBuf::from(path)), + _ => Err("usage: sample-backend --state ".into()), + } +} + +/// Registers `note.clear` the first time there is a note to clear. +fn offer_clear(backend: &BackendHandle, state: &Arc) { + if backend.function_ids().iter().any(|id| id == "note.clear") { + return; + } + let notes = Arc::clone(state); + if backend + .register_function("note.clear", move |_, context| clear(¬es, context)) + .is_err() + { + backend + .diagnostics() + .degraded("initialize", "registration_failed", None) + .expect("fixed diagnostic"); + } +} + +fn clear(state: &Path, context: &Context) -> Result { + context.check_cancelled()?; + match fs::remove_dir_all(state.join("notes")) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()), + _ => Ok(Value::Null), + } +} + +/// Waits for the next change under the first folder of the page's +/// workspace, through a host file watch. +fn next_change(context: &Context) -> Result { + let Some(workspace) = context.workspace_context() else { + return Err(PluginError::new("sample.noWorkspace", "Open a workspace first.").into()); + }; + let host = context.host(); + // Core answers capability discovery itself, from the plugin's permissions. + if !host.supports(WATCH)? { + return Err(PluginError::new( + "sample.watchUnavailable", + "Lithe does not let this plugin watch files.", + ) + .into()); + } + let folders = host + .workspace() + .get_folders(workspace) + .map_err(workspace_failure)?; + let Some(root) = folders["folders"][0]["rootID"].as_str() else { + return Err(PluginError::new("sample.noWorkspace", "The workspace has no folders.").into()); + }; + let watch = host + .workspace() + .watch( + workspace, + json!({ "resource": { "rootID": root, "path": "" }, "recursive": true }), + ) + .map_err(workspace_failure)?; + let outcome = loop { + if context.cancellation().is_cancelled() { + break Err(Cancelled.into()); + } + match watch.recv_timeout(Duration::from_millis(200)) { + Ok(event) => break Ok(json!({ "changedPaths": event.event()["changedPaths"] })), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + break Err(FunctionError::internal("the watch ended")) + } + } + }; + // A subscription outlives the call until it is disposed. + if watch.dispose().is_err() { + context + .diagnostics() + .degraded("files.nextChange", "subscription_dispose_failed", None) + .expect("fixed diagnostic"); + } + outcome +} + +/// A window that switched folders makes the page's context stale +/// (`plugin.host.contextExpired`), which the page can recover from. +fn workspace_failure(error: SdkError) -> FunctionError { + if error.code() == "plugin.host.contextExpired" { + PluginError::new( + "sample.workspaceChanged", + "The workspace changed; try again.", + ) + .into() + } else { + error.into() + } +} + +fn has_notes(state: &Path) -> bool { + fs::read_dir(state.join("notes")).is_ok_and(|mut entries| entries.next().is_some()) +} + +/// A note name is a file name the sample chooses the alphabet of, so a page +/// can never name a path outside the notes directory. +fn note_name(params: &Value) -> Result<&str, PluginError> { + params["name"] + .as_str() + .filter(|name| { + (1..=64).contains(&name.len()) + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) + .ok_or_else(|| { + PluginError::new( + "sample.invalidParams", + "\"name\" must be 1-64 lowercase letters, digits, or \"-\".", + ) + }) +} + +fn credential_key(params: &Value) -> Result<&str, PluginError> { + params["key"] + .as_str() + .filter(|key| lithe_plugin_sdk::is_credential_key(key)) + .ok_or_else(|| PluginError::new("sample.invalidParams", "\"key\" is not a credential key.")) +} + +fn secret(params: &Value) -> Result<&str, PluginError> { + params["secret"] + .as_str() + .ok_or_else(|| PluginError::new("sample.invalidParams", "\"secret\" must be a string.")) +} + +/// A host that offers no credential store (`plugin.host.unavailable`), or a +/// plugin installed without the `credentials.store` permission, is a state +/// the page can explain; other failures stay internal. +fn credentials_failure(error: SdkError) -> FunctionError { + if error.code().starts_with("plugin.host.") { + PluginError::new( + "sample.credentialsUnavailable", + "Lithe cannot store credentials for this plugin.", + ) + .into() + } else { + error.into() + } +} diff --git a/shared/plugins/sdk/rust/examples/sample-backend/ui/index.html b/shared/plugins/sdk/rust/examples/sample-backend/ui/index.html new file mode 100644 index 00000000..78447cfb --- /dev/null +++ b/shared/plugins/sdk/rust/examples/sample-backend/ui/index.html @@ -0,0 +1,23 @@ + + + + + Sample Notes + + + + +
+ + + + +
+
+ + + +
+

+ + diff --git a/shared/plugins/sdk/rust/examples/sample-backend/ui/main.js b/shared/plugins/sdk/rust/examples/sample-backend/ui/main.js new file mode 100644 index 00000000..82da4b21 --- /dev/null +++ b/shared/plugins/sdk/rust/examples/sample-backend/ui/main.js @@ -0,0 +1,43 @@ +// The page reaches its backend only through the host bridge: functions.invoke +// without an instance calls this plugin's backend, which Lithe starts on the +// first call. Inline scripts are refused by the page's content security policy. +const status = document.getElementById('status'); +const note = document.getElementById('note'); +const secret = document.getElementById('secret'); +let calls = 0; + +async function call(fn, params) { + const id = `call-${Date.now()}-${calls++}`; + try { + const { value } = await lithe.request('functions.invoke', { function: fn, params, call: id }); + return value; + } catch (error) { + status.textContent = error.pluginErrorCode ? `${error.message} (${error.pluginErrorCode})` : error.message; + throw error; + } +} + +lithe.on('functions.progress', ({ message }) => { if (message) status.textContent = message; }); + +note.addEventListener('submit', async (event) => { + event.preventDefault(); + const { bytes } = await call('note.save', { name: note.name.value, text: note.text.value }); + status.textContent = `Saved ${bytes} bytes.`; +}); + +document.getElementById('read').addEventListener('click', async () => { + note.text.value = (await call('note.read', { name: note.name.value })).text; + status.textContent = 'Loaded.'; +}); + +secret.addEventListener('submit', async (event) => { + event.preventDefault(); + await call('secret.remember', { key: 'sample:password', secret: secret.secret.value }); + secret.secret.value = ''; + status.textContent = 'Stored in the system credential store.'; +}); + +document.getElementById('check').addEventListener('click', async () => { + const { stored, matches } = await call('secret.check', { key: 'sample:password', secret: secret.secret.value }); + status.textContent = !stored ? 'Nothing stored.' : matches ? 'It matches.' : 'It does not match.'; +}); diff --git a/shared/plugins/sdk/rust/examples/sample-backend/ui/style.css b/shared/plugins/sdk/rust/examples/sample-backend/ui/style.css new file mode 100644 index 00000000..d3973150 --- /dev/null +++ b/shared/plugins/sdk/rust/examples/sample-backend/ui/style.css @@ -0,0 +1,3 @@ +body { font: var(--lithe-font-body, 13px system-ui); color: var(--lithe-text, CanvasText); background: transparent; margin: 12px; } +form { display: grid; gap: 6px; margin-bottom: 16px; } +label { display: grid; gap: 2px; } diff --git a/shared/plugins/sdk/rust/plugin-sdk/Cargo.toml b/shared/plugins/sdk/rust/plugin-sdk/Cargo.toml new file mode 100644 index 00000000..7097aabb --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-plugin-sdk" +description = "Official SDK for Lithe plugin backends: plugin functions and host services over the LSP base protocol." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +serde_json.workspace = true +base64 = "0.22" diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/backend.rs b/shared/plugins/sdk/rust/plugin-sdk/src/backend.rs new file mode 100644 index 00000000..4f2d1da9 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/backend.rs @@ -0,0 +1,1603 @@ +//! The plugin backend session: one process the host starts from the plugin's +//! manifest and talks to over stdio with the LSP base protocol. The SDK owns +//! everything Lithe manages uniformly -- the lifecycle, protocol negotiation, +//! cancellation, error codes, and the `lithe/*` namespace -- and leaves the +//! plugin free to decide what its handlers do. +//! +//! Contract: `contract/v1/README.md` ("Plugin backend", "Plugin functions", +//! and "Host services"). + +use crate::diagnostics::{Diagnostics, OPERATIONS}; +use crate::error::{ + cancelled_response, internal_response, response_error, sdk_codes, Cancelled, FunctionError, + SdkError, +}; +use crate::framing::{read_frame, write_frame, Frame}; +use crate::host::{Channel, Credentials, Host, Outcome, Outgoing, WorkspaceContext}; +use crate::panic_boundary::catch as catch_unwind; +use crate::protocol::{ + bounded_text, error_codes, is_command_id, is_function_id, is_parent_operation_id, methods, + offers, FUNCTIONS_VERSION, KEYMAP_IMPORT_VERSION, LOGGING_VERSION, 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, BTreeSet, HashMap}; +use std::io::{self, BufReader, Read, Write}; +use std::panic::AssertUnwindSafe; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::thread; +use std::time::{Duration, Instant}; + +type Handler = Arc Result + Send + Sync>; +type InitializeHook = Box Result<(), FunctionError> + Send>; +type ShutdownHook = Box; +type CommandHandler = + Arc) -> Result<(), FunctionError> + Send + Sync>; +type NotificationHandler = Arc Result<(), FunctionError> + Send + Sync>; +type Answer = Result; + +/// Host request for one selected shortcut source; also the operation its diagnostic facts name. +const KEYMAP_IMPORT_METHOD: &str = "lithe/keymap/import"; + +/// `Idle` → `Listening` → `Initializing` → `Ready` → `ShuttingDown` → `Exited`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Phase { + Idle, + Listening, + Initializing, + Ready, + ShuttingDown, + Exited, +} + +pub(crate) struct State { + pub(crate) phase: Phase, + pub(crate) functions: BTreeMap, + keymap_providers: BTreeMap, + selected_keymap_providers: BTreeSet, + pub(crate) logging: bool, + pub(crate) logging_ready: bool, + pub(crate) early_diagnostics: Vec, + quiet_success: std::collections::HashSet, + /// The negotiated plugin-function protocol version: `Some(0)` when the + /// host offered it. + functions_version: Option, + /// Host requests being handled, by JSON-RPC ID. + incoming: HashMap, + /// Requests this backend sent to the host, by JSON-RPC ID. + pub(crate) outgoing: HashMap, + pub(crate) channel: Channel, +} + +struct Running { + function_id: String, + cancellation: Cancellation, +} + +/// Everything handler threads share with the session. Locks are taken in the +/// order `registrations`, `capabilities`, `state`, `output`, never the other +/// way round. +pub(crate) struct Shared { + name: String, + pub(crate) state: Mutex, + output: Mutex>>, + /// Serializes register/unregister so the host sees them in call order. + registrations: Mutex<()>, + pub(crate) next_request: AtomicU64, + /// Signalled when a host-request slot frees or a waiting request must + /// give up; waits on `state`. + pub(crate) slots: Condvar, + /// The session's `getCapabilities` answer. + pub(crate) capabilities: Mutex>, +} + +pub(crate) fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The JSON-RPC ID as a map key; numbers and strings never collide. +fn request_key(id: &Value) -> String { + id.to_string() +} + +fn rpc_error(code: i64, message: impl Into) -> Value { + json!({ "code": code, "message": message.into() }) +} + +/// A plugin backend. Declare functions and hooks, then call +/// [`listen`](Self::listen). +/// +/// ```no_run +/// use lithe_plugin_sdk::{Backend, PluginError}; +/// use serde_json::json; +/// +/// let mut backend = Backend::new("example-backend").version("1.0.0"); +/// backend +/// .function("snapshot.refresh", |params, context| { +/// context.progress("Refreshing", Some(10)); +/// let name = params["name"].as_str().ok_or_else(|| { +/// PluginError::new("example.invalidName", "\"name\" must be a string.") +/// })?; +/// Ok(json!({ "refreshed": name })) +/// }) +/// .expect("a valid function ID"); +/// std::process::exit(backend.listen()); +/// ``` +pub struct Backend { + shared: Arc, + version: Option, + initialize_hooks: Vec, + shutdown_hooks: Vec, + commands: HashMap, + contexts_changed: Option, + host_notifications: HashMap, +} + +impl Backend { + /// `name` is reported as `serverInfo.name` and prefixes log lines. + pub fn new(name: impl Into) -> Self { + Self { + shared: Arc::new(Shared { + name: name.into(), + state: Mutex::new(State { + phase: Phase::Idle, + functions: BTreeMap::new(), + keymap_providers: BTreeMap::new(), + selected_keymap_providers: BTreeSet::new(), + logging: false, + logging_ready: false, + early_diagnostics: Vec::new(), + quiet_success: std::collections::HashSet::new(), + functions_version: None, + incoming: HashMap::new(), + outgoing: HashMap::new(), + channel: Channel::default(), + }), + output: Mutex::new(None), + registrations: Mutex::new(()), + next_request: AtomicU64::new(1), + slots: Condvar::new(), + capabilities: Mutex::new(None), + }), + version: None, + initialize_hooks: Vec::new(), + shutdown_hooks: Vec::new(), + commands: HashMap::new(), + contexts_changed: None, + host_notifications: HashMap::new(), + } + } + + /// Omits successful terminal diagnostics for a registered high-frequency operation. + pub fn suppress_success_diagnostics(&mut self, operation: &str) -> Result<&mut Self, SdkError> { + let mut state = lock(&self.shared.state); + if !OPERATIONS.contains(&operation) && !state.functions.contains_key(operation) { + return Err(SdkError::new( + "sdk.logging.invalidOperation", + "Suppress a registered operation.", + )); + } + state.quiet_success.insert(operation.to_string()); + drop(state); + Ok(self) + } + + /// Reported as `serverInfo.version`. + pub fn version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + /// Declares a function offered from the start, in the `initialize` + /// answer. To add one later, use [`BackendHandle::register_function`]. + /// + /// Each call runs on a thread of its own, so a handler may block; it + /// should stop early when its [`Context::cancellation`] fires. + pub fn function(&mut self, id: &str, handler: F) -> Result<&mut Self, SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + let mut state = lock(&self.shared.state); + check_new_function(id, &state.functions)?; + state.functions.insert(id.to_string(), Arc::new(handler)); + drop(state); + 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. + pub fn on_initialize(&mut self, hook: F) -> &mut Self + where + F: FnMut(&mut Initialize) -> Result<(), FunctionError> + Send + 'static, + { + self.initialize_hooks.push(Box::new(hook)); + self + } + + /// Runs on `shutdown`, after running calls were cancelled. + pub fn on_shutdown(&mut self, hook: F) -> &mut Self + where + F: FnMut() + Send + 'static, + { + self.shutdown_hooks.push(Box::new(hook)); + self + } + + /// Handles `lithe/host/commands/didRun` for one of the plugin's commands, + /// with the workspace context the command ran in. It is an + /// event: the host waits for no result, and a failure is only logged. + /// Each run has a thread of its own. A later handler for the same ID + /// replaces the earlier one. + /// + /// `command_id` is the command's ID from the manifest, 1-128 of + /// `[a-z0-9.-]`. + pub fn on_command(&mut self, command_id: &str, handler: F) -> Result<&mut Self, SdkError> + where + F: Fn(&str, Option) -> Result<(), FunctionError> + Send + Sync + 'static, + { + if !is_command_id(command_id) { + return Err(SdkError::new( + "sdk.command.invalidID", + format!("Command ID {command_id:?} must be 1-128 of [a-z0-9.-]."), + )); + } + self.commands + .insert(command_id.to_string(), Arc::new(handler)); + Ok(self) + } + + /// Handles `lithe/host/workspace/didChangeContexts` with + /// `{ revision }`; call `host().workspace().list_contexts()` for the + /// contexts. + pub fn on_contexts_changed(&mut self, handler: F) -> &mut Self + where + F: Fn(Value) -> Result<(), FunctionError> + Send + Sync + 'static, + { + self.contexts_changed = Some(Arc::new(handler)); + self + } + + /// Handles a `lithe/host/*` notification a later host adds. Commands, + /// context changes, and subscription events have their own APIs. + pub fn on_host_notification( + &mut self, + method: &str, + handler: F, + ) -> Result<&mut Self, SdkError> + where + F: Fn(Value) -> Result<(), FunctionError> + Send + Sync + 'static, + { + if !method.starts_with(methods::HOST_PREFIX) { + return Err(SdkError::new( + "sdk.method.invalid", + format!("{method} is not a host notification."), + )); + } + if [ + methods::DID_CHANGE_CONTEXTS, + methods::SUBSCRIPTION_NOTIFY, + methods::COMMAND_DID_RUN, + ] + .contains(&method) + { + return Err(SdkError::new( + "sdk.method.reserved", + format!("{method} is routed by the SDK; use on_command, on_contexts_changed, or Host::subscribe."), + )); + } + self.host_notifications + .insert(method.to_string(), Arc::new(handler)); + Ok(self) + } + + /// A handle for other threads: runtime registration, host services, and + /// logging. + pub fn handle(&self) -> BackendHandle { + BackendHandle { + shared: Arc::clone(&self.shared), + } + } + + /// Serves the host on stdin and stdout until it sends `exit` or closes + /// stdin, and returns the process exit code. Stdout carries only + /// protocol frames: on Unix the SDK moves the protocol to a duplicate of + /// the stdout descriptor and points stdout at stderr, so a stray + /// `println!`, in the plugin or a dependency, lands in the host's log + /// instead of corrupting the stream. + pub fn listen(self) -> i32 { + let output = crate::stdio::protocol_output(); + self.serve(io::stdin().lock(), output) + } + + /// Serves the host on the given streams; [`listen`](Self::listen) uses + /// stdin and stdout. + pub fn serve(mut self, input: impl Read, output: impl Write + Send + 'static) -> i32 { + lock(&self.shared.state).phase = Phase::Listening; + *lock(&self.shared.output) = Some(Box::new(output)); + let mut reader = BufReader::new(input); + loop { + match read_frame(&mut reader) { + Ok(None) => { + let code = i32::from(self.shared.phase() != Phase::ShuttingDown); + return self.shared.finish(code); + } + Err(_) => { + self.handle().diagnostics().terminal( + "notification", + Some(&FunctionError::internal("framing_error")), + Instant::now(), + ); + return self.shared.finish(1); + } + Ok(Some(Frame::Malformed)) => self.shared.respond( + &Value::Null, + Err(rpc_error( + error_codes::PARSE_ERROR, + "The message is not valid JSON.", + )), + ), + Ok(Some(Frame::Message(message))) => { + if let Some(code) = self.dispatch(message) { + return self.shared.finish(code); + } + } + } + } + } + + /// Handles one message; returns the exit code once the host sent `exit`. + fn dispatch(&mut self, message: Value) -> Option { + let Value::Object(message) = message else { + // Valid JSON that is not a message object, such as `null` or a batch. + self.shared.respond( + &Value::Null, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The message is not a JSON-RPC object.", + )), + ); + return None; + }; + let id = message.get("id").filter(|id| !id.is_null()).cloned(); + let params = message.get("params").cloned().unwrap_or(Value::Null); + match (message.get("method").and_then(Value::as_str), id) { + (None, Some(id)) => { + self.shared.settle_outgoing(&id, &message); + None + } + (None, None) => None, + (Some(method), Some(id)) => { + self.handle_request(id, method, params); + None + } + (Some(method), None) => self.handle_notification(method, ¶ms), + } + } + + fn handle_request(&mut self, id: Value, method: &str, params: Value) { + match method { + methods::INITIALIZE => self.initialize(id, params), + methods::SHUTDOWN => self.shutdown(id), + _ => match self.shared.phase() { + Phase::Ready if method == methods::FUNCTION_INVOKE => { + Shared::invoke(&self.shared, id, ¶ms) + } + Phase::Ready if method == KEYMAP_IMPORT_METHOD => { + Shared::invoke_keymap(&self.shared, id, ¶ms) + } + Phase::Ready => self.shared.respond( + &id, + Err(rpc_error( + error_codes::METHOD_NOT_FOUND, + format!("Unsupported method: {method}"), + )), + ), + Phase::Idle | Phase::Listening | Phase::Initializing => self.shared.respond( + &id, + Err(rpc_error( + error_codes::SERVER_NOT_INITIALIZED, + "The backend is not initialized.", + )), + ), + Phase::ShuttingDown | Phase::Exited => self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The backend is shutting down.", + )), + ), + }, + } + } + + fn handle_notification(&mut self, method: &str, params: &Value) -> Option { + match method { + methods::EXIT => Some(i32::from(self.shared.phase() != Phase::ShuttingDown)), + methods::CANCEL_REQUEST => { + let key = request_key(¶ms["id"]); + let running = lock(&self.shared.state) + .incoming + .get(&key) + .map(|running| running.cancellation.clone()); + if let Some(cancellation) = running { + cancellation.cancel(); + } + None + } + method if method.starts_with(methods::HOST_PREFIX) => { + self.host_notification(method, params); + None + } + // `initialized` and anything else a plugin backend is not sent. + _ => None, + } + } + + /// Routes a host notification. Core checked its shape and the session's + /// grant; one this SDK does not know is ignored, as the contract asks. + fn host_notification(&self, method: &str, params: &Value) { + if self.shared.functions_version().is_none() || !params.is_object() { + return; + } + match method { + methods::SUBSCRIPTION_NOTIFY => self.shared.deliver(params), + methods::COMMAND_DID_RUN => { + let command_id = params["commandID"].as_str().unwrap_or_default(); + let Some(handler) = self.commands.get(command_id).cloned() else { + return self.shared.failed("notification", "command_unhandled"); + }; + let command_id = command_id.to_string(); + let context = WorkspaceContext::from_reference(¶ms["context"]); + self.shared + .notify_handler(method, move || handler(&command_id, context)); + } + _ => { + let handler = if method == methods::DID_CHANGE_CONTEXTS { + self.contexts_changed.as_ref() + } else { + self.host_notifications.get(method) + }; + if let Some(handler) = handler.cloned() { + let params = params.clone(); + self.shared.notify_handler(method, move || handler(params)); + } + } + } + } + + fn initialize(&mut self, id: Value, params: Value) { + { + let mut state = lock(&self.shared.state); + if state.phase != Phase::Listening { + drop(state); + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "initialize was already received.", + )), + ); + } + state.phase = Phase::Initializing; + } + let keymap_offer = params + .pointer("/capabilities/experimental/litheKeymapImport") + .cloned(); + let logging = offers( + params.pointer("/capabilities/experimental/litheLogging"), + LOGGING_VERSION, + ); + lock(&self.shared.state).logging = logging; + lock(&self.shared.state).logging_ready = false; + lock(&self.shared.state).early_diagnostics.clear(); + if !logging { + lock(&self.shared.state).phase = Phase::Listening; + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + format!("The backend requires litheLogging version {LOGGING_VERSION}."), + )), + ); + } + let functions_version = offers( + params.pointer("/capabilities/experimental/litheFunctions"), + FUNCTIONS_VERSION, + ) + .then_some(FUNCTIONS_VERSION); + let mut initialize = Initialize { + params, + functions_version, + existing: lock(&self.shared.state).functions.keys().cloned().collect(), + added: BTreeMap::new(), + }; + for hook in &mut self.initialize_hooks { + let outcome = catch_unwind(AssertUnwindSafe(|| hook(&mut initialize))); + let error = match outcome { + Ok(Ok(())) => continue, + Ok(Err(error)) => response_error(&error), + Err(_) => internal_response(), + }; + lock(&self.shared.state).phase = Phase::Listening; + return self.shared.respond(&id, Err(error)); + } + + // The state stays locked until the answer is written, so a function + // registered from another thread cannot overtake it. + let mut state = lock(&self.shared.state); + state.functions.extend(initialize.added); + state.functions_version = functions_version; + let mut experimental = Map::new(); + experimental.insert("litheLogging".into(), json!({ "version": LOGGING_VERSION })); + if let Some(offer) = keymap_offer { + if offers(Some(&offer), KEYMAP_IMPORT_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": KEYMAP_IMPORT_VERSION, + "providers": state.selected_keymap_providers, + }), + ); + } + } + } + } + match functions_version { + Some(version) => { + let functions: Vec<_> = state + .functions + .keys() + .map(|id| json!({ "id": id })) + .collect(); + experimental.insert( + "litheFunctions".into(), + json!({ "version": version, "functions": functions }), + ); + } + None if !state.functions.is_empty() => {} + None => {} + } + let mut server_info = json!({ "name": self.shared.name }); + if let Some(version) = &self.version { + server_info["version"] = json!(version); + } + state.phase = Phase::Ready; + self.shared.respond( + &id, + Ok(json!({ "capabilities": { "experimental": experimental }, "serverInfo": server_info })), + ); + state.logging_ready = true; + let early = std::mem::take(&mut state.early_diagnostics); + drop(state); + for params in early { + self.shared + .write(&json!({"jsonrpc":"2.0", "method":"lithe/log", "params":params})); + } + } + + fn shutdown(&mut self, id: Value) { + let running = { + let mut state = lock(&self.shared.state); + if !matches!( + state.phase, + Phase::Listening | Phase::Initializing | Phase::Ready + ) { + drop(state); + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "shutdown was already received.", + )), + ); + } + state.phase = Phase::ShuttingDown; + state.running_cancellations() + }; + running.iter().for_each(Cancellation::cancel); + for hook in &mut self.shutdown_hooks { + if catch_unwind(AssertUnwindSafe(&mut *hook)).is_err() { + self.shared.failed("shutdown", "panic"); + } + } + self.shared.respond(&id, Ok(Value::Null)); + } +} + +impl State { + fn running_cancellations(&self) -> Vec { + self.incoming + .values() + .map(|running| running.cancellation.clone()) + .collect() + } + + fn require_functions(&self) -> Result<(), SdkError> { + if self.phase == Phase::Exited { + Err(SdkError::new( + sdk_codes::SESSION_CLOSED, + "The session ended.", + )) + } else if self.phase == Phase::Ready && self.functions_version.is_some() { + Ok(()) + } else { + Err(SdkError::new( + "sdk.functions.unavailable", + "The host has not negotiated plugin functions.", + )) + } + } +} + +impl Shared { + fn phase(&self) -> Phase { + lock(&self.state).phase + } + + pub(crate) fn functions_version(&self) -> Option { + lock(&self.state).functions_version + } + + pub(crate) fn failed(&self, operation: &str, code: &str) { + self.write( + &json!({ "jsonrpc": "2.0", "method": "lithe/log", "params": { + "kind": "operationFailed", "operation": operation, "code": code, "durationMs": 0 + }}), + ); + } + + fn finish(&self, code: i32) -> i32 { + let running = { + let mut state = lock(&self.state); + state.phase = Phase::Exited; + self.close_channel(&mut state); + state.running_cancellations() + }; + running.iter().for_each(Cancellation::cancel); + *lock(&self.output) = None; + code + } + + /// Runs a notification handler off the reader thread; a failure is + /// logged. + fn notify_handler( + self: &Arc, + method: &str, + handler: impl FnOnce() -> Result<(), FunctionError> + Send + 'static, + ) { + let shared = Arc::clone(self); + let method = method.to_string(); + let spawned = thread::Builder::new() + .name(format!("lithe-notification-{method}")) + .spawn(move || match catch_unwind(AssertUnwindSafe(handler)) { + Ok(Ok(())) => {} + Ok(Err(FunctionError::Cancelled)) => {} + Ok(Err(error)) => shared.failed("notification", failure_kind(&error)), + Err(_) => shared.failed("notification", "panic"), + }); + if spawned.is_err() { + self.failed("notification", "worker_start_failed"); + } + } + + pub(crate) fn write(&self, message: &Value) { + let mut output = lock(&self.output); + let Some(writer) = output.as_mut() else { + return; + }; + if write_frame(writer.as_mut(), message).is_err() { + // Every later write would fail the same way. + *output = None; + drop(output); + // The protocol pipe is gone; the host owns the process terminal fact. + } + } + + fn respond(&self, id: &Value, answer: Answer) { + self.write(&match answer { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(error) => json!({ "jsonrpc": "2.0", "id": id, "error": error }), + }); + } + + 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_none_or(|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 diagnostic_operation_id = + crate::diagnostics::request_operation_id(&Value::Null, &thread_id); + let context = Context { + shared: Arc::clone(&worker), + function_id: call_id, + operation_id: Value::Null, + diagnostic_operation_id, + host: Host::new( + Arc::clone(&worker), + vec![cancellation.clone(), ended.clone()], + ), + cancellation, + workspace_context: None, + caller: None, + reporting: AtomicBool::new(false), + quiet_success: AtomicBool::new(false), + }; + let started = Instant::now(); + let outcome = catch_unwind(AssertUnwindSafe(|| handler(call_params, &context))); + ended.cancel(); + // Facts name the wire method Core registered, not the per-provider call id. + let answer = + worker.function_answer(&context, KEYMAP_IMPORT_METHOD, outcome, started); + lock(&worker.state).incoming.remove(&thread_key); + worker.respond(&thread_id, answer); + }); + if spawned.is_err() { + lock(&shared.state).incoming.remove(&key); + shared.failed(KEYMAP_IMPORT_METHOD, "worker_start_failed"); + shared.respond(&id, Err(internal_response())); + } + } + + fn invoke(shared: &Arc, id: Value, params: &Value) { + let function_id = params["functionID"] + .as_str() + .unwrap_or_default() + .to_string(); + let operation_id = params["operationID"].clone(); + // What nested cross-plugin calls name as the call they belong to. + let parent_operation = operation_id + .as_str() + .filter(|operation| is_parent_operation_id(operation)) + .map(str::to_string); + let call_params = match params.get("params") { + None | Some(Value::Null) => json!({}), + Some(params) => params.clone(), + }; + let key = request_key(&id); + let cancellation = Cancellation::new(); + let handler = { + let mut state = lock(&shared.state); + let workspace_context = state + .functions_version + .and_then(|_| WorkspaceContext::from_reference(¶ms["context"])); + let caller = state + .functions_version + .and_then(|_| Caller::from_value(¶ms["caller"])); + let handler = state + .functions_version + .and_then(|_| state.functions.get(&function_id).cloned()); + if handler.is_some() { + state.incoming.insert( + key.clone(), + Running { + function_id: function_id.clone(), + cancellation: cancellation.clone(), + }, + ); + } + handler.map(|handler| (handler, workspace_context, caller)) + }; + let Some((handler, workspace_context, caller)) = handler else { + return shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_PARAMS, + format!("Function {function_id} is not registered."), + )), + ); + }; + let worker = Arc::clone(shared); + let thread_key = key.clone(); + let thread_id = id.clone(); + let spawned = thread::Builder::new() + .name(format!("lithe-function-{function_id}")) + .spawn(move || { + // Ends host requests the call left behind, on clones of its host. + let ended = Cancellation::new(); + let diagnostic_operation_id = + crate::diagnostics::request_operation_id(&operation_id, &thread_id); + let context = Context { + shared: Arc::clone(&worker), + function_id, + operation_id, + diagnostic_operation_id, + host: Host::new( + Arc::clone(&worker), + vec![cancellation.clone(), ended.clone()], + ) + .with_parent_operation(parent_operation), + cancellation, + workspace_context, + caller, + reporting: AtomicBool::new(true), + quiet_success: AtomicBool::new(false), + }; + let started = Instant::now(); + let outcome = catch_unwind(AssertUnwindSafe(|| handler(call_params, &context))); + context.reporting.store(false, Ordering::SeqCst); + ended.cancel(); + let answer = + worker.function_answer(&context, &context.function_id, outcome, started); + lock(&worker.state).incoming.remove(&thread_key); + worker.respond(&thread_id, answer); + }); + if spawned.is_err() { + lock(&shared.state).incoming.remove(&key); + shared.failed("function-call", "worker_start_failed"); + shared.respond(&id, Err(internal_response())); + } + } + + /// The answer to one call. A cancelled call answers as cancelled whatever + /// the handler returned, and only failures the host never sees in full + /// are logged with a fixed category, without their private contents. + fn function_answer( + &self, + context: &Context, + function: &str, + outcome: Result, Box>, + started: Instant, + ) -> Answer { + let cancelled = context.cancellation.is_cancelled(); + match outcome { + _ if cancelled => Err(cancelled_response()), + Ok(Ok(value)) => { + let size = serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len()); + if size > MAX_RESULT_BYTES { + context.diagnostics().terminal( + function, + Some(&FunctionError::internal("result_limit")), + started, + ); + Err(internal_response()) + } else { + let quiet = context.quiet_success.load(Ordering::SeqCst) + || lock(&self.state).quiet_success.contains(function); + if !quiet { + context.diagnostics().terminal(function, None, started); + } + Ok(value) + } + } + Ok(Err(error)) => { + if !matches!(error, FunctionError::Cancelled) { + context + .diagnostics() + .terminal(function, Some(&error), started); + } + Err(response_error(&error)) + } + Err(_) => { + let _ = context.diagnostics().record( + "operationFailed", + function, + Some("panic"), + None, + Some(started.elapsed().as_millis().min(9_007_199_254_740_991) as u64), + ); + Err(internal_response()) + } + } + } + + /// A `lithe/function/*` request; it takes no host-request slot. + fn request(self: &Arc, method: &str, params: Value) -> Outcome { + self.exchange(method, params, false, &[], None) + } +} + +fn check_new_function(id: &str, functions: &BTreeMap) -> Result<(), SdkError> { + if !is_function_id(id) { + return Err(SdkError::new( + "sdk.function.invalidID", + format!("Function ID {id:?} must be dot-separated lowerCamelCase segments of at most 128 bytes."), + )); + } + if functions.contains_key(id) { + return Err(SdkError::new( + "sdk.function.duplicate", + format!("Function {id} is already declared."), + )); + } + if functions.len() >= MAX_FUNCTIONS { + return Err(SdkError::new( + "sdk.function.limit", + format!("A backend serves at most {MAX_FUNCTIONS} functions."), + )); + } + Ok(()) +} + +// Failure values can contain user data. Never format their messages or panic payloads +// into diagnostics; preserve the existing response contract separately. +fn failure_kind(error: &FunctionError) -> &'static str { + match error { + FunctionError::Plugin(_) => "plugin_error", + FunctionError::Internal(_) => "internal_error", + FunctionError::Cancelled => "cancelled", + } +} + +/// What a hook sees of `initialize`, before the backend answers it. +pub struct Initialize { + params: Value, + functions_version: Option, + existing: Vec, + added: BTreeMap, +} + +impl Initialize { + /// The host's `initialize` params. + pub fn params(&self) -> &Value { + &self.params + } + + /// The plugin-function protocol version the backend will answer (0), or + /// `None` when the host did not offer it. + pub fn functions_version(&self) -> Option { + self.functions_version + } + + /// The `rootUri` as a path. For a plugin backend this is the plugin's + /// `${stateDir}`, also the working directory. + pub fn root_path(&self) -> Option { + self.params["rootUri"].as_str().and_then(file_uri_path) + } + + /// Adds a function to the initial list, for example one that depends on + /// state found at startup. + pub fn function(&mut self, id: &str, handler: F) -> Result<(), SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + if self.existing.iter().any(|existing| existing == id) { + return Err(SdkError::new( + "sdk.function.duplicate", + format!("Function {id} is already declared."), + )); + } + if self.existing.len() + self.added.len() >= MAX_FUNCTIONS { + return Err(SdkError::new( + "sdk.function.limit", + format!("A backend serves at most {MAX_FUNCTIONS} functions."), + )); + } + check_new_function(id, &self.added)?; + self.added.insert(id.to_string(), Arc::new(handler)); + Ok(()) + } +} + +/// The path of a `file:` URI, percent-decoded. +fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path = rest.strip_prefix("localhost").unwrap_or(rest); + if !path.starts_with('/') { + return None; + } + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok()?; + decoded.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let mut path = String::from_utf8(decoded).ok()?; + // `/C:/Users` on Windows. + if cfg!(windows) && path.as_bytes().get(2) == Some(&b':') { + path.remove(0); + } + while path.len() > 1 && path.ends_with('/') { + path.pop(); + } + Some(PathBuf::from(path)) +} + +/// One running function call. +pub struct Context { + shared: Arc, + function_id: String, + operation_id: Value, + diagnostic_operation_id: Option, + cancellation: Cancellation, + host: Host, + workspace_context: Option, + caller: Option, + reporting: AtomicBool, + quiet_success: AtomicBool, +} + +/// The plugin that called a function through Core's relay. +/// Core writes it from the identity of the calling session and a caller +/// cannot supply it, so a provider may key per-caller state by it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Caller { + plugin_id: String, +} + +impl Caller { + /// The `caller` field of a relayed `lithe/function/invoke`, which is + /// exactly `{ pluginID }` with a non-empty string. + fn from_value(value: &Value) -> Option { + let fields = value.as_object().filter(|fields| fields.len() == 1)?; + let plugin_id = fields + .get("pluginID")? + .as_str() + .filter(|plugin_id| !plugin_id.is_empty())?; + Some(Self { + plugin_id: plugin_id.to_string(), + }) + } + + /// The calling plugin's ID, such as `dev.lithe.fixture.python`. + pub fn plugin_id(&self) -> &str { + &self.plugin_id + } +} + +impl Context { + /// Omits only this call's successful terminal fact; failures remain visible. + pub fn suppress_success_diagnostics(&self) { + self.quiet_success.store(true, Ordering::SeqCst); + } + + /// Structured diagnostics correlated with the host's original operation ID. + pub fn diagnostics(&self) -> Diagnostics { + Diagnostics { + shared: Arc::clone(&self.shared), + operation_id: self.diagnostic_operation_id.clone(), + } + } + + pub fn function_id(&self) -> &str { + &self.function_id + } + + /// Fires when the host cancels the call, withdraws its function, or + /// shuts the backend down. Core has already ended the call by then, so a + /// handler should stop its work and return promptly. + pub fn cancellation(&self) -> &Cancellation { + &self.cancellation + } + + /// `Err(Cancelled)` once the call is cancelled, for use with `?`. + pub fn check_cancelled(&self) -> Result<(), Cancelled> { + self.cancellation.check() + } + + /// Reports progress of this call; the host shows the latest report. + /// `message` is cut to 1024 bytes and `percentage` to 100. Reports after + /// the call ended are dropped. + pub fn progress(&self, message: &str, percentage: Option) { + if !self.reporting.load(Ordering::SeqCst) { + return; + } + let mut report = json!({ + "operationID": self.operation_id, + "message": bounded_text(message, MAX_MESSAGE_BYTES), + }); + if let Some(percentage) = percentage { + report["percentage"] = json!(percentage.min(100)); + } + self.shared.write( + &json!({ "jsonrpc": "2.0", "method": methods::FUNCTION_PROGRESS, "params": report }), + ); + } + + /// Host services for this call. A request still waiting for the host + /// when the call is cancelled or ends fails with `sdk.call.cancelled`, + /// which `?` turns into a cancelled answer; the SDK also cancels it on + /// the wire. + pub fn host(&self) -> &Host { + &self.host + } + + /// The `{ contextID, generation }` of the window the call came from; + /// `None` for calls without one, such as from the plugin's own UI. + pub fn workspace_context(&self) -> Option<&WorkspaceContext> { + self.workspace_context.as_ref() + } + + /// The plugin that called this function through Core's relay. `None` + /// when the host started the call, and for a `caller` of any other + /// shape. + pub fn caller(&self) -> Option<&Caller> { + self.caller.as_ref() + } + + /// `host().credentials()`. + pub fn credentials(&self) -> Credentials { + self.host.credentials() + } + + pub fn backend(&self) -> BackendHandle { + BackendHandle { + shared: Arc::clone(&self.shared), + } + } +} + +/// 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 { + shared: Arc, +} + +impl BackendHandle { + /// Structured facts outside a call; no operation ID is fabricated. + pub fn diagnostics(&self) -> Diagnostics { + Diagnostics { + shared: Arc::clone(&self.shared), + operation_id: None, + } + } + + /// The negotiated plugin-function protocol version (0); `None` before + /// `initialize` or when the host did not offer it. + pub fn functions_version(&self) -> Option { + self.shared.functions_version() + } + + /// IDs of the functions the backend currently serves, sorted. + pub fn function_ids(&self) -> Vec { + lock(&self.shared.state).functions.keys().cloned().collect() + } + + /// Adds a function after `initialize` and waits until the host accepted + /// it. The handler is installed first, because the host may invoke it as + /// soon as its registry changed. + pub fn register_function(&self, id: &str, handler: F) -> Result<(), SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + let _order = lock(&self.shared.registrations); + { + let mut state = lock(&self.shared.state); + state.require_functions()?; + check_new_function(id, &state.functions)?; + state.functions.insert(id.to_string(), Arc::new(handler)); + } + let registered = self.shared.request( + methods::FUNCTION_REGISTER, + json!({ "functions": [{ "id": id }] }), + ); + if registered.is_err() { + lock(&self.shared.state).functions.remove(id); + } + registered.map(drop) + } + + /// Withdraws a function and waits until the host accepted it. The host + /// ends the function's running calls as revoked, and the SDK cancels + /// them. A handler must not withdraw its own function: that revokes the + /// call it runs in. + pub fn unregister_function(&self, id: &str) -> Result<(), SdkError> { + let _order = lock(&self.shared.registrations); + { + let state = lock(&self.shared.state); + state.require_functions()?; + if !state.functions.contains_key(id) { + return Err(SdkError::new( + "sdk.function.unknown", + format!("Function {id} is not registered."), + )); + } + } + self.shared + .request(methods::FUNCTION_UNREGISTER, json!({ "ids": [id] }))?; + let running: Vec<_> = { + let mut state = lock(&self.shared.state); + state.functions.remove(id); + state.quiet_success.remove(id); + state + .incoming + .values() + .filter(|running| running.function_id == id) + .map(|running| running.cancellation.clone()) + .collect() + }; + running.iter().for_each(Cancellation::cancel); + Ok(()) + } + + /// Host services, for work outside a function call; usable once the + /// session is ready. + pub fn host(&self) -> Host { + Host::new(Arc::clone(&self.shared), Vec::new()) + } + + /// `host().credentials()`. + pub fn credentials(&self) -> Credentials { + self.host().credentials() + } +} + +/// The cancellation of one call, or of work a plugin may stop for its own +/// reasons (see [`Host::with_cancellation`]). Clones observe the same token. +#[derive(Clone)] +pub struct Cancellation(Arc); + +struct CancellationInner { + state: Mutex, + changed: Condvar, +} + +struct CancellationState { + cancelled: bool, + next_watch: u64, + callbacks: Vec<(u64, Box)>, +} + +impl Default for Cancellation { + fn default() -> Self { + Self::new() + } +} + +impl Cancellation { + pub fn new() -> Self { + Self(Arc::new(CancellationInner { + state: Mutex::new(CancellationState { + cancelled: false, + next_watch: 0, + callbacks: Vec::new(), + }), + changed: Condvar::new(), + })) + } + + pub fn is_cancelled(&self) -> bool { + lock(&self.0.state).cancelled + } + + /// `Err(Cancelled)` once cancelled, for use with `?`. + pub fn check(&self) -> Result<(), Cancelled> { + if self.is_cancelled() { + Err(Cancelled) + } else { + Ok(()) + } + } + + /// Blocks for at most `timeout`, and returns whether the call was + /// cancelled. Use it instead of `thread::sleep` between retries. + pub fn wait_timeout(&self, timeout: Duration) -> bool { + let state = lock(&self.0.state); + let (state, _) = self + .0 + .changed + .wait_timeout_while(state, timeout, |state| !state.cancelled) + .unwrap_or_else(PoisonError::into_inner); + state.cancelled + } + + /// Runs `callback` once, when the call is cancelled, or right away when + /// it already is: for example to cancel a query a driver is running. + /// The callback runs on the thread that cancels, so it must not block. + pub fn on_cancel(&self, callback: impl FnOnce() + Send + 'static) { + let _ = self.watch(callback); + } + + /// Cancels: wakes waiters and runs the callbacks, once. + pub fn cancel(&self) { + let callbacks = { + let mut state = lock(&self.0.state); + if state.cancelled { + return; + } + state.cancelled = true; + std::mem::take(&mut state.callbacks) + }; + self.0.changed.notify_all(); + for (_, callback) in callbacks { + callback(); + } + } + + /// Like [`on_cancel`](Self::on_cancel), but removable with + /// [`unwatch`](Self::unwatch); `None` when already cancelled, after + /// running `callback`. + pub(crate) fn watch(&self, callback: impl FnOnce() + Send + 'static) -> Option { + let mut state = lock(&self.0.state); + if state.cancelled { + drop(state); + callback(); + return None; + } + let watch = state.next_watch; + state.next_watch += 1; + state.callbacks.push((watch, Box::new(callback))); + Some(watch) + } + + pub(crate) fn unwatch(&self, watch: u64) { + lock(&self.0.state) + .callbacks + .retain(|(other, _)| *other != watch); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn file_uris_become_paths() { + assert_eq!( + file_uri_path("file:///var/state/"), + Some(PathBuf::from("/var/state")) + ); + assert_eq!( + file_uri_path("file://localhost/a%20b/%C3%A9"), + Some(PathBuf::from("/a b/é")) + ); + assert_eq!(file_uri_path("file:///"), Some(PathBuf::from("/"))); + assert_eq!(file_uri_path("https://example.com/"), None); + assert_eq!(file_uri_path("file:///bad%zz"), None); + } + + #[test] + fn cancellation_wakes_waiters_and_runs_callbacks_once() { + let cancellation = Cancellation::new(); + assert!(!cancellation.wait_timeout(Duration::ZERO)); + let (sender, receiver) = mpsc::channel(); + let early = sender.clone(); + cancellation.on_cancel(move || early.send("registered").unwrap()); + let waiter = { + let cancellation = cancellation.clone(); + thread::spawn(move || cancellation.wait_timeout(Duration::from_secs(10))) + }; + cancellation.cancel(); + cancellation.cancel(); + assert!(waiter.join().unwrap()); + cancellation.on_cancel(move || sender.send("late").unwrap()); + let received: Vec<_> = receiver.try_iter().collect(); + assert_eq!(received, ["registered", "late"]); + assert_eq!(cancellation.check(), Err(Cancelled)); + } +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/diagnostics.rs b/shared/plugins/sdk/rust/plugin-sdk/src/diagnostics.rs new file mode 100644 index 00000000..841e47cf --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/diagnostics.rs @@ -0,0 +1,170 @@ +//! Typed plugin facts transported to the host's litheLogging 1 catalog. +use crate::backend::{lock, Shared}; +use crate::{FunctionError, SdkError}; +use serde_json::json; +use std::sync::Arc; +use std::time::Instant; + +pub(crate) const MAX_INTEGER: u64 = 9_007_199_254_740_991; +pub(crate) const OPERATIONS: &[&str] = &[ + "initialize", + "shutdown", + "notification", + "function-call", + "result-encoding", + "lithe/build/model", + "lithe/build/dependencies", + "lithe/build/execute", + "lithe/build/test", + "lithe/build/discoverTests", + "lithe/build/prepareRun", + "lithe/build/prepareDebug", + "lithe/vcs/status", + "lithe/vcs/baseContent", + "lithe/vcs/history", + "lithe/vcs/fileHistory", + "lithe/vcs/annotate", + "lithe/vcs/checkin", + "lithe/vcs/rollback", + "lithe/vcs/update", + "lithe/vcs/fileOperation", + "lithe/vcs/resolve", + "lithe/vcs/cleanup", + "lithe/keymap/import", +]; +fn token(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._:/-$".contains(&c)) +} + +pub(crate) fn request_operation_id( + operation_id: &serde_json::Value, + request_id: &serde_json::Value, +) -> Option { + if let Some(id) = operation_id.as_str().filter(|id| token(id, 128)) { + return Some(id.to_string()); + } + let fallback = if let Some(id) = request_id.as_str() { + id.to_string() + } else if let Some(id) = request_id + .as_i64() + .filter(|id| id.unsigned_abs() <= MAX_INTEGER) + { + id.to_string() + } else { + request_id + .as_u64() + .filter(|id| *id <= MAX_INTEGER)? + .to_string() + }; + token(&fallback, 128).then_some(fallback) +} +/// A lightweight handle using the existing protocol writer, without a queue or file I/O. +#[derive(Clone)] +pub struct Diagnostics { + pub(crate) shared: Arc, + pub(crate) operation_id: Option, +} +impl Diagnostics { + /// Records a handled degradation; code is a controlled category, never an error message. + pub fn degraded( + &self, + operation: &str, + code: &str, + count: Option, + ) -> Result<(), SdkError> { + self.record("degraded", operation, Some(code), count, None) + } + /// Records a real recovery, with the same controlled operation and category. + pub fn recovered( + &self, + operation: &str, + code: &str, + count: Option, + ) -> Result<(), SdkError> { + self.record("recovered", operation, Some(code), count, None) + } + pub(crate) fn terminal( + &self, + operation: &str, + error: Option<&FunctionError>, + started: Instant, + ) { + let code = error.map(|error| match error { + FunctionError::Plugin(error) => error.code(), + FunctionError::Cancelled => "cancelled", + FunctionError::Internal(_) => "internal_error", + }); + let _ = self.record( + if error.is_some() { + "operationFailed" + } else { + "operationFinished" + }, + operation, + code, + None, + Some(started.elapsed().as_millis().min(u128::from(MAX_INTEGER)) as u64), + ); + } + pub(crate) fn record( + &self, + kind: &str, + operation: &str, + code: Option<&str>, + count: Option, + duration: Option, + ) -> Result<(), SdkError> { + let state = lock(&self.shared.state); + if !OPERATIONS.contains(&operation) && !state.functions.contains_key(operation) { + return Err(SdkError::new( + "sdk.logging.invalidOperation", + "Diagnostics need a registered operation.", + )); + } + let enabled = state.logging; + drop(state); + if code.is_some_and(|code| !token(code, 64)) { + return Err(SdkError::new( + "sdk.logging.invalidCode", + "Diagnostics need a controlled code.", + )); + } + if count.is_some_and(|count| count > MAX_INTEGER) { + return Err(SdkError::new( + "sdk.logging.invalidCount", + "Diagnostics need a safe count.", + )); + } + if !enabled { + return Ok(()); + } + let mut params = json!({ "kind": kind, "operation": if operation.len() <= 64 {operation} else {"function-call"} }); + if let Some(code) = code { + params["code"] = json!(code); + } + if let Some(count) = count { + params["count"] = json!(count); + } + if let Some(duration) = duration { + params["durationMs"] = json!(duration); + } + if let Some(id) = self.operation_id.as_deref().filter(|id| token(id, 128)) { + params["operationId"] = json!(id); + } + let mut state = lock(&self.shared.state); + if !state.logging_ready { + if state.early_diagnostics.len() < 8 { + state.early_diagnostics.push(params); + } + return Ok(()); + } + drop(state); + self.shared + .write(&json!({ "jsonrpc": "2.0", "method": "lithe/log", "params": params })); + Ok(()) + } +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/error.rs b/shared/plugins/sdk/rust/plugin-sdk/src/error.rs new file mode 100644 index 00000000..15ecd543 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/error.rs @@ -0,0 +1,392 @@ +//! The three ways a call can fail, and the JSON-RPC error each becomes. + +use crate::protocol::{ + bounded_text, error_codes, is_plugin_error_code, methods, MAX_MESSAGE_BYTES, +}; +use serde_json::{json, Map, Value}; +use std::fmt; + +/// A failure a handler reports on purpose. Core ends the call with the stable +/// `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so +/// the plugin's own UI can tell its failures apart. Lithe never branches on +/// it. The message is shown to the user as written, so it must not contain +/// secrets, stack traces, or machine paths. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginError { + code: String, + message: String, +} + +impl PluginError { + /// `code` is an identifier of at most 64 bytes, such as + /// `db.connectionFailed`; Core keeps the first 1024 bytes of `message`. + /// + /// # Panics + /// + /// When `code` does not start with a letter, uses characters other than + /// letters, digits, `.`, `_`, and `-`, or is longer than 64 bytes. In a + /// handler, that panic ends the call as an internal error. + pub fn new(code: impl Into, message: impl Into) -> Self { + let code = code.into(); + assert!( + is_plugin_error_code(&code), + "Plugin error code {code:?} must start with a letter, use letters, digits, \".\", \"_\" or \"-\", and be at most 64 bytes." + ); + Self { + code, + message: message.into(), + } + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for PluginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for PluginError {} + +/// A handler that noticed its call was cancelled; see +/// [`Cancellation::check`](crate::Cancellation::check). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl fmt::Display for Cancelled { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("The call was cancelled.") + } +} + +impl std::error::Error for Cancelled {} + +/// An SDK usage error, or a host request that failed. `code` is the host's +/// `error.data.code` (such as `plugin.host.contextExpired`) or one of the +/// SDK's own `sdk.*` codes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkError { + code: String, + message: String, + rpc_code: Option, + details: Option>, +} + +/// SDK error codes a backend may want to tell apart. +pub mod sdk_codes { + /// The call that made the host request, or a token passed to + /// [`Host::with_cancellation`](crate::Host::with_cancellation), was + /// cancelled first. + pub const CALL_CANCELLED: &str = "sdk.call.cancelled"; + /// The session ended before the host answered. + pub const SESSION_CLOSED: &str = "sdk.session.closed"; +} + +/// Stable `plugin.host.*` failure codes and their JSON-RPC numbers. A later +/// host adds codes after `-32026`. +pub const HOST_ERROR_CODES: [(&str, i64); 21] = [ + ("plugin.host.unavailable", -32601), + ("plugin.host.invalidParams", -32602), + ("plugin.host.failed", -32603), + ("plugin.host.cancelled", -32800), + ("plugin.host.permissionDenied", -32010), + ("plugin.host.contextExpired", -32011), + ("plugin.host.authorityUnsupported", -32012), + ("plugin.host.versionConflict", -32013), + ("plugin.host.limitExceeded", -32014), + ("plugin.host.timeout", -32015), + ("plugin.host.notFound", -32016), + ("plugin.host.readOnly", -32017), + ("plugin.host.unsupportedOption", -32018), + ("plugin.host.preconditionFailed", -32019), + ("plugin.host.busy", -32020), + ("plugin.host.rateLimited", -32021), + ("plugin.host.languageServerUnavailable", -32022), + ("plugin.host.languageServerError", -32023), + ("plugin.host.methodNotDeclared", -32024), + ("plugin.host.toolUnresolved", -32025), + ("plugin.host.foreignContribution", -32026), +]; + +/// The `plugin.function.*` failures a relayed call from another plugin may +/// end with, and their JSON-RPC numbers. The +/// relay's `plugin.host.*` outcomes (`timeout`, `cancelled`, `contextExpired`, +/// `authorityUnsupported`) are in [`HOST_ERROR_CODES`]. A +/// `plugin.function.failed` answer names the provider's own code in its +/// `pluginErrorCode` detail. +pub const RELAY_ERROR_CODES: [(&str, i64); 11] = [ + ("plugin.function.notDeclared", -32027), + ("plugin.function.notExported", -32028), + ("plugin.function.providerDisabled", -32029), + ("plugin.function.versionMismatch", -32030), + ("plugin.function.callCycle", -32065), + ("plugin.function.depthExceeded", -32066), + ("plugin.function.tooManyCalls", -32067), + ("plugin.function.payloadTooLarge", -32068), + ("plugin.function.providerUnavailable", -32069), + ("plugin.function.failed", -32070), + ("plugin.function.invalidResult", -32071), +]; + +impl SdkError { + pub(crate) fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + rpc_code: None, + details: None, + } + } + + /// The answer Core gives a host request it does not offer. + pub(crate) fn unavailable(message: impl Into) -> Self { + Self { + rpc_code: Some(-32601), + ..Self::new("plugin.host.unavailable", message) + } + } + + /// The failure for a JSON-RPC error answer. A host method without a + /// known code is a host failure: a number names one code (of + /// [`HOST_ERROR_CODES`] or [`RELAY_ERROR_CODES`]), and an unknown number + /// is `plugin.host.failed`. + pub(crate) fn from_response(method: &str, error: &Value) -> Self { + let rpc_code = error["code"].as_i64(); + let code = match error["data"]["code"].as_str() { + Some(code) => code, + None if method.starts_with(methods::HOST_PREFIX) => HOST_ERROR_CODES + .iter() + .chain(RELAY_ERROR_CODES.iter()) + .find(|(_, number)| Some(*number) == rpc_code) + .map_or("plugin.host.failed", |(code, _)| code), + None => "sdk.host.error", + }; + Self { + code: code.to_string(), + message: error["message"] + .as_str() + .map_or_else(|| format!("{method} failed."), str::to_string), + rpc_code, + details: error["data"]["details"].as_object().cloned(), + } + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } + + /// The JSON-RPC error code of a host response, such as `-32601`. + pub fn rpc_code(&self) -> Option { + self.rpc_code + } + + /// The `error.data.details` object a host may add, such as + /// `{ "option": … }` or `{ "retryAfterMilliseconds": … }`. + pub fn details(&self) -> Option<&Map> { + self.details.as_ref() + } +} + +impl fmt::Display for SdkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for SdkError {} + +/// How a function handler fails. `?` converts every error type a handler +/// typically meets: a [`PluginError`] keeps its code, [`Cancelled`] (and a +/// host request cut short by cancellation) answers as cancelled, and anything +/// else becomes a generic internal error; automatic diagnostics omit its contents. +#[derive(Debug)] +pub enum FunctionError { + Plugin(PluginError), + Cancelled, + /// Retained in memory; automatic diagnostics record only the internal-error category. + Internal(String), +} + +impl FunctionError { + pub fn internal(details: impl fmt::Display) -> Self { + Self::Internal(details.to_string()) + } +} + +impl fmt::Display for FunctionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Plugin(error) => error.fmt(formatter), + Self::Cancelled => Cancelled.fmt(formatter), + Self::Internal(details) => formatter.write_str(details), + } + } +} + +impl From for FunctionError { + fn from(error: PluginError) -> Self { + Self::Plugin(error) + } +} + +impl From for FunctionError { + fn from(_: Cancelled) -> Self { + Self::Cancelled + } +} + +impl From for FunctionError { + fn from(error: SdkError) -> Self { + if error.code == sdk_codes::CALL_CANCELLED { + Self::Cancelled + } else { + Self::Internal(error.to_string()) + } + } +} + +impl From for FunctionError { + fn from(error: std::io::Error) -> Self { + Self::Internal(error.to_string()) + } +} + +impl From for FunctionError { + fn from(error: serde_json::Error) -> Self { + Self::Internal(error.to_string()) + } +} + +pub(crate) fn cancelled_response() -> Value { + json!({ "code": error_codes::REQUEST_CANCELLED, "message": "The request was cancelled." }) +} + +/// The JSON-RPC error object for a handler failure. Only a [`PluginError`] +/// carries a plugin code; anything else is a generic internal error, so +/// stack traces and machine paths never reach the host's UI. +pub(crate) fn response_error(error: &FunctionError) -> Value { + match error { + FunctionError::Cancelled => cancelled_response(), + FunctionError::Plugin(error) => json!({ + "code": error_codes::REQUEST_FAILED, + "message": bounded_text(&error.message, MAX_MESSAGE_BYTES), + "data": { "code": error.code }, + }), + FunctionError::Internal(_) => internal_response(), + } +} + +pub(crate) fn internal_response() -> Value { + json!({ "code": error_codes::INTERNAL_ERROR, "message": "The plugin backend failed unexpectedly." }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_plugin_errors_reach_the_host_with_their_message_and_code() { + let plugin = response_error(&PluginError::new("db.locked", "é".repeat(600)).into()); + assert_eq!(plugin["code"], -32000); + assert_eq!(plugin["data"]["code"], "db.locked"); + assert_eq!(plugin["message"].as_str().unwrap().len(), 1024); + let internal = response_error(&FunctionError::internal("/home/me/secret: No such file")); + assert_eq!(internal, internal_response()); + assert_eq!(response_error(&Cancelled.into())["code"], -32800); + let cut_short: FunctionError = SdkError::new(sdk_codes::CALL_CANCELLED, "x").into(); + assert!(matches!(cut_short, FunctionError::Cancelled)); + } + + #[test] + fn host_failures_keep_their_code_or_take_it_from_the_number() { + let coded = SdkError::from_response( + methods::CREDENTIALS_GET, + &json!({ "code": -32010, "message": "No.", "data": { "code": "plugin.host.permissionDenied", "details": { "a": 1 } } }), + ); + assert_eq!(coded.code(), "plugin.host.permissionDenied"); + assert_eq!(coded.rpc_code(), Some(-32010)); + assert_eq!(coded.details(), json!({ "a": 1 }).as_object()); + for (code, number) in HOST_ERROR_CODES { + let error = + SdkError::from_response(methods::CREDENTIALS_GET, &json!({ "code": number })); + assert_eq!(error.code(), code); + assert_eq!(error.message(), "lithe/host/credentials/get failed."); + } + let later = SdkError::from_response( + methods::CREDENTIALS_GET, + &json!({ "code": -32050, "message": "x", "data": { "details": ["not", "an", "object"] } }), + ); + assert_eq!(later.code(), "plugin.host.failed"); + assert_eq!(later.details(), None); + let other = SdkError::from_response(methods::FUNCTION_REGISTER, &json!({ "code": -32602 })); + assert_eq!(other.code(), "sdk.host.error"); + } + + #[test] + fn the_host_and_relay_tables_hold_every_documented_code_once() { + assert_eq!(HOST_ERROR_CODES.len(), 21); + assert_eq!(RELAY_ERROR_CODES.len(), 11); + let mut codes = std::collections::BTreeSet::new(); + let mut numbers = std::collections::BTreeSet::new(); + for (prefix, table) in [ + ("plugin.host.", &HOST_ERROR_CODES[..]), + ("plugin.function.", &RELAY_ERROR_CODES[..]), + ] { + for (code, number) in table { + assert!(code.starts_with(prefix), "{code}"); + assert!(codes.insert(*code), "{code} is listed twice"); + assert!(numbers.insert(*number), "{number} names two codes"); + } + } + } + + #[test] + fn relay_failures_keep_their_code_number_and_the_providers_own_code() { + for (code, number) in RELAY_ERROR_CODES { + // A number alone names the code. + let error = + SdkError::from_response(methods::FUNCTIONS_INVOKE, &json!({ "code": number })); + assert_eq!(error.code(), code); + assert_eq!(error.rpc_code(), Some(number)); + assert_eq!(error.message(), "lithe/host/functions/invoke failed."); + } + let failed = SdkError::from_response( + methods::FUNCTIONS_INVOKE, + &json!({ + "code": -32070, + "message": "The plugin function failed.", + "data": { + "code": "plugin.function.failed", + "details": { "pluginErrorCode": "go.noModule" } + } + }), + ); + assert_eq!(failed.code(), "plugin.function.failed"); + assert_eq!(failed.message(), "The plugin function failed."); + assert_eq!( + failed.details(), + json!({ "pluginErrorCode": "go.noModule" }).as_object() + ); + // Outside a host method a relay number stays an unnamed answer. + let outside = + SdkError::from_response(methods::FUNCTION_REGISTER, &json!({ "code": -32070 })); + assert_eq!(outside.code(), "sdk.host.error"); + } + + #[test] + #[should_panic(expected = "must start with a letter")] + fn an_invalid_plugin_error_code_is_a_programming_error() { + let _ = PluginError::new("1bad", "message"); + } +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/framing.rs b/shared/plugins/sdk/rust/plugin-sdk/src/framing.rs new file mode 100644 index 00000000..9eb203b5 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/framing.rs @@ -0,0 +1,170 @@ +//! LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 +//! JSON body. Lithe talks to every plugin backend this way, so the SDK owns +//! the framing and plugin code never writes to the protocol stream itself. + +use serde_json::Value; +use std::fmt; +use std::io::{self, BufRead, Read, Write}; + +/// Largest header block accepted from the host. +const MAX_HEADER_BYTES: usize = 8192; +/// Largest body accepted from the host. Larger frames end the session. +pub(crate) const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; + +/// One frame read from the host. +#[derive(Debug, PartialEq)] +pub(crate) enum Frame { + Message(Value), + /// A body that is not JSON; the framing position is intact. + Malformed, +} + +/// A framing error the session cannot recover from. +#[derive(Debug)] +pub(crate) struct FramingError(pub String); + +impl fmt::Display for FramingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl From for FramingError { + fn from(error: io::Error) -> Self { + Self(format!("The input failed: {error}")) + } +} + +/// Reads the next frame, or `None` when the input ended between frames. +pub(crate) fn read_frame(reader: &mut impl BufRead) -> Result, FramingError> { + let mut header = Vec::new(); + let mut length = None; + loop { + let mut line = Vec::new(); + let remaining = (MAX_HEADER_BYTES + 1).saturating_sub(header.len()) as u64; + reader + .by_ref() + .take(remaining) + .read_until(b'\n', &mut line)?; + if line.is_empty() { + if header.is_empty() { + return Ok(None); + } + return Err(FramingError( + "The input ended inside a message header.".into(), + )); + } + header.extend_from_slice(&line); + // The same bound whether the header arrived whole or in pieces. + if header.len() > MAX_HEADER_BYTES { + return Err(FramingError("The message header is too long.".into())); + } + if !line.ends_with(b"\n") { + return Err(FramingError( + "The input ended inside a message header.".into(), + )); + } + let text = String::from_utf8_lossy(&line); + let text = text.trim_end_matches(['\r', '\n']); + if text.is_empty() { + break; + } + if length.is_none() { + length = content_length(text)?; + } + } + let length = length + .ok_or_else(|| FramingError("A message has no valid Content-Length header.".into()))?; + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + Ok(Some(match serde_json::from_slice(&body) { + Ok(message) => Frame::Message(message), + Err(_) => Frame::Malformed, + })) +} + +/// The length of a `Content-Length` line, `None` for any other header. +fn content_length(line: &str) -> Result, FramingError> { + let Some((name, value)) = line.split_once(':') else { + return Ok(None); + }; + if !name.trim().eq_ignore_ascii_case("content-length") { + return Ok(None); + } + let value = value.trim(); + let invalid = || FramingError("A message has no valid Content-Length header.".into()); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid()); + } + let length: usize = value.parse().map_err(|_| invalid())?; + if length > MAX_FRAME_BYTES { + return Err(FramingError(format!( + "A message of {length} bytes exceeds the {MAX_FRAME_BYTES}-byte limit." + ))); + } + Ok(Some(length)) +} + +/// Writes one JSON-RPC message as a complete frame and flushes it. +pub(crate) fn write_frame(writer: &mut dyn Write, message: &Value) -> io::Result<()> { + let body = serde_json::to_vec(message)?; + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.extend_from_slice(&body); + writer.write_all(&frame)?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::BufReader; + + fn frames(input: &[u8]) -> Vec, String>> { + let mut reader = BufReader::with_capacity(3, input); + let mut frames = Vec::new(); + loop { + let frame = read_frame(&mut reader).map_err(|error| error.0); + let done = !matches!(frame, Ok(Some(_))); + frames.push(frame); + if done { + return frames; + } + } + } + + #[test] + fn decodes_consecutive_frames_and_reports_malformed_bodies_in_place() { + let mut input = Vec::new(); + write_frame(&mut input, &json!({"id": 1})).unwrap(); + input.extend_from_slice(b"content-length: 3\r\nContent-Type: x\r\n\r\nnot"); + write_frame(&mut input, &json!({"id": "é"})).unwrap(); + assert_eq!( + frames(&input), + [ + Ok(Some(Frame::Message(json!({"id": 1})))), + Ok(Some(Frame::Malformed)), + Ok(Some(Frame::Message(json!({"id": "é"})))), + Ok(None), + ] + ); + } + + #[test] + fn refuses_frames_it_cannot_delimit() { + let oversized = format!("Content-Length: {}\r\n\r\n", MAX_FRAME_BYTES + 1); + let long_header = format!("X: {}\r\n\r\n", "a".repeat(MAX_HEADER_BYTES)); + for (input, expected) in [ + ("Content-Type: x\r\n\r\n{}", "no valid Content-Length"), + ("Content-Length: -1\r\n\r\n", "no valid Content-Length"), + (oversized.as_str(), "exceeds"), + (long_header.as_str(), "too long"), + ("Content-Length: 2\r\n", "inside a message header"), + ("Content-Length: 4\r\n\r\n{}", "input failed"), + ] { + let last = frames(input.as_bytes()).pop().unwrap(); + let error = last.expect_err(input); + assert!(error.contains(expected), "{input:?}: {error}"); + } + } +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/host.rs b/shared/plugins/sdk/rust/plugin-sdk/src/host.rs new file mode 100644 index 00000000..94b688e6 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/host.rs @@ -0,0 +1,1310 @@ +//! Host services of the plugin-function protocol: the `lithe/host/*` +//! requests a backend sends to Lithe, the subscriptions some of them create, +//! and the [`Host`] handlers use. The backend owns the wire; this module owns +//! what Core expects of a well-behaved client -- at most 16 unanswered host +//! requests, cancellation through `$/cancelRequest`, and subscription events +//! routed by ID. +//! +//! Contract: `contract/v1/README.md`, "Host services" and the host sections +//! after it. + +use crate::backend::{lock, Cancellation, Phase, Shared, State}; +use crate::error::{sdk_codes, SdkError}; +use crate::protocol::{ + generation, is_credential_key, is_function_id, is_parent_operation_id, methods, + MAX_CREDENTIAL_SECRET_BYTES, MAX_EARLY_EVENTS, MAX_PENDING_HOST_REQUESTS, +}; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::Duration; + +pub(crate) type Outcome = Result; + +/// The answer Core gives a host request when it did not offer `litheFunctions`. +const NOT_NEGOTIATED: &str = + "The host did not negotiate plugin functions (litheFunctions), which offer host services."; + +/// How a request this backend sent ended for its caller. +pub(crate) enum Settled { + Answered(Outcome), + Cancelled, +} + +/// One request this backend sent to the host, until the host answers it. +pub(crate) struct Outgoing { + method: String, + sender: mpsc::Sender, + /// Whether it holds one of the 16 host-request slots. A cancelled + /// request keeps its slot until the host answers. + slot: bool, + /// Where the events of the subscription it creates go. + route: Option, +} + +/// The session's host-request state, guarded by the state lock. +#[derive(Default)] +pub(crate) struct Channel { + in_flight: usize, + /// Requests waiting for a free slot, in call order. + waiting: VecDeque, + next_ticket: u64, + /// Subscribing requests not yet answered; early events wait only while + /// there are some. + subscribing: usize, + routes: HashMap, + early: VecDeque<(String, u64, Value)>, +} + +pub(crate) struct Route { + live: Arc, + events: mpsc::Sender, + last: u64, +} + +impl Route { + fn emit(&mut self, subscription_id: &str, sequence: u64, event: Value) { + let gap = self.last.checked_add(1) != Some(sequence); + self.last = sequence; + let _ = self.events.send(Event { + subscription_id: subscription_id.to_string(), + sequence, + gap, + event, + }); + } +} + +fn closed() -> SdkError { + SdkError::new(sdk_codes::SESSION_CLOSED, "The session ended.") +} + +fn cancelled() -> SdkError { + SdkError::new(sdk_codes::CALL_CANCELLED, "The request was cancelled.") +} + +/// Why `state` refuses a new request, if it does. +fn refusal(state: &State, scope: &[Cancellation]) -> Option { + match state.phase { + Phase::Ready => scope.iter().any(Cancellation::is_cancelled).then(cancelled), + Phase::Exited => Some(closed()), + _ => Some(SdkError::new( + "sdk.session.notReady", + "The session is not initialized.", + )), + } +} + +/// Wakes a request whose scope was cancelled, waiting for a slot or an answer. +fn wake(shared: &Weak, sender: &mpsc::Sender) { + let _ = sender.send(Settled::Cancelled); + if let Some(shared) = shared.upgrade() { + // Taking the lock orders this wake-up after the waiter's last check. + let _state = lock(&shared.state); + shared.slots.notify_all(); + } +} + +impl Shared { + /// Sends a request to the host and waits for its answer, the end of the + /// session, or the cancellation of a token in `scope`. A request with a + /// `slot` first waits its turn among the 16 the host accepts at once. + pub(crate) fn exchange( + self: &Arc, + method: &str, + params: Value, + slot: bool, + scope: &[Cancellation], + route: Option, + ) -> Outcome { + let id = format!("sdk-{}", self.next_request.fetch_add(1, Ordering::Relaxed)); + let (sender, receiver) = mpsc::channel(); + let watches: Vec<_> = scope + .iter() + .filter_map(|token| { + let shared = Arc::downgrade(self); + let sender = sender.clone(); + let watch = token.watch(move || wake(&shared, &sender))?; + Some((token, watch)) + }) + .collect(); + let live = route.as_ref().map(|route| Arc::clone(&route.live)); + let outcome = self.send_and_wait(&id, method, params, slot, scope, route, sender, receiver); + for (token, watch) in watches { + token.unwatch(watch); + } + match outcome { + Settled::Answered(outcome) => outcome, + Settled::Cancelled => { + let pending = { + let mut state = lock(&self.state); + if let Some(live) = &live { + // Answered just before the cancellation won. + state + .channel + .routes + .retain(|_, route| !Arc::ptr_eq(&route.live, live)); + } + state.outgoing.get_mut(&id).map(|entry| entry.route = None) + }; + if pending.is_some() && self.functions_version().is_some() { + self.write(&json!({ + "jsonrpc": "2.0", + "method": methods::CANCEL_REQUEST, + "params": { "id": id }, + })); + } + // A cancelled mutation may already have taken effect; it is + // never sent again. + Err(cancelled()) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn send_and_wait( + &self, + id: &str, + method: &str, + params: Value, + slot: bool, + scope: &[Cancellation], + route: Option, + sender: mpsc::Sender, + receiver: mpsc::Receiver, + ) -> Settled { + let mut state = lock(&self.state); + if slot { + let ticket = state.channel.next_ticket; + state.channel.next_ticket += 1; + state.channel.waiting.push_back(ticket); + loop { + if let Some(error) = refusal(&state, scope) { + state.channel.waiting.retain(|waiting| *waiting != ticket); + self.slots.notify_all(); + return Settled::Answered(Err(error)); + } + if state.channel.in_flight < MAX_PENDING_HOST_REQUESTS + && state.channel.waiting.front() == Some(&ticket) + { + break; + } + state = self + .slots + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + state.channel.waiting.pop_front(); + state.channel.in_flight += 1; + self.slots.notify_all(); + } else if let Some(error) = refusal(&state, scope) { + return Settled::Answered(Err(error)); + } + state.outgoing.insert( + id.to_string(), + Outgoing { + method: method.to_string(), + sender, + slot, + route, + }, + ); + drop(state); + let mut message = json!({ "jsonrpc": "2.0", "id": id, "method": method }); + if !params.is_null() { + message["params"] = params; + } + self.write(&message); + receiver + .recv() + .unwrap_or_else(|_| Settled::Answered(Err(closed()))) + } + + /// Settles the request a host response answers. + pub(crate) fn settle_outgoing(&self, id: &Value, message: &Map) { + let Some(id) = id.as_str() else { + return; + }; + let mut state = lock(&self.state); + let Some(entry) = state.outgoing.remove(id) else { + return; + }; + if entry.slot { + state.channel.in_flight -= 1; + self.slots.notify_all(); + } + let mut outcome = match message.get("error").filter(|error| !error.is_null()) { + Some(error) => Err(SdkError::from_response(&entry.method, error)), + None => Ok(message.get("result").cloned().unwrap_or(Value::Null)), + }; + if let (Ok(result), Some(mut route)) = (&outcome, entry.route) { + match result["subscriptionID"] + .as_str() + .filter(|id| is_credential_key(id)) + { + Some(subscription_id) => { + let channel = &mut state.channel; + let (early, rest) = channel + .early + .drain(..) + .partition::, _>(|(id, _, _)| id == subscription_id); + channel.early = rest; + // Events the host sent before this answer. + for (_, sequence, event) in early { + route.emit(subscription_id, sequence, event); + } + channel.routes.insert(subscription_id.to_string(), route); + } + None => { + outcome = Err(SdkError::new( + "sdk.host.invalidResult", + format!("{} answered without a subscriptionID.", entry.method), + )) + } + } + } + drop(state); + let _ = entry.sender.send(Settled::Answered(outcome)); + } + + /// Routes one `lithe/host/subscriptions/notify`. + pub(crate) fn deliver(&self, params: &Value) { + let Some(subscription_id) = params["subscriptionID"].as_str() else { + return; + }; + let sequence = params["sequence"].as_u64().unwrap_or(0); + let event = params.get("event").cloned().unwrap_or(Value::Null); + let mut state = lock(&self.state); + let channel = &mut state.channel; + if let Some(route) = channel.routes.get_mut(subscription_id) { + route.emit(subscription_id, sequence, event); + } else if channel.subscribing > 0 { + if channel.early.len() < MAX_EARLY_EVENTS { + channel + .early + .push_back((subscription_id.to_string(), sequence, event)); + } else { + drop(state); + self.failed("notification", "subscription_queue_full"); + } + } + // Otherwise the subscription was disposed; nothing follows a disposal. + } + + /// Ends every host request and subscription with the session. Runs with + /// the state locked. + pub(crate) fn close_channel(&self, state: &mut State) { + for (_, entry) in state.outgoing.drain() { + let _ = entry.sender.send(Settled::Answered(Err(closed()))); + } + for (_, route) in state.channel.routes.drain() { + route.live.store(false, Ordering::SeqCst); + } + state.channel.early.clear(); + self.slots.notify_all(); + } + + /// Stops routing a subscription's events at once and asks the host to + /// dispose it. `live` limits the first step to one subscription object. + fn dispose( + self: &Arc, + subscription_id: &str, + live: Option<&Arc>, + ) -> Result<(), SdkError> { + let ready = { + let mut state = lock(&self.state); + let routes = &mut state.channel.routes; + let owned = routes + .get(subscription_id) + .is_some_and(|route| live.is_none_or(|live| Arc::ptr_eq(&route.live, live))); + if owned { + if let Some(route) = routes.remove(subscription_id) { + route.live.store(false, Ordering::SeqCst); + } + } + state.phase == Phase::Ready + }; + if !ready { + return Ok(()); + } + Host::new(Arc::clone(self), Vec::new()) + .request( + methods::SUBSCRIPTIONS_DISPOSE, + json!({ "subscriptionID": subscription_id }), + ) + .map(drop) + } +} + +/// Host services (`lithe/host/*`): credentials; workspace contexts, files, +/// documents, the editor, previews, the browser, messages, and capability +/// discovery; calls to other plugins' functions. They are available when the +/// host offered `litheFunctions`. Every method blocks until the host answers; +/// failures the host reports carry its `error.data.code`, such as +/// `plugin.host.contextExpired`. +/// +/// [`BackendHandle::host`](crate::BackendHandle::host) is usable once the +/// session is ready. A handler's [`Context::host`](crate::Context::host) +/// belongs to its call: requests still unanswered when the call is cancelled +/// or ends fail with `sdk.call.cancelled` and are cancelled on the wire. At most 16 requests are on the wire at once; the rest wait +/// their turn. +#[derive(Clone)] +pub struct Host { + shared: Arc, + scope: Vec, + /// The `operationID` of the relayed call this host serves, if any; the + /// default `parentOperationID` of its cross-plugin calls. + parent_operation: Option, +} + +impl Host { + pub(crate) fn new(shared: Arc, scope: Vec) -> Self { + Self { + shared, + scope, + parent_operation: None, + } + } + + /// The same host for a call serving the relayed `lithe/function/invoke` + /// whose `operationID` is `parent`. + pub(crate) fn with_parent_operation(mut self, parent: Option) -> Self { + self.parent_operation = parent; + self + } + + /// Whether the host offered `litheFunctions`, and with it host services, + /// in this session; `false` before `initialize`. + pub fn negotiated(&self) -> bool { + self.shared.functions_version().is_some() + } + + /// The same services, with requests also cancelled by `cancellation`, + /// for work a plugin may stop for its own reasons. + pub fn with_cancellation(&self, cancellation: &Cancellation) -> Host { + let mut scope = self.scope.clone(); + scope.push(cancellation.clone()); + Self::new(Arc::clone(&self.shared), scope) + .with_parent_operation(self.parent_operation.clone()) + } + + /// Sends any `lithe/host/*` request, including methods a later host + /// adds; [`supports`](Self::supports) says whether the session may call + /// it. `Value::Null` sends no params. + pub fn request(&self, method: &str, params: Value) -> Result { + self.send(method, params, None) + } + + /// Sends a request that answers `{ subscriptionID }` and routes the + /// subscription's events to the returned [`Subscription`]. + pub fn subscribe(&self, method: &str, params: Value) -> Result { + let (events, receiver) = mpsc::channel(); + let live = Arc::new(AtomicBool::new(true)); + let route = Route { + live: Arc::clone(&live), + events, + last: 0, + }; + lock(&self.shared.state).channel.subscribing += 1; + let result = self.send(method, params, Some(route)); + { + let mut state = lock(&self.shared.state); + let channel = &mut state.channel; + channel.subscribing -= 1; + if channel.subscribing == 0 { + channel.early.clear(); + } + } + let result = result?; + Ok(Subscription { + id: result["subscriptionID"] + .as_str() + .unwrap_or_default() + .to_string(), + shared: Arc::clone(&self.shared), + live, + events: Mutex::new(receiver), + }) + } + + /// `{ protocolVersion, backendAuthority, services, limits }`, asked once + /// per session. + pub fn capabilities(&self) -> Result { + if !self.negotiated() { + return Err(SdkError::unavailable(NOT_NEGOTIATED)); + } + // Held during the request, so concurrent callers share one answer. + let mut cached = lock(&self.shared.capabilities); + if let Some(capabilities) = &*cached { + return Ok(capabilities.clone()); + } + let capabilities = self.request(methods::GET_CAPABILITIES, Value::Null)?; + if !capabilities.is_object() { + return Err(SdkError::new( + "sdk.host.invalidResult", + "lithe/host/getCapabilities answered with something other than an object.", + )); + } + *cached = Some(capabilities.clone()); + Ok(capabilities) + } + + /// Whether this session may call `method`, from + /// [`capabilities`](Self::capabilities). A missing grant still fails the + /// request. Without `litheFunctions` nothing is supported. + pub fn supports(&self, method: &str) -> Result { + if !self.negotiated() { + return Ok(false); + } + let capabilities = self.capabilities()?; + Ok(capabilities["services"] + .as_object() + .is_some_and(|services| { + services.values().any(|service| { + service["methods"].as_array().is_some_and(|methods| { + methods.iter().any(|entry| entry.as_str() == Some(method)) + }) + }) + })) + } + + pub fn credentials(&self) -> Credentials { + Credentials { host: self.clone() } + } + + pub fn subscriptions(&self) -> Subscriptions { + Subscriptions { host: self.clone() } + } + + pub fn workspace(&self) -> Workspace { + Workspace { host: self.clone() } + } + + pub fn documents(&self) -> Documents { + Documents { host: self.clone() } + } + + pub fn editor(&self) -> Editor { + Editor { host: self.clone() } + } + + pub fn preview(&self) -> Preview { + Preview { host: self.clone() } + } + + pub fn browser(&self) -> Browser { + Browser { host: self.clone() } + } + + pub fn terminal(&self) -> Terminal { + Terminal { host: self.clone() } + } + + pub fn window(&self) -> Window { + Window { host: self.clone() } + } + + /// Calls to other plugins' functions through Core. + pub fn functions(&self) -> Functions { + Functions { host: self.clone() } + } + + fn send(&self, method: &str, params: Value, route: Option) -> Outcome { + if !method.starts_with(methods::HOST_PREFIX) { + return Err(SdkError::new( + "sdk.host.method", + format!("{method} is not a host method; host requests are lithe/host/*."), + )); + } + if let Some(error) = refusal(&lock(&self.shared.state), &[]) { + return Err(error); + } + if !self.negotiated() { + // The answer Core would give, without the round trip. + return Err(SdkError::unavailable(NOT_NEGOTIATED)); + } + if self.scope.iter().any(Cancellation::is_cancelled) { + return Err(cancelled()); + } + self.shared + .exchange(method, params, true, &self.scope, route) + } + + /// A method whose params name a workspace context; answers an object or + /// `Value::Null`. + fn scoped(&self, method: &str, context: &WorkspaceContext, params: Value) -> Outcome { + let params = with_context(context, params)?; + match self.request(method, params)? { + result @ (Value::Null | Value::Object(_)) => Ok(result), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + format!("{method} answered with something other than an object."), + )), + } + } + + /// A method that always answers an object. + fn request_object(&self, method: &str, params: Value) -> Outcome { + match self.request(method, params)? { + result @ Value::Object(_) => Ok(result), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + format!("{method} answered with something other than an object."), + )), + } + } +} + +/// `params` with `context` added; params are an object or `Value::Null`. +fn with_context(context: &WorkspaceContext, params: Value) -> Result { + let mut params = match params { + Value::Null => Map::new(), + Value::Object(params) => params, + _ => { + return Err(SdkError::new( + "sdk.host.invalidParams", + "Host request params are an object.", + )) + } + }; + params.insert("context".into(), context.to_value()); + Ok(Value::Object(params)) +} + +/// A workspace context reference, `{ contextID, generation }`: the window a +/// call or command ran in, or one from `listContexts`. A context is replaced +/// when its window's folders change; requests with an old generation fail +/// with `plugin.host.contextExpired`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorkspaceContext { + context_id: String, + generation: u64, +} + +impl WorkspaceContext { + /// `context_id` is 1-128 ASCII letters, digits, `.`, `_`, `:` or `-`; + /// `generation` is positive. + pub fn new(context_id: impl Into, generation: u64) -> Result { + Self::from_value(&json!({ "contextID": context_id.into(), "generation": generation })) + } + + /// The reference of a context reference or of a full context from + /// `listContexts` or `getContext`, so either can be passed on. + pub fn from_value(value: &Value) -> Result { + let context_id = value["contextID"] + .as_str() + .filter(|id| is_credential_key(id)); + match (context_id, generation(&value["generation"])) { + (Some(context_id), Some(generation)) => Ok(Self { + context_id: context_id.to_string(), + generation, + }), + _ => Err(SdkError::new( + "sdk.host.invalidContext", + "A context is { contextID, generation }: an ID of 1-128 ASCII letters, digits, \".\", \"_\", \":\" or \"-\", and a positive generation.", + )), + } + } + + /// The `context` of an invocation or command: exactly a reference. + pub(crate) fn from_reference(value: &Value) -> Option { + if value.as_object()?.len() != 2 { + return None; + } + Self::from_value(value).ok() + } + + pub fn context_id(&self) -> &str { + &self.context_id + } + + pub fn generation(&self) -> u64 { + self.generation + } + + /// `{ "contextID": …, "generation": … }`. + pub fn to_value(&self) -> Value { + json!({ "contextID": self.context_id, "generation": self.generation }) + } +} + +/// One `lithe/host/subscriptions/notify`. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + subscription_id: String, + sequence: u64, + gap: bool, + event: Value, +} + +impl Event { + pub fn subscription_id(&self) -> &str { + &self.subscription_id + } + + /// Starts at 1 and grows by one per event of the subscription. + pub fn sequence(&self) -> u64 { + self.sequence + } + + /// True when events before this one were lost, so state built from + /// earlier events should be refreshed, for example by rescanning. + pub fn gap(&self) -> bool { + self.gap + } + + /// The event, usually an object. + pub fn event(&self) -> &Value { + &self.event + } + + pub fn into_event(self) -> Value { + self.event + } +} + +/// A host subscription, such as a file watch or registered editor actions. +/// Events queue in order until read; dropping the subscription does not +/// dispose it on the host, [`dispose`](Self::dispose) does. +pub struct Subscription { + id: String, + shared: Arc, + live: Arc, + events: Mutex>, +} + +impl Subscription { + pub fn id(&self) -> &str { + &self.id + } + + /// True once disposed here or the session ended. + pub fn is_disposed(&self) -> bool { + !self.live.load(Ordering::SeqCst) + } + + /// The next event, waiting for it; `None` once disposed or the session + /// ended. + pub fn recv(&self) -> Option { + let event = lock(&self.events).recv().ok()?; + (!self.is_disposed()).then_some(event) + } + + /// The next event, waiting at most `timeout`; `Disconnected` once + /// disposed or the session ended. + pub fn recv_timeout(&self, timeout: Duration) -> Result { + if self.is_disposed() { + return Err(RecvTimeoutError::Disconnected); + } + let event = lock(&self.events).recv_timeout(timeout)?; + if self.is_disposed() { + Err(RecvTimeoutError::Disconnected) + } else { + Ok(event) + } + } + + /// Stops routing events at once and asks the host to dispose the + /// subscription. After the session ended there is nothing to ask. + pub fn dispose(&self) -> Result<(), SdkError> { + self.shared.dispose(&self.id, Some(&self.live)) + } +} + +/// The host's `credentials` service: secrets private to this +/// plugin, kept in the platform's secret store, never in `${stateDir}`. It +/// needs the `credentials.store` permission; without it every request fails +/// with `plugin.host.permissionDenied`. +pub struct Credentials { + host: Host, +} + +impl Credentials { + /// The secret stored under `key`, or `None`. + pub fn get(&self, key: &str) -> Result, SdkError> { + check_credential_key(key)?; + match self + .host + .request(methods::CREDENTIALS_GET, json!({ "key": key }))? + { + Value::Null => Ok(None), + Value::Object(mut result) => match result.remove("secret") { + Some(Value::String(secret)) => Ok(Some(secret)), + _ => Err(missing_secret()), + }, + _ => Err(missing_secret()), + } + } + + /// Stores `secret` (at most 16 KiB) under `key`, replacing any value. + pub fn set(&self, key: &str, secret: &str) -> Result<(), SdkError> { + check_credential_key(key)?; + if secret.len() > MAX_CREDENTIAL_SECRET_BYTES { + return Err(SdkError::new( + "sdk.credentials.invalidSecret", + "A secret is a string of at most 16 KiB.", + )); + } + self.host + .request( + methods::CREDENTIALS_SET, + json!({ "key": key, "secret": secret }), + ) + .map(drop) + } + + /// Removes the secret under `key`; succeeds when nothing was stored. + pub fn delete(&self, key: &str) -> Result<(), SdkError> { + check_credential_key(key)?; + self.host + .request(methods::CREDENTIALS_DELETE, json!({ "key": key })) + .map(drop) + } +} + +fn check_credential_key(key: &str) -> Result<(), SdkError> { + if is_credential_key(key) { + Ok(()) + } else { + Err(SdkError::new( + "sdk.credentials.invalidKey", + "A credential key is 1-128 ASCII letters, digits, \".\", \"_\", \":\" or \"-\".", + )) + } +} + +fn missing_secret() -> SdkError { + SdkError::new( + "sdk.host.invalidResult", + "The host answered credentials/get without a secret.", + ) +} + +pub struct Subscriptions { + host: Host, +} + +impl Subscriptions { + /// Disposes a subscription by ID; also succeeds for an unknown or + /// disposed one. + pub fn dispose(&self, subscription_id: &str) -> Result<(), SdkError> { + self.host.shared.dispose(subscription_id, None) + } +} + +/// Workspace contexts and files. Every method but +/// [`list_contexts`](Self::list_contexts) names a context: the call's +/// [`Context::workspace_context`](crate::Context::workspace_context), or one +/// from `list_contexts` read with [`WorkspaceContext::from_value`]. +pub struct Workspace { + host: Host, +} + +impl Workspace { + /// `{ revision, contexts }`. + pub fn list_contexts(&self) -> Result { + self.host + .scoped_without_context(methods::WORKSPACE_LIST_CONTEXTS) + } + + pub fn get_context(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::WORKSPACE_GET_CONTEXT, context, Value::Null) + } + + /// `{ folders }`. + pub fn get_folders(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::WORKSPACE_GET_FOLDERS, context, Value::Null) + } + + /// `params`: `resource, cursor?, limit?`. + pub fn read_directory( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::WORKSPACE_READ_DIRECTORY, context, params) + } + + /// `params`: `resource`. + pub fn stat(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::WORKSPACE_STAT, context, params) + } + + /// `params`: `resource, maximumBytes?`. + pub fn read_file(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WORKSPACE_READ_FILE, context, params) + } + + /// Watches `resource, recursive?`; events are + /// `{ rootID, changedPaths, requiresRescan }`. + pub fn watch( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .subscribe(methods::WORKSPACE_WATCH, with_context(context, params)?) + } + + /// `params`: root `resource, query, fileMask?, cursor?, limit?`. + pub fn find_files(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WORKSPACE_FIND_FILES, context, params) + } + + /// `params`: root `resource, pattern`, and optional search options. + pub fn find_text(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WORKSPACE_FIND_TEXT, context, params) + } +} + +impl Host { + fn scoped_without_context(&self, method: &str) -> Outcome { + match self.request(method, json!({}))? { + result @ (Value::Null | Value::Object(_)) => Ok(result), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + format!("{method} answered with something other than an object."), + )), + } + } +} + +/// Open documents, read from their live buffers. +pub struct Documents { + host: Host, +} + +impl Documents { + /// `{ documents }`. + pub fn list(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::DOCUMENTS_LIST, context, Value::Null) + } + + /// `params`: `documentID`; answers `{ document, text }`. + pub fn get_snapshot( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::DOCUMENTS_GET_SNAPSHOT, context, params) + } +} + +/// The text editor. +pub struct Editor { + host: Host, +} + +impl Editor { + /// The foreground text tab's document summary, or `Value::Null`. + pub fn get_active(&self, context: &WorkspaceContext) -> Result { + self.host + .scoped(methods::EDITOR_GET_ACTIVE, context, Value::Null) + } + + /// `params`: `documentID`. + pub fn get_selections( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_GET_SELECTIONS, context, params) + } + + /// `params`: `documentID, expectedVersion, edits`. + pub fn apply_edits( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_APPLY_EDITS, context, params) + } + + /// `params`: `documentID, expectedVersion, selections, replacements`. + pub fn replace_selections( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_REPLACE_SELECTIONS, context, params) + } + + /// `params`: `documentID, expectedVersion, at, text`. + pub fn insert_text( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::EDITOR_INSERT_TEXT, context, params) + } + + /// `params`: `resource, activation?, range?, tab?`. + pub fn open(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::EDITOR_OPEN, context, params) + } + + /// `params`: `resource, activation?, range?, tab?`. + pub fn reveal(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::EDITOR_REVEAL, context, params) + } + + /// Registers editor actions (`id, function, selector`) that invoke one + /// of the backend's functions; disposing the subscription removes them. + pub fn register_actions( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host.subscribe( + methods::EDITOR_REGISTER_ACTIONS, + with_context(context, params)?, + ) + } +} + +/// HTML previews. +pub struct Preview { + host: Host, +} + +impl Preview { + /// `params`: HTML `resource, presentation?, range?`. + pub fn open_html(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::PREVIEW_OPEN_HTML, context, params) + } +} + +/// The system browser. +pub struct Browser { + host: Host, +} + +impl Browser { + /// `params`: exactly one of an HTTP(S) `url` or an HTML `resource`. + pub fn open_external( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::BROWSER_OPEN_EXTERNAL, context, params) + } +} + +/// Messages in the window. `params`: `text, actions?`; answers +/// `{ selectedIndex }`, `Value::Null` on dismissal. +pub struct Window { + host: Host, +} + +impl Window { + pub fn show_information( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::WINDOW_SHOW_INFORMATION, context, params) + } + + pub fn show_warning( + &self, + context: &WorkspaceContext, + params: Value, + ) -> Result { + self.host + .scoped(methods::WINDOW_SHOW_WARNING, context, params) + } + + pub fn show_error(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host + .scoped(methods::WINDOW_SHOW_ERROR, context, params) + } +} + +/// Integrated native terminals owned by this backend session and workspace. +/// Permission `terminal.create`; discover each method with `Host::supports`. +/// Input and output stay in the host terminal, not in the plugin's web page. +pub struct Terminal { + host: Host, +} + +impl Terminal { + /// Launches executable and arguments directly. Params: `executable`, optional + /// `arguments`, `title`, `cwd` resource, `environment`, and `show` (true). + pub fn create(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::TERMINAL_CREATE, context, params) + } + + /// Shows an owned terminal in its original window without raising the window. + pub fn show(&self, context: &WorkspaceContext, terminal_id: &str) -> Result { + self.host.scoped( + methods::TERMINAL_SHOW, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// Stops the native process, keeping a bounded display suffix for reopen. + /// `retained_context_bytes` overrides the create-time choice (0..=262144). + pub fn close( + &self, + context: &WorkspaceContext, + terminal_id: &str, + retained_context_bytes: Option, + ) -> Result { + let mut params = json!({ "terminalID": terminal_id }); + if let Some(bytes) = retained_context_bytes { + params["retainedContextBytes"] = json!(bytes); + } + self.host.scoped(methods::TERMINAL_CLOSE, context, params) + } + + /// Starts the saved argv again and restores plain display history, never input. + pub fn reopen(&self, context: &WorkspaceContext, terminal_id: &str) -> Result { + self.host.scoped( + methods::TERMINAL_REOPEN, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// Permanently forgets an owned handle and its history, also closing it if live. + pub fn dispose( + &self, + context: &WorkspaceContext, + terminal_id: &str, + ) -> Result { + self.host.scoped( + methods::TERMINAL_DISPOSE, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// One exit event per process generation, including the latest recorded + /// outcome when subscribing after exit. + /// Disposing the subscription stops notifications, not the terminal itself. + pub fn on_exit( + &self, + context: &WorkspaceContext, + terminal_id: &str, + ) -> Result { + self.host.subscribe( + methods::TERMINAL_ON_EXIT, + with_context(context, json!({ "terminalID": terminal_id }))?, + ) + } +} + +/// Calls to the functions other plugins export, relayed by Core. +/// Needs a call the plugin declares as `pluginFunctions.calls` in its +/// manifest; discover the methods with `Host::supports`. The relay accepts +/// local workspaces only. +pub struct Functions { + host: Host, +} + +impl Functions { + /// `{ providers }`: the enabled plugins other than this one that export + /// `function_id`, sorted by plugin ID, each `{ pluginID, version }`. This + /// plugin must have declared the function. + pub fn providers(&self, function_id: &str) -> Result { + if !is_function_id(function_id) { + return Err(invalid_call("functionID is a plugin function ID.")); + } + self.host.request_object( + methods::FUNCTIONS_PROVIDERS, + json!({ "functionID": function_id }), + ) + } + + /// Calls one provider and answers `{ value }`, the provider's result. + /// `call` is an object with `pluginID` and `functionID`, and optionally + /// `params` (an object), `timeoutMilliseconds` (a positive integer; the + /// host defaults it and caps it at 600000), and `parentOperationID`; any + /// other key is not sent. `context` is a workspace context this backend + /// learned from the workspace service. + /// + /// Inside a handler, `parentOperationID` defaults to the `operationID` + /// of the call the handler serves, which tells Core the new call belongs + /// to it. A [`BackendHandle::host`](crate::BackendHandle::host) serves no + /// call, so it sends the field only when `call` sets it. Cancelling the + /// handler's call cancels the relayed call. + pub fn invoke(&self, context: &WorkspaceContext, call: Value) -> Result { + let call = relay_params(call, self.host.parent_operation.as_deref())?; + let params = with_context(context, call)?; + self.host.request_object(methods::FUNCTIONS_INVOKE, params) + } +} + +fn invalid_call(message: &str) -> SdkError { + SdkError::new("sdk.functions.invalidCall", message) +} + +/// The params of `lithe/host/functions/invoke` for `call`, without its +/// context: the fields the method defines and no other, so the host never +/// answers `-32018`. `serving` is the `operationID` that `parentOperationID` +/// defaults to. +fn relay_params(call: Value, serving: Option<&str>) -> Result { + let Value::Object(mut call) = call else { + return Err(invalid_call("A cross-plugin call is an object.")); + }; + let mut relayed = Map::new(); + match call.remove("pluginID") { + Some(Value::String(plugin_id)) if !plugin_id.is_empty() => { + relayed.insert("pluginID".into(), Value::String(plugin_id)); + } + _ => return Err(invalid_call("pluginID is a non-empty string.")), + } + match call.remove("functionID") { + Some(Value::String(function_id)) if is_function_id(&function_id) => { + relayed.insert("functionID".into(), Value::String(function_id)); + } + _ => return Err(invalid_call("functionID is a plugin function ID.")), + } + match call.remove("params") { + Some(params @ Value::Object(_)) => { + relayed.insert("params".into(), params); + } + Some(_) => return Err(invalid_call("params is an object.")), + None => {} + } + match call.remove("timeoutMilliseconds") { + Some(timeout) if timeout.as_u64().is_some_and(|ms| ms > 0) => { + relayed.insert("timeoutMilliseconds".into(), timeout); + } + Some(_) => return Err(invalid_call("timeoutMilliseconds is a positive integer.")), + None => {} + } + match call.remove("parentOperationID") { + Some(Value::String(parent)) if is_parent_operation_id(&parent) => { + relayed.insert("parentOperationID".into(), Value::String(parent)); + } + Some(_) => return Err(invalid_call("parentOperationID is a 1-128 byte string.")), + None => { + if let Some(parent) = serving { + relayed.insert("parentOperationID".into(), json!(parent)); + } + } + } + Ok(Value::Object(relayed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contexts_are_references_or_full_contexts() { + let full = json!({ "contextID": "w1", "generation": 2, "name": "orders" }); + let context = WorkspaceContext::from_value(&full).unwrap(); + assert_eq!( + context.to_value(), + json!({ "contextID": "w1", "generation": 2 }) + ); + assert_eq!(WorkspaceContext::new("w1", 2).unwrap(), context); + // An invocation's context is exactly a reference. + assert_eq!(WorkspaceContext::from_reference(&full), None); + assert_eq!( + WorkspaceContext::from_reference(&context.to_value()), + Some(context) + ); + for invalid in [ + json!({ "contextID": "w1" }), + json!({ "contextID": "w1", "generation": 0 }), + json!({ "contextID": "../w", "generation": 1 }), + json!(null), + ] { + let error = WorkspaceContext::from_value(&invalid).unwrap_err(); + assert_eq!(error.code(), "sdk.host.invalidContext", "{invalid}"); + } + assert!(WorkspaceContext::new("", 1).is_err()); + } + + #[test] + fn a_relayed_call_keeps_only_the_fields_the_method_defines() { + let call = json!({ + "pluginID": "dev.lithe.fixture.java", "functionID": "project.describe", + "params": { "caller": "data" }, "timeoutMilliseconds": 900000, + "parentOperationID": "17", "caller": { "pluginID": "forged" }, + "context": { "contextID": "x" }, "extra": true + }); + let expected = json!({ + "pluginID": "dev.lithe.fixture.java", "functionID": "project.describe", + "params": { "caller": "data" }, "timeoutMilliseconds": 900000, + "parentOperationID": "17" + }); + // Core passes a `caller` inside `params` unchanged; only a top-level one is refused. + assert_eq!(relay_params(call, Some("served")).unwrap(), expected); + } + + #[test] + fn optional_fields_stay_out_and_the_parent_defaults_to_the_call_being_served() { + let call = json!({ "pluginID": "a.b", "functionID": "x.y" }); + assert_eq!(relay_params(call.clone(), None).unwrap(), call); + let served = relay_params(call, Some("op-7")).unwrap(); + assert_eq!(served["parentOperationID"], "op-7"); + assert!(served.get("params").is_none()); + assert!(served.get("timeoutMilliseconds").is_none()); + let named = json!({ "pluginID": "a.b", "functionID": "x.y", "parentOperationID": "own" }); + let relayed = relay_params(named, Some("op-7")).unwrap(); + assert_eq!(relayed["parentOperationID"], "own"); + // The host caps the timeout; the SDK sends what the plugin chose. + let mut slow = json!({ "pluginID": "a.b", "functionID": "x.y" }); + slow["timeoutMilliseconds"] = json!(700000); + let relayed = relay_params(slow, None).unwrap(); + assert_eq!(relayed["timeoutMilliseconds"], 700000); + } + + #[test] + fn an_invalid_relayed_call_is_refused_before_anything_is_sent() { + let valid = json!({ "pluginID": "a.b", "functionID": "x.y" }); + let with = |key: &str, value: Value| { + let mut call = valid.clone(); + call[key] = value; + call + }; + let without = |key: &str| { + let mut call = valid.clone(); + call.as_object_mut().unwrap().remove(key); + call + }; + for invalid in [ + json!("x.y"), + json!(null), + without("pluginID"), + with("pluginID", json!("")), + with("pluginID", json!(7)), + without("functionID"), + with("functionID", json!("Not-Valid")), + with("params", json!([])), + with("params", json!(null)), + with("timeoutMilliseconds", json!(0)), + with("timeoutMilliseconds", json!(-1)), + with("timeoutMilliseconds", json!(1.5)), + with("timeoutMilliseconds", json!("5000")), + with("parentOperationID", json!("")), + with("parentOperationID", json!("p".repeat(129))), + with("parentOperationID", json!(12)), + ] { + let error = relay_params(invalid.clone(), Some("op-1")).unwrap_err(); + assert_eq!(error.code(), "sdk.functions.invalidCall", "{invalid}"); + } + } +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/lib.rs b/shared/plugins/sdk/rust/plugin-sdk/src/lib.rs new file mode 100644 index 00000000..57649801 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/lib.rs @@ -0,0 +1,53 @@ +//! Official SDK for Lithe plugin backends written in Rust. +//! +//! A plugin backend is the one long-lived process a plugin declares under +//! `backend` in `plugin.json`; Lithe starts it when one of the plugin's own +//! web pages first calls a function. It speaks the LSP base protocol on +//! stdio and implements, from +//! [plugin contract v2](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/shared/plugins/contract/v1/README.md): +//! +//! - **`litheFunctions`**: plugin functions with progress, cancellation, and +//! registration at runtime; +//! - **[`Host`] services**: the plugin's own [`Credentials`] in the +//! platform's secret store, workspace contexts, files, documents, the +//! editor, previews, the browser, window messages, capability discovery, +//! subscriptions, the commands and context changes the host notifies, and +//! calls to the functions other plugins export through Core's relay +//! ([`Functions`]) with the [`Caller`] of a function called that way; +//! - **`litheKeymapImport`**: shortcut import providers. +//! +//! The SDK requires `litheLogging` at initialization. +//! +//! The SDK owns the wire format, negotiation, cancellation, and error +//! mapping; plugin code supplies handlers. Each call runs on a thread of its +//! own, so handlers may block. A handler returns `Ok(value)` with a JSON +//! result, a [`PluginError`] the plugin's UI can tell apart by its code, or +//! any other error, which reaches the host only as a generic failure while +//! automatic diagnostics omit its contents. A panic ends only its call; +//! SDK catch boundaries suppress raw panic payload printing. +//! +//! See [`Backend`] for a minimal backend. + +mod backend; +mod diagnostics; +mod error; +mod framing; +mod host; +mod panic_boundary; +mod protocol; +mod stdio; + +pub use backend::{ + Backend, BackendHandle, Caller, Cancellation, Context, Initialize, KeymapImportRequest, + KeymapSourceFile, +}; +pub use diagnostics::Diagnostics; +pub use error::{ + sdk_codes, Cancelled, FunctionError, PluginError, SdkError, HOST_ERROR_CODES, RELAY_ERROR_CODES, +}; +pub use host::{ + Browser, Credentials, Documents, Editor, Event, Functions, Host, Preview, Subscription, + Subscriptions, Terminal, Window, Workspace, WorkspaceContext, +}; +pub use protocol::{is_command_id, is_credential_key, is_function_id, is_plugin_error_code}; +pub use serde_json::Value; diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/panic_boundary.rs b/shared/plugins/sdk/rust/plugin-sdk/src/panic_boundary.rs new file mode 100644 index 00000000..c788cea2 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/panic_boundary.rs @@ -0,0 +1,29 @@ +//! Suppresses payload printing only for panics owned by an SDK catch boundary. +use std::any::Any; +use std::cell::Cell; +use std::panic::{self, UnwindSafe}; +use std::sync::Once; + +thread_local! { static CAUGHT: Cell = const { Cell::new(false) }; } +static INSTALL: Once = Once::new(); + +pub(crate) fn catch( + operation: impl FnOnce() -> T + UnwindSafe, +) -> Result> { + INSTALL.call_once(|| { + let previous = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if !CAUGHT.with(Cell::get) { + previous(info); + } + })); + }); + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + CAUGHT.with(|active| active.set(self.0)); + } + } + let _restore = Restore(CAUGHT.with(|active| active.replace(true))); + panic::catch_unwind(operation) +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs b/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs new file mode 100644 index 00000000..05c1b745 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/protocol.rs @@ -0,0 +1,283 @@ +//! 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 same rules locally so a plugin author sees +//! a mistake when declaring a function instead of as a refused session. + +use serde_json::Value; + +/// The plugin-function protocol version this SDK answers: plugin functions, +/// host services, workspace contexts, capability discovery, host +/// notifications, `$/cancelRequest` for host requests, and calls to other +/// plugins' functions through Core's relay. +pub const FUNCTIONS_VERSION: u64 = 0; +/// The `litheLogging` version this SDK requires and answers. +pub const LOGGING_VERSION: u64 = 0; +/// The `litheKeymapImport` version this SDK answers. +pub const KEYMAP_IMPORT_VERSION: u64 = 0; + +/// Whether an `initialize` offer, `{ "versions": [...] }`, lists `version`. +pub(crate) fn offers(offer: Option<&Value>, version: u64) -> bool { + offer + .and_then(|offer| offer.get("versions")) + .and_then(Value::as_array) + .is_some_and(|versions| versions.iter().any(|entry| entry.as_u64() == Some(version))) +} + +pub(crate) mod methods { + pub const INITIALIZE: &str = "initialize"; + pub const SHUTDOWN: &str = "shutdown"; + pub const EXIT: &str = "exit"; + pub const CANCEL_REQUEST: &str = "$/cancelRequest"; + pub const FUNCTION_INVOKE: &str = "lithe/function/invoke"; + pub const FUNCTION_REGISTER: &str = "lithe/function/register"; + pub const FUNCTION_UNREGISTER: &str = "lithe/function/unregister"; + pub const FUNCTION_PROGRESS: &str = "lithe/function/progress"; + pub const TERMINAL_CREATE: &str = "lithe/host/terminal/create"; + pub const TERMINAL_SHOW: &str = "lithe/host/terminal/show"; + pub const TERMINAL_CLOSE: &str = "lithe/host/terminal/close"; + pub const TERMINAL_REOPEN: &str = "lithe/host/terminal/reopen"; + pub const TERMINAL_DISPOSE: &str = "lithe/host/terminal/dispose"; + pub const TERMINAL_ON_EXIT: &str = "lithe/host/terminal/onExit"; + pub const HOST_PREFIX: &str = "lithe/host/"; + pub const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; + pub const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; + pub const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; + pub const GET_CAPABILITIES: &str = "lithe/host/getCapabilities"; + pub const SUBSCRIPTIONS_DISPOSE: &str = "lithe/host/subscriptions/dispose"; + pub const WORKSPACE_LIST_CONTEXTS: &str = "lithe/host/workspace/listContexts"; + pub const WORKSPACE_GET_CONTEXT: &str = "lithe/host/workspace/getContext"; + pub const WORKSPACE_GET_FOLDERS: &str = "lithe/host/workspace/getFolders"; + pub const WORKSPACE_READ_DIRECTORY: &str = "lithe/host/workspace/readDirectory"; + pub const WORKSPACE_STAT: &str = "lithe/host/workspace/stat"; + pub const WORKSPACE_READ_FILE: &str = "lithe/host/workspace/readFile"; + pub const WORKSPACE_WATCH: &str = "lithe/host/workspace/watch"; + pub const WORKSPACE_FIND_FILES: &str = "lithe/host/workspace/findFiles"; + pub const WORKSPACE_FIND_TEXT: &str = "lithe/host/workspace/findText"; + pub const DOCUMENTS_LIST: &str = "lithe/host/documents/list"; + pub const DOCUMENTS_GET_SNAPSHOT: &str = "lithe/host/documents/getSnapshot"; + pub const EDITOR_REGISTER_ACTIONS: &str = "lithe/host/editor/registerActions"; + pub const EDITOR_GET_ACTIVE: &str = "lithe/host/editor/getActive"; + pub const EDITOR_GET_SELECTIONS: &str = "lithe/host/editor/getSelections"; + pub const EDITOR_APPLY_EDITS: &str = "lithe/host/editor/applyEdits"; + pub const EDITOR_REPLACE_SELECTIONS: &str = "lithe/host/editor/replaceSelections"; + pub const EDITOR_INSERT_TEXT: &str = "lithe/host/editor/insertText"; + pub const EDITOR_OPEN: &str = "lithe/host/editor/open"; + pub const EDITOR_REVEAL: &str = "lithe/host/editor/reveal"; + pub const PREVIEW_OPEN_HTML: &str = "lithe/host/preview/openHTML"; + pub const BROWSER_OPEN_EXTERNAL: &str = "lithe/host/browser/openExternal"; + pub const WINDOW_SHOW_INFORMATION: &str = "lithe/host/window/showInformation"; + pub const WINDOW_SHOW_WARNING: &str = "lithe/host/window/showWarning"; + pub const WINDOW_SHOW_ERROR: &str = "lithe/host/window/showError"; + pub const DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; + pub const SUBSCRIPTION_NOTIFY: &str = "lithe/host/subscriptions/notify"; + pub const COMMAND_DID_RUN: &str = "lithe/host/commands/didRun"; + pub const FUNCTIONS_PROVIDERS: &str = "lithe/host/functions/providers"; + pub const FUNCTIONS_INVOKE: &str = "lithe/host/functions/invoke"; +} + +/// JSON-RPC and LSP error codes the SDK answers with. +pub(crate) mod error_codes { + pub const PARSE_ERROR: i64 = -32700; + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const INVALID_PARAMS: i64 = -32602; + pub const INTERNAL_ERROR: i64 = -32603; + pub const SERVER_NOT_INITIALIZED: i64 = -32002; + pub const REQUEST_FAILED: i64 = -32000; + pub const REQUEST_CANCELLED: i64 = -32800; +} + +pub(crate) const MAX_FUNCTIONS: usize = 256; +const MAX_FUNCTION_ID_BYTES: usize = 128; +const MAX_PLUGIN_ERROR_CODE_BYTES: usize = 64; +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024; +pub(crate) const MAX_RESULT_BYTES: usize = 1 << 20; +const MAX_CREDENTIAL_KEY_BYTES: usize = 128; +pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; +/// Host requests a backend may have unanswered at once; later ones wait. +pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; +/// Events kept for subscriptions whose answer has not been read yet. +pub(crate) const MAX_EARLY_EVENTS: usize = 128; +const MAX_COMMAND_ID_BYTES: usize = 128; +/// The longest `parentOperationID` Core accepts on a relayed call. +const MAX_PARENT_OPERATION_ID_BYTES: usize = 128; +/// The largest integer JSON carries exactly. +const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + +/// Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. +pub fn is_function_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_FUNCTION_ID_BYTES + && value.split('.').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && characters.all(|character| character.is_ascii_alphanumeric()) + }) +} + +/// The plugin error code alphabet Core forwards as `pluginErrorCode`. +pub fn is_plugin_error_code(value: &str) -> bool { + let mut characters = value.chars(); + value.len() <= MAX_PLUGIN_ERROR_CODE_BYTES + && characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && characters + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) +} + +/// A credential key: 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`. +pub fn is_credential_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CREDENTIAL_KEY_BYTES + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-".contains(character)) +} + +/// A command ID from the plugin's manifest: 1–128 of `[a-z0-9.-]`. +pub fn is_command_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_COMMAND_ID_BYTES + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || ".-".contains(character) + }) +} + +/// A `parentOperationID`: the `operationID` of a relayed call, a string of +/// 1-128 bytes. +pub(crate) fn is_parent_operation_id(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_PARENT_OPERATION_ID_BYTES +} + +/// A context generation: a positive integer JSON carries exactly. +pub(crate) fn generation(value: &Value) -> Option { + let generation = match value.as_u64() { + Some(number) => number, + None => { + let number = value.as_f64()?; + if number.fract() != 0.0 || !(1.0..=MAX_SAFE_INTEGER as f64).contains(&number) { + return None; + } + number as u64 + } + }; + (1..=MAX_SAFE_INTEGER) + .contains(&generation) + .then_some(generation) +} + +/// Cuts `value` to `limit` UTF-8 bytes without splitting a character. +pub(crate) fn bounded_text(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_string(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_protocol_this_sdk_speaks_is_version_zero() { + assert_eq!(FUNCTIONS_VERSION, 0); + assert_eq!(LOGGING_VERSION, 0); + assert_eq!(KEYMAP_IMPORT_VERSION, 0); + } + + #[test] + fn an_offer_lists_the_version_exactly() { + use serde_json::json; + assert!(offers(Some(&json!({ "versions": [0] })), 0)); + assert!(offers(Some(&json!({ "versions": [99, 0] })), 0)); + for invalid in [ + json!({ "versions": [1] }), + json!({ "versions": [] }), + json!({ "versions": ["0", true, null, 0.5] }), + json!({ "versions": 0 }), + json!({ "version": 0 }), + json!(0), + json!(null), + ] { + assert!(!offers(Some(&invalid), 0), "{invalid}"); + } + assert!(!offers(None, 0)); + } + + #[test] + fn function_ids_are_dot_separated_lower_camel_case() { + assert!(is_function_id("snapshot.refresh")); + assert!(is_function_id("query.runAll2")); + for invalid in [ + "", + "Snapshot", + "snapshot.", + ".a", + "a..b", + "a-b", + "a.B", + "1a", + ] { + assert!(!is_function_id(invalid), "{invalid}"); + } + assert!(!is_function_id(&"a".repeat(129))); + } + + #[test] + fn plugin_error_codes_and_credential_keys_use_their_alphabets() { + assert!(is_plugin_error_code("db.connection-failed_2")); + assert!(!is_plugin_error_code("2db")); + assert!(!is_plugin_error_code(&"a".repeat(65))); + assert!(is_credential_key("connection:prod.password")); + assert!(!is_credential_key("")); + assert!(!is_credential_key("has space")); + assert!(!is_credential_key(&"k".repeat(129))); + assert!(is_command_id("db.refresh-all2")); + for invalid in ["", "DB.refresh", "db_refresh", "db refresh"] { + assert!(!is_command_id(invalid), "{invalid}"); + } + assert!(!is_command_id(&"a".repeat(129))); + } + + #[test] + fn parent_operation_ids_are_one_to_one_hundred_twenty_eight_bytes() { + assert!(is_parent_operation_id("17")); + assert!(is_parent_operation_id(&"o".repeat(128))); + assert!(is_parent_operation_id(&"é".repeat(64))); + assert!(!is_parent_operation_id("")); + assert!(!is_parent_operation_id(&"o".repeat(129))); + assert!(!is_parent_operation_id(&"é".repeat(65))); + } + + #[test] + fn generations_are_positive_integers_json_carries_exactly() { + use serde_json::json; + assert_eq!(generation(&json!(2)), Some(2)); + assert_eq!(generation(&json!(2.0)), Some(2)); + assert_eq!(generation(&json!(MAX_SAFE_INTEGER)), Some(MAX_SAFE_INTEGER)); + for invalid in [ + json!(0), + json!(-1), + json!(1.5), + json!("2"), + json!(null), + json!(MAX_SAFE_INTEGER + 1), + ] { + assert_eq!(generation(&invalid), None, "{invalid}"); + } + } + + #[test] + fn bounded_text_never_splits_a_character() { + assert_eq!(bounded_text("abc", 8), "abc"); + assert_eq!(bounded_text("aé", 2), "a"); + assert_eq!(bounded_text("日本語", 7), "日本"); + } +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/src/stdio.rs b/shared/plugins/sdk/rust/plugin-sdk/src/stdio.rs new file mode 100644 index 00000000..cd7b5914 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/src/stdio.rs @@ -0,0 +1,31 @@ +//! Keeps stdout for protocol frames only. + +use std::io::{self, Write}; + +/// The stream the protocol is written to. On Unix this is a duplicate of +/// the original stdout descriptor, and descriptor 1 is pointed at stderr, so +/// output printed by plugin code or its dependencies cannot corrupt the +/// protocol. Elsewhere it is stdout itself, and plugin code must not print. +pub(crate) fn protocol_output() -> Box { + #[cfg(unix)] + { + use std::os::fd::FromRawFd; + extern "C" { + fn dup(fd: i32) -> i32; + fn dup2(source: i32, target: i32) -> i32; + } + // SAFETY: `dup` and `dup2` only operate on the process's descriptor + // table. The duplicate is a fresh descriptor that nothing else owns, + // so the `File` becomes its single owner. + unsafe { + let protocol = dup(1); + if protocol >= 0 { + if dup2(2, 1) < 0 { + std::process::exit(1); + } + return Box::new(std::fs::File::from_raw_fd(protocol)); + } + } + } + Box::new(io::stdout()) +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/tests/common/mod.rs b/shared/plugins/sdk/rust/plugin-sdk/tests/common/mod.rs new file mode 100644 index 00000000..919ac36d --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/tests/common/mod.rs @@ -0,0 +1,161 @@ +//! A scripted host for driving a backend session over in-process pipes. +//! Every wait is bounded, so a broken session fails with the frames it sent +//! instead of hanging. +#![allow(dead_code)] + +use lithe_plugin_sdk::Backend; +use serde_json::{json, Value}; +use std::io::{self, BufRead, BufReader, Write}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +pub const WAIT: Duration = Duration::from_secs(10); + +/// The host side of one session. +pub struct Host { + input: Option, + frames: mpsc::Receiver, + exit: mpsc::Receiver, +} + +impl Host { + pub fn start(backend: Backend) -> Self { + let (backend_input, input) = io::pipe().expect("input pipe"); + let (output, backend_output) = io::pipe().expect("output pipe"); + let (exit_sender, exit) = mpsc::channel(); + thread::spawn(move || { + let _ = exit_sender.send(backend.serve(backend_input, backend_output)); + }); + let (frame_sender, frames) = mpsc::channel(); + thread::spawn(move || { + let mut reader = BufReader::new(output); + while let Some(frame) = read_frame(&mut reader) { + if frame_sender.send(frame).is_err() { + return; + } + } + }); + Self { + input: Some(input), + frames, + exit, + } + } + + pub fn send_raw(&mut self, bytes: &[u8]) { + let input = self.input.as_mut().expect("open input"); + input.write_all(bytes).expect("write to the backend"); + } + + pub fn send(&mut self, message: Value) { + let body = message.to_string(); + self.send_raw(format!("Content-Length: {}\r\n\r\n{body}", body.len()).as_bytes()); + } + + pub fn request(&mut self, id: impl Into, method: &str, params: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": id.into(), "method": method, "params": params })); + } + + pub fn notify(&mut self, method: &str, params: Value) { + self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params })); + } + + pub fn next(&self) -> Value { + self.frames + .recv_timeout(WAIT) + .expect("a frame from the backend in time") + } + + /// The answer to request `id`, after any frames before it. + pub fn answer(&self, id: impl Into) -> (Value, Vec) { + let id = id.into(); + let mut before = Vec::new(); + loop { + let frame = self.next(); + if frame["id"] == id && frame.get("method").is_none() { + return (frame, before); + } + before.push(frame); + } + } + + pub fn initialize(&mut self, versions: Value) -> Value { + let mut capabilities = if versions.is_null() { + json!({}) + } else { + json!({ "experimental": { "litheFunctions": { "versions": versions } } }) + }; + capabilities["experimental"]["litheLogging"] = json!({ "versions": [0] }); + self.request( + 0, + "initialize", + json!({ "rootUri": "file:///tmp/state/", "capabilities": capabilities }), + ); + self.answer(0).0["result"].clone() + } + + pub fn invoke(&mut self, id: i64, function: &str, params: Value) { + self.request( + id, + "lithe/function/invoke", + json!({ "functionID": function, "operationID": format!("op-{id}"), "params": params }), + ); + } + + /// An invocation with extra top-level fields (`context`, `caller`, or a + /// different `operationID`) merged over the ones `invoke` sends. + pub fn invoke_with(&mut self, id: i64, function: &str, params: Value, extra: Value) { + let mut call = json!({ + "functionID": function, + "operationID": format!("op-{id}"), + "params": params, + }); + for (key, value) in extra.as_object().expect("extra fields are an object") { + call[key] = value.clone(); + } + self.request(id, "lithe/function/invoke", call); + } + + /// Ends the input stream and returns the backend's exit code. + pub fn close(mut self) -> i32 { + self.input = None; + self.exit.recv_timeout(WAIT).expect("the backend to end") + } + + pub fn exit_code(&self) -> i32 { + self.exit.recv_timeout(WAIT).expect("the backend to end") + } + + /// The next frame, if the backend sends one within `timeout`. + pub fn next_within(&self, timeout: Duration) -> Option { + self.frames.recv_timeout(timeout).ok() + } + + /// Fails when the backend sends a frame within a short wait. + pub fn assert_quiet(&self) { + if let Some(frame) = self.next_within(Duration::from_millis(200)) { + panic!("unexpected frame: {frame}"); + } + } +} + +fn read_frame(reader: &mut impl BufRead) -> Option { + let mut length = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).ok()? == 0 { + return None; + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some(value) = line.strip_prefix("Content-Length: ") { + length = value.parse().ok(); + } + } + let mut body = vec![0; length?]; + reader.read_exact(&mut body).ok()?; + serde_json::from_slice(&body).ok() +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/tests/host_services.rs b/shared/plugins/sdk/rust/plugin-sdk/tests/host_services.rs new file mode 100644 index 00000000..baf876b2 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/tests/host_services.rs @@ -0,0 +1,1136 @@ +//! Host services of the plugin-function protocol, driven by a scripted host +//! and the contract fixture; mirrors `sdk/node/test/host.test.mjs`. + +mod common; + +use common::{Host, WAIT}; +use lithe_plugin_sdk::{ + is_credential_key, Backend, Cancellation, FunctionError, SdkError, WorkspaceContext, + HOST_ERROR_CODES, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeSet, VecDeque}; +use std::fs; +use std::path::Path; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +const QUIET: Duration = Duration::from_millis(200); + +fn fixture() -> Value { + // Read at run time: the packaged crate has no contract directory. + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../contract/v1/fixtures/backend/function-protocol.json"); + let text = + fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display())); + serde_json::from_str(&text).expect("the fixture is JSON") +} + +fn cases(fixture: &Value, key: &str) -> Vec { + fixture[key].as_array().expect("fixture cases").clone() +} + +fn context() -> WorkspaceContext { + WorkspaceContext::new("w1", 2).unwrap() +} + +/// Runs blocking SDK work off the test thread. +fn background(work: impl FnOnce() -> T + Send + 'static) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let _ = sender.send(work()); + }); + receiver +} + +fn done(receiver: &mpsc::Receiver) -> T { + receiver + .recv_timeout(WAIT) + .expect("the host request to finish in time") +} + +fn code(result: Result) -> String { + match result { + Ok(value) => panic!("the host request succeeded: {value:?}"), + Err(error) => error.code().to_string(), + } +} + +/// A host whose session negotiated plugin functions (or did not), and every +/// frame the backend sent. +struct Session { + host: Host, + log: Vec, + unread: VecDeque, +} + +impl Session { + /// A session whose host offered `litheFunctions` version 0. + fn start(backend: Backend) -> Self { + Self::begin(backend, json!([0])) + } + + /// A session whose host offered no plugin functions. + fn without_functions(backend: Backend) -> Self { + Self::begin(backend, Value::Null) + } + + fn begin(backend: Backend, offer: Value) -> Self { + let mut host = Host::start(backend); + host.initialize(offer); + Self { + host, + log: Vec::new(), + unread: VecDeque::new(), + } + } + + fn pull(&mut self, timeout: Duration) -> Option { + let frame = self.host.next_within(timeout)?; + self.log.push(frame.clone()); + Some(frame) + } + + /// The next frame the test has not taken that `matches`. + fn take_where(&mut self, matches: impl Fn(&Value) -> bool) -> Value { + if let Some(index) = self.unread.iter().position(&matches) { + return self.unread.remove(index).unwrap(); + } + loop { + let frame = self + .pull(WAIT) + .unwrap_or_else(|| panic!("no matching frame; unread: {:?}", self.unread)); + if matches(&frame) { + return frame; + } + self.unread.push_back(frame); + } + } + + /// The next host request with `method`. + fn take(&mut self, method: &str) -> Value { + self.take_where(|frame| frame["method"] == method && frame.get("id").is_some()) + } + + /// The backend's answer to request `id`. + fn response(&mut self, id: impl Into) -> Value { + let id = id.into(); + self.take_where(|frame| frame["id"] == id && frame.get("method").is_none()) + } + + /// Every frame with `method` the backend sent, once it went quiet. + fn sent(&mut self, method: &str) -> Vec { + while let Some(frame) = self.pull(QUIET) { + self.unread.push_back(frame); + } + self.log + .iter() + .filter(|frame| frame["method"] == method) + .cloned() + .collect() + } + + fn reply(&mut self, request: &Value, result: Value) { + self.host + .send(json!({ "jsonrpc": "2.0", "id": request["id"], "result": result })); + } + + fn refuse(&mut self, request: &Value, error: Value) { + self.host + .send(json!({ "jsonrpc": "2.0", "id": request["id"], "error": error })); + } + + fn close(self) -> i32 { + self.host.close() + } +} + +#[test] +fn the_sdk_answers_plugin_functions_only_when_the_host_offers_version_zero() { + for (offered, negotiated) in [ + (json!([0]), true), + (json!([0, 99]), true), + (json!([1]), false), + (json!([99]), false), + (json!([]), false), + ] { + let mut backend = Backend::new("test-backend"); + backend + .function("query.run", |_, _| Ok(Value::Null)) + .unwrap(); + let handle = backend.handle(); + let mut host = Host::start(backend); + let result = host.initialize(offered.clone()); + let answer = &result["capabilities"]["experimental"]["litheFunctions"]; + if negotiated { + assert_eq!( + answer, + &json!({ "version": 0, "functions": [{ "id": "query.run" }] }) + ); + assert_eq!(handle.functions_version(), Some(0)); + } else { + assert_eq!(answer, &Value::Null, "{offered}"); + assert_eq!(handle.functions_version(), None, "{offered}"); + } + assert_eq!(handle.host().negotiated(), negotiated, "{offered}"); + host.close(); + } +} + +#[test] +fn credentials_round_trip_through_the_host_with_the_contract_shapes() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + + let credentials = host.clone(); + let reading = background(move || credentials.credentials().get("connection.prod")); + let get = session.take("lithe/host/credentials/get"); + assert_eq!(get["params"], json!({ "key": "connection.prod" })); + session.reply(&get, json!({ "secret": "s3cret" })); + assert_eq!(done(&reading).unwrap(), Some("s3cret".to_string())); + + let credentials = host.clone(); + let missing = background(move || credentials.credentials().get("connection.prod")); + let get = session.take("lithe/host/credentials/get"); + session.reply(&get, Value::Null); + assert_eq!(done(&missing).unwrap(), None); + + let credentials = host.clone(); + let malformed = background(move || credentials.credentials().get("connection.prod")); + let get = session.take("lithe/host/credentials/get"); + session.reply(&get, json!({})); + assert_eq!(code(done(&malformed)), "sdk.host.invalidResult"); + + let credentials = host.clone(); + let writing = background(move || { + credentials + .credentials() + .set("connection.prod:password", "s3cret") + }); + let set = session.take("lithe/host/credentials/set"); + assert_eq!( + set["params"], + json!({ "key": "connection.prod:password", "secret": "s3cret" }) + ); + session.reply(&set, Value::Null); + done(&writing).unwrap(); + + let credentials = host.clone(); + let deleting = background(move || credentials.credentials().delete("connection.prod")); + let delete = session.take("lithe/host/credentials/delete"); + assert_eq!(delete["params"], json!({ "key": "connection.prod" })); + session.reply(&delete, Value::Null); + done(&deleting).unwrap(); + + // Malformed arguments fail locally and never reach the host. + let credentials = host.credentials(); + for key in ["", "has space", &"k".repeat(129)] { + assert_eq!( + code(credentials.get(key)), + "sdk.credentials.invalidKey", + "{key:?}" + ); + } + let secret = "x".repeat(16 * 1024 + 1); + assert_eq!( + code(credentials.set("key", &secret)), + "sdk.credentials.invalidSecret" + ); + session.host.assert_quiet(); + session.close(); +} + +#[test] +fn local_key_and_context_checks_agree_with_the_fixture_cases_core_refuses_or_forwards() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let (mut keys, mut contexts) = (0, 0); + for case in cases(&fixture(), "hostRequests") { + let name = case["name"].as_str().unwrap(); + let method = case["method"].as_str().unwrap(); + if method.starts_with("lithe/host/credentials/") { + let key = case["params"]["key"].as_str().unwrap(); + if case["expected"].get("forward").is_some() { + assert!(is_credential_key(key), "{name}"); + } else if name.contains("key") { + assert_eq!( + code(host.credentials().get(key)), + "sdk.credentials.invalidKey", + "{name}" + ); + keys += 1; + } + } + if [ + "context without", + "context ID outside", + "generation is positive", + ] + .iter() + .any(|pattern| name.contains(pattern)) + { + let error = WorkspaceContext::from_value(&case["params"]["context"]).unwrap_err(); + assert_eq!(error.code(), "sdk.host.invalidContext", "{name}"); + contexts += 1; + } + } + assert_eq!((keys, contexts), (2, 3)); + session.host.assert_quiet(); + + // A full context from listContexts is passed on as its reference. + let full = json!({ "contextID": "w1", "generation": 2, "name": "orders", "folders": [] }); + let workspace = WorkspaceContext::from_value(&full).unwrap(); + let asking = { + let host = host.clone(); + background(move || host.workspace().get_folders(&workspace)) + }; + let request = session.take("lithe/host/workspace/getFolders"); + assert_eq!( + request["params"], + json!({ "context": { "contextID": "w1", "generation": 2 } }) + ); + session.reply(&request, json!({ "folders": [] })); + assert_eq!(done(&asking).unwrap(), json!({ "folders": [] })); + session.close(); +} + +#[test] +fn host_refusals_keep_the_code_number_and_details_core_sent() { + let fixture = fixture(); + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let mut answer = |wire: Value| -> SdkError { + let pending = { + let host = host.clone(); + background(move || host.request("lithe/host/workspace/listContexts", Value::Null)) + }; + let request = session.take("lithe/host/workspace/listContexts"); + session.refuse(&request, wire); + done(&pending).expect_err("a refusal") + }; + let mut refusals = 0; + for case in cases(&fixture, "hostRequests") { + let expected = &case["expected"]; + let Some(expected_code) = expected["code"].as_str() else { + continue; + }; + let mut data = json!({ "code": expected_code }); + if !expected["details"].is_null() { + data["details"] = expected["details"].clone(); + } + let error = + answer(json!({ "code": expected["rpcCode"], "message": "Refused.", "data": data })); + assert_eq!(error.code(), expected_code, "{}", case["name"]); + assert_eq!( + error.rpc_code(), + expected["rpcCode"].as_i64(), + "{}", + case["name"] + ); + assert_eq!( + error.details(), + expected["details"].as_object(), + "{}", + case["name"] + ); + assert_eq!(error.message(), "Refused."); + refusals += 1; + } + assert!(refusals > 0); + let failures = cases(&fixture, "hostFailures"); + assert!(!failures.is_empty()); + for case in failures { + let expected = &case["expected"]; + let number = expected["rpcCode"].as_i64().unwrap(); + let known = HOST_ERROR_CODES + .iter() + .find(|(code, _)| expected["code"] == *code) + .map(|(_, number)| *number); + assert_eq!(known, Some(number), "{}", case["name"]); + // The number alone names the code. + let error = answer(json!({ "code": number, "message": "Failed." })); + assert_eq!(error.code(), expected["code"], "{}", case["name"]); + } + let later = answer(json!({ + "code": -32050, "message": "Later code.", "data": { "details": ["not", "an", "object"] } + })); + assert_eq!(later.code(), "plugin.host.failed"); + assert_eq!(later.details(), None); + session.close(); +} + +#[test] +fn without_plugin_functions_the_host_offers_no_service_and_host_requests_stay_within_lithe_host() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let session = Session::without_functions(backend); + let unavailable = host.credentials().get("connection.prod").unwrap_err(); + assert_eq!(unavailable.code(), "plugin.host.unavailable"); + assert_eq!(unavailable.rpc_code(), Some(-32601)); + assert_eq!(code(host.capabilities()), "plugin.host.unavailable"); + assert!(!host.supports("lithe/host/credentials/get").unwrap()); + // The relay is a host service too, and so is the generic request. + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let functions = host.functions(); + let providers = functions.providers("languageImport.settings"); + let invoked = functions.invoke(&context(), call); + let generic = host.request("lithe/host/functions/invoke", json!({})); + for result in [providers, invoked, generic] { + let error = result.unwrap_err(); + assert_eq!(error.code(), "plugin.host.unavailable"); + assert_eq!(error.rpc_code(), Some(-32601)); + } + assert_eq!( + code(host.request("lithe/function/invoke", json!({}))), + "sdk.host.method" + ); + session.host.assert_quiet(); + session.close(); + + let idle = Backend::new("idle").handle().host(); + assert_eq!(code(idle.credentials().get("key")), "sdk.session.notReady"); +} + +#[test] +fn capabilities_are_asked_for_once_and_supports_reads_them() { + let answer = cases(&fixture(), "hostRequests") + .into_iter() + .find_map(|case| case["expected"].get("answer").cloned()) + .expect("a capabilities answer"); + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let first = { + let host = host.clone(); + background(move || host.supports("lithe/host/workspace/getFolders")) + }; + let second = { + let host = host.clone(); + background(move || host.supports("lithe/host/workspace/readFile")) + }; + let request = session.take("lithe/host/getCapabilities"); + assert!(request.get("params").is_none(), "{request}"); + session.reply(&request, answer); + assert!(done(&first).unwrap()); + assert!(!done(&second).unwrap()); + assert_eq!( + host.capabilities().unwrap()["limits"]["pendingHostRequests"], + 16 + ); + assert_eq!(session.sent("lithe/host/getCapabilities").len(), 1); + session.close(); +} + +#[test] +fn a_call_carries_its_workspace_context_to_the_handler() { + let mut checked = 0; + for case in cases(&fixture(), "invocationContexts") { + let params = &case["expected"]["params"]; + if params.is_null() { + continue; + } + let (sender, seen) = mpsc::channel(); + let mut backend = Backend::new("test-backend"); + backend + .function(params["functionID"].as_str().unwrap(), move |_, context| { + let context = context.workspace_context().map(WorkspaceContext::to_value); + sender.send(context.unwrap_or(Value::Null)).unwrap(); + Ok(Value::Null) + }) + .unwrap(); + let mut session = Session::start(backend); + session + .host + .request(9, "lithe/function/invoke", params.clone()); + let response = session.response(9); + assert!( + response.get("error").is_none(), + "{}: {response}", + case["name"] + ); + assert_eq!( + seen.recv_timeout(WAIT).unwrap(), + params["context"], + "{}", + case["name"] + ); + session.close(); + checked += 1; + } + assert!(checked > 0); +} + +#[test] +fn cancelling_a_call_cancels_its_host_requests_at_core() { + let (sender, outcome) = mpsc::channel(); + let mut backend = Backend::new("test-backend"); + backend + .function("secret.read", move |_, context| { + let read = context.host().credentials().get("connection.prod"); + let failure = read.as_ref().err().map(|error| error.code().to_string()); + sender.send(failure).unwrap(); + Ok(json!(read?)) + }) + .unwrap(); + let mut session = Session::start(backend); + session.host.request( + 41, + "lithe/function/invoke", + json!({ "functionID": "secret.read", "operationID": "c" }), + ); + let request = session.take("lithe/host/credentials/get"); + session.host.notify("$/cancelRequest", json!({ "id": 41 })); + assert_eq!(session.response(41)["error"]["code"], -32800); + // The host request fails with the call's cancellation. + assert_eq!( + outcome.recv_timeout(WAIT).unwrap().as_deref(), + Some("sdk.call.cancelled") + ); + let cancels: Vec<_> = session + .sent("$/cancelRequest") + .into_iter() + .map(|frame| frame["params"].clone()) + .collect(); + assert_eq!(cancels, [json!({ "id": request["id"] })]); + // Core's late answer is dropped; the session continues. + session.refuse( + &request, + json!({ "code": -32800, "message": "Cancelled.", "data": { "code": "plugin.host.cancelled" } }), + ); + assert_eq!(session.close(), 1); +} + +#[test] +fn a_cancelled_token_ends_its_request_which_is_never_sent_again() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let stop = Cancellation::new(); + let cancellable = host.with_cancellation(&stop); + let edit = json!({ "documentID": "d1", "expectedVersion": 4, "edits": [] }); + let pending = { + let (cancellable, edit) = (cancellable.clone(), edit.clone()); + background(move || cancellable.editor().apply_edits(&context(), edit)) + }; + let request = session.take("lithe/host/editor/applyEdits"); + let mut with_context = edit; + with_context["context"] = context().to_value(); + assert_eq!(request["params"], with_context); + stop.cancel(); + assert_eq!(code(done(&pending)), "sdk.call.cancelled"); + let cancel = session.take_where(|frame| frame["method"] == "$/cancelRequest"); + assert_eq!(cancel["params"], json!({ "id": request["id"] })); + session.reply( + &request, + json!({ "documentID": "d1", "version": 5, "applied": true }), + ); + assert_eq!( + code(cancellable.request("lithe/host/editor/applyEdits", json!({}))), + "sdk.call.cancelled" + ); + assert_eq!(session.sent("lithe/host/editor/applyEdits").len(), 1); + session.close(); +} + +#[test] +fn at_most_sixteen_host_requests_are_unanswered_later_ones_wait_for_a_free_slot() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let calls: Vec<_> = (0..18) + .map(|_| { + let host = host.clone(); + background(move || host.workspace().list_contexts()) + }) + .collect(); + let first = session.sent("lithe/host/workspace/listContexts"); + assert_eq!(first.len(), 16); + let answered = &first[0]; + session.reply(answered, json!({ "revision": 1, "contexts": [] })); + assert_eq!(session.sent("lithe/host/workspace/listContexts").len(), 17); + // Ending the session fails the queued request too. + session.close(); + let mut outcomes: Vec<_> = calls + .iter() + .map(|call| match done(call) { + Ok(result) => result.to_string(), + Err(error) => error.code().to_string(), + }) + .collect(); + outcomes.sort(); + let mut expected = vec!["sdk.session.closed".to_string(); 17]; + expected.push(json!({ "revision": 1, "contexts": [] }).to_string()); + expected.sort(); + assert_eq!(outcomes, expected); +} + +#[test] +fn subscription_events_arrive_in_order_also_when_sent_before_the_answer() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let watched = json!({ "resource": { "rootID": "r1", "path": "" }, "recursive": true }); + let watching = { + let (host, watched) = (host.clone(), watched.clone()); + background(move || host.workspace().watch(&context(), watched)) + }; + let request = session.take("lithe/host/workspace/watch"); + let mut with_context = watched; + with_context["context"] = context().to_value(); + assert_eq!(request["params"], with_context); + let notify = |session: &mut Session, sequence: u64| { + session.host.notify( + "lithe/host/subscriptions/notify", + json!({ + "subscriptionID": "s1", "sequence": sequence, + "event": { "rootID": "r1", "changedPaths": [format!("f{sequence}")], "requiresRescan": false } + }), + ); + }; + notify(&mut session, 1); + session.reply(&request, json!({ "subscriptionID": "s1" })); + let subscription = Arc::new(done(&watching).unwrap()); + notify(&mut session, 2); + notify(&mut session, 4); + let seen: Vec<_> = (0..3) + .map(|_| { + let event = subscription.recv_timeout(WAIT).expect("an event"); + json!([ + event.subscription_id(), + event.sequence(), + event.gap(), + event.event()["changedPaths"] + ]) + }) + .collect(); + assert_eq!( + seen, + [ + json!(["s1", 1, false, ["f1"]]), + json!(["s1", 2, false, ["f2"]]), + json!(["s1", 4, true, ["f4"]]), + ] + ); + assert_eq!(subscription.id(), "s1"); + assert!(!subscription.is_disposed()); + + let disposing = { + let subscription = Arc::clone(&subscription); + background(move || subscription.dispose()) + }; + let dispose = session.take("lithe/host/subscriptions/dispose"); + assert!(subscription.is_disposed()); + assert_eq!(dispose["params"], json!({ "subscriptionID": "s1" })); + notify(&mut session, 5); + session.reply(&dispose, Value::Null); + done(&disposing).unwrap(); + // Event 5 came after the disposal; it was dropped. + assert_eq!( + subscription.recv_timeout(QUIET), + Err(RecvTimeoutError::Disconnected) + ); + assert_eq!(subscription.recv(), None); + session.close(); +} + +#[test] +fn commands_context_changes_and_later_host_notifications_reach_their_handlers() { + let delivered: Vec<_> = cases(&fixture(), "hostNotifications") + .into_iter() + .filter(|case| { + case["expected"]["delivered"] == true + && case["method"] != "lithe/host/subscriptions/notify" + }) + .collect(); + let mut expected: BTreeSet = delivered + .iter() + .map(|case| { + let params = &case["params"]; + let entry = match case["method"].as_str().unwrap() { + "lithe/host/commands/didRun" => { + json!(["command", params["commandID"], params["context"]]) + } + "lithe/host/workspace/didChangeContexts" => json!(["contexts", params]), + method => json!(["notification", method, params]), + }; + entry.to_string() + }) + .collect(); + expected.insert(json!(["later", { "value": 1 }]).to_string()); + // Every delivered case is told apart by its method and params. + assert_eq!(expected.len(), delivered.len() + 1); + + let (sender, seen) = mpsc::channel::(); + let mut backend = Backend::new("test-backend"); + let (commands, contexts, later) = (sender.clone(), sender.clone(), sender.clone()); + backend + .on_command("db.refresh", move |command_id, context| { + let context = context.map_or(Value::Null, |context| context.to_value()); + commands + .send(json!(["command", command_id, context])) + .unwrap(); + Ok(()) + }) + .unwrap() + .on_contexts_changed(move |params| { + contexts.send(json!(["contexts", params])).unwrap(); + Ok(()) + }) + .on_host_notification("lithe/host/later/didSomething", move |params| { + later.send(json!(["later", params])).unwrap(); + Ok(()) + }) + .unwrap(); + // The other delivered methods (run exits, language server changes and + // forwarded notifications) use the generic registration, once each. + let routed = [ + "lithe/host/commands/didRun", + "lithe/host/workspace/didChangeContexts", + ]; + let generic: BTreeSet<&str> = delivered + .iter() + .filter_map(|case| case["method"].as_str()) + .filter(|method| !routed.contains(method)) + .collect(); + assert_eq!(generic.len(), 3); + for method in generic { + let sender = sender.clone(); + let method_name = method.to_string(); + backend + .on_host_notification(method, move |params| { + sender + .send(json!(["notification", method_name, params])) + .unwrap(); + Ok(()) + }) + .unwrap(); + } + let refused = |result: Result<&mut Backend, SdkError>| { + result.err().expect("a refusal").code().to_string() + }; + let reserved = backend.on_host_notification("lithe/host/commands/didRun", |_| Ok(())); + assert_eq!(refused(reserved), "sdk.method.reserved"); + let invalid = backend.on_host_notification("workspace/didChange", |_| Ok(())); + assert_eq!(refused(invalid), "sdk.method.invalid"); + let command = backend.on_command("DB_Refresh", |_, _| Err(FunctionError::Cancelled)); + assert_eq!(refused(command), "sdk.command.invalidID"); + + let mut session = Session::start(backend); + for case in &delivered { + session + .host + .notify(case["method"].as_str().unwrap(), case["params"].clone()); + } + // A notification this SDK does not know is ignored, as the contract asks. + session + .host + .notify("lithe/host/unknown/ignored", json!({ "anything": true })); + session + .host + .notify("lithe/host/later/didSomething", json!({ "value": 1 })); + // Handlers run on their own threads, so only the set is fixed. + let received: BTreeSet = (0..expected.len()) + .map(|_| seen.recv_timeout(WAIT).expect("a notification").to_string()) + .collect(); + assert_eq!(received, expected); + assert!(seen.recv_timeout(QUIET).is_err()); + session.host.assert_quiet(); + session.close(); +} + +#[test] +fn native_terminal_methods_keep_context_and_literal_arguments() { + let backend = Backend::new("terminal-test"); + let handle = backend.handle(); + let mut session = Session::start(backend); + let host = handle.host(); + let creating = background(move || { + host.terminal().create( + &context(), + json!({ + "executable": "example-client", "arguments": ["literal;$(value)", "space value"] + }), + ) + }); + let create = session.take("lithe/host/terminal/create"); + assert_eq!(create["params"]["context"], context().to_value()); + assert_eq!( + create["params"]["arguments"], + json!(["literal;$(value)", "space value"]) + ); + session.reply(&create, json!({ "terminalID": "owned" })); + assert_eq!(done(&creating).unwrap()["terminalID"], "owned"); + for method in ["show", "close", "reopen", "dispose"] { + let host = handle.host(); + let operation = background(move || match method { + "show" => host.terminal().show(&context(), "owned"), + "close" => host.terminal().close(&context(), "owned", Some(4096)), + "reopen" => host.terminal().reopen(&context(), "owned"), + _ => host.terminal().dispose(&context(), "owned"), + }); + let request = session.take(&format!("lithe/host/terminal/{method}")); + assert_eq!(request["params"]["terminalID"], "owned"); + session.reply(&request, Value::Null); + assert_eq!(done(&operation).unwrap(), Value::Null); + } + session.close(); +} + +#[test] +fn a_relayed_call_names_the_plugin_that_called_it_and_a_host_call_names_none() { + let mut checked = 0; + for case in cases(&fixture(), "invocationCallers") { + let params = &case["expected"]["params"]; + let (sender, seen) = mpsc::channel::>(); + let mut backend = Backend::new("test-backend"); + backend + .function(params["functionID"].as_str().unwrap(), move |_, context| { + let caller = context.caller().map(|caller| caller.plugin_id().into()); + sender.send(caller).unwrap(); + Ok(Value::Null) + }) + .unwrap(); + let mut session = Session::start(backend); + session + .host + .request(9, "lithe/function/invoke", params.clone()); + let response = session.response(9); + assert!( + response.get("error").is_none(), + "{}: {response}", + case["name"] + ); + assert_eq!( + seen.recv_timeout(WAIT).unwrap(), + params["caller"]["pluginID"].as_str().map(str::to_string), + "{}", + case["name"] + ); + session.close(); + checked += 1; + } + assert!(checked > 0); +} + +#[test] +fn a_caller_that_is_not_exactly_a_plugin_id_is_not_reported() { + let (sender, seen) = mpsc::channel::>(); + let mut backend = Backend::new("test-backend"); + backend + .function("languageImport.settings", move |_, context| { + let caller = context.caller().map(|caller| caller.plugin_id().into()); + sender.send(caller).unwrap(); + Ok(Value::Null) + }) + .unwrap(); + let mut session = Session::start(backend); + let python = "dev.lithe.fixture.python"; + let attempts = [ + (json!({ "pluginID": python }), Some(python)), + (json!({ "pluginID": "" }), None), + (json!({ "pluginID": 7 }), None), + (json!({}), None), + (json!({ "pluginID": python, "extra": true }), None), + (json!(python), None), + (Value::Null, None), + ]; + for (id, (caller, expected)) in (10..).zip(attempts) { + let extra = json!({ "caller": caller }); + session + .host + .invoke_with(id, "languageImport.settings", json!({}), extra); + assert_eq!(session.response(id)["result"], Value::Null); + let reported = seen.recv_timeout(WAIT).unwrap(); + assert_eq!(reported.as_deref(), expected, "{caller}"); + } + session.close(); +} + +#[test] +fn relay_requests_carry_the_fixture_params_and_only_the_fields_the_methods_define() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let mut forwarded = 0; + for case in cases(&fixture(), "hostRequests") { + if case["expected"]["relay"] != true { + continue; + } + let method = case["method"].as_str().unwrap().to_string(); + let answer = if method.ends_with("/providers") { + json!({ "providers": [{ "pluginID": "dev.lithe.fixture.go", "version": 1 }] }) + } else { + json!({ "value": { "settings": [] } }) + }; + let sending = { + let host = host.clone(); + let (method, params) = (method.clone(), case["params"].clone()); + background(move || { + if method.ends_with("/providers") { + let function_id = params["functionID"].as_str().unwrap(); + host.functions().providers(function_id) + } else { + let context = WorkspaceContext::from_value(¶ms["context"]).unwrap(); + host.functions().invoke(&context, params) + } + }) + }; + let request = session.take(&method); + assert_eq!(request["params"], case["params"], "{}", case["name"]); + session.reply(&request, answer.clone()); + assert_eq!(done(&sending).unwrap(), answer, "{}", case["name"]); + forwarded += 1; + } + assert_eq!(forwarded, 3); + + // Fields the method does not define are not sent: Core would refuse a + // top-level `caller`, and the call's context is the argument. + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", + "caller": { "pluginID": "dev.lithe.fixture.other" }, + "context": { "contextID": "other", "generation": 9 }, "extra": true + }); + let sending = { + let host = host.clone(); + background(move || host.functions().invoke(&context(), call)) + }; + let request = session.take("lithe/host/functions/invoke"); + let expected = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", + "context": context().to_value() + }); + assert_eq!(request["params"], expected); + session.reply(&request, json!({ "value": null })); + done(&sending).unwrap(); + session.close(); +} + +#[test] +fn a_relayed_call_from_a_handler_belongs_to_the_call_it_serves_unless_it_names_another() { + let mut backend = Backend::new("test-backend"); + backend + .function("project.collect", |params, served| { + let value = served.host().functions().invoke(&context(), params)?; + Ok(value) + }) + .unwrap(); + let mut session = Session::start(backend); + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + + session.host.invoke(5, "project.collect", call.clone()); + let request = session.take("lithe/host/functions/invoke"); + assert_eq!(request["params"]["parentOperationID"], "op-5"); + session.reply(&request, json!({ "value": { "files": 3 } })); + let response = session.response(5); + assert_eq!(response["result"], json!({ "value": { "files": 3 } })); + + let mut named = call.clone(); + named["parentOperationID"] = json!("op-elsewhere"); + session.host.invoke(6, "project.collect", named); + let request = session.take("lithe/host/functions/invoke"); + assert_eq!(request["params"]["parentOperationID"], "op-elsewhere"); + session.reply(&request, json!({ "value": null })); + let response = session.response(6); + assert_eq!(response["result"], json!({ "value": null })); + + // A parent the contract would refuse never leaves the SDK. + let mut invalid = call; + invalid["parentOperationID"] = json!(""); + session.host.invoke(7, "project.collect", invalid); + assert!(session.response(7).get("error").is_some()); + assert_eq!(session.sent("lithe/host/functions/invoke").len(), 2); + session.close(); +} + +#[test] +fn relay_refusals_keep_the_code_number_and_the_providers_own_code() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let mut refused = |wire: Value| -> SdkError { + let pending = { + let (host, call) = (host.clone(), call.clone()); + background(move || host.functions().invoke(&context(), call)) + }; + let request = session.take("lithe/host/functions/invoke"); + session.refuse(&request, wire); + done(&pending).expect_err("a refusal") + }; + let relay_errors = cases(&fixture(), "relayErrors"); + assert_eq!(relay_errors.len(), 15); + for case in relay_errors { + let expected = case["code"].as_str().unwrap(); + let number = case["rpcCode"].as_i64().unwrap(); + let data = json!({ "code": expected }); + let named = refused(json!({ "code": number, "message": "Refused.", "data": data })); + assert_eq!(named.code(), expected, "{}", case["name"]); + assert_eq!(named.rpc_code(), Some(number), "{}", case["name"]); + // The number alone names the code. + let numbered = refused(json!({ "code": number, "message": "Refused." })); + assert_eq!(numbered.code(), expected, "{}", case["name"]); + assert_eq!(numbered.rpc_code(), Some(number), "{}", case["name"]); + } + let failed = refused(json!({ + "code": -32070, "message": "The provider failed.", + "data": { + "code": "plugin.function.failed", "details": { "pluginErrorCode": "db.locked" } + } + })); + assert_eq!(failed.code(), "plugin.function.failed"); + assert_eq!(failed.details().unwrap()["pluginErrorCode"], "db.locked"); + session.close(); +} + +#[test] +fn an_invalid_relayed_call_fails_locally_and_nothing_is_sent() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let session = Session::start(backend); + let functions = host.functions(); + let valid = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let attempts = [ + ("pluginID", json!("")), + ("pluginID", json!(7)), + ("functionID", json!("Not-Valid")), + ("params", json!([1])), + ("timeoutMilliseconds", json!(0)), + ("timeoutMilliseconds", json!(-5)), + ("timeoutMilliseconds", json!(1.5)), + ("timeoutMilliseconds", json!("5")), + ("parentOperationID", json!(17)), + ("parentOperationID", json!("")), + ("parentOperationID", json!("p".repeat(129))), + ]; + for (field, bad) in attempts { + let label = format!("{field}: {bad}"); + let mut call = valid.clone(); + call[field] = bad; + let result = functions.invoke(&context(), call); + assert_eq!(code(result), "sdk.functions.invalidCall", "{label}"); + } + let incomplete = [ + json!([]), + json!({ "functionID": "languageImport.settings" }), + json!({ "pluginID": "dev.lithe.fixture.go" }), + ]; + for call in incomplete { + let label = call.to_string(); + let result = functions.invoke(&context(), call); + assert_eq!(code(result), "sdk.functions.invalidCall", "{label}"); + } + for bad in ["", "Not-Valid", "UPPER", "a..b"] { + let result = functions.providers(bad); + assert_eq!(code(result), "sdk.functions.invalidCall", "{bad:?}"); + } + + // The fixture's cases Core refuses as invalid params agree with these + // checks, whenever the call names a context. + let mut refused = 0; + for case in cases(&fixture(), "hostRequests") { + let params = &case["params"]; + if case["method"] != "lithe/host/functions/invoke" + || case["expected"]["code"] != "plugin.host.invalidParams" + || params.get("context").is_none() + { + continue; + } + let workspace = WorkspaceContext::from_value(¶ms["context"]).unwrap(); + let result = functions.invoke(&workspace, params.clone()); + assert_eq!( + code(result), + "sdk.functions.invalidCall", + "{}", + case["name"] + ); + refused += 1; + } + assert_eq!(refused, 4); + session.host.assert_quiet(); + session.close(); +} + +#[test] +fn relay_answers_are_the_hosts_objects_and_a_null_value_stays_inside_value() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(backend); + let listing = { + let host = host.clone(); + background(move || host.functions().providers("languageImport.settings")) + }; + let request = session.take("lithe/host/functions/providers"); + let answer = json!({ + "providers": [{ "pluginID": "dev.lithe.fixture.go", "version": 1 }] + }); + session.reply(&request, answer.clone()); + assert_eq!(done(&listing).unwrap(), answer); + + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let calling = { + let host = host.clone(); + background(move || host.functions().invoke(&context(), call)) + }; + let request = session.take("lithe/host/functions/invoke"); + session.reply(&request, json!({ "value": null })); + assert_eq!(done(&calling).unwrap(), json!({ "value": null })); + + // An answer that is not an object is the host's mistake, not a value. + let broken = { + let host = host.clone(); + background(move || host.functions().providers("languageImport.settings")) + }; + let request = session.take("lithe/host/functions/providers"); + session.reply(&request, json!([])); + assert_eq!(code(done(&broken)), "sdk.host.invalidResult"); + session.close(); +} + +#[test] +fn cancelling_a_call_cancels_the_relayed_call_it_waits_on() { + let (sender, outcome) = mpsc::channel(); + let mut backend = Backend::new("test-backend"); + backend + .function("project.collect", move |params, served| { + let relay = served.host().functions().invoke(&context(), params); + let failure = relay.as_ref().err().map(|error| error.code().to_string()); + sender.send(failure).unwrap(); + Ok(relay?) + }) + .unwrap(); + let mut session = Session::start(backend); + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + session.host.invoke(41, "project.collect", call); + let request = session.take("lithe/host/functions/invoke"); + session.host.notify("$/cancelRequest", json!({ "id": 41 })); + assert_eq!(session.response(41)["error"]["code"], -32800); + // The relayed request fails with the call's cancellation, and Core is told. + assert_eq!( + outcome.recv_timeout(WAIT).unwrap().as_deref(), + Some("sdk.call.cancelled") + ); + let cancels: Vec<_> = session + .sent("$/cancelRequest") + .into_iter() + .map(|frame| frame["params"].clone()) + .collect(); + assert_eq!(cancels, [json!({ "id": request["id"] })]); + session.close(); +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/tests/keymap.rs b/shared/plugins/sdk/rust/plugin-sdk/tests/keymap.rs new file mode 100644 index 00000000..736f88a9 --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/tests/keymap.rs @@ -0,0 +1,150 @@ +//! 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": { + "litheLogging": {"versions": [0]}, + "litheKeymapImport": {"versions": [0], "providers": ["first", "unknown"]} + }}}), + ); + let result = host.answer(0).0["result"].clone(); + assert_eq!( + result["capabilities"]["experimental"]["litheKeymapImport"], + json!({"version":0,"providers":["first"]}) + ); + host.request( + 1, + "lithe/keymap/import", + json!({"providerID":"first", "snapshotID":"snapshot-1"}), + ); + let (answer, before) = host.answer(1); + assert_eq!(answer["result"], json!({"bindings":[]})); + // The fact names the wire method Core accepts, never the provider-scoped call id. + let facts: Vec<_> = before + .iter() + .filter(|frame| frame["method"] == "lithe/log") + .collect(); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0]["params"]["kind"], "operationFinished"); + assert_eq!(facts[0]["params"]["operation"], "lithe/keymap/import"); + 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":{"litheLogging":{"versions":[0]},"litheKeymapImport":{"versions":[0],"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":{ + "litheLogging":{"versions":[0]}, "litheFunctions":{"versions":[0]}, + "litheKeymapImport":{"versions":[0],"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(); +} diff --git a/shared/plugins/sdk/rust/plugin-sdk/tests/session.rs b/shared/plugins/sdk/rust/plugin-sdk/tests/session.rs new file mode 100644 index 00000000..3c0cb4df --- /dev/null +++ b/shared/plugins/sdk/rust/plugin-sdk/tests/session.rs @@ -0,0 +1,528 @@ +//! The backend session driven by a scripted host over in-process pipes. +//! Every wait is bounded, so a broken session fails here with the frames it +//! sent instead of hanging. + +mod common; + +use common::{Host, WAIT}; +use lithe_plugin_sdk::{Backend, FunctionError, PluginError, SdkError}; +use serde_json::{json, Value}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +fn echo_backend() -> Backend { + let mut backend = Backend::new("test-backend").version("1.2.3"); + backend + .function("echo.value", |params, _| Ok(params)) + .expect("valid function"); + backend +} + +#[test] +fn negotiates_version_zero_and_lists_the_functions() { + let mut host = Host::start(echo_backend()); + let result = host.initialize(json!([0])); + assert_eq!( + result, + json!({ + "capabilities": { "experimental": { + "litheLogging": {"version": 0}, + "litheFunctions": { "version": 0, "functions": [{ "id": "echo.value" }] } + } }, + "serverInfo": { "name": "test-backend", "version": "1.2.3" } + }) + ); + host.invoke(1, "echo.value", json!({ "a": 1 })); + assert_eq!(host.answer(1).0["result"], json!({ "a": 1 })); + assert_eq!(host.close(), 1); +} + +#[test] +fn a_host_without_plugin_functions_gets_no_functions() { + let mut host = Host::start(echo_backend()); + assert_eq!( + host.initialize(Value::Null)["capabilities"], + json!({ "experimental": {"litheLogging": {"version": 0}} }) + ); + host.invoke(1, "echo.value", json!({})); + assert_eq!(host.answer(1).0["error"]["code"], -32602); + host.close(); +} + +#[test] +fn follows_the_lsp_lifecycle() { + let mut host = Host::start(echo_backend()); + host.invoke(1, "echo.value", json!({})); + assert_eq!(host.answer(1).0["error"]["code"], -32002); + host.send_raw(b"Content-Length: 3\r\n\r\nnot"); + let malformed = host.next(); + assert_eq!( + (malformed["id"].clone(), malformed["error"]["code"].clone()), + (Value::Null, json!(-32700)) + ); + host.send(json!([1, 2])); + assert_eq!(host.next()["error"]["code"], -32600); + + host.initialize(json!([0])); + host.request(2, "initialize", json!({})); + assert_eq!(host.answer(2).0["error"]["code"], -32600); + host.request(3, "textDocument/hover", json!({})); + assert_eq!(host.answer(3).0["error"]["code"], -32601); + host.request(4, "shutdown", Value::Null); + assert_eq!( + host.answer(4).0, + json!({ "jsonrpc": "2.0", "id": 4, "result": null }) + ); + host.invoke(5, "echo.value", json!({})); + assert_eq!(host.answer(5).0["error"]["code"], -32600); + host.notify("exit", Value::Null); + assert_eq!(host.exit_code(), 0); +} + +#[test] +fn exit_without_shutdown_and_a_broken_frame_end_with_a_failure() { + let mut host = Host::start(echo_backend()); + host.notify("exit", Value::Null); + assert_eq!(host.exit_code(), 1); + + let mut host = Host::start(echo_backend()); + host.send_raw(b"Content-Length: nope\r\n\r\n"); + assert_eq!(host.exit_code(), 1); +} + +#[test] +fn maps_results_failures_and_panics() { + let mut backend = Backend::new("test-backend"); + backend + .function("work.run", |params, context| { + context.progress("Halfway", Some(150)); + match params["mode"].as_str() { + Some("plugin") => Err(PluginError::new("work.failed", "The work failed.").into()), + Some("internal") => Err(FunctionError::internal("/private/path: denied")), + Some("panic") => panic!("secret detail"), + Some("large") => Ok(json!("x".repeat(1 << 20))), + _ => Ok(json!({ "done": true })), + } + }) + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([0])); + + host.invoke(1, "work.run", json!({})); + let (answer, before) = host.answer(1); + assert_eq!(answer["result"], json!({ "done": true })); + assert_eq!( + before[0], + json!({ + "jsonrpc": "2.0", "method": "lithe/function/progress", + "params": { "operationID": "op-1", "message": "Halfway", "percentage": 100 } + }) + ); + assert_eq!(before[1]["method"], "lithe/log"); + assert_eq!(before[1]["params"]["kind"], "operationFinished"); + assert_eq!(before[1]["params"]["operationId"], "op-1"); + + host.invoke(2, "work.run", json!({ "mode": "plugin" })); + assert_eq!( + host.answer(2).0["error"], + json!({ "code": -32000, "message": "The work failed.", "data": { "code": "work.failed" } }) + ); + for (id, mode) in [(3, "internal"), (4, "panic"), (5, "large")] { + host.invoke(id, "work.run", json!({ "mode": mode })); + assert_eq!( + host.answer(id).0["error"], + json!({ "code": -32603, "message": "The plugin backend failed unexpectedly." }), + "{mode}" + ); + } + // The session survives a panicking call. + host.invoke(6, "work.run", json!({})); + assert_eq!(host.answer(6).0["result"], json!({ "done": true })); + host.close(); +} + +#[test] +fn cancel_request_cancels_the_running_call() { + let mut backend = Backend::new("test-backend"); + backend + .function("work.wait", |_, context| { + context.progress("Waiting", None); + if context.cancellation().wait_timeout(Duration::from_secs(30)) { + context.check_cancelled()?; + } + Ok(json!("not cancelled")) + }) + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([0])); + host.invoke(7, "work.wait", json!({})); + assert_eq!(host.next()["method"], "lithe/function/progress"); + host.notify("$/cancelRequest", json!({ "id": 7 })); + assert_eq!( + host.answer(7).0["error"], + json!({ "code": -32800, "message": "The request was cancelled." }) + ); + host.close(); +} + +#[test] +fn shutdown_cancels_running_calls() { + let mut backend = Backend::new("test-backend"); + let (sender, receiver) = mpsc::channel(); + backend + .function("work.wait", |_, context| { + context.progress("Waiting", None); + context.cancellation().wait_timeout(Duration::from_secs(30)); + context.check_cancelled()?; + Ok(Value::Null) + }) + .unwrap(); + backend.on_shutdown(move || sender.send("shutdown hook").unwrap()); + let mut host = Host::start(backend); + host.initialize(json!([0])); + host.invoke(1, "work.wait", json!({})); + assert_eq!(host.next()["method"], "lithe/function/progress"); + host.request(2, "shutdown", Value::Null); + let mut answers: Vec<_> = (0..2).map(|_| host.next()).collect(); + answers.sort_by_key(|answer| answer["id"].as_i64()); + assert_eq!(answers[0]["error"]["code"], -32800); + assert_eq!(answers[1]["result"], Value::Null); + assert_eq!(receiver.recv_timeout(WAIT), Ok("shutdown hook")); + host.close(); +} + +/// A function that stores, reads, and deletes a credential. +fn credentials_backend() -> Backend { + let mut backend = Backend::new("test-backend"); + backend + .function("secret.roundTrip", |params, context| { + let credentials = context.credentials(); + let key = params["key"].as_str().unwrap_or_default(); + if let Err(error) = credentials.set(key, "s3cr3t") { + return Err(PluginError::new("test.credentials", error.code().to_string()).into()); + } + let stored = credentials.get(key)?; + credentials.delete(key)?; + Ok(json!({ "stored": stored })) + }) + .unwrap(); + backend +} + +#[test] +fn credentials_are_host_requests() { + let mut host = Host::start(credentials_backend()); + host.initialize(json!([0])); + host.invoke(1, "secret.roundTrip", json!({ "key": "db:main" })); + let set = host.next(); + assert_eq!(set["method"], "lithe/host/credentials/set"); + assert_eq!( + set["params"], + json!({ "key": "db:main", "secret": "s3cr3t" }) + ); + host.send(json!({ "jsonrpc": "2.0", "id": set["id"], "result": null })); + let get = host.next(); + assert_eq!(get["method"], "lithe/host/credentials/get"); + assert_eq!(get["params"], json!({ "key": "db:main" })); + host.send(json!({ "jsonrpc": "2.0", "id": get["id"], "result": { "secret": "s3cr3t" } })); + let delete = host.next(); + assert_eq!(delete["method"], "lithe/host/credentials/delete"); + host.send(json!({ "jsonrpc": "2.0", "id": delete["id"], "result": null })); + assert_eq!(host.answer(1).0["result"], json!({ "stored": "s3cr3t" })); + + // Core's refusal reaches the handler with its code. + host.invoke(2, "secret.roundTrip", json!({ "key": "db:main" })); + let set = host.next(); + host.send(json!({ + "jsonrpc": "2.0", "id": set["id"], + "error": { "code": -32601, "message": "Not granted.", "data": { "code": "plugin.host.permissionDenied" } } + })); + assert_eq!( + host.answer(2).0["error"]["message"], + "plugin.host.permissionDenied" + ); + + // A malformed key never reaches the host. + host.invoke(3, "secret.roundTrip", json!({ "key": "has space" })); + assert_eq!( + host.answer(3).0["error"]["message"], + "sdk.credentials.invalidKey" + ); + host.close(); +} + +#[test] +fn cancelling_a_call_ends_its_waiting_host_request() { + let mut host = Host::start(credentials_backend()); + host.initialize(json!([0])); + host.invoke(1, "secret.roundTrip", json!({ "key": "db:main" })); + assert_eq!(host.next()["method"], "lithe/host/credentials/set"); + host.notify("$/cancelRequest", json!({ "id": 1 })); + assert_eq!(host.answer(1).0["error"]["code"], -32800); + host.close(); +} + +#[test] +fn functions_are_registered_and_withdrawn_at_runtime() { + let mut backend = Backend::new("test-backend"); + backend + .function("extra.add", |_, context| { + context + .backend() + .register_function("extra.value", |_, _| Ok(json!(42)))?; + Ok(json!(context.backend().function_ids())) + }) + .unwrap(); + backend + .function("extra.remove", |_, context| { + context.backend().unregister_function("extra.value")?; + Ok(Value::Null) + }) + .unwrap(); + let handle = backend.handle(); + let mut host = Host::start(backend); + host.initialize(json!([0])); + + host.invoke(1, "extra.add", json!({})); + let register = host.next(); + assert_eq!(register["method"], "lithe/function/register"); + assert_eq!( + register["params"], + json!({ "functions": [{ "id": "extra.value" }] }) + ); + host.send(json!({ "jsonrpc": "2.0", "id": register["id"], "result": null })); + assert_eq!( + host.answer(1).0["result"], + json!(["extra.add", "extra.remove", "extra.value"]) + ); + host.invoke(2, "extra.value", json!({})); + assert_eq!(host.answer(2).0["result"], 42); + + host.invoke(3, "extra.remove", json!({})); + let unregister = host.next(); + assert_eq!(unregister["params"], json!({ "ids": ["extra.value"] })); + host.send(json!({ "jsonrpc": "2.0", "id": unregister["id"], "result": null })); + assert_eq!(host.answer(3).0["result"], Value::Null); + host.invoke(4, "extra.value", json!({})); + assert_eq!(host.answer(4).0["error"]["code"], -32602); + + // A refused registration leaves the function out. + let registering = { + let handle = handle.clone(); + thread::spawn(move || handle.register_function("extra.refused", |_, _| Ok(Value::Null))) + }; + let register = host.next(); + host.send(json!({ + "jsonrpc": "2.0", "id": register["id"], + "error": { "code": -32602, "message": "No.", "data": { "code": "plugin.function.invalidRegistration" } } + })); + let refused: Result<(), SdkError> = registering.join().unwrap(); + assert_eq!( + refused.unwrap_err().code(), + "plugin.function.invalidRegistration" + ); + assert!(!handle.function_ids().contains(&"extra.refused".to_string())); + host.close(); + // After the session, requests fail instead of waiting. + assert_eq!( + handle + .register_function("extra.late", |_, _| Ok(Value::Null)) + .unwrap_err() + .code(), + "sdk.session.closed" + ); +} + +#[test] +fn function_declarations_are_checked() { + let mut backend = Backend::new("test-backend"); + let invalid = backend + .function("Bad", |_, _| Ok(Value::Null)) + .err() + .unwrap(); + assert_eq!(invalid.code(), "sdk.function.invalidID"); + backend.function("a.b", |_, _| Ok(Value::Null)).unwrap(); + let duplicate = backend + .function("a.b", |_, _| Ok(Value::Null)) + .err() + .unwrap(); + assert_eq!(duplicate.code(), "sdk.function.duplicate"); + assert_eq!( + backend + .handle() + .register_function("c.d", |_, _| Ok(Value::Null)) + .unwrap_err() + .code(), + "sdk.functions.unavailable" + ); +} + +#[test] +fn initialize_hooks_see_the_root_and_add_functions() { + let mut backend = Backend::new("test-backend"); + let (sender, receiver) = mpsc::channel(); + backend.on_initialize(move |initialize| { + sender.send(initialize.root_path()).unwrap(); + initialize.function("late.added", |_, _| Ok(json!("added")))?; + Ok(()) + }); + let mut host = Host::start(backend); + let result = host.initialize(json!([0])); + assert_eq!( + result["capabilities"]["experimental"]["litheFunctions"]["functions"], + json!([{ "id": "late.added" }]) + ); + assert_eq!( + receiver.recv_timeout(WAIT).unwrap(), + Some(std::path::PathBuf::from("/tmp/state")) + ); + + let mut refusing = Backend::new("test-backend"); + refusing.on_initialize(|_| Err(PluginError::new("test.notReady", "Not ready.").into())); + let mut host = Host::start(refusing); + host.request( + 0, + "initialize", + json!({ "capabilities": {"experimental": {"litheLogging": {"versions": [0]}}} }), + ); + assert_eq!(host.answer(0).0["error"]["data"]["code"], "test.notReady"); + host.close(); +} + +#[test] +fn typed_diagnostics_preserve_operation_and_suppress_only_success() { + let mut backend = Backend::new("test-backend"); + backend + .function("work.inspect", |params, context| { + context + .diagnostics() + .degraded("work.inspect", "cache_unavailable", Some(2))?; + context + .diagnostics() + .recovered("work.inspect", "cache_restored", None)?; + if params["fail"] == true { + return Err(PluginError::new("work.failed", "private explanation").into()); + } + Ok(Value::Null) + }) + .unwrap(); + backend + .suppress_success_diagnostics("work.inspect") + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([0])); + host.invoke(1, "work.inspect", json!({"fail": false})); + let (_, success) = host.answer(1); + assert_eq!(success.len(), 2); + assert_eq!(success[0]["params"]["operationId"], "op-1"); + assert_eq!(success[0]["params"]["kind"], "degraded"); + assert_eq!(success[1]["params"]["kind"], "recovered"); + host.invoke(2, "work.inspect", json!({"fail": true})); + let (_, failure) = host.answer(2); + assert_eq!(failure.len(), 3); + assert_eq!(failure[2]["params"]["kind"], "operationFailed"); + assert_eq!(failure[2]["params"]["code"], "work.failed"); + assert!(failure[2]["params"]["durationMs"].as_u64().is_some()); + assert!(!failure[2].to_string().contains("private explanation")); + host.close(); +} + +#[test] +fn per_call_suppression_keeps_user_success_and_failures() { + let mut backend = Backend::new("test-backend"); + backend + .function("database.call", |params, context| { + if params["readOnly"] == true { + context.suppress_success_diagnostics(); + } + if params["fail"] == true { + return Err(FunctionError::internal("private failure")); + } + Ok(Value::Null) + }) + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([0])); + host.invoke(1, "database.call", json!({"readOnly": true})); + assert!(host.answer(1).1.is_empty()); + host.invoke(2, "database.call", json!({})); + assert_eq!(host.answer(2).1[0]["params"]["kind"], "operationFinished"); + host.invoke(3, "database.call", json!({"readOnly": true,"fail": true})); + assert_eq!(host.answer(3).1[0]["params"]["kind"], "operationFailed"); + host.close(); +} + +#[test] +fn initialize_diagnostics_wait_for_capability_answer_and_stay_bounded() { + let mut backend = Backend::new("test-backend"); + let diagnostics = backend.handle().diagnostics(); + backend.on_initialize(move |_| { + for count in 0..10 { + diagnostics.degraded("initialize", "snapshot_unavailable", Some(count))?; + } + Ok(()) + }); + let mut host = Host::start(backend); + host.request( + 0, + "initialize", + json!({"capabilities":{"experimental":{"litheLogging":{"versions":[0]}}}}), + ); + let answer = host.next(); + assert_eq!( + answer["id"], 0, + "no diagnostic before capability answer: {answer}" + ); + assert_eq!( + answer["result"]["capabilities"]["experimental"]["litheLogging"]["version"], + 0 + ); + for count in 0..8 { + let fact = host.next(); + assert_eq!(fact["method"], "lithe/log"); + assert_eq!(fact["params"]["count"], count); + } + host.request(1, "shutdown", Value::Null); + assert_eq!( + host.next()["id"], + 1, + "initialization buffer is bounded to eight facts" + ); + host.close(); +} + +#[test] +fn diagnostics_use_original_rpc_id_when_operation_id_is_absent() { + let mut backend = Backend::new("test-backend"); + backend + .function("work.inspect", |_, context| { + context + .diagnostics() + .degraded("work.inspect", "cache_unavailable", None)?; + Ok(Value::Null) + }) + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([0])); + host.request( + 42, + "lithe/function/invoke", + json!({"functionID":"work.inspect"}), + ); + let (_, facts) = host.answer(42); + assert_eq!(facts.len(), 2); + assert!(facts + .iter() + .all(|fact| fact["params"]["operationId"] == "42")); + host.request( + "rpc-second", + "lithe/function/invoke", + json!({"functionID":"work.inspect", "operationID":"wire-original"}), + ); + let (_, facts) = host.answer("rpc-second"); + assert!(facts + .iter() + .all(|fact| fact["params"]["operationId"] == "wire-original")); + host.close(); +}