diff --git a/Cargo.lock b/Cargo.lock index 73e548ae5a5f..a7ef8eb0504b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1240,6 +1240,7 @@ dependencies = [ "dunce", "encoding_rs", "etcetera", + "globset", "hashbrown 0.14.3", "helix-loader", "helix-stdx", diff --git a/book/src/languages.md b/book/src/languages.md index 944ebf097ab7..cd1d4b76b480 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -120,13 +120,14 @@ languages = { typescript = [ { formatCommand ="prettier --stdin-filepath ${INPUT These are the available options for a language server. -| Key | Description | -| ---- | ----------- | -| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` | -| `args` | A list of arguments to pass to the language server binary | -| `config` | LSP initialization options | -| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | -| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` | +| Key | Description | +| ---- | ----------- | +| `command` | The name or path of the language server binary to execute. Binaries must be in `$PATH` | +| `args` | A list of arguments to pass to the language server binary | +| `config` | LSP initialization options | +| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | +| `environment` | Any environment variables that will be used when starting the language server `{ "KEY1" = "Value1", "KEY2" = "Value2" }` | +| `required-root-patterns` | A list of `glob` patterns to look for in the working directory. The language server is started if at least one of them is found. | A `format` sub-table within `config` can be used to pass extra formatting options to [Document Formatting Requests](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_formatting). diff --git a/helix-core/Cargo.toml b/helix-core/Cargo.toml index 8c63af8ef266..fb68ccc083db 100644 --- a/helix-core/Cargo.toml +++ b/helix-core/Cargo.toml @@ -52,6 +52,7 @@ textwrap = "0.16.0" nucleo.workspace = true parking_lot = "0.12" +globset = "0.4.14" [dev-dependencies] quickcheck = { version = "1", default-features = false } diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index 24de1a338758..9b82fa73ec4f 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -10,6 +10,7 @@ use crate::{ use ahash::RandomState; use arc_swap::{ArcSwap, Guard}; use bitflags::bitflags; +use globset::GlobSet; use hashbrown::raw::RawTable; use slotmap::{DefaultKey as LayerId, HopSlotMap}; @@ -358,6 +359,22 @@ where serializer.end() } +fn deserialize_required_root_patterns<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let patterns = Vec::::deserialize(deserializer)?; + if patterns.is_empty() { + return Ok(None); + } + let mut builder = globset::GlobSetBuilder::new(); + for pattern in patterns { + let glob = globset::Glob::new(&pattern).map_err(serde::de::Error::custom)?; + builder.add(glob); + } + builder.build().map(Some).map_err(serde::de::Error::custom) +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LanguageServerConfiguration { @@ -371,6 +388,12 @@ pub struct LanguageServerConfiguration { pub config: Option, #[serde(default = "default_timeout")] pub timeout: u64, + #[serde( + default, + skip_serializing, + deserialize_with = "deserialize_required_root_patterns" + )] + pub required_root_patterns: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 94bad6faf9d8..0d3a2a56e9d3 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -177,12 +177,11 @@ impl Client { args: &[String], config: Option, server_environment: HashMap, - root_markers: &[String], - manual_roots: &[PathBuf], + root_path: PathBuf, + root_uri: Option, id: usize, name: String, req_timeout: u64, - doc_path: Option<&std::path::PathBuf>, ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { // Resolve path to the binary let cmd = helix_stdx::env::which(cmd)?; @@ -206,22 +205,6 @@ impl Client { let (server_rx, server_tx, initialize_notify) = Transport::start(reader, writer, stderr, id, name.clone()); - let (workspace, workspace_is_cwd) = find_workspace(); - let workspace = path::normalize(workspace); - let root = find_lsp_workspace( - doc_path - .and_then(|x| x.parent().and_then(|x| x.to_str())) - .unwrap_or("."), - root_markers, - manual_roots, - &workspace, - workspace_is_cwd, - ); - - // `root_uri` and `workspace_folder` can be empty in case there is no workspace - // `root_url` can not, use `workspace` as a fallback - let root_path = root.clone().unwrap_or_else(|| workspace.clone()); - let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok()); let workspace_folders = root_uri .clone() diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 4ce445aeed0c..05764418f0a2 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -680,7 +680,7 @@ impl Registry { doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, - ) -> Result> { + ) -> Result>> { let config = self .syn_loader .language_server_configs() @@ -688,7 +688,7 @@ impl Registry { .ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?; let id = self.counter; self.counter += 1; - let NewClient(client, incoming) = start_client( + if let Some(NewClient(client, incoming)) = start_client( id, name, ls_config, @@ -696,9 +696,12 @@ impl Registry { doc_path, root_dirs, enable_snippets, - )?; - self.incoming.push(UnboundedReceiverStream::new(incoming)); - Ok(client) + )? { + self.incoming.push(UnboundedReceiverStream::new(incoming)); + Ok(Some(client)) + } else { + Ok(None) + } } /// If this method is called, all documents that have a reference to language servers used by the language config have to refresh their language servers, @@ -723,8 +726,8 @@ impl Registry { root_dirs, enable_snippets, ) { - Ok(client) => client, - error => return Some(error), + Ok(client) => client?, + Err(error) => return Some(Err(error)), }; let old_clients = self .inner @@ -764,13 +767,13 @@ impl Registry { root_dirs: &'a [PathBuf], enable_snippets: bool, ) -> impl Iterator>)> + 'a { - language_config.language_servers.iter().map( + language_config.language_servers.iter().filter_map( move |LanguageServerFeatures { name, .. }| { if let Some(clients) = self.inner.get(name) { if let Some((_, client)) = clients.iter().enumerate().find(|(i, client)| { client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0) }) { - return (name.to_owned(), Ok(client.clone())); + return Some((name.to_owned(), Ok(client.clone()))); } } match self.start_client( @@ -781,13 +784,14 @@ impl Registry { enable_snippets, ) { Ok(client) => { + let client = client?; self.inner .entry(name.to_owned()) .or_default() .push(client.clone()); - (name.clone(), Ok(client)) + Some((name.clone(), Ok(client))) } - Err(err) => (name.to_owned(), Err(err)), + Err(err) => Some((name.to_owned(), Err(err))), } }, ) @@ -888,18 +892,45 @@ fn start_client( doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, -) -> Result { +) -> Result> { + let (workspace, workspace_is_cwd) = helix_loader::find_workspace(); + let workspace = path::normalize(workspace); + let root = find_lsp_workspace( + doc_path + .and_then(|x| x.parent().and_then(|x| x.to_str())) + .unwrap_or("."), + &config.roots, + config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), + &workspace, + workspace_is_cwd, + ); + + // `root_uri` and `workspace_folder` can be empty in case there is no workspace + // `root_url` can not, use `workspace` as a fallback + let root_path = root.clone().unwrap_or_else(|| workspace.clone()); + let root_uri = root.and_then(|root| lsp::Url::from_file_path(root).ok()); + + if let Some(globset) = &ls_config.required_root_patterns { + if !root_path + .read_dir()? + .flatten() + .map(|entry| entry.file_name()) + .any(|entry| globset.is_match(entry)) + { + return Ok(None); + } + } + let (client, incoming, initialize_notify) = Client::start( &ls_config.command, &ls_config.args, ls_config.config.clone(), ls_config.environment.clone(), - &config.roots, - config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), + root_path, + root_uri, id, name, ls_config.timeout, - doc_path, )?; let client = Arc::new(client); @@ -938,7 +969,7 @@ fn start_client( initialize_notify.notify_one(); }); - Ok(NewClient(client, incoming)) + Ok(Some(NewClient(client, incoming))) } /// Find an LSP workspace of a file using the following mechanism: