Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
442 lines
16 KiB
Rust
442 lines
16 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,
|
|
Toml,
|
|
}
|
|
|
|
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),
|
|
"toml" => Some(Self::Toml),
|
|
_ => 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",
|
|
LanguageId::Toml => "toml",
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
LanguageId::Toml => LSPServerType::Tombi,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
/// Notifications sent immediately after the server completes LSP initialization.
|
|
post_initialize_notifications: Vec<(String, serde_json::Value)>,
|
|
}
|
|
|
|
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)
|
|
.field(
|
|
"post_initialize_notifications",
|
|
&self.post_initialize_notifications,
|
|
)
|
|
.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,
|
|
post_initialize_notifications: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// Adds a custom notification to send after the language server initializes.
|
|
pub fn with_post_initialize_notification(
|
|
mut self,
|
|
method: impl Into<String>,
|
|
params: serde_json::Value,
|
|
) -> Self {
|
|
self.post_initialize_notifications
|
|
.push((method.into(), params));
|
|
self
|
|
}
|
|
|
|
pub(crate) fn post_initialize_notifications(&self) -> &[(String, serde_json::Value)] {
|
|
&self.post_initialize_notifications
|
|
}
|
|
|
|
/// 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 mut params = default_init_params(&self.initial_workspace, self.client_name)?;
|
|
|
|
// Resolve server-specific initialization options (e.g., tsserver.path for TypeScript)
|
|
let init_options = self
|
|
.server_type
|
|
.initialization_options(&self.initial_workspace, executor.path_env_var())
|
|
.await;
|
|
if let Some(options) = init_options {
|
|
params.initialization_options = Some(options);
|
|
}
|
|
|
|
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;
|