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 | 337 |
1 files changed, 63 insertions, 274 deletions
diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs index f3223487..41ada7b4 100644 --- a/helix-term/src/handlers/completion.rs +++ b/helix-term/src/handlers/completion.rs @@ -1,307 +1,86 @@ -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::Arc; -use std::time::Duration; -use arc_swap::ArcSwap; -use futures_util::stream::FuturesUnordered; -use futures_util::FutureExt; +use anyhow::Result; + 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, send_blocking, 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::lsp::CompletionEvent; -use helix_view::{DocumentId, Editor, ViewId}; -use path::path_completion; +use helix_view::Editor; use tokio::sync::mpsc::Sender; -use tokio::time::Instant; -use tokio_stream::StreamExt as _; +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::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>, + incomplete: bool, +) -> Option<CompletionResponse> { + loop { + let response = requests.join_next().await?.unwrap(); + if !incomplete && !response.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>, + 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 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)) - }); - - 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(response) = handle_response(&mut requests, 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; + } + completion.replace_provider_completions(response); + if completion.is_empty() { + editor_view.clear_completion(editor); + // clearing completions might mean we want to immediately rerequest them (usually + // this occurs if typing a trigger char) + trigger_auto_completion(&editor.handlers.completions, editor, false); } - .boxed() - }) - .chain(path_completion(cursor, text.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) }) - .await - }); + .await; + } } fn show_completion( editor: &mut Editor, compositor: &mut Compositor, items: Vec<CompletionItem>, + incomplete_completion_lists: HashMap<CompletionProvider, i8>, trigger: Trigger, savepoint: Arc<SavePoint>, ) { @@ -321,7 +100,14 @@ fn show_completion( return; } - let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size); + let completion_area = ui.set_completion( + editor, + savepoint, + items, + incomplete_completion_lists, + trigger.pos, + size, + ); let signature_help_area = compositor .find_id::<Popup<SignatureHelp>>(SignatureHelp::ID) .map(|signature_help| signature_help.area(size, editor)); @@ -395,18 +181,21 @@ pub fn trigger_auto_completion( } } -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 let Some(ui) = &mut editor_view.completion { + ui.update_filter(c); + if ui.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); } + } else { + let handle = ui.incomplete_list_controller.restart(); + request_incomplete_completion_list(cx.editor, ui, handle) } } })) @@ -422,7 +211,7 @@ fn clear_completions(cx: &mut commands::Context) { fn completion_post_command_hook( tx: &Sender<CompletionEvent>, PostCommand { command, cx }: &mut PostCommand<'_, '_>, -) -> anyhow::Result<()> { +) -> Result<()> { if cx.editor.mode == Mode::Insert { if cx.editor.last_completion.is_some() { match command { @@ -433,7 +222,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 { @@ -483,7 +272,7 @@ pub(super) fn register_hooks(handlers: &Handlers) { 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); } |