use std::fmt; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; #[cfg(not(target_arch = "wasm32"))] use command::r#async::Command; use lsp_types::{ ClientCapabilities, ClientInfo, CodeActionClientCapabilities, CompletionClientCapabilities, CompletionItemCapability, CompletionItemCapabilityResolveSupport, DidChangeWatchedFilesClientCapabilities, GotoCapability, HoverClientCapabilities, InitializeParams, MarkupKind, PublishDiagnosticsClientCapabilities, RenameClientCapabilities, SignatureHelpClientCapabilities, TextDocumentClientCapabilities, TextDocumentSyncClientCapabilities, Uri, WindowClientCapabilities, WorkDoneProgressParams, WorkspaceClientCapabilities, WorkspaceFolder, }; use crate::supported_servers::LSPServerType; /// Result of resolving an LSP server command, including the command and init params. #[cfg(not(target_arch = "wasm32"))] pub struct ResolvedLspCommand { pub command: Command, pub params: InitializeParams, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LanguageId { Rust, Go, Python, TypeScript, TypeScriptReact, JavaScript, JavaScriptReact, C, Cpp, } impl LanguageId { pub fn from_path(path: &Path) -> Option { let extn = path.extension()?; match extn.to_str()? { "rs" => Some(Self::Rust), "go" => Some(Self::Go), "py" => Some(Self::Python), "ts" => Some(Self::TypeScript), "tsx" => Some(Self::TypeScriptReact), "js" | "mjs" | "cjs" => Some(Self::JavaScript), "jsx" => Some(Self::JavaScriptReact), "c" | "C" => Some(Self::C), "cc" | "cpp" | "cxx" => Some(Self::Cpp), // NOTE: `.h` files are ambiguous (could be C or C++). We map them to Cpp // because clangd defaults to C++ for `.h` files anyway. When a // compile_commands.json is present, clangd will use the correct language // regardless of the languageId we send. "h" | "H" | "hh" | "hpp" | "hxx" => Some(Self::Cpp), _ => None, } } /// Returns the language identifier as used by LSP. /// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem pub(crate) fn lsp_language_identifier(&self) -> &'static str { match self { LanguageId::Rust => "rust", LanguageId::Go => "go", LanguageId::Python => "python", LanguageId::TypeScript => "typescript", LanguageId::TypeScriptReact => "typescriptreact", LanguageId::JavaScript => "javascript", LanguageId::JavaScriptReact => "javascriptreact", LanguageId::C => "c", LanguageId::Cpp => "cpp", } } /// For now we assume a 1:1 language -> LSP server type. This might change in the future as we support more configurabilities. pub fn server_type(&self) -> LSPServerType { match self { LanguageId::Rust => LSPServerType::RustAnalyzer, LanguageId::Go => LSPServerType::GoPls, LanguageId::Python => LSPServerType::Pyright, LanguageId::TypeScript | LanguageId::TypeScriptReact | LanguageId::JavaScript | LanguageId::JavaScriptReact => LSPServerType::TypeScriptLanguageServer, LanguageId::C | LanguageId::Cpp => LSPServerType::Clangd, } } } /// Configuration for spawning an LSP server process. #[derive(Clone)] #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub struct LspServerConfig { server_type: LSPServerType, initial_workspace: PathBuf, /// The local PATH variable set when starting the server. This is needed when the app is started /// without a shell based parent process. /// TODO(kevin): This might not be sufficient for all cases (e.g. user might remove LSP from PATH). path_env_var: Option, client_name: String, /// Shared HTTP client used for LSP installation checks and downloads. client: Arc, /// Optional path relative to the LSP log namespace for server stderr output. log_relative_path: Option, } impl fmt::Debug for LspServerConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LspServerConfig") .field("server_type", &self.server_type) .field("initial_workspace", &self.initial_workspace) .field("path_env_var", &self.path_env_var) .field("client_name", &self.client_name) .field("log_relative_path", &self.log_relative_path) .finish() } } impl LspServerConfig { pub fn new( server_type: LSPServerType, initial_workspace: PathBuf, path_env_var: Option, client_name: String, client: Arc, ) -> Self { Self { server_type, initial_workspace, path_env_var, client_name, client, log_relative_path: None, } } /// Sets the relative log path for this server's stderr output. pub fn with_log_relative_path(mut self, log_relative_path: PathBuf) -> Self { self.log_relative_path = Some(log_relative_path); self } /// Returns the relative log path if configured. pub fn log_relative_path(&self) -> Option<&PathBuf> { self.log_relative_path.as_ref() } /// Returns the initial workspace path. pub fn initial_workspace(&self) -> &Path { &self.initial_workspace } pub(crate) fn server_name(&self) -> String { self.server_type.binary_name().to_string() } pub(crate) fn languages(&self) -> Vec { self.server_type.languages() } /// Creates the command and init params for the LSP server. /// /// PATH takes precedence over custom installations. If the binary is available /// and working on PATH, we use that. Otherwise, we fall back to our custom installation. #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn command_and_params(self) -> Result { // Resolve the effective PATH for this server. We try multiple sources: // 1. The PATH passed from the terminal session // 2. PATH captured from the user's interactive login shell (picks up nvm, fnm, volta, etc.) let executor = self.resolve_effective_executor().await; let is_working_on_path = self .server_type .is_working_on_path(&executor, self.client.clone()) .await; let custom_binary_config = if is_working_on_path { None } else { self.server_type .find_installed_binary_config(executor.path_env_var()) .await }; if !is_working_on_path && custom_binary_config.is_none() { anyhow::bail!( "{} is not installed. Binary was not found on PATH and no custom installation exists", self.server_type.binary_name() ); } let mut command = self .server_type .create_command(custom_binary_config.clone(), &executor); // Set the working directory to the workspace root. This is required for // LSP servers like rust-analyzer to properly discover the project structure. command.current_dir(&self.initial_workspace); log::info!( "LSP {} starting with custom_binary_config: {:?}", self.server_type.binary_name(), custom_binary_config ); let params = default_init_params(&self.initial_workspace, self.client_name)?; Ok(ResolvedLspCommand { command, params }) } /// Resolves the best available PATH for running LSP-related commands. /// Tries the session PATH first, then falls back to capturing PATH from /// the user's interactive login shell (which includes nvm, fnm, volta, pyenv, etc.). #[cfg(not(target_arch = "wasm32"))] async fn resolve_effective_executor(&self) -> crate::CommandBuilder { // If we have a session PATH, check if it can at least find common tools if let Some(ref path) = self.path_env_var { if !path.is_empty() { // Quick sanity check: can this PATH find the LSP binary or node? let executor = crate::CommandBuilder::new(Some(path.clone())); let works = self .server_type .is_working_on_path(&executor, self.client.clone()) .await; if works { return executor; } } } // Session PATH didn't work — try interactive shell PATH if let Some(shell_path) = crate::CommandBuilder::capture_interactive_shell_path().await { log::info!( "Using interactive shell PATH for {} (session PATH insufficient)", self.server_type.binary_name() ); return crate::CommandBuilder::new(Some(shell_path)); } // Fall back to whatever we have crate::CommandBuilder::new(self.path_env_var.clone()) } pub(crate) fn server_type(&self) -> LSPServerType { self.server_type } } pub(crate) fn path_to_lsp_uri(path: &Path) -> Result { if !path.is_absolute() { return Err(anyhow::anyhow!("Path must be absolute: {}", path.display())); } // url::Url::from_file_path handles percent-encoding internally but is not // available on WASM. LSP is not supported on WASM either, so the fallback // is a simple string concatenation. #[cfg(not(target_arch = "wasm32"))] { let url = url::Url::from_file_path(path).map_err(|()| { anyhow::anyhow!("Failed to convert path to file URI: {}", path.display()) })?; // The url crate doesn't encode brackets, but LSP requires them to be // percent-encoded (e.g. Next.js [slug].tsx routes). let uri_str = url.as_str().replace('[', "%5B").replace(']', "%5D"); uri_str.parse::().map_err(anyhow::Error::from) } #[cfg(target_arch = "wasm32")] { let path_str = path.to_string_lossy(); let uri_string = format!("file://{path_str}"); uri_string.parse::().map_err(anyhow::Error::from) } } pub(crate) fn lsp_uri_to_path(uri: &Uri) -> Result { // Validate this is a file URI let scheme = uri.scheme().map(|s| s.as_str()); if scheme != Some("file") { return Err(anyhow::anyhow!("Invalid file URI: {}", uri.as_str())); } // Decode percent-encoded characters (e.g., %40 -> @) // This is necessary because LSP servers return URL-encoded paths let decoded_path = uri .path() .as_estr() .decode() .into_string() .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in URI path: {e}"))?; let mut path_str: &str = decoded_path.as_ref(); // Windows URIs are formatted like: file:///C:/path/to/file // The path component is `/C:/path/to/file`, strip the leading slash. if cfg!(windows) { path_str = path_str.strip_prefix('/').unwrap_or(path_str); return Ok(PathBuf::from(path_str.replace('/', "\\"))); } Ok(PathBuf::from(path_str)) } fn path_to_workspace_folder(path: &Path) -> Result { path_to_lsp_uri(path).map(|url| WorkspaceFolder { uri: url, name: path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(), }) } fn default_client_capabilities() -> ClientCapabilities { ClientCapabilities { workspace: Some(WorkspaceClientCapabilities { did_change_watched_files: Option::from(DidChangeWatchedFilesClientCapabilities { dynamic_registration: Some(true), relative_pattern_support: Some(true), }), ..Default::default() }), window: Some(WindowClientCapabilities { work_done_progress: Some(true), ..Default::default() }), text_document: Some(TextDocumentClientCapabilities { synchronization: Some(TextDocumentSyncClientCapabilities { dynamic_registration: Some(true), will_save: Some(false), will_save_wait_until: Some(false), did_save: Some(true), }), completion: Some(CompletionClientCapabilities { dynamic_registration: Some(false), completion_item: Some(CompletionItemCapability { snippet_support: Some(true), documentation_format: Some(vec![MarkupKind::Markdown, MarkupKind::PlainText]), resolve_support: Some(CompletionItemCapabilityResolveSupport { properties: vec![ "documentation".into(), "detail".into(), "additionalTextEdits".into(), ], }), ..Default::default() }), ..Default::default() }), definition: Some(GotoCapability { dynamic_registration: Some(false), link_support: Some(true), }), hover: Some(HoverClientCapabilities { dynamic_registration: Some(false), content_format: Some(vec![MarkupKind::Markdown, MarkupKind::PlainText]), }), publish_diagnostics: Some(PublishDiagnosticsClientCapabilities { version_support: Some(true), related_information: Some(true), ..Default::default() }), signature_help: Some(SignatureHelpClientCapabilities { dynamic_registration: Some(false), ..Default::default() }), rename: Some(RenameClientCapabilities { dynamic_registration: Some(false), prepare_support: Some(true), ..Default::default() }), code_action: Some(CodeActionClientCapabilities { dynamic_registration: Some(false), ..Default::default() }), ..Default::default() }), ..Default::default() } } pub fn default_init_params(workspace_uri: &Path, client_name: String) -> Result { let workspace_folder = path_to_workspace_folder(workspace_uri)?; Ok(InitializeParams { process_id: Some(std::process::id()), capabilities: default_client_capabilities(), workspace_folders: Some(vec![workspace_folder]), client_info: Some(ClientInfo { name: client_name, version: option_env!("GIT_RELEASE_TAG").map(|s| s.to_string()), }), locale: None, work_done_progress_params: WorkDoneProgressParams::default(), ..Default::default() }) } #[cfg(test)] #[path = "config_tests.rs"] mod tests;