Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/handlers/completion.rs')
| -rw-r--r-- | helix-term/src/handlers/completion.rs | 392 |
1 files changed, 84 insertions, 308 deletions
diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs index 4e03a063..046cfab7 100644 --- a/helix-term/src/handlers/completion.rs +++ b/helix-term/src/handlers/completion.rs @@ -1,310 +1,90 @@ -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; +use std::collections::HashMap; -use arc_swap::ArcSwap; -use futures_util::stream::FuturesUnordered; -use futures_util::FutureExt; use helix_core::chars::char_is_word; +use helix_core::completion::CompletionProvider; use helix_core::syntax::LanguageServerFeature; -use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle}; +use helix_event::{register_hook, TaskHandle}; 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::completion::CompletionEvent; -use helix_view::{DocumentId, Editor, ViewId}; -use path::path_completion; -use tokio::sync::mpsc::Sender; -use tokio::time::Instant; -use tokio_stream::StreamExt as _; +use helix_view::document::Mode; +use helix_view::handlers::completion::{CompletionEvent, ResponseContext}; +use helix_view::Editor; +use tokio::task::JoinSet; 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::handlers::completion::request::{request_incomplete_completion_list, Trigger}; +use crate::job::dispatch; use crate::keymap::MappableCommand; -use crate::ui::editor::InsertEvent; use crate::ui::lsp::signature_help::SignatureHelp; use crate::ui::{self, Popup}; use super::Handlers; -pub use item::{CompletionItem, LspCompletionItem}; + +pub use item::{CompletionItem, CompletionItems, CompletionResponse, LspCompletionItem}; +pub use request::CompletionHandler; pub use resolve::ResolveHandler; + mod item; mod path; +mod request; mod resolve; -#[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>, - in_flight: Option<Trigger>, - task_controller: TaskController, - config: Arc<ArcSwap<Config>>, -} - -impl CompletionHandler { - pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler { - Self { - config, - task_controller: TaskController::new(), - trigger: None, - in_flight: None, +async fn handle_response( + requests: &mut JoinSet<CompletionResponse>, + is_incomplete: bool, +) -> Option<CompletionResponse> { + loop { + let response = requests.join_next().await?.unwrap(); + if !is_incomplete && !response.context.is_incomplete && response.items.is_empty() { + continue; } + return Some(response); } } -impl helix_event::AsyncHook for CompletionHandler { - type Event = CompletionEvent; - - fn handle_event( - &mut self, - event: Self::Event, - _old_timeout: Option<Instant>, - ) -> Option<Instant> { - if self.in_flight.is_some() && !self.task_controller.is_running() { - self.in_flight = None; - } - 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 - .or(self.in_flight) - .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.task_controller.cancel(); - 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.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.task_controller.cancel(); - } - CompletionEvent::DeleteText { cursor } => { - // if we deleted the original trigger, abort the completion - if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos) - { - self.trigger = None; - self.task_controller.cancel(); - } - } - } - self.trigger.map(|trigger| { - // if the current request was closed forget about it - // otherwise immediately restart the completion request - let timeout = if trigger.kind == TriggerKind::Auto { - 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"); - self.in_flight = Some(trigger); - let handle = self.task_controller.restart(); - dispatch_blocking(move |editor, compositor| { - request_completion(trigger, handle, editor, compositor) - }); - } -} - -fn request_completion( - mut trigger: Trigger, +async fn replace_completions( handle: TaskHandle, - editor: &mut Editor, - compositor: &mut Compositor, + mut requests: JoinSet<CompletionResponse>, + is_incomplete: bool, ) { - let (view, doc) = current!(editor); - - if compositor - .find::<ui::EditorView>() - .unwrap() - .completion - .is_some() - || editor.mode != Mode::Insert - { - return; - } - - let text = doc.text(); - let selection = doc.selection(view.id); - let cursor = selection.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)) - }); - - if trigger_char.is_some() { - lsp::CompletionContext { - trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER, - trigger_character: trigger_char.cloned(), - } - } else { - lsp::CompletionContext { - trigger_kind: lsp::CompletionTriggerKind::INVOKED, - trigger_character: None, - } - } + while let Some(mut response) = handle_response(&mut requests, is_incomplete).await { + let handle = handle.clone(); + dispatch(move |editor, compositor| { + let editor_view = compositor.find::<ui::EditorView>().unwrap(); + let Some(completion) = &mut editor_view.completion else { + return; }; - - 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::Lsp(LspCompletionItem { - item, - provider: language_server_id, - resolved: false, - }) - }) - .collect(); - anyhow::Ok(items) + if handle.is_canceled() { + log::error!("dropping outdated completion response"); + return; } - .boxed() - }) - .chain(path_completion(selection.clone(), doc, handle.clone())) - .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, &handle).await; - let Some(items) = items.filter(|items| !items.is_empty()) else { - return; - }; - dispatch(move |editor, compositor| { - show_completion(editor, compositor, items, trigger, savepoint); - drop(handle) + completion.replace_provider_completions(&mut response, is_incomplete); + if completion.is_empty() { + editor_view.clear_completion(editor); + // clearing completions might mean we want to immediately re-request them (usually + // this occurs if typing a trigger char) + trigger_auto_completion(editor, false); + } else { + editor + .handlers + .completions + .active_completions + .insert(response.provider, response.context); + } }) - .await - }); + .await; + } } fn show_completion( editor: &mut Editor, compositor: &mut Compositor, items: Vec<CompletionItem>, + context: HashMap<CompletionProvider, ResponseContext>, trigger: Trigger, - savepoint: Arc<SavePoint>, ) { let (view, doc) = current_ref!(editor); // check if the completion request is stale. @@ -321,8 +101,9 @@ fn show_completion( if ui.completion.is_some() { return; } + editor.handlers.completions.active_completions = context; - let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size); + let completion_area = ui.set_completion(editor, items, trigger.pos, size); let signature_help_area = compositor .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID) .map(|signature_help| signature_help.area(size, editor)); @@ -332,11 +113,7 @@ fn show_completion( } } -pub fn trigger_auto_completion( - tx: &Sender<CompletionEvent>, - editor: &Editor, - trigger_char_only: bool, -) { +pub fn trigger_auto_completion(editor: &Editor, trigger_char_only: bool) { let config = editor.config.load(); if !config.auto_completion { return; @@ -364,15 +141,13 @@ pub fn trigger_auto_completion( #[cfg(not(windows))] let is_path_completion_trigger = matches!(cursor_char, Some(b'/')); + let handler = &editor.handlers.completions; if is_trigger_char || (is_path_completion_trigger && doc.path_completion_enabled()) { - send_blocking( - tx, - CompletionEvent::TriggerChar { - cursor, - doc: doc.id(), - view: view.id, - }, - ); + handler.event(CompletionEvent::TriggerChar { + cursor, + doc: doc.id(), + view: view.id, + }); return; } @@ -385,29 +160,29 @@ pub fn trigger_auto_completion( .all(char_is_word); if is_auto_trigger { - send_blocking( - tx, - CompletionEvent::AutoTrigger { - cursor, - doc: doc.id(), - view: view.id, - }, - ); + handler.event(CompletionEvent::AutoTrigger { + cursor, + doc: doc.id(), + view: view.id, + }); } } -fn update_completions(cx: &mut commands::Context, c: Option<char>) { +fn update_completion_filter(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() { + if completion.is_empty() || c.is_some_and(|c| !char_is_word(c)) { 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); + trigger_auto_completion(cx.editor, false); } + } else { + let handle = cx.editor.handlers.completions.request_controller.restart(); + request_incomplete_completion_list(cx.editor, handle) } } })) @@ -421,7 +196,6 @@ fn clear_completions(cx: &mut commands::Context) { } fn completion_post_command_hook( - tx: &Sender<CompletionEvent>, PostCommand { command, cx }: &mut PostCommand<'_, '_>, ) -> anyhow::Result<()> { if cx.editor.mode == Mode::Insert { @@ -434,7 +208,7 @@ fn completion_post_command_hook( MappableCommand::Static { name: "delete_char_backward", .. - } => update_completions(cx, None), + } => update_completion_filter(cx, None), _ => clear_completions(cx), } } else { @@ -460,33 +234,35 @@ fn completion_post_command_hook( } => return Ok(()), _ => CompletionEvent::Cancel, }; - send_blocking(tx, event); + cx.editor.handlers.completions.event(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)); +pub(super) fn register_hooks(_handlers: &Handlers) { + register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(event)); - let tx = handlers.completions.clone(); register_hook!(move |event: &mut OnModeSwitch<'_, '_>| { if event.old_mode == Mode::Insert { - send_blocking(&tx, CompletionEvent::Cancel); + event + .cx + .editor + .handlers + .completions + .event(CompletionEvent::Cancel); clear_completions(event.cx); } else if event.new_mode == Mode::Insert { - trigger_auto_completion(&tx, event.cx.editor, false) + trigger_auto_completion(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)) + update_completion_filter(event.cx, Some(event.c)) } else { - trigger_auto_completion(&tx, event.cx.editor, false); + trigger_auto_completion(event.cx.editor, false); } Ok(()) }); |