diff --git a/helix-core/src/completion.rs b/helix-core/src/completion.rs index 0bd111eb4..c024f9549 100644 --- a/helix-core/src/completion.rs +++ b/helix-core/src/completion.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; +use crate::diagnostic::LanguageServerId; use crate::Transaction; #[derive(Debug, PartialEq, Clone)] @@ -9,4 +10,17 @@ pub struct CompletionItem { pub kind: Cow<'static, str>, /// Containing Markdown pub documentation: String, + pub provider: CompletionProvider, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub enum CompletionProvider { + Lsp(LanguageServerId), + PathCompletions, +} + +impl From for CompletionProvider { + fn from(id: LanguageServerId) -> Self { + CompletionProvider::Lsp(id) + } } diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index cc1c4ce8f..28a1bd09f 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -426,29 +426,32 @@ impl Client { let server_tx = self.server_tx.clone(); let id = self.next_request_id(); - let params = serde_json::to_value(params); + // it' important this is not part of the future so that it gets + // executed right away so that the request order stays concisents + let rx = serde_json::to_value(params) + .map_err(Error::from) + .and_then(|params| { + let request = jsonrpc::MethodCall { + jsonrpc: Some(jsonrpc::Version::V2), + id: id.clone(), + method: R::METHOD.to_string(), + params: Self::value_into_params(params), + }; + let (tx, rx) = channel::>(1); + server_tx + .send(Payload::Request { + chan: tx, + value: request, + }) + .map_err(|e| Error::Other(e.into()))?; + Ok(rx) + }); + async move { use std::time::Duration; use tokio::time::timeout; - - let request = jsonrpc::MethodCall { - jsonrpc: Some(jsonrpc::Version::V2), - id: id.clone(), - method: R::METHOD.to_string(), - params: Self::value_into_params(params?), - }; - - let (tx, mut rx) = channel::>(1); - - server_tx - .send(Payload::Request { - chan: tx, - value: request, - }) - .map_err(|e| Error::Other(e.into()))?; - // TODO: delay other calls until initialize success - timeout(Duration::from_secs(timeout_secs), rx.recv()) + timeout(Duration::from_secs(timeout_secs), rx?.recv()) .await .map_err(|_| Error::Timeout(id))? // return Timeout .ok_or(Error::StreamClosed)? @@ -465,21 +468,25 @@ impl Client { { let server_tx = self.server_tx.clone(); - async move { - let params = serde_json::to_value(params)?; + // it' important this is not part of the future so that it gets + // executed right away so that the request order stays consisents + let res = serde_json::to_value(params) + .map_err(Error::from) + .and_then(|params| { + let params = serde_json::to_value(params)?; - let notification = jsonrpc::Notification { - jsonrpc: Some(jsonrpc::Version::V2), - method: R::METHOD.to_string(), - params: Self::value_into_params(params), - }; - - server_tx - .send(Payload::Notification(notification)) - .map_err(|e| Error::Other(e.into()))?; - - Ok(()) - } + let notification = jsonrpc::Notification { + jsonrpc: Some(jsonrpc::Version::V2), + method: R::METHOD.to_string(), + params: Self::value_into_params(params), + }; + server_tx + .send(Payload::Notification(notification)) + .map_err(|e| Error::Other(e.into())) + }); + // TODO: this function is not async and never should have been + // but turning it into non-async function is a big refactor + async move { res } } /// Reply to a language server RPC call. @@ -492,26 +499,27 @@ impl Client { let server_tx = self.server_tx.clone(); - async move { - let output = match result { - Ok(result) => Output::Success(Success { + let output = match result { + Ok(result) => serde_json::to_value(result).map(|result| { + Output::Success(Success { jsonrpc: Some(Version::V2), id, - result: serde_json::to_value(result)?, - }), - Err(error) => Output::Failure(Failure { - jsonrpc: Some(Version::V2), - id, - error, - }), - }; + result, + }) + }), + Err(error) => Ok(Output::Failure(Failure { + jsonrpc: Some(Version::V2), + id, + error, + })), + }; + let res = output.map_err(Error::from).and_then(|output| { server_tx .send(Payload::Response(output)) - .map_err(|e| Error::Other(e.into()))?; - - Ok(()) - } + .map_err(|e| Error::Other(e.into())) + }); + async move { res } } // ------------------------------------------------------------------------------------------- diff --git a/helix-term/src/handlers.rs b/helix-term/src/handlers.rs index 31e15330e..50e04d681 100644 --- a/helix-term/src/handlers.rs +++ b/helix-term/src/handlers.rs @@ -9,7 +9,6 @@ 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; diff --git a/helix-term/src/handlers/completion.rs b/helix-term/src/handlers/completion.rs index f3223487c..41ada7b49 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, - 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, + incomplete: bool, +) -> Option { + 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, - ) -> 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, + incomplete: bool, ) { - let (view, doc) = current!(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 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(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::().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(response) = handle_response(&mut requests, 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(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); + } }) - .await - }); + .await; + } } fn show_completion( editor: &mut Editor, compositor: &mut Compositor, items: Vec, + incomplete_completion_lists: HashMap, trigger: Trigger, savepoint: Arc, ) { @@ -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::>(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) { +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 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, 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); } diff --git a/helix-term/src/handlers/completion/item.rs b/helix-term/src/handlers/completion/item.rs index bcd35cd54..de08d0dba 100644 --- a/helix-term/src/handlers/completion/item.rs +++ b/helix-term/src/handlers/completion/item.rs @@ -1,10 +1,69 @@ +use helix_core::completion::CompletionProvider; use helix_lsp::{lsp, LanguageServerId}; +pub struct CompletionResponse { + pub items: CompletionItems, + pub incomplete: bool, + pub provider: CompletionProvider, + pub priority: i8, +} + +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 into_items(self, dst: &mut Vec) { + match self.items { + CompletionItems::Lsp(items) => dst.extend(items.into_iter().map(|item| { + CompletionItem::Lsp(LspCompletionItem { + item, + provider: match self.provider { + CompletionProvider::Lsp(provider) => provider, + CompletionProvider::PathCompletions => unreachable!(), + }, + resolved: false, + provider_priority: self.priority, + }) + })), + CompletionItems::Other(items) if dst.is_empty() => *dst = items, + CompletionItems::Other(mut items) => dst.append(&mut 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 incomplete_completion_list: bool, + 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 +72,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 +101,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 e92be51cf..a16a0d5c6 100644 --- a/helix-term/src/handlers/completion/path.rs +++ b/helix-term/src/handlers/completion/path.rs @@ -5,22 +5,21 @@ use std::{ str::FromStr as _, }; -use futures_util::{future::BoxFuture, FutureExt as _}; -use helix_core as core; use helix_core::Transaction; +use helix_core::{self as core, completion::CompletionProvider}; use helix_event::TaskHandle; use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix}; use helix_view::Document; use url::Url; -use super::item::CompletionItem; +use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems}; pub(crate) fn path_completion( cursor: usize, text: core::Rope, doc: &Document, handle: TaskHandle, -) -> Option>>> { +) -> Option CompletionResponse> { if !doc.path_completion_enabled() { return None; } @@ -67,12 +66,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()), + incomplete: false, + provider: CompletionProvider::PathCompletions, + priority: PRIORITY, // TODO: hand + }; }; - read_dir + let res: Vec<_> = read_dir .filter_map(Result::ok) .filter_map(|dir_entry| { dir_entry @@ -103,12 +109,19 @@ pub(crate) fn path_completion( label: file_name.into(), transaction, documentation, + provider: CompletionProvider::PathCompletions, })) }) - .collect::>() - }); + .collect(); + CompletionResponse { + items: CompletionItems::Other(res), + incomplete: false, + provider: CompletionProvider::PathCompletions, + priority: PRIORITY, // TODO: hand + } + }; - 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..9c37b7e34 --- /dev/null +++ b/helix-term/src/handlers/completion/request.rs @@ -0,0 +1,373 @@ +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; +use helix_view::handlers::lsp::CompletionEvent; +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 { + /// 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, + } => { + // 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_completions(trigger, handle, editor, compositor) + }); + } +} + +fn request_completions( + mut trigger: Trigger, + handle: TaskHandle, + editor: &mut Editor, + compositor: &mut Compositor, +) { + let (view, doc) = current!(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 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 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), + )); + } + if let Some(path_completion_request) = + path_completion(cursor, text.clone(), doc, handle.clone()) + { + requests.spawn_blocking(path_completion_request); + } + + let savepoint = doc.savepoint(view); + + let ui = compositor.find::().unwrap(); + ui.last_insert.1.push(InsertEvent::RequestCompletion); + let handle_ = handle.clone(); + let request_completions = async move { + let mut incomplete_completion_lists = HashMap::new(); + let Some(response) = handle_response(&mut requests, false).await else { + return; + }; + + if response.incomplete { + incomplete_completion_lists.insert(response.provider, response.priority); + } + let mut items: Vec<_> = Vec::new(); + response.into_items(&mut items); + let deadline = Instant::now() + Duration::from_millis(100); + loop { + let Some(response) = timeout_at(deadline, handle_response(&mut requests, false)) + .await + .ok() + .flatten() + else { + break; + }; + if response.incomplete { + incomplete_completion_lists.insert(response.provider, response.priority); + } + response.into_items(&mut items); + } + dispatch(move |editor, compositor| { + show_completion( + editor, + compositor, + items, + incomplete_completion_lists, + trigger, + savepoint, + ) + }) + .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, +) -> 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 berofe 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, 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), + incomplete, + provider: CompletionProvider::Lsp(provider), + priority, + } + } +} + +pub fn request_incomplete_completion_list( + editor: &mut Editor, + ui: &mut ui::Completion, + handle: TaskHandle, +) { + if ui.incomplete_completion_lists.is_empty() { + return; + } + let (view, doc) = current_ref!(editor); + let mut requests = JoinSet::new(); + log::error!("request incomplete completions"); + ui.incomplete_completion_lists + .retain(|&provider, &mut priority| { + let CompletionProvider::Lsp(ls_id) = provider else { + unimplemented!("non-lsp incomplete completion lists") + }; + let Some(ls) = editor.language_server_by_id(ls_id) else { + return false; + }; + log::error!("request incomplete completions2"); + let request = request_completions_from_language_server( + ls, + doc, + view.id, + CompletionContext { + trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS, + trigger_character: None, + }, + priority, + ); + requests.spawn(request); + true + }); + tokio::spawn(replace_completions(handle, requests, true)); +} diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index a5e0f59b2..93b6a7534 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,10 +1,11 @@ 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_event::TaskController; use helix_view::{ document::SavePoint, editor::CompleteAction, @@ -12,12 +13,18 @@ use helix_view::{ theme::{Modifier, Style}, ViewId, }; +use nucleo::{ + pattern::{Atom, AtomKind, CaseMatching, Normalization}, + Config, Utf32Str, +}; use tui::{buffer::Buffer as Surface, text::Span}; -use std::{borrow::Cow, sync::Arc}; +use std::{cmp::Reverse, collections::HashMap, sync::Arc}; use helix_core::{ self as core, chars, + completion::CompletionProvider, + fuzzy::MATCHER, snippets::{ActiveSnippet, RenderedSnippet, Snippet}, Change, Transaction, }; @@ -29,22 +36,6 @@ use helix_lsp::{lsp, util, OffsetEncoding}; impl menu::Item for CompletionItem { type Data = (); - 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, _data: &Self::Data) -> menu::Row { let deprecated = match self { @@ -119,6 +110,9 @@ pub struct Completion { trigger_offset: usize, filter: String, resolve_handler: ResolveHandler, + pub incomplete_completion_lists: HashMap, + // controller for requesting updates for incomplete completion lists + pub incomplete_list_controller: TaskController, } impl Completion { @@ -127,13 +121,12 @@ impl Completion { pub fn new( editor: &Editor, savepoint: Arc, - mut items: Vec, + items: Vec, + incomplete_completion_lists: HashMap, 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()); // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { @@ -309,17 +302,77 @@ impl Completion { // and avoid allocation during matching filter: String::from(fragment), resolve_handler: ResolveHandler::new(), + incomplete_completion_lists, + incomplete_list_controller: TaskController::new(), }; // 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)) + })); + } + // nuclueo is meant as an fzf-like fuzzy matcher and only hides + // matches that are truely impossible (as in the sequence of char + // just doens't appeart) that doesn't work well for completions + // with multi lsps where all completions of the next lsp are below + // the current one (so you would good suggestions from the second lsp 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 dervied from nucleo internal + // constants and 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( @@ -361,7 +414,28 @@ 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: CompletionResponse) { + let menu = self.popup.contents_mut(); + let (_, options) = menu.update_options(); + if self + .incomplete_completion_lists + .remove(&response.provider) + .is_some() + { + options.retain(|item| item.provider() != response.provider) + } + if response.incomplete { + self.incomplete_completion_lists + .insert(response.provider, response.priority); + } + response.into_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 5ac401bf7..4c42d1073 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -14,6 +14,7 @@ use crate::{ }; use helix_core::{ + completion::CompletionProvider, diagnostic::NumberOrString, graphemes::{next_grapheme_boundary, prev_grapheme_boundary}, movement::Direction, @@ -31,7 +32,7 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; +use std::{collections::HashMap, mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc}; use tui::{buffer::Buffer as Surface, text::Span}; @@ -1057,10 +1058,17 @@ impl EditorView { editor: &mut Editor, savepoint: Arc, items: Vec, + incomplete_completion_lists: HashMap, trigger_offset: usize, size: Rect, ) -> Option { - let mut completion = Completion::new(editor, savepoint, items, trigger_offset); + let mut completion = Completion::new( + editor, + savepoint, + items, + incomplete_completion_lists, + trigger_offset, + ); if completion.is_empty() { // skip if we got no completion results 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/Cargo.toml b/helix-view/Cargo.toml index 6f71fa052..f69f2982b 100644 --- a/helix-view/Cargo.toml +++ b/helix-view/Cargo.toml @@ -40,8 +40,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std tokio-stream = "0.1" futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false } -slotmap = "1" - +slotmap.workspace = true chardetng = "0.1" serde = { version = "1.0", features = ["derive"] } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index dcdc8dc2f..1b3a77b6a 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1435,16 +1435,12 @@ impl Document { // TODO: move to hook // emit lsp notification for language_server in self.language_servers() { - let notify = language_server.text_document_did_change( + let _ = language_server.text_document_did_change( self.versioned_identifier(), &old_doc, self.text(), changes, ); - - if let Some(notify) = notify { - tokio::spawn(notify); - } } } @@ -1761,6 +1757,25 @@ impl Document { }) } + pub fn language_servers_with_feature_owned( + &self, + feature: LanguageServerFeature, + ) -> impl Iterator> + '_ { + self.language_config().into_iter().flat_map(move |config| { + config.language_servers.iter().filter_map(move |features| { + let ls = self.language_servers.get(&features.name)?.clone(); + if ls.is_initialized() + && ls.supports_feature(feature) + && features.has_feature(feature) + { + Some(ls) + } else { + None + } + }) + }) + } + pub fn supports_language_server(&self, id: LanguageServerId) -> bool { self.language_servers().any(|l| l.id() == id) }