use futures_util::{stream::FuturesOrdered, FutureExt}; use helix_lsp::{ block_on, lsp, util::lsp_range_to_range, Client, LanguageServerId, OffsetEncoding, }; use tokio_stream::StreamExt; use tui::text::Span; use super::{align_view, push_jump, Align, Context, Editor}; use helix_core::{ 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, Diagnostic, Document, DocumentId, View, }; use crate::{ compositor::{self, Compositor}, job::Callback, ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent}, }; use std::{collections::HashSet, fmt::Display, future::Future}; /// 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 \" 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 { Some(language_server) => language_server, None => { $editor.set_error(format!( "No configured language server supports {}", $feature )); return; } } }}; } /// A wrapper around `lsp::Location`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Location { uri: Uri, range: helix_view::Range, } impl Location { fn lsp(location: lsp::Location, offset_encoding: OffsetEncoding) -> Option { let uri = match location.uri.try_into() { Ok(uri) => uri, Err(err) => { log::warn!("discarding invalid or unsupported URI: {err}"); return None; } }; Some(Self { uri, range: helix_view::Range::Lsp { range: location.range, offset_encoding, }, }) } fn file_location<'a>(&'a self, editor: &Editor) -> Option> { let (path_or_id, doc) = match &self.uri { Uri::File(path) => ((&**path).into(), None), Uri::Scratch(doc_id) => ((*doc_id).into(), editor.documents.get(doc_id)), _ => return None, }; let lines = match self.range { helix_view::Range::Lsp { range, .. } => { Some((range.start.line as usize, range.end.line as usize)) } helix_view::Range::Document(range) => doc.map(|doc| { let text = doc.text().slice(..); (text.char_to_line(range.start), text.char_to_line(range.end)) }), }; Some((path_or_id, lines)) } } struct SymbolInformationItem { location: Location, symbol: lsp::SymbolInformation, } struct DiagnosticStyles { hint: Style, info: Style, warning: Style, error: Style, } struct PickerDiagnostic { location: Location, diag: Diagnostic, } fn jump_to_location(editor: &mut Editor, location: &Location, action: Action) { let (view, doc) = current!(editor); push_jump(view, doc); let doc_id = match &location.uri { Uri::Scratch(doc_id) => { editor.switch(*doc_id, action); *doc_id } Uri::File(path) => match editor.open(path, action) { Ok(doc_id) => doc_id, Err(err) => { editor.set_error(format!("failed to open path: {:?}: {:?}", path, err)); return; } }, _ => return, }; jump_to_position(editor, doc_id, location.range, action); } fn jump_to_position( editor: &mut Editor, doc_id: DocumentId, range: helix_view::Range, action: Action, ) { let Some(doc) = editor.documents.get_mut(&doc_id) else { return; }; let view = view_mut!(editor); let selection = match range { helix_view::Range::Lsp { range, offset_encoding, } => { let Some(range) = lsp_range_to_range(doc.text(), range, offset_encoding) else { log::warn!("lsp position out of bounds - {:?}", range); return; }; range.into() } helix_view::Range::Document(range) => Selection::single(range.start, range.end), }; // 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); if action.align_view(view, doc.id()) { 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); "" } } } #[derive(Copy, Clone, PartialEq)] enum DiagnosticsFormat { ShowSourcePath, HideSourcePath, } type DiagnosticsPicker = Picker; fn diag_picker( cx: &Context, diagnostics: impl IntoIterator)>, format: DiagnosticsFormat, ) -> DiagnosticsPicker { // flatten the map to a vec of (url, diag) pairs let mut flat_diag = Vec::new(); for (uri, diags) in diagnostics { flat_diag.reserve(diags.len()); for diag in diags { flat_diag.push(PickerDiagnostic { location: Location { uri: uri.clone(), range: diag.range, }, diag, }); } } let styles = DiagnosticStyles { hint: cx.editor.theme.get("hint"), info: cx.editor.theme.get("info"), warning: cx.editor.theme.get("warning"), error: cx.editor.theme.get("error"), }; let mut columns = vec![ ui::PickerColumn::new( "severity", |item: &PickerDiagnostic, styles: &DiagnosticStyles| { use helix_core::diagnostic::Severity::*; match item.diag.severity { Some(Hint) => Span::styled("HINT", styles.hint), Some(Info) => Span::styled("INFO", styles.info), Some(Warning) => Span::styled("WARN", styles.warning), Some(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, _| { item.diag .code .as_ref() .map(|c| c.as_string()) .unwrap_or_default() .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); let (view, doc) = current!(cx.editor); view.diagnostics_handler .immediately_show_diagnostic(doc, view.id); }, ) .with_preview(|editor, diag| diag.location.file_location(editor)) .truncate_start(false) } pub fn symbol_picker(cx: &mut Context) { fn nested_to_flat( list: &mut Vec, 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: helix_view::Range::Lsp { range: symbol.selection_range, offset_encoding, }, }, }); for child in symbol.children.into_iter().flatten() { nested_to_flat(list, file, uri, child, offset_encoding); } } let doc = doc!(cx.editor); let mut seen_language_servers = HashSet::new(); 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(); async move { let symbols = match request.await? { Some(symbols) => symbols, None => return anyhow::Ok(vec![]), }; // lsp has two ways to represent symbols (flat/nested) // convert the nested variant to flat, so that we have a homogeneous list let symbols = match symbols { lsp::DocumentSymbolResponse::Flat(symbols) => symbols .into_iter() .map(|symbol| SymbolInformationItem { location: Location { uri: doc_uri.clone(), range: helix_view::Range::Lsp { range: symbol.location.range, offset_encoding, }, }, symbol, }) .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, ) } 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 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(|editor, item| item.location.file_location(editor)) .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: helix_view::Range::Lsp { 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(()) } .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| item.location.file_location(editor)) .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); let 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))); } 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 diagnostics = cx.editor.diagnostics.clone(); let picker = diag_picker(cx, diagnostics, DiagnosticsFormat::ShowSourcePath); cx.push_layer(Box::new(overlaid(picker))); } #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind, pub failed_change_idx: usize, } #[derive(Debug)] pub enum ApplyEditErrorKind { DocumentChanged, FileNotFound, UnknownURISchema, IoError(std::io::Error), // TODO: check edits before applying and propagate failure // InvalidEdit, } 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}")), } } } /// Precondition: `locations` should be non-empty. fn goto_impl(editor: &mut Editor, compositor: &mut Compositor, locations: Vec) { let cwdir = helix_stdx::env::current_working_dir(); 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| { use std::fmt::Write; let mut path = if let Some(path) = item.uri.as_path() { path.strip_prefix(cwdir) .unwrap_or(path) .to_string_lossy() .to_string() } else { item.uri.to_string() }; if let helix_view::Range::Lsp { range, .. } = item.range { write!(path, ":{}", range.start.line + 1).unwrap(); } path.into() }, )]; let picker = Picker::new(columns, 0, locations, cwdir, |cx, location, action| { jump_to_location(cx.editor, location, action) }) .with_preview(|editor, location| location.file_location(editor)); compositor.push(Box::new(overlaid(picker))); } } } fn goto_single_impl(cx: &mut Context, feature: LanguageServerFeature, request_provider: P) where P: Fn(&Client, lsp::Position, lsp::TextDocumentIdentifier) -> Option, F: Future>> + '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(Location::lsp(lsp_location, offset_encoding)); } Some(lsp::GotoDefinitionResponse::Array(lsp_locations)) => { locations.extend( lsp_locations .into_iter() .flat_map(|location| Location::lsp(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| Location::lsp(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("No definition 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), ); } pub fn goto_definition(cx: &mut Context) { goto_single_impl( cx, LanguageServerFeature::GotoDefinition, |ls, pos, doc_id| ls.goto_definition(doc_id, pos, None), ); } 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), ); } pub fn goto_implementation(cx: &mut Context) { goto_single_impl( cx, LanguageServerFeature::GotoImplementation, |ls, pos, doc_id| ls.goto_implementation(doc_id, pos, None), ); } pub fn goto_reference(cx: &mut Context) { let config = cx.editor.config(); let (view, doc) = current_ref!(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(); 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| Location::lsp(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))) }); } pub fn signature_help(cx: &mut Context) { cx.editor .handlers .trigger_signature_help(SignatureHelpInvoked::Manual, cx.editor) } 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(); cx.jobs.callback(async move { let mut hovers: Vec<(String, lsp::Hover)> = Vec::new(); 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 call = move |editor: &mut Editor, compositor: &mut Compositor| { if hovers.is_empty() { editor.set_status("No hover results available."); 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, ) -> Result { 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)) } None => Err("lsp did not respond to prepare rename request"), } } fn create_rename_prompt( editor: &Editor, prefill: String, history_register: Option, language_server_id: Option, ) -> Box { 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 { return; } let (view, doc) = current!(cx.editor); let Some(language_server) = doc .language_servers_with_feature(LanguageServerFeature::RenameSymbol) .find(|ls| language_server_id.map_or(true, |id| id == ls.id())) else { cx.editor .set_error("No configured language server supports symbol renaming"); return; }; 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()); } Err(err) => cx.editor.set_error(err.to_string()), } }, ) .with_line(prefill, editor); Box::new(prompt) } 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) .find(|ls| { matches!( ls.capabilities().rename_provider, Some(lsp::OneOf::Right(lsp::RenameOptions { prepare_provider: Some(true), .. })) ) }); 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| { let prefill = match get_prefill_from_lsp_response(editor, offset_encoding, response) { Ok(p) => p, Err(e) => { editor.set_error(e); return; } }; let prompt = create_rename_prompt(editor, prefill, history_register, 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); cx.push_layer(prompt); } } 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 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(); cx.callback( future, move |editor, _compositor, response: Option>| { let document_highlights = match response { Some(highlights) if !highlights.is_empty() => highlights, _ => return, }; let (view, doc) = current!(editor); let text = doc.text(); let pos = doc.selection(view.id).primary().cursor(text.slice(..)); // We must find the range that contains our primary cursor to prevent our primary cursor to move let mut primary_index = 0; let ranges = document_highlights .iter() .filter_map(|highlight| lsp_range_to_range(text, highlight.range, offset_encoding)) .enumerate() .map(|(i, range)| { if range.contains(pos) { primary_index = i; } range }) .collect(); let selection = Selection::new(ranges, primary_index); doc.set_selection(view.id, selection); }, ); } 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>>>> { 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>| { // 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::>() .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) }