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 | 679 |
1 files changed, 323 insertions, 356 deletions
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 0494db3e..3b9efb43 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -14,8 +14,7 @@ use tui::{text::Span, widgets::Row}; use super::{align_view, push_jump, Align, Context, Editor}; use helix_core::{ - diagnostic::DiagnosticProvider, syntax::config::LanguageServerFeature, - text_annotations::InlineAnnotation, Selection, Uri, + syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection, Uri, }; use helix_stdx::path; use helix_view::{ @@ -32,7 +31,13 @@ use crate::{ ui::{self, overlay::overlaid, 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::Path, +}; /// 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. @@ -46,7 +51,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 +61,10 @@ 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; - } - }; - Some(Location { - uri, - range: location.range, - offset_encoding, - }) -} - struct SymbolInformationItem { - location: Location, symbol: lsp::SymbolInformation, + offset_encoding: OffsetEncoding, + uri: Uri, } struct DiagnosticStyles { @@ -96,35 +75,35 @@ struct DiagnosticStyles { } struct PickerDiagnostic { - location: Location, + uri: Uri, diag: lsp::Diagnostic, + offset_encoding: OffsetEncoding, } -fn location_to_file_location(location: &Location) -> Option<FileLocation<'_>> { - let path = location.uri.as_path()?; - let line = Some(( - location.range.start.line as usize, - location.range.end.line as usize, - )); +fn uri_to_file_location<'a>(uri: &'a Uri, range: &lsp::Range) -> Option<FileLocation<'a>> { + let path = uri.as_path()?; + let line = Some((range.start.line as usize, range.end.line as usize)); Some((path.into(), line)) } -fn jump_to_location(editor: &mut Editor, location: &Location, action: Action) { +fn jump_to_location( + editor: &mut Editor, + location: &lsp::Location, + offset_encoding: OffsetEncoding, + 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; + 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; + } }; - jump_to_position( - editor, - path, - location.range, - location.offset_encoding, - action, - ); + jump_to_position(editor, &path, location.range, offset_encoding, action); } fn jump_to_position( @@ -204,7 +183,7 @@ type DiagnosticsPicker = Picker<PickerDiagnostic, DiagnosticStyles>; fn diag_picker( cx: &Context, - diagnostics: impl IntoIterator<Item = (Uri, Vec<(lsp::Diagnostic, DiagnosticProvider)>)>, + diagnostics: BTreeMap<Uri, Vec<(lsp::Diagnostic, LanguageServerId)>>, format: DiagnosticsFormat, ) -> DiagnosticsPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? @@ -214,30 +193,17 @@ fn diag_picker( for (uri, 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(), - }, + uri: uri.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"), @@ -259,9 +225,6 @@ fn diag_picker( .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(), @@ -273,14 +236,14 @@ fn diag_picker( item.diag.message.as_str().into() }), ]; - let mut primary_column = 3; // message + let mut primary_column = 2; // message if format == DiagnosticsFormat::ShowSourcePath { columns.insert( // between message code and message - 3, + 2, ui::PickerColumn::new("path", |item: &PickerDiagnostic, _| { - if let Some(path) = item.location.uri.as_path() { + if let Some(path) = item.uri.as_path() { path::get_truncated_path(path) .to_string_lossy() .to_string() @@ -298,14 +261,26 @@ fn diag_picker( primary_column, flat_diag, styles, - move |cx, diag, action| { - jump_to_location(cx.editor, &diag.location, action); + move |cx, + PickerDiagnostic { + uri, + diag, + offset_encoding, + }, + action| { + let Some(path) = uri.as_path() else { + return; + }; + jump_to_position(cx.editor, path, diag.range, *offset_encoding, action); let (view, doc) = current!(cx.editor); view.diagnostics_handler .immediately_show_diagnostic(doc, view.id); }, ) - .with_preview(move |_editor, diag| location_to_file_location(&diag.location)) + .with_preview(move |_editor, PickerDiagnostic { uri, diag, .. }| { + let line = Some((diag.range.start.line as usize, diag.range.end.line as usize)); + Some((uri.as_path()?.into(), line)) + }) .truncate_start(false) } @@ -327,11 +302,8 @@ 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, + uri: uri.clone(), }); for child in symbol.children.into_iter().flatten() { nested_to_flat(list, file, uri, child, offset_encoding); @@ -353,7 +325,9 @@ pub fn symbol_picker(cx: &mut Context) { .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,12 +337,9 @@ 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, - }, + uri: doc_uri.clone(), symbol, + offset_encoding, }) .collect(), lsp::DocumentSymbolResponse::Nested(symbols) => { @@ -398,11 +369,9 @@ 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 = [ @@ -415,13 +384,6 @@ pub fn symbol_picker(cx: &mut Context) { 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( @@ -430,10 +392,17 @@ pub fn symbol_picker(cx: &mut Context) { symbols, (), move |cx, item, action| { - jump_to_location(cx.editor, &item.location, action); + jump_to_location( + cx.editor, + &item.symbol.location, + item.offset_encoding, + action, + ); }, ) - .with_preview(move |_editor, item| location_to_file_location(&item.location)) + .with_preview(move |_editor, item| { + uri_to_file_location(&item.uri, &item.symbol.location.range) + }) .truncate_start(false); compositor.push(Box::new(overlaid(picker))) @@ -469,34 +438,27 @@ pub fn workspace_symbol_picker(cx: &mut Context) { .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 { + let json = request.await?; + + let response: Vec<_> = + serde_json::from_value::<Option<Vec<lsp::SymbolInformation>>>(json)? + .unwrap_or_default() + .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 { + symbol, uri, - range: symbol.location.range, offset_encoding, - }, - symbol, + }) }) - }) - .collect(); + .collect(); anyhow::Ok(response) } @@ -509,14 +471,10 @@ pub fn workspace_symbol_picker(cx: &mut Context) { 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}"), + // TODO if one symbol request errors, all other requests are discarded (even if they're valid) + while let Some(lsp_items) = futures.try_next().await? { + for item in lsp_items { + injector.push(item)?; } } Ok(()) @@ -531,15 +489,8 @@ pub fn workspace_symbol_picker(cx: &mut Context) { 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() { + if let Some(path) = item.uri.as_path() { path::get_relative_path(path) .to_string_lossy() .to_string() @@ -556,10 +507,15 @@ pub fn workspace_symbol_picker(cx: &mut Context) { [], (), move |cx, item, action| { - jump_to_location(cx.editor, &item.location, action); + jump_to_location( + cx.editor, + &item.symbol.location, + item.offset_encoding, + action, + ); }, ) - .with_preview(|_editor, item| location_to_file_location(&item.location)) + .with_preview(|_editor, item| uri_to_file_location(&item.uri, &item.symbol.location.range)) .with_dynamic_query(get_symbols, None) .truncate_start(false); @@ -570,7 +526,11 @@ 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); + let picker = diag_picker( + cx, + [(uri, diagnostics)].into(), + DiagnosticsFormat::HideSourcePath, + ); cx.push_layer(Box::new(overlaid(picker))); } } @@ -589,7 +549,7 @@ struct CodeActionOrCommandItem { 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(), @@ -688,8 +648,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 +717,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| { @@ -784,16 +744,22 @@ 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); + if let Some(future) = + language_server.resolve_code_action(code_action.clone()) + { + if let Ok(response) = helix_lsp::block_on(future) { + if let Ok(code_action) = + serde_json::from_value::<CodeAction>(response) + { + resolved_code_action = Some(code_action); + } } } } @@ -807,16 +773,14 @@ pub fn code_action(cx: &mut Context) { // if code action provides both edit and command first the edit // should be applied and then the command if let Some(command) = &code_action.command { - 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 +789,33 @@ pub fn code_action(cx: &mut Context) { }); } +pub fn execute_lsp_command( + editor: &mut Editor, + language_server_id: LanguageServerId, + 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); + } + }); +} + #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind, @@ -841,113 +832,136 @@ 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(), } } } /// Precondition: `locations` should be non-empty. -fn goto_impl(editor: &mut Editor, compositor: &mut Compositor, locations: Vec<Location>) { +fn goto_impl( + editor: &mut Editor, + compositor: &mut Compositor, + locations: Vec<lsp::Location>, + offset_encoding: OffsetEncoding, +) { let cwdir = helix_stdx::env::current_working_dir(); match locations.as_slice() { [location] => { - jump_to_location(editor, location, Action::Replace); + jump_to_location(editor, location, offset_encoding, 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() + |item: &lsp::Location, cwdir: &std::path::PathBuf| { + // 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(item.uri.as_str().len()); + + if item.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`. + if let Ok(path) = item.uri.to_file_path() { + // We don't convert to a `helix_core::Uri` here because we've already checked the scheme. + // This path won't be normalized but it's only used for display. + res.push_str( + &path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy(), + ); + } } else { - item.uri.to_string().into() - }; + // Never allocates since we declared the string with this capacity already. + res.push_str(item.uri.as_str()); + } - format!("{path}:{}", item.range.start.line + 1).into() + // 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, ":{}", item.range.start.line + 1) + .expect("Will only failed if allocating fail"); + res.into() }, )]; - let picker = Picker::new(columns, 0, locations, cwdir, |cx, location, action| { - jump_to_location(cx.editor, location, action) + let picker = Picker::new(columns, 0, 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| { + use crate::ui::picker::PathOrId; + + let lines = Some(( + location.range.start.line as usize, + location.range.end.line as usize, + )); + + // TODO: we should avoid allocating by doing the Uri conversion ahead of time. + // + // To do this, introduce a `Location` type in `helix-core` that reuses the core + // `Uri` type instead of the LSP `Url` type and replaces the LSP `Range` type. + // Refactor the callers of `goto_impl` to pass iterators that translate the + // LSP location type to the custom one in core, or have them collect and pass + // `Vec<Location>`s. Replace the `uri_to_file_location` function with + // `location_to_file_location` that takes only `&helix_core::Location` as + // parameters. + // + // By doing this we can also eliminate the duplicated URI info in the + // `SymbolInformationItem` type and introduce a custom Symbol type in `helix-core` + // which will be reused in the future for tree-sitter based symbol pickers. + let path = Uri::try_from(&location.uri).ok()?.as_path_buf()?; + #[allow(deprecated)] + Some((PathOrId::from_path_buf(path), lines)) + }); 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.", - }); + 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); + if items.is_empty() { + editor.set_error("No definition found."); } else { - goto_impl(editor, compositor, locations); + goto_impl(editor, compositor, items, offset_encoding); } - }; - Ok(Callback::EditorCompositor(Box::new(call))) - }); + }, + ); } pub fn goto_declaration(cx: &mut Context) { @@ -984,47 +998,34 @@ 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() { + cx.callback( + future, + move |editor, compositor, response: Option<Vec<lsp::Location>>| { + let items = response.unwrap_or_default(); + if items.is_empty() { editor.set_error("No references found."); } else { - goto_impl(editor, compositor, locations); + goto_impl(editor, compositor, items, offset_encoding); } - }; - Ok(Callback::EditorCompositor(Box::new(call))) - }); + }, + ); } pub fn signature_help(cx: &mut Context) { @@ -1034,59 +1035,54 @@ pub fn signature_help(cx: &mut Context) { } pub fn hover(cx: &mut Context) { - use ui::lsp::hover::Hover; - 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(); + // 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.jobs.callback(async move { - let mut hovers: Vec<(String, lsp::Hover)> = Vec::new(); + 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) + } + } + } + } - 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 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 call = move |editor: &mut Editor, compositor: &mut Compositor| { - if hovers.is_empty() { - editor.set_status("No hover results available."); - return; - } + // skip if contents empty - // 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 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) { @@ -1146,7 +1142,7 @@ 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"); @@ -1161,9 +1157,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 _ = cx.editor.apply_workspace_edit(offset_encoding, &edits); } Err(err) => cx.editor.set_error(err.to_string()), } @@ -1305,8 +1299,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 +1313,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 +1359,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 +1368,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 +1378,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 +1386,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, |