Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/commands/lsp.rs')
| -rw-r--r-- | helix-term/src/commands/lsp.rs | 1970 |
1 files changed, 885 insertions, 1085 deletions
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0494db3e..810e3adf 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,91 +1,96 @@ -use futures_util::{stream::FuturesOrdered, FutureExt}; use helix_lsp::{ block_on, - lsp::{ - self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity, - NumberOrString, - }, - util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, - Client, LanguageServerId, OffsetEncoding, + lsp::{self, CodeAction, CodeActionOrCommand, DiagnosticSeverity, NumberOrString}, + util::{diagnostic_to_lsp_diagnostic, lsp_pos_to_pos, lsp_range_to_range, range_to_lsp_range}, + OffsetEncoding, }; -use tokio_stream::StreamExt; -use tui::{text::Span, widgets::Row}; +use tui::text::{Span, Spans}; -use super::{align_view, push_jump, Align, Context, Editor}; +use super::{align_view, push_jump, Align, Context, Editor, Open}; -use helix_core::{ - diagnostic::DiagnosticProvider, syntax::config::LanguageServerFeature, - text_annotations::InlineAnnotation, Selection, Uri, -}; -use helix_stdx::path; -use helix_view::{ - document::{DocumentInlayHints, DocumentInlayHintsId}, - editor::Action, - handlers::lsp::SignatureHelpInvoked, - theme::Style, - Document, View, -}; +use helix_core::{path, Selection}; +use helix_view::{apply_transaction, document::Mode, editor::Action, theme::Style}; use crate::{ compositor::{self, Compositor}, - job::Callback, - ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent}, + ui::{ + self, lsp::SignatureHelp, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent, + }, }; -use std::{cmp::Ordering, collections::HashSet, fmt::Display, future::Future, path::Path}; +use std::{ + borrow::Cow, cmp::Ordering, collections::BTreeMap, fmt::Write, path::PathBuf, sync::Arc, +}; -/// Gets the first language server that is attached to a document which supports a specific feature. -/// If there is no configured language server that supports the feature, this 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 "No configured language server supports \<feature>" status message confusingly. +/// 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_with_feature { - ($editor:expr, $doc:expr, $feature:expr) => {{ - let language_server = $doc.language_servers_with_feature($feature).next(); - match language_server { +macro_rules! language_server { + ($editor:expr, $doc:expr) => { + match $doc.language_server() { Some(language_server) => language_server, None => { - $editor.set_error(format!( - "No configured language server supports {}", - $feature - )); + $editor.set_status("Language server not active for current buffer"); return; } } - }}; -} - -/// A wrapper around `lsp::Location` that swaps out the LSP URI for `helix_core::Uri` and adds -/// the server's offset encoding. -#[derive(Debug, Clone, PartialEq, Eq)] -struct Location { - uri: Uri, - range: lsp::Range, - offset_encoding: OffsetEncoding, + }; } -fn lsp_location_to_location( - location: lsp::Location, - offset_encoding: OffsetEncoding, -) -> Option<Location> { - let uri = match location.uri.try_into() { - Ok(uri) => uri, - Err(err) => { - log::warn!("discarding invalid or unsupported URI: {err}"); - return None; +impl ui::menu::Item for lsp::Location { + /// Current working directory. + type Data = PathBuf; + + fn label(&self, cwdir: &Self::Data) -> Spans { + // The preallocation here will overallocate a few characters since it will account for the + // URL's scheme, which is not used most of the time since that scheme will be "file://". + // Those extra chars will be used to avoid allocating when writing the line number (in the + // common case where it has 5 digits or less, which should be enough for a cast majority + // of usages). + let mut res = String::with_capacity(self.uri.as_str().len()); + + if self.uri.scheme() == "file" { + // With the preallocation above and UTF-8 paths already, this closure will do one (1) + // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. + let mut write_path_to_res = || -> Option<()> { + let path = self.uri.to_file_path().ok()?; + res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy()); + Some(()) + }; + write_path_to_res(); + } else { + // Never allocates since we declared the string with this capacity already. + res.push_str(self.uri.as_str()); } - }; - Some(Location { - uri, - range: location.range, - offset_encoding, - }) + + // Most commonly, this will not allocate, especially on Unix systems where the root prefix + // is a simple `/` and not `C:\` (with whatever drive letter) + write!(&mut res, ":{}", self.range.start.line) + .expect("Will only failed if allocating fail"); + res.into() + } } -struct SymbolInformationItem { - location: Location, - symbol: lsp::SymbolInformation, +impl ui::menu::Item for lsp::SymbolInformation { + /// Path to currently focussed document + type Data = Option<lsp::Url>; + + fn label(&self, current_doc_path: &Self::Data) -> Spans { + if current_doc_path.as_ref() == Some(&self.location.uri) { + self.name.as_str().into() + } else { + match self.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() + } + Err(_) => format!("{} ({})", &self.name, &self.location.uri).into(), + } + } + } } struct DiagnosticStyles { @@ -96,102 +101,148 @@ struct DiagnosticStyles { } struct PickerDiagnostic { - location: Location, + url: lsp::Url, diag: lsp::Diagnostic, } -fn location_to_file_location(location: &Location) -> Option<FileLocation<'_>> { - let path = location.uri.as_path()?; +impl ui::menu::Item for PickerDiagnostic { + type Data = (DiagnosticStyles, DiagnosticsFormat); + + fn label(&self, (styles, format): &Self::Data) -> Spans { + let mut style = self + .diag + .severity + .map(|s| match s { + DiagnosticSeverity::HINT => styles.hint, + DiagnosticSeverity::INFORMATION => styles.info, + DiagnosticSeverity::WARNING => styles.warning, + DiagnosticSeverity::ERROR => styles.error, + _ => Style::default(), + }) + .unwrap_or_default(); + + // remove background as it is distracting in the picker list + style.bg = None; + + let code: Cow<'_, str> = self + .diag + .code + .as_ref() + .map(|c| match c { + NumberOrString::Number(n) => n.to_string().into(), + NumberOrString::String(s) => s.as_str().into(), + }) + .unwrap_or_default(); + + let path = match format { + DiagnosticsFormat::HideSourcePath => String::new(), + DiagnosticsFormat::ShowSourcePath => { + let path = path::get_truncated_path(self.url.path()); + format!("{}: ", path.to_string_lossy()) + } + }; + + Spans::from(vec![ + Span::raw(path), + Span::styled(&self.diag.message, style), + Span::styled(code, style), + ]) + } +} + +fn location_to_file_location(location: &lsp::Location) -> FileLocation { + let path = location.uri.to_file_path().unwrap(); let line = Some(( location.range.start.line as usize, location.range.end.line as usize, )); - Some((path.into(), line)) -} - -fn jump_to_location(editor: &mut Editor, location: &Location, action: Action) { - let (view, doc) = current!(editor); - push_jump(view, doc); - - let Some(path) = location.uri.as_path() else { - let err = format!("unable to convert URI to filepath: {:?}", location.uri); - editor.set_error(err); - return; - }; - jump_to_position( - editor, - path, - location.range, - location.offset_encoding, - action, - ); + (path.into(), line) } -fn jump_to_position( +// TODO: share with symbol picker(symbol.location) +fn jump_to_location( editor: &mut Editor, - path: &Path, - range: lsp::Range, + location: &lsp::Location, offset_encoding: OffsetEncoding, action: Action, ) { - let doc = match editor.open(path, action) { - Ok(id) => doc_mut!(editor, &id), - Err(err) => { - let err = format!("failed to open path: {:?}: {:?}", path, err); + let (view, doc) = current!(editor); + push_jump(view, doc); + + let path = match location.uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", location.uri); editor.set_error(err); return; } }; - let view = view_mut!(editor); + match editor.open(&path, action) { + Ok(_) => (), + Err(err) => { + let err = format!("failed to open path: {:?}: {:?}", location.uri, err); + editor.set_error(err); + return; + } + } + let (view, doc) = current!(editor); + let definition_pos = location.range.start; // TODO: convert inside server - let new_range = if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) + let new_pos = if let Some(new_pos) = lsp_pos_to_pos(doc.text(), definition_pos, offset_encoding) { - new_range + new_pos } else { - log::warn!("lsp position out of bounds - {:?}", range); return; }; - // 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(new_range.head, new_range.anchor)); - if action.align_view(view, doc.id()) { - align_view(doc, view, Align::Center); - } + doc.set_selection(view.id, Selection::point(new_pos)); + align_view(doc, view, Align::Center); } -fn display_symbol_kind(kind: lsp::SymbolKind) -> &'static str { - match kind { - lsp::SymbolKind::FILE => "file", - lsp::SymbolKind::MODULE => "module", - lsp::SymbolKind::NAMESPACE => "namespace", - lsp::SymbolKind::PACKAGE => "package", - lsp::SymbolKind::CLASS => "class", - lsp::SymbolKind::METHOD => "method", - lsp::SymbolKind::PROPERTY => "property", - lsp::SymbolKind::FIELD => "field", - lsp::SymbolKind::CONSTRUCTOR => "construct", - lsp::SymbolKind::ENUM => "enum", - lsp::SymbolKind::INTERFACE => "interface", - lsp::SymbolKind::FUNCTION => "function", - lsp::SymbolKind::VARIABLE => "variable", - lsp::SymbolKind::CONSTANT => "constant", - lsp::SymbolKind::STRING => "string", - lsp::SymbolKind::NUMBER => "number", - lsp::SymbolKind::BOOLEAN => "boolean", - lsp::SymbolKind::ARRAY => "array", - lsp::SymbolKind::OBJECT => "object", - lsp::SymbolKind::KEY => "key", - lsp::SymbolKind::NULL => "null", - lsp::SymbolKind::ENUM_MEMBER => "enummem", - lsp::SymbolKind::STRUCT => "struct", - lsp::SymbolKind::EVENT => "event", - lsp::SymbolKind::OPERATOR => "operator", - lsp::SymbolKind::TYPE_PARAMETER => "typeparam", - _ => { - log::warn!("Unknown symbol kind: {:?}", kind); - "" - } - } +fn sym_picker( + symbols: Vec<lsp::SymbolInformation>, + current_path: Option<lsp::Url>, + offset_encoding: OffsetEncoding, +) -> FilePicker<lsp::SymbolInformation> { + // TODO: drop current_path comparison and instead use workspace: bool flag? + FilePicker::new( + symbols, + current_path.clone(), + move |cx, symbol, 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; + let path = match uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", uri); + cx.editor.set_error(err); + return; + } + }; + if let Err(err) = cx.editor.open(&path, action) { + let err = format!("failed to open document: {}: {}", uri, err); + log::error!("{}", err); + cx.editor.set_error(err); + return; + } + } + + let (view, doc) = current!(cx.editor); + + if let Some(range) = + lsp_range_to_range(doc.text(), symbol.location.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, symbol| Some(location_to_file_location(&symbol.location)), + ) + .truncate_start(false) } #[derive(Copy, Clone, PartialEq)] @@ -200,44 +251,27 @@ enum DiagnosticsFormat { HideSourcePath, } -type DiagnosticsPicker = Picker<PickerDiagnostic, DiagnosticStyles>; - fn diag_picker( cx: &Context, - diagnostics: impl IntoIterator<Item = (Uri, Vec<(lsp::Diagnostic, DiagnosticProvider)>)>, + diagnostics: BTreeMap<lsp::Url, Vec<lsp::Diagnostic>>, + current_path: Option<lsp::Url>, format: DiagnosticsFormat, -) -> DiagnosticsPicker { + offset_encoding: OffsetEncoding, +) -> FilePicker<PickerDiagnostic> { // TODO: drop current_path comparison and instead use workspace: bool flag? // flatten the map to a vec of (url, diag) pairs let mut flat_diag = Vec::new(); - for (uri, diags) in diagnostics { + for (url, diags) in diagnostics { flat_diag.reserve(diags.len()); - - for (diag, provider) in diags { - if let Some(ls) = provider - .language_server_id() - .and_then(|id| cx.editor.language_server_by_id(id)) - { - flat_diag.push(PickerDiagnostic { - location: Location { - uri: uri.clone(), - range: diag.range, - offset_encoding: ls.offset_encoding(), - }, - diag, - }); - } + for diag in diags { + flat_diag.push(PickerDiagnostic { + url: url.clone(), + diag, + }); } } - flat_diag.sort_by(|a, b| { - a.diag - .severity - .unwrap_or(lsp::DiagnosticSeverity::HINT) - .cmp(&b.diag.severity.unwrap_or(lsp::DiagnosticSeverity::HINT)) - }); - let styles = DiagnosticStyles { hint: cx.editor.theme.get("hint"), info: cx.editor.theme.get("info"), @@ -245,352 +279,161 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; - let mut columns = vec![ - ui::PickerColumn::new( - "severity", - |item: &PickerDiagnostic, styles: &DiagnosticStyles| { - match item.diag.severity { - Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint), - Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info), - Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning), - Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error), - _ => Span::raw(""), - } - .into() - }, - ), - ui::PickerColumn::new("source", |item: &PickerDiagnostic, _| { - item.diag.source.as_deref().unwrap_or("").into() - }), - ui::PickerColumn::new("code", |item: &PickerDiagnostic, _| { - match item.diag.code.as_ref() { - Some(NumberOrString::Number(n)) => n.to_string().into(), - Some(NumberOrString::String(s)) => s.as_str().into(), - None => "".into(), + FilePicker::new( + flat_diag, + (styles, format), + move |cx, PickerDiagnostic { url, diag }, action| { + if current_path.as_ref() == Some(url) { + let (view, doc) = current!(cx.editor); + push_jump(view, doc); + } else { + let path = url.to_file_path().unwrap(); + cx.editor.open(&path, action).expect("editor.open failed"); } - }), - ui::PickerColumn::new("message", |item: &PickerDiagnostic, _| { - item.diag.message.as_str().into() - }), - ]; - let mut primary_column = 3; // message - - if format == DiagnosticsFormat::ShowSourcePath { - columns.insert( - // between message code and message - 3, - ui::PickerColumn::new("path", |item: &PickerDiagnostic, _| { - if let Some(path) = item.location.uri.as_path() { - path::get_truncated_path(path) - .to_string_lossy() - .to_string() - .into() - } else { - Default::default() - } - }), - ); - primary_column += 1; - } - Picker::new( - columns, - primary_column, - flat_diag, - styles, - move |cx, diag, action| { - jump_to_location(cx.editor, &diag.location, action); let (view, doc) = current!(cx.editor); - view.diagnostics_handler - .immediately_show_diagnostic(doc, view.id); + + 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 }| { + let location = lsp::Location::new(url.clone(), diag.range); + Some(location_to_file_location(&location)) }, ) - .with_preview(move |_editor, diag| location_to_file_location(&diag.location)) .truncate_start(false) } pub fn symbol_picker(cx: &mut Context) { fn nested_to_flat( - list: &mut Vec<SymbolInformationItem>, + list: &mut Vec<lsp::SymbolInformation>, file: &lsp::TextDocumentIdentifier, - uri: &Uri, symbol: lsp::DocumentSymbol, - offset_encoding: OffsetEncoding, ) { #[allow(deprecated)] - 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, - }, - location: Location { - uri: uri.clone(), - range: symbol.selection_range, - offset_encoding, - }, + 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, }); for child in symbol.children.into_iter().flatten() { - nested_to_flat(list, file, uri, child, offset_encoding); + nested_to_flat(list, file, child); } } let doc = doc!(cx.editor); - let mut seen_language_servers = HashSet::new(); + let language_server = language_server!(cx.editor, doc); + let current_url = doc.url(); + let offset_encoding = language_server.offset_encoding(); - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(LanguageServerFeature::DocumentSymbols) - .filter(|ls| seen_language_servers.insert(ls.id())) - .map(|language_server| { - let request = language_server.document_symbols(doc.identifier()).unwrap(); - let offset_encoding = language_server.offset_encoding(); - let doc_id = doc.identifier(); - let doc_uri = doc - .uri() - .expect("docs with active language servers must be backed by paths"); - - async move { - let symbols = match request.await? { - Some(symbols) => symbols, - None => return anyhow::Ok(vec![]), - }; + 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<lsp::DocumentSymbolResponse>| { + 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 - .into_iter() - .map(|symbol| SymbolInformationItem { - location: Location { - uri: doc_uri.clone(), - range: symbol.location.range, - offset_encoding, - }, - symbol, - }) - .collect(), + 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_id, - &doc_uri, - symbol, - offset_encoding, - ) + nested_to_flat(&mut flat_symbols, &doc.identifier(), symbol) } flat_symbols } }; - Ok(symbols) - } - }) - .collect(); - - if futures.is_empty() { - cx.editor - .set_error("No configured language server supports document symbols"); - return; - } - cx.jobs.callback(async move { - let mut symbols = Vec::new(); - while let Some(response) = futures.next().await { - match response { - Ok(mut items) => symbols.append(&mut items), - Err(err) => log::error!("Error requesting document symbols: {err}"), + let picker = sym_picker(symbols, current_url, offset_encoding); + compositor.push(Box::new(overlayed(picker))) } - } - let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let columns = [ - ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { - display_symbol_kind(item.symbol.kind).into() - }), - // Some symbols in the document symbol picker may have a URI that isn't - // the current file. It should be rare though, so we concatenate that - // URI in with the symbol name in this picker. - ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { - item.symbol.name.as_str().into() - }), - ui::PickerColumn::new("container", |item: &SymbolInformationItem, _| { - item.symbol - .container_name - .as_deref() - .unwrap_or_default() - .into() - }), - ]; - - let picker = Picker::new( - columns, - 1, // name column - symbols, - (), - move |cx, item, action| { - jump_to_location(cx.editor, &item.location, action); - }, - ) - .with_preview(move |_editor, item| location_to_file_location(&item.location)) - .truncate_start(false); - - compositor.push(Box::new(overlaid(picker))) - }; - - Ok(Callback::EditorCompositor(Box::new(call))) - }); + }, + ) } pub fn workspace_symbol_picker(cx: &mut Context) { - use crate::ui::picker::Injector; - let doc = doc!(cx.editor); - if doc - .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) - .count() - == 0 - { - cx.editor - .set_error("No configured language server supports workspace symbols"); - return; - } - - let get_symbols = |pattern: &str, editor: &mut Editor, _data, injector: &Injector<_, _>| { - let doc = doc!(editor); - let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(LanguageServerFeature::WorkspaceSymbols) - .filter(|ls| seen_language_servers.insert(ls.id())) - .map(|language_server| { - let request = language_server - .workspace_symbols(pattern.to_string()) - .unwrap(); - let offset_encoding = language_server.offset_encoding(); - async move { - let symbols = request - .await? - .and_then(|resp| match resp { - lsp::WorkspaceSymbolResponse::Flat(symbols) => Some(symbols), - lsp::WorkspaceSymbolResponse::Nested(_) => None, - }) - .unwrap_or_default(); - - let response: Vec<_> = symbols - .into_iter() - .filter_map(|symbol| { - let uri = match Uri::try_from(&symbol.location.uri) { - Ok(uri) => uri, - Err(err) => { - log::warn!("discarding symbol with invalid URI: {err}"); - return None; - } - }; - Some(SymbolInformationItem { - location: Location { - uri, - range: symbol.location.range, - offset_encoding, - }, - symbol, - }) - }) - .collect(); - - anyhow::Ok(response) - } - }) - .collect(); - - if futures.is_empty() { - editor.set_error("No configured language server supports workspace symbols"); - } - - let injector = injector.clone(); - async move { - while let Some(response) = futures.next().await { - match response { - Ok(items) => { - for item in items { - injector.push(item)?; - } - } - Err(err) => log::error!("Error requesting workspace symbols: {err}"), - } - } - Ok(()) + 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; } - .boxed() }; - let columns = [ - ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { - display_symbol_kind(item.symbol.kind).into() - }), - ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { - item.symbol.name.as_str().into() - }) - .without_filtering(), - ui::PickerColumn::new("container", |item: &SymbolInformationItem, _| { - item.symbol - .container_name - .as_deref() - .unwrap_or_default() - .into() - }), - ui::PickerColumn::new("path", |item: &SymbolInformationItem, _| { - if let Some(path) = item.location.uri.as_path() { - path::get_relative_path(path) - .to_string_lossy() - .to_string() - .into() - } else { - item.symbol.location.uri.to_string().into() + + cx.callback( + future, + move |_editor, compositor, response: Option<Vec<lsp::SymbolInformation>>| { + if let Some(symbols) = response { + let picker = sym_picker(symbols, current_url, offset_encoding); + compositor.push(Box::new(overlayed(picker))) } - }), - ]; - - let picker = Picker::new( - columns, - 1, // name column - [], - (), - move |cx, item, action| { - jump_to_location(cx.editor, &item.location, action); }, ) - .with_preview(|_editor, item| location_to_file_location(&item.location)) - .with_dynamic_query(get_symbols, None) - .truncate_start(false); - - cx.push_layer(Box::new(overlaid(picker))); } pub fn diagnostics_picker(cx: &mut Context) { let doc = doc!(cx.editor); - if let Some(uri) = doc.uri() { - let diagnostics = cx.editor.diagnostics.get(&uri).cloned().unwrap_or_default(); - let picker = diag_picker(cx, [(uri, diagnostics)], DiagnosticsFormat::HideSourcePath); - cx.push_layer(Box::new(overlaid(picker))); + 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 + .get(¤t_url) + .cloned() + .unwrap_or_default(); + let picker = diag_picker( + cx, + [(current_url.clone(), diagnostics)].into(), + Some(current_url), + DiagnosticsFormat::HideSourcePath, + offset_encoding, + ); + cx.push_layer(Box::new(overlayed(picker))); } } pub fn workspace_diagnostics_picker(cx: &mut Context) { - // TODO not yet filtered by LanguageServerFeature, need to do something similar as Document::shown_diagnostics here for all open documents + 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(); let diagnostics = cx.editor.diagnostics.clone(); - let picker = diag_picker(cx, diagnostics, DiagnosticsFormat::ShowSourcePath); - cx.push_layer(Box::new(overlaid(picker))); -} - -struct CodeActionOrCommandItem { - lsp_item: lsp::CodeActionOrCommand, - language_server_id: LanguageServerId, + let picker = diag_picker( + cx, + diagnostics, + current_url, + DiagnosticsFormat::ShowSourcePath, + offset_encoding, + ); + cx.push_layer(Box::new(overlayed(picker))); } -impl ui::menu::Item for CodeActionOrCommandItem { +impl ui::menu::Item for lsp::CodeActionOrCommand { type Data = (); - fn format(&self, _data: &Self::Data) -> Row<'_> { - match &self.lsp_item { + fn label(&self, _data: &Self::Data) -> Spans { + match self { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), } @@ -604,7 +447,7 @@ impl ui::menu::Item for CodeActionOrCommandItem { /// /// While the `kind` field is defined as open ended in the LSP spec (any value may be used) /// in practice a closed set of common values (mostly suggested in the LSP spec) are used. -/// VSCode displays each of these categories separately (separated by a heading in the codeactions picker) +/// VSCode displays each of these categories seperatly (seperated by a heading in the codeactions picker) /// to make them easier to navigate. Helix does not display these headings to the user. /// However it does sort code actions by their categories to achieve the same order as the VScode picker, /// just without the headings. @@ -634,7 +477,7 @@ fn action_category(action: &CodeActionOrCommand) -> u32 { } } -fn action_preferred(action: &CodeActionOrCommand) -> bool { +fn action_prefered(action: &CodeActionOrCommand) -> bool { matches!( action, CodeActionOrCommand::CodeAction(CodeAction { @@ -657,39 +500,44 @@ 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 mut seen_language_servers = HashSet::new(); + let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding); - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(LanguageServerFeature::CodeAction) - .filter(|ls| seen_language_servers.insert(ls.id())) - // 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, language_server_id)) - }) - .map(|(request, ls_id)| async move { - let Some(mut actions) = request.await? else { - return anyhow::Ok(Vec::new()); + 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, + }, + ) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support code actions"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option<lsp::CodeActionResponse>| { + let mut actions = match response { + Some(a) => a, + None => return, }; // remove disabled code actions @@ -701,13 +549,18 @@ 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. + // Many details are modeled after vscode because langauge servers are usually tested against it. // VScode sorts the codeaction two times: // // First the codeactions that fix some diagnostics are moved to the front. // If both codeactions fix some diagnostics (or both fix none) the codeaction - // that is marked with `is_preferred` is shown first. The codeactions are then shown in separate + // that is marked with `is_preffered` is shown first. The codeactions are then shown in seperate // submenus that only contain a certain category (see `action_category`) of actions. // // Below this done in in a single sorting step @@ -729,516 +582,648 @@ pub fn code_action(cx: &mut Context) { return order; } - // if one of the codeactions is marked as preferred show it first + // if one of the codeactions is marked as prefered show it first // otherwise keep the original LSP sorting - action_preferred(action1) - .cmp(&action_preferred(action2)) + action_prefered(action1) + .cmp(&action_prefered(action2)) .reverse() }); - Ok(actions - .into_iter() - .map(|lsp_item| CodeActionOrCommandItem { - lsp_item, - language_server_id: ls_id, - }) - .collect()) - }) - .collect(); - - if futures.is_empty() { - cx.editor - .set_error("No configured language server supports code actions"); - return; - } - - cx.jobs.callback(async move { - let mut actions = Vec::new(); - - while let Some(output) = futures.next().await { - match output { - Ok(mut lsp_items) => actions.append(&mut lsp_items), - Err(err) => log::error!("while gathering code actions: {err}"), - } - } - - 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| { + let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| { if event != PromptEvent::Validate { return; } // always present here - let action = action.unwrap(); - let Some(language_server) = editor.language_server_by_id(action.language_server_id) - else { - editor.set_error("Language Server disappeared"); - return; - }; - let offset_encoding = language_server.offset_encoding(); + let code_action = code_action.unwrap(); - match &action.lsp_item { + match code_action { lsp::CodeActionOrCommand::Command(command) => { log::debug!("code action command: {:?}", command); - editor.execute_lsp_command(command.clone(), action.language_server_id); + execute_lsp_command(editor, command.clone()); } lsp::CodeActionOrCommand::CodeAction(code_action) => { log::debug!("code action: {:?}", code_action); - // we support lsp "codeAction/resolve" for `edit` and `command` fields - let mut resolved_code_action = None; - if code_action.edit.is_none() || code_action.command.is_none() { - if let Some(future) = language_server.resolve_code_action(code_action) { - if let Ok(code_action) = helix_lsp::block_on(future) { - resolved_code_action = Some(code_action); - } - } - } - let resolved_code_action = - resolved_code_action.as_ref().unwrap_or(code_action); - - if let Some(ref workspace_edit) = resolved_code_action.edit { - let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit); + if let Some(ref workspace_edit) = code_action.edit { + log::debug!("edit: {:?}", workspace_edit); + apply_workspace_edit(editor, offset_encoding, workspace_edit); } // 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 { - editor.execute_lsp_command(command.clone(), action.language_server_id); + execute_lsp_command(editor, command.clone()); } } } }); picker.move_down(); // pre-select the first item - let popup = Popup::new("code-action", picker) - .with_scrollbar(false) - .auto_close(true); - + let popup = Popup::new("code-action", picker).with_scrollbar(false); compositor.replace_or_push("code-action", popup); - }; - - Ok(Callback::EditorCompositor(Box::new(call))) - }); + }, + ) } -#[derive(Debug)] -pub struct ApplyEditError { - pub kind: ApplyEditErrorKind, - pub failed_change_idx: usize, +impl ui::menu::Item for lsp::Command { + type Data = (); + fn label(&self, _data: &Self::Data) -> Spans { + self.title.as_str().into() + } } -#[derive(Debug)] -pub enum ApplyEditErrorKind { - DocumentChanged, - FileNotFound, - UnknownURISchema, - IoError(std::io::Error), - // TODO: check edits before applying and propagate failure - // InvalidEdit, +pub fn execute_lsp_command(editor: &mut Editor, cmd: lsp::Command) { + let doc = doc!(editor); + let language_server = language_server!(editor, doc); + + // the command is executed on the server and communicated back + // to the client asynchronously using workspace edits + let future = match language_server.command(cmd) { + Some(future) => future, + None => { + editor.set_error("Language server does not support executing commands"); + return; + } + }; + + tokio::spawn(async move { + let res = future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); } -impl Display for ApplyEditErrorKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ApplyEditErrorKind::DocumentChanged => f.write_str("document has changed"), - ApplyEditErrorKind::FileNotFound => f.write_str("file not found"), - ApplyEditErrorKind::UnknownURISchema => f.write_str("URI schema not supported"), - ApplyEditErrorKind::IoError(err) => f.write_str(&format!("{err}")), +pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { + use lsp::ResourceOp; + use std::fs; + match op { + ResourceOp::Create(op) => { + let path = op.uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if ignore_if_exists && path.exists() { + Ok(()) + } else { + // Create directory if it does not exist + if let Some(dir) = path.parent() { + if !dir.is_dir() { + fs::create_dir_all(dir)?; + } + } + + fs::write(&path, []) + } + } + ResourceOp::Delete(op) => { + let path = op.uri.to_file_path().unwrap(); + if path.is_dir() { + let recursive = op + .options + .as_ref() + .and_then(|options| options.recursive) + .unwrap_or(false); + + if recursive { + fs::remove_dir_all(&path) + } else { + fs::remove_dir(&path) + } + } else if path.is_file() { + fs::remove_file(&path) + } else { + Ok(()) + } + } + ResourceOp::Rename(op) => { + let from = op.old_uri.to_file_path().unwrap(); + let to = op.new_uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if ignore_if_exists && to.exists() { + Ok(()) + } else { + fs::rename(from, &to) + } } } } -/// Precondition: `locations` should be non-empty. -fn goto_impl(editor: &mut Editor, compositor: &mut Compositor, locations: Vec<Location>) { - let cwdir = helix_stdx::env::current_working_dir(); +pub fn apply_workspace_edit( + editor: &mut Editor, + offset_encoding: OffsetEncoding, + workspace_edit: &lsp::WorkspaceEdit, +) { + let mut apply_edits = |uri: &helix_lsp::Url, text_edits: Vec<lsp::TextEdit>| { + let path = match uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", uri); + log::error!("{}", err); + editor.set_error(err); + return; + } + }; - match locations.as_slice() { - [location] => { - jump_to_location(editor, location, Action::Replace); - } - [] => unreachable!("`locations` should be non-empty for `goto_impl`"), - _locations => { - let columns = [ui::PickerColumn::new( - "location", - |item: &Location, cwdir: &std::path::PathBuf| { - let path = if let Some(path) = item.uri.as_path() { - path.strip_prefix(cwdir).unwrap_or(path).to_string_lossy() - } else { - item.uri.to_string().into() - }; - - format!("{path}:{}", item.range.start.line + 1).into() - }, - )]; + let current_view_id = view!(editor).id; + let doc_id = match editor.open(&path, Action::Load) { + Ok(doc_id) => doc_id, + Err(err) => { + let err = format!("failed to open document: {}: {}", uri, err); + log::error!("{}", err); + editor.set_error(err); + return; + } + }; - let picker = Picker::new(columns, 0, locations, cwdir, |cx, location, action| { - jump_to_location(cx.editor, location, action) - }) - .with_preview(|_editor, location| location_to_file_location(location)); - compositor.push(Box::new(overlaid(picker))); + let doc = doc_mut!(editor, &doc_id); + + // Need to determine a view for apply/append_changes_to_history + let selections = doc.selections(); + let view_id = if selections.contains_key(¤t_view_id) { + // use current if possible + current_view_id + } else { + // Hack: we take the first available view_id + selections + .keys() + .next() + .copied() + .expect("No view_id available") + }; + + let transaction = helix_lsp::util::generate_transaction_from_edits( + doc.text(), + text_edits, + offset_encoding, + ); + let view = view_mut!(editor, view_id); + apply_transaction(&transaction, doc, view); + doc.append_changes_to_history(view); + }; + + if let Some(ref changes) = workspace_edit.changes { + log::debug!("workspace changes: {:?}", changes); + for (uri, text_edits) in changes { + let text_edits = text_edits.to_vec(); + apply_edits(uri, text_edits) } + return; + // Not sure if it works properly, it'll be safer to just panic here to avoid breaking some parts of code on which code actions will be used + // TODO: find some example that uses workspace changes, and test it + // for (url, edits) in changes.iter() { + // let file_path = url.origin().ascii_serialization(); + // let file_path = std::path::PathBuf::from(file_path); + // let file = std::fs::File::open(file_path).unwrap(); + // let mut text = Rope::from_reader(file).unwrap(); + // let transaction = edits_to_changes(&text, edits); + // transaction.apply(&mut text); + // } } -} -fn goto_single_impl<P, F>(cx: &mut Context, feature: LanguageServerFeature, request_provider: P) -where - P: Fn(&Client, lsp::Position, lsp::TextDocumentIdentifier) -> Option<F>, - F: Future<Output = helix_lsp::Result<Option<lsp::GotoDefinitionResponse>>> + 'static + Send, -{ - let (view, doc) = current_ref!(cx.editor); - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(feature) - .map(|language_server| { - let offset_encoding = language_server.offset_encoding(); - let pos = doc.position(view.id, offset_encoding); - let future = request_provider(language_server, pos, doc.identifier()).unwrap(); - async move { anyhow::Ok((future.await?, offset_encoding)) } - }) - .collect(); - - cx.jobs.callback(async move { - let mut locations = Vec::new(); - while let Some(response) = futures.next().await { - match response { - Ok((response, offset_encoding)) => match response { - Some(lsp::GotoDefinitionResponse::Scalar(lsp_location)) => { - locations.extend(lsp_location_to_location(lsp_location, offset_encoding)); - } - Some(lsp::GotoDefinitionResponse::Array(lsp_locations)) => { - locations.extend(lsp_locations.into_iter().flat_map(|location| { - lsp_location_to_location(location, offset_encoding) - })); - } - Some(lsp::GotoDefinitionResponse::Link(lsp_locations)) => { - locations.extend( - lsp_locations - .into_iter() - .map(|location_link| { - lsp::Location::new( - location_link.target_uri, - location_link.target_range, - ) + if let Some(ref document_changes) = workspace_edit.document_changes { + match document_changes { + lsp::DocumentChanges::Edits(document_edits) => { + for document_edit in document_edits { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } + } + lsp::DocumentChanges::Operations(operations) => { + log::debug!("document changes - operations: {:?}", operations); + for operation in operations { + match operation { + lsp::DocumentChangeOperation::Op(op) => { + apply_document_resource_op(op).unwrap(); + } + + lsp::DocumentChangeOperation::Edit(document_edit) => { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } }) - .flat_map(|location| { - lsp_location_to_location(location, offset_encoding) - }), - ); + .cloned() + .collect(); + apply_edits(&document_edit.text_document.uri, edits); + } } - None => (), - }, - Err(err) => log::error!("Error requesting locations: {err}"), + } } } - let call = move |editor: &mut Editor, compositor: &mut Compositor| { - if locations.is_empty() { - editor.set_error(match feature { - LanguageServerFeature::GotoDeclaration => "No declaration found.", - LanguageServerFeature::GotoDefinition => "No definition found.", - LanguageServerFeature::GotoTypeDefinition => "No type definition found.", - LanguageServerFeature::GotoImplementation => "No implementation found.", - _ => "No location found.", - }); - } else { - goto_impl(editor, compositor, locations); - } - }; - Ok(Callback::EditorCompositor(Box::new(call))) - }); + } } -pub fn goto_declaration(cx: &mut Context) { - goto_single_impl( - cx, - LanguageServerFeature::GotoDeclaration, - |ls, pos, doc_id| ls.goto_declaration(doc_id, pos, None), - ); +fn goto_impl( + editor: &mut Editor, + compositor: &mut Compositor, + locations: Vec<lsp::Location>, + offset_encoding: OffsetEncoding, +) { + let cwdir = std::env::current_dir().unwrap_or_default(); + + match locations.as_slice() { + [location] => { + jump_to_location(editor, location, offset_encoding, Action::Replace); + } + [] => { + editor.set_error("No definition found."); + } + _locations => { + let picker = FilePicker::new( + locations, + cwdir, + move |cx, location, action| { + jump_to_location(cx.editor, location, offset_encoding, action) + }, + move |_editor, location| Some(location_to_file_location(location)), + ); + compositor.push(Box::new(overlayed(picker))); + } + } +} + +fn to_locations(definitions: Option<lsp::GotoDefinitionResponse>) -> Vec<lsp::Location> { + match definitions { + Some(lsp::GotoDefinitionResponse::Scalar(location)) => vec![location], + Some(lsp::GotoDefinitionResponse::Array(locations)) => locations, + Some(lsp::GotoDefinitionResponse::Link(locations)) => locations + .into_iter() + .map(|location_link| lsp::Location { + uri: location_link.target_uri, + range: location_link.target_range, + }) + .collect(), + None => Vec::new(), + } } pub fn goto_definition(cx: &mut Context) { - goto_single_impl( - cx, - LanguageServerFeature::GotoDefinition, - |ls, pos, doc_id| ls.goto_definition(doc_id, pos, None), + 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, + None => { + cx.editor + .set_error("Language server does not support goto-definition"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option<lsp::GotoDefinitionResponse>| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, ); } pub fn goto_type_definition(cx: &mut Context) { - goto_single_impl( - cx, - LanguageServerFeature::GotoTypeDefinition, - |ls, pos, doc_id| ls.goto_type_definition(doc_id, pos, None), + 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, + None => { + cx.editor + .set_error("Language server does not support goto-type-definition"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option<lsp::GotoDefinitionResponse>| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, ); } pub fn goto_implementation(cx: &mut Context) { - goto_single_impl( - cx, - LanguageServerFeature::GotoImplementation, - |ls, pos, doc_id| ls.goto_implementation(doc_id, pos, None), + 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, + None => { + cx.editor + .set_error("Language server does not support goto-implementation"); + return; + } + }; + + cx.callback( + future, + move |editor, compositor, response: Option<lsp::GotoDefinitionResponse>| { + let items = to_locations(response); + goto_impl(editor, compositor, items, offset_encoding); + }, ); } pub fn goto_reference(cx: &mut Context) { - let config = cx.editor.config(); - let (view, doc) = current_ref!(cx.editor); + let (view, doc) = current!(cx.editor); + let language_server = language_server!(cx.editor, doc); + let offset_encoding = language_server.offset_encoding(); - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(LanguageServerFeature::GotoReference) - .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, - ) - .unwrap(); - async move { anyhow::Ok((future.await?, offset_encoding)) } - }) - .collect(); - - cx.jobs.callback(async move { - let mut locations = Vec::new(); - while let Some(response) = futures.next().await { - match response { - Ok((lsp_locations, offset_encoding)) => locations.extend( - lsp_locations - .into_iter() - .flatten() - .flat_map(|location| lsp_location_to_location(location, offset_encoding)), - ), - Err(err) => log::error!("Error requesting references: {err}"), - } + let pos = doc.position(view.id, offset_encoding); + + let future = match language_server.goto_reference(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support goto-reference"); + return; } - let call = move |editor: &mut Editor, compositor: &mut Compositor| { - if locations.is_empty() { - editor.set_error("No references found."); - } else { - goto_impl(editor, compositor, locations); - } - }; - Ok(Callback::EditorCompositor(Box::new(call))) - }); + }; + + cx.callback( + future, + move |editor, compositor, response: Option<Vec<lsp::Location>>| { + let items = response.unwrap_or_default(); + goto_impl(editor, compositor, items, offset_encoding); + }, + ); } -pub fn signature_help(cx: &mut Context) { - cx.editor - .handlers - .trigger_signature_help(SignatureHelpInvoked::Manual, cx.editor) +#[derive(PartialEq, Eq)] +pub enum SignatureHelpInvoked { + Manual, + Automatic, } -pub fn hover(cx: &mut Context) { - use ui::lsp::hover::Hover; +pub fn signature_help(cx: &mut Context) { + signature_help_impl(cx, SignatureHelpInvoked::Manual) +} +pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) { let (view, doc) = current!(cx.editor); - if doc - .language_servers_with_feature(LanguageServerFeature::Hover) - .count() - == 0 - { - cx.editor - .set_error("No configured language server supports hover"); - return; - } - - let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesOrdered<_> = doc - .language_servers_with_feature(LanguageServerFeature::Hover) - .filter(|ls| seen_language_servers.insert(ls.id())) - .map(|language_server| { - let server_name = language_server.name().to_string(); - // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier - let pos = doc.position(view.id, language_server.offset_encoding()); - let request = language_server - .text_document_hover(doc.identifier(), pos, None) - .unwrap(); - - async move { anyhow::Ok((server_name, request.await?)) } - }) - .collect(); + let was_manually_invoked = invoked == SignatureHelpInvoked::Manual; + + let language_server = match doc.language_server() { + Some(language_server) => language_server, + None => { + // Do not show the message if signature help was invoked + // automatically on backspace, trigger characters, etc. + if was_manually_invoked { + cx.editor + .set_status("Language server not active for current buffer"); + } + return; + } + }; + let offset_encoding = language_server.offset_encoding(); - cx.jobs.callback(async move { - let mut hovers: Vec<(String, lsp::Hover)> = Vec::new(); + let pos = doc.position(view.id, offset_encoding); - while let Some(response) = futures.next().await { - match response { - Ok((server_name, Some(hover))) => hovers.push((server_name, hover)), - Ok(_) => (), - Err(err) => log::error!("Error requesting hover: {err}"), + 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"); } + return; } + }; - let call = move |editor: &mut Editor, compositor: &mut Compositor| { - if hovers.is_empty() { - editor.set_status("No hover results available."); + cx.callback( + future, + move |editor, compositor, response: Option<lsp::SignatureHelp>| { + let config = &editor.config(); + + if !(config.lsp.auto_signature_help + || SignatureHelp::visible_popup(compositor).is_some() + || was_manually_invoked) + { return; } - // create new popup - let contents = Hover::new(hovers, editor.syn_loader.clone()); - let popup = Popup::new(Hover::ID, contents).auto_close(true); - compositor.replace_or_push(Hover::ID, popup); - }; - Ok(Callback::EditorCompositor(Box::new(call))) - }); -} - -pub fn rename_symbol(cx: &mut Context) { - fn get_prefill_from_word_boundary(editor: &Editor) -> String { - let (view, doc) = current_ref!(editor); - let text = doc.text().slice(..); - let primary_selection = doc.selection(view.id).primary(); - if primary_selection.len() > 1 { - primary_selection - } else { - use helix_core::textobject::{textobject_word, TextObject}; - textobject_word(text, primary_selection, TextObject::Inside, 1, false) - } - .fragment(text) - .into() - } - - fn get_prefill_from_lsp_response( - editor: &Editor, - offset_encoding: OffsetEncoding, - response: Option<lsp::PrepareRenameResponse>, - ) -> Result<String, &'static str> { - match response { - Some(lsp::PrepareRenameResponse::Range(range)) => { - let text = doc!(editor).text(); - - Ok(lsp_range_to_range(text, range, offset_encoding) - .ok_or("lsp sent invalid selection range for rename")? - .fragment(text.slice(..)) - .into()) - } - Some(lsp::PrepareRenameResponse::RangeWithPlaceholder { placeholder, .. }) => { - Ok(placeholder) - } - Some(lsp::PrepareRenameResponse::DefaultBehavior { .. }) => { - Ok(get_prefill_from_word_boundary(editor)) + // If the signature help invocation is automatic, don't show it outside of Insert Mode: + // 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 { + return; } - None => Err("lsp did not respond to prepare rename request"), - } - } - fn create_rename_prompt( - editor: &Editor, - prefill: String, - history_register: Option<char>, - language_server_id: Option<LanguageServerId>, - ) -> Box<ui::Prompt> { - let prompt = ui::Prompt::new( - "rename-to:".into(), - history_register, - ui::completers::none, - move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { - if event != PromptEvent::Validate { + let response = match response { + // According to the spec the response should be None if there + // are no signatures, but some servers don't follow this. + Some(s) if !s.signatures.is_empty() => s, + _ => { + compositor.remove(SignatureHelp::ID); return; } - let (view, doc) = current!(cx.editor); + }; + let doc = doc!(editor); + let language = doc.language_name().unwrap_or(""); - let Some(language_server) = doc - .language_servers_with_feature(LanguageServerFeature::RenameSymbol) - .find(|ls| language_server_id.is_none_or(|id| id == ls.id())) - else { - cx.editor - .set_error("No configured language server supports symbol renaming"); - return; - }; + let signature = match response + .signatures + .get(response.active_signature.unwrap_or(0) as usize) + { + Some(s) => s, + None => return, + }; + let mut contents = SignatureHelp::new( + signature.label.clone(), + language.to_string(), + Arc::clone(&editor.syn_loader), + ); + + let signature_doc = if config.lsp.display_signature_help_docs { + signature.documentation.as_ref().map(|doc| match doc { + lsp::Documentation::String(s) => s.clone(), + lsp::Documentation::MarkupContent(markup) => markup.value.clone(), + }) + } else { + None + }; - 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()) - .unwrap(); - - match block_on(future) { - Ok(edits) => { - let _ = cx - .editor - .apply_workspace_edit(offset_encoding, &edits.unwrap_or_default()); + contents.set_signature_doc(signature_doc); + + let active_param_range = || -> Option<(usize, usize)> { + let param_idx = signature + .active_parameter + .or(response.active_parameter) + .unwrap_or(0) as usize; + let param = signature.parameters.as_ref()?.get(param_idx)?; + match ¶m.label { + lsp::ParameterLabel::Simple(string) => { + let start = signature.label.find(string.as_str())?; + Some((start, start + string.len())) + } + lsp::ParameterLabel::LabelOffsets([start, end]) => { + // LS sends offsets based on utf-16 based string representation + // but highlighting in helix is done using byte offset. + use helix_core::str_utils::char_to_byte_idx; + let from = char_to_byte_idx(&signature.label, *start as usize); + let to = char_to_byte_idx(&signature.label, *end as usize); + Some((from, to)) } - Err(err) => cx.editor.set_error(err.to_string()), } - }, - ) - .with_line(prefill, editor); + }; + contents.set_active_param_range(active_param_range()); + + let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID); + let popup = Popup::new(SignatureHelp::ID, contents) + .position(old_popup.and_then(|p| p.get_position())) + .position_bias(Open::Above) + .ignore_escape_key(true); + compositor.replace_or_push(SignatureHelp::ID, popup); + }, + ); +} - Box::new(prompt) - } +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(); - let (view, doc) = current_ref!(cx.editor); - let history_register = cx.register; + // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier - if doc - .language_servers_with_feature(LanguageServerFeature::RenameSymbol) - .next() - .is_none() - { - cx.editor - .set_error("No configured language server supports symbol renaming"); - return; - } + let pos = doc.position(view.id, offset_encoding); - let language_server_with_prepare_rename_support = doc - .language_servers_with_feature(LanguageServerFeature::RenameSymbol) - .find(|ls| { - matches!( - ls.capabilities().rename_provider, - Some(lsp::OneOf::Right(lsp::RenameOptions { - prepare_provider: Some(true), - .. - })) - ) - }); + let future = match language_server.text_document_hover(doc.identifier(), pos, None) { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support hover"); + return; + } + }; - if let Some(language_server) = language_server_with_prepare_rename_support { - let ls_id = language_server.id(); - let offset_encoding = language_server.offset_encoding(); - let pos = doc.position(view.id, offset_encoding); - let future = language_server - .prepare_rename(doc.identifier(), pos) - .unwrap(); - cx.callback( - future, - move |editor, compositor, response: Option<lsp::PrepareRenameResponse>| { - let prefill = match get_prefill_from_lsp_response(editor, offset_encoding, response) - { - Ok(p) => p, - Err(e) => { - editor.set_error(e); - return; + cx.callback( + future, + move |editor, compositor, response: Option<lsp::Hover>| { + if let Some(hover) = response { + // hover.contents / .range <- used for visualizing + + fn marked_string_to_markdown(contents: lsp::MarkedString) -> String { + match contents { + lsp::MarkedString::String(contents) => contents, + lsp::MarkedString::LanguageString(string) => { + if string.language == "markdown" { + string.value + } else { + format!("```{}\n{}\n```", string.language, string.value) + } + } } + } + + let contents = match hover.contents { + lsp::HoverContents::Scalar(contents) => marked_string_to_markdown(contents), + lsp::HoverContents::Array(contents) => contents + .into_iter() + .map(marked_string_to_markdown) + .collect::<Vec<_>>() + .join("\n\n"), + lsp::HoverContents::Markup(contents) => contents.value, }; - let prompt = create_rename_prompt(editor, prefill, history_register, Some(ls_id)); + // skip if contents empty - compositor.push(prompt); - }, - ); + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents).auto_close(true); + compositor.replace_or_push("hover", popup); + } + }, + ); +} + +pub fn rename_symbol(cx: &mut Context) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text().slice(..); + let primary_selection = doc.selection(view.id).primary(); + let prefill = if primary_selection.len() > 1 { + primary_selection } else { - let prefill = get_prefill_from_word_boundary(cx.editor); - let prompt = create_rename_prompt(cx.editor, prefill, history_register, None); - cx.push_layer(prompt); + use helix_core::textobject::{textobject_word, TextObject}; + textobject_word(text, primary_selection, TextObject::Inside, 1, false) } + .fragment(text) + .into(); + ui::prompt_with_input( + cx, + "rename-to:".into(), + prefill, + None, + ui::completers::none, + move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { + 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; + } + }; + match block_on(future) { + Ok(edits) => apply_workspace_edit(cx.editor, offset_encoding, &edits), + Err(err) => cx.editor.set_error(err.to_string()), + } + }, + ); } pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { let (view, doc) = current!(cx.editor); - let language_server = - language_server_with_feature!(cx.editor, doc, LanguageServerFeature::DocumentHighlight); + 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 = language_server - .text_document_document_highlight(doc.identifier(), pos, None) - .unwrap(); + + let future = match language_server.text_document_document_highlight(doc.identifier(), pos, None) + { + Some(future) => future, + None => { + cx.editor + .set_error("Language server does not support document highlight"); + return; + } + }; cx.callback( future, @@ -1248,8 +1233,10 @@ 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().cursor(text.slice(..)); + let pos = doc.selection(view.id).primary().head; // We must find the range that contains our primary cursor to prevent our primary cursor to move let mut primary_index = 0; @@ -1269,190 +1256,3 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) { }, ); } - -pub fn compute_inlay_hints_for_all_views(editor: &mut Editor, jobs: &mut crate::job::Jobs) { - if !editor.config().lsp.display_inlay_hints { - return; - } - - for (view, _) in editor.tree.views() { - let doc = match editor.documents.get(&view.doc) { - Some(doc) => doc, - None => continue, - }; - if let Some(callback) = compute_inlay_hints_for_view(view, doc) { - jobs.callback(callback); - } - } -} - -fn compute_inlay_hints_for_view( - view: &View, - doc: &Document, -) -> Option<std::pin::Pin<Box<impl Future<Output = Result<crate::job::Callback, anyhow::Error>>>>> { - let view_id = view.id; - let doc_id = view.doc; - - let language_server = doc - .language_servers_with_feature(LanguageServerFeature::InlayHints) - .next()?; - - 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(doc.view_offset(view_id).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) - .is_some_and(|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 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 offset_encoding = language_server.offset_encoding(); - - let callback = super::make_job_callback( - language_server.text_document_range_inlay_hints(doc.identifier(), range, None)?, - move |editor, _compositor, response: Option<Vec<lsp::InlayHint>>| { - // 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() { - return; - } - - // Add annotations to relevant document, not the current one (it may have changed in between) - let doc = match editor.documents.get_mut(&doc_id) { - Some(doc) => doc, - None => return, - }; - - // If we have neither hints nor an LSP, empty the inlay hints since they're now oudated - let mut hints = match response { - Some(hints) if !hints.is_empty() => hints, - _ => { - doc.set_inlay_hints( - view_id, - DocumentInlayHints::empty_with_id(new_doc_inlay_hints_id), - ); - doc.inlay_hints_oudated = false; - return; - } - }; - - // Most language servers will already send them sorted but ensure this is the case to - // avoid errors on our end. - hints.sort_by_key(|inlay_hint| inlay_hint.position); - - let mut padding_before_inlay_hints = Vec::new(); - let mut type_inlay_hints = Vec::new(); - let mut parameter_inlay_hints = Vec::new(); - let mut other_inlay_hints = Vec::new(); - let mut padding_after_inlay_hints = Vec::new(); - - let doc_text = doc.text(); - let inlay_hints_length_limit = doc.config.load().lsp.inlay_hints_length_limit; - - for hint in hints { - let char_idx = - match helix_lsp::util::lsp_pos_to_pos(doc_text, hint.position, offset_encoding) - { - Some(pos) => pos, - // Skip inlay hints that have no "real" position - None => continue, - }; - - let mut label = match hint.label { - lsp::InlayHintLabel::String(s) => s, - lsp::InlayHintLabel::LabelParts(parts) => parts - .into_iter() - .map(|p| p.value) - .collect::<Vec<_>>() - .join(""), - }; - // Truncate the hint if too long - if let Some(limit) = inlay_hints_length_limit { - // Limit on displayed width - use helix_core::unicode::{ - segmentation::UnicodeSegmentation, width::UnicodeWidthStr, - }; - - let width = label.width(); - let limit = limit.get().into(); - if width > limit { - let mut floor_boundary = 0; - let mut acc = 0; - for (i, grapheme_cluster) in label.grapheme_indices(true) { - acc += grapheme_cluster.width(); - - if acc > limit { - floor_boundary = i; - break; - } - } - - label.truncate(floor_boundary); - label.push('…'); - } - } - - let inlay_hints_vec = match hint.kind { - Some(lsp::InlayHintKind::TYPE) => &mut type_inlay_hints, - Some(lsp::InlayHintKind::PARAMETER) => &mut parameter_inlay_hints, - // We can't warn on unknown kind here since LSPs are free to set it or not, for - // example Rust Analyzer does not: every kind will be `None`. - _ => &mut other_inlay_hints, - }; - - if let Some(true) = hint.padding_left { - padding_before_inlay_hints.push(InlineAnnotation::new(char_idx, " ")); - } - - inlay_hints_vec.push(InlineAnnotation::new(char_idx, label)); - - if let Some(true) = hint.padding_right { - padding_after_inlay_hints.push(InlineAnnotation::new(char_idx, " ")); - } - } - - doc.set_inlay_hints( - view_id, - DocumentInlayHints { - id: new_doc_inlay_hints_id, - type_inlay_hints, - parameter_inlay_hints, - other_inlay_hints, - padding_before_inlay_hints, - padding_after_inlay_hints, - }, - ); - doc.inlay_hints_oudated = false; - }, - ); - - Some(callback) -} |