diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index 31e15330e..b580e678b 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -6,10 +6,8 @@ use helix_event::AsyncHook; use crate::config::Config; use crate::events; use crate::handlers::auto_save::AutoSaveHandler; -use crate::handlers::completion::CompletionHandler; use crate::handlers::signature_help::SignatureHelpHandler; -pub use completion::trigger_auto_completion; pub use helix_view::handlers::Handlers; mod auto_save; @@ -21,12 +19,12 @@ mod snippet; pub fn setup(config: Arc>) -> Handlers { events::register(); - let completions = CompletionHandler::new(config).spawn(); + let event_tx = completion::CompletionHandler::new(config).spawn(); let signature_hints = SignatureHelpHandler::new().spawn(); let auto_save = AutoSaveHandler::new().spawn(); let handlers = Handlers { - completions, + completions: helix_view::handlers::completion::CompletionHandler::new(event_tx), signature_hints, auto_save, }; diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs index 4e03a0636..046cfab79 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, - in_flight: Option, - task_controller: TaskController, - config: Arc>, -} - -impl CompletionHandler { - pub fn new(config: Arc>) -> CompletionHandler { - Self { - config, - task_controller: TaskController::new(), - trigger: None, - in_flight: None, +async fn handle_response( + requests: &mut JoinSet, + is_incomplete: bool, +) -> Option { + 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, - ) -> Option { - 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, + is_incomplete: bool, ) { - let (view, doc) = current!(editor); - - if compositor - .find::() - .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, - } - } - }; - - let completion_response = ls.completion(doc_id, pos, None, context).unwrap(); - async move { - let json = completion_response.await?; - let response: Option = 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) - } - .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::().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; - }; + while let Some(mut response) = handle_response(&mut requests, is_incomplete).await { + let handle = handle.clone(); dispatch(move |editor, compositor| { - show_completion(editor, compositor, items, trigger, savepoint); - drop(handle) + let editor_view = compositor.find::().unwrap(); + let Some(completion) = &mut editor_view.completion else { + return; + }; + if handle.is_canceled() { + log::error!("dropping outdated completion response"); + return; + } + + 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, + context: HashMap, trigger: Trigger, - savepoint: Arc, ) { 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::>(SignatureHelp::ID) .map(|signature_help| signature_help.area(size, editor)); @@ -332,11 +113,7 @@ fn show_completion( } } -pub fn trigger_auto_completion( - tx: &Sender, - 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) { +fn update_completion_filter(cx: &mut commands::Context, c: Option) { cx.callback.push(Box::new(move |compositor, cx| { let editor_view = compositor.find::().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, 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(()) }); diff --git a/helix-term/src/handlers/completion/item.rs b/helix-term/src/handlers/completion/item.rs index bcd35cd54..7a473b024 100644 --- a/helix-term/src/handlers/completion/item.rs +++ b/helix-term/src/handlers/completion/item.rs @@ -1,10 +1,70 @@ +use std::mem; + +use helix_core::completion::CompletionProvider; use helix_lsp::{lsp, LanguageServerId}; +use helix_view::handlers::completion::ResponseContext; + +pub struct CompletionResponse { + pub items: CompletionItems, + pub provider: CompletionProvider, + pub context: ResponseContext, +} + +pub enum CompletionItems { + Lsp(Vec), + Other(Vec), +} + +impl CompletionItems { + pub fn is_empty(&self) -> bool { + match self { + CompletionItems::Lsp(items) => items.is_empty(), + CompletionItems::Other(items) => items.is_empty(), + } + } +} + +impl CompletionResponse { + pub fn take_items(&mut self, dst: &mut Vec) { + match &mut self.items { + CompletionItems::Lsp(items) => dst.extend(items.drain(..).map(|item| { + CompletionItem::Lsp(LspCompletionItem { + item, + provider: match self.provider { + CompletionProvider::Lsp(provider) => provider, + _ => unreachable!(), + }, + resolved: false, + provider_priority: self.context.priority, + }) + })), + CompletionItems::Other(items) if dst.is_empty() => mem::swap(dst, items), + CompletionItems::Other(items) => dst.append(items), + } + } +} #[derive(Debug, PartialEq, Clone)] pub struct LspCompletionItem { pub item: lsp::CompletionItem, pub provider: LanguageServerId, pub resolved: bool, + // TODO: we should not be filtering and sorting incomplete completion list + // according to the spec but vscode does that anyway and most servers ( + // including rust-analyzer) rely on that.. so we can't do that without + // breaking completions. + pub provider_priority: i8, +} + +impl LspCompletionItem { + #[inline] + pub fn filter_text(&self) -> &str { + self.item + .filter_text + .as_ref() + .unwrap_or(&self.item.label) + .as_str() + } } #[derive(Debug, PartialEq, Clone)] @@ -13,6 +73,16 @@ pub enum CompletionItem { Other(helix_core::CompletionItem), } +impl CompletionItem { + #[inline] + pub fn filter_text(&self) -> &str { + match self { + CompletionItem::Lsp(item) => item.filter_text(), + CompletionItem::Other(item) => &item.label, + } + } +} + impl PartialEq for LspCompletionItem { fn eq(&self, other: &CompletionItem) -> bool { match other { @@ -32,6 +102,21 @@ impl PartialEq for helix_core::CompletionItem { } impl CompletionItem { + pub fn provider_priority(&self) -> i8 { + match self { + CompletionItem::Lsp(item) => item.provider_priority, + // sorting path completions after LSP for now + CompletionItem::Other(_) => 1, + } + } + + pub fn provider(&self) -> CompletionProvider { + match self { + CompletionItem::Lsp(item) => CompletionProvider::Lsp(item.provider), + CompletionItem::Other(item) => item.provider, + } + } + pub fn preselect(&self) -> bool { match self { CompletionItem::Lsp(LspCompletionItem { item, .. }) => item.preselect.unwrap_or(false), diff --git a/helix-term/src/handlers/completion/path.rs b/helix-term/src/handlers/completion/path.rs index db04c4566..21dc9c31f 100644 --- a/helix-term/src/handlers/completion/path.rs +++ b/helix-term/src/handlers/completion/path.rs @@ -3,22 +3,23 @@ use std::{ fs, path::{Path, PathBuf}, str::FromStr as _, + sync::Arc, }; -use futures_util::{future::BoxFuture, FutureExt as _}; use helix_core::{self as core, completion::CompletionProvider, Selection, Transaction}; use helix_event::TaskHandle; use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix}; -use helix_view::Document; +use helix_view::{document::SavePoint, handlers::completion::ResponseContext, Document}; use url::Url; -use super::item::CompletionItem; +use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems}; pub(crate) fn path_completion( selection: Selection, doc: &Document, handle: TaskHandle, -) -> Option>>> { + savepoint: Arc, +) -> Option CompletionResponse> { if !doc.path_completion_enabled() { return None; } @@ -67,9 +68,19 @@ pub(crate) fn path_completion( return None; } - let future = tokio::task::spawn_blocking(move || { + // TODO: handle properly in the future + const PRIORITY: i8 = 1; + let future = move || { let Ok(read_dir) = std::fs::read_dir(&dir_path) else { - return Vec::new(); + return CompletionResponse { + items: CompletionItems::Other(Vec::new()), + provider: CompletionProvider::Path, + context: ResponseContext { + is_incomplete: false, + priority: PRIORITY, + savepoint, + }, + }; }; let edit_diff = typed_file_name @@ -77,7 +88,7 @@ pub(crate) fn path_completion( .map(|s| s.chars().count()) .unwrap_or_default(); - read_dir + let res: Vec<_> = read_dir .filter_map(Result::ok) .filter_map(|dir_entry| { dir_entry @@ -106,10 +117,19 @@ pub(crate) fn path_completion( provider: CompletionProvider::Path, })) }) - .collect::>() - }); + .collect(); + CompletionResponse { + items: CompletionItems::Other(res), + provider: CompletionProvider::Path, + context: ResponseContext { + is_incomplete: false, + priority: PRIORITY, + savepoint, + }, + } + }; - Some(async move { Ok(future.await?) }.boxed()) + Some(future) } #[cfg(unix)] diff --git a/helix-term/src/handlers/completion/request.rs b/helix-term/src/handlers/completion/request.rs new file mode 100644 index 000000000..3d2a158ea --- /dev/null +++ b/helix-term/src/handlers/completion/request.rs @@ -0,0 +1,368 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use futures_util::Future; +use helix_core::completion::CompletionProvider; +use helix_core::syntax::LanguageServerFeature; +use helix_event::{cancelable_future, TaskController, TaskHandle}; +use helix_lsp::lsp; +use helix_lsp::lsp::{CompletionContext, CompletionTriggerKind}; +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, ResponseContext}; +use helix_view::{Document, DocumentId, Editor, ViewId}; +use tokio::task::JoinSet; +use tokio::time::{timeout_at, Instant}; + +use crate::compositor::Compositor; +use crate::config::Config; +use crate::handlers::completion::item::CompletionResponse; +use crate::handlers::completion::path::path_completion; +use crate::handlers::completion::{ + handle_response, replace_completions, show_completion, CompletionItems, +}; +use crate::job::{dispatch, dispatch_blocking}; +use crate::ui; +use crate::ui::editor::InsertEvent; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(super) enum TriggerKind { + Auto, + TriggerChar, + Manual, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct Trigger { + pub(super) pos: usize, + pub(super) view: ViewId, + pub(super) doc: DocumentId, + pub(super) kind: TriggerKind, +} + +#[derive(Debug)] +pub struct CompletionHandler { + /// The currently active trigger which will cause a completion request after the timeout. + trigger: Option, + in_flight: Option, + task_controller: TaskController, + config: Arc>, +} + +impl CompletionHandler { + pub fn new(config: Arc>) -> CompletionHandler { + Self { + config, + task_controller: TaskController::new(), + trigger: None, + in_flight: None, + } + } +} + +impl helix_event::AsyncHook for CompletionHandler { + type Event = CompletionEvent; + + fn handle_event( + &mut self, + event: Self::Event, + _old_timeout: Option, + ) -> Option { + if self.in_flight.is_some() && !self.task_controller.is_running() { + self.in_flight = None; + } + match event { + CompletionEvent::AutoTrigger { + cursor: trigger_pos, + doc, + view, + } => { + // Technically it shouldn't be possible to switch views/documents in insert mode + // but people may create weird keymaps/use the mouse so let's 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_completions(trigger, handle, editor, compositor) + }); + } +} + +fn request_completions( + mut trigger: Trigger, + handle: TaskHandle, + editor: &mut Editor, + compositor: &mut Compositor, +) { + let (view, doc) = current_ref!(editor); + + if compositor + .find::() + .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 language server if we type fast + // enough? Yes that is true but it's not actually a problem. The language server will resolve + // the completion to the identifier anyway (in fact sending the later position is necessary to + // get the right results from language servers that provide incomplete completion list). We + // rely on the trigger offset and primary cursor matching for multi-cursor completions so this + // is definitely necessary from our side too. + trigger.pos = cursor; + let doc = doc_mut!(editor, &doc.id()); + let savepoint = doc.savepoint(view); + let text = doc.text(); + let trigger_text = text.slice(..cursor); + + let mut seen_language_servers = HashSet::new(); + let language_servers: Vec<_> = doc + .language_servers_with_feature(LanguageServerFeature::Completion) + .filter(|ls| seen_language_servers.insert(ls.id())) + .collect(); + let mut requests = JoinSet::new(); + for (priority, ls) in language_servers.iter().enumerate() { + 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, + } + } + }; + requests.spawn(request_completions_from_language_server( + ls, + doc, + view.id, + context, + -(priority as i8), + savepoint.clone(), + )); + } + if let Some(path_completion_request) = path_completion( + doc.selection(view.id).clone(), + doc, + handle.clone(), + savepoint, + ) { + requests.spawn_blocking(path_completion_request); + } + + let ui = compositor.find::().unwrap(); + ui.last_insert.1.push(InsertEvent::RequestCompletion); + let handle_ = handle.clone(); + let request_completions = async move { + let mut context = HashMap::new(); + let Some(mut response) = handle_response(&mut requests, false).await else { + return; + }; + + let mut items: Vec<_> = Vec::new(); + response.take_items(&mut items); + context.insert(response.provider, response.context); + let deadline = Instant::now() + Duration::from_millis(100); + loop { + let Some(mut response) = timeout_at(deadline, handle_response(&mut requests, false)) + .await + .ok() + .flatten() + else { + break; + }; + response.take_items(&mut items); + context.insert(response.provider, response.context); + } + dispatch(move |editor, compositor| { + show_completion(editor, compositor, items, context, trigger) + }) + .await; + if !requests.is_empty() { + replace_completions(handle_, requests, false).await; + } + }; + tokio::spawn(cancelable_future(request_completions, handle)); +} + +fn request_completions_from_language_server( + ls: &helix_lsp::Client, + doc: &Document, + view: ViewId, + context: lsp::CompletionContext, + priority: i8, + savepoint: Arc, +) -> impl Future { + let provider = ls.id(); + let offset_encoding = ls.offset_encoding(); + let text = doc.text(); + let cursor = doc.selection(view).primary().cursor(text.slice(..)); + let pos = pos_to_lsp_pos(text, cursor, offset_encoding); + let doc_id = doc.identifier(); + + // it's important that this is before the async block (and that this is not an async function) + // to ensure the request is dispatched right away before any new edit notifications + let completion_response = ls.completion(doc_id, pos, None, context).unwrap(); + async move { + let response: Option = completion_response + .await + .and_then(|json| serde_json::from_value(json).map_err(helix_lsp::Error::Parse)) + .inspect_err(|err| log::error!("completion request failed: {err}")) + .ok() + .flatten(); + let (mut items, is_incomplete) = match response { + Some(lsp::CompletionResponse::Array(items)) => (items, false), + Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete, + items, + })) => (items, is_incomplete), + None => (Vec::new(), false), + }; + items.sort_by(|item1, item2| { + let sort_text1 = item1.sort_text.as_deref().unwrap_or(&item1.label); + let sort_text2 = item2.sort_text.as_deref().unwrap_or(&item2.label); + sort_text1.cmp(sort_text2) + }); + CompletionResponse { + items: CompletionItems::Lsp(items), + context: ResponseContext { + is_incomplete, + priority, + savepoint, + }, + provider: CompletionProvider::Lsp(provider), + } + } +} + +pub fn request_incomplete_completion_list(editor: &mut Editor, handle: TaskHandle) { + let handler = &mut editor.handlers.completions; + let mut requests = JoinSet::new(); + let mut savepoint = None; + for (&provider, context) in &handler.active_completions { + if !context.is_incomplete { + continue; + } + let CompletionProvider::Lsp(ls_id) = provider else { + log::error!("non-lsp incomplete completion lists"); + continue; + }; + let Some(ls) = editor.language_servers.get_by_id(ls_id) else { + continue; + }; + let (view, doc) = current!(editor); + let savepoint = savepoint.get_or_insert_with(|| doc.savepoint(view)).clone(); + let request = request_completions_from_language_server( + ls, + doc, + view.id, + CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS, + trigger_character: None, + }, + context.priority, + savepoint, + ); + requests.spawn(request); + } + if !requests.is_empty() { + tokio::spawn(replace_completions(handle, requests, true)); + } +} diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 3e86237d2..be78dd083 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,53 +1,32 @@ +use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; use crate::{ compositor::{Component, Context, Event, EventResult}, - handlers::{ - completion::{CompletionItem, LspCompletionItem, ResolveHandler}, - trigger_auto_completion, + handlers::completion::{ + trigger_auto_completion, CompletionItem, CompletionResponse, LspCompletionItem, + ResolveHandler, }, }; +use helix_core::snippets::{ActiveSnippet, RenderedSnippet, Snippet}; +use helix_core::{self as core, chars, fuzzy::MATCHER, Change, Transaction}; +use helix_lsp::{lsp, util, OffsetEncoding}; use helix_view::{ - document::SavePoint, editor::CompleteAction, handlers::lsp::SignatureHelpInvoked, theme::{Color, Modifier, Style}, ViewId, }; -use tui::{ - buffer::Buffer as Surface, - text::{Span, Spans}, -}; - -use std::{borrow::Cow, sync::Arc}; - -use helix_core::{ - self as core, chars, - snippets::{ActiveSnippet, RenderedSnippet, Snippet}, - Change, Transaction, -}; use helix_view::{graphics::Rect, Document, Editor}; +use nucleo::{ + pattern::{Atom, AtomKind, CaseMatching, Normalization}, + Config, Utf32Str, +}; +use tui::text::Spans; +use tui::{buffer::Buffer as Surface, text::Span}; -use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent}; - -use helix_lsp::{lsp, util, OffsetEncoding}; +use std::cmp::Reverse; impl menu::Item for CompletionItem { type Data = Style; - fn sort_text(&self, data: &Self::Data) -> Cow { - self.filter_text(data) - } - - #[inline] - fn filter_text(&self, _data: &Self::Data) -> Cow { - match self { - CompletionItem::Lsp(LspCompletionItem { item, .. }) => item - .filter_text - .as_ref() - .unwrap_or(&item.label) - .as_str() - .into(), - CompletionItem::Other(core::CompletionItem { label, .. }) => label.clone(), - } - } fn format(&self, dir_style: &Self::Data) -> menu::Row { let deprecated = match self { @@ -143,22 +122,16 @@ pub struct Completion { #[allow(dead_code)] trigger_offset: usize, filter: String, + // TODO: move to helix-view/central handler struct in the future resolve_handler: ResolveHandler, } impl Completion { pub const ID: &'static str = "completion"; - pub fn new( - editor: &Editor, - savepoint: Arc, - mut items: Vec, - trigger_offset: usize, - ) -> Self { + pub fn new(editor: &Editor, items: Vec, trigger_offset: usize) -> Self { let preview_completion_insert = editor.config().preview_completion_insert; let replace_mode = editor.config().completion_replace; - // Sort completion items according to their preselect status (given by the LSP server) - items.sort_by_key(|item| !item.preselect()); let dir_style = editor.theme.get("ui.text.directory"); @@ -202,10 +175,11 @@ impl Completion { savepoint: doc.savepoint(view), }) } - // if more text was entered, remove it - doc.restore(view, &savepoint, false); - // always present here let item = item.unwrap(); + let context = &editor.handlers.completions.active_completions[&item.provider()]; + // if more text was entered, remove it + doc.restore(view, &context.savepoint, false); + // always present here match item { CompletionItem::Lsp(item) => { @@ -232,13 +206,15 @@ impl Completion { doc.restore(view, &savepoint, false); } + let item = item.unwrap(); + let context = &editor.handlers.completions.active_completions[&item.provider()]; // if more text was entered, remove it - doc.restore(view, &savepoint, true); + doc.restore(view, &context.savepoint, true); // save an undo checkpoint before the completion doc.append_changes_to_history(view); // item always present here - let (transaction, additional_edits, snippet) = match item.unwrap().clone() { + let (transaction, additional_edits, snippet) = match item.clone() { CompletionItem::Lsp(mut item) => { let language_server = language_server!(item); @@ -302,7 +278,7 @@ impl Completion { } // we could have just inserted a trigger char (like a `crate::` completion for rust // so we want to retrigger immediately when accepting a completion. - trigger_auto_completion(&editor.handlers.completions, editor, true); + trigger_auto_completion(editor, true); } }; @@ -339,14 +315,70 @@ impl Completion { }; // need to recompute immediately in case start_offset != trigger_offset - completion - .popup - .contents_mut() - .score(&completion.filter, false); + completion.score(false); completion } + fn score(&mut self, incremental: bool) { + let pattern = &self.filter; + let mut matcher = MATCHER.lock(); + matcher.config = Config::DEFAULT; + // slight preference towards prefix matches + matcher.config.prefer_prefix = true; + let pattern = Atom::new( + pattern, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + false, + ); + let mut buf = Vec::new(); + let (matches, options) = self.popup.contents_mut().update_options(); + if incremental { + matches.retain_mut(|(index, score)| { + let option = &options[*index as usize]; + let text = option.filter_text(); + let new_score = pattern.score(Utf32Str::new(text, &mut buf), &mut matcher); + match new_score { + Some(new_score) => { + *score = new_score as u32 / 2; + true + } + None => false, + } + }) + } else { + matches.clear(); + matches.extend(options.iter().enumerate().filter_map(|(i, option)| { + let text = option.filter_text(); + pattern + .score(Utf32Str::new(text, &mut buf), &mut matcher) + .map(|score| (i as u32, score as u32 / 3)) + })); + } + // Nucleo is meant as an FZF-like fuzzy matcher and only hides matches that are truly + // impossible - as in the sequence of characters just doesn't appear. That doesn't work + // well for completions with multiple language servers where all completions of the next + // server are below the current one (so you would get good suggestions from the second + // server below those of the first). Setting a reasonable cutoff below which to move bad + // completions out of the way helps with that. + // + // The score computation is a heuristic derived from Nucleo internal constants that may + // move upstream in the future. I want to test this out here to settle on a good number. + let min_score = (7 + pattern.needle_text().len() as u32 * 14) / 3; + matches.sort_unstable_by_key(|&(i, score)| { + let option = &options[i as usize]; + ( + score <= min_score, + Reverse(option.preselect()), + option.provider_priority(), + Reverse(score), + i, + ) + }); + } + /// Synchronously resolve the given completion item. This is used when /// accepting a completion. fn resolve_completion_item( @@ -388,7 +420,24 @@ impl Completion { } } } - menu.score(&self.filter, c.is_some()); + self.score(c.is_some()); + self.popup.contents_mut().reset_cursor(); + } + + pub fn replace_provider_completions( + &mut self, + response: &mut CompletionResponse, + is_incomplete: bool, + ) { + let menu = self.popup.contents_mut(); + let (_, options) = menu.update_options(); + if is_incomplete { + options.retain(|item| item.provider() != response.provider) + } + response.take_items(options); + self.score(false); + let menu = self.popup.contents_mut(); + menu.ensure_cursor_in_bounds(); } pub fn is_empty(&self) -> bool { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 5d028415e..6fecd512b 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -24,14 +24,14 @@ use helix_core::{ }; use helix_view::{ annotations::diagnostics::DiagnosticFilter, - document::{Mode, SavePoint, SCRATCH_BUFFER_NAME}, + document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}, keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; +use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc}; use tui::{buffer::Buffer as Surface, text::Span}; @@ -1049,12 +1049,11 @@ impl EditorView { pub fn set_completion( &mut self, editor: &mut Editor, - savepoint: Arc, items: Vec, trigger_offset: usize, size: Rect, ) -> Option { - let mut completion = Completion::new(editor, savepoint, items, trigger_offset); + let mut completion = Completion::new(editor, items, trigger_offset); if completion.is_empty() { // skip if we got no completion results @@ -1073,6 +1072,8 @@ impl EditorView { pub fn clear_completion(&mut self, editor: &mut Editor) -> Option { self.completion = None; let mut on_next_key: Option = None; + editor.handlers.completions.request_controller.restart(); + editor.handlers.completions.active_completions.clear(); if let Some(last_completion) = editor.last_completion.take() { match last_completion { CompleteAction::Triggered => (), diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 612832ce1..76e50229a 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -1,12 +1,7 @@ -use std::{borrow::Cow, cmp::Reverse}; - use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, }; -use helix_core::fuzzy::MATCHER; -use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; -use nucleo::{Config, Utf32Str}; use tui::{buffer::Buffer as Surface, widgets::Table}; pub use tui::widgets::{Cell, Row}; @@ -19,16 +14,6 @@ pub trait Item: Sync + Send + 'static { type Data: Sync + Send + 'static; fn format(&self, data: &Self::Data) -> Row; - - fn sort_text(&self, data: &Self::Data) -> Cow { - let label: String = self.format(data).cell_text().collect(); - label.into() - } - - fn filter_text(&self, data: &Self::Data) -> Cow { - let label: String = self.format(data).cell_text().collect(); - label.into() - } } pub type MenuCallback = Box, MenuEvent)>; @@ -77,49 +62,30 @@ impl Menu { } } - pub fn score(&mut self, pattern: &str, incremental: bool) { - let mut matcher = MATCHER.lock(); - matcher.config = Config::DEFAULT; - let pattern = Atom::new( - pattern, - CaseMatching::Ignore, - Normalization::Smart, - AtomKind::Fuzzy, - false, - ); - let mut buf = Vec::new(); - if incremental { - self.matches.retain_mut(|(index, score)| { - let option = &self.options[*index as usize]; - let text = option.filter_text(&self.editor_data); - let new_score = pattern.score(Utf32Str::new(&text, &mut buf), &mut matcher); - match new_score { - Some(new_score) => { - *score = new_score as u32; - true - } - None => false, - } - }) - } else { - self.matches.clear(); - let matches = self.options.iter().enumerate().filter_map(|(i, option)| { - let text = option.filter_text(&self.editor_data); - pattern - .score(Utf32Str::new(&text, &mut buf), &mut matcher) - .map(|score| (i as u32, score as u32)) - }); - self.matches.extend(matches); - } - self.matches - .sort_unstable_by_key(|&(i, score)| (Reverse(score), i)); - - // reset cursor position + pub fn reset_cursor(&mut self) { self.cursor = None; self.scroll = 0; self.recalculate = true; } + pub fn update_options(&mut self) -> (&mut Vec<(u32, u32)>, &mut Vec) { + self.recalculate = true; + (&mut self.matches, &mut self.options) + } + + pub fn ensure_cursor_in_bounds(&mut self) { + if self.matches.is_empty() { + self.cursor = None; + self.scroll = 0; + } else { + self.scroll = 0; + self.recalculate = true; + if let Some(cursor) = &mut self.cursor { + *cursor = (*cursor).min(self.matches.len() - 1) + } + } + } + pub fn clear(&mut self) { self.matches.clear(); diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index e2f95ded5..a26c4ddb1 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -1,3 +1,4 @@ +use completion::{CompletionEvent, CompletionHandler}; use helix_event::send_blocking; use tokio::sync::mpsc::Sender; @@ -17,7 +18,7 @@ pub enum AutoSaveEvent { pub struct Handlers { // only public because most of the actual implementation is in helix-term right now :/ - pub completions: Sender, + pub completions: CompletionHandler, pub signature_hints: Sender, pub auto_save: Sender, } @@ -25,14 +26,11 @@ pub struct Handlers { impl Handlers { /// Manually trigger completion (c-x) pub fn trigger_completions(&self, trigger_pos: usize, doc: DocumentId, view: ViewId) { - send_blocking( - &self.completions, - completion::CompletionEvent::ManualTrigger { - cursor: trigger_pos, - doc, - view, - }, - ); + self.completions.event(CompletionEvent::ManualTrigger { + cursor: trigger_pos, + doc, + view, + }); } pub fn trigger_signature_help(&self, invocation: SignatureHelpInvoked, editor: &Editor) {