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 | 1403 |
1 files changed, 806 insertions, 597 deletions
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0494db3e..55153648 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -1,4 +1,4 @@ -use futures_util::{stream::FuturesOrdered, FutureExt}; +use futures_util::{future::BoxFuture, stream::FuturesUnordered, FutureExt}; use helix_lsp::{ block_on, lsp::{ @@ -6,22 +6,23 @@ use helix_lsp::{ NumberOrString, }, util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, - Client, LanguageServerId, OffsetEncoding, + Client, OffsetEncoding, }; +use serde_json::Value; use tokio_stream::StreamExt; -use tui::{text::Span, widgets::Row}; +use tui::{ + text::{Span, Spans}, + widgets::Row, +}; -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, + path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, }; -use helix_stdx::path; use helix_view::{ - document::{DocumentInlayHints, DocumentInlayHintsId}, + document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, editor::Action, - handlers::lsp::SignatureHelpInvoked, theme::Style, Document, View, }; @@ -29,16 +30,26 @@ use helix_view::{ use crate::{ compositor::{self, Compositor}, job::Callback, - ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent}, + ui::{ + self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, + PromptEvent, + }, }; -use std::{cmp::Ordering, collections::HashSet, fmt::Display, future::Future, path::Path}; +use std::{ + cmp::Ordering, + collections::{BTreeMap, HashSet}, + fmt::Write, + future::Future, + 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. +/// will spam the "No configured language server supports <feature>" status message confusingly. #[macro_export] macro_rules! language_server_with_feature { ($editor:expr, $doc:expr, $feature:expr) => {{ @@ -46,7 +57,7 @@ macro_rules! language_server_with_feature { match language_server { Some(language_server) => language_server, None => { - $editor.set_error(format!( + $editor.set_status(format!( "No configured language server supports {}", $feature )); @@ -56,36 +67,67 @@ macro_rules! language_server_with_feature { }}; } -/// 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 format(&self, cwdir: &Self::Data) -> Row { + // 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 + 1) + .expect("Will only failed if allocating fail"); + res.into() + } } struct SymbolInformationItem { - location: Location, symbol: lsp::SymbolInformation, + offset_encoding: OffsetEncoding, +} + +impl ui::menu::Item for SymbolInformationItem { + /// Path to currently focussed document + type Data = Option<lsp::Url>; + + fn format(&self, current_doc_path: &Self::Data) -> Row { + if current_doc_path.as_ref() == Some(&self.symbol.location.uri) { + self.symbol.name.as_str().into() + } else { + match self.symbol.location.uri.to_file_path() { + Ok(path) => { + let get_relative_path = path::get_relative_path(path.as_path()); + format!( + "{} ({})", + &self.symbol.name, + get_relative_path.to_string_lossy() + ) + .into() + } + Err(_) => format!("{} ({})", &self.symbol.name, &self.symbol.location.uri).into(), + } + } + } } struct DiagnosticStyles { @@ -96,102 +138,143 @@ struct DiagnosticStyles { } struct PickerDiagnostic { - location: Location, + url: lsp::Url, diag: lsp::Diagnostic, + offset_encoding: OffsetEncoding, } -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 format(&self, (styles, format): &Self::Data) -> Row { + 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 = match self.diag.code.as_ref() { + Some(NumberOrString::Number(n)) => format!(" ({n})"), + Some(NumberOrString::String(s)) => format!(" ({s})"), + None => String::new(), + }; + + let path = match format { + DiagnosticsFormat::HideSourcePath => String::new(), + DiagnosticsFormat::ShowSourcePath => { + let file_path = self.url.to_file_path().unwrap(); + let path = path::get_truncated_path(file_path); + format!("{}: ", path.to_string_lossy()) + } + }; + + Spans::from(vec![ + Span::raw(path), + Span::styled(&self.diag.message, style), + Span::styled(code, style), + ]) + .into() + } +} + +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); // TODO: convert inside server - let new_range = if let Some(new_range) = lsp_range_to_range(doc.text(), range, offset_encoding) - { - new_range - } else { - log::warn!("lsp position out of bounds - {:?}", range); - return; - }; + let new_range = + if let Some(new_range) = lsp_range_to_range(doc.text(), location.range, offset_encoding) { + new_range + } else { + log::warn!("lsp position out of bounds - {:?}", location.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); - } + 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); - "" +type SymbolPicker = Picker<SymbolInformationItem>; + +fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker { + // TODO: drop current_path comparison and instead use workspace: bool flag? + Picker::new(symbols, current_path.clone(), move |cx, item, action| { + let (view, doc) = current!(cx.editor); + push_jump(view, doc); + + 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(_) => { + 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(), 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). + doc.set_selection(view.id, Selection::single(range.head, range.anchor)); + align_view(doc, view, Align::Center); + } + }) + .with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location))) + .truncate_start(false) } #[derive(Copy, Clone, PartialEq)] @@ -200,44 +283,30 @@ 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, usize)>>, + current_path: Option<lsp::Url>, format: DiagnosticsFormat, -) -> DiagnosticsPicker { +) -> Picker<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)) - { + for (diag, ls) in diags { + if let Some(ls) = cx.editor.language_server_by_id(ls) { flat_diag.push(PickerDiagnostic { - location: Location { - uri: uri.clone(), - range: diag.range, - offset_encoding: ls.offset_encoding(), - }, + url: url.clone(), diag, + offset_encoding: ls.offset_encoding(), }); } } } - 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,67 +314,38 @@ 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(), - } - }), - 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); + (styles, format), + 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); + } else { + let path = url.to_file_path().unwrap(); + cx.editor.open(&path, action).expect("editor.open failed"); + } + 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); + } }, ) - .with_preview(move |_editor, diag| location_to_file_location(&diag.location)) + .with_preview(move |_editor, PickerDiagnostic { url, diag, .. }| { + let location = lsp::Location::new(url.clone(), diag.range); + Some(location_to_file_location(&location)) + }) .truncate_start(false) } @@ -313,7 +353,6 @@ pub fn symbol_picker(cx: &mut Context) { fn nested_to_flat( list: &mut Vec<SymbolInformationItem>, file: &lsp::TextDocumentIdentifier, - uri: &Uri, symbol: lsp::DocumentSymbol, offset_encoding: OffsetEncoding, ) { @@ -327,33 +366,28 @@ pub fn symbol_picker(cx: &mut Context) { location: lsp::Location::new(file.uri.clone(), symbol.selection_range), container_name: None, }, - location: Location { - uri: uri.clone(), - range: symbol.selection_range, - offset_encoding, - }, + offset_encoding, }); for child in symbol.children.into_iter().flatten() { - nested_to_flat(list, file, uri, child, offset_encoding); + nested_to_flat(list, file, child, offset_encoding); } } let doc = doc!(cx.editor); let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesOrdered<_> = doc + let mut futures: FuturesUnordered<_> = 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? { + let json = request.await?; + let response: Option<lsp::DocumentSymbolResponse> = serde_json::from_value(json)?; + let symbols = match response { Some(symbols) => symbols, None => return anyhow::Ok(vec![]), }; @@ -363,24 +397,14 @@ pub fn symbol_picker(cx: &mut Context) { lsp::DocumentSymbolResponse::Flat(symbols) => symbols .into_iter() .map(|symbol| SymbolInformationItem { - location: Location { - uri: doc_uri.clone(), - range: symbol.location.range, - offset_encoding, - }, 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, - &doc_uri, - symbol, - offset_encoding, - ) + nested_to_flat(&mut flat_symbols, &doc_id, symbol, offset_encoding) } flat_symbols } @@ -389,6 +413,7 @@ pub fn symbol_picker(cx: &mut Context) { } }) .collect(); + let current_url = doc.url(); if futures.is_empty() { cx.editor @@ -398,44 +423,12 @@ pub fn symbol_picker(cx: &mut Context) { 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}"), - } + // 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| { - 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); - + let picker = sym_picker(symbols, current_url); compositor.push(Box::new(overlaid(picker))) }; @@ -444,8 +437,6 @@ pub fn symbol_picker(cx: &mut Context) { } 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) @@ -457,46 +448,27 @@ pub fn workspace_symbol_picker(cx: &mut Context) { return; } - let get_symbols = |pattern: &str, editor: &mut Editor, _data, injector: &Injector<_, _>| { + let get_symbols = move |pattern: String, editor: &mut Editor| { let doc = doc!(editor); let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesOrdered<_> = doc + let mut futures: FuturesUnordered<_> = 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 request = language_server.workspace_symbols(pattern.clone()).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 json = request.await?; - 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, - }, + let response = + serde_json::from_value::<Option<Vec<lsp::SymbolInformation>>>(json)? + .unwrap_or_default() + .into_iter() + .map(|symbol| SymbolInformationItem { symbol, + offset_encoding, }) - }) - .collect(); + .collect(); anyhow::Ok(response) } @@ -507,89 +479,73 @@ pub fn workspace_symbol_picker(cx: &mut Context) { 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}"), - } + 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); } - Ok(()) + anyhow::Ok(symbols) } .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() - } - }), - ]; - - 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))); + let current_url = doc.url(); + let initial_symbols = get_symbols("".to_owned(), cx.editor); + + 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); - 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); + if let Some(current_url) = doc.url() { + 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, + ); cx.push_layer(Box::new(overlaid(picker))); } } pub fn workspace_diagnostics_picker(cx: &mut Context) { + let doc = doc!(cx.editor); + let current_url = doc.url(); // 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, DiagnosticsFormat::ShowSourcePath); + let picker = diag_picker( + cx, + diagnostics, + current_url, + DiagnosticsFormat::ShowSourcePath, + ); cx.push_layer(Box::new(overlaid(picker))); } struct CodeActionOrCommandItem { lsp_item: lsp::CodeActionOrCommand, - language_server_id: LanguageServerId, + language_server_id: usize, } impl ui::menu::Item for CodeActionOrCommandItem { type Data = (); - fn format(&self, _data: &Self::Data) -> Row<'_> { + fn format(&self, _data: &Self::Data) -> Row { match &self.lsp_item { lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(), lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(), @@ -661,7 +617,7 @@ pub fn code_action(cx: &mut Context) { let mut seen_language_servers = HashSet::new(); - let mut futures: FuturesOrdered<_> = doc + let mut futures: FuturesUnordered<_> = 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" @@ -688,8 +644,11 @@ pub fn code_action(cx: &mut 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 json = request.await?; + let response: Option<lsp::CodeActionResponse> = serde_json::from_value(json)?; + let mut actions = match response { + Some(a) => a, + None => return anyhow::Ok(Vec::new()), }; // remove disabled code actions @@ -754,12 +713,9 @@ pub fn code_action(cx: &mut Context) { 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}"), - } + // 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| { @@ -774,8 +730,7 @@ pub fn code_action(cx: &mut Context) { // always present here let action = action.unwrap(); - let Some(language_server) = editor.language_server_by_id(action.language_server_id) - else { + let Some(language_server) = editor.language_server_by_id(action.language_server_id) else { editor.set_error("Language Server disappeared"); return; }; @@ -784,40 +739,26 @@ pub fn code_action(cx: &mut Context) { match &action.lsp_item { lsp::CodeActionOrCommand::Command(command) => { log::debug!("code action command: {:?}", command); - editor.execute_lsp_command(command.clone(), action.language_server_id); + execute_lsp_command(editor, action.language_server_id, 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); + let _ = 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, action.language_server_id, 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); }; @@ -825,6 +766,93 @@ pub fn code_action(cx: &mut Context) { }); } +impl ui::menu::Item for lsp::Command { + type Data = (); + fn format(&self, _data: &Self::Data) -> Row { + self.title.as_str().into() + } +} + +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 editor + .language_server_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"); + return; + } + }; + + tokio::spawn(async move { + let res = future.await; + + if let Err(e) = res { + log::error!("execute LSP command: {}", e); + } + }); +} + +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) + } + } + } +} + #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind, @@ -841,113 +869,223 @@ pub enum ApplyEditErrorKind { // InvalidEdit, } -impl Display for ApplyEditErrorKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl ToString for ApplyEditErrorKind { + fn to_string(&self) -> String { 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}")), + ApplyEditErrorKind::DocumentChanged => "document has changed".to_string(), + ApplyEditErrorKind::FileNotFound => "file not found".to_string(), + ApplyEditErrorKind::UnknownURISchema => "URI schema not supported".to_string(), + ApplyEditErrorKind::IoError(err) => err.to_string(), + } + } +} + +///TODO make this transactional (and set failureMode to transactional) +pub fn apply_workspace_edit( + editor: &mut Editor, + offset_encoding: OffsetEncoding, + workspace_edit: &lsp::WorkspaceEdit, +) -> Result<(), ApplyEditError> { + let mut apply_edits = |uri: &helix_lsp::Url, + version: Option<i32>, + text_edits: Vec<lsp::TextEdit>| + -> Result<(), ApplyEditErrorKind> { + 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 Err(ApplyEditErrorKind::UnknownURISchema); + } + }; + + 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 Err(ApplyEditErrorKind::FileNotFound); + } + }; + + let doc = doc_mut!(editor, &doc_id); + if let Some(version) = version { + if version != doc.version() { + let err = format!("outdated workspace edit for {path:?}"); + log::error!("{err}, expected {} but got {version}", doc.version()); + editor.set_error(err); + return Err(ApplyEditErrorKind::DocumentChanged); + } + } + + // 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); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); + Ok(()) + }; + + if let Some(ref document_changes) = workspace_edit.document_changes { + match document_changes { + lsp::DocumentChanges::Edits(document_edits) => { + for (i, document_edit) in document_edits.iter().enumerate() { + 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, + document_edit.text_document.version, + edits, + ) + .map_err(|kind| ApplyEditError { + kind, + failed_change_idx: i, + })?; + } + } + lsp::DocumentChanges::Operations(operations) => { + log::debug!("document changes - operations: {:?}", operations); + for (i, operation) in operations.iter().enumerate() { + match operation { + lsp::DocumentChangeOperation::Op(op) => { + apply_document_resource_op(op).map_err(|io| ApplyEditError { + kind: ApplyEditErrorKind::IoError(io), + failed_change_idx: i, + })?; + } + + 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 + } + }) + .cloned() + .collect(); + apply_edits( + &document_edit.text_document.uri, + document_edit.text_document.version, + edits, + ) + .map_err(|kind| ApplyEditError { + kind, + failed_change_idx: i, + })?; + } + } + } + } } + + return Ok(()); } + + if let Some(ref changes) = workspace_edit.changes { + log::debug!("workspace changes: {:?}", changes); + for (i, (uri, text_edits)) in changes.iter().enumerate() { + let text_edits = text_edits.to_vec(); + apply_edits(uri, None, text_edits).map_err(|kind| ApplyEditError { + kind, + failed_change_idx: i, + })?; + } + } + + Ok(()) } -/// 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(); +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, Action::Replace); + jump_to_location(editor, location, offset_encoding, Action::Replace); + } + [] => { + editor.set_error("No definition found."); } - [] => 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 picker = Picker::new(columns, 0, locations, cwdir, |cx, location, action| { - jump_to_location(cx.editor, location, action) + let picker = Picker::new(locations, cwdir, move |cx, location, action| { + jump_to_location(cx.editor, location, offset_encoding, action) }) - .with_preview(|_editor, location| location_to_file_location(location)); + .with_preview(move |_editor, location| Some(location_to_file_location(location))); compositor.push(Box::new(overlaid(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(), + } +} + 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, + F: Future<Output = helix_lsp::Result<serde_json::Value>> + '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(); + let (view, doc) = current!(cx.editor); - 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, - ) - }) - .flat_map(|location| { - lsp_location_to_location(location, offset_encoding) - }), - ); - } - 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))) - }); + let language_server = language_server_with_feature!(cx.editor, doc, feature); + 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(); + + 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_declaration(cx: &mut Context) { @@ -984,109 +1122,222 @@ pub fn goto_implementation(cx: &mut Context) { 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 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(); + // TODO could probably support multiple language servers, + // not sure if there's a real practical use case for this though + let language_server = + language_server_with_feature!(cx.editor, doc, LanguageServerFeature::GotoReference); + 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(); - 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 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, Clone, Copy)] +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 + // TODO merge multiple language server signature help into one instead of just taking the first language server that supports it + let future = doc + .language_servers_with_feature(LanguageServerFeature::SignatureHelp) + .find_map(|language_server| { let pos = doc.position(view.id, language_server.offset_encoding()); - let request = language_server - .text_document_hover(doc.identifier(), pos, None) - .unwrap(); + language_server.text_document_signature_help(doc.identifier(), pos, None) + }); - async move { anyhow::Ok((server_name, request.await?)) } - }) - .collect(); + let Some(future) = future else { + // Do not show the message if signature help was invoked + // automatically on backspace, trigger characters, etc. + if invoked == SignatureHelpInvoked::Manual { + cx.editor.set_error("No configured language server supports signature-help"); + } + return; + }; + signature_help_impl_with_future(cx, future.boxed(), invoked); +} - cx.jobs.callback(async move { - let mut hovers: Vec<(String, lsp::Hover)> = Vec::new(); +pub fn signature_help_impl_with_future( + cx: &mut Context, + future: BoxFuture<'static, helix_lsp::Result<Value>>, + invoked: SignatureHelpInvoked, +) { + cx.callback( + future, + move |editor, compositor, response: Option<lsp::SignatureHelp>| { + let config = &editor.config(); - 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}"), + if !(config.lsp.auto_signature_help + || SignatureHelp::visible_popup(compositor).is_some() + || invoked == SignatureHelpInvoked::Manual) + { + return; } - } - let call = move |editor: &mut Editor, compositor: &mut Compositor| { - if hovers.is_empty() { - editor.set_status("No hover results available."); + // 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 invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert { 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))) - }); + 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 doc = doc!(editor); + let language = doc.language_name().unwrap_or(""); + + 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 + }; + + 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)) + } + } + }; + contents.set_active_param_range(active_param_range()); + + let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID); + let mut popup = Popup::new(SignatureHelp::ID, contents) + .position(old_popup.and_then(|p| p.get_position())) + .position_bias(Open::Above) + .ignore_escape_key(true); + + // Don't create a popup if it intersects the auto-complete menu. + let size = compositor.size(); + if compositor + .find::<ui::EditorView>() + .unwrap() + .completion + .as_mut() + .map(|completion| completion.area(size, editor)) + .filter(|area| area.intersects(popup.area(size, editor))) + .is_some() + { + return; + } + + compositor.replace_or_push(SignatureHelp::ID, popup); + }, + ); +} + +pub fn hover(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + // TODO support multiple language servers (merge UI somehow) + let language_server = + language_server_with_feature!(cx.editor, doc, LanguageServerFeature::Hover); + // TODO: factor out a doc.position_identifier() that returns lsp::TextDocumentPositionIdentifier + let pos = doc.position(view.id, language_server.offset_encoding()); + let future = language_server + .text_document_hover(doc.identifier(), pos, None) + .unwrap(); + + 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, + }; + + // skip if contents empty + + 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) { @@ -1131,12 +1382,11 @@ pub fn rename_symbol(cx: &mut Context) { fn create_rename_prompt( editor: &Editor, prefill: String, - history_register: Option<char>, - language_server_id: Option<LanguageServerId>, + language_server_id: Option<usize>, ) -> Box<ui::Prompt> { let prompt = ui::Prompt::new( "rename-to:".into(), - history_register, + None, ui::completers::none, move |cx: &mut compositor::Context, input: &str, event: PromptEvent| { if event != PromptEvent::Validate { @@ -1146,10 +1396,9 @@ pub fn rename_symbol(cx: &mut Context) { let Some(language_server) = doc .language_servers_with_feature(LanguageServerFeature::RenameSymbol) - .find(|ls| language_server_id.is_none_or(|id| id == ls.id())) + .find(|ls| language_server_id.map_or(true, |id| id == ls.id())) else { - cx.editor - .set_error("No configured language server supports symbol renaming"); + cx.editor.set_error("No configured language server supports symbol renaming"); return; }; @@ -1161,9 +1410,7 @@ pub fn rename_symbol(cx: &mut Context) { match block_on(future) { Ok(edits) => { - let _ = cx - .editor - .apply_workspace_edit(offset_encoding, &edits.unwrap_or_default()); + let _ = apply_workspace_edit(cx.editor, offset_encoding, &edits); } Err(err) => cx.editor.set_error(err.to_string()), } @@ -1175,17 +1422,6 @@ pub fn rename_symbol(cx: &mut Context) { } let (view, doc) = current_ref!(cx.editor); - let history_register = cx.register; - - if doc - .language_servers_with_feature(LanguageServerFeature::RenameSymbol) - .next() - .is_none() - { - cx.editor - .set_error("No configured language server supports symbol renaming"); - return; - } let language_server_with_prepare_rename_support = doc .language_servers_with_feature(LanguageServerFeature::RenameSymbol) @@ -1218,14 +1454,14 @@ pub fn rename_symbol(cx: &mut Context) { } }; - let prompt = create_rename_prompt(editor, prefill, history_register, Some(ls_id)); + let prompt = create_rename_prompt(editor, prefill, Some(ls_id)); compositor.push(prompt); }, ); } else { let prefill = get_prefill_from_word_boundary(cx.editor); - let prompt = create_rename_prompt(cx.editor, prefill, history_register, None); + let prompt = create_rename_prompt(cx.editor, prefill, None); cx.push_layer(prompt); } } @@ -1305,8 +1541,7 @@ fn compute_inlay_hints_for_view( // 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_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)) @@ -1320,7 +1555,7 @@ fn compute_inlay_hints_for_view( if !doc.inlay_hints_oudated && doc .inlay_hints(view_id) - .is_some_and(|dih| dih.id == new_doc_inlay_hints_id) + .map_or(false, |dih| dih.id == new_doc_inlay_hints_id) { return None; } @@ -1366,7 +1601,7 @@ fn compute_inlay_hints_for_view( // 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); + hints.sort_unstable_by_key(|inlay_hint| inlay_hint.position); let mut padding_before_inlay_hints = Vec::new(); let mut type_inlay_hints = Vec::new(); @@ -1375,7 +1610,6 @@ fn compute_inlay_hints_for_view( 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 = @@ -1386,7 +1620,7 @@ fn compute_inlay_hints_for_view( None => continue, }; - let mut label = match hint.label { + let label = match hint.label { lsp::InlayHintLabel::String(s) => s, lsp::InlayHintLabel::LabelParts(parts) => parts .into_iter() @@ -1394,31 +1628,6 @@ fn compute_inlay_hints_for_view( .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, @@ -1443,11 +1652,11 @@ fn compute_inlay_hints_for_view( 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, + type_inlay_hints: type_inlay_hints.into(), + parameter_inlay_hints: parameter_inlay_hints.into(), + other_inlay_hints: other_inlay_hints.into(), + padding_before_inlay_hints: padding_before_inlay_hints.into(), + padding_after_inlay_hints: padding_after_inlay_hints.into(), }, ); doc.inlay_hints_oudated = false; |