diff --git a/AGENTS-CN.md b/AGENTS-CN.md index daf6700ca5..0c75337b2a 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -25,9 +25,9 @@ Execution、Extension、Cross-platform Adapter 的边界以 | 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线和 adapter/service 注册 | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | 适配层 | `src/crates/adapters` | AI/API/transport/WebDriver 协议 adapter 和外部 provider 转换 | `ai-adapters`, `api-layer`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | -| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、harness、stream、DeepReview policy/report、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | -| 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | +| 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、LSP protocol/plugin DTO、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | 边界规则: @@ -35,7 +35,7 @@ Execution、Extension、Cross-platform Adapter 的边界以 - 组装层只接线下层并选择产品能力事实,不实现具体 adapter、OS 或 service 细节。 - 产品特性只在内核能力之上组装用户侧命令、UI contribution、设置和默认策略;长程任务、scheduler、permission、session/workspace、memory、DFX、hook 和 event 事实属于 Agent Kernel owner。 - 适配层翻译协议和外部 provider 形状,不拥有产品能力选择或可复用 OS service 行为。 -- 服务实现层负责可复用的 OS、process、terminal、MCP、remote、git、filesystem、session persistence primitives 和 MiniApp runtime IO 能力。 +- 服务实现层负责可复用的 OS、process、terminal、MCP、remote、git、filesystem、LSP plugin registry、session persistence primitives 和 MiniApp runtime IO 能力。 - 外部系统是边界外资源,不是仓库内层级。只有已注册的 adapter、service 或 app-local provider 应调用它们;其他层消费 port 和稳定契约。 - 执行原语层只放可移植运行时构件,不拥有宿主或交付形态。 - 契约层保持轻行为,不得向上依赖。 diff --git a/AGENTS.md b/AGENTS.md index bd93193802..1d2a93d128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,9 +26,9 @@ Keep crate dependencies inside each layer to the smallest set needed. | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, and adapter/service registration | `core`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | Adapters | `src/crates/adapters` | AI/API/transport/WebDriver protocol adapters and external-provider translation | `ai-adapters`, `api-layer`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | -| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | +| 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | -| 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | +| 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, LSP protocol/plugin DTOs, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | Boundary rules: @@ -36,7 +36,7 @@ Boundary rules: - Assembly wires lower layers and selects product capability facts; it must not implement concrete adapter, OS, or service details. - Product features assemble user-facing commands, UI contributions, settings, and default policy on top of kernel capabilities; long-running task, scheduler, permission, session/workspace, memory, DFX, hook, and event facts stay in Agent Kernel owners. - Adapters translate protocols and external-provider shapes; they should not own product capability selection or reusable OS service behavior. -- Services implement reusable concrete OS, process, terminal, MCP, remote, git, filesystem, and MiniApp runtime IO capabilities. +- Services implement reusable concrete OS, process, terminal, MCP, remote, git, filesystem, LSP plugin registry, and MiniApp runtime IO capabilities. - External systems are boundary resources, not repository layers. Only registered adapters/services/app-local providers should call them; other layers consume ports and stable contracts. - Execution crates are portable runtime building blocks, not host-specific or delivery-profile owners. - Contracts stay behavior-light and must not depend upward. diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 2692fe406d..1aba9f2c13 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -9,13 +9,13 @@ ## 1. 基础边界 - 已建立 `product-full` 作为完整产品能力保护开关,产品入口显式启用完整能力。 -- 已抽取 `bitfun-core-types`、`bitfun-events`、`bitfun-runtime-ports`、`bitfun-agent-stream` 等基础契约。 +- 已抽取 `bitfun-core-types`、`bitfun-events`、`bitfun-runtime-ports`、`bitfun-agent-stream` 等基础契约;LSP protocol DTO 和 plugin manifest DTO 已进入 `bitfun-core-types`。 - 已建立 `bitfun-services-core`、`bitfun-services-integrations`、`bitfun-agent-tools`、`tool-runtime`、`bitfun-tool-packs`、`bitfun-agent-runtime`、`bitfun-runtime-services`、`bitfun-harness`、`bitfun-product-domains`、`bitfun-product-capabilities` 等 owner crate。 - `src/crates` 已按 `interfaces / assembly / adapters / services / execution / contracts` 六层布局整理,DeepReview path classifier、boundary rules、Cargo workspace path 和根/层级 AGENTS 已同步。 ## 2. 已迁移 owner -- `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、diagnostic redaction、session usage/token usage 基础服务。 +- `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、LSP plugin registry / extension matching / command-target mapping、diagnostic redaction、session usage/token usage 基础服务。 - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 - `bitfun-events` 已承接 backend event DTO、agentic event DTO 和 platform-neutral `EventEmitter` trait。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index b2d91ced30..819b2955c3 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -52,18 +52,21 @@ ### PR-D:Extension Host 与 OpenCode / ACP 适配收口 -状态:本阶段收口 ACP external-agent tool bridge;Extension/OpenCode/plugin、UI extension、effect / permission mapping 和多形态 SDK 验证转入后续阶段。 +状态:本阶段收口已有消费路径中的 ACP external-agent tool bridge 与 LSP plugin contract / registry owner;OpenCode / UI extension / capability-effect 泛化 API 不提前稳定,等出现真实消费路径后单独接入。 完成口径: - ACP external-agent tool name、schema、validation、presentation 和 ToolResult shape 由 `bitfun-agent-tools` 承接。 - `bitfun-acp` 继续持有 ACP protocol、client lifecycle、remote probing、permission bridge 和配置加载;现有 `AcpClientInfo` API shape 不变。 -- OpenCode/plugin concrete host、UI contribution、hook/workflow provider mapping 和 capability/effect policy 仍需在实际消费路径明确后单独接入,避免提前扩大稳定 API。 +- LSP protocol DTO 和 plugin manifest DTO 由 `bitfun-core-types` 承接,`bitfun-core::service::lsp::types` 只保留兼容 re-export。 +- LSP plugin registry、extension/language lookup、surface-facing supported-extension summary 和 manifest command placeholder 解析由 `bitfun-services-core` 承接;core 保留 plugin package IO、server path 检查和 process lifecycle。 +- OpenCode concrete host、UI contribution、hook/workflow provider mapping 和 capability/effect policy 仍需在实际消费路径明确后单独接入,避免提前扩大稳定 API。 保护: - 插件、OpenCode、ACP、external skills 不能直接写 kernel 权威状态、permission decision、audit event 或 UI implementation。 - 未声明能力默认受限;UI contribution descriptor 可 round-trip,并在不支持形态返回 unsupported/unavailable。 +- LSP manifest 序列化默认值、registry 查找/卸载/错误文案、supported-extension summary 和跨平台 command placeholder 解析必须有 focused contract tests。 ### PR-E:Cross-platform Adapter 与多形态 SDK 验证 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index c0afd263e0..653967e6f0 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -31,6 +31,46 @@ export const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/assembly/core/src/service/lsp/types.rs', + patterns: [ + { + regex: /\bpub struct LspPlugin\b/, + message: + 'LSP plugin manifest DTO belongs in bitfun-core-types; keep core LSP types as a compatibility facade', + }, + { + regex: /\bpub enum JsonRpcMessage\b/, + message: + 'LSP JSON-RPC DTOs belong in bitfun-core-types; keep core LSP types as a compatibility facade', + }, + { + regex: /\buse serde::\{Deserialize,\s*Serialize\}/, + message: + 'core LSP types should not own serialization DTOs after migration to bitfun-core-types', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/lsp/registry.rs', + patterns: [ + { + regex: /\bpub struct PluginRegistry\b/, + message: + 'LSP plugin registry belongs in bitfun-services-core; keep core registry as a compatibility facade', + }, + { + regex: /\bHashMap\b/, + message: + 'core LSP registry must not own plugin index maps after migration to bitfun-services-core', + }, + { + regex: /\bPathBuf::from\(file_path\)/, + message: + 'LSP file-extension lookup belongs in bitfun-services-core registry rules', + }, + ], + }, { path: 'src/crates/assembly/core/src/product_assembly.rs', patterns: [ diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index f358eb27c7..edd1794630 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1,6 +1,125 @@ // Boundary rules for source ownership, facades, and required owner content. export const requiredContentRules = [ + { + path: 'src/crates/contracts/core-types/src/lsp.rs', + reason: + 'core-types must own shared LSP protocol DTOs and plugin manifest wire contracts', + patterns: [ + { + regex: /\bpub struct LspPlugin\b/, + message: 'missing LSP plugin manifest contract owner', + }, + { + regex: /\bpub struct ServerConfig\b/, + message: 'missing LSP plugin server config contract', + }, + { + regex: /\bpub enum JsonRpcMessage\b/, + message: 'missing LSP JSON-RPC wire DTO contract', + }, + { + regex: /\bpub enum PluginSource\b/, + message: 'missing LSP plugin source contract', + }, + ], + }, + { + path: 'src/crates/services/services-core/src/lsp.rs', + reason: + 'services-core must own pure LSP plugin registry and command-target mapping rules', + patterns: [ + { + regex: /\bpub struct PluginRegistry\b/, + message: 'missing services-owned LSP plugin registry', + }, + { + regex: /\bpub struct LspSupportedExtensions\b/, + message: 'missing supported extension summary owner', + }, + { + regex: /\bpub struct LspPluginRuntimeTarget\b/, + message: 'missing LSP plugin runtime target contract', + }, + { + regex: /\bpub fn resolve_plugin_command_for_target\b/, + message: 'missing pure LSP plugin command placeholder resolver', + }, + { + regex: /\bpub fn resolve_plugin_command_for_current_target\b/, + message: 'missing current-target LSP plugin command resolver', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/lsp/types.rs', + reason: + 'core LSP types path must remain a compatibility facade over core-types', + patterns: [ + { + regex: /\bpub use bitfun_core_types::lsp::\*/, + message: 'core LSP types must re-export bitfun-core-types contracts', + }, + ], + }, + { + path: 'src/crates/assembly/core/src/service/lsp/registry.rs', + reason: + 'core LSP registry path must remain a compatibility facade over services-core', + patterns: [ + { + regex: /\bpub use bitfun_services_core::lsp::\{/, + message: 'core LSP registry must re-export services-core registry', + }, + ], + }, + { + path: 'src/crates/contracts/core-types/tests/lsp_contracts.rs', + reason: + 'core-types must keep LSP manifest serialization and default-value regressions', + patterns: [ + { + regex: /\blsp_plugin_manifest_defaults_preserve_legacy_shape\b/, + message: 'missing LSP manifest default regression', + }, + { + regex: /\blsp_capability_config_missing_fields_default_to_false\b/, + message: 'missing LSP capability default regression', + }, + ], + }, + { + path: 'src/crates/services/services-core/tests/lsp_plugin_registry_contracts.rs', + reason: + 'services-core must keep behavior-equivalence contracts for LSP plugin registry and command mapping', + patterns: [ + { + regex: /\bregistry_preserves_language_extension_and_file_path_lookup\b/, + message: 'missing LSP registry lookup regression', + }, + { + regex: /\bregistry_unregister_removes_plugin_indexes\b/, + message: 'missing LSP registry unregister regression', + }, + { + regex: /\bregistry_unregister_preserves_indexes_owned_by_newer_plugin\b/, + message: + 'missing LSP registry overlapping-plugin unregister regression', + }, + { + regex: /\bregistry_duplicate_and_missing_errors_keep_legacy_messages\b/, + message: 'missing LSP registry error-message regression', + }, + { + regex: /\bregistry_supported_extensions_matches_desktop_api_shape\b/, + message: 'missing LSP supported extension summary regression', + }, + { + regex: /\bplugin_command_placeholder_resolution_is_target_driven\b/, + message: 'missing LSP plugin command placeholder regression', + }, + ], + }, { path: 'src/crates/execution/runtime-services/src/lib.rs', reason: diff --git a/src/apps/desktop/src/api/lsp_api.rs b/src/apps/desktop/src/api/lsp_api.rs index d96ad3341a..5b617f95ae 100644 --- a/src/apps/desktop/src/api/lsp_api.rs +++ b/src/apps/desktop/src/api/lsp_api.rs @@ -393,32 +393,14 @@ pub struct SupportedExtensionsResponse { #[tauri::command] pub async fn lsp_get_supported_extensions() -> Result { - use std::collections::HashMap; - let manager = get_global_lsp_manager().map_err(|e| format!("LSP not initialized: {}", e))?; let guard = manager.read().await; - let plugins = guard.list_plugins().await; - - let mut extension_to_language: HashMap = HashMap::new(); - let mut supported_languages: std::collections::HashSet = - std::collections::HashSet::new(); - - for plugin in plugins { - for lang in &plugin.languages { - supported_languages.insert(lang.clone()); - } - - for ext in &plugin.file_extensions { - if !plugin.languages.is_empty() { - extension_to_language.insert(ext.clone(), plugin.languages[0].clone()); - } - } - } + let summary = guard.supported_extensions().await; Ok(SupportedExtensionsResponse { - extension_to_language, - supported_languages: supported_languages.into_iter().collect(), + extension_to_language: summary.extension_to_language, + supported_languages: summary.supported_languages, }) } diff --git a/src/crates/assembly/core/src/service/lsp/manager.rs b/src/crates/assembly/core/src/service/lsp/manager.rs index 9092579b3a..a70dab6276 100644 --- a/src/crates/assembly/core/src/service/lsp/manager.rs +++ b/src/crates/assembly/core/src/service/lsp/manager.rs @@ -11,7 +11,7 @@ use super::plugin_loader::PluginLoader; use super::process::{ CrashCallback, DiagnosticsCallback, LspServerProcess, ProgressCallback, TokenCreateCallback, }; -use super::registry::PluginRegistry; +use super::registry::{LspSupportedExtensions, PluginRegistry}; use super::types::{CompletionItem, LspPlugin}; /// LSP protocol-layer manager (stateless, pure protocol implementation). @@ -250,6 +250,12 @@ impl LspManager { registry.find_by_file_path(file_path).cloned() } + /// Returns surface-facing supported extension facts. + pub async fn supported_extensions(&self) -> LspSupportedExtensions { + let registry = self.registry.read().await; + registry.supported_extensions() + } + /// Shuts down all servers. pub async fn shutdown(&self) -> Result<()> { info!("Shutting down all LSP servers"); diff --git a/src/crates/assembly/core/src/service/lsp/plugin_loader.rs b/src/crates/assembly/core/src/service/lsp/plugin_loader.rs index 6d704ce9c0..2fa747b740 100644 --- a/src/crates/assembly/core/src/service/lsp/plugin_loader.rs +++ b/src/crates/assembly/core/src/service/lsp/plugin_loader.rs @@ -197,7 +197,9 @@ impl PluginLoader { pub fn get_server_path(&self, plugin: &LspPlugin) -> Result { let plugin_dir = self.plugins_dir.join(&plugin.id); - let command = self.resolve_command(&plugin.server.command)?; + let command = bitfun_services_core::lsp::resolve_plugin_command_for_current_target( + &plugin.server.command, + )?; let command = command.replace('/', std::path::MAIN_SEPARATOR_STR); @@ -255,36 +257,6 @@ impl PluginLoader { Ok(server_path) } - /// Resolves placeholders in the command. - fn resolve_command(&self, command: &str) -> Result { - let mut resolved = command.to_string(); - - let platform = if cfg!(target_os = "windows") { - "win" - } else if cfg!(target_os = "macos") { - "darwin" - } else if cfg!(target_os = "linux") { - "linux" - } else { - return Err(anyhow!("Unsupported platform")); - }; - - resolved = resolved.replace("${platform}", platform); - resolved = resolved.replace("${os}", platform); - - let arch = if cfg!(target_arch = "x86_64") { - "x64" - } else if cfg!(target_arch = "aarch64") { - "arm64" - } else { - return Err(anyhow!("Unsupported architecture")); - }; - - resolved = resolved.replace("${arch}", arch); - - Ok(resolved) - } - /// Returns the plugin directory path. pub fn get_plugin_dir(&self, plugin_id: &str) -> PathBuf { self.plugins_dir.join(plugin_id) diff --git a/src/crates/assembly/core/src/service/lsp/registry.rs b/src/crates/assembly/core/src/service/lsp/registry.rs index 7e3a0bcfb7..bef84c3fb6 100644 --- a/src/crates/assembly/core/src/service/lsp/registry.rs +++ b/src/crates/assembly/core/src/service/lsp/registry.rs @@ -1,163 +1,8 @@ -//! LSP plugin registry +//! Compatibility re-exports for LSP plugin registry rules. //! -//! Manages information about installed plugins. +//! The pure registry owner is `bitfun-services-core`; this legacy path remains +//! for downstream callers that import through `bitfun_core::service::lsp`. -use anyhow::{anyhow, Result}; -use log::{info, warn}; -use std::collections::HashMap; -use std::path::PathBuf; - -use super::types::LspPlugin; - -/// Plugin registry. -pub struct PluginRegistry { - /// Registered plugins (`plugin_id -> plugin`). - plugins: HashMap, - /// Language-to-plugin mapping (`language -> plugin_id`). - language_map: HashMap, - /// File-extension-to-plugin mapping (`extension -> plugin_id`). - extension_map: HashMap, -} - -impl PluginRegistry { - /// Creates a new plugin registry. - pub fn new() -> Self { - Self { - plugins: HashMap::new(), - language_map: HashMap::new(), - extension_map: HashMap::new(), - } - } - - /// Registers a plugin. - pub fn register(&mut self, plugin: LspPlugin) -> Result<()> { - let plugin_id = plugin.id.clone(); - - if self.plugins.contains_key(&plugin_id) { - return Err(anyhow!("Plugin already registered: {}", plugin_id)); - } - - for language in &plugin.languages { - if let Some(existing) = self.language_map.get(language) { - warn!( - "Language '{}' already mapped to plugin '{}', overwriting with '{}'", - language, existing, plugin_id - ); - } - self.language_map - .insert(language.clone(), plugin_id.clone()); - } - - for ext in &plugin.file_extensions { - if let Some(existing) = self.extension_map.get(ext) { - warn!( - "Extension '{}' already mapped to plugin '{}', overwriting with '{}'", - ext, existing, plugin_id - ); - } - self.extension_map.insert(ext.clone(), plugin_id.clone()); - } - - self.plugins.insert(plugin_id.clone(), plugin); - - info!( - "Plugin registered: {} with {} language(s) and {} extension(s)", - plugin_id, - self.language_map - .values() - .filter(|v| *v == &plugin_id) - .count(), - self.extension_map - .values() - .filter(|v| *v == &plugin_id) - .count() - ); - - Ok(()) - } - - /// Unregisters a plugin. - pub fn unregister(&mut self, plugin_id: &str) -> Result<()> { - let plugin = self - .plugins - .remove(plugin_id) - .ok_or_else(|| anyhow!("Plugin not found: {}", plugin_id))?; - - for language in &plugin.languages { - self.language_map.remove(language); - } - - for ext in &plugin.file_extensions { - self.extension_map.remove(ext); - } - - info!("Plugin unregistered: {}", plugin_id); - - Ok(()) - } - - /// Gets a plugin by plugin ID. - pub fn get_plugin(&self, plugin_id: &str) -> Option<&LspPlugin> { - self.plugins.get(plugin_id) - } - - /// Finds a plugin by language ID. - pub fn find_by_language(&self, language: &str) -> Option<&LspPlugin> { - self.language_map - .get(language) - .and_then(|id| self.plugins.get(id)) - } - - /// Finds a plugin by file extension. - pub fn find_by_extension(&self, extension: &str) -> Option<&LspPlugin> { - let ext = if extension.starts_with('.') { - extension.to_string() - } else { - format!(".{}", extension) - }; - - self.extension_map - .get(&ext) - .and_then(|id| self.plugins.get(id)) - } - - /// Finds a plugin by file path. - pub fn find_by_file_path(&self, file_path: &str) -> Option<&LspPlugin> { - let path = PathBuf::from(file_path); - if let Some(extension) = path.extension() { - if let Some(ext_str) = extension.to_str() { - return self.find_by_extension(ext_str); - } - } - None - } - - /// Lists all registered plugins. - pub fn list_all(&self) -> Vec<&LspPlugin> { - self.plugins.values().collect() - } - - /// Lists all plugins that support a specific language. - pub fn list_by_language(&self, language: &str) -> Vec<&LspPlugin> { - self.plugins - .values() - .filter(|p| p.languages.contains(&language.to_string())) - .collect() - } - - /// Returns whether a plugin is registered. - pub fn is_registered(&self, plugin_id: &str) -> bool { - self.plugins.contains_key(plugin_id) - } - - /// Returns the number of registered plugins. - pub fn count(&self) -> usize { - self.plugins.len() - } -} - -impl Default for PluginRegistry { - fn default() -> Self { - Self::new() - } -} +pub use bitfun_services_core::lsp::{ + LspPluginRegistryError, LspSupportedExtensions, PluginRegistry, +}; diff --git a/src/crates/assembly/core/src/service/lsp/types.rs b/src/crates/assembly/core/src/service/lsp/types.rs index ec322c8f69..9338349b0e 100644 --- a/src/crates/assembly/core/src/service/lsp/types.rs +++ b/src/crates/assembly/core/src/service/lsp/types.rs @@ -1,364 +1,6 @@ -//! LSP type definitions +//! Compatibility re-exports for LSP protocol and plugin manifest DTOs. //! -//! Data structures related to the LSP protocol. +//! The shared contract owner is `bitfun-core-types`; this legacy path remains +//! for downstream callers that import through `bitfun_core::service::lsp`. -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; - -/// LSP plugin manifest. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LspPlugin { - /// Plugin unique identifier. - pub id: String, - /// Plugin display name. - pub name: String, - /// Version. - pub version: String, - /// Author. - pub author: String, - /// Description. - pub description: String, - /// Server configuration. - pub server: ServerConfig, - /// Supported languages. - pub languages: Vec, - /// File extensions. - pub file_extensions: Vec, - /// Capability configuration. - pub capabilities: CapabilitiesConfig, - /// Default settings. - #[serde(default)] - pub settings: HashMap, - /// Checksum. - #[serde(default)] - pub checksum: String, - /// Minimum BitFun version. - #[serde(default)] - pub min_bitfun_version: String, -} - -/// Server configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - /// Command path (relative to the plugin directory). - pub command: String, - /// Command arguments. - #[serde(default)] - pub args: Vec, - /// Environment variables. - #[serde(default)] - pub env: HashMap, - /// Runtime type (optional: "exe", "bash", "node"). - /// Defaults to "exe"; Windows is handled automatically. - #[serde(default)] - pub runtime: Option, -} - -/// Runtime type enum. -#[derive(Debug, Clone, PartialEq)] -pub enum RuntimeType { - /// Native executable (e.g. `.exe`). - Executable, - /// Bash script. - Bash, - /// Node.js program. - Node, -} - -/// Capability configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CapabilitiesConfig { - #[serde(default)] - pub completion: bool, - #[serde(default)] - pub hover: bool, - #[serde(default)] - pub definition: bool, - #[serde(default)] - pub references: bool, - #[serde(default)] - pub rename: bool, - #[serde(default)] - pub formatting: bool, - #[serde(default)] - pub diagnostics: bool, - #[serde(default)] - pub inlay_hints: bool, -} - -/// JSON-RPC message. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum JsonRpcMessage { - Request(JsonRpcRequest), - Response(JsonRpcResponse), - Notification(JsonRpcNotification), -} - -/// JSON-RPC request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcRequest { - pub jsonrpc: String, - pub id: u64, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -/// JSON-RPC response. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - pub id: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// JSON-RPC error. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -/// JSON-RPC notification. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcNotification { - pub jsonrpc: String, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -/// Initialize parameters. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeParams { - /// Process ID. - pub process_id: Option, - /// Workspace root path (deprecated). - #[serde(skip_serializing_if = "Option::is_none")] - pub root_path: Option, - /// Workspace URI. - pub root_uri: Option, - /// Client capabilities. - pub capabilities: ClientCapabilities, - /// Initialization options. - #[serde(skip_serializing_if = "Option::is_none")] - pub initialization_options: Option, - /// Workspace folders. - #[serde(skip_serializing_if = "Option::is_none")] - pub workspace_folders: Option>, -} - -/// Client capabilities. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub window: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub workspace: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text_document: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub experimental: Option, -} - -/// Workspace folder. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceFolder { - pub uri: String, - pub name: String, -} - -/// Initialize result. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeResult { - pub capabilities: ServerCapabilities, - #[serde(skip_serializing_if = "Option::is_none")] - pub server_info: Option, -} - -/// Server capabilities. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct ServerCapabilities { - #[serde(skip_serializing_if = "Option::is_none")] - pub completion_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub hover_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub definition_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub references_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub rename_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub document_formatting_provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text_document_sync: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub inlay_hint_provider: Option, -} - -/// Server information. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerInfo { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -/// Completion item. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompletionItem { - /// Label (display text). - pub label: String, - /// Kind. - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - /// Detail. - #[serde(skip_serializing_if = "Option::is_none")] - pub detail: Option, - /// Documentation. - #[serde(skip_serializing_if = "Option::is_none")] - pub documentation: Option, - /// Sort text. - #[serde(skip_serializing_if = "Option::is_none")] - pub sort_text: Option, - /// Filter text. - #[serde(skip_serializing_if = "Option::is_none")] - pub filter_text: Option, - /// Insert text. - #[serde(skip_serializing_if = "Option::is_none")] - pub insert_text: Option, - /// Insert text format. - #[serde(skip_serializing_if = "Option::is_none")] - pub insert_text_format: Option, -} - -/// Completion list (LSP response format). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompletionList { - /// Whether the list is incomplete (more completion items exist). - #[serde(skip_serializing_if = "Option::is_none")] - pub is_incomplete: Option, - /// Completion items. - pub items: Vec, -} - -/// Position. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Position { - pub line: u32, - pub character: u32, -} - -/// Range. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Range { - pub start: Position, - pub end: Position, -} - -/// Text document identifier. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TextDocumentIdentifier { - pub uri: String, -} - -/// Text document position parameters. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TextDocumentPositionParams { - pub text_document: TextDocumentIdentifier, - pub position: Position, -} - -/// Diagnostic. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Diagnostic { - pub range: Range, - #[serde(skip_serializing_if = "Option::is_none")] - pub severity: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - pub message: String, -} - -/// Plugin installation source. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PluginSource { - Marketplace, - File(PathBuf), - Url(String), -} - -/// Inlay hint. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InlayHint { - /// Position. - pub position: Position, - /// Label (display text). - pub label: InlayHintLabel, - /// Kind (1 = Type, 2 = Parameter). - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - /// Tooltip. - #[serde(skip_serializing_if = "Option::is_none")] - pub tooltip: Option, - /// Whether to render padding before the position. - #[serde(skip_serializing_if = "Option::is_none")] - pub padding_left: Option, - /// Whether to render padding after the position. - #[serde(skip_serializing_if = "Option::is_none")] - pub padding_right: Option, - /// Text edits. - #[serde(skip_serializing_if = "Option::is_none")] - pub text_edits: Option>, -} - -/// Inlay hint label. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum InlayHintLabel { - String(String), - Parts(Vec), -} - -/// Inlay hint label part. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InlayHintLabelPart { - pub value: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tooltip: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub location: Option, -} - -/// Location. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Location { - pub uri: String, - pub range: Range, -} - -/// Text edit. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TextEdit { - pub range: Range, - #[serde(rename = "newText")] - pub new_text: String, -} +pub use bitfun_core_types::lsp::*; diff --git a/src/crates/contracts/AGENTS-CN.md b/src/crates/contracts/AGENTS-CN.md index 1ed5ce489b..4f82b96136 100644 --- a/src/crates/contracts/AGENTS-CN.md +++ b/src/crates/contracts/AGENTS-CN.md @@ -8,7 +8,7 @@ | Crate | 职责 | 本地文档 | |---|---|---| -| `core-types` | 共享 DTO、错误、session/surface 数据和小型 value type | [AGENTS.md](core-types/AGENTS.md) | +| `core-types` | 共享 DTO、错误、session/surface 数据、LSP protocol/plugin DTO 和小型 value type | [AGENTS.md](core-types/AGENTS.md) | | `events` | 事件 payload 和 emitter 契约 | [AGENTS.md](events/AGENTS.md) | | `product-domains` | 产品领域 DTO、规则、策略和窄 port | [AGENTS.md](product-domains/AGENTS.md) | | `runtime-ports` | runtime owner crate 使用的 trait 和 port | [AGENTS.md](runtime-ports/AGENTS.md) | diff --git a/src/crates/contracts/AGENTS.md b/src/crates/contracts/AGENTS.md index 73e1d26778..93ff20c3ab 100644 --- a/src/crates/contracts/AGENTS.md +++ b/src/crates/contracts/AGENTS.md @@ -10,7 +10,7 @@ implementation details upward. | Crate | Responsibility | Local doc | |---|---|---| -| `core-types` | Shared DTOs, errors, session/surface data, and small value types | [AGENTS.md](core-types/AGENTS.md) | +| `core-types` | Shared DTOs, errors, session/surface data, LSP protocol/plugin DTOs, and small value types | [AGENTS.md](core-types/AGENTS.md) | | `events` | Event payloads and emitter contracts | [AGENTS.md](events/AGENTS.md) | | `product-domains` | Product domain DTOs, rules, policies, and narrow ports | [AGENTS.md](product-domains/AGENTS.md) | | `runtime-ports` | Runtime-facing traits and ports used by owner crates | [AGENTS.md](runtime-ports/AGENTS.md) | diff --git a/src/crates/contracts/core-types/AGENTS.md b/src/crates/contracts/core-types/AGENTS.md index 91f8345526..2898d18d9c 100644 --- a/src/crates/contracts/core-types/AGENTS.md +++ b/src/crates/contracts/core-types/AGENTS.md @@ -2,8 +2,8 @@ Scope: this guide applies to `src/crates/contracts/core-types`. -`bitfun-core-types` owns low-level shared DTOs and error/session/surface -contracts. Keep it dependency-light and stable for cross-crate reuse. +`bitfun-core-types` owns low-level shared DTOs and error/session/surface/LSP +protocol contracts. Keep it dependency-light and stable for cross-crate reuse. ## Guardrails @@ -12,6 +12,8 @@ contracts. Keep it dependency-light and stable for cross-crate reuse. tool-runtime implementations. - Keep additions limited to portable data shapes, serialization contracts, and small pure helpers. +- LSP protocol and plugin manifest DTOs may live here; plugin loading, + registry rules, process lifecycle, and host integration belong above. - Preserve persisted and cross-process wire compatibility. Any field rename, enum variant change, or default change must be treated as a contract change. - Product policy, runtime behavior, IO, process execution, and platform diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 580d0e1261..1f21a9966d 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -5,6 +5,7 @@ pub mod ai; pub mod errors; +pub mod lsp; pub mod session; pub mod surface; pub mod tool_image_attachment; diff --git a/src/crates/contracts/core-types/src/lsp.rs b/src/crates/contracts/core-types/src/lsp.rs new file mode 100644 index 0000000000..886fe71f17 --- /dev/null +++ b/src/crates/contracts/core-types/src/lsp.rs @@ -0,0 +1,365 @@ +//! Shared LSP protocol and plugin manifest DTOs. +//! +//! These are wire/data contracts only. Plugin loading, filesystem access, and +//! process lifecycle stay in service/runtime owner crates. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// LSP plugin manifest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LspPlugin { + /// Plugin unique identifier. + pub id: String, + /// Plugin display name. + pub name: String, + /// Version. + pub version: String, + /// Author. + pub author: String, + /// Description. + pub description: String, + /// Server configuration. + pub server: ServerConfig, + /// Supported languages. + pub languages: Vec, + /// File extensions. + pub file_extensions: Vec, + /// Capability configuration. + pub capabilities: CapabilitiesConfig, + /// Default settings. + #[serde(default)] + pub settings: HashMap, + /// Checksum. + #[serde(default)] + pub checksum: String, + /// Minimum BitFun version. + #[serde(default)] + pub min_bitfun_version: String, +} + +/// Server configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + /// Command path (relative to the plugin directory). + pub command: String, + /// Command arguments. + #[serde(default)] + pub args: Vec, + /// Environment variables. + #[serde(default)] + pub env: HashMap, + /// Runtime type (optional: "exe", "bash", "node"). + /// Defaults to "exe"; Windows is handled automatically. + #[serde(default)] + pub runtime: Option, +} + +/// Runtime type enum. +#[derive(Debug, Clone, PartialEq)] +pub enum RuntimeType { + /// Native executable (e.g. `.exe`). + Executable, + /// Bash script. + Bash, + /// Node.js program. + Node, +} + +/// Capability configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapabilitiesConfig { + #[serde(default)] + pub completion: bool, + #[serde(default)] + pub hover: bool, + #[serde(default)] + pub definition: bool, + #[serde(default)] + pub references: bool, + #[serde(default)] + pub rename: bool, + #[serde(default)] + pub formatting: bool, + #[serde(default)] + pub diagnostics: bool, + #[serde(default)] + pub inlay_hints: bool, +} + +/// JSON-RPC message. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcMessage { + Request(JsonRpcRequest), + Response(JsonRpcResponse), + Notification(JsonRpcNotification), +} + +/// JSON-RPC request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: u64, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC error. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// JSON-RPC notification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// Initialize parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + /// Process ID. + pub process_id: Option, + /// Workspace root path (deprecated). + #[serde(skip_serializing_if = "Option::is_none")] + pub root_path: Option, + /// Workspace URI. + pub root_uri: Option, + /// Client capabilities. + pub capabilities: ClientCapabilities, + /// Initialization options. + #[serde(skip_serializing_if = "Option::is_none")] + pub initialization_options: Option, + /// Workspace folders. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_folders: Option>, +} + +/// Client capabilities. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_document: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, +} + +/// Workspace folder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceFolder { + pub uri: String, + pub name: String, +} + +/// Initialize result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub capabilities: ServerCapabilities, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, +} + +/// Server capabilities. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ServerCapabilities { + #[serde(skip_serializing_if = "Option::is_none")] + pub completion_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hover_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub definition_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub references_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rename_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_formatting_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_document_sync: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inlay_hint_provider: Option, +} + +/// Server information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// Completion item. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionItem { + /// Label (display text). + pub label: String, + /// Kind. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Detail. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Documentation. + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, + /// Sort text. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort_text: Option, + /// Filter text. + #[serde(skip_serializing_if = "Option::is_none")] + pub filter_text: Option, + /// Insert text. + #[serde(skip_serializing_if = "Option::is_none")] + pub insert_text: Option, + /// Insert text format. + #[serde(skip_serializing_if = "Option::is_none")] + pub insert_text_format: Option, +} + +/// Completion list (LSP response format). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionList { + /// Whether the list is incomplete (more completion items exist). + #[serde(skip_serializing_if = "Option::is_none")] + pub is_incomplete: Option, + /// Completion items. + pub items: Vec, +} + +/// Position. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Position { + pub line: u32, + pub character: u32, +} + +/// Range. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Range { + pub start: Position, + pub end: Position, +} + +/// Text document identifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextDocumentIdentifier { + pub uri: String, +} + +/// Text document position parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextDocumentPositionParams { + pub text_document: TextDocumentIdentifier, + pub position: Position, +} + +/// Diagnostic. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Diagnostic { + pub range: Range, + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + pub message: String, +} + +/// Plugin installation source. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginSource { + Marketplace, + File(PathBuf), + Url(String), +} + +/// Inlay hint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlayHint { + /// Position. + pub position: Position, + /// Label (display text). + pub label: InlayHintLabel, + /// Kind (1 = Type, 2 = Parameter). + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Tooltip. + #[serde(skip_serializing_if = "Option::is_none")] + pub tooltip: Option, + /// Whether to render padding before the position. + #[serde(skip_serializing_if = "Option::is_none")] + pub padding_left: Option, + /// Whether to render padding after the position. + #[serde(skip_serializing_if = "Option::is_none")] + pub padding_right: Option, + /// Text edits. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_edits: Option>, +} + +/// Inlay hint label. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum InlayHintLabel { + String(String), + Parts(Vec), +} + +/// Inlay hint label part. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlayHintLabelPart { + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tooltip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Location. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Location { + pub uri: String, + pub range: Range, +} + +/// Text edit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextEdit { + pub range: Range, + #[serde(rename = "newText")] + pub new_text: String, +} diff --git a/src/crates/contracts/core-types/tests/lsp_contracts.rs b/src/crates/contracts/core-types/tests/lsp_contracts.rs new file mode 100644 index 0000000000..8abb126a74 --- /dev/null +++ b/src/crates/contracts/core-types/tests/lsp_contracts.rs @@ -0,0 +1,47 @@ +use bitfun_core_types::lsp::{CapabilitiesConfig, LspPlugin}; + +#[test] +fn lsp_plugin_manifest_defaults_preserve_legacy_shape() { + let plugin: LspPlugin = serde_json::from_value(serde_json::json!({ + "id": "rust-analyzer", + "name": "Rust Analyzer", + "version": "1.0.0", + "author": "BitFun", + "description": "Rust language support", + "server": { + "command": "bin/${platform}/${arch}/rust-analyzer", + "args": ["--stdio"] + }, + "languages": ["rust"], + "file_extensions": [".rs"], + "capabilities": { + "completion": true, + "definition": true + } + })) + .expect("legacy manifest should parse"); + + assert_eq!(plugin.server.env.len(), 0); + assert_eq!(plugin.server.runtime, None); + assert_eq!(plugin.settings.len(), 0); + assert_eq!(plugin.checksum, ""); + assert_eq!(plugin.min_bitfun_version, ""); + assert!(plugin.capabilities.completion); + assert!(plugin.capabilities.definition); + assert!(!plugin.capabilities.hover); +} + +#[test] +fn lsp_capability_config_missing_fields_default_to_false() { + let config: CapabilitiesConfig = + serde_json::from_value(serde_json::json!({})).expect("empty capabilities should parse"); + + assert!(!config.completion); + assert!(!config.hover); + assert!(!config.definition); + assert!(!config.references); + assert!(!config.rename); + assert!(!config.formatting); + assert!(!config.diagnostics); + assert!(!config.inlay_hints); +} diff --git a/src/crates/services/AGENTS-CN.md b/src/crates/services/AGENTS-CN.md index 8b70327e46..50e5d282a6 100644 --- a/src/crates/services/AGENTS-CN.md +++ b/src/crates/services/AGENTS-CN.md @@ -2,19 +2,19 @@ # 服务实现层 -本层负责接触本地系统或 runtime infrastructure 的可复用具体实现:filesystem、git、file watch、terminal、MCP、remote connectivity、process lifecycle、session persistence primitives、MiniApp runtime/import IO 以及类似 OS/network 能力。 +本层负责接触本地系统或 runtime infrastructure 的可复用具体实现:filesystem、git、file watch、terminal、MCP、LSP plugin registry、remote connectivity、process lifecycle、session persistence primitives、MiniApp runtime/import IO 以及类似 OS/network 能力。 ## 模块 | Crate | 职责 | 本地文档 | |---|---|---| -| `services-core` | 不包含产品组装决策的本地 service primitive,包括 session storage、metadata store CRUD/index rebuild、metadata 构造/计数/索引/字段 mutation、lineage 规则和 JSON file IO | [AGENTS.md](services-core/AGENTS.md) | +| `services-core` | 不包含产品组装决策的本地 service primitive,包括 LSP plugin registry、session storage、metadata store CRUD/index rebuild、metadata 构造/计数/索引/字段 mutation、lineage 规则和 JSON file IO | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | MCP、git、remote、file watch、MiniApp runtime、产品领域 port 具体实现,以及平台无关的 Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | | `terminal` | PTY、shell integration 与 terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | ## 放置规则 -- 具体 OS、process、filesystem、git、terminal、MCP、remote SSH、file watch、session persistence primitives、MiniApp runtime IO 和 network service 实现放在这里。 +- 具体 OS、process、filesystem、git、terminal、MCP、LSP registry、remote SSH、file watch、session persistence primitives、MiniApp runtime IO 和 network service 实现放在这里。 - 需要具体依赖的 `contracts`、`execution` 或 `contracts/product-domains` port 实现在这里。 - 协议/transport projection 放在 `adapters`,产品能力选择放在 `assembly`。 diff --git a/src/crates/services/AGENTS.md b/src/crates/services/AGENTS.md index f3c57d46c1..5ae26277c5 100644 --- a/src/crates/services/AGENTS.md +++ b/src/crates/services/AGENTS.md @@ -3,21 +3,21 @@ # Service Layer This layer owns reusable concrete implementations that touch local systems or -runtime infrastructure: filesystem, git, file watch, terminal, MCP, remote -connectivity, process lifecycle, session persistence primitives, MiniApp concrete runtime IO, and similar +runtime infrastructure: filesystem, git, file watch, terminal, MCP, LSP plugin +registry, remote connectivity, process lifecycle, session persistence primitives, MiniApp concrete runtime IO, and similar OS/network capabilities. ## Modules | Crate | Responsibility | Local doc | |---|---|---| -| `services-core` | Reusable local service primitives, filesystem helpers, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | +| `services-core` | Reusable local service primitives, filesystem helpers, LSP plugin registry rules, session storage layout/indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation/lineage rules, and JSON file IO without product assembly decisions | [AGENTS.md](services-core/AGENTS.md) | | `services-integrations` | Concrete MCP, git, remote, file-watch, MiniApp runtime, product-domain port implementations, and platform-neutral Remote Connect primitives | [AGENTS.md](services-integrations/AGENTS.md) | | `terminal` | PTY, shell integration, and terminal session infrastructure | [AGENTS.md](terminal/AGENTS.md) | ## Placement Rules -- Put concrete OS, process, filesystem, git, terminal, MCP, remote SSH, +- Put concrete OS, process, filesystem, git, terminal, MCP, LSP registry, remote SSH, file-watch, MiniApp runtime IO, and network service implementations here. - Implement `contracts`, `execution`, or `contracts/product-domains` ports here when the implementation needs concrete dependencies. diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index f8960d19e2..72cc2f8eb5 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -4,8 +4,9 @@ Scope: this guide applies to `src/crates/services/services-core`. `bitfun-services-core` owns platform-neutral service DTOs and helpers that can compile without the full product runtime. It also owns generic local filesystem -operations/tree/search/listing primitives, session storage layout helpers, turn -file indexing/deletion, metadata store CRUD/index rebuild, metadata construction/counter/index/field +operations/tree/search/listing primitives, LSP plugin registry and command-target +mapping rules, session storage layout helpers, turn file indexing/deletion, +metadata store CRUD/index rebuild, metadata construction/counter/index/field mutation rules, lineage/branch metadata shaping, and reusable JSON file IO; product crates may layer remote workspace routing or legacy error mapping outside this crate. @@ -19,6 +20,9 @@ this crate. - Keep the default feature lightweight; feature groups such as search, LSP, cron, or snapshot should not become new crates until measured compile cost proves the split is needed. +- LSP manifest and protocol DTOs belong in `bitfun-core-types`; plugin package + filesystem IO and process lifecycle stay in the core compatibility adapter or + a reviewed concrete service owner. - Runtime call sites that touch agent execution, scheduler state, workspace managers, filesystem orchestration, or product behavior stay in core until a reviewed port/provider design and equivalence tests exist. diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 609210af47..274e45955a 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod diagnostics; pub mod diff; pub mod filesystem; pub mod json_store; +pub mod lsp; pub mod managed_runtime; pub mod process_manager; pub mod session; diff --git a/src/crates/services/services-core/src/lsp.rs b/src/crates/services/services-core/src/lsp.rs new file mode 100644 index 0000000000..0b4a47a204 --- /dev/null +++ b/src/crates/services/services-core/src/lsp.rs @@ -0,0 +1,304 @@ +//! Platform-neutral LSP plugin service rules. +//! +//! This module owns pure plugin registry and command-target mapping rules. It +//! does not load plugin packages, touch the filesystem, or spawn LSP processes. + +use bitfun_core_types::lsp::LspPlugin; +use log::{info, warn}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use thiserror::Error; + +pub type LspPluginRegistryResult = Result; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum LspPluginRegistryError { + #[error("Plugin already registered: {0}")] + AlreadyRegistered(String), + #[error("Plugin not found: {0}")] + NotFound(String), + #[error("Unsupported platform")] + UnsupportedPlatform, + #[error("Unsupported architecture")] + UnsupportedArchitecture, +} + +/// Current or simulated platform for plugin command placeholder expansion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LspPluginRuntimePlatform { + Windows, + Macos, + Linux, +} + +impl LspPluginRuntimePlatform { + pub const fn manifest_token(self) -> &'static str { + match self { + Self::Windows => "win", + Self::Macos => "darwin", + Self::Linux => "linux", + } + } +} + +/// Current or simulated CPU architecture for plugin command placeholder +/// expansion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LspPluginRuntimeArch { + X64, + Arm64, +} + +impl LspPluginRuntimeArch { + pub const fn manifest_token(self) -> &'static str { + match self { + Self::X64 => "x64", + Self::Arm64 => "arm64", + } + } +} + +/// Runtime target facts used by LSP plugin manifests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LspPluginRuntimeTarget { + pub platform: LspPluginRuntimePlatform, + pub arch: LspPluginRuntimeArch, +} + +impl LspPluginRuntimeTarget { + pub const fn new(platform: LspPluginRuntimePlatform, arch: LspPluginRuntimeArch) -> Self { + Self { platform, arch } + } +} + +/// Resolves `${platform}`, `${os}`, and `${arch}` placeholders in an LSP plugin +/// command without touching the filesystem. +pub fn resolve_plugin_command_for_target(command: &str, target: LspPluginRuntimeTarget) -> String { + command + .replace("${platform}", target.platform.manifest_token()) + .replace("${os}", target.platform.manifest_token()) + .replace("${arch}", target.arch.manifest_token()) +} + +/// Returns the current runtime target supported by LSP plugin manifests. +pub fn current_lsp_plugin_runtime_target() -> LspPluginRegistryResult { + let platform = if cfg!(target_os = "windows") { + LspPluginRuntimePlatform::Windows + } else if cfg!(target_os = "macos") { + LspPluginRuntimePlatform::Macos + } else if cfg!(target_os = "linux") { + LspPluginRuntimePlatform::Linux + } else { + return Err(LspPluginRegistryError::UnsupportedPlatform); + }; + + let arch = if cfg!(target_arch = "x86_64") { + LspPluginRuntimeArch::X64 + } else if cfg!(target_arch = "aarch64") { + LspPluginRuntimeArch::Arm64 + } else { + return Err(LspPluginRegistryError::UnsupportedArchitecture); + }; + + Ok(LspPluginRuntimeTarget::new(platform, arch)) +} + +/// Resolves plugin command placeholders for the current runtime target. +pub fn resolve_plugin_command_for_current_target(command: &str) -> LspPluginRegistryResult { + Ok(resolve_plugin_command_for_target( + command, + current_lsp_plugin_runtime_target()?, + )) +} + +/// Supported extension summary used by product surfaces. +pub struct LspSupportedExtensions { + pub extension_to_language: HashMap, + pub supported_languages: Vec, +} + +/// Plugin registry. +pub struct PluginRegistry { + /// Registered plugins (`plugin_id -> plugin`). + plugins: HashMap, + /// Language-to-plugin mapping (`language -> plugin_id`). + language_map: HashMap, + /// File-extension-to-plugin mapping (`extension -> plugin_id`). + extension_map: HashMap, +} + +impl PluginRegistry { + /// Creates a new plugin registry. + pub fn new() -> Self { + Self { + plugins: HashMap::new(), + language_map: HashMap::new(), + extension_map: HashMap::new(), + } + } + + /// Registers a plugin. + pub fn register(&mut self, plugin: LspPlugin) -> LspPluginRegistryResult<()> { + let plugin_id = plugin.id.clone(); + + if self.plugins.contains_key(&plugin_id) { + return Err(LspPluginRegistryError::AlreadyRegistered(plugin_id)); + } + + for language in &plugin.languages { + if let Some(existing) = self.language_map.get(language) { + warn!( + "Language '{}' already mapped to plugin '{}', overwriting with '{}'", + language, existing, plugin_id + ); + } + self.language_map + .insert(language.clone(), plugin_id.clone()); + } + + for ext in &plugin.file_extensions { + if let Some(existing) = self.extension_map.get(ext) { + warn!( + "Extension '{}' already mapped to plugin '{}', overwriting with '{}'", + ext, existing, plugin_id + ); + } + self.extension_map.insert(ext.clone(), plugin_id.clone()); + } + + self.plugins.insert(plugin_id.clone(), plugin); + + info!( + "Plugin registered: {} with {} language(s) and {} extension(s)", + plugin_id, + self.language_map + .values() + .filter(|v| *v == &plugin_id) + .count(), + self.extension_map + .values() + .filter(|v| *v == &plugin_id) + .count() + ); + + Ok(()) + } + + /// Unregisters a plugin. + pub fn unregister(&mut self, plugin_id: &str) -> LspPluginRegistryResult<()> { + let plugin = self + .plugins + .remove(plugin_id) + .ok_or_else(|| LspPluginRegistryError::NotFound(plugin_id.to_string()))?; + + for language in &plugin.languages { + remove_index_if_owned(&mut self.language_map, language, plugin_id); + } + + for ext in &plugin.file_extensions { + remove_index_if_owned(&mut self.extension_map, ext, plugin_id); + } + + info!("Plugin unregistered: {}", plugin_id); + + Ok(()) + } + + /// Gets a plugin by plugin ID. + pub fn get_plugin(&self, plugin_id: &str) -> Option<&LspPlugin> { + self.plugins.get(plugin_id) + } + + /// Finds a plugin by language ID. + pub fn find_by_language(&self, language: &str) -> Option<&LspPlugin> { + self.language_map + .get(language) + .and_then(|id| self.plugins.get(id)) + } + + /// Finds a plugin by file extension. + pub fn find_by_extension(&self, extension: &str) -> Option<&LspPlugin> { + let ext = if extension.starts_with('.') { + extension.to_string() + } else { + format!(".{}", extension) + }; + + self.extension_map + .get(&ext) + .and_then(|id| self.plugins.get(id)) + } + + /// Finds a plugin by file path. + pub fn find_by_file_path(&self, file_path: &str) -> Option<&LspPlugin> { + let path = PathBuf::from(file_path); + if let Some(extension) = path.extension() { + if let Some(ext_str) = extension.to_str() { + return self.find_by_extension(ext_str); + } + } + None + } + + /// Lists all registered plugins. + pub fn list_all(&self) -> Vec<&LspPlugin> { + self.plugins.values().collect() + } + + /// Lists all plugins that support a specific language. + pub fn list_by_language(&self, language: &str) -> Vec<&LspPlugin> { + self.plugins + .values() + .filter(|p| p.languages.iter().any(|candidate| candidate == language)) + .collect() + } + + /// Returns surface-facing supported extension facts. + pub fn supported_extensions(&self) -> LspSupportedExtensions { + let mut extension_to_language = HashMap::new(); + let mut supported_languages = HashSet::new(); + + for plugin in self.plugins.values() { + for language in &plugin.languages { + supported_languages.insert(language.clone()); + } + + for ext in &plugin.file_extensions { + if let Some(language) = plugin.languages.first() { + extension_to_language.insert(ext.clone(), language.clone()); + } + } + } + + LspSupportedExtensions { + extension_to_language, + supported_languages: supported_languages.into_iter().collect(), + } + } + + /// Returns whether a plugin is registered. + pub fn is_registered(&self, plugin_id: &str) -> bool { + self.plugins.contains_key(plugin_id) + } + + /// Returns the number of registered plugins. + pub fn count(&self) -> usize { + self.plugins.len() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} + +fn remove_index_if_owned(index: &mut HashMap, key: &str, plugin_id: &str) { + if index + .get(key) + .map(|current_plugin_id| current_plugin_id == plugin_id) + .unwrap_or(false) + { + index.remove(key); + } +} diff --git a/src/crates/services/services-core/tests/lsp_plugin_registry_contracts.rs b/src/crates/services/services-core/tests/lsp_plugin_registry_contracts.rs new file mode 100644 index 0000000000..42be554cc1 --- /dev/null +++ b/src/crates/services/services-core/tests/lsp_plugin_registry_contracts.rs @@ -0,0 +1,184 @@ +use bitfun_core_types::lsp::{CapabilitiesConfig, LspPlugin, ServerConfig}; +use bitfun_services_core::lsp::{ + resolve_plugin_command_for_target, LspPluginRegistryError, LspPluginRuntimeArch, + LspPluginRuntimePlatform, LspPluginRuntimeTarget, PluginRegistry, +}; +use std::collections::HashMap; + +fn plugin(id: &str, languages: &[&str], extensions: &[&str]) -> LspPlugin { + LspPlugin { + id: id.to_string(), + name: id.to_string(), + version: "1.0.0".to_string(), + author: "BitFun".to_string(), + description: "test plugin".to_string(), + server: ServerConfig { + command: "server".to_string(), + args: vec![], + env: HashMap::new(), + runtime: None, + }, + languages: languages.iter().map(|value| value.to_string()).collect(), + file_extensions: extensions.iter().map(|value| value.to_string()).collect(), + capabilities: CapabilitiesConfig { + completion: true, + hover: false, + definition: false, + references: false, + rename: false, + formatting: false, + diagnostics: false, + inlay_hints: false, + }, + settings: HashMap::new(), + checksum: String::new(), + min_bitfun_version: String::new(), + } +} + +#[test] +fn registry_preserves_language_extension_and_file_path_lookup() { + let mut registry = PluginRegistry::new(); + registry + .register(plugin("rust", &["rust"], &[".rs"])) + .expect("plugin should register"); + + assert_eq!(registry.count(), 1); + assert!(registry.is_registered("rust")); + assert_eq!(registry.find_by_language("rust").unwrap().id, "rust"); + assert_eq!(registry.find_by_extension("rs").unwrap().id, "rust"); + assert_eq!(registry.find_by_extension(".rs").unwrap().id, "rust"); + assert_eq!( + registry + .find_by_file_path("workspace/src/main.rs") + .unwrap() + .id, + "rust" + ); +} + +#[test] +fn registry_unregister_removes_plugin_indexes() { + let mut registry = PluginRegistry::new(); + registry + .register(plugin("python", &["python"], &[".py"])) + .expect("plugin should register"); + + registry + .unregister("python") + .expect("plugin should unregister"); + + assert_eq!(registry.count(), 0); + assert!(!registry.is_registered("python")); + assert!(registry.find_by_language("python").is_none()); + assert!(registry.find_by_extension("py").is_none()); +} + +#[test] +fn registry_unregister_preserves_indexes_owned_by_newer_plugin() { + let mut registry = PluginRegistry::new(); + registry + .register(plugin("legacy-rust", &["rust"], &[".rs"])) + .expect("legacy plugin should register"); + registry + .register(plugin("current-rust", &["rust"], &[".rs"])) + .expect("current plugin should register"); + + assert_eq!( + registry.find_by_language("rust").unwrap().id, + "current-rust" + ); + assert_eq!(registry.find_by_extension("rs").unwrap().id, "current-rust"); + + registry + .unregister("legacy-rust") + .expect("legacy plugin should unregister without clearing current indexes"); + + assert_eq!(registry.count(), 1); + assert_eq!( + registry.find_by_language("rust").unwrap().id, + "current-rust" + ); + assert_eq!(registry.find_by_extension("rs").unwrap().id, "current-rust"); +} + +#[test] +fn registry_duplicate_and_missing_errors_keep_legacy_messages() { + let mut registry = PluginRegistry::new(); + registry + .register(plugin("rust", &["rust"], &[".rs"])) + .expect("plugin should register"); + + assert_eq!( + registry.register(plugin("rust", &["rust"], &[".rs"])), + Err(LspPluginRegistryError::AlreadyRegistered( + "rust".to_string() + )) + ); + assert_eq!( + registry + .register(plugin("rust", &["rust"], &[".rs"])) + .unwrap_err() + .to_string(), + "Plugin already registered: rust" + ); + assert_eq!( + registry.unregister("missing"), + Err(LspPluginRegistryError::NotFound("missing".to_string())) + ); + assert_eq!( + registry.unregister("missing").unwrap_err().to_string(), + "Plugin not found: missing" + ); +} + +#[test] +fn registry_supported_extensions_matches_desktop_api_shape() { + let mut registry = PluginRegistry::new(); + registry + .register(plugin("rust", &["rust"], &[".rs"])) + .expect("rust plugin should register"); + registry + .register(plugin( + "typescript", + &["typescript", "javascript"], + &[".ts", ".tsx"], + )) + .expect("typescript plugin should register"); + + let summary = registry.supported_extensions(); + + assert_eq!(summary.extension_to_language.get(".rs").unwrap(), "rust"); + assert_eq!( + summary.extension_to_language.get(".ts").unwrap(), + "typescript" + ); + assert_eq!( + summary.extension_to_language.get(".tsx").unwrap(), + "typescript" + ); + assert!(summary + .supported_languages + .iter() + .any(|language| language == "rust")); + assert!(summary + .supported_languages + .iter() + .any(|language| language == "typescript")); + assert!(summary + .supported_languages + .iter() + .any(|language| language == "javascript")); +} + +#[test] +fn plugin_command_placeholder_resolution_is_target_driven() { + let command = "bin/${platform}/${os}/${arch}/server"; + let target = + LspPluginRuntimeTarget::new(LspPluginRuntimePlatform::Macos, LspPluginRuntimeArch::Arm64); + + assert_eq!( + resolve_plugin_command_for_target(command, target), + "bin/darwin/darwin/arm64/server" + ); +}