diff --git a/book/src/generated/typable-cmd.md b/book/src/generated/typable-cmd.md index a775c655589c..0c377b3bbd4b 100644 --- a/book/src/generated/typable-cmd.md +++ b/book/src/generated/typable-cmd.md @@ -50,7 +50,7 @@ | `:reload-all` | Discard changes and reload all documents from the source files. | | `:update`, `:u` | Write changes only if the file has been modified. | | `:lsp-workspace-command` | Open workspace command picker | -| `:lsp-restart` | Restarts the Language Server that is in use by the current doc | +| `:lsp-restart` | Restarts the language servers used by the currently opened file | | `:lsp-stop` | Stops the Language Server that is in use by the current doc | | `:tree-sitter-scopes` | Display tree sitter scopes, primarily for theming and development. | | `:debug-start`, `:dbg` | Start a debug session from a given template with given parameters. | diff --git a/book/src/guides/adding_languages.md b/book/src/guides/adding_languages.md index b92af4028e9a..93ec013f5c3e 100644 --- a/book/src/guides/adding_languages.md +++ b/book/src/guides/adding_languages.md @@ -9,6 +9,7 @@ below. necessary configuration for the new language. For more information on language configuration, refer to the [language configuration section](../languages.md) of the documentation. + A new language server can be added by extending the `[language-server]` table in the same file. 2. If you are adding a new language or updating an existing language server configuration, run the command `cargo xtask docgen` to update the [Language Support](../lang-support.md) documentation. diff --git a/book/src/languages.md b/book/src/languages.md index fe4db141309d..3328c6103a09 100644 --- a/book/src/languages.md +++ b/book/src/languages.md @@ -18,6 +18,9 @@ There are three possible locations for a `languages.toml` file: ```toml # in /helix/languages.toml +[language-server.mylang-lsp] +command = "mylang-lsp" + [[language]] name = "rust" auto-format = false @@ -41,8 +44,8 @@ injection-regex = "mylang" file-types = ["mylang", "myl"] comment-token = "#" indent = { tab-width = 2, unit = " " } -language-server = { command = "mylang-lsp", args = ["--stdio"], environment = { "ENV1" = "value1", "ENV2" = "value2" } } formatter = { command = "mylang-formatter" , args = ["--stdin"] } +language-servers = [ "mylang-lsp" ] ``` These configuration keys are available: @@ -50,6 +53,7 @@ These configuration keys are available: | Key | Description | | ---- | ----------- | | `name` | The name of the language | +| `language-id` | The language-id for language servers, checkout the table at [TextDocumentItem](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem) for the right id | | `scope` | A string like `source.js` that identifies the language. Currently, we strive to match the scope names used by popular TextMate grammars and by the Linguist library. Usually `source.` or `text.` in case of markup languages | | `injection-regex` | regex pattern that will be tested against a language name in order to determine whether this language should be used for a potential [language injection][treesitter-language-injection] site. | | `file-types` | The filetypes of the language, for example `["yml", "yaml"]`. See the file-type detection section below. | @@ -59,7 +63,7 @@ These configuration keys are available: | `diagnostic-severity` | Minimal severity of diagnostic for it to be displayed. (Allowed values: `Error`, `Warning`, `Info`, `Hint`) | | `comment-token` | The token to use as a comment-token | | `indent` | The indent to use. Has sub keys `unit` (the text inserted into the document when indenting; usually set to N spaces or `"\t"` for tabs) and `tab-width` (the number of spaces rendered for a tab) | -| `language-server` | The Language Server to run. See the Language Server configuration section below. | +| `language-servers` | The Language Servers used for this language. See below for more information in the section [Configuring Language Servers for a language](#configuring-language-servers-for-a-language) | | `config` | Language Server configuration | | `grammar` | The tree-sitter grammar to use (defaults to the value of `name`) | | `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout | @@ -92,31 +96,97 @@ with the following priorities: replaced at runtime with the appropriate path separator for the operating system, so this rule would match against `.git\config` files on Windows. -### Language Server configuration +## Language Server configuration + +Language servers are configured separately in the table `language-server` in the same file as the languages `languages.toml` + +For example: + +```toml +[language-server.mylang-lsp] +command = "mylang-lsp" +args = ["--stdio"] +config = { provideFormatter = true } +environment = { "ENV1" = "value1", "ENV2" = "value2" } + +[language-server.efm-lsp-prettier] +command = "efm-langserver" + +[language-server.efm-lsp-prettier.config] +documentFormatting = true +languages = { typescript = [ { formatCommand ="prettier --stdin-filepath ${INPUT}", formatStdin = true } ] } +``` -The `language-server` field takes the following keys: +These are the available options for a language server. -| Key | Description | -| --- | ----------- | -| `command` | The name of the language server binary to execute. Binaries must be in `$PATH` | -| `args` | A list of arguments to pass to the language server binary | -| `timeout` | The maximum time a request to the language server may take, in seconds. Defaults to `20` | -| `language-id` | The language name to pass to the language server. Some language servers support multiple languages and use this field to determine which one is being served in a buffer | -| `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" }` | -The top-level `config` field is used to configure the LSP initialization options. A `format` -sub-table within `config` can be used to pass extra formatting options to -[Document Formatting Requests](https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-16.md#document-formatting-request--leftwards_arrow_with_hook). +A `format` sub-table within `config` can be used to pass extra formatting options to +[Document Formatting Requests](https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-17.md#document-formatting-request--leftwards_arrow_with_hook). For example with typescript: ```toml -[[language]] -name = "typescript" -auto-format = true +[language-server.typescript-language-server] # pass format options according to https://github.com/typescript-language-server/typescript-language-server#workspacedidchangeconfiguration omitting the "[language].format." prefix. config = { format = { "semicolons" = "insert", "insertSpaceBeforeFunctionParenthesis" = true } } ``` +### Configuring Language Servers for a language + +The `language-servers` attribute in a language tells helix which language servers are used for this language. +They have to be defined in the `[language-server]` table as described in the previous section. +Different languages can use the same language server instance, e.g. `typescript-language-server` is used for javascript, jsx, tsx and typescript by default. +In case multiple language servers are specified in the `language-servers` attribute of a `language`, +it's often useful to only enable/disable certain language-server features for these language servers. +For example `efm-lsp-prettier` of the previous example is used only with a formatting command `prettier`, +so everything else should be handled by the `typescript-language-server` (which is configured by default) +The language configuration for typescript could look like this: + +```toml +[[language]] +name = "typescript" +language-servers = [ { name = "efm-lsp-prettier", only-features = [ "format" ] }, "typescript-language-server" ] +``` + +or equivalent: + +```toml +[[language]] +name = "typescript" +language-servers = [ { name = "typescript-language-server", except-features = [ "format" ] }, "efm-lsp-prettier" ] +``` + +Each requested LSP feature is priorized in the order of the `language-servers` array. +For example the first `goto-definition` supported language server (in this case `typescript-language-server`) will be taken for the relevant LSP request (command `goto_definition`). +If no `except-features` or `only-features` is given all features for the language server are enabled. +If a language server itself doesn't support a feature the next language server array entry will be tried (and so on). + +The list of supported features are: + +- `format` +- `goto-definition` +- `goto-declaration` +- `goto-type-definition` +- `goto-reference` +- `goto-implementation` +- `signature-help` +- `hover` +- `document-highlight` +- `completion` +- `code-action` +- `workspace-command` +- `document-symbols` +- `workspace-symbols` +- `diagnostics` +- `rename-symbol` +- `inlay-hints` + ## Tree-sitter grammar configuration The source for a language's tree-sitter grammar is specified in a `[[grammar]]` diff --git a/helix-core/src/diagnostic.rs b/helix-core/src/diagnostic.rs index 58ddb0383a0a..0b75d2a586f1 100644 --- a/helix-core/src/diagnostic.rs +++ b/helix-core/src/diagnostic.rs @@ -43,6 +43,7 @@ pub struct Diagnostic { pub message: String, pub severity: Option, pub code: Option, + pub language_server_id: usize, pub tags: Vec, pub source: Option, pub data: Option, diff --git a/helix-core/src/syntax.rs b/helix-core/src/syntax.rs index f36c985e3485..ff4bb6c27b50 100644 --- a/helix-core/src/syntax.rs +++ b/helix-core/src/syntax.rs @@ -17,7 +17,7 @@ use std::{ borrow::Cow, cell::RefCell, collections::{HashMap, VecDeque}, - fmt, + fmt::{self, Display}, hash::{Hash, Hasher}, mem::{replace, transmute}, path::{Path, PathBuf}, @@ -60,8 +60,11 @@ fn default_timeout() -> u64 { } #[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub struct Configuration { pub language: Vec, + #[serde(default)] + pub language_server: HashMap, } impl Default for Configuration { @@ -75,7 +78,10 @@ impl Default for Configuration { #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct LanguageConfiguration { #[serde(rename = "name")] - pub language_id: String, // c-sharp, rust + pub language_id: String, // c-sharp, rust, tsx + #[serde(rename = "language-id")] + // see the table under https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem + pub language_server_language_id: Option, // csharp, rust, typescriptreact, for the language-server pub scope: String, // source.rust pub file_types: Vec, // filename extension or ends_with? #[serde(default)] @@ -85,9 +91,6 @@ pub struct LanguageConfiguration { pub text_width: Option, pub soft_wrap: Option, - #[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")] - pub config: Option, - #[serde(default)] pub auto_format: bool, @@ -107,8 +110,8 @@ pub struct LanguageConfiguration { #[serde(skip)] pub(crate) highlight_config: OnceCell>>, // tags_config OnceCell<> https://github.com/tree-sitter/tree-sitter/pull/583 - #[serde(skip_serializing_if = "Option::is_none")] - pub language_server: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub language_servers: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub indent: Option, @@ -208,6 +211,68 @@ impl<'de> Deserialize<'de> for FileType { } } +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LanguageServerFeature { + Format, + GotoDeclaration, + GotoDefinition, + GotoTypeDefinition, + GotoReference, + GotoImplementation, + // Goto, use bitflags, combining previous Goto members? + SignatureHelp, + Hover, + DocumentHighlight, + Completion, + CodeAction, + WorkspaceCommand, + DocumentSymbols, + WorkspaceSymbols, + // Symbols, use bitflags, see above? + Diagnostics, + RenameSymbol, + InlayHints, +} + +impl Display for LanguageServerFeature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LanguageServerFeature::Format => write!(f, "format"), + LanguageServerFeature::GotoDeclaration => write!(f, "goto-declaration"), + LanguageServerFeature::GotoDefinition => write!(f, "goto-definition"), + LanguageServerFeature::GotoTypeDefinition => write!(f, "goto-type-definition"), + LanguageServerFeature::GotoReference => write!(f, "goto-type-definition"), + LanguageServerFeature::GotoImplementation => write!(f, "goto-implementation"), + LanguageServerFeature::SignatureHelp => write!(f, "signature-help"), + LanguageServerFeature::Hover => write!(f, "hover"), + LanguageServerFeature::DocumentHighlight => write!(f, "document-highlight"), + LanguageServerFeature::Completion => write!(f, "completion"), + LanguageServerFeature::CodeAction => write!(f, "code-action"), + LanguageServerFeature::WorkspaceCommand => write!(f, "workspace-command"), + LanguageServerFeature::DocumentSymbols => write!(f, "document-symbols"), + LanguageServerFeature::WorkspaceSymbols => write!(f, "workspace-symbols"), + LanguageServerFeature::Diagnostics => write!(f, "diagnostics"), + LanguageServerFeature::RenameSymbol => write!(f, "rename-symbol"), + LanguageServerFeature::InlayHints => write!(f, "inlay-hints"), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged, rename_all = "kebab-case", deny_unknown_fields)] +pub enum LanguageServerFeatureConfiguration { + #[serde(rename_all = "kebab-case")] + Features { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + only_features: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + except_features: Vec, + name: String, + }, + Simple(String), +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct LanguageServerConfiguration { @@ -217,9 +282,10 @@ pub struct LanguageServerConfiguration { pub args: Vec, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub environment: HashMap, + #[serde(default, skip_serializing, deserialize_with = "deserialize_lsp_config")] + pub config: Option, #[serde(default = "default_timeout")] pub timeout: u64, - pub language_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -584,6 +650,15 @@ pub struct SoftWrap { pub wrap_at_text_width: Option, } +impl LanguageServerFeatureConfiguration { + pub fn name(&self) -> &String { + match self { + LanguageServerFeatureConfiguration::Simple(name) => name, + LanguageServerFeatureConfiguration::Features { name, .. } => name, + } + } +} + // Expose loader as Lazy<> global since it's always static? #[derive(Debug)] @@ -594,6 +669,8 @@ pub struct Loader { language_config_ids_by_suffix: HashMap, language_config_ids_by_shebang: HashMap, + language_server_configs: HashMap, + scopes: ArcSwap>, } @@ -601,6 +678,7 @@ impl Loader { pub fn new(config: Configuration) -> Self { let mut loader = Self { language_configs: Vec::new(), + language_server_configs: config.language_server, language_config_ids_by_extension: HashMap::new(), language_config_ids_by_suffix: HashMap::new(), language_config_ids_by_shebang: HashMap::new(), @@ -725,6 +803,10 @@ impl Loader { self.language_configs.iter() } + pub fn language_server_configs(&self) -> &HashMap { + &self.language_server_configs + } + pub fn set_scopes(&self, scopes: Vec) { self.scopes.store(Arc::new(scopes)); @@ -2370,7 +2452,10 @@ mod test { "#, ); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(Configuration { + language: vec![], + language_server: HashMap::new(), + }); let language = get_language("rust").unwrap(); let query = Query::new(language, query_str).unwrap(); @@ -2429,7 +2514,10 @@ mod test { .map(String::from) .collect(); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(Configuration { + language: vec![], + language_server: HashMap::new(), + }); let language = get_language("rust").unwrap(); let config = HighlightConfiguration::new( @@ -2532,7 +2620,10 @@ mod test { ) { let source = Rope::from_str(source); - let loader = Loader::new(Configuration { language: vec![] }); + let loader = Loader::new(Configuration { + language: vec![], + language_server: HashMap::new(), + }); let language = get_language(language_name).unwrap(); let config = HighlightConfiguration::new(language, "", "", "").unwrap(); diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index 840e73828dc7..c0f3adb8211f 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -44,6 +44,7 @@ fn workspace_for_uri(uri: lsp::Url) -> WorkspaceFolder { #[derive(Debug)] pub struct Client { id: usize, + name: String, _process: Child, server_tx: UnboundedSender, request_counter: AtomicU64, @@ -166,8 +167,7 @@ impl Client { tokio::spawn(self.did_change_workspace(vec![workspace_for_uri(root_uri)], Vec::new())); } - #[allow(clippy::type_complexity)] - #[allow(clippy::too_many_arguments)] + #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn start( cmd: &str, args: &[String], @@ -176,6 +176,7 @@ impl Client { root_markers: &[String], manual_roots: &[PathBuf], id: usize, + name: String, req_timeout: u64, doc_path: Option<&std::path::PathBuf>, ) -> Result<(Self, UnboundedReceiver<(usize, Call)>, Arc)> { @@ -200,7 +201,7 @@ impl Client { let stderr = BufReader::new(process.stderr.take().expect("Failed to open stderr")); let (server_rx, server_tx, initialize_notify) = - Transport::start(reader, writer, stderr, id); + Transport::start(reader, writer, stderr, id, name.clone()); let (workspace, workspace_is_cwd) = find_workspace(); let workspace = path::get_normalized_path(&workspace); let root = find_lsp_workspace( @@ -225,6 +226,7 @@ impl Client { let client = Self { id, + name, _process: process, server_tx, request_counter: AtomicU64::new(0), @@ -240,6 +242,10 @@ impl Client { Ok((client, server_rx, initialize_notify)) } + pub fn name(&self) -> &String { + &self.name + } + pub fn id(&self) -> usize { self.id } diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 31ee1d75cb7d..12e63255adb2 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -17,19 +17,16 @@ use helix_core::{ use tokio::sync::mpsc::UnboundedReceiver; use std::{ - collections::{hash_map::Entry, HashMap}, + collections::HashMap, path::{Path, PathBuf}, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, + sync::Arc, }; use thiserror::Error; use tokio_stream::wrappers::UnboundedReceiverStream; pub type Result = core::result::Result; -type LanguageId = String; +type LanguageServerName = String; #[derive(Error, Debug)] pub enum Error { @@ -49,7 +46,7 @@ pub enum Error { Other(#[from] anyhow::Error), } -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum OffsetEncoding { /// UTF-8 code units aka bytes Utf8, @@ -624,23 +621,18 @@ impl Notification { #[derive(Debug)] pub struct Registry { - inner: HashMap)>>, - - counter: AtomicUsize, + inner: HashMap>>, + syn_loader: Arc, + counter: usize, pub incoming: SelectAll>, } -impl Default for Registry { - fn default() -> Self { - Self::new() - } -} - impl Registry { - pub fn new() -> Self { + pub fn new(syn_loader: Arc) -> Self { Self { inner: HashMap::new(), - counter: AtomicUsize::new(0), + syn_loader, + counter: 0, incoming: SelectAll::new(), } } @@ -649,15 +641,43 @@ impl Registry { self.inner .values() .flatten() - .find(|(client_id, _)| client_id == &id) - .map(|(_, client)| client.as_ref()) + .find(|client| client.id() == id) + .map(|client| &**client) } pub fn remove_by_id(&mut self, id: usize) { - self.inner.retain(|_, clients| { - clients.retain(|&(client_id, _)| client_id != id); - !clients.is_empty() - }) + self.inner.retain(|_, language_servers| { + language_servers.retain(|ls| id != ls.id()); + !language_servers.is_empty() + }); + } + + fn start_client( + &mut self, + name: String, + ls_config: &LanguageConfiguration, + doc_path: Option<&std::path::PathBuf>, + root_dirs: &[PathBuf], + enable_snippets: bool, + ) -> Result> { + let config = self + .syn_loader + .language_server_configs() + .get(&name) + .ok_or_else(|| anyhow::anyhow!("Language server '{name}' not defined"))?; + self.counter += 1; + let id = self.counter; + let NewClient(client, incoming) = start_client( + id, + name, + ls_config, + config, + doc_path, + root_dirs, + enable_snippets, + )?; + self.incoming.push(UnboundedReceiverStream::new(incoming)); + Ok(client) } pub fn restart( @@ -666,48 +686,46 @@ impl Registry { doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, - ) -> Result>> { - let config = match &language_config.language_server { - Some(config) => config, - None => return Ok(None), - }; - - let scope = language_config.scope.clone(); - - match self.inner.entry(scope) { - Entry::Vacant(_) => Ok(None), - Entry::Occupied(mut entry) => { - // initialize a new client - let id = self.counter.fetch_add(1, Ordering::Relaxed); - - let NewClientResult(client, incoming) = start_client( - id, - language_config, - config, - doc_path, - root_dirs, - enable_snippets, - )?; - self.incoming.push(UnboundedReceiverStream::new(incoming)); + ) -> Result>> { + language_config + .language_servers + .iter() + .filter_map(|config| { + let name = config.name().clone(); + + #[allow(clippy::map_entry)] + if self.inner.contains_key(&name) { + let client = match self.start_client( + name.clone(), + language_config, + doc_path, + root_dirs, + enable_snippets, + ) { + Ok(client) => client, + error => return Some(error), + }; + let old_clients = self.inner.insert(name, vec![client.clone()]).unwrap(); - let old_clients = entry.insert(vec![(id, client.clone())]); + // TODO what if there are different language servers for different workspaces, + // I think the language servers will be stopped without being restarted, which is not intended + for old_client in old_clients { + tokio::spawn(async move { + let _ = old_client.force_shutdown().await; + }); + } - for (_, old_client) in old_clients { - tokio::spawn(async move { - let _ = old_client.force_shutdown().await; - }); + Some(Ok(client)) + } else { + None } - - Ok(Some(client)) - } - } + }) + .collect() } - pub fn stop(&mut self, language_config: &LanguageConfiguration) { - let scope = language_config.scope.clone(); - - if let Some(clients) = self.inner.remove(&scope) { - for (_, client) in clients { + pub fn stop(&mut self, name: &str) { + if let Some(clients) = self.inner.remove(name) { + for client in clients { tokio::spawn(async move { let _ = client.force_shutdown().await; }); @@ -721,37 +739,35 @@ impl Registry { doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, - ) -> Result>> { - let config = match &language_config.language_server { - Some(config) => config, - None => return Ok(None), - }; - - let clients = self.inner.entry(language_config.scope.clone()).or_default(); - // check if we already have a client for this documents root that we can reuse - if let Some((_, client)) = clients.iter_mut().enumerate().find(|(i, (_, client))| { - client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0) - }) { - return Ok(Some(client.1.clone())); - } - // initialize a new client - let id = self.counter.fetch_add(1, Ordering::Relaxed); - - let NewClientResult(client, incoming) = start_client( - id, - language_config, - config, - doc_path, - root_dirs, - enable_snippets, - )?; - clients.push((id, client.clone())); - self.incoming.push(UnboundedReceiverStream::new(incoming)); - Ok(Some(client)) + ) -> Result>> { + language_config + .language_servers + .iter() + .map(|features| { + let name = features.name(); + if let Some(clients) = self.inner.get_mut(name) { + if let Some((_, client)) = clients.iter_mut().enumerate().find(|(i, client)| { + client.try_add_doc(&language_config.roots, root_dirs, doc_path, *i == 0) + }) { + return Ok(client.clone()); + } + } + let client = self.start_client( + name.clone(), + language_config, + doc_path, + root_dirs, + enable_snippets, + )?; + let clients = self.inner.entry(features.name().clone()).or_default(); + clients.push(client.clone()); + Ok(client) + }) + .collect() } pub fn iter_clients(&self) -> impl Iterator> { - self.inner.values().flatten().map(|(_, client)| client) + self.inner.values().flatten() } } @@ -833,26 +849,28 @@ impl LspProgressMap { } } -struct NewClientResult(Arc, UnboundedReceiver<(usize, Call)>); +struct NewClient(Arc, UnboundedReceiver<(usize, Call)>); /// start_client takes both a LanguageConfiguration and a LanguageServerConfiguration to ensure that /// it is only called when it makes sense. fn start_client( id: usize, + name: String, config: &LanguageConfiguration, ls_config: &LanguageServerConfiguration, doc_path: Option<&std::path::PathBuf>, root_dirs: &[PathBuf], enable_snippets: bool, -) -> Result { +) -> Result { let (client, incoming, initialize_notify) = Client::start( &ls_config.command, &ls_config.args, - config.config.clone(), + ls_config.config.clone(), ls_config.environment.clone(), &config.roots, config.workspace_lsp_roots.as_deref().unwrap_or(root_dirs), id, + name, ls_config.timeout, doc_path, )?; @@ -886,7 +904,7 @@ fn start_client( initialize_notify.notify_one(); }); - Ok(NewClientResult(client, incoming)) + Ok(NewClient(client, incoming)) } /// Find an LSP workspace of a file using the following mechanism: diff --git a/helix-lsp/src/transport.rs b/helix-lsp/src/transport.rs index 3e3e06eec4c5..8c38c1773c19 100644 --- a/helix-lsp/src/transport.rs +++ b/helix-lsp/src/transport.rs @@ -38,6 +38,7 @@ enum ServerMessage { #[derive(Debug)] pub struct Transport { id: usize, + name: String, pending_requests: Mutex>>>, } @@ -47,6 +48,7 @@ impl Transport { server_stdin: BufWriter, server_stderr: BufReader, id: usize, + name: String, ) -> ( UnboundedReceiver<(usize, jsonrpc::Call)>, UnboundedSender, @@ -58,6 +60,7 @@ impl Transport { let transport = Self { id, + name, pending_requests: Mutex::new(HashMap::default()), }; @@ -83,6 +86,7 @@ impl Transport { async fn recv_server_message( reader: &mut (impl AsyncBufRead + Unpin + Send), buffer: &mut String, + language_server_name: &str, ) -> Result { let mut content_length = None; loop { @@ -124,7 +128,7 @@ impl Transport { reader.read_exact(&mut content).await?; let msg = std::str::from_utf8(&content).context("invalid utf8 from server")?; - info!("<- {}", msg); + info!("{language_server_name} <- {msg}"); // try parsing as output (server response) or call (server request) let output: serde_json::Result = serde_json::from_str(msg); @@ -135,12 +139,13 @@ impl Transport { async fn recv_server_error( err: &mut (impl AsyncBufRead + Unpin + Send), buffer: &mut String, + language_server_name: &str, ) -> Result<()> { buffer.truncate(0); if err.read_line(buffer).await? == 0 { return Err(Error::StreamClosed); }; - error!("err <- {:?}", buffer); + error!("{language_server_name} err <- {buffer:?}"); Ok(()) } @@ -162,15 +167,17 @@ impl Transport { Payload::Notification(value) => serde_json::to_string(&value)?, Payload::Response(error) => serde_json::to_string(&error)?, }; - self.send_string_to_server(server_stdin, json).await + self.send_string_to_server(server_stdin, json, &self.name) + .await } async fn send_string_to_server( &self, server_stdin: &mut BufWriter, request: String, + language_server_name: &str, ) -> Result<()> { - info!("-> {}", request); + info!("{language_server_name} -> {request}"); // send the headers server_stdin @@ -189,9 +196,13 @@ impl Transport { &self, client_tx: &UnboundedSender<(usize, jsonrpc::Call)>, msg: ServerMessage, + language_server_name: &str, ) -> Result<()> { match msg { - ServerMessage::Output(output) => self.process_request_response(output).await?, + ServerMessage::Output(output) => { + self.process_request_response(output, language_server_name) + .await? + } ServerMessage::Call(call) => { client_tx .send((self.id, call)) @@ -202,14 +213,18 @@ impl Transport { Ok(()) } - async fn process_request_response(&self, output: jsonrpc::Output) -> Result<()> { + async fn process_request_response( + &self, + output: jsonrpc::Output, + language_server_name: &str, + ) -> Result<()> { let (id, result) = match output { jsonrpc::Output::Success(jsonrpc::Success { id, result, .. }) => { - info!("<- {}", result); + info!("{language_server_name} <- {}", result); (id, Ok(result)) } jsonrpc::Output::Failure(jsonrpc::Failure { id, error, .. }) => { - error!("<- {}", error); + error!("{language_server_name} <- {error}"); (id, Err(error.into())) } }; @@ -240,12 +255,17 @@ impl Transport { ) { let mut recv_buffer = String::new(); loop { - match Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await { + match Self::recv_server_message(&mut server_stdout, &mut recv_buffer, &transport.name) + .await + { Ok(msg) => { - match transport.process_server_message(&client_tx, msg).await { + match transport + .process_server_message(&client_tx, msg, &transport.name) + .await + { Ok(_) => {} Err(err) => { - error!("err: <- {:?}", err); + error!("{} err: <- {err:?}", transport.name); break; } }; @@ -270,7 +290,7 @@ impl Transport { params: jsonrpc::Params::None, })); match transport - .process_server_message(&client_tx, notification) + .process_server_message(&client_tx, notification, &transport.name) .await { Ok(_) => {} @@ -281,20 +301,22 @@ impl Transport { break; } Err(err) => { - error!("err: <- {:?}", err); + error!("{} err: <- {err:?}", transport.name); break; } } } } - async fn err(_transport: Arc, mut server_stderr: BufReader) { + async fn err(transport: Arc, mut server_stderr: BufReader) { let mut recv_buffer = String::new(); loop { - match Self::recv_server_error(&mut server_stderr, &mut recv_buffer).await { + match Self::recv_server_error(&mut server_stderr, &mut recv_buffer, &transport.name) + .await + { Ok(_) => {} Err(err) => { - error!("err: <- {:?}", err); + error!("{} err: <- {err:?}", transport.name); break; } } @@ -348,10 +370,11 @@ impl Transport { method: lsp_types::notification::Initialized::METHOD.to_string(), params: jsonrpc::Params::None, })); - match transport.process_server_message(&client_tx, notification).await { + let language_server_name = &transport.name; + match transport.process_server_message(&client_tx, notification, language_server_name).await { Ok(_) => {} Err(err) => { - error!("err: <- {:?}", err); + error!("{language_server_name} err: <- {err:?}"); } } @@ -361,7 +384,7 @@ impl Transport { match transport.send_payload_to_server(&mut server_stdin, msg).await { Ok(_) => {} Err(err) => { - error!("err: <- {:?}", err); + error!("{language_server_name} err: <- {err:?}"); } } } @@ -380,7 +403,7 @@ impl Transport { match transport.send_payload_to_server(&mut server_stdin, msg).await { Ok(_) => {} Err(err) => { - error!("err: <- {:?}", err); + error!("{} err: <- {err:?}", transport.name); } } } diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index b54d6835a5af..45f99e48d275 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -30,6 +30,7 @@ use crate::{ use log::{debug, error, warn}; use std::{ + collections::btree_map::Entry, io::{stdin, stdout}, path::Path, sync::Arc, @@ -564,7 +565,7 @@ impl Application { let doc = doc_mut!(self.editor, &doc_save_event.doc_id); let id = doc.id(); doc.detect_language(loader); - let _ = self.editor.refresh_language_server(id); + self.editor.refresh_language_servers(id); } // TODO: fix being overwritten by lsp @@ -662,6 +663,18 @@ impl Application { ) { use helix_lsp::{Call, MethodCall, Notification}; + macro_rules! language_server { + () => { + match self.editor.language_servers.get_by_id(server_id) { + Some(language_server) => language_server, + None => { + warn!("can't find language server with id `{}`", server_id); + return; + } + } + }; + } + match call { Call::Notification(helix_lsp::jsonrpc::Notification { method, params, .. }) => { let notification = match Notification::parse(&method, params) { @@ -677,14 +690,7 @@ impl Application { match notification { Notification::Initialized => { - let language_server = - match self.editor.language_servers.get_by_id(server_id) { - Some(language_server) => language_server, - None => { - warn!("can't find language server with id `{}`", server_id); - return; - } - }; + let language_server = language_server!(); // Trigger a workspace/didChangeConfiguration notification after initialization. // This might not be required by the spec but Neovim does this as well, so it's @@ -694,7 +700,7 @@ impl Application { } let docs = self.editor.documents().filter(|doc| { - doc.language_server().map(|server| server.id()) == Some(server_id) + doc.language_servers().iter().any(|l| l.id() == server_id) }); // trigger textDocument/didOpen for docs that are already open @@ -723,6 +729,7 @@ impl Application { return; } }; + let offset_encoding = language_server!().offset_encoding(); let doc = self.editor.document_by_path_mut(&path).filter(|doc| { if let Some(version) = params.version { if version != doc.version() { @@ -745,18 +752,11 @@ impl Application { use helix_core::diagnostic::{Diagnostic, Range, Severity::*}; use lsp::DiagnosticSeverity; - let language_server = if let Some(language_server) = doc.language_server() { - language_server - } else { - log::warn!("Discarding diagnostic because language server is not initialized: {:?}", diagnostic); - return None; - }; - // TODO: convert inside server let start = if let Some(start) = lsp_pos_to_pos( text, diagnostic.range.start, - language_server.offset_encoding(), + offset_encoding, ) { start } else { @@ -764,11 +764,9 @@ impl Application { return None; }; - let end = if let Some(end) = lsp_pos_to_pos( - text, - diagnostic.range.end, - language_server.offset_encoding(), - ) { + let end = if let Some(end) = + lsp_pos_to_pos(text, diagnostic.range.end, offset_encoding) + { end } else { log::warn!("lsp position out of bounds - {:?}", diagnostic); @@ -807,14 +805,19 @@ impl Application { None => None, }; - let tags = if let Some(ref tags) = diagnostic.tags { - let new_tags = tags.iter().filter_map(|tag| { - match *tag { - lsp::DiagnosticTag::DEPRECATED => Some(DiagnosticTag::Deprecated), - lsp::DiagnosticTag::UNNECESSARY => Some(DiagnosticTag::Unnecessary), - _ => None - } - }).collect(); + let tags = if let Some(tags) = &diagnostic.tags { + let new_tags = tags + .iter() + .filter_map(|tag| match *tag { + lsp::DiagnosticTag::DEPRECATED => { + Some(DiagnosticTag::Deprecated) + } + lsp::DiagnosticTag::UNNECESSARY => { + Some(DiagnosticTag::Unnecessary) + } + _ => None, + }) + .collect(); new_tags } else { @@ -830,11 +833,12 @@ impl Application { tags, source: diagnostic.source.clone(), data: diagnostic.data.clone(), + language_server_id: server_id, }) }) .collect(); - doc.set_diagnostics(diagnostics); + doc.replace_diagnostics(diagnostics, server_id); } // Sort diagnostics first by severity and then by line numbers. @@ -842,13 +846,26 @@ impl Application { params .diagnostics .sort_unstable_by_key(|d| (d.severity, d.range.start)); + let diagnostics = params + .diagnostics + .into_iter() + .map(|d| (d, server_id, offset_encoding)) + .collect(); // Insert the original lsp::Diagnostics here because we may have no open document // for diagnosic message and so we can't calculate the exact position. // When using them later in the diagnostics picker, we calculate them on-demand. - self.editor - .diagnostics - .insert(params.uri, params.diagnostics); + match self.editor.diagnostics.entry(params.uri) { + Entry::Occupied(o) => { + let current_diagnostics = o.into_mut(); + // there may entries of other language servers, which is why we can't overwrite the whole entry + current_diagnostics.retain(|(_, lsp_id, _)| *lsp_id != server_id); + current_diagnostics.extend(diagnostics); + } + Entry::Vacant(v) => { + v.insert(diagnostics); + } + }; } Notification::ShowMessage(params) => { log::warn!("unhandled window/showMessage: {:?}", params); @@ -950,10 +967,12 @@ impl Application { .editor .documents_mut() .filter_map(|doc| { - if doc.language_server().map(|server| server.id()) - == Some(server_id) + if doc + .language_servers() + .iter() + .any(|server| server.id() == server_id) { - doc.set_diagnostics(Vec::new()); + doc.clear_diagnostics(server_id); doc.url() } else { None @@ -1029,28 +1048,15 @@ impl Application { })) } Ok(MethodCall::WorkspaceFolders) => { - let language_server = - self.editor.language_servers.get_by_id(server_id).unwrap(); - - Ok(json!(&*language_server.workspace_folders().await)) + Ok(json!(&*language_server!().workspace_folders().await)) } Ok(MethodCall::WorkspaceConfiguration(params)) => { + let language_server = language_server!(); let result: Vec<_> = params .items .iter() - .map(|item| { - let mut config = match &item.scope_uri { - Some(scope) => { - let path = scope.to_file_path().ok()?; - let doc = self.editor.document_by_path(path)?; - doc.language_config()?.config.as_ref()? - } - None => self - .editor - .language_servers - .get_by_id(server_id)? - .config()?, - }; + .filter_map(|item| { + let mut config = language_server.config()?; if let Some(section) = item.section.as_ref() { for part in section.split('.') { config = config.get(part)?; @@ -1074,15 +1080,7 @@ impl Application { } }; - let language_server = match self.editor.language_servers.get_by_id(server_id) { - Some(language_server) => language_server, - None => { - warn!("can't find language server with id `{}`", server_id); - return; - } - }; - - tokio::spawn(language_server.reply(id, reply)); + tokio::spawn(language_server!().reply(id, reply)); } Call::Invalid { id } => log::error!("LSP invalid method call id={:?}", id), } diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 5a844e35152e..c7d28e1987c5 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -23,6 +23,7 @@ use helix_core::{ regex::{self, Regex, RegexBuilder}, search::{self, CharMatcher}, selection, shellwords, surround, + syntax::LanguageServerFeature, text_annotations::TextAnnotations, textobject, tree_sitter::Node, @@ -54,13 +55,13 @@ use crate::{ job::Callback, keymap::ReverseKeymap, ui::{ - self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, FilePicker, Picker, - Popup, Prompt, PromptEvent, + self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, + FilePicker, Picker, Popup, Prompt, PromptEvent, }, }; use crate::job::{self, Jobs}; -use futures_util::StreamExt; +use futures_util::{stream::FuturesUnordered, StreamExt, TryStreamExt}; use std::{collections::HashMap, fmt, future::Future}; use std::{collections::HashSet, num::NonZeroUsize}; @@ -3029,7 +3030,7 @@ fn exit_select_mode(cx: &mut Context) { fn goto_first_diag(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let selection = match doc.diagnostics().first() { + let selection = match doc.shown_diagnostics().next() { Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; @@ -3038,7 +3039,7 @@ fn goto_first_diag(cx: &mut Context) { fn goto_last_diag(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let selection = match doc.diagnostics().last() { + let selection = match doc.shown_diagnostics().last() { Some(diag) => Selection::single(diag.range.start, diag.range.end), None => return, }; @@ -3054,10 +3055,9 @@ fn goto_next_diag(cx: &mut Context) { .cursor(doc.text().slice(..)); let diag = doc - .diagnostics() - .iter() + .shown_diagnostics() .find(|diag| diag.range.start > cursor_pos) - .or_else(|| doc.diagnostics().first()); + .or_else(|| doc.shown_diagnostics().next()); let selection = match diag { Some(diag) => Selection::single(diag.range.start, diag.range.end), @@ -3075,11 +3075,12 @@ fn goto_prev_diag(cx: &mut Context) { .cursor(doc.text().slice(..)); let diag = doc - .diagnostics() - .iter() + .shown_diagnostics() + .collect::>() + .into_iter() .rev() .find(|diag| diag.range.start < cursor_pos) - .or_else(|| doc.diagnostics().last()); + .or_else(|| doc.shown_diagnostics().last()); let selection = match diag { // NOTE: the selection is reversed because we're jumping to the @@ -3234,60 +3235,72 @@ pub mod insert { use helix_lsp::lsp; // if ch matches completion char, trigger completion let doc = doc_mut!(cx.editor); - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; + let trigger_completion = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .iter() + .any(|ls| { + let capabilities = ls.capabilities(); - let capabilities = language_server.capabilities(); + // TODO: what if trigger is multiple chars long + matches!(&capabilities.completion_provider, Some(lsp::CompletionOptions { + trigger_characters: Some(triggers), + .. + }) if triggers.iter().any(|trigger| trigger.contains(ch))) + }); - if let Some(lsp::CompletionOptions { - trigger_characters: Some(triggers), - .. - }) = &capabilities.completion_provider - { - // TODO: what if trigger is multiple chars long - if triggers.iter().any(|trigger| trigger.contains(ch)) { - cx.editor.clear_idle_timer(); - super::completion(cx); - } + if trigger_completion { + cx.editor.clear_idle_timer(); + super::completion(cx); } } fn signature_help(cx: &mut Context, ch: char) { + use futures_util::FutureExt; use helix_lsp::lsp; // if ch matches signature_help char, trigger - let doc = doc_mut!(cx.editor); - // The language_server!() macro is not used here since it will - // print an "LSP not active for current buffer" message on - // every keypress. - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let capabilities = language_server.capabilities(); + let (view, doc) = current!(cx.editor); + // lsp doesn't tell us when to close the signature help, so we request + // the help information again after common close triggers which should + // return None, which in turn closes the popup. + let close_triggers = &[')', ';', '.']; + // TODO support multiple language servers (not just the first that is found) + let future = doc + .language_servers_with_feature(LanguageServerFeature::SignatureHelp) + .iter() + .find_map(|ls| { + let capabilities = ls.capabilities(); + + match capabilities { + lsp::ServerCapabilities { + signature_help_provider: + Some(lsp::SignatureHelpOptions { + trigger_characters: Some(triggers), + // TODO: retrigger_characters + .. + }), + .. + } if triggers.iter().any(|trigger| trigger.contains(ch)) + || close_triggers.contains(&ch) => + { + let pos = doc.position(view.id, ls.offset_encoding()); + ls.text_document_signature_help(doc.identifier(), pos, None) + } + _ if close_triggers.contains(&ch) => ls.text_document_signature_help( + doc.identifier(), + doc.position(view.id, ls.offset_encoding()), + None, + ), + // TODO: what if trigger is multiple chars long + _ => None, + } + }); - if let lsp::ServerCapabilities { - signature_help_provider: - Some(lsp::SignatureHelpOptions { - trigger_characters: Some(triggers), - // TODO: retrigger_characters - .. - }), - .. - } = capabilities - { - // TODO: what if trigger is multiple chars long - let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch)); - // lsp doesn't tell us when to close the signature help, so we request - // the help information again after common close triggers which should - // return None, which in turn closes the popup. - let close_triggers = &[')', ';', '.']; - - if is_trigger || close_triggers.contains(&ch) { - super::signature_help_impl(cx, SignatureHelpInvoked::Automatic); - } + if let Some(future) = future { + super::signature_help_impl_with_future( + cx, + future.boxed(), + SignatureHelpInvoked::Automatic, + ) } } @@ -3301,7 +3314,7 @@ pub mod insert { Some(transaction) } - use helix_core::auto_pairs; + use helix_core::{auto_pairs, syntax::LanguageServerFeature}; pub fn insert_char(cx: &mut Context, c: char) { let (view, doc) = current_ref!(cx.editor); @@ -4046,55 +4059,55 @@ fn format_selections(cx: &mut Context) { use helix_lsp::{lsp, util::range_to_lsp_range}; let (view, doc) = current!(cx.editor); + let view_id = view.id; // via lsp if available // TODO: else via tree-sitter indentation calculations - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let ranges: Vec = doc - .selection(view.id) - .iter() - .map(|range| range_to_lsp_range(doc.text(), *range, language_server.offset_encoding())) - .collect(); - - if ranges.len() != 1 { + if doc.selection(view_id).len() != 1 { cx.editor .set_error("format_selections only supports a single selection for now"); return; } - // TODO: handle fails - // TODO: concurrent map over all ranges - - let range = ranges[0]; - - let request = match language_server.text_document_range_formatting( - doc.identifier(), - range, - lsp::FormattingOptions::default(), - None, - ) { - Some(future) => future, + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::Format) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let ranges: Vec = doc + .selection(view_id) + .iter() + .map(|range| range_to_lsp_range(doc.text(), *range, offset_encoding)) + .collect(); + + // TODO: handle fails + // TODO: concurrent map over all ranges + + let range = ranges[0]; + + let future = language_server.text_document_range_formatting( + doc.identifier(), + range, + lsp::FormattingOptions::default(), + None, + )?; + Some((future, offset_encoding)) + }) { + Some(future_offset_encoding) => future_offset_encoding, None => { cx.editor - .set_error("Language server does not support range formatting"); + .set_error("No language server supports range formatting"); return; } }; - let edits = tokio::task::block_in_place(|| helix_lsp::block_on(request)).unwrap_or_default(); + let edits = tokio::task::block_in_place(|| helix_lsp::block_on(future)).unwrap_or_default(); - let transaction = helix_lsp::util::generate_transaction_from_edits( - doc.text(), - edits, - language_server.offset_encoding(), - ); + let transaction = + helix_lsp::util::generate_transaction_from_edits(doc.text(), edits, offset_encoding); - doc.apply(&transaction, view.id); + doc.apply(&transaction, view_id); } fn join_selections_impl(cx: &mut Context, select_space: bool) { @@ -4231,21 +4244,45 @@ pub fn completion(cx: &mut Context) { doc.savepoint(view) }; - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => return, - }; - - let offset_encoding = language_server.offset_encoding(); let text = savepoint.text.clone(); let cursor = savepoint.cursor(); - let pos = pos_to_lsp_pos(&text, cursor, offset_encoding); + let mut futures: FuturesUnordered<_> = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .iter() + // TODO this should probably already been filtered in something like "language_servers_with_feature" + .filter_map(|language_server| { + let language_server_id = language_server.id(); + let offset_encoding = language_server.offset_encoding(); + let pos = pos_to_lsp_pos(doc.text(), cursor, helix_lsp::OffsetEncoding::Utf8); + let completion_request = language_server.completion(doc.identifier(), pos, None)?; + + Some(async move { + let json = completion_request.await?; + let response: Option = serde_json::from_value(json)?; + + let items = match response { + Some(lsp::CompletionResponse::Array(items)) => items, + // TODO: do something with is_incomplete + Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: _is_incomplete, + items, + })) => items, + None => Vec::new(), + } + .into_iter() + .map(|item| CompletionItem { + item, + language_server_id, + offset_encoding, + resolved: false, + }) + .collect(); - let future = match language_server.completion(doc.identifier(), pos, None) { - Some(future) => future, - None => return, - }; + anyhow::Ok(items) + }) + }) + .collect(); // setup a channel that allows the request to be canceled let (tx, rx) = oneshot::channel(); @@ -4254,12 +4291,20 @@ pub fn completion(cx: &mut Context) { // and the associated request is automatically dropped cx.editor.completion_request_handle = Some(tx); let future = async move { + let items_future = async move { + let mut items = Vec::new(); + // TODO if one completion request errors, all other completion requests are discarded (even if they're valid) + while let Some(mut lsp_items) = futures.try_next().await? { + items.append(&mut lsp_items); + } + anyhow::Ok(items) + }; tokio::select! { biased; _ = rx => { - Ok(serde_json::Value::Null) + Ok(Vec::new()) } - res = future => { + res = items_future => { res } } @@ -4293,9 +4338,9 @@ pub fn completion(cx: &mut Context) { }, )); - cx.callback( - future, - move |editor, compositor, response: Option| { + cx.jobs.callback(async move { + let items = future.await?; + let call = move |editor: &mut Editor, compositor: &mut Compositor| { let (view, doc) = current_ref!(editor); // check if the completion request is stale. // @@ -4306,16 +4351,6 @@ pub fn completion(cx: &mut Context) { return; } - let items = match response { - Some(lsp::CompletionResponse::Array(items)) => items, - // TODO: do something with is_incomplete - Some(lsp::CompletionResponse::List(lsp::CompletionList { - is_incomplete: _is_incomplete, - items, - })) => items, - None => Vec::new(), - }; - if items.is_empty() { // editor.set_error("No completion available"); return; @@ -4326,7 +4361,6 @@ pub fn completion(cx: &mut Context) { editor, savepoint, items, - offset_encoding, start_offset, trigger_offset, size, @@ -4340,8 +4374,9 @@ pub fn completion(cx: &mut Context) { { compositor.remove(SignatureHelp::ID); } - }, - ); + }; + Ok(Callback::EditorCompositor(Box::new(call))) + }); } // comments @@ -5141,7 +5176,7 @@ async fn shell_impl_async( helix_view::document::to_writer(&mut stdin, (encoding::UTF_8, false), &input) .await?; } - Ok::<_, anyhow::Error>(()) + anyhow::Ok(()) }); let (output, _) = tokio::join! { process.wait_with_output(), diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0ad6fb7eb4b4..efef1211c4f6 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,4 +1,4 @@ -use futures_util::FutureExt; +use futures_util::{future::BoxFuture, stream::FuturesUnordered, FutureExt}; use helix_lsp::{ block_on, lsp::{ @@ -8,6 +8,8 @@ use helix_lsp::{ util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, OffsetEncoding, }; +use serde_json::Value; +use tokio_stream::StreamExt; use tui::{ text::{Span, Spans}, widgets::Row, @@ -15,7 +17,9 @@ use tui::{ use super::{align_view, push_jump, Align, Context, Editor, Open}; -use helix_core::{path, text_annotations::InlineAnnotation, Selection}; +use helix_core::{ + path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, +}; use helix_view::{ document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, editor::Action, @@ -25,6 +29,7 @@ use helix_view::{ use crate::{ compositor::{self, Compositor}, + job::Callback, ui::{ self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, FilePicker, Popup, PromptEvent, @@ -35,24 +40,6 @@ use std::{ cmp::Ordering, collections::BTreeMap, fmt::Write, future::Future, path::PathBuf, sync::Arc, }; -/// Gets the language server that is attached to a document, and -/// if it's not active displays a status message. Using this macro -/// in a context where the editor automatically queries the LSP -/// (instead of when the user explicitly does so via a keybind like -/// `gd`) will spam the "LSP inactive" status message confusingly. -#[macro_export] -macro_rules! language_server { - ($editor:expr, $doc:expr) => { - match $doc.language_server() { - Some(language_server) => language_server, - None => { - $editor.set_status("Language server not active for current buffer"); - return; - } - } - }; -} - impl ui::menu::Item for lsp::Location { /// Current working directory. type Data = PathBuf; @@ -87,20 +74,30 @@ impl ui::menu::Item for lsp::Location { } } -impl ui::menu::Item for lsp::SymbolInformation { +struct SymbolInformationItem { + symbol: lsp::SymbolInformation, + offset_encoding: OffsetEncoding, +} + +impl ui::menu::Item for SymbolInformationItem { /// Path to currently focussed document type Data = Option; fn format(&self, current_doc_path: &Self::Data) -> Row { - if current_doc_path.as_ref() == Some(&self.location.uri) { - self.name.as_str().into() + if current_doc_path.as_ref() == Some(&self.symbol.location.uri) { + self.symbol.name.as_str().into() } else { - match self.location.uri.to_file_path() { + match self.symbol.location.uri.to_file_path() { Ok(path) => { let get_relative_path = path::get_relative_path(path.as_path()); - format!("{} ({})", &self.name, get_relative_path.to_string_lossy()).into() + format!( + "{} ({})", + &self.symbol.name, + get_relative_path.to_string_lossy() + ) + .into() } - Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(), + Err(_) => format!("{} ({})", &self.symbol.name, &self.symbol.location.uri).into(), } } } @@ -116,6 +113,7 @@ struct DiagnosticStyles { struct PickerDiagnostic { url: lsp::Url, diag: lsp::Diagnostic, + offset_encoding: OffsetEncoding, } impl ui::menu::Item for PickerDiagnostic { @@ -211,21 +209,19 @@ fn jump_to_location( align_view(doc, view, Align::Center); } -fn sym_picker( - symbols: Vec, - current_path: Option, - offset_encoding: OffsetEncoding, -) -> FilePicker { +type SymbolPicker = FilePicker; + +fn sym_picker(symbols: Vec, current_path: Option) -> SymbolPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? FilePicker::new( symbols, current_path.clone(), - move |cx, symbol, action| { + move |cx, item, action| { let (view, doc) = current!(cx.editor); push_jump(view, doc); - if current_path.as_ref() != Some(&symbol.location.uri) { - let uri = &symbol.location.uri; + if current_path.as_ref() != Some(&item.symbol.location.uri) { + let uri = &item.symbol.location.uri; let path = match uri.to_file_path() { Ok(path) => path, Err(_) => { @@ -245,7 +241,7 @@ fn sym_picker( let (view, doc) = current!(cx.editor); if let Some(range) = - lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) + lsp_range_to_range(doc.text(), item.symbol.location.range, item.offset_encoding) { // we flip the range so that the cursor sits on the start of the symbol // (for example start of the function). @@ -253,7 +249,7 @@ fn sym_picker( align_view(doc, view, Align::Center); } }, - move |_editor, symbol| Some(location_to_file_location(&symbol.location)), + move |_editor, item| Some(location_to_file_location(&item.symbol.location)), ) .truncate_start(false) } @@ -266,10 +262,9 @@ enum DiagnosticsFormat { fn diag_picker( cx: &Context, - diagnostics: BTreeMap>, + diagnostics: BTreeMap>, current_path: Option, format: DiagnosticsFormat, - offset_encoding: OffsetEncoding, ) -> FilePicker { // TODO: drop current_path comparison and instead use workspace: bool flag? @@ -277,10 +272,11 @@ fn diag_picker( let mut flat_diag = Vec::new(); for (url, diags) in diagnostics { flat_diag.reserve(diags.len()); - for diag in diags { + for (diag, _, offset_encoding) in diags { flat_diag.push(PickerDiagnostic { url: url.clone(), diag, + offset_encoding, }); } } @@ -295,7 +291,13 @@ fn diag_picker( FilePicker::new( flat_diag, (styles, format), - move |cx, PickerDiagnostic { url, diag }, action| { + move |cx, + PickerDiagnostic { + url, + diag, + offset_encoding, + }, + action| { if current_path.as_ref() == Some(url) { let (view, doc) = current!(cx.editor); push_jump(view, doc); @@ -306,14 +308,14 @@ fn diag_picker( let (view, doc) = current!(cx.editor); - if let Some(range) = lsp_range_to_range(doc.text(), diag.range, offset_encoding) { + if let Some(range) = lsp_range_to_range(doc.text(), diag.range, *offset_encoding) { // we flip the range so that the cursor sits on the start of the symbol // (for example start of the function). doc.set_selection(view.id, Selection::single(range.head, range.anchor)); align_view(doc, view, Align::Center); } }, - move |_editor, PickerDiagnostic { url, diag }| { + move |_editor, PickerDiagnostic { url, diag, .. }| { let location = lsp::Location::new(url.clone(), diag.range); Some(location_to_file_location(&location)) }, @@ -323,126 +325,149 @@ fn diag_picker( pub fn symbol_picker(cx: &mut Context) { fn nested_to_flat( - list: &mut Vec, + list: &mut Vec, file: &lsp::TextDocumentIdentifier, symbol: lsp::DocumentSymbol, + offset_encoding: OffsetEncoding, ) { #[allow(deprecated)] - list.push(lsp::SymbolInformation { - name: symbol.name, - kind: symbol.kind, - tags: symbol.tags, - deprecated: symbol.deprecated, - location: lsp::Location::new(file.uri.clone(), symbol.selection_range), - container_name: None, + list.push(SymbolInformationItem { + symbol: lsp::SymbolInformation { + name: symbol.name, + kind: symbol.kind, + tags: symbol.tags, + deprecated: symbol.deprecated, + location: lsp::Location::new(file.uri.clone(), symbol.selection_range), + container_name: None, + }, + offset_encoding, }); for child in symbol.children.into_iter().flatten() { - nested_to_flat(list, file, child); + nested_to_flat(list, file, child, offset_encoding); } } let doc = doc!(cx.editor); - let language_server = language_server!(cx.editor, doc); + let mut futures: FuturesUnordered<_> = doc + .language_servers_with_feature(LanguageServerFeature::DocumentSymbols) + .iter() + .filter_map(|ls| { + let request = ls.document_symbols(doc.identifier())?; + Some((request, ls.offset_encoding(), doc.identifier())) + }) + .map(|(request, offset_encoding, doc_id)| async move { + let json = request.await?; + let response: Option = serde_json::from_value(json)?; + let symbols = match response { + Some(symbols) => symbols, + None => return anyhow::Ok(vec![]), + }; + // lsp has two ways to represent symbols (flat/nested) + // convert the nested variant to flat, so that we have a homogeneous list + let symbols = match symbols { + lsp::DocumentSymbolResponse::Flat(symbols) => symbols + .into_iter() + .map(|symbol| SymbolInformationItem { + symbol, + offset_encoding, + }) + .collect(), + lsp::DocumentSymbolResponse::Nested(symbols) => { + let mut flat_symbols = Vec::new(); + for symbol in symbols { + nested_to_flat(&mut flat_symbols, &doc_id, symbol, offset_encoding) + } + flat_symbols + } + }; + Ok(symbols) + }) + .collect(); let current_url = doc.url(); - let offset_encoding = language_server.offset_encoding(); - let future = match language_server.document_symbols(doc.identifier()) { - Some(future) => future, - None => { - cx.editor - .set_error("Language server does not support document symbols"); - return; - } - }; - - cx.callback( - future, - move |editor, compositor, response: Option| { - if let Some(symbols) = response { - // lsp has two ways to represent symbols (flat/nested) - // convert the nested variant to flat, so that we have a homogeneous list - let symbols = match symbols { - lsp::DocumentSymbolResponse::Flat(symbols) => symbols, - lsp::DocumentSymbolResponse::Nested(symbols) => { - let doc = doc!(editor); - let mut flat_symbols = Vec::new(); - for symbol in symbols { - nested_to_flat(&mut flat_symbols, &doc.identifier(), symbol) - } - flat_symbols - } - }; + if futures.is_empty() { + cx.editor + .set_error("No Language server does support document symbols"); + return; + } - let picker = sym_picker(symbols, current_url, offset_encoding); - compositor.push(Box::new(overlaid(picker))) + cx.jobs.callback(async move { + let mut symbols = Vec::new(); + // TODO if one symbol request errors, all other requests are discarded (even if they're valid) + while let Some(mut lsp_items) = futures.try_next().await? { + symbols.append(&mut lsp_items); + } + let call = move |editor: &mut Editor, compositor: &mut Compositor| { + if symbols.is_empty() { + editor.set_error("No symbols available"); + return; } - }, - ) + let picker = sym_picker(symbols, current_url); + compositor.push(Box::new(overlaid(picker))) + }; + + Ok(Callback::EditorCompositor(Box::new(call))) + }); } pub fn workspace_symbol_picker(cx: &mut Context) { let doc = doc!(cx.editor); - let current_url = doc.url(); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - let future = match language_server.workspace_symbols("".to_string()) { - Some(future) => future, - None => { - cx.editor - .set_error("Language server does not support workspace symbols"); - return; + + let get_symbols = move |pattern: String, editor: &mut Editor| { + let doc = doc!(editor); + let mut futures: FuturesUnordered<_> = doc + .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) + .iter() + .filter_map(|ls| Some((ls.workspace_symbols(pattern.clone())?, ls.offset_encoding()))) + .map(|(request, offset_encoding)| async move { + let json = request.await?; + + let response = serde_json::from_value::>>(json)? + .unwrap_or_default() + .into_iter() + .map(|symbol| SymbolInformationItem { + symbol, + offset_encoding, + }) + .collect(); + + anyhow::Ok(response) + }) + .collect(); + + if futures.is_empty() { + editor.set_error("No Language server does support workspace symbols"); } - }; - cx.callback( - future, - move |_editor, compositor, response: Option>| { - let symbols = response.unwrap_or_default(); - let picker = sym_picker(symbols, current_url, offset_encoding); - let get_symbols = |query: String, editor: &mut Editor| { - let doc = doc!(editor); - let language_server = match doc.language_server() { - Some(s) => s, - None => { - // This should not generally happen since the picker will not - // even open in the first place if there is no server. - return async move { Err(anyhow::anyhow!("LSP not active")) }.boxed(); - } - }; - let symbol_request = match language_server.workspace_symbols(query) { - Some(future) => future, - None => { - // This should also not happen since the language server must have - // supported workspace symbols before to reach this block. - return async move { - Err(anyhow::anyhow!( - "Language server does not support workspace symbols" - )) - } - .boxed(); - } - }; + async move { + let mut symbols = Vec::new(); + // TODO if one symbol request errors, all other requests are discarded (even if they're valid) + while let Some(mut lsp_items) = futures.try_next().await? { + symbols.append(&mut lsp_items); + } + anyhow::Ok(symbols) + } + .boxed() + }; - let future = async move { - let json = symbol_request.await?; - let response: Option> = - serde_json::from_value(json)?; + let current_url = doc.url(); + let initial_symbols = get_symbols("".to_owned(), cx.editor); - Ok(response.unwrap_or_default()) - }; - future.boxed() - }; + cx.jobs.callback(async move { + let symbols = initial_symbols.await?; + let call = move |_editor: &mut Editor, compositor: &mut Compositor| { + let picker = sym_picker(symbols, current_url); let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); compositor.push(Box::new(overlaid(dyn_picker))) - }, - ) + }; + + Ok(Callback::EditorCompositor(Box::new(call))) + }); } pub fn diagnostics_picker(cx: &mut Context) { let doc = doc!(cx.editor); - let language_server = language_server!(cx.editor, doc); if let Some(current_url) = doc.url() { - let offset_encoding = language_server.offset_encoding(); let diagnostics = cx .editor .diagnostics @@ -454,7 +479,6 @@ pub fn diagnostics_picker(cx: &mut Context) { [(current_url.clone(), diagnostics)].into(), Some(current_url), DiagnosticsFormat::HideSourcePath, - offset_encoding, ); cx.push_layer(Box::new(overlaid(picker))); } @@ -462,24 +486,28 @@ pub fn diagnostics_picker(cx: &mut Context) { pub fn workspace_diagnostics_picker(cx: &mut Context) { let doc = doc!(cx.editor); - let language_server = language_server!(cx.editor, doc); let current_url = doc.url(); - let offset_encoding = language_server.offset_encoding(); + // TODO not yet filtered by LanguageServerFeature, need to do something similar as Document::shown_diagnostics here for all open documents let diagnostics = cx.editor.diagnostics.clone(); let picker = diag_picker( cx, diagnostics, current_url, DiagnosticsFormat::ShowSourcePath, - offset_encoding, ); cx.push_layer(Box::new(overlaid(picker))); } -impl ui::menu::Item for lsp::CodeActionOrCommand { +struct CodeActionOrCommandItem { + lsp_item: lsp::CodeActionOrCommand, + offset_encoding: OffsetEncoding, + language_server_id: usize, +} + +impl ui::menu::Item for CodeActionOrCommandItem { type Data = (); fn format(&self, _data: &Self::Data) -> Row { - match self { + match &self.lsp_item { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), } @@ -546,45 +574,40 @@ fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool { pub fn code_action(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let selection_range = doc.selection(view.id).primary(); - let offset_encoding = language_server.offset_encoding(); - - let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - let future = match language_server.code_actions( - doc.identifier(), - range, - // Filter and convert overlapping diagnostics - lsp::CodeActionContext { - diagnostics: doc - .diagnostics() - .iter() - .filter(|&diag| { - selection_range - .overlaps(&helix_core::Range::new(diag.range.start, diag.range.end)) - }) - .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) - .collect(), - only: None, - trigger_kind: Some(CodeActionTriggerKind::INVOKED), - }, - ) { - Some(future) => future, - None => { - cx.editor - .set_error("Language server does not support code actions"); - return; - } - }; - - cx.callback( - future, - move |editor, compositor, response: Option| { + let mut futures: FuturesUnordered<_> = doc + .language_servers_with_feature(LanguageServerFeature::CodeAction) + .iter() + // TODO this should probably already been filtered in something like "language_servers_with_feature" + .filter_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let language_server_id = language_server.id(); + let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); + // Filter and convert overlapping diagnostics + let code_action_context = lsp::CodeActionContext { + diagnostics: doc + .diagnostics() + .iter() + .filter(|&diag| { + selection_range + .overlaps(&helix_core::Range::new(diag.range.start, diag.range.end)) + }) + .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding)) + .collect(), + only: None, + trigger_kind: Some(CodeActionTriggerKind::INVOKED), + }; + let code_action_request = + language_server.code_actions(doc.identifier(), range, code_action_context)?; + Some((code_action_request, offset_encoding, language_server_id)) + }) + .map(|(request, offset_encoding, ls_id)| async move { + let json = request.await?; + let response: Option = serde_json::from_value(json)?; let mut actions = match response { Some(a) => a, - None => return, + None => return anyhow::Ok(Vec::new()), }; // remove disabled code actions @@ -596,11 +619,6 @@ pub fn code_action(cx: &mut Context) { ) }); - if actions.is_empty() { - editor.set_status("No code actions available"); - return; - } - // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec. // Many details are modeled after vscode because language servers are usually tested against it. // VScode sorts the codeaction two times: @@ -636,18 +654,48 @@ pub fn code_action(cx: &mut Context) { .reverse() }); - let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| { + Ok(actions + .into_iter() + .map(|lsp_item| CodeActionOrCommandItem { + lsp_item, + offset_encoding, + language_server_id: ls_id, + }) + .collect()) + }) + .collect(); + + if futures.is_empty() { + cx.editor + .set_error("No Language server does support code actions"); + return; + } + + cx.jobs.callback(async move { + let mut actions = Vec::new(); + // TODO if one code action request errors, all other requests are ignored (even if they're valid) + while let Some(mut lsp_items) = futures.try_next().await? { + actions.append(&mut lsp_items); + } + + let call = move |editor: &mut Editor, compositor: &mut Compositor| { + if actions.is_empty() { + editor.set_error("No code actions available"); + return; + } + let mut picker = ui::Menu::new(actions, (), move |editor, action, event| { if event != PromptEvent::Validate { return; } // always present here - let code_action = code_action.unwrap(); + let action = action.unwrap(); + let offset_encoding = action.offset_encoding; - match code_action { + match &action.lsp_item { lsp::CodeActionOrCommand::Command(command) => { log::debug!("code action command: {:?}", command); - execute_lsp_command(editor, command.clone()); + execute_lsp_command(editor, action.language_server_id, command.clone()); } lsp::CodeActionOrCommand::CodeAction(code_action) => { log::debug!("code action: {:?}", code_action); @@ -659,7 +707,7 @@ pub fn code_action(cx: &mut Context) { // if code action provides both edit and command first the edit // should be applied and then the command if let Some(command) = &code_action.command { - execute_lsp_command(editor, command.clone()); + execute_lsp_command(editor, action.language_server_id, command.clone()); } } } @@ -668,8 +716,10 @@ pub fn code_action(cx: &mut Context) { let popup = Popup::new("code-action", picker).with_scrollbar(false); compositor.replace_or_push("code-action", popup); - }, - ) + }; + + Ok(Callback::EditorCompositor(Box::new(call))) + }); } impl ui::menu::Item for lsp::Command { @@ -679,13 +729,14 @@ impl ui::menu::Item for lsp::Command { } } -pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { - let doc = doc!(editor); - let language_server = language_server!(editor, doc); - +pub fn execute_lsp_command(editor: &mut Editor, language_server_id: usize, cmd: lsp::Command) { // the command is executed on the server and communicated back // to the client asynchronously using workspace edits - let future = match language_server.command(cmd) { + let future = match editor + .language_servers + .get_by_id(language_server_id) + .and_then(|language_server| language_server.command(cmd)) + { Some(future) => future, None => { editor.set_error("Language server does not support executing commands"); @@ -977,18 +1028,22 @@ fn to_locations(definitions: Option) -> Vec future, + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::GotoDeclaration) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.goto_declaration(doc.identifier(), pos, None)?; + Some((future, offset_encoding)) + }) { + Some(future_offset_encoding) => future_offset_encoding, None => { cx.editor - .set_error("Language server does not support goto-declaration"); + .set_error("No language server supports goto-declaration"); return; } }; @@ -1004,16 +1059,19 @@ pub fn goto_declaration(cx: &mut Context) { pub fn goto_definition(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.goto_definition(doc.identifier(), pos, None) { - Some(future) => future, + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::GotoDefinition) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.goto_definition(doc.identifier(), pos, None)?; + Some((future, offset_encoding)) + }) { + Some(future_offset_encoding) => future_offset_encoding, None => { cx.editor - .set_error("Language server does not support goto-definition"); + .set_error("No language server supports goto-definition"); return; } }; @@ -1029,16 +1087,19 @@ pub fn goto_definition(cx: &mut Context) { pub fn goto_type_definition(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.goto_type_definition(doc.identifier(), pos, None) { - Some(future) => future, + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::GotoTypeDefinition) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.goto_type_definition(doc.identifier(), pos, None)?; + Some((future, offset_encoding)) + }) { + Some(future_offset_encoding) => future_offset_encoding, None => { cx.editor - .set_error("Language server does not support goto-type-definition"); + .set_error("No language server supports goto-type-definition"); return; } }; @@ -1054,16 +1115,19 @@ pub fn goto_type_definition(cx: &mut Context) { pub fn goto_implementation(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.goto_implementation(doc.identifier(), pos, None) { - Some(future) => future, + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::GotoImplementation) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.goto_implementation(doc.identifier(), pos, None)?; + Some((future, offset_encoding)) + }) { + Some(future_offset_encoding) => future_offset_encoding, None => { cx.editor - .set_error("Language server does not support goto-implementation"); + .set_error("no language server supports goto-implementation"); return; } }; @@ -1080,21 +1144,24 @@ pub fn goto_implementation(cx: &mut Context) { pub fn goto_reference(cx: &mut Context) { let config = cx.editor.config(); let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.goto_reference( - doc.identifier(), - pos, - config.lsp.goto_reference_include_declaration, - None, - ) { - Some(future) => future, + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::GotoReference) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.goto_reference( + doc.identifier(), + pos, + config.lsp.goto_reference_include_declaration, + None, + )?; + Some((future, offset_encoding)) + }) { + Some(future_offset_encoding) => future_offset_encoding, None => { cx.editor - .set_error("Language server does not support goto-reference"); + .set_error("language server supports goto-reference"); return; } }; @@ -1108,7 +1175,7 @@ pub fn goto_reference(cx: &mut Context) { ); } -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Clone, Copy)] pub enum SignatureHelpInvoked { Manual, Automatic, @@ -1120,35 +1187,34 @@ pub fn signature_help(cx: &mut Context) { pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { let (view, doc) = current!(cx.editor); - let was_manually_invoked = invoked == SignatureHelpInvoked::Manual; - let language_server = match doc.language_server() { - Some(language_server) => language_server, + // TODO merge multiple language server signature help into one instead of just taking the first language server that supports it + let future = match doc + .language_servers_with_feature(LanguageServerFeature::SignatureHelp) + .iter() + .find_map(|language_server| { + let pos = doc.position(view.id, language_server.offset_encoding()); + language_server.text_document_signature_help(doc.identifier(), pos, None) + }) { + Some(future) => future.boxed(), None => { // Do not show the message if signature help was invoked // automatically on backspace, trigger characters, etc. - if was_manually_invoked { + if invoked == SignatureHelpInvoked::Manual { cx.editor - .set_status("Language server not active for current buffer"); - } - return; - } - }; - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.text_document_signature_help(doc.identifier(), pos, None) { - Some(f) => f, - None => { - if was_manually_invoked { - cx.editor - .set_error("Language server does not support signature-help"); + .set_error("No language server supports signature-help"); } return; } }; + signature_help_impl_with_future(cx, future, invoked); +} +pub fn signature_help_impl_with_future( + cx: &mut Context, + future: BoxFuture<'static, helix_lsp::Result>, + invoked: SignatureHelpInvoked, +) { cx.callback( future, move |editor, compositor, response: Option| { @@ -1156,7 +1222,7 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { if !(config.lsp.auto_signature_help || SignatureHelp::visible_popup(compositor).is_some() - || was_manually_invoked) + || invoked == SignatureHelpInvoked::Manual) { return; } @@ -1165,7 +1231,7 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { // it very probably means the server was a little slow to respond and the user has // already moved on to something else, making a signature help popup will just be an // annoyance, see https://github.com/helix-editor/helix/issues/3112 - if !was_manually_invoked && editor.mode != Mode::Insert { + if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert { return; } @@ -1255,18 +1321,20 @@ pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { pub fn hover(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier + let request = doc + .language_servers_with_feature(LanguageServerFeature::Hover) + .iter() + .find_map(|language_server| { + let pos = doc.position(view.id, language_server.offset_encoding()); + language_server.text_document_hover(doc.identifier(), pos, None) + }); - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.text_document_hover(doc.identifier(), pos, None) { + let future = match request { Some(future) => future, None => { - cx.editor - .set_error("Language server does not support hover"); + cx.editor.set_error("No language server supports hover"); return; } }; @@ -1349,7 +1417,11 @@ pub fn rename_symbol(cx: &mut Context) { } } - fn create_rename_prompt(editor: &Editor, prefill: String) -> Box { + fn create_rename_prompt( + editor: &Editor, + prefill: String, + language_server_id: Option, + ) -> Box { let prompt = ui::Prompt::new( "rename-to:".into(), None, @@ -1358,27 +1430,36 @@ pub fn rename_symbol(cx: &mut Context) { if event != PromptEvent::Validate { return; } - let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = - match language_server.rename_symbol(doc.identifier(), pos, input.to_string()) { - Some(future) => future, - None => { - cx.editor - .set_error("Language server does not support symbol renaming"); - return; + let request = doc + .language_servers_with_feature(LanguageServerFeature::RenameSymbol) + .iter() + .find_map(|language_server| { + if let Some(language_server_id) = language_server_id { + if language_server.id() != language_server_id { + return None; + } } - }; - match block_on(future) { - Ok(edits) => { - let _ = apply_workspace_edit(cx.editor, offset_encoding, &edits); + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.rename_symbol( + doc.identifier(), + pos, + input.to_string(), + )?; + Some((future, offset_encoding)) + }); + + if let Some((future, offset_encoding)) = request { + match block_on(future) { + Ok(edits) => { + let _ = apply_workspace_edit(cx.editor, offset_encoding, &edits); + } + Err(err) => cx.editor.set_error(err.to_string()), } - Err(err) => cx.editor.set_error(err.to_string()), + } else { + cx.editor + .set_error("No language server supports symbol renaming"); } }, ) @@ -1388,20 +1469,20 @@ pub fn rename_symbol(cx: &mut Context) { } let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - if !language_server.supports_rename() { - cx.editor - .set_error("Language server does not support symbol renaming"); - return; - } - let pos = doc.position(view.id, offset_encoding); + let prepare_rename_request = doc + .language_servers_with_feature(LanguageServerFeature::RenameSymbol) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = language_server.prepare_rename(doc.identifier(), pos)?; + Some((future, offset_encoding, language_server.id())) + }); - match language_server.prepare_rename(doc.identifier(), pos) { + match prepare_rename_request { // Language server supports textDocument/prepareRename, use it. - Some(future) => cx.callback( + Some((future, offset_encoding, ls_id)) => cx.callback( future, move |editor, compositor, response: Option| { let prefill = match get_prefill_from_lsp_response(editor, offset_encoding, response) @@ -1413,7 +1494,7 @@ pub fn rename_symbol(cx: &mut Context) { } }; - let prompt = create_rename_prompt(editor, prefill); + let prompt = create_rename_prompt(editor, prefill, Some(ls_id)); compositor.push(prompt); }, @@ -1423,7 +1504,7 @@ pub fn rename_symbol(cx: &mut Context) { None => { let prefill = get_prefill_from_word_boundary(cx.editor); - let prompt = create_rename_prompt(cx.editor, prefill); + let prompt = create_rename_prompt(cx.editor, prefill, None); cx.push_layer(prompt); } @@ -1432,17 +1513,20 @@ pub fn rename_symbol(cx: &mut Context) { pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = language_server!(cx.editor, doc); - let offset_encoding = language_server.offset_encoding(); - - let pos = doc.position(view.id, offset_encoding); - - let future = match language_server.text_document_document_highlight(doc.identifier(), pos, None) - { + let (future, offset_encoding) = match doc + .language_servers_with_feature(LanguageServerFeature::DocumentHighlight) + .iter() + .find_map(|language_server| { + let offset_encoding = language_server.offset_encoding(); + let pos = doc.position(view.id, offset_encoding); + let future = + language_server.text_document_document_highlight(doc.identifier(), pos, None)?; + Some((future, offset_encoding)) + }) { Some(future) => future, None => { cx.editor - .set_error("Language server does not support document highlight"); + .set_error("No language server supports document-highlight"); return; } }; @@ -1455,8 +1539,6 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { _ => return, }; let (view, doc) = current!(editor); - let language_server = language_server!(editor, doc); - let offset_encoding = language_server.offset_encoding(); let text = doc.text(); let pos = doc.selection(view.id).primary().head; @@ -1502,63 +1584,58 @@ fn compute_inlay_hints_for_view( let view_id = view.id; let doc_id = view.doc; - let language_server = doc.language_server()?; - - let capabilities = language_server.capabilities(); - - let (future, new_doc_inlay_hints_id) = match capabilities.inlay_hint_provider { - Some( - lsp::OneOf::Left(true) - | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)), - ) => { - let doc_text = doc.text(); - let len_lines = doc_text.len_lines(); - - // Compute ~3 times the current view height of inlay hints, that way some scrolling - // will not show half the view with hints and half without while still being faster - // than computing all the hints for the full file (which could be dozens of time - // longer than the view is). - let view_height = view.inner_height(); - let first_visible_line = - doc_text.char_to_line(view.offset.anchor.min(doc_text.len_chars())); - let first_line = first_visible_line.saturating_sub(view_height); - let last_line = first_visible_line - .saturating_add(view_height.saturating_mul(2)) - .min(len_lines); - - let new_doc_inlay_hint_id = DocumentInlayHintsId { - first_line, - last_line, - }; - // Don't recompute the annotations in case nothing has changed about the view - if !doc.inlay_hints_oudated - && doc - .inlay_hints(view_id) - .map_or(false, |dih| dih.id == new_doc_inlay_hint_id) - { - return None; - } + let language_servers = doc.language_servers_with_feature(LanguageServerFeature::InlayHints); + let language_server = language_servers.iter().find(|language_server| { + matches!( + language_server.capabilities().inlay_hint_provider, + Some( + lsp::OneOf::Left(true) + | lsp::OneOf::Right(lsp::InlayHintServerCapabilities::Options(_)) + ) + ) + })?; + + let doc_text = doc.text(); + let len_lines = doc_text.len_lines(); + + // Compute ~3 times the current view height of inlay hints, that way some scrolling + // will not show half the view with hints and half without while still being faster + // than computing all the hints for the full file (which could be dozens of time + // longer than the view is). + let view_height = view.inner_height(); + let first_visible_line = doc_text.char_to_line(view.offset.anchor.min(doc_text.len_chars())); + let first_line = first_visible_line.saturating_sub(view_height); + let last_line = first_visible_line + .saturating_add(view_height.saturating_mul(2)) + .min(len_lines); + + let new_doc_inlay_hints_id = DocumentInlayHintsId { + first_line, + last_line, + }; + // Don't recompute the annotations in case nothing has changed about the view + if !doc.inlay_hints_oudated + && doc + .inlay_hints(view_id) + .map_or(false, |dih| dih.id == new_doc_inlay_hints_id) + { + return None; + } - let doc_slice = doc_text.slice(..); - let first_char_in_range = doc_slice.line_to_char(first_line); - let last_char_in_range = doc_slice.line_to_char(last_line); + let doc_slice = doc_text.slice(..); + let first_char_in_range = doc_slice.line_to_char(first_line); + let last_char_in_range = doc_slice.line_to_char(last_line); - let range = helix_lsp::util::range_to_lsp_range( - doc_text, - helix_core::Range::new(first_char_in_range, last_char_in_range), - language_server.offset_encoding(), - ); + let range = helix_lsp::util::range_to_lsp_range( + doc_text, + helix_core::Range::new(first_char_in_range, last_char_in_range), + language_server.offset_encoding(), + ); - ( - language_server.text_document_range_inlay_hints(doc.identifier(), range, None), - new_doc_inlay_hint_id, - ) - } - _ => return None, - }; + let offset_encoding = language_server.offset_encoding(); let callback = super::make_job_callback( - future?, + language_server.text_document_range_inlay_hints(doc.identifier(), range, None)?, move |editor, _compositor, response: Option>| { // The config was modified or the window was closed while the request was in flight if !editor.config().lsp.display_inlay_hints || editor.tree.try_get(view_id).is_none() { @@ -1572,8 +1649,8 @@ fn compute_inlay_hints_for_view( }; // If we have neither hints nor an LSP, empty the inlay hints since they're now oudated - let (mut hints, offset_encoding) = match (response, doc.language_server()) { - (Some(h), Some(ls)) if !h.is_empty() => (h, ls.offset_encoding()), + let mut hints = match response { + Some(hints) if !hints.is_empty() => hints, _ => { doc.set_inlay_hints( view_id, diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 81a24059a900..b78de7729a22 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1329,23 +1329,20 @@ fn lsp_workspace_command( if event != PromptEvent::Validate { return Ok(()); } - - let (_, doc) = current!(cx.editor); - - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => { - cx.editor - .set_status("Language server not active for current buffer"); - return Ok(()); - } - }; - - let options = match &language_server.capabilities().execute_command_provider { - Some(options) => options, + let doc = doc!(cx.editor); + let language_servers = + doc.language_servers_with_feature(LanguageServerFeature::WorkspaceCommand); + let (language_server_id, options) = match language_servers.iter().find_map(|ls| { + ls.capabilities() + .execute_command_provider + .as_ref() + .map(|options| (ls.id(), options)) + }) { + Some(id_options) => id_options, None => { - cx.editor - .set_status("Workspace commands are not supported for this language server"); + cx.editor.set_status( + "No active language servers for this document support workspace commands", + ); return Ok(()); } }; @@ -1362,8 +1359,8 @@ fn lsp_workspace_command( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::Picker::new(commands, (), |cx, command, _action| { - execute_lsp_command(cx.editor, command.clone()); + let picker = ui::Picker::new(commands, (), move |cx, command, _action| { + execute_lsp_command(cx.editor, language_server_id, command.clone()); }); compositor.push(Box::new(overlaid(picker))) }, @@ -1376,6 +1373,7 @@ fn lsp_workspace_command( if options.commands.iter().any(|c| c == &command) { execute_lsp_command( cx.editor, + language_server_id, helix_lsp::lsp::Command { title: command.clone(), arguments: None, @@ -1426,7 +1424,7 @@ fn lsp_restart( .collect(); for document_id in document_ids_to_refresh { - cx.editor.refresh_language_server(document_id); + cx.editor.refresh_language_servers(document_id); } Ok(()) @@ -1443,21 +1441,63 @@ fn lsp_stop( let doc = doc!(cx.editor); - let ls_id = doc - .language_server() - .map(|ls| ls.id()) - .context("LSP not running for the current document")?; + // TODO this stops language servers which may be used in another doc/language type that uses the same language servers + // I'm not sure if this is really what we want + let ls_shutdown_names = doc + .language_servers() + .iter() + .map(|ls| ls.name()) + .collect::>(); - let config = doc - .language_config() - .context("LSP not defined for the current document")?; - cx.editor.language_servers.stop(config); + for ls_name in &ls_shutdown_names { + cx.editor.language_servers.stop(ls_name); + } + + let doc_ids_active_clients: Vec<_> = cx + .editor + .documents() + .filter_map(|doc| { + let doc_active_ls_ids: Vec<_> = doc + .language_servers() + .iter() + .filter(|ls| !ls_shutdown_names.contains(&ls.name())) + .map(|ls| ls.id()) + .collect(); + + let active_clients: Vec<_> = cx + .editor + .language_servers + .iter_clients() + .filter(|client| doc_active_ls_ids.contains(&client.id())) + .map(Clone::clone) + .collect(); + + if active_clients.len() != doc.language_servers().len() { + Some((doc.id(), active_clients)) + } else { + None + } + }) + .collect(); + + for (doc_id, active_clients) in doc_ids_active_clients { + let doc = cx.editor.documents.get_mut(&doc_id).unwrap(); + + let stopped_clients: Vec<_> = doc + .language_servers() + .iter() + .filter(|ls| { + !active_clients + .iter() + .any(|active_ls| active_ls.id() == ls.id()) + }) + .map(|ls| ls.id()) + .collect(); // is necessary because of borrow-checking - for doc in cx.editor.documents_mut() { - if doc.language_server().map_or(false, |ls| ls.id() == ls_id) { - doc.set_language_server(None); - doc.set_diagnostics(Default::default()); + for client_id in stopped_clients { + doc.clear_diagnostics(client_id) } + doc.set_language_servers(active_clients); } Ok(()) @@ -1850,7 +1890,7 @@ fn language( doc.detect_indent_and_line_ending(); let id = doc.id(); - cx.editor.refresh_language_server(id); + cx.editor.refresh_language_servers(id); Ok(()) } @@ -2588,7 +2628,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "lsp-restart", aliases: &[], - doc: "Restarts the Language Server that is in use by the current doc", + doc: "Restarts the language servers used by the current doc", fun: lsp_restart, signature: CommandSignature::none(), }, diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs index 480c2c67579b..031f982ccea2 100644 --- a/helix-term/src/health.rs +++ b/helix-term/src/health.rs @@ -2,7 +2,10 @@ use crossterm::{ style::{Color, Print, Stylize}, tty::IsTty, }; -use helix_core::config::{default_syntax_loader, user_syntax_loader}; +use helix_core::{ + config::{default_syntax_loader, user_syntax_loader}, + syntax::LanguageServerFeatureConfiguration, +}; use helix_loader::grammar::load_runtime_file; use helix_view::clipboard::get_clipboard_provider; use std::io::Write; @@ -192,10 +195,14 @@ pub fn languages_all() -> std::io::Result<()> { for lang in &syn_loader_conf.language { column(&lang.language_id, Color::Reset); - let lsp = lang - .language_server - .as_ref() - .map(|lsp| lsp.command.to_string()); + // TODO multiple language servers (check binary for each supported language server, not just the first) + + let lsp = lang.language_servers.first().and_then(|lsp| { + syn_loader_conf + .language_server + .get(lsp.name()) + .map(|config| config.command.clone()) + }); check_binary(lsp); let dap = lang.debugger.as_ref().map(|dap| dap.command.to_string()); @@ -264,11 +271,15 @@ pub fn language(lang_str: String) -> std::io::Result<()> { } }; + // TODO multiple language servers probe_protocol( "language server", - lang.language_server - .as_ref() - .map(|lsp| lsp.command.to_string()), + lang.language_servers.first().and_then(|lsp| { + syn_loader_conf + .language_server + .get(lsp.name()) + .map(|config| config.command.clone()) + }), )?; probe_protocol( diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index c5c405801f28..859403a7f64e 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -15,7 +15,7 @@ use helix_view::{graphics::Rect, Document, Editor}; use crate::commands; use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; -use helix_lsp::{lsp, util}; +use helix_lsp::{lsp, util, OffsetEncoding}; impl menu::Item for CompletionItem { type Data = (); @@ -38,6 +38,7 @@ impl menu::Item for CompletionItem { || self.item.tags.as_ref().map_or(false, |tags| { tags.contains(&lsp::CompletionItemTag::DEPRECATED) }); + menu::Row::new(vec![ menu::Cell::from(Span::styled( self.item.label.as_str(), @@ -79,19 +80,16 @@ impl menu::Item for CompletionItem { } None => "", }), - // self.detail.as_deref().unwrap_or("") - // self.label_details - // .as_ref() - // .or(self.detail()) - // .as_str(), ]) } } #[derive(Debug, PartialEq, Default, Clone)] -struct CompletionItem { - item: lsp::CompletionItem, - resolved: bool, +pub struct CompletionItem { + pub item: lsp::CompletionItem, + pub language_server_id: usize, + pub offset_encoding: OffsetEncoding, + pub resolved: bool, } /// Wraps a Menu. @@ -109,21 +107,13 @@ impl Completion { pub fn new( editor: &Editor, savepoint: Arc, - mut items: Vec, - offset_encoding: helix_lsp::OffsetEncoding, + mut items: Vec, start_offset: usize, trigger_offset: usize, ) -> Self { let replace_mode = editor.config().completion_replace; // Sort completion items according to their preselect status (given by the LSP server) - items.sort_by_key(|item| !item.preselect.unwrap_or(false)); - let items = items - .into_iter() - .map(|item| CompletionItem { - item, - resolved: false, - }) - .collect(); + items.sort_by_key(|item| !item.item.preselect.unwrap_or(false)); // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { @@ -131,7 +121,6 @@ impl Completion { doc: &Document, view_id: ViewId, item: &CompletionItem, - offset_encoding: helix_lsp::OffsetEncoding, trigger_offset: usize, include_placeholder: bool, replace_mode: bool, @@ -154,6 +143,8 @@ impl Completion { } }; + let offset_encoding = item.offset_encoding; + let Some(range) = util::lsp_range_to_range(doc.text(), edit.range, offset_encoding) else{ return Transaction::new(doc.text()); }; @@ -247,15 +238,8 @@ impl Completion { // always present here let item = item.unwrap(); - let transaction = item_to_transaction( - doc, - view.id, - item, - offset_encoding, - trigger_offset, - true, - replace_mode, - ); + let transaction = + item_to_transaction(doc, view.id, item, trigger_offset, true, replace_mode); doc.apply_temporary(&transaction, view.id); } PromptEvent::Validate => { @@ -267,10 +251,15 @@ impl Completion { // always present here let mut item = item.unwrap().clone(); + let language_server = editor + .language_servers + .get_by_id(item.language_server_id) + .unwrap(); + // resolve item if not yet resolved if !item.resolved { if let Some(resolved) = - Self::resolve_completion_item(doc, item.item.clone()) + Self::resolve_completion_item(language_server, item.item.clone()) { item.item = resolved; } @@ -281,7 +270,6 @@ impl Completion { doc, view.id, &item, - offset_encoding, trigger_offset, false, replace_mode, @@ -299,7 +287,7 @@ impl Completion { let transaction = util::generate_transaction_from_edits( doc.text(), additional_edits, - offset_encoding, // TODO: should probably transcode in Client + item.offset_encoding, // TODO: should probably transcode in Client ); doc.apply(&transaction, view.id); } @@ -323,10 +311,17 @@ impl Completion { } fn resolve_completion_item( - doc: &Document, + language_server: &helix_lsp::Client, completion_item: lsp::CompletionItem, ) -> Option { - let language_server = doc.language_server()?; + let completion_resolve_provider = language_server + .capabilities() + .completion_provider + .as_ref()? + .resolve_provider; + if completion_resolve_provider != Some(true) { + return None; + } let future = language_server.resolve_completion_item(completion_item)?; let response = helix_lsp::block_on(future); @@ -397,8 +392,11 @@ impl Completion { Some(item) if !item.resolved => item.clone(), _ => return false, }; - - let language_server = match doc!(cx.editor).language_server() { + let language_server = match cx + .editor + .language_servers + .get_by_id(current_item.language_server_id) + { Some(language_server) => language_server, None => return false, }; @@ -422,13 +420,14 @@ impl Completion { .unwrap() .completion { - completion.replace_item( - current_item, - CompletionItem { - item: resolved_item, - resolved: true, - }, - ); + let resolved_item = CompletionItem { + item: resolved_item, + language_server_id: current_item.language_server_id, + offset_encoding: current_item.offset_encoding, + resolved: true, + }; + + completion.replace_item(current_item, resolved_item); } }, ); diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index f0989fa887ab..43b5d1af6ec3 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -33,7 +33,7 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; use tui::{buffer::Buffer as Surface, text::Span}; -use super::statusline; +use super::{completion::CompletionItem, statusline}; use super::{document::LineDecoration, lsp::SignatureHelp}; pub struct EditorView { @@ -650,7 +650,7 @@ impl EditorView { .primary() .cursor(doc.text().slice(..)); - let diagnostics = doc.diagnostics().iter().filter(|diagnostic| { + let diagnostics = doc.shown_diagnostics().filter(|diagnostic| { diagnostic.range.start <= cursor && diagnostic.range.end >= cursor }); @@ -953,20 +953,13 @@ impl EditorView { &mut self, editor: &mut Editor, savepoint: Arc, - items: Vec, - offset_encoding: helix_lsp::OffsetEncoding, + items: Vec, start_offset: usize, trigger_offset: usize, size: Rect, ) -> Option { - let mut completion = Completion::new( - editor, - savepoint, - items, - offset_encoding, - start_offset, - trigger_offset, - ); + let mut completion = + Completion::new(editor, savepoint, items, start_offset, trigger_offset); if completion.is_empty() { // skip if we got no completion results diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 3e9a14b06307..118836c0f4d7 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -17,7 +17,7 @@ mod text; use crate::compositor::{Component, Compositor}; use crate::filter_picker_entry; use crate::job::{self, Callback}; -pub use completion::Completion; +pub use completion::{Completion, CompletionItem}; pub use editor::EditorView; pub use markdown::Markdown; pub use menu::Menu; @@ -238,6 +238,7 @@ pub mod completers { use crate::ui::prompt::Completion; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; + use helix_core::syntax::LanguageServerFeature; use helix_view::document::SCRATCH_BUFFER_NAME; use helix_view::theme; use helix_view::{editor::Config, Editor}; @@ -393,17 +394,13 @@ pub mod completers { pub fn lsp_workspace_command(editor: &Editor, input: &str) -> Vec { let matcher = Matcher::default(); - let (_, doc) = current_ref!(editor); - - let language_server = match doc.language_server() { - Some(language_server) => language_server, - None => { - return vec![]; - } - }; - - let options = match &language_server.capabilities().execute_command_provider { - Some(options) => options, + let language_servers = + doc!(editor).language_servers_with_feature(LanguageServerFeature::WorkspaceCommand); + let options = match language_servers + .into_iter() + .find_map(|ls| ls.capabilities().execute_command_provider.as_ref()) + { + Some(id_options) => id_options, None => { return vec![]; } diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 887863519319..b10e8076d404 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -197,15 +197,16 @@ where ); } +// TODO think about handling multiple language servers fn render_lsp_spinner(context: &mut RenderContext, write: F) where F: Fn(&mut RenderContext, String, Option