diff options
author | Pascal Kuthe | 2023-11-30 23:03:27 +0000 |
---|---|---|
committer | Blaž Hrastnik | 2024-01-23 02:20:19 +0000 |
commit | 8e592a151fe7adfbf3fb35ae134b7f2a70700f09 (patch) | |
tree | 603a94042068620e52f50cb26cf881d5461d1c8d /helix-term/src/handlers/completion.rs | |
parent | 13ed4f6c4748019787d24c2b686d417b71604242 (diff) |
refactor completion and signature help using hooks
Diffstat (limited to 'helix-term/src/handlers/completion.rs')
-rw-r--r-- | helix-term/src/handlers/completion.rs | 465 |
1 files changed, 465 insertions, 0 deletions
diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs new file mode 100644 index 00000000..d71fd24f --- /dev/null +++ b/helix-term/src/handlers/completion.rs @@ -0,0 +1,465 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use futures_util::stream::FuturesUnordered; +use helix_core::chars::char_is_word; +use helix_core::syntax::LanguageServerFeature; +use helix_event::{ + cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx, +}; +use helix_lsp::lsp; +use helix_lsp::util::pos_to_lsp_pos; +use helix_stdx::rope::RopeSliceExt; +use helix_view::document::{Mode, SavePoint}; +use helix_view::handlers::lsp::CompletionEvent; +use helix_view::{DocumentId, Editor, ViewId}; +use tokio::sync::mpsc::Sender; +use tokio::time::Instant; +use tokio_stream::StreamExt; + +use crate::commands; +use crate::compositor::Compositor; +use crate::config::Config; +use crate::events::{OnModeSwitch, PostCommand, PostInsertChar}; +use crate::job::{dispatch, dispatch_blocking}; +use crate::keymap::MappableCommand; +use crate::ui::editor::InsertEvent; +use crate::ui::lsp::SignatureHelp; +use crate::ui::{self, CompletionItem, Popup}; + +use super::Handlers; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum TriggerKind { + Auto, + TriggerChar, + Manual, +} + +#[derive(Debug, Clone, Copy)] +struct Trigger { + pos: usize, + view: ViewId, + doc: DocumentId, + kind: TriggerKind, +} + +#[derive(Debug)] +pub(super) struct CompletionHandler { + /// currently active trigger which will cause a + /// completion request after the timeout + trigger: Option<Trigger>, + /// A handle for currently active completion request. + /// This can be used to determine whether the current + /// request is still active (and new triggers should be + /// ignored) and can also be used to abort the current + /// request (by dropping the handle) + request: Option<CancelTx>, + config: Arc<ArcSwap<Config>>, +} + +impl CompletionHandler { + pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler { + Self { + config, + request: None, + trigger: None, + } + } +} + +impl helix_event::AsyncHook for CompletionHandler { + type Event = CompletionEvent; + + fn handle_event( + &mut self, + event: Self::Event, + _old_timeout: Option<Instant>, + ) -> Option<Instant> { + match event { + CompletionEvent::AutoTrigger { + cursor: trigger_pos, + doc, + view, + } => { + // techically it shouldn't be possible to switch views/documents in insert mode + // but people may create weird keymaps/use the mouse so lets be extra careful + if self + .trigger + .as_ref() + .map_or(true, |trigger| trigger.doc != doc || trigger.view != view) + { + self.trigger = Some(Trigger { + pos: trigger_pos, + view, + doc, + kind: TriggerKind::Auto, + }); + } + } + CompletionEvent::TriggerChar { cursor, doc, view } => { + // immediately request completions and drop all auto completion requests + self.request = None; + self.trigger = Some(Trigger { + pos: cursor, + view, + doc, + kind: TriggerKind::TriggerChar, + }); + } + CompletionEvent::ManualTrigger { cursor, doc, view } => { + // immediately request completions and drop all auto completion requests + self.request = None; + self.trigger = Some(Trigger { + pos: cursor, + view, + doc, + kind: TriggerKind::Manual, + }); + // stop debouncing immediately and request the completion + self.finish_debounce(); + return None; + } + CompletionEvent::Cancel => { + self.trigger = None; + self.request = None; + } + CompletionEvent::DeleteText { cursor } => { + // if we deleted the original trigger, abort the completion + if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) { + self.trigger = None; + self.request = None; + } + } + } + self.trigger.map(|trigger| { + // if the current request was closed forget about it + // otherwise immediately restart the completion request + let cancel = self.request.take().map_or(false, |req| !req.is_closed()); + let timeout = if trigger.kind == TriggerKind::Auto && !cancel { + self.config.load().editor.completion_timeout + } else { + // we want almost instant completions for trigger chars + // and restarting completion requests. The small timeout here mainly + // serves to better handle cases where the completion handler + // may fall behind (so multiple events in the channel) and macros + Duration::from_millis(5) + }; + Instant::now() + timeout + }) + } + + fn finish_debounce(&mut self) { + let trigger = self.trigger.take().expect("debounce always has a trigger"); + let (tx, rx) = cancelation(); + self.request = Some(tx); + dispatch_blocking(move |editor, compositor| { + request_completion(trigger, rx, editor, compositor) + }); + } +} + +fn request_completion( + mut trigger: Trigger, + cancel: CancelRx, + editor: &mut Editor, + compositor: &mut Compositor, +) { + let (view, doc) = current!(editor); + + if compositor + .find::<ui::EditorView>() + .unwrap() + .completion + .is_some() + || editor.mode != Mode::Insert + { + return; + } + + let text = doc.text(); + let cursor = doc.selection(view.id).primary().cursor(text.slice(..)); + if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos { + return; + } + // this looks odd... Why are we not using the trigger position from + // the `trigger` here? Won't that mean that the trigger char doesn't get + // send to the LS if we type fast enougn? Yes that is true but it's + // not actually a problem. The LSP will resolve the completion to the identifier + // anyway (in fact sending the later position is necessary to get the right results + // from LSPs that provide incomplete completion list). We rely on trigger offset + // and primary cursor matching for multi-cursor completions so this is definitely + // necessary from our side too. + trigger.pos = cursor; + let trigger_text = text.slice(..cursor); + + let mut seen_language_servers = HashSet::new(); + let mut futures: FuturesUnordered<_> = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .filter(|ls| seen_language_servers.insert(ls.id())) + .map(|ls| { + let language_server_id = ls.id(); + let offset_encoding = ls.offset_encoding(); + let pos = pos_to_lsp_pos(text, cursor, offset_encoding); + let doc_id = doc.identifier(); + let context = if trigger.kind == TriggerKind::Manual { + lsp::CompletionContext { + trigger_kind: lsp::CompletionTriggerKind::INVOKED, + trigger_character: None, + } + } else { + let trigger_char = + ls.capabilities() + .completion_provider + .as_ref() + .and_then(|provider| { + provider + .trigger_characters + .as_deref()? + .iter() + .find(|&trigger| trigger_text.ends_with(trigger)) + }); + lsp::CompletionContext { + trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_character: trigger_char.cloned(), + } + }; + + let completion_response = ls.completion(doc_id, pos, None, context).unwrap(); + async move { + let json = completion_response.await?; + let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?; + let items = match response { + Some(lsp::CompletionResponse::Array(items)) => items, + // TODO: do something with is_incomplete + Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: _is_incomplete, + items, + })) => items, + None => Vec::new(), + } + .into_iter() + .map(|item| CompletionItem { + item, + language_server_id, + resolved: false, + }) + .collect(); + anyhow::Ok(items) + } + }) + .collect(); + + let future = async move { + let mut items = Vec::new(); + while let Some(lsp_items) = futures.next().await { + match lsp_items { + Ok(mut lsp_items) => items.append(&mut lsp_items), + Err(err) => { + log::debug!("completion request failed: {err:?}"); + } + }; + } + items + }; + + let savepoint = doc.savepoint(view); + + let ui = compositor.find::<ui::EditorView>().unwrap(); + ui.last_insert.1.push(InsertEvent::RequestCompletion); + tokio::spawn(async move { + let items = cancelable_future(future, cancel).await.unwrap_or_default(); + if items.is_empty() { + return; + } + dispatch(move |editor, compositor| { + show_completion(editor, compositor, items, trigger, savepoint) + }) + .await + }); +} + +fn show_completion( + editor: &mut Editor, + compositor: &mut Compositor, + items: Vec<CompletionItem>, + trigger: Trigger, + savepoint: Arc<SavePoint>, +) { + let (view, doc) = current_ref!(editor); + // check if the completion request is stale. + // + // Completions are completed asynchronously and therefore the user could + //switch document/view or leave insert mode. In all of thoise cases the + // completion should be discarded + if editor.mode != Mode::Insert || view.id != trigger.view || doc.id() != trigger.doc { + return; + } + + let size = compositor.size(); + let ui = compositor.find::<ui::EditorView>().unwrap(); + if ui.completion.is_some() { + return; + } + + let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size); + let signature_help_area = compositor + .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID) + .map(|signature_help| signature_help.area(size, editor)); + // Delete the signature help popup if they intersect. + if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b)) { + compositor.remove(SignatureHelp::ID); + } +} + +pub fn trigger_auto_completion( + tx: &Sender<CompletionEvent>, + editor: &Editor, + trigger_char_only: bool, +) { + let config = editor.config.load(); + if !config.auto_completion { + return; + } + let (view, doc): (&helix_view::View, &helix_view::Document) = current_ref!(editor); + let mut text = doc.text().slice(..); + let cursor = doc.selection(view.id).primary().cursor(text); + text = doc.text().slice(..cursor); + + let is_trigger_char = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .any(|ls| { + matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions { + trigger_characters: Some(triggers), + .. + }) if triggers.iter().any(|trigger| text.ends_with(trigger))) + }); + if is_trigger_char { + send_blocking( + tx, + CompletionEvent::TriggerChar { + cursor, + doc: doc.id(), + view: view.id, + }, + ); + return; + } + + let is_auto_trigger = !trigger_char_only + && doc + .text() + .chars_at(cursor) + .reversed() + .take(config.completion_trigger_len as usize) + .all(char_is_word); + + if is_auto_trigger { + send_blocking( + tx, + CompletionEvent::AutoTrigger { + cursor, + doc: doc.id(), + view: view.id, + }, + ); + } +} + +fn update_completions(cx: &mut commands::Context, c: Option<char>) { + cx.callback.push(Box::new(move |compositor, cx| { + let editor_view = compositor.find::<ui::EditorView>().unwrap(); + if let Some(completion) = &mut editor_view.completion { + completion.update_filter(c); + if completion.is_empty() { + editor_view.clear_completion(cx.editor); + // clearing completions might mean we want to immediately rerequest them (usually + // this occurs if typing a trigger char) + if c.is_some() { + trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false); + } + } + } + })) +} + +fn clear_completions(cx: &mut commands::Context) { + cx.callback.push(Box::new(|compositor, cx| { + let editor_view = compositor.find::<ui::EditorView>().unwrap(); + editor_view.clear_completion(cx.editor); + })) +} + +fn completion_post_command_hook( + tx: &Sender<CompletionEvent>, + PostCommand { command, cx }: &mut PostCommand<'_, '_>, +) -> anyhow::Result<()> { + if cx.editor.mode == Mode::Insert { + if cx.editor.last_completion.is_some() { + match command { + MappableCommand::Static { + name: "delete_word_forward" | "delete_char_forward" | "completion", + .. + } => (), + MappableCommand::Static { + name: "delete_char_backward", + .. + } => update_completions(cx, None), + _ => clear_completions(cx), + } + } else { + let event = match command { + MappableCommand::Static { + name: "delete_char_backward" | "delete_word_forward" | "delete_char_forward", + .. + } => { + let (view, doc) = current!(cx.editor); + let primary_cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + CompletionEvent::DeleteText { + cursor: primary_cursor, + } + } + // hacks: some commands are handeled elsewhere and we don't want to + // cancel in that case + MappableCommand::Static { + name: "completion" | "insert_mode" | "append_mode", + .. + } => return Ok(()), + _ => CompletionEvent::Cancel, + }; + send_blocking(tx, event); + } + } + Ok(()) +} + +pub(super) fn register_hooks(handlers: &Handlers) { + let tx = handlers.completions.clone(); + register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(&tx, event)); + + let tx = handlers.completions.clone(); + register_hook!(move |event: &mut OnModeSwitch<'_, '_>| { + if event.old_mode == Mode::Insert { + send_blocking(&tx, CompletionEvent::Cancel); + clear_completions(event.cx); + } else if event.new_mode == Mode::Insert { + trigger_auto_completion(&tx, event.cx.editor, false) + } + Ok(()) + }); + + let tx = handlers.completions.clone(); + register_hook!(move |event: &mut PostInsertChar<'_, '_>| { + if event.cx.editor.last_completion.is_some() { + update_completions(event.cx, Some(event.c)) + } else { + trigger_auto_completion(&tx, event.cx.editor, false); + } + Ok(()) + }); +} |