Files
galaxy/crates/lsp/src/config.rs
T
Ryan WardandClaude Opus 4.6 59cfd0e2f5 Bump version to 1.6.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-12 14:17:06 -05:00

407 lines
15 KiB
Rust

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<Self> {
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<String>,
client_name: String,
/// Shared HTTP client used for LSP installation checks and downloads.
client: Arc<http_client::Client>,
/// Optional path relative to the LSP log namespace for server stderr output.
log_relative_path: Option<PathBuf>,
}
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<String>,
client_name: String,
client: Arc<http_client::Client>,
) -> 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<LanguageId> {
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<ResolvedLspCommand> {
// 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<Uri> {
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::<Uri>().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::<Uri>().map_err(anyhow::Error::from)
}
}
pub(crate) fn lsp_uri_to_path(uri: &Uri) -> Result<PathBuf> {
// 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<WorkspaceFolder> {
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<InitializeParams> {
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;