diff --git a/src/crates/adapters/ai-adapters/AGENTS.md b/src/crates/adapters/ai-adapters/AGENTS.md index f23e4ef64f..3d8c017f92 100644 --- a/src/crates/adapters/ai-adapters/AGENTS.md +++ b/src/crates/adapters/ai-adapters/AGENTS.md @@ -3,9 +3,10 @@ Scope: this guide applies to `src/crates/adapters/ai-adapters`. `bitfun-ai-adapters` owns provider-specific request/response mapping, stream -protocol parsing, and provider/model selection helpers that are independent of -core config IO. Keep provider quirks here, then convert stream chunks into the -provider-neutral contracts owned by `bitfun-agent-stream`. +protocol parsing, CLI credential resolution, and provider/model selection +helpers that are independent of core config IO. Keep provider quirks here, then +convert stream chunks into the provider-neutral contracts owned by +`bitfun-agent-stream`. ## Guardrails @@ -19,12 +20,17 @@ provider-neutral contracts owned by `bitfun-agent-stream`. adapter tests and downstream usage expectations. - Do not move provider-neutral stream DTOs, replay policy, or tool-call accumulation ownership back into this crate. +- CLI credential probing may reuse lower-layer service command helpers for + PATH and process-platform behavior; do not introduce host framework calls. +- Keep `cli-credentials` optional so standalone protocol adapters do not pull + service/process dependencies by default. ## Verification ```bash cargo test -p bitfun-agent-stream cargo test -p bitfun-ai-adapters +cargo test -p bitfun-ai-adapters --features cli-credentials cli_credentials ``` If stream behavior affects core integration, also run the relevant tests in diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index 69f6ce66f1..577a4903d1 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -12,9 +12,12 @@ crate-type = ["rlib"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true, optional = true } bitfun-agent-stream = { path = "../../execution/agent-stream" } bitfun-core-types = { path = "../../contracts/core-types" } +bitfun-services-core = { path = "../../services/services-core", default-features = false, optional = true } chrono = { workspace = true } +dirs = { workspace = true, optional = true } eventsource-stream = { workspace = true } futures = { workspace = true } log = { workspace = true } @@ -25,6 +28,10 @@ tokio = { workspace = true } tokio-stream = { workspace = true } tokio-util = { workspace = true } urlencoding = { workspace = true } +uuid = { workspace = true, optional = true } + +[features] +cli-credentials = ["dep:base64", "dep:bitfun-services-core", "dep:dirs", "dep:uuid"] [dev-dependencies] axum = { workspace = true } diff --git a/src/crates/assembly/core/src/infrastructure/cli_credentials/codex.rs b/src/crates/adapters/ai-adapters/src/cli_credentials/codex.rs similarity index 98% rename from src/crates/assembly/core/src/infrastructure/cli_credentials/codex.rs rename to src/crates/adapters/ai-adapters/src/cli_credentials/codex.rs index cdc31fcc48..7220861a56 100644 --- a/src/crates/assembly/core/src/infrastructure/cli_credentials/codex.rs +++ b/src/crates/adapters/ai-adapters/src/cli_credentials/codex.rs @@ -154,10 +154,10 @@ fn parse_codex_cli_version(output: &str) -> Option { } async fn resolve_codex_cli_version() -> Option { - let check = crate::service::system::check_command("codex"); + let check = bitfun_services_core::system::check_command("codex"); let command = check.path.as_deref()?; let args = vec!["--version".to_string()]; - let output = crate::service::system::run_command(command, &args, None, None) + let output = bitfun_services_core::system::run_command(command, &args, None, None) .await .ok()?; if !output.success { diff --git a/src/crates/assembly/core/src/infrastructure/cli_credentials/gemini.rs b/src/crates/adapters/ai-adapters/src/cli_credentials/gemini.rs similarity index 100% rename from src/crates/assembly/core/src/infrastructure/cli_credentials/gemini.rs rename to src/crates/adapters/ai-adapters/src/cli_credentials/gemini.rs diff --git a/src/crates/adapters/ai-adapters/src/cli_credentials/mod.rs b/src/crates/adapters/ai-adapters/src/cli_credentials/mod.rs new file mode 100644 index 0000000000..e1ec43264d --- /dev/null +++ b/src/crates/adapters/ai-adapters/src/cli_credentials/mod.rs @@ -0,0 +1,89 @@ +//! CLI credential discovery and resolution. +//! +//! Lets BitFun reuse already-authenticated Codex CLI / Gemini CLI sessions on +//! the local machine instead of asking the user to paste an API key. Each +//! provider exposes a [`CredentialResolver`] that: +//! * inspects well-known config files in the user's home directory, +//! * refreshes OAuth tokens if they are expired or close to expiry, +//! * returns a [`ResolvedCredential`] that the AI client factory uses to +//! override `api_key` / `base_url` / `request_url` / `format` / +//! `custom_headers` before constructing an `AIClient`. + +pub mod codex; +pub mod gemini; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Source kind of a discovered CLI credential. Persisted on `AIModelConfig.auth`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliCredentialKind { + Codex, + Gemini, +} + +/// Concrete sub-mode of a credential, auto-detected from the on-disk file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CliCredentialMode { + /// Codex CLI in `OPENAI_API_KEY` mode, or Gemini CLI in `GEMINI_API_KEY` mode. + ApiKey, + /// Codex CLI in ChatGPT login mode (uses chatgpt.com backend with OAuth tokens). + ChatGpt, + /// Gemini CLI in personal Google OAuth mode (uses Cloud Code Assist endpoint). + OauthPersonal, +} + +/// What `discover_cli_credentials` returns to the UI for each detected source. +#[derive(Debug, Clone, Serialize)] +pub struct DiscoveredCredential { + pub kind: CliCredentialKind, + pub mode: CliCredentialMode, + pub display_label: String, + pub account: Option, + pub expires_at: Option, + pub source_path: String, + /// Suggested provider format (`responses`, `gemini`, `gemini-code-assist`, `openai`). + pub suggested_format: String, + /// Suggested base URL to seed the model entry with. + pub suggested_base_url: String, + /// Suggested model name to seed the entry with. + pub suggested_model: String, +} + +/// Final, runtime-resolved credential that overrides fields in `AIConfig`. +#[derive(Debug, Clone)] +pub struct ResolvedCredential { + pub api_key: String, + pub base_url: Option, + pub request_url: Option, + pub format: Option, + pub extra_headers: HashMap, + /// Unix seconds when this credential expires; `None` means non-expiring. + pub expires_at: Option, +} + +#[async_trait] +pub trait CredentialResolver: Send + Sync { + async fn resolve(&self) -> anyhow::Result; +} + +/// Discover all CLI credentials on the local machine. Errors per-source are +/// swallowed (logged) so that a broken Codex install doesn't hide a working +/// Gemini install. +pub async fn discover_all() -> Vec { + let mut out = Vec::new(); + match codex::discover().await { + Ok(Some(item)) => out.push(item), + Ok(None) => {} + Err(err) => log::debug!("codex credential discovery failed: {err:#}"), + } + match gemini::discover().await { + Ok(Some(item)) => out.push(item), + Ok(None) => {} + Err(err) => log::debug!("gemini credential discovery failed: {err:#}"), + } + out +} diff --git a/src/crates/adapters/ai-adapters/src/lib.rs b/src/crates/adapters/ai-adapters/src/lib.rs index a095b624d7..bdc91956a4 100644 --- a/src/crates/adapters/ai-adapters/src/lib.rs +++ b/src/crates/adapters/ai-adapters/src/lib.rs @@ -1,5 +1,7 @@ #![doc = include_str!("../README.md")] +#[cfg(feature = "cli-credentials")] +pub mod cli_credentials; pub mod client; pub mod diagnostics; pub mod model_selector; diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 16791a9537..8b33362c82 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -54,7 +54,6 @@ dirs = { workspace = true } dunce = { workspace = true } filetime = { workspace = true, optional = true } fs2 = { workspace = true, optional = true } -zip = { workspace = true } flate2 = { workspace = true, optional = true } include_dir = { workspace = true, optional = true } @@ -97,7 +96,7 @@ bitfun-agent-tools = { path = "../../execution/tool-contracts" } bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } # Core service owner crate -bitfun-services-core = { path = "../../services/services-core" } +bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["lsp"] } # Integration service owner crate bitfun-services-integrations = { path = "../../services/services-integrations", default-features = false, features = ["remote-ssh"] } @@ -183,7 +182,11 @@ product-full = [ "service-integrations", "tool-packs", ] -ai-adapter-runtime = ["dep:bitfun-ai-adapters", "dep:reqwest"] +ai-adapter-runtime = [ + "dep:bitfun-ai-adapters", + "bitfun-ai-adapters/cli-credentials", + "dep:reqwest", +] product-capabilities = ["dep:bitfun-product-capabilities"] product-domains = [ "ai-adapter-runtime", diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs index 1afc480e9e..d8ce73046c 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/debug.rs @@ -13,9 +13,9 @@ use crate::agentic::agents::{ }; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; -use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo}; use crate::util::errors::BitFunResult; use async_trait::async_trait; +use bitfun_services_core::lsp::project_detector::{ProjectDetector, ProjectInfo}; use log::debug; use std::path::Path; diff --git a/src/crates/assembly/core/src/infrastructure/cli_credentials/mod.rs b/src/crates/assembly/core/src/infrastructure/cli_credentials/mod.rs index e1ec43264d..1608b5eb4e 100644 --- a/src/crates/assembly/core/src/infrastructure/cli_credentials/mod.rs +++ b/src/crates/assembly/core/src/infrastructure/cli_credentials/mod.rs @@ -1,89 +1,5 @@ -//! CLI credential discovery and resolution. +//! Compatibility re-exports for CLI credential discovery and resolution. //! -//! Lets BitFun reuse already-authenticated Codex CLI / Gemini CLI sessions on -//! the local machine instead of asking the user to paste an API key. Each -//! provider exposes a [`CredentialResolver`] that: -//! * inspects well-known config files in the user's home directory, -//! * refreshes OAuth tokens if they are expired or close to expiry, -//! * returns a [`ResolvedCredential`] that the AI client factory uses to -//! override `api_key` / `base_url` / `request_url` / `format` / -//! `custom_headers` before constructing an `AIClient`. +//! The provider-specific implementation lives in `bitfun-ai-adapters`. -pub mod codex; -pub mod gemini; - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Source kind of a discovered CLI credential. Persisted on `AIModelConfig.auth`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CliCredentialKind { - Codex, - Gemini, -} - -/// Concrete sub-mode of a credential, auto-detected from the on-disk file. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CliCredentialMode { - /// Codex CLI in `OPENAI_API_KEY` mode, or Gemini CLI in `GEMINI_API_KEY` mode. - ApiKey, - /// Codex CLI in ChatGPT login mode (uses chatgpt.com backend with OAuth tokens). - ChatGpt, - /// Gemini CLI in personal Google OAuth mode (uses Cloud Code Assist endpoint). - OauthPersonal, -} - -/// What `discover_cli_credentials` returns to the UI for each detected source. -#[derive(Debug, Clone, Serialize)] -pub struct DiscoveredCredential { - pub kind: CliCredentialKind, - pub mode: CliCredentialMode, - pub display_label: String, - pub account: Option, - pub expires_at: Option, - pub source_path: String, - /// Suggested provider format (`responses`, `gemini`, `gemini-code-assist`, `openai`). - pub suggested_format: String, - /// Suggested base URL to seed the model entry with. - pub suggested_base_url: String, - /// Suggested model name to seed the entry with. - pub suggested_model: String, -} - -/// Final, runtime-resolved credential that overrides fields in `AIConfig`. -#[derive(Debug, Clone)] -pub struct ResolvedCredential { - pub api_key: String, - pub base_url: Option, - pub request_url: Option, - pub format: Option, - pub extra_headers: HashMap, - /// Unix seconds when this credential expires; `None` means non-expiring. - pub expires_at: Option, -} - -#[async_trait] -pub trait CredentialResolver: Send + Sync { - async fn resolve(&self) -> anyhow::Result; -} - -/// Discover all CLI credentials on the local machine. Errors per-source are -/// swallowed (logged) so that a broken Codex install doesn't hide a working -/// Gemini install. -pub async fn discover_all() -> Vec { - let mut out = Vec::new(); - match codex::discover().await { - Ok(Some(item)) => out.push(item), - Ok(None) => {} - Err(err) => log::debug!("codex credential discovery failed: {err:#}"), - } - match gemini::discover().await { - Ok(Some(item)) => out.push(item), - Ok(None) => {} - Err(err) => log::debug!("gemini credential discovery failed: {err:#}"), - } - out -} +pub use bitfun_ai_adapters::cli_credentials::*; diff --git a/src/crates/assembly/core/src/service/lsp/config_watcher.rs b/src/crates/assembly/core/src/service/lsp/config_watcher.rs index 04ae4c8c76..3768d6137f 100644 --- a/src/crates/assembly/core/src/service/lsp/config_watcher.rs +++ b/src/crates/assembly/core/src/service/lsp/config_watcher.rs @@ -1,127 +1,5 @@ -//! Configuration file watcher +//! Compatibility re-exports for LSP configuration watching. //! -//! Features: -//! - Watches configuration file changes (tsconfig.json, package.json, etc.) -//! - Automatically restarts the corresponding LSP server when config changes +//! The reusable watcher lives in `bitfun-services-core`. -use anyhow::Result; -use log::{debug, info, warn}; -use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::mpsc; - -/// Configuration file watcher. -pub struct ConfigWatcher { - workspace_path: PathBuf, - _watcher: RecommendedWatcher, // Keep the watcher alive (prevent it from being dropped) -} - -impl ConfigWatcher { - /// Creates a configuration file watcher. - pub fn new( - workspace_path: PathBuf, - on_config_changed: Arc, - ) -> Result { - info!( - "Setting up config file watcher for workspace: {:?}", - workspace_path - ); - - let (tx, mut rx) = mpsc::channel(100); - - let mut watcher = RecommendedWatcher::new( - move |res: Result| { - if let Ok(event) = res { - let _ = tx.blocking_send(event); - } - }, - Config::default(), - )?; - - let config_files = vec![ - "tsconfig.json", - "package.json", - "Cargo.toml", - ".eslintrc.json", - ".eslintrc.js", - "pyproject.toml", - "setup.py", - "go.mod", - "pom.xml", - "build.gradle", - "CMakeLists.txt", - ]; - - for file_name in config_files { - let file_path = workspace_path.join(file_name); - if file_path.exists() { - if let Err(e) = watcher.watch(&file_path, RecursiveMode::NonRecursive) { - warn!("Failed to watch config file {}: {}", file_name, e); - } - } - } - - let workspace_path_clone = workspace_path.clone(); - tokio::spawn(async move { - while let Some(event) = rx.recv().await { - Self::handle_file_event(event, &workspace_path_clone, &on_config_changed); - } - }); - - info!("Config file watcher started"); - - Ok(Self { - workspace_path, - _watcher: watcher, - }) - } - - /// Handles file change events. - fn handle_file_event( - event: Event, - _workspace_path: &Path, - on_config_changed: &Arc, - ) { - if !matches!(event.kind, EventKind::Modify(_)) { - return; - } - - for path in event.paths { - if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { - let language = Self::config_file_to_language(file_name); - - if let Some(lang) = language { - info!( - "Config file changed: {}, restarting {} server", - file_name, lang - ); - on_config_changed(lang.to_string(), file_name.to_string()); - } - } - } - } - - /// Infers a language from a configuration filename. - fn config_file_to_language(file_name: &str) -> Option<&'static str> { - match file_name { - "tsconfig.json" | "package.json" => Some("typescript"), - "Cargo.toml" => Some("rust"), - "pyproject.toml" | "setup.py" => Some("python"), - "go.mod" => Some("go"), - "pom.xml" | "build.gradle" => Some("java"), - "CMakeLists.txt" => Some("cpp"), - ".eslintrc.json" | ".eslintrc.js" => Some("javascript"), - _ => None, - } - } -} - -impl Drop for ConfigWatcher { - fn drop(&mut self) { - debug!( - "ConfigWatcher dropped for workspace: {:?}", - self.workspace_path - ); - } -} +pub use bitfun_services_core::lsp::config_watcher::ConfigWatcher; diff --git a/src/crates/assembly/core/src/service/lsp/debouncer.rs b/src/crates/assembly/core/src/service/lsp/debouncer.rs index 67f9343fd1..20f257f1de 100644 --- a/src/crates/assembly/core/src/service/lsp/debouncer.rs +++ b/src/crates/assembly/core/src/service/lsp/debouncer.rs @@ -1,60 +1,5 @@ -//! LSP request debouncer +//! Compatibility re-exports for LSP request debouncing. //! -//! Prevents sending a burst of duplicate requests in a short time, improving performance and -//! stability. +//! The reusable debouncer lives in `bitfun-services-core`. -use log::debug; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -/// Request debouncer. -pub struct RequestDebouncer { - /// Last request time (`uri + method -> last_time`). - last_requests: Arc>>, - /// Debounce delay (milliseconds). - debounce_ms: u64, -} - -impl RequestDebouncer { - /// Creates a new debouncer. - pub fn new(debounce_ms: u64) -> Self { - Self { - last_requests: Arc::new(RwLock::new(HashMap::new())), - debounce_ms, - } - } - - /// Returns whether a request should be sent. - /// Returns `true` if it can be sent, or `false` if it should be skipped (too frequent). - pub async fn should_send(&self, uri: &str, method: &str) -> bool { - let key = format!("{}:{}", uri, method); - let now = Instant::now(); - - let mut requests = self.last_requests.write().await; - - if let Some(last_time) = requests.get(&key) { - let elapsed = now.duration_since(*last_time); - if elapsed < Duration::from_millis(self.debounce_ms) { - debug!( - "Request debounced: {} ({}ms elapsed)", - key, - elapsed.as_millis() - ); - return false; - } - } - - requests.insert(key, now); - true - } - - /// Cleans up expired records (call periodically). - pub async fn cleanup(&self, max_age: Duration) { - let mut requests = self.last_requests.write().await; - let now = Instant::now(); - - requests.retain(|_, last_time| now.duration_since(*last_time) < max_age); - } -} +pub use bitfun_services_core::lsp::debouncer::RequestDebouncer; diff --git a/src/crates/assembly/core/src/service/lsp/global.rs b/src/crates/assembly/core/src/service/lsp/global.rs index 871454d226..938fb0e91f 100644 --- a/src/crates/assembly/core/src/service/lsp/global.rs +++ b/src/crates/assembly/core/src/service/lsp/global.rs @@ -10,7 +10,8 @@ use std::sync::{Arc, OnceLock}; use tokio::sync::RwLock; use super::file_sync::{FileSyncConfig, LspFileSync}; -use super::{LspManager, WorkspaceLspManager}; +use super::WorkspaceLspManager; +use bitfun_services_core::lsp::manager::LspManager; type WorkspaceManagerMap = HashMap>; type GlobalWorkspaceManagers = Arc>; diff --git a/src/crates/assembly/core/src/service/lsp/manager.rs b/src/crates/assembly/core/src/service/lsp/manager.rs index a70dab6276..f058e117b7 100644 --- a/src/crates/assembly/core/src/service/lsp/manager.rs +++ b/src/crates/assembly/core/src/service/lsp/manager.rs @@ -1,713 +1,5 @@ -//! LSP protocol-layer manager +//! Compatibility re-exports for LSP protocol-layer manager. +//! +//! The reusable LSP manager lives in `bitfun-services-core`. -use anyhow::{anyhow, Result}; -use log::{debug, error, info, warn}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; - -use super::plugin_loader::PluginLoader; -use super::process::{ - CrashCallback, DiagnosticsCallback, LspServerProcess, ProgressCallback, TokenCreateCallback, -}; -use super::registry::{LspSupportedExtensions, PluginRegistry}; -use super::types::{CompletionItem, LspPlugin}; - -/// LSP protocol-layer manager (stateless, pure protocol implementation). -pub struct LspManager { - /// Plugin loader. - plugin_loader: PluginLoader, - /// Plugin registry. - registry: Arc>, - /// Running LSP server processes (`language -> process`). - processes: Arc>>>, - /// Diagnostics cache (`uri -> diagnostics`). - diagnostics_cache: Arc>>>, -} - -impl LspManager { - /// Creates a new LSP manager. - pub fn new(plugins_dir: PathBuf) -> Self { - Self { - plugin_loader: PluginLoader::new(plugins_dir), - registry: Arc::new(RwLock::new(PluginRegistry::new())), - processes: Arc::new(RwLock::new(HashMap::new())), - diagnostics_cache: Arc::new(RwLock::new(HashMap::new())), - } - } - - /// Initializes the manager (loads installed plugins). - pub async fn initialize(&self) -> Result<()> { - info!("Initializing LSP Manager"); - - if let Err(e) = self.plugin_loader.cleanup_temp_dirs().await { - warn!("Failed to cleanup temp directories: {}", e); - } - - let plugins = self.plugin_loader.load_all_plugins().await?; - - for plugin in plugins { - if let Err(e) = self.register_plugin_internal(plugin).await { - error!("Failed to register plugin: {}", e); - } - } - - let count = { - let registry = self.registry.read().await; - registry.count() - }; - - info!("LSP Manager initialized with {} plugin(s)", count); - - Ok(()) - } - - // Note: workspace root path management has been moved to WorkspaceLspManager. - // LspManager is responsible for protocol-layer operations only. - - /// Registers a plugin (internal). - async fn register_plugin_internal(&self, plugin: LspPlugin) -> Result<()> { - let mut registry = self.registry.write().await; - registry.register(plugin)?; - Ok(()) - } - - /// Installs a plugin. - pub async fn install_plugin(&self, package_path: PathBuf) -> Result { - info!("Installing plugin from: {:?}", package_path); - - let plugin_id = self - .plugin_loader - .install_plugin_package(&package_path) - .await?; - - let plugin = self.plugin_loader.load_plugin(&plugin_id).await?; - - { - let mut registry = self.registry.write().await; - registry.register(plugin)?; - } - - info!("Plugin installed and registered: {}", plugin_id); - - Ok(plugin_id) - } - - /// Uninstalls a plugin. - pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { - info!("Uninstalling plugin: {}", plugin_id); - - if let Err(e) = self.stop_server(plugin_id).await { - warn!("Failed to stop server for {}: {}", plugin_id, e); - } - - { - let mut registry = self.registry.write().await; - registry.unregister(plugin_id)?; - } - - self.plugin_loader.uninstall_plugin(plugin_id).await?; - - info!("Plugin uninstalled: {}", plugin_id); - - Ok(()) - } - - /// Starts an LSP server. - /// workspace_root: Workspace root path, provided by the caller (WorkspaceLspManager). - /// crash_callback: Callback invoked when the process crashes. - /// progress_callback: Indexing progress callback. - /// token_create_callback: Token creation callback. - /// diagnostics_callback: Diagnostics callback. - pub async fn start_server( - &self, - language: &str, - workspace_root: Option, - crash_callback: Option, - progress_callback: Option, - token_create_callback: Option, - diagnostics_callback: Option, - ) -> Result<()> { - let plugin = { - let registry = self.registry.read().await; - match registry.find_by_language(language).cloned() { - Some(plugin) => plugin, - None => { - let err = anyhow!("No LSP plugin found for language: {}", language); - warn!("{} (this is expected for plaintext)", err); - return Err(err); - } - } - }; - - let plugin_id = plugin.id.clone(); - - { - let processes = self.processes.read().await; - if processes.contains_key(language) { - return Ok(()); - } - } - - let server_path = self.plugin_loader.get_server_path(&plugin).map_err(|e| { - error!("Failed to get server path: {}", e); - e - })?; - - let process = LspServerProcess::spawn( - plugin_id.clone(), - server_path.clone(), - &plugin.server, - crash_callback, - progress_callback, - token_create_callback, - diagnostics_callback, - ) - .await - .map_err(|e| { - error!("Failed to spawn process: {}", e); - e - })?; - - let root_uri = workspace_root.and_then(|p| p.to_str().map(|s| s.to_string())); - - process.initialize(root_uri.clone()).await.map_err(|e| { - error!("Failed to initialize LSP connection: {}", e); - e - })?; - - { - let mut processes = self.processes.write().await; - processes.insert(language.to_string(), Arc::new(process)); - } - - info!("LSP server started successfully: {}", language); - Ok(()) - } - - /// Stops an LSP server. - pub async fn stop_server(&self, language: &str) -> Result<()> { - debug!("Stopping LSP server: {}", language); - - let mut processes = self.processes.write().await; - if let Some(process) = processes.remove(language) { - if let Err(e) = process.shutdown().await { - warn!("Failed to shutdown server {}: {}", language, e); - } - } - - info!("LSP server stopped: {}", language); - Ok(()) - } - - /// Returns whether the server is running. - pub async fn is_server_running(&self, language: &str) -> bool { - let processes = self.processes.read().await; - processes.contains_key(language) - } - - /// Returns whether the server process is alive. - pub async fn is_server_alive(&self, language: &str) -> bool { - let processes = self.processes.read().await; - if let Some(process) = processes.get(language) { - process.is_alive().await - } else { - false - } - } - - /// Gets the server process (internal use). - async fn get_process(&self, language: &str) -> Result> { - let processes = self.processes.read().await; - processes - .get(language) - .cloned() - .ok_or_else(|| anyhow!("LSP server not running for: {}", language)) - } - - /// Lists all installed plugins. - pub async fn list_plugins(&self) -> Vec { - let registry = self.registry.read().await; - registry.list_all().into_iter().cloned().collect() - } - - /// Gets plugin information. - pub async fn get_plugin(&self, plugin_id: &str) -> Option { - let registry = self.registry.read().await; - registry.get_plugin(plugin_id).cloned() - } - - /// Finds a plugin by language. - pub async fn find_plugin_by_language(&self, language: &str) -> Option { - let registry = self.registry.read().await; - registry.find_by_language(language).cloned() - } - - /// Finds a plugin by file path. - pub async fn find_plugin_by_file(&self, file_path: &str) -> Option { - let registry = self.registry.read().await; - 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"); - - let plugin_ids: Vec = { - let processes = self.processes.read().await; - processes.keys().cloned().collect() - }; - - for plugin_id in plugin_ids { - if let Err(e) = self.stop_server(&plugin_id).await { - error!("Failed to stop server {}: {}", plugin_id, e); - } - } - - info!("All LSP servers stopped"); - - Ok(()) - } - - /// Shuts down all servers (alias). - pub async fn stop_all_servers(&self) -> Result<()> { - self.shutdown().await - } - - /// Document open notification (protocol-only; does not include startup logic). - pub async fn did_open(&self, language: &str, uri: &str, text: &str) -> Result<()> { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri, - "languageId": language, - "version": 1, - "text": text - } - }); - - process - .send_notification("textDocument/didOpen", Some(params)) - .await - } - - /// Document change notification. - pub async fn did_change( - &self, - language: &str, - uri: &str, - version: i32, - text: &str, - ) -> Result<()> { - let process = self.get_process(language).await?; - - let content_len = text.len(); - debug!( - "Sending didChange to LSP: lang={}, uri={}, version={}, size={} bytes", - language, uri, version, content_len - ); - - let params = serde_json::json!({ - "textDocument": { - "uri": uri, - "version": version - }, - "contentChanges": [{ - "text": text - }] - }); - - process - .send_notification("textDocument/didChange", Some(params)) - .await - } - - /// Document save notification. - pub async fn did_save(&self, language: &str, uri: &str) -> Result<()> { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - } - }); - - process - .send_notification("textDocument/didSave", Some(params)) - .await - } - - /// Document close notification. - pub async fn did_close(&self, language: &str, uri: &str) -> Result<()> { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - } - }); - - process - .send_notification("textDocument/didClose", Some(params)) - .await - } - - /// Gets code completion (protocol-only). - pub async fn get_completions( - &self, - language: &str, - uri: &str, - line: u32, - character: u32, - ) -> Result> { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "position": { - "line": line, - "character": character - } - }); - - let result = process - .send_request("textDocument/completion", Some(params)) - .await?; - - let items = if let Ok(list) = - serde_json::from_value::(result.clone()) - { - list.items - } else if let Ok(items) = serde_json::from_value::>(result.clone()) { - items - } else { - warn!("Unexpected completion response format, returning empty list"); - Vec::new() - }; - - Ok(items) - } - - /// Go to definition (protocol-only). - pub async fn goto_definition( - &self, - language: &str, - uri: &str, - line: u32, - character: u32, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "position": { - "line": line, - "character": character - } - }); - - process - .send_request("textDocument/definition", Some(params)) - .await - } - - /// Gets hover information. - pub async fn get_hover( - &self, - language: &str, - uri: &str, - line: u32, - character: u32, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "position": { - "line": line, - "character": character - } - }); - - process - .send_request("textDocument/hover", Some(params)) - .await - } - - /// Finds references. - pub async fn find_references( - &self, - language: &str, - uri: &str, - line: u32, - character: u32, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "position": { - "line": line, - "character": character - }, - "context": { - "includeDeclaration": true - } - }); - - process - .send_request("textDocument/references", Some(params)) - .await - } - - /// Gets code actions. - pub async fn get_code_actions( - &self, - language: &str, - uri: &str, - range: serde_json::Value, - context: serde_json::Value, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "range": range, - "context": context - }); - - process - .send_request("textDocument/codeAction", Some(params)) - .await - } - - /// Formats a document. - pub async fn format_document( - &self, - language: &str, - uri: &str, - tab_size: u32, - insert_spaces: bool, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "options": { - "tabSize": tab_size, - "insertSpaces": insert_spaces - } - }); - - process - .send_request("textDocument/formatting", Some(params)) - .await - } - - /// Gets inlay hints. - pub async fn get_inlay_hints( - &self, - language: &str, - uri: &str, - start_line: u32, - start_character: u32, - end_line: u32, - end_character: u32, - ) -> Result> { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "range": { - "start": { - "line": start_line, - "character": start_character - }, - "end": { - "line": end_line, - "character": end_character - } - } - }); - - let result = process - .send_request("textDocument/inlayHint", Some(params)) - .await?; - - if result.is_null() { - return Ok(vec![]); - } - - let hints: Vec = serde_json::from_value(result) - .map_err(|e| anyhow!("Failed to parse inlay hints: {}", e))?; - - Ok(hints) - } - - /// Renames a symbol. - pub async fn rename( - &self, - language: &str, - uri: &str, - line: u32, - character: u32, - new_name: &str, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "position": { - "line": line, - "character": character - }, - "newName": new_name - }); - - process - .send_request("textDocument/rename", Some(params)) - .await - } - - /// Gets document highlights (Document Highlight). - /// Used to highlight all references of the symbol at the cursor. - pub async fn get_document_highlight( - &self, - language: &str, - uri: &str, - line: u32, - character: u32, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "position": { - "line": line, - "character": character - } - }); - - process - .send_request("textDocument/documentHighlight", Some(params)) - .await - } - - /// Gets document symbols (Document Symbols). - /// Used for outlines, symbol navigation, etc. - pub async fn get_document_symbols( - &self, - language: &str, - uri: &str, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - } - }); - - process - .send_request("textDocument/documentSymbol", Some(params)) - .await - } - - /// Gets semantic tokens (Semantic Tokens). - /// Used for semantic-level syntax highlighting. - pub async fn get_semantic_tokens( - &self, - language: &str, - uri: &str, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - } - }); - - process - .send_request("textDocument/semanticTokens/full", Some(params)) - .await - } - - /// Gets semantic tokens range (Semantic Tokens Range). - /// Used for incremental updates to semantic highlighting. - pub async fn get_semantic_tokens_range( - &self, - language: &str, - uri: &str, - range: serde_json::Value, - ) -> Result { - let process = self.get_process(language).await?; - - let params = serde_json::json!({ - "textDocument": { - "uri": uri - }, - "range": range - }); - - process - .send_request("textDocument/semanticTokens/range", Some(params)) - .await - } - - /// Returns server capabilities. - pub async fn get_server_capabilities(&self, language: &str) -> Result { - let process = self.get_process(language).await?; - - let capabilities = process - .get_capabilities() - .await - .ok_or_else(|| anyhow!("Server capabilities not available"))?; - - Ok(capabilities) - } - - /// Gets diagnostics for a file (from cache). - pub async fn get_diagnostics(&self, uri: &str) -> Vec { - let cache = self.diagnostics_cache.read().await; - cache.get(uri).cloned().unwrap_or_default() - } - - /// Updates the diagnostics cache (called by `diagnostics_callback`). - pub async fn update_diagnostics_cache(&self, uri: String, diagnostics: Vec) { - let mut cache = self.diagnostics_cache.write().await; - cache.insert(uri, diagnostics); - } -} - -impl Drop for LspManager { - fn drop(&mut self) { - debug!("Dropping LSP Manager"); - } -} +pub use bitfun_services_core::lsp::manager::LspManager; diff --git a/src/crates/assembly/core/src/service/lsp/mod.rs b/src/crates/assembly/core/src/service/lsp/mod.rs index b73857eaa7..6ba16d9c90 100644 --- a/src/crates/assembly/core/src/service/lsp/mod.rs +++ b/src/crates/assembly/core/src/service/lsp/mod.rs @@ -1,10 +1,9 @@ -//! LSP (Language Server Protocol) service module +//! Product-facing LSP workspace bridge and compatibility re-exports. //! -//! Provides full LSP support, including: -//! - Plugin management (install/uninstall/load) -//! - Server process lifecycle management -//! - LSP protocol communication -//! - Code completion, navigation, diagnostics, and more +//! Reusable LSP package loading, protocol, process, manager, detection, watch, +//! and debounce helpers live in `bitfun-services-core`. This core module keeps +//! workspace/global/file-sync orchestration, frontend event bridging, and legacy +//! import paths. pub mod config_watcher; pub mod debouncer; 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 2fa747b740..5022e559a5 100644 --- a/src/crates/assembly/core/src/service/lsp/plugin_loader.rs +++ b/src/crates/assembly/core/src/service/lsp/plugin_loader.rs @@ -1,269 +1,6 @@ -//! LSP plugin loader +//! Compatibility re-exports for LSP plugin package loading. //! -//! Responsible for loading and installing plugins from the filesystem. +//! The reusable package loader lives in `bitfun-services-core`; this legacy path +//! remains for downstream callers that import through `bitfun_core::service::lsp`. -use anyhow::{anyhow, Result}; -use log::{debug, error, info, warn}; -use std::path::{Path, PathBuf}; -use tokio::fs; - -use super::types::LspPlugin; - -/// Plugin loader. -pub struct PluginLoader { - /// Plugins directory. - plugins_dir: PathBuf, -} - -impl PluginLoader { - /// Creates a new plugin loader. - pub fn new(plugins_dir: PathBuf) -> Self { - Self { plugins_dir } - } - - /// Loads a specific plugin. - pub async fn load_plugin(&self, plugin_id: &str) -> Result { - let plugin_dir = self.plugins_dir.join(plugin_id); - let manifest_path = plugin_dir.join("manifest.json"); - - if !manifest_path.exists() { - return Err(anyhow!( - "Plugin manifest not found: {}", - manifest_path.display() - )); - } - - let content = fs::read_to_string(&manifest_path).await?; - let plugin: LspPlugin = serde_json::from_str(&content) - .map_err(|e| anyhow!("Failed to parse manifest: {}", e))?; - - if plugin.id != plugin_id { - return Err(anyhow!( - "Plugin ID mismatch: expected '{}', found '{}'", - plugin_id, - plugin.id - )); - } - - info!("Plugin loaded: {} v{}", plugin.name, plugin.version); - debug!("Supported languages: {:?}", plugin.languages); - debug!("File extensions: {:?}", plugin.file_extensions); - - Ok(plugin) - } - - /// Loads all installed plugins. - pub async fn load_all_plugins(&self) -> Result> { - if !self.plugins_dir.exists() { - fs::create_dir_all(&self.plugins_dir).await?; - info!("Created plugins directory: {:?}", self.plugins_dir); - return Ok(vec![]); - } - - let mut plugins = Vec::new(); - let mut entries = fs::read_dir(&self.plugins_dir).await?; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - - if path.is_dir() { - if let Some(plugin_id) = path.file_name().and_then(|n| n.to_str()) { - if plugin_id.starts_with('.') { - continue; - } - - if plugin_id == "temp" || plugin_id == "cache" || plugin_id == "backup" { - continue; - } - - match self.load_plugin(plugin_id).await { - Ok(plugin) => { - plugins.push(plugin); - } - Err(e) => { - error!("Failed to load plugin '{}': {}", plugin_id, e); - } - } - } - } - } - - info!("Successfully loaded {} plugin(s)", plugins.len()); - - Ok(plugins) - } - - /// Installs a plugin package (a `.vcpkg` file). - pub async fn install_plugin_package(&self, package_path: &Path) -> Result { - info!("Installing plugin package: {:?}", package_path); - - if !package_path.exists() { - error!("Plugin package not found: {:?}", package_path); - return Err(anyhow!("Plugin package not found: {:?}", package_path)); - } - - if package_path.extension().and_then(|e| e.to_str()) != Some("vcpkg") { - error!("Invalid plugin package format (expected .vcpkg)"); - return Err(anyhow!("Invalid plugin package format (expected .vcpkg)")); - } - - let temp_id = format!(".temp-{}", std::process::id()); - let temp_dir = self.plugins_dir.join(&temp_id); - - if temp_dir.exists() { - fs::remove_dir_all(&temp_dir).await?; - } - - fs::create_dir_all(&temp_dir).await?; - - let file = std::fs::File::open(package_path)?; - let mut archive = zip::ZipArchive::new(file)?; - - let mut manifest_content = String::new(); - { - let mut manifest_file = archive.by_name("manifest.json")?; - std::io::Read::read_to_string(&mut manifest_file, &mut manifest_content)?; - } - - let plugin: LspPlugin = serde_json::from_str(&manifest_content)?; - let plugin_id = plugin.id.clone(); - - let plugin_dir = self.plugins_dir.join(&plugin_id); - if plugin_dir.exists() { - return Err(anyhow!("Plugin already installed: {}", plugin_id)); - } - - archive.extract(&plugin_dir)?; - - if temp_dir.exists() { - let _ = fs::remove_dir_all(&temp_dir).await; - } - - info!( - "Plugin installed: {} v{} (id: {})", - plugin.name, plugin.version, plugin_id - ); - - Ok(plugin_id) - } - - /// Uninstalls a plugin. - pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { - info!("Uninstalling plugin: {}", plugin_id); - - let plugin_dir = self.plugins_dir.join(plugin_id); - - if !plugin_dir.exists() { - error!("Plugin not found: {}", plugin_id); - return Err(anyhow!("Plugin not found: {}", plugin_id)); - } - - fs::remove_dir_all(&plugin_dir).await?; - - info!("Plugin uninstalled successfully: {}", plugin_id); - - Ok(()) - } - - /// Cleans up temporary directories. - pub async fn cleanup_temp_dirs(&self) -> Result<()> { - let mut entries = fs::read_dir(&self.plugins_dir).await?; - let mut cleaned_count = 0; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - - if path.is_dir() { - if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { - if dir_name.starts_with(".temp") { - if let Err(e) = fs::remove_dir_all(&path).await { - warn!("Failed to remove temp directory {}: {}", dir_name, e); - } else { - cleaned_count += 1; - } - } - } - } - } - - if cleaned_count > 0 { - info!("Cleaned {} temporary director(ies)", cleaned_count); - } - - Ok(()) - } - - /// Returns the plugin server executable path. - pub fn get_server_path(&self, plugin: &LspPlugin) -> Result { - let plugin_dir = self.plugins_dir.join(&plugin.id); - - let command = bitfun_services_core::lsp::resolve_plugin_command_for_current_target( - &plugin.server.command, - )?; - - let command = command.replace('/', std::path::MAIN_SEPARATOR_STR); - - let server_path = plugin_dir.join(&command); - - if !server_path.exists() { - #[cfg(windows)] - { - let mut server_path = server_path.clone(); - let extensions = vec![".exe", ".bat", ".cmd"]; - let mut found = false; - - for ext in extensions { - let path_with_ext = plugin_dir.join(format!("{}{}", command, ext)); - - if path_with_ext.exists() { - server_path = path_with_ext; - found = true; - break; - } - } - - if !found { - error!("LSP server binary not found at: {:?}", server_path); - error!("Tried extensions: .exe, .bat, .cmd"); - error!("Plugin directory: {:?}", plugin_dir); - return Err(anyhow!( - "LSP server binary not found: {}\nTried: {}.exe, {}.bat, {}.cmd", - server_path.display(), - command, - command, - command - )); - } - } - - #[cfg(not(windows))] - { - error!("LSP server binary not found: {:?}", server_path); - return Err(anyhow!( - "LSP server binary not found: {}", - server_path.display() - )); - } - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&server_path)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&server_path, perms)?; - } - - Ok(server_path) - } - - /// Returns the plugin directory path. - pub fn get_plugin_dir(&self, plugin_id: &str) -> PathBuf { - self.plugins_dir.join(plugin_id) - } - - /// Returns the plugins root directory. - pub fn get_plugins_root(&self) -> &Path { - &self.plugins_dir - } -} +pub use bitfun_services_core::lsp::plugin_loader::PluginLoader; diff --git a/src/crates/assembly/core/src/service/lsp/process.rs b/src/crates/assembly/core/src/service/lsp/process.rs index 7bd4fb57ee..5f5ee3c040 100644 --- a/src/crates/assembly/core/src/service/lsp/process.rs +++ b/src/crates/assembly/core/src/service/lsp/process.rs @@ -1,1087 +1,7 @@ -//! LSP server process management +//! Compatibility re-exports for LSP server process lifecycle. //! -//! Manages the lifecycle of a single LSP server process. +//! The reusable LSP process owner lives in `bitfun-services-core`. -use anyhow::{anyhow, Result}; -use log::{debug, error, info, warn}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use tokio::io::BufReader; -use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout}; -use tokio::sync::{mpsc, oneshot, RwLock}; -use tokio::time::{timeout, Duration}; - -use super::protocol::{ - create_notification, create_request, extract_result, read_message, write_message, -}; -use super::types::{ - InitializeParams, InitializeResult, JsonRpcMessage, JsonRpcResponse, RuntimeType, ServerConfig, +pub use bitfun_services_core::lsp::process::{ + CrashCallback, DiagnosticsCallback, LspServerProcess, ProgressCallback, TokenCreateCallback, }; - -/// Process crash callback type. -pub type CrashCallback = Arc; - -/// Progress notification callback type. -/// Parameters: `(kind: "begin" | "report" | "end", token: String, percentage: Option, message: String)`. -pub type ProgressCallback = Arc, String) + Send + Sync>; - -/// Token creation callback type. -/// Parameters: `(token: String)`. -pub type TokenCreateCallback = Arc; - -/// Diagnostics callback type. -/// Parameters: `(uri: String, diagnostics: Vec)`. -pub type DiagnosticsCallback = Arc) + Send + Sync>; - -/// LSP server process. -pub struct LspServerProcess { - /// Plugin ID. - pub id: String, - /// Child process. - child: Arc>, - /// Standard input. - stdin: Arc>, - /// Request ID counter. - request_id: Arc, - /// Pending requests waiting for a response. - pending_requests: Arc>>>, - /// Notification sender. - notification_tx: mpsc::UnboundedSender, - /// Server capabilities. - capabilities: Arc>>, - /// Crash callback. - crash_callback: Option, - /// Progress callback. - progress_callback: Option, - /// Token creation callback. - token_create_callback: Option, - /// Diagnostics callback. - diagnostics_callback: Option, -} - -impl LspServerProcess { - /// Spawns a new LSP server process. - pub async fn spawn( - id: String, - server_bin: PathBuf, - config: &ServerConfig, - crash_callback: Option, - progress_callback: Option, - token_create_callback: Option, - diagnostics_callback: Option, - ) -> Result { - info!("Spawning LSP server: {} at {:?}", id, server_bin); - debug!( - "LSP config - args: {:?}, env: {:?}", - config.args, config.env - ); - - if !server_bin.exists() { - error!("LSP server binary not found: {:?}", server_bin); - return Err(anyhow!("LSP server binary not found: {:?}", server_bin)); - } - - let runtime_type = Self::detect_runtime_type(config, &server_bin); - debug!("Detected runtime type: {:?}", runtime_type); - - let mut cmd = Self::build_command(&runtime_type, &server_bin, config)?; - - cmd.stdin(Stdio::piped()); - cmd.stdout(Stdio::piped()); - cmd.stderr(Stdio::piped()); - - let mut child = cmd.spawn().map_err(|e| { - error!("Failed to spawn LSP server {}: {}", id, e); - anyhow!("Failed to spawn LSP server {}: {}", id, e) - })?; - - if let Some(pid) = child.id() { - debug!("LSP server process started with PID: {}", pid); - } - - let stdin = child - .stdin - .take() - .ok_or_else(|| anyhow!("Failed to capture stdin"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| anyhow!("Failed to capture stdout"))?; - - let stderr = child - .stderr - .take() - .ok_or_else(|| anyhow!("Failed to capture stderr"))?; - - let (notification_tx, notification_rx) = mpsc::unbounded_channel(); - - let process = Self { - id: id.clone(), - child: Arc::new(RwLock::new(child)), - stdin: Arc::new(RwLock::new(stdin)), - request_id: Arc::new(AtomicU64::new(1)), - pending_requests: Arc::new(RwLock::new(HashMap::new())), - notification_tx, - capabilities: Arc::new(RwLock::new(None)), - crash_callback, - progress_callback, - token_create_callback, - diagnostics_callback, - }; - - process.start_read_task(stdout).await; - - process.start_stderr_task(stderr).await; - - process.start_notification_task(notification_rx).await; - - info!("LSP server process spawned: {}", id); - - Ok(process) - } - - /// Starts the message reader task. - async fn start_read_task(&self, stdout: ChildStdout) { - let pending_requests = self.pending_requests.clone(); - let notification_tx = self.notification_tx.clone(); - let id = self.id.clone(); - let crash_callback = self.crash_callback.clone(); - - tokio::spawn(async move { - let mut reader = BufReader::new(stdout); - let mut consecutive_timeouts = 0; - const MAX_CONSECUTIVE_TIMEOUTS: u32 = 3; - - loop { - match timeout(Duration::from_secs(30), read_message(&mut reader)).await { - Ok(Ok(message)) => { - consecutive_timeouts = 0; - - match &message { - JsonRpcMessage::Response(response) => { - let request_id = response.id; - let mut pending = pending_requests.write().await; - - if let Some(sender) = pending.remove(&request_id) { - let _ = sender.send(response.clone()); - } else { - warn!( - "[{}] Received response for unknown request ID: {}", - id, request_id - ); - } - } - JsonRpcMessage::Notification(_) => { - if let Err(e) = notification_tx.send(message) { - error!("[{}] Failed to send notification: {}", id, e); - break; - } - } - JsonRpcMessage::Request(_req) => { - if let Err(e) = notification_tx.send(message) { - error!("[{}] Failed to send request: {}", id, e); - break; - } - } - } - } - Ok(Err(e)) => { - error!("[{}] Failed to read message: {}", id, e); - error!("[{}] This usually means the LSP server is outputting non-protocol data to stdout", id); - break; - } - Err(_) => { - consecutive_timeouts += 1; - - if consecutive_timeouts >= MAX_CONSECUTIVE_TIMEOUTS { - warn!( - "[{}] No LSP messages for {}s (this is normal if idle)", - id, - 30 * MAX_CONSECUTIVE_TIMEOUTS - ); - - consecutive_timeouts = 0; - } - } - } - } - - error!("LSP server read task ended abnormally: {}", id); - - { - let mut pending = pending_requests.write().await; - let count = pending.len(); - if count > 0 { - warn!("Dropping {} pending request(s) for server {}", count, id); - } - pending.clear(); - } - - if let Some(callback) = crash_callback { - error!("Invoking crash callback - server connection lost: {}", id); - callback(id.clone()); - } - }); - } - - /// Starts the stderr reader task. - /// - /// This task continuously reads the LSP server's stderr output to prevent the pipe buffer from - /// filling up and blocking the process. - /// The LSP protocol specifies using stdout for protocol communication; stderr is used for the - /// server's diagnostic logs. - async fn start_stderr_task(&self, stderr: ChildStderr) { - let id = self.id.clone(); - - tokio::spawn(async move { - use tokio::io::AsyncBufReadExt; - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - let mut line_count = 0; - let mut error_count = 0; - let mut warn_count = 0; - - let mut missing_cmake = false; - let mut missing_spectre = false; - let mut build_script_errors = std::collections::HashSet::new(); - - loop { - line.clear(); - match reader.read_line(&mut line).await { - Ok(0) => break, - Ok(_) => { - let trimmed = line.trim(); - if !trimmed.is_empty() { - line_count += 1; - - let lower = trimmed.to_lowercase(); - - if lower.contains("missing dependency: cmake") - || (lower.contains("failed to spawn") && lower.contains("cmake")) - { - if !missing_cmake { - missing_cmake = true; - warn!("[{}] Missing build dependency: CMake not installed or not in PATH", id); - info!("[{}] Tip: Some Rust crates require CMake to compile C/C++ code. Download: https://cmake.org/download/", id); - } - continue; - } - - if lower.contains("no spectre-mitigated libs") { - if !missing_spectre { - missing_spectre = true; - warn!("[{}] Missing build dependency: MSVC Spectre mitigation libraries not installed", id); - info!("[{}] Tip: Some Rust crates require MSVC Spectre libraries. Install via Visual Studio Installer", id); - } - continue; - } - - if lower.contains("failed to run custom build command") { - if let Some(start) = trimmed.find("for `") { - if let Some(end) = trimmed[start + 5..].find('`') { - let package = &trimmed[start + 5..start + 5 + end]; - if build_script_errors.insert(package.to_string()) { - warn!("[{}] Build script failed for package: {} (LSP may still work but code analysis accuracy may be affected)", id, package); - } - } - } - continue; - } - - if lower.contains("compiling") - || lower.contains("building") - || lower.contains("cargo:rerun-if") - { - continue; - } - - if lower.contains("panic") { - error_count += 1; - if error_count <= 3 { - debug!("[{}] Build script panic: {}", id, trimmed); - } - continue; - } - - if lower.contains("error") || lower.contains("fatal") { - error_count += 1; - - if error_count <= 5 { - error!("[{}] stderr: {}", id, trimmed); - } else if error_count % 10 == 0 { - error!("[{}] stderr: ... (omitted {} errors)", id, error_count); - } - } else if lower.contains("warn") || lower.contains("warning") { - warn_count += 1; - - if warn_count <= 10 { - warn!("[{}] stderr: {}", id, trimmed); - } else if warn_count % 100 == 0 { - warn!("[{}] stderr: ... (omitted {} warnings)", id, warn_count); - } - } else { - if line_count <= 5 || line_count % 1000 == 0 { - debug!("[{}] stderr: {}", id, trimmed); - } - } - } - } - Err(e) => { - error!("Failed to read stderr from {}: {}", id, e); - break; - } - } - } - - if line_count > 0 || error_count > 0 || warn_count > 0 { - info!( - "LSP server stderr task ended: {} (read {} lines, {} errors, {} warnings)", - id, line_count, error_count, warn_count - ); - - if !build_script_errors.is_empty() { - warn!("[{}] {} package(s) had build script failures, but LSP service is still running", id, build_script_errors.len()); - } - - if missing_cmake || missing_spectre { - info!("[{}] Tip: Installing missing dependencies may improve code analysis accuracy", id); - } - } - }); - } - - /// Starts the notification handler task. - async fn start_notification_task( - &self, - mut notification_rx: mpsc::UnboundedReceiver, - ) { - let id = self.id.clone(); - let progress_callback = self.progress_callback.clone(); - let token_create_callback = self.token_create_callback.clone(); - let diagnostics_callback = self.diagnostics_callback.clone(); - let stdin = self.stdin.clone(); - - tokio::spawn(async move { - while let Some(message) = notification_rx.recv().await { - match message { - JsonRpcMessage::Notification(notif) => match notif.method.as_str() { - "$/progress" => { - if let Some(params) = ¬if.params { - let token = params - .get("token") - .and_then(|t| t.as_str()) - .unwrap_or("unknown") - .to_string(); - - if let Some(value) = params.get("value") { - if let Some(kind) = value.get("kind").and_then(|k| k.as_str()) { - match kind { - "begin" => { - let title = value - .get("title") - .and_then(|t| t.as_str()) - .unwrap_or(""); - info!("[{}] Indexing started: {}", id, title); - - if let Some(ref callback) = progress_callback { - callback( - "begin".to_string(), - token.clone(), - Some(0), - title.to_string(), - ); - } - } - "report" => { - let percentage = value - .get("percentage") - .and_then(|p| p.as_u64()); - let message = value - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or(""); - - if let Some(ref callback) = progress_callback { - callback( - "report".to_string(), - token.clone(), - percentage.map(|p| p as u32), - message.to_string(), - ); - } - } - "end" => { - let message = value - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or(""); - info!("[{}] Indexing completed: {}", id, message); - - if let Some(ref callback) = progress_callback { - callback( - "end".to_string(), - token.clone(), - Some(100), - message.to_string(), - ); - } - } - _ => {} - } - } - } - } - } - "textDocument/publishDiagnostics" => { - if let Some(params) = ¬if.params { - if let Some(uri) = params.get("uri").and_then(|u| u.as_str()) { - if let Some(diagnostics_arr) = - params.get("diagnostics").and_then(|d| d.as_array()) - { - let diags: Vec = diagnostics_arr.clone(); - - debug!( - "[{}] Diagnostics: {} items for {}", - id, - diags.len(), - uri - ); - - if let Some(callback) = &diagnostics_callback { - callback(uri.to_string(), diags); - } - } - } - } - } - "window/logMessage" => { - if let Some(params) = ¬if.params { - let msg_type = - params.get("type").and_then(|t| t.as_u64()).unwrap_or(3); - if let Some(msg) = params.get("message").and_then(|m| m.as_str()) { - match msg_type { - 1 => error!("[{}] Server log: {}", id, msg), - 2 => warn!("[{}] Server log: {}", id, msg), - 3 => info!("[{}] Server log: {}", id, msg), - 4 => debug!("[{}] Server log: {}", id, msg), - _ => debug!("[{}] Server log: {}", id, msg), - } - } - } - } - "window/showMessage" => { - if let Some(params) = ¬if.params { - let msg_type = - params.get("type").and_then(|t| t.as_u64()).unwrap_or(3); - if let Some(msg) = params.get("message").and_then(|m| m.as_str()) { - match msg_type { - 1 => error!("[{}] Server message: {}", id, msg), - 2 => warn!("[{}] Server message: {}", id, msg), - 3 => info!("[{}] Server message: {}", id, msg), - 4 => debug!("[{}] Server message: {}", id, msg), - _ => info!("[{}] Server message: {}", id, msg), - } - } - } - } - _ => {} - }, - - JsonRpcMessage::Request(req) => match req.method.as_str() { - "window/workDoneProgress/create" => { - if let Some(params) = &req.params { - if let Some(token) = params.get("token") { - let token_str = token.as_str().unwrap_or("unknown").to_string(); - - if let Some(ref callback) = token_create_callback { - callback(token_str); - } - } - } - - let response = super::types::JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: req.id, - result: Some(serde_json::Value::Null), - error: None, - }; - - let response_message = super::types::JsonRpcMessage::Response(response); - let mut stdin_lock = stdin.write().await; - if let Err(e) = - super::protocol::write_message(&mut stdin_lock, &response_message) - .await - { - error!( - "[{}] Failed to send workDoneProgress/create response: {}", - id, e - ); - } - } - "client/registerCapability" => { - let response = super::types::JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: req.id, - result: Some(serde_json::Value::Null), - error: None, - }; - - let response_message = super::types::JsonRpcMessage::Response(response); - let mut stdin_lock = stdin.write().await; - if let Err(e) = - super::protocol::write_message(&mut stdin_lock, &response_message) - .await - { - error!( - "[{}] Failed to send registerCapability response: {}", - id, e - ); - } - } - "workspace/configuration" => { - let response = super::types::JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: req.id, - result: Some(serde_json::json!([])), - error: None, - }; - - let response_message = super::types::JsonRpcMessage::Response(response); - let mut stdin_lock = stdin.write().await; - if let Err(e) = - super::protocol::write_message(&mut stdin_lock, &response_message) - .await - { - error!("[{}] Failed to send configuration response: {}", id, e); - } - } - _ => { - warn!("[{}] Unhandled server request: {}", id, req.method); - - let response = super::types::JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: req.id, - result: None, - error: Some(super::types::JsonRpcError { - code: -32601, - message: format!("Method not supported: {}", req.method), - data: None, - }), - }; - - let response_message = super::types::JsonRpcMessage::Response(response); - let mut stdin_lock = stdin.write().await; - if let Err(e) = - super::protocol::write_message(&mut stdin_lock, &response_message) - .await - { - error!("[{}] Failed to send error response: {}", id, e); - } - } - }, - _ => {} - } - } - - info!("LSP notification task ended: {}", id); - }); - } - - /// Sends a request and waits for the response. - pub async fn send_request( - &self, - method: impl Into, - params: Option, - ) -> Result { - let id = self.request_id.fetch_add(1, Ordering::SeqCst); - let method_str = method.into(); - - let message = create_request(id, method_str.clone(), params); - - let (tx, rx) = oneshot::channel(); - - { - let mut pending = self.pending_requests.write().await; - pending.insert(id, tx); - } - - { - let mut stdin = self.stdin.write().await; - write_message(&mut stdin, &message).await?; - } - - let response = timeout(Duration::from_secs(60), rx).await.map_err(|_| { - error!("LSP request timeout after 60s: {}", method_str); - anyhow!( - "LSP request timeout (60s): {}. The LSP server may not be responding.", - method_str - ) - })??; - - extract_result(response) - } - - /// Sends a notification (does not wait for a response). - pub async fn send_notification( - &self, - method: impl Into, - params: Option, - ) -> Result<()> { - let method_str = method.into(); - let message = create_notification(method_str, params); - - let mut stdin = self.stdin.write().await; - write_message(&mut stdin, &message).await?; - - Ok(()) - } - - /// Initializes the server. - pub async fn initialize(&self, workspace_root: Option) -> Result { - info!("Initializing LSP server: {}", self.id); - - let root_uri = workspace_root.as_ref().map(|path| { - if cfg!(windows) { - format!("file:///{}", path.replace('\\', "/")) - } else { - format!("file://{}", path) - } - }); - - let workspace_folders = workspace_root.as_ref().map(|root| { - let uri = if cfg!(windows) { - format!("file:///{}", root.replace('\\', "/")) - } else { - format!("file://{}", root) - }; - - let name = std::path::Path::new(root) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("workspace") - .to_string(); - - vec![super::types::WorkspaceFolder { uri, name }] - }); - - let params = InitializeParams { - process_id: Some(std::process::id()), - root_path: None, - root_uri: root_uri.clone(), - capabilities: super::types::ClientCapabilities { - window: Some(serde_json::json!({ - "workDoneProgress": true, - "showMessage": { - "messageActionItem": { - "additionalPropertiesSupport": false - } - }, - "showDocument": { - "support": true - } - })), - - workspace: Some(serde_json::json!({ - "applyEdit": true, - "workspaceEdit": { - "documentChanges": true, - "resourceOperations": ["create", "rename", "delete"] - }, - "didChangeConfiguration": { - "dynamicRegistration": false - }, - "didChangeWatchedFiles": { - "dynamicRegistration": false - }, - "symbol": { - "dynamicRegistration": false - }, - "executeCommand": { - "dynamicRegistration": false - }, - "workspaceFolders": true, - "configuration": true - })), - text_document: Some(serde_json::json!({ - "synchronization": { - "dynamicRegistration": false, - "didSave": true, - "willSave": false, - "willSaveWaitUntil": false - }, - "completion": { - "dynamicRegistration": false, - "completionItem": { - "snippetSupport": true, - "commitCharactersSupport": false, - "documentationFormat": ["plaintext", "markdown"], - "deprecatedSupport": false, - "preselectSupport": false - }, - "contextSupport": false - }, - "hover": { - "dynamicRegistration": false, - "contentFormat": ["plaintext", "markdown"] - }, - "signatureHelp": { - "dynamicRegistration": false, - "signatureInformation": { - "documentationFormat": ["plaintext", "markdown"] - } - }, - "definition": { - "dynamicRegistration": false, - "linkSupport": true - }, - "references": { - "dynamicRegistration": false - }, - "documentHighlight": { - "dynamicRegistration": false - }, - "documentSymbol": { - "dynamicRegistration": false, - "hierarchicalDocumentSymbolSupport": true - }, - "codeAction": { - "dynamicRegistration": false, - "codeActionLiteralSupport": { - "codeActionKind": { - "valueSet": ["quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports"] - } - } - }, - "formatting": { - "dynamicRegistration": false - }, - "rangeFormatting": { - "dynamicRegistration": false - }, - "rename": { - "dynamicRegistration": false, - "prepareSupport": false - }, - "publishDiagnostics": { - "relatedInformation": true, - "tagSupport": { - "valueSet": [1, 2] - } - }, - "inlayHint": { - "dynamicRegistration": false, - "resolveSupport": { - "properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"] - } - } - })), - experimental: None, - }, - - initialization_options: Some(serde_json::json!({ - - "checkOnSave": { - "command": "clippy" - }, - "cargo": { - "allFeatures": true - }, - - })), - - workspace_folders, - }; - - let result = self - .send_request("initialize", Some(serde_json::to_value(params)?)) - .await?; - - let init_result: InitializeResult = serde_json::from_value(result)?; - - { - let mut caps = self.capabilities.write().await; - *caps = Some(serde_json::to_value(&init_result.capabilities)?); - } - - self.send_notification("initialized", Some(serde_json::json!({}))) - .await?; - - info!("LSP server initialized: {}", self.id); - - Ok(init_result) - } - - /// Shuts down the server. - pub async fn shutdown(&self) -> Result<()> { - info!("Shutting down LSP server: {}", self.id); - - let _ = self.send_request("shutdown", None).await; - - let _ = self.send_notification("exit", None).await; - - tokio::time::sleep(Duration::from_millis(500)).await; - - let mut child = self.child.write().await; - let _ = child.kill().await; - - info!("LSP server shut down: {}", self.id); - - Ok(()) - } - - /// Returns server capabilities. - pub async fn get_capabilities(&self) -> Option { - let caps = self.capabilities.read().await; - caps.clone() - } - - /// Returns whether the process is still alive. - pub async fn is_alive(&self) -> bool { - let mut child = self.child.write().await; - match child.try_wait() { - Ok(Some(status)) => { - warn!("[{}] Process has exited with status: {:?}", self.id, status); - false - } - Ok(None) => true, - Err(e) => { - error!("[{}] Failed to check process status: {}", self.id, e); - false - } - } - } - - /// Detects the runtime type. - fn detect_runtime_type(config: &ServerConfig, server_bin: &Path) -> RuntimeType { - if let Some(runtime) = &config.runtime { - debug!("Runtime explicitly specified: {}", runtime); - return match runtime.to_lowercase().as_str() { - "bash" | "sh" => RuntimeType::Bash, - "node" | "nodejs" => RuntimeType::Node, - "exe" | "executable" => RuntimeType::Executable, - _ => { - warn!( - "Unknown runtime type '{}', defaulting to executable", - runtime - ); - RuntimeType::Executable - } - }; - } - - if let Some(ext) = server_bin.extension().and_then(|e| e.to_str()) { - match ext.to_lowercase().as_str() { - "sh" | "bash" => return RuntimeType::Bash, - "js" | "mjs" | "cjs" => return RuntimeType::Node, - _ => {} - } - } - - RuntimeType::Executable - } - - /// Builds the command based on the runtime type. - fn build_command( - runtime_type: &RuntimeType, - server_bin: &PathBuf, - config: &ServerConfig, - ) -> Result { - match runtime_type { - RuntimeType::Executable => { - #[cfg(windows)] - { - if let Some(ext) = server_bin.extension().and_then(|e| e.to_str()) { - let ext_lower = ext.to_lowercase(); - - if ext_lower == "bat" || ext_lower == "cmd" { - debug!( - "Detected batch file (.{}), extracting node command", - ext_lower - ); - - if let Ok(content) = std::fs::read_to_string(server_bin) { - let mut script_path: Option = None; - - for line in content.lines() { - let line = line.trim(); - - if line.starts_with("node ") || line.starts_with("node.exe ") { - info!("Found node execution command: {}", line); - - if let Some(start_quote) = line.find('"') { - if let Some(end_quote) = - line[start_quote + 1..].find('"') - { - let path_expr = &line - [start_quote + 1..start_quote + 1 + end_quote]; - debug!("Extracted path expression: {}", path_expr); - - for prev_line in content.lines() { - let prev_line = prev_line.trim(); - if prev_line.starts_with("set ") - && prev_line.contains( - path_expr - .trim_matches('%') - .split('%') - .next() - .unwrap_or(""), - ) - { - if let Some(eq_pos) = prev_line.find('=') { - let value_part = &prev_line - [eq_pos + 1..] - .trim_matches('"'); - - if let Some(parent) = - server_bin.parent() - { - let mut resolved_path = - parent.to_path_buf(); - - let rel_part = value_part - .replace("%SCRIPT_DIR%", ""); - - for component in - rel_part.split(['\\', '/']) - { - match component { - "" | "." => continue, - ".." => { - resolved_path.pop(); - } - part => { - resolved_path.push(part) - } - } - } - - if resolved_path.exists() { - script_path = - Some(resolved_path); - break; - } else { - warn!("Resolved path does not exist: {:?}", resolved_path); - } - } - } - } - } - } - } - break; - } - } - - if let Some(js_path) = script_path { - let node_cmd = if cfg!(windows) { "node.exe" } else { "node" }; - - let mut cmd = - crate::util::process_manager::create_tokio_command( - node_cmd, - ); - cmd.arg(js_path); - cmd.args(&config.args); - cmd.envs(&config.env); - return Ok(cmd); - } - } - - error!("Failed to extract node command from bat file"); - error!("Bat files cannot be executed directly without cmd wrapper"); - return Err(anyhow!( - "Failed to parse batch file. Please check the plugin installation." - )); - } - } - } - - let mut cmd = crate::util::process_manager::create_tokio_command(server_bin); - cmd.args(&config.args); - cmd.envs(&config.env); - Ok(cmd) - } - RuntimeType::Bash => { - #[cfg(windows)] - { - let bash_paths = vec![ - "bash.exe", - "C:\\Program Files\\Git\\bin\\bash.exe", - "C:\\Program Files (x86)\\Git\\bin\\bash.exe", - "wsl.exe", - ]; - - let mut bash_exe = None; - for path in &bash_paths { - if crate::util::process_manager::create_command(path) - .arg("--version") - .output() - .is_ok() - { - bash_exe = Some(path.to_string()); - break; - } - } - - let bash_cmd = bash_exe.ok_or_else(|| { - error!( - "Bash not found on Windows. Searched paths: {:?}", - bash_paths - ); - anyhow!( - "Bash not found on Windows. Please install Git Bash or WSL.\n\ - - Git Bash: https://git-scm.com/download/win\n\ - - WSL: https://docs.microsoft.com/windows/wsl/install" - ) - })?; - - let mut cmd = crate::util::process_manager::create_tokio_command(&bash_cmd); - cmd.arg(server_bin); - cmd.args(&config.args); - cmd.envs(&config.env); - Ok(cmd) - } - - #[cfg(not(windows))] - { - let mut cmd = crate::util::process_manager::create_tokio_command("bash"); - cmd.arg(server_bin); - cmd.args(&config.args); - cmd.envs(&config.env); - Ok(cmd) - } - } - RuntimeType::Node => { - let node_cmd = if cfg!(windows) { "node.exe" } else { "node" }; - - match crate::util::process_manager::create_command(node_cmd) - .arg("--version") - .output() - { - Ok(_) => {} - Err(e) => { - error!("Node.js not found: {}", e); - return Err(anyhow!( - "Node.js not found. Please install Node.js from https://nodejs.org/\n\ - The LSP plugin requires Node.js to be installed and available in PATH." - )); - } - } - - let mut cmd = crate::util::process_manager::create_tokio_command(node_cmd); - cmd.arg(server_bin); - cmd.args(&config.args); - cmd.envs(&config.env); - Ok(cmd) - } - } - } -} - -impl Drop for LspServerProcess { - fn drop(&mut self) { - debug!("Dropping LSP server process: {}", self.id); - } -} diff --git a/src/crates/assembly/core/src/service/lsp/project_detector.rs b/src/crates/assembly/core/src/service/lsp/project_detector.rs index 76a8b65ffe..5329011f5f 100644 --- a/src/crates/assembly/core/src/service/lsp/project_detector.rs +++ b/src/crates/assembly/core/src/service/lsp/project_detector.rs @@ -1,467 +1,5 @@ -//! Project type detector +//! Compatibility re-exports for LSP project detection. //! -//! Features: -//! - Scans the workspace to identify project types -//! - Detects programming languages in use -//! - Counts files by type -//! - Determines the primary programming language -//! - Supports monorepos and subdirectory project layouts +//! The reusable detector lives in `bitfun-services-core`. -use anyhow::Result; -use log::{debug, info}; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use tokio::fs; - -/// Project information. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[derive(Default)] -pub struct ProjectInfo { - /// Detected languages. - pub languages: Vec, - /// Primary language (usually the one with the most files). - pub primary_language: Option, - /// File counts per language. - pub file_counts: HashMap, - /// Project type tags. - pub project_types: Vec, - /// Total file count. - pub total_files: usize, -} - -/// Project type detector. -pub struct ProjectDetector; - -impl ProjectDetector { - /// Detects the project type. - pub async fn detect(workspace_path: &Path) -> Result { - debug!("Detecting project type for: {:?}", workspace_path); - - let mut info = ProjectInfo::default(); - - Self::detect_by_file_extensions(workspace_path, &mut info).await?; - - Self::detect_by_config_files(workspace_path, &mut info).await; - - Self::determine_primary_language(&mut info); - - Self::deduplicate_languages(&mut info); - - info!( - "Project detection complete: languages={:?}, primary={:?}, project_types={:?}", - info.languages, info.primary_language, info.project_types - ); - - Ok(info) - } - - /// Detects project type via config files (supports root and subdirectories). - async fn detect_by_config_files(workspace_path: &Path, info: &mut ProjectInfo) { - Self::detect_root_config_files(workspace_path, info); - - Self::detect_subdirectory_config_files(workspace_path, info).await; - } - - /// Detects config files in the workspace root. - fn detect_root_config_files(workspace_path: &Path, info: &mut ProjectInfo) { - if workspace_path.join("tsconfig.json").exists() { - Self::add_language(info, "typescript"); - Self::add_project_type(info, "typescript"); - } - - if workspace_path.join("package.json").exists() { - if !info.languages.contains(&"typescript".to_string()) { - Self::add_language(info, "javascript"); - } - Self::add_project_type(info, "nodejs"); - } - - if workspace_path.join("Cargo.toml").exists() { - Self::add_language(info, "rust"); - Self::add_project_type(info, "rust"); - } - - if workspace_path.join("pyproject.toml").exists() - || workspace_path.join("setup.py").exists() - || workspace_path.join("requirements.txt").exists() - { - Self::add_language(info, "python"); - Self::add_project_type(info, "python"); - } - - if workspace_path.join("go.mod").exists() { - Self::add_language(info, "go"); - Self::add_project_type(info, "go"); - } - - if workspace_path.join("pom.xml").exists() - || workspace_path.join("build.gradle").exists() - || workspace_path.join("build.gradle.kts").exists() - { - Self::add_language(info, "java"); - Self::add_project_type(info, "java"); - } - - if workspace_path.join("CMakeLists.txt").exists() - || workspace_path.join("Makefile").exists() - || workspace_path.join("meson.build").exists() - { - Self::add_language(info, "cpp"); - Self::add_project_type(info, "cpp"); - } - - if Self::has_file_with_extension(workspace_path, "csproj") - || Self::has_file_with_extension(workspace_path, "fsproj") - || workspace_path.join("global.json").exists() - { - Self::add_language(info, "csharp"); - Self::add_project_type(info, "dotnet"); - } - } - - /// Detects config files in subdirectories (supports monorepo layouts). - async fn detect_subdirectory_config_files(workspace_path: &Path, info: &mut ProjectInfo) { - let subdirectories = [ - "cli", - "src-tauri", - "crates", - "rust", - "backend", - "core", - "lib", - "packages", - "apps", - "frontend", - "web", - "client", - "server", - "api", - "src", - ]; - - for subdir in subdirectories { - let subdir_path = workspace_path.join(subdir); - if !subdir_path.exists() || !subdir_path.is_dir() { - continue; - } - - if subdir_path.join("Cargo.toml").exists() { - Self::add_language(info, "rust"); - Self::add_project_type(info, "rust"); - } - - if subdir_path.join("go.mod").exists() { - Self::add_language(info, "go"); - Self::add_project_type(info, "go"); - } - - if subdir_path.join("pyproject.toml").exists() || subdir_path.join("setup.py").exists() - { - Self::add_language(info, "python"); - Self::add_project_type(info, "python"); - } - - if subdir_path.join("pom.xml").exists() - || subdir_path.join("build.gradle").exists() - || subdir_path.join("build.gradle.kts").exists() - { - Self::add_language(info, "java"); - Self::add_project_type(info, "java"); - } - } - - if let Ok(mut entries) = fs::read_dir(workspace_path).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - if path.is_dir() { - let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - - if matches!( - dir_name, - "node_modules" | "target" | ".git" | "dist" | "build" | "out" - ) { - continue; - } - - if path.join("Cargo.toml").exists() - && !info.languages.contains(&"rust".to_string()) - { - Self::add_language(info, "rust"); - Self::add_project_type(info, "rust"); - } - } - } - } - } - - /// Checks whether a directory contains any file with the given extension. - fn has_file_with_extension(dir: &Path, ext: &str) -> bool { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - if let Some(file_ext) = entry.path().extension() { - if file_ext - .to_str() - .map(|e| e.eq_ignore_ascii_case(ext)) - .unwrap_or(false) - { - return true; - } - } - } - } - false - } - - /// Detects languages by file extension (deep scan with a file count limit). - async fn detect_by_file_extensions( - workspace_path: &Path, - info: &mut ProjectInfo, - ) -> Result<()> { - let mut counts: HashMap = HashMap::new(); - let max_scan_files = 5000; - let mut scanned = 0; - - Self::scan_directory_iterative(workspace_path, &mut counts, &mut scanned, max_scan_files) - .await?; - - info.total_files = scanned; - - for (ext, count) in counts { - let language = Self::extension_to_language(&ext); - if language != "unknown" { - *info.file_counts.entry(language.clone()).or_insert(0) += count; - - let threshold = Self::language_threshold(&language); - if count >= threshold { - Self::add_language(info, &language); - - if count >= 10 { - if let Some(project_type) = Self::language_to_project_type(&language) { - Self::add_project_type(info, project_type); - } - } - } - } - } - - Ok(()) - } - - /// Mapping from language to project types. - fn language_to_project_type(language: &str) -> Option<&'static str> { - match language { - "rust" => Some("rust"), - "python" => Some("python"), - "go" => Some("go"), - "java" => Some("java"), - "kotlin" => Some("kotlin"), - "typescript" => Some("typescript"), - "javascript" => Some("nodejs"), - "cpp" | "c" => Some("cpp"), - "csharp" => Some("dotnet"), - "swift" => Some("swift"), - "ruby" => Some("ruby"), - "php" => Some("php"), - "scala" => Some("scala"), - _ => None, - } - } - - /// Returns the file-count threshold for a language. - fn language_threshold(language: &str) -> usize { - match language { - "rust" | "go" | "java" | "python" | "typescript" | "javascript" => 3, - "json5" | "yaml" | "toml" => 10, - _ => 5, - } - } - - /// Iteratively scans directories (avoids recursion depth limits). - async fn scan_directory_iterative( - root: &Path, - counts: &mut HashMap, - scanned: &mut usize, - max_files: usize, - ) -> Result<()> { - let mut dir_stack: Vec = vec![root.to_path_buf()]; - - while let Some(dir) = dir_stack.pop() { - if *scanned >= max_files { - break; - } - - let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if matches!( - dir_name, - "node_modules" - | "target" - | ".git" - | "dist" - | "build" - | "out" - | "__pycache__" - | ".hvigor" - | "hvigor" - | "vendor" - | ".cargo" - | ".venv" - | "venv" - | "env" - | "screenshots" - | "signature" - ) { - continue; - } - - let mut entries = match fs::read_dir(&dir).await { - Ok(entries) => entries, - Err(_) => continue, - }; - - while let Some(entry) = entries.next_entry().await? { - if *scanned >= max_files { - break; - } - - let path = entry.path(); - let metadata = match entry.metadata().await { - Ok(m) => m, - Err(_) => continue, - }; - - if metadata.is_dir() { - dir_stack.push(path); - } else if metadata.is_file() { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - *counts.entry(ext.to_lowercase()).or_insert(0) += 1; - } - *scanned += 1; - } - } - } - - Ok(()) - } - - /// Determines the primary language based on file counts. - fn determine_primary_language(info: &mut ProjectInfo) { - if info.primary_language.is_some() { - return; - } - - if info.file_counts.is_empty() { - return; - } - - let programming_languages: HashSet<&str> = [ - "rust", - "python", - "go", - "java", - "javascript", - "typescript", - "cpp", - "c", - "csharp", - "kotlin", - "swift", - "ruby", - "php", - "scala", - ] - .into_iter() - .collect(); - - let primary = info - .file_counts - .iter() - .filter(|(lang, _)| programming_languages.contains(lang.as_str())) - .max_by_key(|(_, count)| *count) - .map(|(lang, _)| lang.clone()); - - if let Some(lang) = primary { - info.primary_language = Some(lang.clone()); - } - } - - /// Deduplicates the language list. - fn deduplicate_languages(info: &mut ProjectInfo) { - let mut seen = HashSet::new(); - info.languages.retain(|lang| seen.insert(lang.clone())); - - let mut seen = HashSet::new(); - info.project_types.retain(|pt| seen.insert(pt.clone())); - } - - /// Adds a language (avoids duplicates). - fn add_language(info: &mut ProjectInfo, language: &str) { - if !info.languages.contains(&language.to_string()) { - info.languages.push(language.to_string()); - } - } - - /// Adds a project type (avoids duplicates). - fn add_project_type(info: &mut ProjectInfo, project_type: &str) { - if !info.project_types.contains(&project_type.to_string()) { - info.project_types.push(project_type.to_string()); - } - } - - /// Mapping from file extension to language. - fn extension_to_language(ext: &str) -> String { - match ext { - "json5" => "json5", - "ts" | "tsx" | "ets" => "typescript", - "js" | "jsx" | "mjs" | "cjs" => "javascript", - "rs" => "rust", - "py" | "pyw" => "python", - "go" => "go", - "java" => "java", - "c" | "h" => "c", - "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp", - "cs" => "csharp", - "rb" => "ruby", - "php" => "php", - "swift" => "swift", - "kt" | "kts" => "kotlin", - "scala" => "scala", - "sh" | "bash" => "shell", - _ => "unknown", - } - .to_string() - } - - /// Returns whether the server should be pre-started (based on project size). - pub fn should_prestart(info: &ProjectInfo) -> Vec { - let mut languages_to_start = Vec::new(); - - match info.total_files { - 0..=100 => { - languages_to_start.extend(info.languages.clone()); - debug!( - "Small project detected ({} files), will prestart all languages", - info.total_files - ); - } - 101..=1000 => { - if let Some(primary) = &info.primary_language { - languages_to_start.push(primary.clone()); - debug!( - "Medium project detected ({} files), will prestart primary language: {}", - info.total_files, primary - ); - } - } - _ => { - debug!( - "Large project detected ({} files), will use on-demand loading", - info.total_files - ); - } - } - - languages_to_start - } -} +pub use bitfun_services_core::lsp::project_detector::{ProjectDetector, ProjectInfo}; diff --git a/src/crates/assembly/core/src/service/lsp/protocol.rs b/src/crates/assembly/core/src/service/lsp/protocol.rs index 33c73df649..11b90d5111 100644 --- a/src/crates/assembly/core/src/service/lsp/protocol.rs +++ b/src/crates/assembly/core/src/service/lsp/protocol.rs @@ -1,183 +1,7 @@ -//! LSP protocol handling +//! Compatibility re-exports for LSP protocol encoding and decoding. //! -//! Implements encoding and decoding of JSON-RPC messages. +//! The reusable protocol helpers live in `bitfun-services-core`. -use anyhow::{anyhow, Result}; -use log::{error, warn}; -use tokio::io::{AsyncWriteExt, BufReader}; -use tokio::process::{ChildStdin, ChildStdout}; - -use super::types::{JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse}; - -/// Reads an LSP message. -/// -/// LSP uses HTTP-style headers: -/// Content-Length: xxx\r\n -/// \r\n -/// {json content} -pub async fn read_message(reader: &mut BufReader) -> Result { - let mut content_length: Option = None; - let mut line_count = 0; - let mut empty_line_count = 0; - let mut found_lsp_header = false; - - const MAX_LINES: usize = 100; - const MAX_EMPTY_LINES: usize = 50; - - loop { - let mut raw_line = Vec::new(); - let _bytes_read = - tokio::io::AsyncBufReadExt::read_until(reader, b'\n', &mut raw_line).await?; - line_count += 1; - - let header = match String::from_utf8(raw_line.clone()) { - Ok(s) => s, - Err(e) => { - warn!( - "[LSP Protocol] Line {} contains non-UTF8 data: {:?}", - line_count, e - ); - - String::from_utf8_lossy(&raw_line).to_string() - } - }; - - let header = header.trim(); - - if line_count > MAX_LINES { - return Err(anyhow!( - "Protocol error: Read {} lines without finding valid LSP header. \ - The LSP server may be outputting non-protocol data to stdout. \ - Check server stderr logs for details.", - line_count - )); - } - - if !found_lsp_header && header.is_empty() { - empty_line_count += 1; - - if empty_line_count > MAX_EMPTY_LINES { - return Err(anyhow!( - "Protocol error: Skipped {} empty lines without finding LSP header. \ - The LSP server stdout may be misconfigured. \ - Ensure the server only outputs LSP protocol messages to stdout.", - empty_line_count - )); - } - - if empty_line_count <= 10 || empty_line_count % 10 == 0 { - warn!( - "[LSP Protocol] Skipped {} empty lines, still waiting for LSP header (will fail after {} empty lines)", - empty_line_count, - MAX_EMPTY_LINES - ); - } - continue; - } - - if found_lsp_header && header.is_empty() { - break; - } - - if header.starts_with("Content-Length:") { - found_lsp_header = true; - let length_str = header - .strip_prefix("Content-Length:") - .ok_or_else(|| anyhow!("Invalid Content-Length header"))? - .trim(); - content_length = Some(length_str.parse()?); - } else if header.starts_with("Content-Type:") { - found_lsp_header = true; - } else if !header.is_empty() { - if found_lsp_header { - warn!("[LSP Protocol] Unexpected header line: {:?}", header); - } else { - if line_count <= 10 { - warn!("[LSP Protocol] Non-LSP output (skipping): {:?}", header); - } - } - } - } - - let content_length = content_length.ok_or_else(|| { - error!( - "[LSP Protocol] Missing Content-Length header after {} lines", - line_count - ); - anyhow!("Missing Content-Length header") - })?; - - let mut buffer = vec![0u8; content_length]; - tokio::io::AsyncReadExt::read_exact(reader, &mut buffer).await?; - - let message: JsonRpcMessage = serde_json::from_slice(&buffer).map_err(|e| { - let content_preview = String::from_utf8_lossy(&buffer); - let preview = if content_preview.len() > 500 { - let pos = content_preview - .char_indices() - .take_while(|(i, _)| *i < 500) - .last() - .map(|(i, c)| i + c.len_utf8()) - .unwrap_or(0); - format!("{}...", &content_preview[..pos]) - } else { - content_preview.to_string() - }; - error!("[LSP Protocol] Failed to parse JSON: {}", e); - error!("[LSP Protocol] Content preview: {}", preview); - anyhow!("Failed to parse JSON: {}", e) - })?; - - Ok(message) -} - -/// Writes an LSP message. -pub async fn write_message(writer: &mut ChildStdin, message: &JsonRpcMessage) -> Result<()> { - let content = serde_json::to_string(message)?; - let content_bytes = content.as_bytes(); - - let header = format!("Content-Length: {}\r\n\r\n", content_bytes.len()); - - writer.write_all(header.as_bytes()).await?; - writer.write_all(content_bytes).await?; - writer.flush().await?; - - Ok(()) -} - -/// Creates a request message. -pub fn create_request( - id: u64, - method: impl Into, - params: Option, -) -> JsonRpcMessage { - JsonRpcMessage::Request(JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id, - method: method.into(), - params, - }) -} - -/// Creates a notification message. -pub fn create_notification( - method: impl Into, - params: Option, -) -> JsonRpcMessage { - JsonRpcMessage::Notification(JsonRpcNotification { - jsonrpc: "2.0".to_string(), - method: method.into(), - params, - }) -} - -/// Extracts the result from a response. -pub fn extract_result(response: JsonRpcResponse) -> Result { - if let Some(error) = response.error { - return Err(anyhow!("LSP Error {}: {}", error.code, error.message)); - } - - response - .result - .ok_or_else(|| anyhow!("Missing result in response")) -} +pub use bitfun_services_core::lsp::protocol::{ + create_notification, create_request, extract_result, read_message, write_message, +}; diff --git a/src/crates/assembly/core/src/service/lsp/workspace_manager.rs b/src/crates/assembly/core/src/service/lsp/workspace_manager.rs index 56d2d06344..b6f179c1fc 100644 --- a/src/crates/assembly/core/src/service/lsp/workspace_manager.rs +++ b/src/crates/assembly/core/src/service/lsp/workspace_manager.rs @@ -18,10 +18,11 @@ use std::time::{Duration, SystemTime}; use tokio::sync::RwLock; use tokio::task::JoinHandle; -use super::config_watcher::ConfigWatcher; -use super::manager::LspManager; -use super::project_detector::{ProjectDetector, ProjectInfo}; use crate::infrastructure::events::EventEmitter; +use bitfun_core_types::lsp::{CompletionItem, InlayHint}; +use bitfun_services_core::lsp::config_watcher::ConfigWatcher; +use bitfun_services_core::lsp::manager::LspManager; +use bitfun_services_core::lsp::project_detector::{ProjectDetector, ProjectInfo}; /// LSP event types (pushed to the frontend). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1101,7 +1102,7 @@ impl WorkspaceLspManager { uri: &str, line: u32, character: u32, - ) -> Result> { + ) -> Result> { let server_language = self .get_running_server_for_language(language) .await @@ -1205,7 +1206,7 @@ impl WorkspaceLspManager { start_character: u32, end_line: u32, end_character: u32, - ) -> Result> { + ) -> Result> { let server_language = self .get_running_server_for_language(language) .await diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 72cc2f8eb5..131e5b9b15 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -2,14 +2,15 @@ Scope: this guide applies to `src/crates/services/services-core`. -`bitfun-services-core` owns platform-neutral service DTOs and helpers that can +`bitfun-services-core` owns cross-platform service DTOs and helpers that can compile without the full product runtime. It also owns generic local filesystem -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. +operations/tree/search/listing primitives, reusable LSP registry/package +loading/protocol/project detection/config watching/debounce/process-manager +helpers, 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. ## Guardrails @@ -17,12 +18,12 @@ this crate. runtime crates. - Prefer `bitfun-core-types` for shared DTOs and `bitfun-runtime-ports` for cross-layer traits. -- 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. +- Keep dependency features explicit. Non-LSP consumers should use + `default-features = false`; LSP consumers must enable the `lsp` feature. +- LSP manifest and protocol DTOs belong in `bitfun-core-types`; reusable LSP + package, protocol, detection, debounce, watch, and process-manager helpers + belong in `services-core`; product workspace state, event emission, global + singletons, and file-sync orchestration stay outside this crate. - 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. @@ -34,7 +35,7 @@ this crate. ## Verification ```bash -cargo test -p bitfun-services-core +cargo test -p bitfun-services-core --features lsp node scripts/check-core-boundaries.mjs cargo check -p bitfun-core --features product-full ``` diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index b69e49a6d0..b91e2bcd21 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -10,14 +10,17 @@ name = "bitfun_services_core" crate-type = ["rlib"] [dependencies] +anyhow = { workspace = true, optional = true } bitfun-core-types = { path = "../../contracts/core-types" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } +zip = { workspace = true, optional = true } thiserror = { workspace = true } log = { workspace = true } +notify = { workspace = true, optional = true } ignore = { workspace = true } sha2 = { workspace = true } which = { workspace = true } @@ -27,5 +30,9 @@ regex = { workspace = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } +[features] +default = ["lsp"] +lsp = ["dep:anyhow", "dep:notify", "dep:zip"] + [dev-dependencies] tempfile = { workspace = true } diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 274e45955a..ad7c6cd1ad 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; +#[cfg(feature = "lsp")] pub mod lsp; pub mod managed_runtime; pub mod process_manager; diff --git a/src/crates/services/services-core/src/lsp.rs b/src/crates/services/services-core/src/lsp.rs index 0b4a47a204..74adb878d6 100644 --- a/src/crates/services/services-core/src/lsp.rs +++ b/src/crates/services/services-core/src/lsp.rs @@ -1,7 +1,18 @@ -//! Platform-neutral LSP plugin service rules. +//! Cross-platform reusable LSP 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. +//! This module owns pure plugin registry, command-target mapping rules, +//! reusable plugin package filesystem loading, protocol helpers, project +//! detection, request debounce, configuration file watching, and LSP server +//! process/manager primitives. It does not own product workspace state, +//! frontend event emission, or global singleton wiring. + +pub mod config_watcher; +pub mod debouncer; +pub mod manager; +pub mod plugin_loader; +pub mod process; +pub mod project_detector; +pub mod protocol; use bitfun_core_types::lsp::LspPlugin; use log::{info, warn}; diff --git a/src/crates/services/services-core/src/lsp/config_watcher.rs b/src/crates/services/services-core/src/lsp/config_watcher.rs new file mode 100644 index 0000000000..04ae4c8c76 --- /dev/null +++ b/src/crates/services/services-core/src/lsp/config_watcher.rs @@ -0,0 +1,127 @@ +//! Configuration file watcher +//! +//! Features: +//! - Watches configuration file changes (tsconfig.json, package.json, etc.) +//! - Automatically restarts the corresponding LSP server when config changes + +use anyhow::Result; +use log::{debug, info, warn}; +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// Configuration file watcher. +pub struct ConfigWatcher { + workspace_path: PathBuf, + _watcher: RecommendedWatcher, // Keep the watcher alive (prevent it from being dropped) +} + +impl ConfigWatcher { + /// Creates a configuration file watcher. + pub fn new( + workspace_path: PathBuf, + on_config_changed: Arc, + ) -> Result { + info!( + "Setting up config file watcher for workspace: {:?}", + workspace_path + ); + + let (tx, mut rx) = mpsc::channel(100); + + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(event) = res { + let _ = tx.blocking_send(event); + } + }, + Config::default(), + )?; + + let config_files = vec![ + "tsconfig.json", + "package.json", + "Cargo.toml", + ".eslintrc.json", + ".eslintrc.js", + "pyproject.toml", + "setup.py", + "go.mod", + "pom.xml", + "build.gradle", + "CMakeLists.txt", + ]; + + for file_name in config_files { + let file_path = workspace_path.join(file_name); + if file_path.exists() { + if let Err(e) = watcher.watch(&file_path, RecursiveMode::NonRecursive) { + warn!("Failed to watch config file {}: {}", file_name, e); + } + } + } + + let workspace_path_clone = workspace_path.clone(); + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + Self::handle_file_event(event, &workspace_path_clone, &on_config_changed); + } + }); + + info!("Config file watcher started"); + + Ok(Self { + workspace_path, + _watcher: watcher, + }) + } + + /// Handles file change events. + fn handle_file_event( + event: Event, + _workspace_path: &Path, + on_config_changed: &Arc, + ) { + if !matches!(event.kind, EventKind::Modify(_)) { + return; + } + + for path in event.paths { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + let language = Self::config_file_to_language(file_name); + + if let Some(lang) = language { + info!( + "Config file changed: {}, restarting {} server", + file_name, lang + ); + on_config_changed(lang.to_string(), file_name.to_string()); + } + } + } + } + + /// Infers a language from a configuration filename. + fn config_file_to_language(file_name: &str) -> Option<&'static str> { + match file_name { + "tsconfig.json" | "package.json" => Some("typescript"), + "Cargo.toml" => Some("rust"), + "pyproject.toml" | "setup.py" => Some("python"), + "go.mod" => Some("go"), + "pom.xml" | "build.gradle" => Some("java"), + "CMakeLists.txt" => Some("cpp"), + ".eslintrc.json" | ".eslintrc.js" => Some("javascript"), + _ => None, + } + } +} + +impl Drop for ConfigWatcher { + fn drop(&mut self) { + debug!( + "ConfigWatcher dropped for workspace: {:?}", + self.workspace_path + ); + } +} diff --git a/src/crates/services/services-core/src/lsp/debouncer.rs b/src/crates/services/services-core/src/lsp/debouncer.rs new file mode 100644 index 0000000000..d7dbd04035 --- /dev/null +++ b/src/crates/services/services-core/src/lsp/debouncer.rs @@ -0,0 +1,86 @@ +//! LSP request debouncer +//! +//! Prevents sending a burst of duplicate requests in a short time, improving performance and +//! stability. + +use log::debug; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// Request debouncer. +pub struct RequestDebouncer { + /// Last request time (`uri + method -> last_time`). + last_requests: Arc>>, + /// Debounce delay (milliseconds). + debounce_ms: u64, +} + +impl RequestDebouncer { + /// Creates a new debouncer. + pub fn new(debounce_ms: u64) -> Self { + Self { + last_requests: Arc::new(RwLock::new(HashMap::new())), + debounce_ms, + } + } + + /// Returns whether a request should be sent. + /// Returns `true` if it can be sent, or `false` if it should be skipped (too frequent). + pub async fn should_send(&self, uri: &str, method: &str) -> bool { + let key = format!("{}:{}", uri, method); + let now = Instant::now(); + + let mut requests = self.last_requests.write().await; + + if let Some(last_time) = requests.get(&key) { + let elapsed = now.duration_since(*last_time); + if elapsed < Duration::from_millis(self.debounce_ms) { + debug!( + "Request debounced: {} ({}ms elapsed)", + key, + elapsed.as_millis() + ); + return false; + } + } + + requests.insert(key, now); + true + } + + /// Cleans up expired records (call periodically). + pub async fn cleanup(&self, max_age: Duration) { + let mut requests = self.last_requests.write().await; + let now = Instant::now(); + + requests.retain(|_, last_time| now.duration_since(*last_time) < max_age); + } +} + +#[cfg(test)] +mod tests { + use super::RequestDebouncer; + + #[tokio::test] + async fn rejects_duplicate_request_inside_debounce_window() { + let debouncer = RequestDebouncer::new(1_000); + + assert!( + debouncer + .should_send("file:///a.rs", "textDocument/hover") + .await + ); + assert!( + !debouncer + .should_send("file:///a.rs", "textDocument/hover") + .await + ); + assert!( + debouncer + .should_send("file:///a.rs", "textDocument/definition") + .await + ); + } +} diff --git a/src/crates/services/services-core/src/lsp/manager.rs b/src/crates/services/services-core/src/lsp/manager.rs new file mode 100644 index 0000000000..3430f6b2bc --- /dev/null +++ b/src/crates/services/services-core/src/lsp/manager.rs @@ -0,0 +1,711 @@ +//! LSP protocol-layer manager + +use anyhow::{anyhow, Result}; +use log::{debug, error, info, warn}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::lsp::plugin_loader::PluginLoader; +use crate::lsp::process::{ + CrashCallback, DiagnosticsCallback, LspServerProcess, ProgressCallback, TokenCreateCallback, +}; +use crate::lsp::{LspSupportedExtensions, PluginRegistry}; +use bitfun_core_types::lsp::{CompletionItem, CompletionList, InlayHint, LspPlugin}; + +/// LSP protocol-layer manager (stateless, pure protocol implementation). +pub struct LspManager { + /// Plugin loader. + plugin_loader: PluginLoader, + /// Plugin registry. + registry: Arc>, + /// Running LSP server processes (`language -> process`). + processes: Arc>>>, + /// Diagnostics cache (`uri -> diagnostics`). + diagnostics_cache: Arc>>>, +} + +impl LspManager { + /// Creates a new LSP manager. + pub fn new(plugins_dir: PathBuf) -> Self { + Self { + plugin_loader: PluginLoader::new(plugins_dir), + registry: Arc::new(RwLock::new(PluginRegistry::new())), + processes: Arc::new(RwLock::new(HashMap::new())), + diagnostics_cache: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Initializes the manager (loads installed plugins). + pub async fn initialize(&self) -> Result<()> { + info!("Initializing LSP Manager"); + + if let Err(e) = self.plugin_loader.cleanup_temp_dirs().await { + warn!("Failed to cleanup temp directories: {}", e); + } + + let plugins = self.plugin_loader.load_all_plugins().await?; + + for plugin in plugins { + if let Err(e) = self.register_plugin_internal(plugin).await { + error!("Failed to register plugin: {}", e); + } + } + + let count = { + let registry = self.registry.read().await; + registry.count() + }; + + info!("LSP Manager initialized with {} plugin(s)", count); + + Ok(()) + } + + // Note: workspace root path management has been moved to WorkspaceLspManager. + // LspManager is responsible for protocol-layer operations only. + + /// Registers a plugin (internal). + async fn register_plugin_internal(&self, plugin: LspPlugin) -> Result<()> { + let mut registry = self.registry.write().await; + registry.register(plugin)?; + Ok(()) + } + + /// Installs a plugin. + pub async fn install_plugin(&self, package_path: PathBuf) -> Result { + info!("Installing plugin from: {:?}", package_path); + + let plugin_id = self + .plugin_loader + .install_plugin_package(&package_path) + .await?; + + let plugin = self.plugin_loader.load_plugin(&plugin_id).await?; + + { + let mut registry = self.registry.write().await; + registry.register(plugin)?; + } + + info!("Plugin installed and registered: {}", plugin_id); + + Ok(plugin_id) + } + + /// Uninstalls a plugin. + pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { + info!("Uninstalling plugin: {}", plugin_id); + + if let Err(e) = self.stop_server(plugin_id).await { + warn!("Failed to stop server for {}: {}", plugin_id, e); + } + + { + let mut registry = self.registry.write().await; + registry.unregister(plugin_id)?; + } + + self.plugin_loader.uninstall_plugin(plugin_id).await?; + + info!("Plugin uninstalled: {}", plugin_id); + + Ok(()) + } + + /// Starts an LSP server. + /// workspace_root: Workspace root path, provided by the caller (WorkspaceLspManager). + /// crash_callback: Callback invoked when the process crashes. + /// progress_callback: Indexing progress callback. + /// token_create_callback: Token creation callback. + /// diagnostics_callback: Diagnostics callback. + pub async fn start_server( + &self, + language: &str, + workspace_root: Option, + crash_callback: Option, + progress_callback: Option, + token_create_callback: Option, + diagnostics_callback: Option, + ) -> Result<()> { + let plugin = { + let registry = self.registry.read().await; + match registry.find_by_language(language).cloned() { + Some(plugin) => plugin, + None => { + let err = anyhow!("No LSP plugin found for language: {}", language); + warn!("{} (this is expected for plaintext)", err); + return Err(err); + } + } + }; + + let plugin_id = plugin.id.clone(); + + { + let processes = self.processes.read().await; + if processes.contains_key(language) { + return Ok(()); + } + } + + let server_path = self.plugin_loader.get_server_path(&plugin).map_err(|e| { + error!("Failed to get server path: {}", e); + e + })?; + + let process = LspServerProcess::spawn( + plugin_id.clone(), + server_path.clone(), + &plugin.server, + crash_callback, + progress_callback, + token_create_callback, + diagnostics_callback, + ) + .await + .map_err(|e| { + error!("Failed to spawn process: {}", e); + e + })?; + + let root_uri = workspace_root.and_then(|p| p.to_str().map(|s| s.to_string())); + + process.initialize(root_uri.clone()).await.map_err(|e| { + error!("Failed to initialize LSP connection: {}", e); + e + })?; + + { + let mut processes = self.processes.write().await; + processes.insert(language.to_string(), Arc::new(process)); + } + + info!("LSP server started successfully: {}", language); + Ok(()) + } + + /// Stops an LSP server. + pub async fn stop_server(&self, language: &str) -> Result<()> { + debug!("Stopping LSP server: {}", language); + + let mut processes = self.processes.write().await; + if let Some(process) = processes.remove(language) { + if let Err(e) = process.shutdown().await { + warn!("Failed to shutdown server {}: {}", language, e); + } + } + + info!("LSP server stopped: {}", language); + Ok(()) + } + + /// Returns whether the server is running. + pub async fn is_server_running(&self, language: &str) -> bool { + let processes = self.processes.read().await; + processes.contains_key(language) + } + + /// Returns whether the server process is alive. + pub async fn is_server_alive(&self, language: &str) -> bool { + let processes = self.processes.read().await; + if let Some(process) = processes.get(language) { + process.is_alive().await + } else { + false + } + } + + /// Gets the server process (internal use). + async fn get_process(&self, language: &str) -> Result> { + let processes = self.processes.read().await; + processes + .get(language) + .cloned() + .ok_or_else(|| anyhow!("LSP server not running for: {}", language)) + } + + /// Lists all installed plugins. + pub async fn list_plugins(&self) -> Vec { + let registry = self.registry.read().await; + registry.list_all().into_iter().cloned().collect() + } + + /// Gets plugin information. + pub async fn get_plugin(&self, plugin_id: &str) -> Option { + let registry = self.registry.read().await; + registry.get_plugin(plugin_id).cloned() + } + + /// Finds a plugin by language. + pub async fn find_plugin_by_language(&self, language: &str) -> Option { + let registry = self.registry.read().await; + registry.find_by_language(language).cloned() + } + + /// Finds a plugin by file path. + pub async fn find_plugin_by_file(&self, file_path: &str) -> Option { + let registry = self.registry.read().await; + 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"); + + let plugin_ids: Vec = { + let processes = self.processes.read().await; + processes.keys().cloned().collect() + }; + + for plugin_id in plugin_ids { + if let Err(e) = self.stop_server(&plugin_id).await { + error!("Failed to stop server {}: {}", plugin_id, e); + } + } + + info!("All LSP servers stopped"); + + Ok(()) + } + + /// Shuts down all servers (alias). + pub async fn stop_all_servers(&self) -> Result<()> { + self.shutdown().await + } + + /// Document open notification (protocol-only; does not include startup logic). + pub async fn did_open(&self, language: &str, uri: &str, text: &str) -> Result<()> { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri, + "languageId": language, + "version": 1, + "text": text + } + }); + + process + .send_notification("textDocument/didOpen", Some(params)) + .await + } + + /// Document change notification. + pub async fn did_change( + &self, + language: &str, + uri: &str, + version: i32, + text: &str, + ) -> Result<()> { + let process = self.get_process(language).await?; + + let content_len = text.len(); + debug!( + "Sending didChange to LSP: lang={}, uri={}, version={}, size={} bytes", + language, uri, version, content_len + ); + + let params = serde_json::json!({ + "textDocument": { + "uri": uri, + "version": version + }, + "contentChanges": [{ + "text": text + }] + }); + + process + .send_notification("textDocument/didChange", Some(params)) + .await + } + + /// Document save notification. + pub async fn did_save(&self, language: &str, uri: &str) -> Result<()> { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + } + }); + + process + .send_notification("textDocument/didSave", Some(params)) + .await + } + + /// Document close notification. + pub async fn did_close(&self, language: &str, uri: &str) -> Result<()> { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + } + }); + + process + .send_notification("textDocument/didClose", Some(params)) + .await + } + + /// Gets code completion (protocol-only). + pub async fn get_completions( + &self, + language: &str, + uri: &str, + line: u32, + character: u32, + ) -> Result> { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "position": { + "line": line, + "character": character + } + }); + + let result = process + .send_request("textDocument/completion", Some(params)) + .await?; + + let items = if let Ok(list) = serde_json::from_value::(result.clone()) { + list.items + } else if let Ok(items) = serde_json::from_value::>(result.clone()) { + items + } else { + warn!("Unexpected completion response format, returning empty list"); + Vec::new() + }; + + Ok(items) + } + + /// Go to definition (protocol-only). + pub async fn goto_definition( + &self, + language: &str, + uri: &str, + line: u32, + character: u32, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "position": { + "line": line, + "character": character + } + }); + + process + .send_request("textDocument/definition", Some(params)) + .await + } + + /// Gets hover information. + pub async fn get_hover( + &self, + language: &str, + uri: &str, + line: u32, + character: u32, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "position": { + "line": line, + "character": character + } + }); + + process + .send_request("textDocument/hover", Some(params)) + .await + } + + /// Finds references. + pub async fn find_references( + &self, + language: &str, + uri: &str, + line: u32, + character: u32, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "position": { + "line": line, + "character": character + }, + "context": { + "includeDeclaration": true + } + }); + + process + .send_request("textDocument/references", Some(params)) + .await + } + + /// Gets code actions. + pub async fn get_code_actions( + &self, + language: &str, + uri: &str, + range: serde_json::Value, + context: serde_json::Value, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "range": range, + "context": context + }); + + process + .send_request("textDocument/codeAction", Some(params)) + .await + } + + /// Formats a document. + pub async fn format_document( + &self, + language: &str, + uri: &str, + tab_size: u32, + insert_spaces: bool, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "options": { + "tabSize": tab_size, + "insertSpaces": insert_spaces + } + }); + + process + .send_request("textDocument/formatting", Some(params)) + .await + } + + /// Gets inlay hints. + pub async fn get_inlay_hints( + &self, + language: &str, + uri: &str, + start_line: u32, + start_character: u32, + end_line: u32, + end_character: u32, + ) -> Result> { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "range": { + "start": { + "line": start_line, + "character": start_character + }, + "end": { + "line": end_line, + "character": end_character + } + } + }); + + let result = process + .send_request("textDocument/inlayHint", Some(params)) + .await?; + + if result.is_null() { + return Ok(vec![]); + } + + let hints: Vec = serde_json::from_value(result) + .map_err(|e| anyhow!("Failed to parse inlay hints: {}", e))?; + + Ok(hints) + } + + /// Renames a symbol. + pub async fn rename( + &self, + language: &str, + uri: &str, + line: u32, + character: u32, + new_name: &str, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "position": { + "line": line, + "character": character + }, + "newName": new_name + }); + + process + .send_request("textDocument/rename", Some(params)) + .await + } + + /// Gets document highlights (Document Highlight). + /// Used to highlight all references of the symbol at the cursor. + pub async fn get_document_highlight( + &self, + language: &str, + uri: &str, + line: u32, + character: u32, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "position": { + "line": line, + "character": character + } + }); + + process + .send_request("textDocument/documentHighlight", Some(params)) + .await + } + + /// Gets document symbols (Document Symbols). + /// Used for outlines, symbol navigation, etc. + pub async fn get_document_symbols( + &self, + language: &str, + uri: &str, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + } + }); + + process + .send_request("textDocument/documentSymbol", Some(params)) + .await + } + + /// Gets semantic tokens (Semantic Tokens). + /// Used for semantic-level syntax highlighting. + pub async fn get_semantic_tokens( + &self, + language: &str, + uri: &str, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + } + }); + + process + .send_request("textDocument/semanticTokens/full", Some(params)) + .await + } + + /// Gets semantic tokens range (Semantic Tokens Range). + /// Used for incremental updates to semantic highlighting. + pub async fn get_semantic_tokens_range( + &self, + language: &str, + uri: &str, + range: serde_json::Value, + ) -> Result { + let process = self.get_process(language).await?; + + let params = serde_json::json!({ + "textDocument": { + "uri": uri + }, + "range": range + }); + + process + .send_request("textDocument/semanticTokens/range", Some(params)) + .await + } + + /// Returns server capabilities. + pub async fn get_server_capabilities(&self, language: &str) -> Result { + let process = self.get_process(language).await?; + + let capabilities = process + .get_capabilities() + .await + .ok_or_else(|| anyhow!("Server capabilities not available"))?; + + Ok(capabilities) + } + + /// Gets diagnostics for a file (from cache). + pub async fn get_diagnostics(&self, uri: &str) -> Vec { + let cache = self.diagnostics_cache.read().await; + cache.get(uri).cloned().unwrap_or_default() + } + + /// Updates the diagnostics cache (called by `diagnostics_callback`). + pub async fn update_diagnostics_cache(&self, uri: String, diagnostics: Vec) { + let mut cache = self.diagnostics_cache.write().await; + cache.insert(uri, diagnostics); + } +} + +impl Drop for LspManager { + fn drop(&mut self) { + debug!("Dropping LSP Manager"); + } +} diff --git a/src/crates/services/services-core/src/lsp/plugin_loader.rs b/src/crates/services/services-core/src/lsp/plugin_loader.rs new file mode 100644 index 0000000000..d8f024fb36 --- /dev/null +++ b/src/crates/services/services-core/src/lsp/plugin_loader.rs @@ -0,0 +1,268 @@ +//! LSP plugin loader +//! +//! Responsible for loading and installing plugins from the filesystem. + +use anyhow::{anyhow, Result}; +use log::{debug, error, info, warn}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +use bitfun_core_types::lsp::LspPlugin; + +/// Plugin loader. +pub struct PluginLoader { + /// Plugins directory. + plugins_dir: PathBuf, +} + +impl PluginLoader { + /// Creates a new plugin loader. + pub fn new(plugins_dir: PathBuf) -> Self { + Self { plugins_dir } + } + + /// Loads a specific plugin. + pub async fn load_plugin(&self, plugin_id: &str) -> Result { + let plugin_dir = self.plugins_dir.join(plugin_id); + let manifest_path = plugin_dir.join("manifest.json"); + + if !manifest_path.exists() { + return Err(anyhow!( + "Plugin manifest not found: {}", + manifest_path.display() + )); + } + + let content = fs::read_to_string(&manifest_path).await?; + let plugin: LspPlugin = serde_json::from_str(&content) + .map_err(|e| anyhow!("Failed to parse manifest: {}", e))?; + + if plugin.id != plugin_id { + return Err(anyhow!( + "Plugin ID mismatch: expected '{}', found '{}'", + plugin_id, + plugin.id + )); + } + + info!("Plugin loaded: {} v{}", plugin.name, plugin.version); + debug!("Supported languages: {:?}", plugin.languages); + debug!("File extensions: {:?}", plugin.file_extensions); + + Ok(plugin) + } + + /// Loads all installed plugins. + pub async fn load_all_plugins(&self) -> Result> { + if !self.plugins_dir.exists() { + fs::create_dir_all(&self.plugins_dir).await?; + info!("Created plugins directory: {:?}", self.plugins_dir); + return Ok(vec![]); + } + + let mut plugins = Vec::new(); + let mut entries = fs::read_dir(&self.plugins_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + + if path.is_dir() { + if let Some(plugin_id) = path.file_name().and_then(|n| n.to_str()) { + if plugin_id.starts_with('.') { + continue; + } + + if plugin_id == "temp" || plugin_id == "cache" || plugin_id == "backup" { + continue; + } + + match self.load_plugin(plugin_id).await { + Ok(plugin) => { + plugins.push(plugin); + } + Err(e) => { + error!("Failed to load plugin '{}': {}", plugin_id, e); + } + } + } + } + } + + info!("Successfully loaded {} plugin(s)", plugins.len()); + + Ok(plugins) + } + + /// Installs a plugin package (a `.vcpkg` file). + pub async fn install_plugin_package(&self, package_path: &Path) -> Result { + info!("Installing plugin package: {:?}", package_path); + + if !package_path.exists() { + error!("Plugin package not found: {:?}", package_path); + return Err(anyhow!("Plugin package not found: {:?}", package_path)); + } + + if package_path.extension().and_then(|e| e.to_str()) != Some("vcpkg") { + error!("Invalid plugin package format (expected .vcpkg)"); + return Err(anyhow!("Invalid plugin package format (expected .vcpkg)")); + } + + let temp_id = format!(".temp-{}", std::process::id()); + let temp_dir = self.plugins_dir.join(&temp_id); + + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir).await?; + } + + fs::create_dir_all(&temp_dir).await?; + + let file = std::fs::File::open(package_path)?; + let mut archive = zip::ZipArchive::new(file)?; + + let mut manifest_content = String::new(); + { + let mut manifest_file = archive.by_name("manifest.json")?; + std::io::Read::read_to_string(&mut manifest_file, &mut manifest_content)?; + } + + let plugin: LspPlugin = serde_json::from_str(&manifest_content)?; + let plugin_id = plugin.id.clone(); + + let plugin_dir = self.plugins_dir.join(&plugin_id); + if plugin_dir.exists() { + return Err(anyhow!("Plugin already installed: {}", plugin_id)); + } + + archive.extract(&plugin_dir)?; + + if temp_dir.exists() { + let _ = fs::remove_dir_all(&temp_dir).await; + } + + info!( + "Plugin installed: {} v{} (id: {})", + plugin.name, plugin.version, plugin_id + ); + + Ok(plugin_id) + } + + /// Uninstalls a plugin. + pub async fn uninstall_plugin(&self, plugin_id: &str) -> Result<()> { + info!("Uninstalling plugin: {}", plugin_id); + + let plugin_dir = self.plugins_dir.join(plugin_id); + + if !plugin_dir.exists() { + error!("Plugin not found: {}", plugin_id); + return Err(anyhow!("Plugin not found: {}", plugin_id)); + } + + fs::remove_dir_all(&plugin_dir).await?; + + info!("Plugin uninstalled successfully: {}", plugin_id); + + Ok(()) + } + + /// Cleans up temporary directories. + pub async fn cleanup_temp_dirs(&self) -> Result<()> { + let mut entries = fs::read_dir(&self.plugins_dir).await?; + let mut cleaned_count = 0; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + + if path.is_dir() { + if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) { + if dir_name.starts_with(".temp") { + if let Err(e) = fs::remove_dir_all(&path).await { + warn!("Failed to remove temp directory {}: {}", dir_name, e); + } else { + cleaned_count += 1; + } + } + } + } + } + + if cleaned_count > 0 { + info!("Cleaned {} temporary director(ies)", cleaned_count); + } + + Ok(()) + } + + /// Returns the plugin server executable path. + pub fn get_server_path(&self, plugin: &LspPlugin) -> Result { + let plugin_dir = self.plugins_dir.join(&plugin.id); + + let command = + crate::lsp::resolve_plugin_command_for_current_target(&plugin.server.command)?; + + let command = command.replace('/', std::path::MAIN_SEPARATOR_STR); + + let server_path = plugin_dir.join(&command); + + if !server_path.exists() { + #[cfg(windows)] + { + let mut server_path = server_path.clone(); + let extensions = vec![".exe", ".bat", ".cmd"]; + let mut found = false; + + for ext in extensions { + let path_with_ext = plugin_dir.join(format!("{}{}", command, ext)); + + if path_with_ext.exists() { + server_path = path_with_ext; + found = true; + break; + } + } + + if !found { + error!("LSP server binary not found at: {:?}", server_path); + error!("Tried extensions: .exe, .bat, .cmd"); + error!("Plugin directory: {:?}", plugin_dir); + return Err(anyhow!( + "LSP server binary not found: {}\nTried: {}.exe, {}.bat, {}.cmd", + server_path.display(), + command, + command, + command + )); + } + } + + #[cfg(not(windows))] + { + error!("LSP server binary not found: {:?}", server_path); + return Err(anyhow!( + "LSP server binary not found: {}", + server_path.display() + )); + } + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&server_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&server_path, perms)?; + } + + Ok(server_path) + } + + /// Returns the plugin directory path. + pub fn get_plugin_dir(&self, plugin_id: &str) -> PathBuf { + self.plugins_dir.join(plugin_id) + } + + /// Returns the plugins root directory. + pub fn get_plugins_root(&self) -> &Path { + &self.plugins_dir + } +} diff --git a/src/crates/services/services-core/src/lsp/process.rs b/src/crates/services/services-core/src/lsp/process.rs new file mode 100644 index 0000000000..fedfa33479 --- /dev/null +++ b/src/crates/services/services-core/src/lsp/process.rs @@ -0,0 +1,1131 @@ +//! LSP server process management +//! +//! Manages the lifecycle of a single LSP server process. + +use anyhow::{anyhow, Result}; +use log::{debug, error, info, warn}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::io::BufReader; +use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout}; +use tokio::sync::{mpsc, oneshot, RwLock}; +use tokio::time::{timeout, Duration}; + +use crate::lsp::protocol::{ + create_notification, create_request, extract_result, read_message, write_message, +}; +use bitfun_core_types::lsp::{ + ClientCapabilities, InitializeParams, InitializeResult, JsonRpcError, JsonRpcMessage, + JsonRpcResponse, RuntimeType, ServerConfig, WorkspaceFolder, +}; + +/// Process crash callback type. +pub type CrashCallback = Arc; + +/// Progress notification callback type. +/// Parameters: `(kind: "begin" | "report" | "end", token: String, percentage: Option, message: String)`. +pub type ProgressCallback = Arc, String) + Send + Sync>; + +/// Token creation callback type. +/// Parameters: `(token: String)`. +pub type TokenCreateCallback = Arc; + +/// Diagnostics callback type. +/// Parameters: `(uri: String, diagnostics: Vec)`. +pub type DiagnosticsCallback = Arc) + Send + Sync>; + +/// LSP server process. +pub struct LspServerProcess { + /// Plugin ID. + pub id: String, + /// Child process. + child: Arc>, + /// Standard input. + stdin: Arc>, + /// Request ID counter. + request_id: Arc, + /// Pending requests waiting for a response. + pending_requests: Arc>>>, + /// Notification sender. + notification_tx: mpsc::UnboundedSender, + /// Server capabilities. + capabilities: Arc>>, + /// Crash callback. + crash_callback: Option, + /// Progress callback. + progress_callback: Option, + /// Token creation callback. + token_create_callback: Option, + /// Diagnostics callback. + diagnostics_callback: Option, +} + +impl LspServerProcess { + /// Spawns a new LSP server process. + pub async fn spawn( + id: String, + server_bin: PathBuf, + config: &ServerConfig, + crash_callback: Option, + progress_callback: Option, + token_create_callback: Option, + diagnostics_callback: Option, + ) -> Result { + info!("Spawning LSP server: {} at {:?}", id, server_bin); + debug!( + "LSP config - args: {:?}, env: {:?}", + config.args, config.env + ); + + if !server_bin.exists() { + error!("LSP server binary not found: {:?}", server_bin); + return Err(anyhow!("LSP server binary not found: {:?}", server_bin)); + } + + let runtime_type = Self::detect_runtime_type(config, &server_bin); + debug!("Detected runtime type: {:?}", runtime_type); + + let mut cmd = Self::build_command(&runtime_type, &server_bin, config)?; + + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| { + error!("Failed to spawn LSP server {}: {}", id, e); + anyhow!("Failed to spawn LSP server {}: {}", id, e) + })?; + + if let Some(pid) = child.id() { + debug!("LSP server process started with PID: {}", pid); + } + + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to capture stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to capture stdout"))?; + + let stderr = child + .stderr + .take() + .ok_or_else(|| anyhow!("Failed to capture stderr"))?; + + let (notification_tx, notification_rx) = mpsc::unbounded_channel(); + + let process = Self { + id: id.clone(), + child: Arc::new(RwLock::new(child)), + stdin: Arc::new(RwLock::new(stdin)), + request_id: Arc::new(AtomicU64::new(1)), + pending_requests: Arc::new(RwLock::new(HashMap::new())), + notification_tx, + capabilities: Arc::new(RwLock::new(None)), + crash_callback, + progress_callback, + token_create_callback, + diagnostics_callback, + }; + + process.start_read_task(stdout).await; + + process.start_stderr_task(stderr).await; + + process.start_notification_task(notification_rx).await; + + info!("LSP server process spawned: {}", id); + + Ok(process) + } + + /// Starts the message reader task. + async fn start_read_task(&self, stdout: ChildStdout) { + let pending_requests = self.pending_requests.clone(); + let notification_tx = self.notification_tx.clone(); + let id = self.id.clone(); + let crash_callback = self.crash_callback.clone(); + + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut consecutive_timeouts = 0; + const MAX_CONSECUTIVE_TIMEOUTS: u32 = 3; + + loop { + match timeout(Duration::from_secs(30), read_message(&mut reader)).await { + Ok(Ok(message)) => { + consecutive_timeouts = 0; + + match &message { + JsonRpcMessage::Response(response) => { + let request_id = response.id; + let mut pending = pending_requests.write().await; + + if let Some(sender) = pending.remove(&request_id) { + let _ = sender.send(response.clone()); + } else { + warn!( + "[{}] Received response for unknown request ID: {}", + id, request_id + ); + } + } + JsonRpcMessage::Notification(_) => { + if let Err(e) = notification_tx.send(message) { + error!("[{}] Failed to send notification: {}", id, e); + break; + } + } + JsonRpcMessage::Request(_req) => { + if let Err(e) = notification_tx.send(message) { + error!("[{}] Failed to send request: {}", id, e); + break; + } + } + } + } + Ok(Err(e)) => { + error!("[{}] Failed to read message: {}", id, e); + error!("[{}] This usually means the LSP server is outputting non-protocol data to stdout", id); + break; + } + Err(_) => { + consecutive_timeouts += 1; + + if consecutive_timeouts >= MAX_CONSECUTIVE_TIMEOUTS { + warn!( + "[{}] No LSP messages for {}s (this is normal if idle)", + id, + 30 * MAX_CONSECUTIVE_TIMEOUTS + ); + + consecutive_timeouts = 0; + } + } + } + } + + error!("LSP server read task ended abnormally: {}", id); + + { + let mut pending = pending_requests.write().await; + let count = pending.len(); + if count > 0 { + warn!("Dropping {} pending request(s) for server {}", count, id); + } + pending.clear(); + } + + if let Some(callback) = crash_callback { + error!("Invoking crash callback - server connection lost: {}", id); + callback(id.clone()); + } + }); + } + + /// Starts the stderr reader task. + /// + /// This task continuously reads the LSP server's stderr output to prevent the pipe buffer from + /// filling up and blocking the process. + /// The LSP protocol specifies using stdout for protocol communication; stderr is used for the + /// server's diagnostic logs. + async fn start_stderr_task(&self, stderr: ChildStderr) { + let id = self.id.clone(); + + tokio::spawn(async move { + use tokio::io::AsyncBufReadExt; + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + let mut line_count = 0; + let mut error_count = 0; + let mut warn_count = 0; + + let mut missing_cmake = false; + let mut missing_spectre = false; + let mut build_script_errors = std::collections::HashSet::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim(); + if !trimmed.is_empty() { + line_count += 1; + + let lower = trimmed.to_lowercase(); + + if lower.contains("missing dependency: cmake") + || (lower.contains("failed to spawn") && lower.contains("cmake")) + { + if !missing_cmake { + missing_cmake = true; + warn!("[{}] Missing build dependency: CMake not installed or not in PATH", id); + info!("[{}] Tip: Some Rust crates require CMake to compile C/C++ code. Download: https://cmake.org/download/", id); + } + continue; + } + + if lower.contains("no spectre-mitigated libs") { + if !missing_spectre { + missing_spectre = true; + warn!("[{}] Missing build dependency: MSVC Spectre mitigation libraries not installed", id); + info!("[{}] Tip: Some Rust crates require MSVC Spectre libraries. Install via Visual Studio Installer", id); + } + continue; + } + + if lower.contains("failed to run custom build command") { + if let Some(start) = trimmed.find("for `") { + if let Some(end) = trimmed[start + 5..].find('`') { + let package = &trimmed[start + 5..start + 5 + end]; + if build_script_errors.insert(package.to_string()) { + warn!("[{}] Build script failed for package: {} (LSP may still work but code analysis accuracy may be affected)", id, package); + } + } + } + continue; + } + + if lower.contains("compiling") + || lower.contains("building") + || lower.contains("cargo:rerun-if") + { + continue; + } + + if lower.contains("panic") { + error_count += 1; + if error_count <= 3 { + debug!("[{}] Build script panic: {}", id, trimmed); + } + continue; + } + + if lower.contains("error") || lower.contains("fatal") { + error_count += 1; + + if error_count <= 5 { + error!("[{}] stderr: {}", id, trimmed); + } else if error_count % 10 == 0 { + error!("[{}] stderr: ... (omitted {} errors)", id, error_count); + } + } else if lower.contains("warn") || lower.contains("warning") { + warn_count += 1; + + if warn_count <= 10 { + warn!("[{}] stderr: {}", id, trimmed); + } else if warn_count % 100 == 0 { + warn!("[{}] stderr: ... (omitted {} warnings)", id, warn_count); + } + } else { + if line_count <= 5 || line_count % 1000 == 0 { + debug!("[{}] stderr: {}", id, trimmed); + } + } + } + } + Err(e) => { + error!("Failed to read stderr from {}: {}", id, e); + break; + } + } + } + + if line_count > 0 || error_count > 0 || warn_count > 0 { + info!( + "LSP server stderr task ended: {} (read {} lines, {} errors, {} warnings)", + id, line_count, error_count, warn_count + ); + + if !build_script_errors.is_empty() { + warn!("[{}] {} package(s) had build script failures, but LSP service is still running", id, build_script_errors.len()); + } + + if missing_cmake || missing_spectre { + info!("[{}] Tip: Installing missing dependencies may improve code analysis accuracy", id); + } + } + }); + } + + /// Starts the notification handler task. + async fn start_notification_task( + &self, + mut notification_rx: mpsc::UnboundedReceiver, + ) { + let id = self.id.clone(); + let progress_callback = self.progress_callback.clone(); + let token_create_callback = self.token_create_callback.clone(); + let diagnostics_callback = self.diagnostics_callback.clone(); + let stdin = self.stdin.clone(); + + tokio::spawn(async move { + while let Some(message) = notification_rx.recv().await { + match message { + JsonRpcMessage::Notification(notif) => match notif.method.as_str() { + "$/progress" => { + if let Some(params) = ¬if.params { + let token = params + .get("token") + .and_then(|t| t.as_str()) + .unwrap_or("unknown") + .to_string(); + + if let Some(value) = params.get("value") { + if let Some(kind) = value.get("kind").and_then(|k| k.as_str()) { + match kind { + "begin" => { + let title = value + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or(""); + info!("[{}] Indexing started: {}", id, title); + + if let Some(ref callback) = progress_callback { + callback( + "begin".to_string(), + token.clone(), + Some(0), + title.to_string(), + ); + } + } + "report" => { + let percentage = value + .get("percentage") + .and_then(|p| p.as_u64()); + let message = value + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or(""); + + if let Some(ref callback) = progress_callback { + callback( + "report".to_string(), + token.clone(), + percentage.map(|p| p as u32), + message.to_string(), + ); + } + } + "end" => { + let message = value + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or(""); + info!("[{}] Indexing completed: {}", id, message); + + if let Some(ref callback) = progress_callback { + callback( + "end".to_string(), + token.clone(), + Some(100), + message.to_string(), + ); + } + } + _ => {} + } + } + } + } + } + "textDocument/publishDiagnostics" => { + if let Some(params) = ¬if.params { + if let Some(uri) = params.get("uri").and_then(|u| u.as_str()) { + if let Some(diagnostics_arr) = + params.get("diagnostics").and_then(|d| d.as_array()) + { + let diags: Vec = diagnostics_arr.clone(); + + debug!( + "[{}] Diagnostics: {} items for {}", + id, + diags.len(), + uri + ); + + if let Some(callback) = &diagnostics_callback { + callback(uri.to_string(), diags); + } + } + } + } + } + "window/logMessage" => { + if let Some(params) = ¬if.params { + let msg_type = + params.get("type").and_then(|t| t.as_u64()).unwrap_or(3); + if let Some(msg) = params.get("message").and_then(|m| m.as_str()) { + match msg_type { + 1 => error!("[{}] Server log: {}", id, msg), + 2 => warn!("[{}] Server log: {}", id, msg), + 3 => info!("[{}] Server log: {}", id, msg), + 4 => debug!("[{}] Server log: {}", id, msg), + _ => debug!("[{}] Server log: {}", id, msg), + } + } + } + } + "window/showMessage" => { + if let Some(params) = ¬if.params { + let msg_type = + params.get("type").and_then(|t| t.as_u64()).unwrap_or(3); + if let Some(msg) = params.get("message").and_then(|m| m.as_str()) { + match msg_type { + 1 => error!("[{}] Server message: {}", id, msg), + 2 => warn!("[{}] Server message: {}", id, msg), + 3 => info!("[{}] Server message: {}", id, msg), + 4 => debug!("[{}] Server message: {}", id, msg), + _ => info!("[{}] Server message: {}", id, msg), + } + } + } + } + _ => {} + }, + + JsonRpcMessage::Request(req) => match req.method.as_str() { + "window/workDoneProgress/create" => { + if let Some(params) = &req.params { + if let Some(token) = params.get("token") { + let token_str = token.as_str().unwrap_or("unknown").to_string(); + + if let Some(ref callback) = token_create_callback { + callback(token_str); + } + } + } + + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: Some(serde_json::Value::Null), + error: None, + }; + + let response_message = JsonRpcMessage::Response(response); + let mut stdin_lock = stdin.write().await; + if let Err(e) = write_message(&mut stdin_lock, &response_message).await + { + error!( + "[{}] Failed to send workDoneProgress/create response: {}", + id, e + ); + } + } + "client/registerCapability" => { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: Some(serde_json::Value::Null), + error: None, + }; + + let response_message = JsonRpcMessage::Response(response); + let mut stdin_lock = stdin.write().await; + if let Err(e) = write_message(&mut stdin_lock, &response_message).await + { + error!( + "[{}] Failed to send registerCapability response: {}", + id, e + ); + } + } + "workspace/configuration" => { + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: Some(serde_json::json!([])), + error: None, + }; + + let response_message = JsonRpcMessage::Response(response); + let mut stdin_lock = stdin.write().await; + if let Err(e) = write_message(&mut stdin_lock, &response_message).await + { + error!("[{}] Failed to send configuration response: {}", id, e); + } + } + _ => { + warn!("[{}] Unhandled server request: {}", id, req.method); + + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: req.id, + result: None, + error: Some(JsonRpcError { + code: -32601, + message: format!("Method not supported: {}", req.method), + data: None, + }), + }; + + let response_message = JsonRpcMessage::Response(response); + let mut stdin_lock = stdin.write().await; + if let Err(e) = write_message(&mut stdin_lock, &response_message).await + { + error!("[{}] Failed to send error response: {}", id, e); + } + } + }, + _ => {} + } + } + + info!("LSP notification task ended: {}", id); + }); + } + + /// Sends a request and waits for the response. + pub async fn send_request( + &self, + method: impl Into, + params: Option, + ) -> Result { + let id = self.request_id.fetch_add(1, Ordering::SeqCst); + let method_str = method.into(); + + let message = create_request(id, method_str.clone(), params); + + let (tx, rx) = oneshot::channel(); + + { + let mut pending = self.pending_requests.write().await; + pending.insert(id, tx); + } + + { + let mut stdin = self.stdin.write().await; + write_message(&mut stdin, &message).await?; + } + + let response = timeout(Duration::from_secs(60), rx).await.map_err(|_| { + error!("LSP request timeout after 60s: {}", method_str); + anyhow!( + "LSP request timeout (60s): {}. The LSP server may not be responding.", + method_str + ) + })??; + + extract_result(response) + } + + /// Sends a notification (does not wait for a response). + pub async fn send_notification( + &self, + method: impl Into, + params: Option, + ) -> Result<()> { + let method_str = method.into(); + let message = create_notification(method_str, params); + + let mut stdin = self.stdin.write().await; + write_message(&mut stdin, &message).await?; + + Ok(()) + } + + /// Initializes the server. + pub async fn initialize(&self, workspace_root: Option) -> Result { + info!("Initializing LSP server: {}", self.id); + + let root_uri = workspace_root.as_ref().map(|path| { + if cfg!(windows) { + format!("file:///{}", path.replace('\\', "/")) + } else { + format!("file://{}", path) + } + }); + + let workspace_folders = workspace_root.as_ref().map(|root| { + let uri = if cfg!(windows) { + format!("file:///{}", root.replace('\\', "/")) + } else { + format!("file://{}", root) + }; + + let name = std::path::Path::new(root) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("workspace") + .to_string(); + + vec![WorkspaceFolder { uri, name }] + }); + + let params = InitializeParams { + process_id: Some(std::process::id()), + root_path: None, + root_uri: root_uri.clone(), + capabilities: ClientCapabilities { + window: Some(serde_json::json!({ + "workDoneProgress": true, + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": false + } + }, + "showDocument": { + "support": true + } + })), + + workspace: Some(serde_json::json!({ + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": ["create", "rename", "delete"] + }, + "didChangeConfiguration": { + "dynamicRegistration": false + }, + "didChangeWatchedFiles": { + "dynamicRegistration": false + }, + "symbol": { + "dynamicRegistration": false + }, + "executeCommand": { + "dynamicRegistration": false + }, + "workspaceFolders": true, + "configuration": true + })), + text_document: Some(serde_json::json!({ + "synchronization": { + "dynamicRegistration": false, + "didSave": true, + "willSave": false, + "willSaveWaitUntil": false + }, + "completion": { + "dynamicRegistration": false, + "completionItem": { + "snippetSupport": true, + "commitCharactersSupport": false, + "documentationFormat": ["plaintext", "markdown"], + "deprecatedSupport": false, + "preselectSupport": false + }, + "contextSupport": false + }, + "hover": { + "dynamicRegistration": false, + "contentFormat": ["plaintext", "markdown"] + }, + "signatureHelp": { + "dynamicRegistration": false, + "signatureInformation": { + "documentationFormat": ["plaintext", "markdown"] + } + }, + "definition": { + "dynamicRegistration": false, + "linkSupport": true + }, + "references": { + "dynamicRegistration": false + }, + "documentHighlight": { + "dynamicRegistration": false + }, + "documentSymbol": { + "dynamicRegistration": false, + "hierarchicalDocumentSymbolSupport": true + }, + "codeAction": { + "dynamicRegistration": false, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": ["quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports"] + } + } + }, + "formatting": { + "dynamicRegistration": false + }, + "rangeFormatting": { + "dynamicRegistration": false + }, + "rename": { + "dynamicRegistration": false, + "prepareSupport": false + }, + "publishDiagnostics": { + "relatedInformation": true, + "tagSupport": { + "valueSet": [1, 2] + } + }, + "inlayHint": { + "dynamicRegistration": false, + "resolveSupport": { + "properties": ["tooltip", "textEdits", "label.tooltip", "label.location", "label.command"] + } + } + })), + experimental: None, + }, + + initialization_options: Some(serde_json::json!({ + + "checkOnSave": { + "command": "clippy" + }, + "cargo": { + "allFeatures": true + }, + + })), + + workspace_folders, + }; + + let result = self + .send_request("initialize", Some(serde_json::to_value(params)?)) + .await?; + + let init_result: InitializeResult = serde_json::from_value(result)?; + + { + let mut caps = self.capabilities.write().await; + *caps = Some(serde_json::to_value(&init_result.capabilities)?); + } + + self.send_notification("initialized", Some(serde_json::json!({}))) + .await?; + + info!("LSP server initialized: {}", self.id); + + Ok(init_result) + } + + /// Shuts down the server. + pub async fn shutdown(&self) -> Result<()> { + info!("Shutting down LSP server: {}", self.id); + + let _ = self.send_request("shutdown", None).await; + + let _ = self.send_notification("exit", None).await; + + tokio::time::sleep(Duration::from_millis(500)).await; + + let mut child = self.child.write().await; + let _ = child.kill().await; + + info!("LSP server shut down: {}", self.id); + + Ok(()) + } + + /// Returns server capabilities. + pub async fn get_capabilities(&self) -> Option { + let caps = self.capabilities.read().await; + caps.clone() + } + + /// Returns whether the process is still alive. + pub async fn is_alive(&self) -> bool { + let mut child = self.child.write().await; + match child.try_wait() { + Ok(Some(status)) => { + warn!("[{}] Process has exited with status: {:?}", self.id, status); + false + } + Ok(None) => true, + Err(e) => { + error!("[{}] Failed to check process status: {}", self.id, e); + false + } + } + } + + /// Detects the runtime type. + fn detect_runtime_type(config: &ServerConfig, server_bin: &Path) -> RuntimeType { + if let Some(runtime) = &config.runtime { + debug!("Runtime explicitly specified: {}", runtime); + return match runtime.to_lowercase().as_str() { + "bash" | "sh" => RuntimeType::Bash, + "node" | "nodejs" => RuntimeType::Node, + "exe" | "executable" => RuntimeType::Executable, + _ => { + warn!( + "Unknown runtime type '{}', defaulting to executable", + runtime + ); + RuntimeType::Executable + } + }; + } + + if let Some(ext) = server_bin.extension().and_then(|e| e.to_str()) { + match ext.to_lowercase().as_str() { + "sh" | "bash" => return RuntimeType::Bash, + "js" | "mjs" | "cjs" => return RuntimeType::Node, + _ => {} + } + } + + RuntimeType::Executable + } + + /// Builds the command based on the runtime type. + fn build_command( + runtime_type: &RuntimeType, + server_bin: &PathBuf, + config: &ServerConfig, + ) -> Result { + match runtime_type { + RuntimeType::Executable => { + #[cfg(windows)] + { + if let Some(ext) = server_bin.extension().and_then(|e| e.to_str()) { + let ext_lower = ext.to_lowercase(); + + if ext_lower == "bat" || ext_lower == "cmd" { + debug!( + "Detected batch file (.{}), extracting node command", + ext_lower + ); + + if let Ok(content) = std::fs::read_to_string(server_bin) { + let mut script_path: Option = None; + + for line in content.lines() { + let line = line.trim(); + + if line.starts_with("node ") || line.starts_with("node.exe ") { + info!("Found node execution command: {}", line); + + if let Some(start_quote) = line.find('"') { + if let Some(end_quote) = + line[start_quote + 1..].find('"') + { + let path_expr = &line + [start_quote + 1..start_quote + 1 + end_quote]; + debug!("Extracted path expression: {}", path_expr); + + for prev_line in content.lines() { + let prev_line = prev_line.trim(); + if prev_line.starts_with("set ") + && prev_line.contains( + path_expr + .trim_matches('%') + .split('%') + .next() + .unwrap_or(""), + ) + { + if let Some(eq_pos) = prev_line.find('=') { + let value_part = &prev_line + [eq_pos + 1..] + .trim_matches('"'); + + if let Some(parent) = + server_bin.parent() + { + let mut resolved_path = + parent.to_path_buf(); + + let rel_part = value_part + .replace("%SCRIPT_DIR%", ""); + + for component in + rel_part.split(['\\', '/']) + { + match component { + "" | "." => continue, + ".." => { + resolved_path.pop(); + } + part => { + resolved_path.push(part) + } + } + } + + if resolved_path.exists() { + script_path = + Some(resolved_path); + break; + } else { + warn!("Resolved path does not exist: {:?}", resolved_path); + } + } + } + } + } + } + } + break; + } + } + + if let Some(js_path) = script_path { + let node_cmd = if cfg!(windows) { "node.exe" } else { "node" }; + + let mut cmd = + crate::process_manager::create_tokio_command(node_cmd); + cmd.arg(js_path); + cmd.args(&config.args); + cmd.envs(&config.env); + return Ok(cmd); + } + } + + error!("Failed to extract node command from bat file"); + error!("Bat files cannot be executed directly without cmd wrapper"); + return Err(anyhow!( + "Failed to parse batch file. Please check the plugin installation." + )); + } + } + } + + let mut cmd = crate::process_manager::create_tokio_command(server_bin); + cmd.args(&config.args); + cmd.envs(&config.env); + Ok(cmd) + } + RuntimeType::Bash => { + #[cfg(windows)] + { + let bash_paths = vec![ + "bash.exe", + "C:\\Program Files\\Git\\bin\\bash.exe", + "C:\\Program Files (x86)\\Git\\bin\\bash.exe", + "wsl.exe", + ]; + + let mut bash_exe = None; + for path in &bash_paths { + if crate::process_manager::create_command(path) + .arg("--version") + .output() + .is_ok() + { + bash_exe = Some(path.to_string()); + break; + } + } + + let bash_cmd = bash_exe.ok_or_else(|| { + error!( + "Bash not found on Windows. Searched paths: {:?}", + bash_paths + ); + anyhow!( + "Bash not found on Windows. Please install Git Bash or WSL.\n\ + - Git Bash: https://git-scm.com/download/win\n\ + - WSL: https://docs.microsoft.com/windows/wsl/install" + ) + })?; + + let mut cmd = crate::process_manager::create_tokio_command(&bash_cmd); + cmd.arg(server_bin); + cmd.args(&config.args); + cmd.envs(&config.env); + Ok(cmd) + } + + #[cfg(not(windows))] + { + let mut cmd = crate::process_manager::create_tokio_command("bash"); + cmd.arg(server_bin); + cmd.args(&config.args); + cmd.envs(&config.env); + Ok(cmd) + } + } + RuntimeType::Node => { + let node_cmd = if cfg!(windows) { "node.exe" } else { "node" }; + + match crate::process_manager::create_command(node_cmd) + .arg("--version") + .output() + { + Ok(_) => {} + Err(e) => { + error!("Node.js not found: {}", e); + return Err(anyhow!( + "Node.js not found. Please install Node.js from https://nodejs.org/\n\ + The LSP plugin requires Node.js to be installed and available in PATH." + )); + } + } + + let mut cmd = crate::process_manager::create_tokio_command(node_cmd); + cmd.arg(server_bin); + cmd.args(&config.args); + cmd.envs(&config.env); + Ok(cmd) + } + } + } +} + +impl Drop for LspServerProcess { + fn drop(&mut self) { + debug!("Dropping LSP server process: {}", self.id); + } +} + +#[cfg(test)] +mod tests { + use super::LspServerProcess; + use bitfun_core_types::lsp::{RuntimeType, ServerConfig}; + use std::collections::HashMap; + use std::path::PathBuf; + + fn server_config(runtime: Option<&str>) -> ServerConfig { + ServerConfig { + command: "server".to_string(), + args: Vec::new(), + env: HashMap::new(), + runtime: runtime.map(str::to_string), + } + } + + #[test] + fn detects_runtime_from_manifest_before_extension() { + let config = server_config(Some("node")); + let server_bin = PathBuf::from("server.sh"); + + assert_eq!( + LspServerProcess::detect_runtime_type(&config, &server_bin), + RuntimeType::Node + ); + } + + #[test] + fn detects_runtime_from_server_extension() { + assert_eq!( + LspServerProcess::detect_runtime_type( + &server_config(None), + &PathBuf::from("server.sh") + ), + RuntimeType::Bash + ); + assert_eq!( + LspServerProcess::detect_runtime_type( + &server_config(None), + &PathBuf::from("server.mjs") + ), + RuntimeType::Node + ); + assert_eq!( + LspServerProcess::detect_runtime_type( + &server_config(None), + &PathBuf::from("server.exe") + ), + RuntimeType::Executable + ); + } +} diff --git a/src/crates/services/services-core/src/lsp/project_detector.rs b/src/crates/services/services-core/src/lsp/project_detector.rs new file mode 100644 index 0000000000..1ba1c4c36c --- /dev/null +++ b/src/crates/services/services-core/src/lsp/project_detector.rs @@ -0,0 +1,510 @@ +//! Project type detector +//! +//! Features: +//! - Scans the workspace to identify project types +//! - Detects programming languages in use +//! - Counts files by type +//! - Determines the primary programming language +//! - Supports monorepos and subdirectory project layouts + +use anyhow::Result; +use log::{debug, info}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// Project information. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[derive(Default)] +pub struct ProjectInfo { + /// Detected languages. + pub languages: Vec, + /// Primary language (usually the one with the most files). + pub primary_language: Option, + /// File counts per language. + pub file_counts: HashMap, + /// Project type tags. + pub project_types: Vec, + /// Total file count. + pub total_files: usize, +} + +/// Project type detector. +pub struct ProjectDetector; + +impl ProjectDetector { + /// Detects the project type. + pub async fn detect(workspace_path: &Path) -> Result { + debug!("Detecting project type for: {:?}", workspace_path); + + let mut info = ProjectInfo::default(); + + Self::detect_by_file_extensions(workspace_path, &mut info).await?; + + Self::detect_by_config_files(workspace_path, &mut info).await; + + Self::determine_primary_language(&mut info); + + Self::deduplicate_languages(&mut info); + + info!( + "Project detection complete: languages={:?}, primary={:?}, project_types={:?}", + info.languages, info.primary_language, info.project_types + ); + + Ok(info) + } + + /// Detects project type via config files (supports root and subdirectories). + async fn detect_by_config_files(workspace_path: &Path, info: &mut ProjectInfo) { + Self::detect_root_config_files(workspace_path, info); + + Self::detect_subdirectory_config_files(workspace_path, info).await; + } + + /// Detects config files in the workspace root. + fn detect_root_config_files(workspace_path: &Path, info: &mut ProjectInfo) { + if workspace_path.join("tsconfig.json").exists() { + Self::add_language(info, "typescript"); + Self::add_project_type(info, "typescript"); + } + + if workspace_path.join("package.json").exists() { + if !info.languages.contains(&"typescript".to_string()) { + Self::add_language(info, "javascript"); + } + Self::add_project_type(info, "nodejs"); + } + + if workspace_path.join("Cargo.toml").exists() { + Self::add_language(info, "rust"); + Self::add_project_type(info, "rust"); + } + + if workspace_path.join("pyproject.toml").exists() + || workspace_path.join("setup.py").exists() + || workspace_path.join("requirements.txt").exists() + { + Self::add_language(info, "python"); + Self::add_project_type(info, "python"); + } + + if workspace_path.join("go.mod").exists() { + Self::add_language(info, "go"); + Self::add_project_type(info, "go"); + } + + if workspace_path.join("pom.xml").exists() + || workspace_path.join("build.gradle").exists() + || workspace_path.join("build.gradle.kts").exists() + { + Self::add_language(info, "java"); + Self::add_project_type(info, "java"); + } + + if workspace_path.join("CMakeLists.txt").exists() + || workspace_path.join("Makefile").exists() + || workspace_path.join("meson.build").exists() + { + Self::add_language(info, "cpp"); + Self::add_project_type(info, "cpp"); + } + + if Self::has_file_with_extension(workspace_path, "csproj") + || Self::has_file_with_extension(workspace_path, "fsproj") + || workspace_path.join("global.json").exists() + { + Self::add_language(info, "csharp"); + Self::add_project_type(info, "dotnet"); + } + } + + /// Detects config files in subdirectories (supports monorepo layouts). + async fn detect_subdirectory_config_files(workspace_path: &Path, info: &mut ProjectInfo) { + let subdirectories = [ + "cli", + "src-tauri", + "crates", + "rust", + "backend", + "core", + "lib", + "packages", + "apps", + "frontend", + "web", + "client", + "server", + "api", + "src", + ]; + + for subdir in subdirectories { + let subdir_path = workspace_path.join(subdir); + if !subdir_path.exists() || !subdir_path.is_dir() { + continue; + } + + if subdir_path.join("Cargo.toml").exists() { + Self::add_language(info, "rust"); + Self::add_project_type(info, "rust"); + } + + if subdir_path.join("go.mod").exists() { + Self::add_language(info, "go"); + Self::add_project_type(info, "go"); + } + + if subdir_path.join("pyproject.toml").exists() || subdir_path.join("setup.py").exists() + { + Self::add_language(info, "python"); + Self::add_project_type(info, "python"); + } + + if subdir_path.join("pom.xml").exists() + || subdir_path.join("build.gradle").exists() + || subdir_path.join("build.gradle.kts").exists() + { + Self::add_language(info, "java"); + Self::add_project_type(info, "java"); + } + } + + if let Ok(mut entries) = fs::read_dir(workspace_path).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if matches!( + dir_name, + "node_modules" | "target" | ".git" | "dist" | "build" | "out" + ) { + continue; + } + + if path.join("Cargo.toml").exists() + && !info.languages.contains(&"rust".to_string()) + { + Self::add_language(info, "rust"); + Self::add_project_type(info, "rust"); + } + } + } + } + } + + /// Checks whether a directory contains any file with the given extension. + fn has_file_with_extension(dir: &Path, ext: &str) -> bool { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + if let Some(file_ext) = entry.path().extension() { + if file_ext + .to_str() + .map(|e| e.eq_ignore_ascii_case(ext)) + .unwrap_or(false) + { + return true; + } + } + } + } + false + } + + /// Detects languages by file extension (deep scan with a file count limit). + async fn detect_by_file_extensions( + workspace_path: &Path, + info: &mut ProjectInfo, + ) -> Result<()> { + let mut counts: HashMap = HashMap::new(); + let max_scan_files = 5000; + let mut scanned = 0; + + Self::scan_directory_iterative(workspace_path, &mut counts, &mut scanned, max_scan_files) + .await?; + + info.total_files = scanned; + + for (ext, count) in counts { + let language = Self::extension_to_language(&ext); + if language != "unknown" { + *info.file_counts.entry(language.clone()).or_insert(0) += count; + + let threshold = Self::language_threshold(&language); + if count >= threshold { + Self::add_language(info, &language); + + if count >= 10 { + if let Some(project_type) = Self::language_to_project_type(&language) { + Self::add_project_type(info, project_type); + } + } + } + } + } + + Ok(()) + } + + /// Mapping from language to project types. + fn language_to_project_type(language: &str) -> Option<&'static str> { + match language { + "rust" => Some("rust"), + "python" => Some("python"), + "go" => Some("go"), + "java" => Some("java"), + "kotlin" => Some("kotlin"), + "typescript" => Some("typescript"), + "javascript" => Some("nodejs"), + "cpp" | "c" => Some("cpp"), + "csharp" => Some("dotnet"), + "swift" => Some("swift"), + "ruby" => Some("ruby"), + "php" => Some("php"), + "scala" => Some("scala"), + _ => None, + } + } + + /// Returns the file-count threshold for a language. + fn language_threshold(language: &str) -> usize { + match language { + "rust" | "go" | "java" | "python" | "typescript" | "javascript" => 3, + "json5" | "yaml" | "toml" => 10, + _ => 5, + } + } + + /// Iteratively scans directories (avoids recursion depth limits). + async fn scan_directory_iterative( + root: &Path, + counts: &mut HashMap, + scanned: &mut usize, + max_files: usize, + ) -> Result<()> { + let mut dir_stack: Vec = vec![root.to_path_buf()]; + + while let Some(dir) = dir_stack.pop() { + if *scanned >= max_files { + break; + } + + let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if matches!( + dir_name, + "node_modules" + | "target" + | ".git" + | "dist" + | "build" + | "out" + | "__pycache__" + | ".hvigor" + | "hvigor" + | "vendor" + | ".cargo" + | ".venv" + | "venv" + | "env" + | "screenshots" + | "signature" + ) { + continue; + } + + let mut entries = match fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(_) => continue, + }; + + while let Some(entry) = entries.next_entry().await? { + if *scanned >= max_files { + break; + } + + let path = entry.path(); + let metadata = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + + if metadata.is_dir() { + dir_stack.push(path); + } else if metadata.is_file() { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + *counts.entry(ext.to_lowercase()).or_insert(0) += 1; + } + *scanned += 1; + } + } + } + + Ok(()) + } + + /// Determines the primary language based on file counts. + fn determine_primary_language(info: &mut ProjectInfo) { + if info.primary_language.is_some() { + return; + } + + if info.file_counts.is_empty() { + return; + } + + let programming_languages: HashSet<&str> = [ + "rust", + "python", + "go", + "java", + "javascript", + "typescript", + "cpp", + "c", + "csharp", + "kotlin", + "swift", + "ruby", + "php", + "scala", + ] + .into_iter() + .collect(); + + let primary = info + .file_counts + .iter() + .filter(|(lang, _)| programming_languages.contains(lang.as_str())) + .max_by_key(|(_, count)| *count) + .map(|(lang, _)| lang.clone()); + + if let Some(lang) = primary { + info.primary_language = Some(lang.clone()); + } + } + + /// Deduplicates the language list. + fn deduplicate_languages(info: &mut ProjectInfo) { + let mut seen = HashSet::new(); + info.languages.retain(|lang| seen.insert(lang.clone())); + + let mut seen = HashSet::new(); + info.project_types.retain(|pt| seen.insert(pt.clone())); + } + + /// Adds a language (avoids duplicates). + fn add_language(info: &mut ProjectInfo, language: &str) { + if !info.languages.contains(&language.to_string()) { + info.languages.push(language.to_string()); + } + } + + /// Adds a project type (avoids duplicates). + fn add_project_type(info: &mut ProjectInfo, project_type: &str) { + if !info.project_types.contains(&project_type.to_string()) { + info.project_types.push(project_type.to_string()); + } + } + + /// Mapping from file extension to language. + fn extension_to_language(ext: &str) -> String { + match ext { + "json5" => "json5", + "ts" | "tsx" | "ets" => "typescript", + "js" | "jsx" | "mjs" | "cjs" => "javascript", + "rs" => "rust", + "py" | "pyw" => "python", + "go" => "go", + "java" => "java", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp", + "cs" => "csharp", + "rb" => "ruby", + "php" => "php", + "swift" => "swift", + "kt" | "kts" => "kotlin", + "scala" => "scala", + "sh" | "bash" => "shell", + _ => "unknown", + } + .to_string() + } + + /// Returns whether the server should be pre-started (based on project size). + pub fn should_prestart(info: &ProjectInfo) -> Vec { + let mut languages_to_start = Vec::new(); + + match info.total_files { + 0..=100 => { + languages_to_start.extend(info.languages.clone()); + debug!( + "Small project detected ({} files), will prestart all languages", + info.total_files + ); + } + 101..=1000 => { + if let Some(primary) = &info.primary_language { + languages_to_start.push(primary.clone()); + debug!( + "Medium project detected ({} files), will prestart primary language: {}", + info.total_files, primary + ); + } + } + _ => { + debug!( + "Large project detected ({} files), will use on-demand loading", + info.total_files + ); + } + } + + languages_to_start + } +} + +#[cfg(test)] +mod tests { + use super::ProjectDetector; + use tokio::fs; + + #[tokio::test] + async fn detects_rust_workspace_from_manifest_and_sources() { + let tempdir = tempfile::tempdir().expect("create tempdir"); + let root = tempdir.path(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n", + ) + .await + .expect("write manifest"); + fs::create_dir(root.join("src")) + .await + .expect("create src dir"); + fs::write(root.join("src/lib.rs"), "pub fn sample() {}\n") + .await + .expect("write rust source"); + + let info = ProjectDetector::detect(root).await.expect("detect project"); + + assert!(info.languages.contains(&"rust".to_string())); + assert!(info.project_types.contains(&"rust".to_string())); + } + + #[test] + fn prestarts_all_languages_for_small_projects() { + let info = super::ProjectInfo { + languages: vec!["rust".to_string(), "typescript".to_string()], + total_files: 20, + ..Default::default() + }; + + assert_eq!( + ProjectDetector::should_prestart(&info), + vec!["rust".to_string(), "typescript".to_string()] + ); + } +} diff --git a/src/crates/services/services-core/src/lsp/protocol.rs b/src/crates/services/services-core/src/lsp/protocol.rs new file mode 100644 index 0000000000..67eaeea37e --- /dev/null +++ b/src/crates/services/services-core/src/lsp/protocol.rs @@ -0,0 +1,220 @@ +//! LSP protocol handling +//! +//! Implements encoding and decoding of JSON-RPC messages. + +use anyhow::{anyhow, Result}; +use log::{error, warn}; +use tokio::io::{AsyncWriteExt, BufReader}; +use tokio::process::{ChildStdin, ChildStdout}; + +use bitfun_core_types::lsp::{ + JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, +}; + +/// Reads an LSP message. +/// +/// LSP uses HTTP-style headers: +/// Content-Length: xxx\r\n +/// \r\n +/// {json content} +pub async fn read_message(reader: &mut BufReader) -> Result { + let mut content_length: Option = None; + let mut line_count = 0; + let mut empty_line_count = 0; + let mut found_lsp_header = false; + + const MAX_LINES: usize = 100; + const MAX_EMPTY_LINES: usize = 50; + + loop { + let mut raw_line = Vec::new(); + let _bytes_read = + tokio::io::AsyncBufReadExt::read_until(reader, b'\n', &mut raw_line).await?; + line_count += 1; + + let header = match String::from_utf8(raw_line.clone()) { + Ok(s) => s, + Err(e) => { + warn!( + "[LSP Protocol] Line {} contains non-UTF8 data: {:?}", + line_count, e + ); + + String::from_utf8_lossy(&raw_line).to_string() + } + }; + + let header = header.trim(); + + if line_count > MAX_LINES { + return Err(anyhow!( + "Protocol error: Read {} lines without finding valid LSP header. \ + The LSP server may be outputting non-protocol data to stdout. \ + Check server stderr logs for details.", + line_count + )); + } + + if !found_lsp_header && header.is_empty() { + empty_line_count += 1; + + if empty_line_count > MAX_EMPTY_LINES { + return Err(anyhow!( + "Protocol error: Skipped {} empty lines without finding LSP header. \ + The LSP server stdout may be misconfigured. \ + Ensure the server only outputs LSP protocol messages to stdout.", + empty_line_count + )); + } + + if empty_line_count <= 10 || empty_line_count % 10 == 0 { + warn!( + "[LSP Protocol] Skipped {} empty lines, still waiting for LSP header (will fail after {} empty lines)", + empty_line_count, + MAX_EMPTY_LINES + ); + } + continue; + } + + if found_lsp_header && header.is_empty() { + break; + } + + if header.starts_with("Content-Length:") { + found_lsp_header = true; + let length_str = header + .strip_prefix("Content-Length:") + .ok_or_else(|| anyhow!("Invalid Content-Length header"))? + .trim(); + content_length = Some(length_str.parse()?); + } else if header.starts_with("Content-Type:") { + found_lsp_header = true; + } else if !header.is_empty() { + if found_lsp_header { + warn!("[LSP Protocol] Unexpected header line: {:?}", header); + } else { + if line_count <= 10 { + warn!("[LSP Protocol] Non-LSP output (skipping): {:?}", header); + } + } + } + } + + let content_length = content_length.ok_or_else(|| { + error!( + "[LSP Protocol] Missing Content-Length header after {} lines", + line_count + ); + anyhow!("Missing Content-Length header") + })?; + + let mut buffer = vec![0u8; content_length]; + tokio::io::AsyncReadExt::read_exact(reader, &mut buffer).await?; + + let message: JsonRpcMessage = serde_json::from_slice(&buffer).map_err(|e| { + let content_preview = String::from_utf8_lossy(&buffer); + let preview = if content_preview.len() > 500 { + let pos = content_preview + .char_indices() + .take_while(|(i, _)| *i < 500) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + format!("{}...", &content_preview[..pos]) + } else { + content_preview.to_string() + }; + error!("[LSP Protocol] Failed to parse JSON: {}", e); + error!("[LSP Protocol] Content preview: {}", preview); + anyhow!("Failed to parse JSON: {}", e) + })?; + + Ok(message) +} + +/// Writes an LSP message. +pub async fn write_message(writer: &mut ChildStdin, message: &JsonRpcMessage) -> Result<()> { + let content = serde_json::to_string(message)?; + let content_bytes = content.as_bytes(); + + let header = format!("Content-Length: {}\r\n\r\n", content_bytes.len()); + + writer.write_all(header.as_bytes()).await?; + writer.write_all(content_bytes).await?; + writer.flush().await?; + + Ok(()) +} + +/// Creates a request message. +pub fn create_request( + id: u64, + method: impl Into, + params: Option, +) -> JsonRpcMessage { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id, + method: method.into(), + params, + }) +} + +/// Creates a notification message. +pub fn create_notification( + method: impl Into, + params: Option, +) -> JsonRpcMessage { + JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: method.into(), + params, + }) +} + +/// Extracts the result from a response. +pub fn extract_result(response: JsonRpcResponse) -> Result { + if let Some(error) = response.error { + return Err(anyhow!("LSP Error {}: {}", error.code, error.message)); + } + + response + .result + .ok_or_else(|| anyhow!("Missing result in response")) +} + +#[cfg(test)] +mod tests { + use super::{create_notification, create_request, extract_result}; + use bitfun_core_types::lsp::{JsonRpcMessage, JsonRpcResponse}; + use serde_json::json; + + #[test] + fn creates_request_and_notification_messages() { + assert!(matches!( + create_request(7, "textDocument/hover", Some(json!({ "uri": "file:///a.rs" }))), + JsonRpcMessage::Request(request) + if request.id == 7 && request.method == "textDocument/hover" + )); + + assert!(matches!( + create_notification("textDocument/didOpen", None), + JsonRpcMessage::Notification(notification) + if notification.method == "textDocument/didOpen" + )); + } + + #[test] + fn extracts_response_result_without_losing_payload() { + let result = json!({ "capabilities": { "hoverProvider": true } }); + let response = JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: 1, + result: Some(result.clone()), + error: None, + }; + + assert_eq!(extract_result(response).expect("extract result"), result); + } +} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index eab66ed08d..8cdb9610ec 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -23,7 +23,7 @@ aes-gcm = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } base64 = { workspace = true, optional = true } -bitfun-services-core = { path = "../services-core", optional = true } +bitfun-services-core = { path = "../services-core", default-features = false, optional = true } chrono = { workspace = true, optional = true } dunce = { workspace = true, optional = true } futures = { workspace = true, optional = true }