Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-term/src/handlers/completion/request.rs')
| -rw-r--r-- | helix-term/src/handlers/completion/request.rs | 368 |
1 files changed, 368 insertions, 0 deletions
diff --git a/helix-term/src/handlers/completion/request.rs b/helix-term/src/handlers/completion/request.rs new file mode 100644 index 00000000..3d2a158e --- /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<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, + } + } +} + +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, + } => { + // 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::<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 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::<ui::EditorView>().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<SavePoint>, +) -> impl Future<Output = CompletionResponse> { + 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<lsp::CompletionResponse> = 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)); + } +} |