diff --git a/helix-core/src/completion.rs b/helix-core/src/completion.rs index 0bd111eb4767..c024f9549d1e 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 cc1c4ce8fe67..28a1bd09f86c 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)?; - - 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(()) - } + // 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())) + }); + // 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 { - jsonrpc: Some(Version::V2), - id, - result: serde_json::to_value(result)?, - }), - Err(error) => Output::Failure(Failure { + let output = match result { + Ok(result) => serde_json::to_value(result).map(|result| { + Output::Success(Success { 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 b27e34e297d0..b09ca471d09e 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; @@ -20,12 +18,12 @@ mod signature_help; 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::lsp::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 f3223487c6ca..a7d2d1f782c7 100644 --- a/helix-term/src/handlers/completion.rs +++ b/helix-term/src/handlers/completion.rs @@ -1,309 +1,92 @@ -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; +use std::collections::HashMap; + +use anyhow::Result; -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::lsp::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::lsp::{CompletionEvent, CompletionResponseMeta}; +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::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, - } - } -} - -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(); - } - } +async fn handle_response( + requests: &mut JoinSet, + incomplete: bool, +) -> Option { + loop { + let response = requests.join_next().await?.unwrap(); + if !incomplete && !response.meta.incomplete && response.items.is_empty() { + continue; } - 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) - }); + return Some(response); } } -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, - } - } + while let Some(mut response) = handle_response(&mut requests, incomplete).await { + let handle = handle.clone(); + dispatch(move |editor, compositor| { + let editor_view = compositor.find::().unwrap(); + let Some(completion) = &mut editor_view.completion else { + return; }; - - let completion_response = ls.completion(doc_id, pos, None, context).unwrap(); - async move { - let json = completion_response.await?; - let response: Option = serde_json::from_value(json)?; - let items = match response { - Some(lsp::CompletionResponse::Array(items)) => items, - // TODO: do something with is_incomplete - Some(lsp::CompletionResponse::List(lsp::CompletionList { - is_incomplete: _is_incomplete, - items, - })) => items, - None => Vec::new(), - } - .into_iter() - .map(|item| { - CompletionItem::Lsp(LspCompletionItem { - item, - provider: language_server_id, - resolved: false, - }) - }) - .collect(); - anyhow::Ok(items) + if handle.is_canceled() { + log::error!("dropping outdated completion response"); + return; } - .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; - }; - dispatch(move |editor, compositor| { - show_completion(editor, compositor, items, trigger, savepoint); - drop(handle) + completion.replace_provider_completions(&mut response, incomplete); + 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, false); + } else { + editor + .handlers + .completions + .active_completions + .insert(response.provider, response.meta); + } }) - .await - }); + .await; + } } fn show_completion( editor: &mut Editor, compositor: &mut Compositor, items: Vec, + meta: HashMap, trigger: Trigger, - savepoint: Arc, ) { let (view, doc) = current_ref!(editor); // check if the completion request is stale. @@ -320,8 +103,9 @@ fn show_completion( if ui.completion.is_some() { return; } + editor.handlers.completions.active_completions = meta; - 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)); @@ -331,11 +115,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; @@ -363,15 +143,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; } @@ -384,29 +162,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 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); + trigger_auto_completion(cx.editor, false); } + } else { + let handle = cx.editor.handlers.completions.request_controller.restart(); + request_incomplete_completion_list(cx.editor, handle) } } })) @@ -420,9 +198,8 @@ 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 +210,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 { @@ -459,33 +236,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 bcd35cd5411e..987d07c18853 100644 --- a/helix-term/src/handlers/completion/item.rs +++ b/helix-term/src/handlers/completion/item.rs @@ -1,10 +1,71 @@ +use std::mem; + +use helix_core::completion::CompletionProvider; use helix_lsp::{lsp, LanguageServerId}; +use helix_view::handlers::lsp::CompletionResponseMeta; + +pub struct CompletionResponse { + pub items: CompletionItems, + pub provider: CompletionProvider, + pub meta: CompletionResponseMeta, +} + +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, + CompletionProvider::PathCompletions => unreachable!(), + }, + resolved: false, + provider_priority: self.meta.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 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 +74,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 +103,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 e92be51cfa78..a74e1b781dbd 100644 --- a/helix-term/src/handlers/completion/path.rs +++ b/helix-term/src/handlers/completion/path.rs @@ -3,24 +3,25 @@ use std::{ fs, path::{Path, PathBuf}, str::FromStr as _, + sync::Arc, }; -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 helix_view::{document::SavePoint, handlers::lsp::CompletionResponseMeta, 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>>> { + savepoint: Arc, +) -> Option CompletionResponse> { if !doc.path_completion_enabled() { return None; } @@ -67,12 +68,22 @@ 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::PathCompletions, + meta: CompletionResponseMeta { + incomplete: false, + priority: PRIORITY, + savepoint, + }, + }; }; - read_dir + let res: Vec<_> = read_dir .filter_map(Result::ok) .filter_map(|dir_entry| { dir_entry @@ -103,12 +114,22 @@ pub(crate) fn path_completion( label: file_name.into(), transaction, documentation, + provider: CompletionProvider::PathCompletions, })) }) - .collect::>() - }); + .collect(); + CompletionResponse { + items: CompletionItems::Other(res), + provider: CompletionProvider::PathCompletions, + meta: CompletionResponseMeta { + 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 000000000000..9f1f6be1cff3 --- /dev/null +++ b/helix-term/src/handlers/completion/request.rs @@ -0,0 +1,367 @@ +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::lsp::{CompletionEvent, CompletionResponseMeta}; +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 (view, doc) = current!(editor); + 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(cursor, text.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 metadata = 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); + metadata.insert(response.provider, response.meta); + 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); + metadata.insert(response.provider, response.meta); + } + dispatch(move |editor, compositor| { + show_completion(editor, compositor, items, metadata, 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 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), + meta: CompletionResponseMeta { + 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, meta) in &handler.active_completions { + if !meta.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, + }, + meta.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 cb0af6fc638a..4c15286556dd 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,46 +1,30 @@ +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::{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::{Modifier, Style}, ViewId, }; -use tui::{buffer::Buffer as Surface, text::Span}; - -use std::{borrow::Cow, sync::Arc}; - -use helix_core::{self as core, chars, Change, Transaction}; use helix_view::{graphics::Rect, Document, Editor}; +use nucleo::{ + pattern::{Atom, AtomKind, CaseMatching, Normalization}, + Config, Utf32Str, +}; +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 = (); - 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 { @@ -114,22 +98,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()); // Then create the menu let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| { @@ -266,10 +244,11 @@ impl Completion { savepoint: doc.savepoint(view), }) } + let item = item.unwrap(); + let meta = &editor.handlers.completions.active_completions[&item.provider()]; // if more text was entered, remove it - doc.restore(view, &savepoint, false); + doc.restore(view, &meta.savepoint, false); // always present here - let item = item.unwrap(); match item { CompletionItem::Lsp(item) => doc.apply_temporary( @@ -297,13 +276,15 @@ impl Completion { doc.restore(view, &savepoint, false); } + let item = item.unwrap(); + let meta = &editor.handlers.completions.active_completions[&item.provider()]; // if more text was entered, remove it - doc.restore(view, &savepoint, true); + doc.restore(view, &meta.savepoint, true); // save an undo checkpoint before the completion doc.append_changes_to_history(view); // item always present here - let (transaction, additional_edits) = match item.unwrap().clone() { + let (transaction, additional_edits) = match item.clone() { CompletionItem::Lsp(mut item) => { let language_server = language_server!(item); @@ -356,7 +337,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); } }; @@ -393,14 +374,72 @@ 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)) + })); + } + // 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( @@ -442,7 +481,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, + incomplete: bool, + ) { + let menu = self.popup.contents_mut(); + let (_, options) = menu.update_options(); + if 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 5179be4f4e1c..1473255445b1 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}; @@ -1029,12 +1029,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 @@ -1052,6 +1051,8 @@ impl EditorView { pub fn clear_completion(&mut self, editor: &mut Editor) { self.completion = 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 612832ce1221..76e50229a295 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 6f71fa05204f..f69f2982b50a 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 fa089cdafeab..1271216190ac 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1430,16 +1430,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); - } } } @@ -1756,6 +1752,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) } diff --git a/helix-view/src/handlers.rs b/helix-view/src/handlers.rs index 93336beb5683..35ba04d0f795 100644 --- a/helix-view/src/handlers.rs +++ b/helix-view/src/handlers.rs @@ -1,7 +1,7 @@ use helix_event::send_blocking; use tokio::sync::mpsc::Sender; -use crate::handlers::lsp::SignatureHelpInvoked; +use crate::handlers::lsp::{CompletionHandler, SignatureHelpInvoked}; use crate::{DocumentId, Editor, ViewId}; pub mod dap; @@ -16,7 +16,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, } @@ -24,14 +24,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, - lsp::CompletionEvent::ManualTrigger { - cursor: trigger_pos, - doc, - view, - }, - ); + self.completions.event(lsp::CompletionEvent::ManualTrigger { + cursor: trigger_pos, + doc, + view, + }); } pub fn trigger_signature_help(&self, invocation: SignatureHelpInvoked, editor: &Editor) { diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 1fd2289db5d8..700035e7ad42 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -1,11 +1,45 @@ +use std::collections::HashMap; use std::fmt::Display; +use std::sync::Arc; +use crate::document::SavePoint; use crate::editor::Action; use crate::Editor; use crate::{DocumentId, ViewId}; +use helix_core::completion::CompletionProvider; use helix_core::Uri; +use helix_event::{send_blocking, TaskController}; use helix_lsp::util::generate_transaction_from_edits; use helix_lsp::{lsp, OffsetEncoding}; +use tokio::sync::mpsc::Sender; + +pub struct CompletionHandler { + event_tx: Sender, + pub active_completions: HashMap, + pub request_controller: TaskController, +} + +impl CompletionHandler { + pub fn new(event_tx: Sender) -> CompletionHandler { + CompletionHandler { + event_tx, + active_completions: HashMap::new(), + request_controller: TaskController::new(), + } + } +} + +impl CompletionHandler { + pub fn event(&self, event: CompletionEvent) { + send_blocking(&self.event_tx, event); + } +} +// bikeshed: this name sucks but I don't have a better idea +pub struct CompletionResponseMeta { + pub incomplete: bool, + pub priority: i8, + pub savepoint: Arc, +} pub enum CompletionEvent { /// Auto completion was triggered by typing a word char