Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/handlers/inlay_hints.rs')
-rw-r--r--helix-term/src/handlers/inlay_hints.rs338
1 files changed, 338 insertions, 0 deletions
diff --git a/helix-term/src/handlers/inlay_hints.rs b/helix-term/src/handlers/inlay_hints.rs
new file mode 100644
index 00000000..e2d50a2b
--- /dev/null
+++ b/helix-term/src/handlers/inlay_hints.rs
@@ -0,0 +1,338 @@
+use std::{collections::HashSet, mem, time::Duration};
+
+use crate::job;
+
+use super::Handlers;
+
+use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation};
+use helix_event::{cancelable_future, register_hook, send_blocking};
+use helix_lsp::lsp;
+use helix_view::{
+ document::{DocumentInlayHints, DocumentInlayHintsId},
+ events::{
+ DocumentDidChange, DocumentDidOpen, LanguageServerExited, LanguageServerInitialized,
+ SelectionDidChange,
+ },
+ handlers::lsp::InlayHintEvent,
+ DocumentId, Editor, ViewId,
+};
+use tokio::time::Instant;
+
+#[derive(Debug, Default)]
+pub(super) struct InlayHintHandler {
+ views: HashSet<ViewId>,
+ docs: HashSet<DocumentId>,
+}
+
+const DOCUMENT_CHANGE_DEBOUNCE: Duration = Duration::from_millis(500);
+const VIEWPORT_SCROLL_DEBOUNCE: Duration = Duration::from_millis(100);
+
+impl helix_event::AsyncHook for InlayHintHandler {
+ type Event = InlayHintEvent;
+
+ fn handle_event(&mut self, event: Self::Event, timeout: Option<Instant>) -> Option<Instant> {
+ match event {
+ InlayHintEvent::DocumentChanged(doc) => {
+ self.docs.insert(doc);
+ Some(Instant::now() + DOCUMENT_CHANGE_DEBOUNCE)
+ }
+ InlayHintEvent::ViewportScrolled(view) => {
+ self.views.insert(view);
+ let mut new_timeout = Instant::now() + VIEWPORT_SCROLL_DEBOUNCE;
+ if let Some(timeout) = timeout {
+ new_timeout = new_timeout.max(timeout);
+ }
+ Some(new_timeout)
+ }
+ }
+ }
+
+ fn finish_debounce(&mut self) {
+ let mut views = mem::take(&mut self.views);
+ let docs = mem::take(&mut self.docs);
+
+ job::dispatch_blocking(move |editor, _compositor| {
+ // Drop any views which have been closed.
+ views.retain(|&view| editor.tree.contains(view));
+ // Add any views that show documents which changed.
+ views.extend(
+ editor
+ .tree
+ .views()
+ .filter_map(|(view, _)| docs.contains(&view.doc).then_some(view.id)),
+ );
+
+ for view in views {
+ let doc = editor.tree.get(view).doc;
+ let is_scroll = !docs.contains(&doc);
+ request_inlay_hints_for_view(editor, view, doc, is_scroll);
+ }
+ });
+ }
+}
+
+fn request_inlay_hints_for_view(
+ editor: &mut Editor,
+ view_id: ViewId,
+ doc_id: DocumentId,
+ is_scroll: bool,
+) {
+ if !editor.config().lsp.display_inlay_hints {
+ return;
+ }
+ let Some(doc) = editor.documents.get_mut(&doc_id) else {
+ return;
+ };
+ let Some(view) = editor.tree.try_get(view_id) else {
+ return;
+ };
+ let Some(language_server) = doc
+ .language_servers_with_feature(LanguageServerFeature::InlayHints)
+ .next()
+ else {
+ return;
+ };
+
+ let rope = doc.text();
+ let text = rope.slice(..);
+ let len_lines = text.len_lines();
+ let view_height = view.inner_height();
+ let first_visible_line =
+ text.char_to_line(doc.view_offset(view_id).anchor.min(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,
+ };
+ // If the view was updated by scrolling (rather than changing) and the viewport still has the
+ // the same position, we can reuse the hints.
+ if is_scroll
+ && doc
+ .inlay_hints(view_id)
+ .is_some_and(|hint| hint.id == new_doc_inlay_hints_id)
+ {
+ return;
+ }
+ let offset_encoding = language_server.offset_encoding();
+ let range = helix_lsp::util::range_to_lsp_range(
+ rope,
+ helix_core::Range::new(text.line_to_char(first_line), text.line_to_char(last_line)),
+ offset_encoding,
+ );
+ let future = language_server
+ .text_document_range_inlay_hints(doc.identifier(), range, None)
+ .expect("language server must return Some if it supports inlay hints");
+ let controller = doc.inlay_hint_controllers.entry(view_id).or_default();
+ let cancel = controller.restart();
+
+ tokio::spawn(async move {
+ match cancelable_future(future, cancel).await {
+ Some(Ok(res)) => {
+ job::dispatch(move |editor, _compositor| {
+ attach_inlay_hints(
+ editor,
+ view_id,
+ doc_id,
+ new_doc_inlay_hints_id,
+ offset_encoding,
+ res,
+ );
+ })
+ .await
+ }
+ Some(Err(err)) => log::error!("inlay hint request failed: {err}"),
+ None => (),
+ }
+ });
+}
+
+fn attach_inlay_hints(
+ editor: &mut Editor,
+ view_id: ViewId,
+ doc_id: DocumentId,
+ id: DocumentInlayHintsId,
+ offset_encoding: helix_lsp::OffsetEncoding,
+ response: Option<Vec<lsp::InlayHint>>,
+) {
+ if !editor.config().lsp.display_inlay_hints || editor.tree.try_get(view_id).is_none() {
+ return;
+ }
+
+ let Some(doc) = editor.documents.get_mut(&doc_id) else {
+ return;
+ };
+
+ let mut hints = match response {
+ Some(hints) if !hints.is_empty() => hints,
+ _ => {
+ doc.set_inlay_hints(view_id, DocumentInlayHints::empty_with_id(id));
+ 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();
+
+ 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 label = match hint.label {
+ lsp::InlayHintLabel::String(s) => s,
+ lsp::InlayHintLabel::LabelParts(parts) => parts
+ .into_iter()
+ .map(|p| p.value)
+ .collect::<Vec<_>>()
+ .join(""),
+ };
+
+ 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,
+ type_inlay_hints,
+ parameter_inlay_hints,
+ other_inlay_hints,
+ padding_before_inlay_hints,
+ padding_after_inlay_hints,
+ },
+ );
+}
+
+pub(super) fn register_hooks(handlers: &Handlers) {
+ register_hook!(move |event: &mut DocumentDidOpen<'_>| {
+ // When a document is initially opened, request inlay hints for it.
+ let views: Vec<_> = event
+ .editor
+ .tree
+ .views()
+ .filter_map(|(view, _)| (view.doc == event.doc).then_some(view.id))
+ .collect();
+ for view in views {
+ request_inlay_hints_for_view(event.editor, view, event.doc, false);
+ }
+
+ Ok(())
+ });
+
+ let tx = handlers.inlay_hints.clone();
+ register_hook!(move |event: &mut DocumentDidChange<'_>| {
+ // Update the inlay hint annotations' positions, helping ensure they are displayed in the
+ // proper place.
+ let apply_inlay_hint_changes = |annotations: &mut Vec<InlineAnnotation>| {
+ event.changes.update_positions(
+ annotations
+ .iter_mut()
+ .map(|annotation| (&mut annotation.char_idx, helix_core::Assoc::After)),
+ );
+ };
+
+ for (_view_id, text_annotation) in event.doc.inlay_hints_mut() {
+ let DocumentInlayHints {
+ id: _,
+ type_inlay_hints,
+ parameter_inlay_hints,
+ other_inlay_hints,
+ padding_before_inlay_hints,
+ padding_after_inlay_hints,
+ } = text_annotation;
+
+ apply_inlay_hint_changes(padding_before_inlay_hints);
+ apply_inlay_hint_changes(type_inlay_hints);
+ apply_inlay_hint_changes(parameter_inlay_hints);
+ apply_inlay_hint_changes(other_inlay_hints);
+ apply_inlay_hint_changes(padding_after_inlay_hints);
+ }
+
+ if !event.ghost_transaction {
+ if let Some(controller) = event.doc.inlay_hint_controllers.get_mut(&event.view) {
+ controller.cancel();
+ }
+ // TODO: ideally we should only send this if the document is visible.
+ send_blocking(&tx, InlayHintEvent::DocumentChanged(event.doc.id()));
+ }
+
+ Ok(())
+ });
+
+ let tx = handlers.inlay_hints.clone();
+ register_hook!(move |event: &mut SelectionDidChange<'_>| {
+ if let Some(controller) = event.doc.inlay_hint_controllers.get_mut(&event.view) {
+ controller.cancel();
+ }
+ // Ideally this would only trigger an update if the viewport changed...
+ send_blocking(&tx, InlayHintEvent::ViewportScrolled(event.view));
+
+ Ok(())
+ });
+
+ register_hook!(move |event: &mut LanguageServerInitialized<'_>| {
+ let views: Vec<_> = event
+ .editor
+ .tree
+ .views()
+ .map(|(view, _)| (view.id, view.doc))
+ .collect();
+ for (view, doc) in views {
+ request_inlay_hints_for_view(event.editor, view, doc, false);
+ }
+
+ Ok(())
+ });
+
+ register_hook!(move |event: &mut LanguageServerExited<'_>| {
+ // Clear and re-request all annotations when a server exits.
+ for doc in event.editor.documents_mut() {
+ if doc.supports_language_server(event.server_id) {
+ doc.reset_all_inlay_hints();
+ }
+ }
+
+ let views: Vec<_> = event
+ .editor
+ .tree
+ .views()
+ .map(|(view, _)| (view.id, view.doc))
+ .collect();
+ for (view, doc) in views {
+ request_inlay_hints_for_view(event.editor, view, doc, false);
+ }
+
+ Ok(())
+ });
+}