Move LSP inlay hints to the event system

This commit is contained in:
Michael Davis 2025-03-04 10:56:14 -05:00
parent a2c580c4ae
commit cbea11947d
No known key found for this signature in database
8 changed files with 368 additions and 216 deletions

View file

@ -13,18 +13,9 @@ use tui::{text::Span, widgets::Row};
use super::{align_view, push_jump, Align, Context, Editor};
use helix_core::{
diagnostic::DiagnosticProvider, syntax::LanguageServerFeature,
text_annotations::InlineAnnotation, Selection, Uri,
};
use helix_core::{diagnostic::DiagnosticProvider, syntax::LanguageServerFeature, Selection, Uri};
use helix_stdx::path;
use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId},
editor::Action,
handlers::lsp::SignatureHelpInvoked,
theme::Style,
Document, View,
};
use helix_view::{editor::Action, handlers::lsp::SignatureHelpInvoked, theme::Style};
use crate::{
compositor::{self, Compositor},
@ -1251,164 +1242,3 @@ pub fn select_references_to_symbol_under_cursor(cx: &mut Context) {
},
);
}
pub fn compute_inlay_hints_for_all_views(editor: &mut Editor, jobs: &mut crate::job::Jobs) {
if !editor.config().lsp.display_inlay_hints {
return;
}
for (view, _) in editor.tree.views() {
let doc = match editor.documents.get(&view.doc) {
Some(doc) => doc,
None => continue,
};
if let Some(callback) = compute_inlay_hints_for_view(view, doc) {
jobs.callback(callback);
}
}
}
fn compute_inlay_hints_for_view(
view: &View,
doc: &Document,
) -> Option<std::pin::Pin<Box<impl Future<Output = Result<crate::job::Callback, anyhow::Error>>>>> {
let view_id = view.id;
let doc_id = view.doc;
let language_server = doc
.language_servers_with_feature(LanguageServerFeature::InlayHints)
.next()?;
let doc_text = doc.text();
let len_lines = doc_text.len_lines();
// Compute ~3 times the current view height of inlay hints, that way some scrolling
// will not show half the view with hints and half without while still being faster
// than computing all the hints for the full file (which could be dozens of time
// longer than the view is).
let view_height = view.inner_height();
let first_visible_line =
doc_text.char_to_line(doc.view_offset(view_id).anchor.min(doc_text.len_chars()));
let first_line = first_visible_line.saturating_sub(view_height);
let last_line = first_visible_line
.saturating_add(view_height.saturating_mul(2))
.min(len_lines);
let new_doc_inlay_hints_id = DocumentInlayHintsId {
first_line,
last_line,
};
// Don't recompute the annotations in case nothing has changed about the view
if !doc.inlay_hints_oudated
&& doc
.inlay_hints(view_id)
.is_some_and(|dih| dih.id == new_doc_inlay_hints_id)
{
return None;
}
let doc_slice = doc_text.slice(..);
let first_char_in_range = doc_slice.line_to_char(first_line);
let last_char_in_range = doc_slice.line_to_char(last_line);
let range = helix_lsp::util::range_to_lsp_range(
doc_text,
helix_core::Range::new(first_char_in_range, last_char_in_range),
language_server.offset_encoding(),
);
let offset_encoding = language_server.offset_encoding();
let callback = super::make_job_callback(
language_server.text_document_range_inlay_hints(doc.identifier(), range, None)?,
move |editor, _compositor, response: Option<Vec<lsp::InlayHint>>| {
// The config was modified or the window was closed while the request was in flight
if !editor.config().lsp.display_inlay_hints || editor.tree.try_get(view_id).is_none() {
return;
}
// Add annotations to relevant document, not the current one (it may have changed in between)
let doc = match editor.documents.get_mut(&doc_id) {
Some(doc) => doc,
None => return,
};
// If we have neither hints nor an LSP, empty the inlay hints since they're now oudated
let mut hints = match response {
Some(hints) if !hints.is_empty() => hints,
_ => {
doc.set_inlay_hints(
view_id,
DocumentInlayHints::empty_with_id(new_doc_inlay_hints_id),
);
doc.inlay_hints_oudated = false;
return;
}
};
// Most language servers will already send them sorted but ensure this is the case to
// avoid errors on our end.
hints.sort_by_key(|inlay_hint| inlay_hint.position);
let mut padding_before_inlay_hints = Vec::new();
let mut type_inlay_hints = Vec::new();
let mut parameter_inlay_hints = Vec::new();
let mut other_inlay_hints = Vec::new();
let mut padding_after_inlay_hints = Vec::new();
let doc_text = doc.text();
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: 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)
}

View file

@ -1623,8 +1623,6 @@ fn lsp_stop(cx: &mut compositor::Context, args: Args, event: PromptEvent) -> any
for doc in cx.editor.documents_mut() {
if let Some(client) = doc.remove_language_server_by_name(ls_name) {
doc.clear_diagnostics_for_language_server(client.id());
doc.reset_all_inlay_hints();
doc.inlay_hints_oudated = true;
}
}
}

View file

