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.rs368
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));
+ }
+}