@ -6,6 +6,7 @@ use helix_event::AsyncHook;
use crate::config::Config;
use crate::events;
use crate::handlers::auto_save::AutoSaveHandler;
use crate::handlers::inlay_hints::InlayHintHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use helix_view::handlers::Handlers;
@ -16,6 +17,7 @@ mod auto_save;
pub mod completion;
mod diagnostics;
mod document_colors;
mod inlay_hints;
mod signature_help;
mod snippet;
@ -24,12 +26,14 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
let event_tx = completion::CompletionHandler::new(config).spawn();
let signature_hints = SignatureHelpHandler::new().spawn();
let inlay_hints = InlayHintHandler::default().spawn();
let auto_save = AutoSaveHandler::new().spawn();
let document_colors = DocumentColorsHandler::default().spawn();
let handlers = Handlers {
completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
signature_hints,
inlay_hints,
auto_save,
document_colors,
};
@ -41,5 +45,6 @@ pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
diagnostics::register_hooks(&handlers);
snippet::register_hooks(&handlers);
document_colors::register_hooks(&handlers);
inlay_hints::register_hooks(&handlers);
handlers
}

View file

@ -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(())
});
}

View file

@ -1105,12 +1105,6 @@ impl EditorView {
}
on_next_key
}
pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
commands::compute_inlay_hints_for_all_views(cx.editor, cx.jobs);
EventResult::Ignored(None)
}
}
impl EditorView {
@ -1516,7 +1510,7 @@ impl Component for EditorView {
}
Event::Mouse(event) => self.handle_mouse_event(event, &mut cx),
Event::IdleTimeout => self.handle_idle_timeout(&mut cx),
Event::IdleTimeout => EventResult::Ignored(None),
Event::FocusGained => {
self.terminal_focused = true;
EventResult::Consumed(None)

View file

@ -148,9 +148,6 @@ pub struct Document {
/// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
/// Set to `true` when the document is updated, reset to `false` on the next inlay hints
/// update from the LSP
pub inlay_hints_oudated: bool,
path: Option<PathBuf>,
relative_path: OnceCell<Option<PathBuf>>,
@ -207,6 +204,8 @@ pub struct Document {
// NOTE: ideally this would live on the handler for color swatches. This is blocked on a
// large refactor that would make `&mut Editor` available on the `DocumentDidChange` event.
pub color_swatch_controller: TaskController,
// NOTE: ideally this would live on the handler for inlay hints, see the comment above.
pub inlay_hint_controllers: HashMap<ViewId, TaskController>,
}
#[derive(Debug, Clone, Default)]
@ -290,7 +289,6 @@ impl fmt::Debug for Document {
.field("id", &self.id)
.field("text", &self.text)
.field("selections", &self.selections)
.field("inlay_hints_oudated", &self.inlay_hints_oudated)
.field("text_annotations", &self.inlay_hints)
.field("view_data", &self.view_data)
.field("path", &self.path)
@ -693,7 +691,7 @@ impl Document {
text,
selections: HashMap::default(),
inlay_hints: HashMap::default(),
inlay_hints_oudated: false,
inlay_hint_controllers: HashMap::default(),
view_data: Default::default(),
indent_style: DEFAULT_INDENT,
editor_config: EditorConfig::default(),
@ -1461,33 +1459,6 @@ impl Document {
)
});
// Update the inlay hint annotations' positions, helping ensure they are displayed in the proper place
let apply_inlay_hint_changes = |annotations: &mut Vec<InlineAnnotation>| {
changes.update_positions(
annotations
.iter_mut()
.map(|annotation| (&mut annotation.char_idx, Assoc::After)),
);
};
self.inlay_hints_oudated = true;
for text_annotation in self.inlay_hints.values_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);
}
helix_event::dispatch(DocumentDidChange {
doc: self,
view: view_id,
@ -2242,6 +2213,12 @@ impl Document {
self.inlay_hints.get(&view_id)
}
pub fn inlay_hints_mut(&mut self) -> impl Iterator<Item = (ViewId, &mut DocumentInlayHints)> {
self.inlay_hints
.iter_mut()
.map(|(view_id, hints)| (*view_id, hints))
}
/// Completely removes all the inlay hints saved for the document, dropping them to free memory
/// (since it often means inlay hints have been fully deactivated).
pub fn reset_all_inlay_hints(&mut self) {

View file

@ -20,6 +20,7 @@ pub struct Handlers {
// only public because most of the actual implementation is in helix-term right now :/
pub completions: CompletionHandler,
pub signature_hints: Sender<lsp::SignatureHelpEvent>,
pub inlay_hints: Sender<lsp::InlayHintEvent>,
pub auto_save: Sender<AutoSaveEvent>,
pub document_colors: Sender<lsp::DocumentColorsEvent>,
}

View file

@ -5,9 +5,8 @@ use crate::editor::Action;
use crate::events::{
DiagnosticsDidChange, DocumentDidChange, DocumentDidClose, LanguageServerInitialized,
};
use crate::{DocumentId, Editor};
use helix_core::diagnostic::DiagnosticProvider;
use helix_core::Uri;
use crate::{DocumentId, Editor, ViewId};
use helix_core::{diagnostic::DiagnosticProvider, Uri};
use helix_event::register_hook;
use helix_lsp::util::generate_transaction_from_edits;
use helix_lsp::{lsp, LanguageServerId, OffsetEncoding};
@ -30,6 +29,16 @@ pub enum SignatureHelpEvent {
RequestComplete { open: bool },
}
#[derive(Debug)]
pub enum InlayHintEvent {
/// The contents of a document changed.
/// This event should request annotations after a long debounce.
DocumentChanged(DocumentId),
/// The viewport was scrolled and/or the selection changed.
/// This event should request annotations after a short debounce.
ViewportScrolled(ViewId),
}
#[derive(Debug)]
pub struct ApplyEditError {
pub kind: ApplyEditErrorKind,