implement incomplete completion requests

This commit is contained in:
Pascal Kuthe 2025-02-01 15:48:42 -05:00 committed by Michael Davis
parent 4e0fc0efc6
commit 5c1f3f814f
No known key found for this signature in database
9 changed files with 705 additions and 444 deletions

View file

@ -6,10 +6,8 @@ use helix_event::AsyncHook;
use crate::config::Config;
use crate::events;
use crate::handlers::auto_save::AutoSaveHandler;
use crate::handlers::completion::CompletionHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use completion::trigger_auto_completion;
pub use helix_view::handlers::Handlers;
mod auto_save;
@ -21,12 +19,12 @@ mod snippet;
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register();
let completions = CompletionHandler::new(config).spawn();
let event_tx = completion::CompletionHandler::new(config).spawn();
let signature_hints = SignatureHelpHandler::new().spawn();
let auto_save = AutoSaveHandler::new().spawn();
let handlers = Handlers {
completions,
completions: helix_view::handlers::completion::CompletionHandler::new(event_tx),
signature_hints,
auto_save,
};

View file

@ -1,310 +1,90 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use std::collections::HashMap;
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use futures_util::FutureExt;
use helix_core::chars::char_is_word;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle};
use helix_event::{register_hook, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::completion::CompletionEvent;
use helix_view::{DocumentId, Editor, ViewId};
use path::path_completion;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt as _;
use helix_view::document::Mode;
use helix_view::handlers::completion::{CompletionEvent, ResponseContext};
use helix_view::Editor;
use tokio::task::JoinSet;
use crate::commands;
use crate::compositor::Compositor;
use crate::config::Config;
use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
use crate::job::{dispatch, dispatch_blocking};
use crate::handlers::completion::request::{request_incomplete_completion_list, Trigger};
use crate::job::dispatch;
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::signature_help::SignatureHelp;
use crate::ui::{self, Popup};
use super::Handlers;
pub use item::{CompletionItem, LspCompletionItem};
pub use item::{CompletionItem, CompletionItems, CompletionResponse, LspCompletionItem};
pub use request::CompletionHandler;
pub use resolve::ResolveHandler;
mod item;
mod path;
mod request;
mod resolve;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
struct Trigger {
pos: usize,
view: ViewId,
doc: DocumentId,
kind: TriggerKind,
}
#[derive(Debug)]
pub(super) struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<Trigger>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
async fn handle_response(
requests: &mut JoinSet<CompletionResponse>,
is_incomplete: bool,
) -> Option<CompletionResponse> {
loop {
let response = requests.join_next().await?.unwrap();
if !is_incomplete && !response.context.is_incomplete && response.items.is_empty() {
continue;
}
return Some(response);
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
if self.in_flight.is_some() && !self.task_controller.is_running() {
self.in_flight = None;
}
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// 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<CompletionResponse>,
is_incomplete: bool,
) {
let (view, doc) = current!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let selection = doc.selection(view.id);
let cursor = selection.primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// this looks odd... Why are we not using the trigger position from
// the `trigger` here? Won't that mean that the trigger char doesn't get
// send to the LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|ls| {
let language_server_id = ls.id();
let offset_encoding = ls.offset_encoding();
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let json = completion_response.await?;
let response: Option<lsp::CompletionResponse> = serde_json::from_value(json)?;
let items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete: _is_incomplete,
items,
})) => items,
None => Vec::new(),
}
.into_iter()
.map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: language_server_id,
resolved: false,
})
})
.collect();
anyhow::Ok(items)
}
.boxed()
})
.chain(path_completion(selection.clone(), doc, handle.clone()))
.collect();
let future = async move {
let mut items = Vec::new();
while let Some(lsp_items) = futures.next().await {
match lsp_items {
Ok(mut lsp_items) => items.append(&mut lsp_items),
Err(err) => {
log::debug!("completion request failed: {err:?}");
}
};
}
items
};
let savepoint = doc.savepoint(view);
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
tokio::spawn(async move {
let items = cancelable_future(future, &handle).await;
let Some(items) = items.filter(|items| !items.is_empty()) else {
return;
};
while let Some(mut response) = handle_response(&mut requests, is_incomplete).await {
let handle = handle.clone();
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint);
drop(handle)
let editor_view = compositor.find::<ui::EditorView>().unwrap();
let Some(completion) = &mut editor_view.completion else {
return;
};
if handle.is_canceled() {
log::error!("dropping outdated completion response");
return;
}
completion.replace_provider_completions(&mut response, is_incomplete);
if completion.is_empty() {
editor_view.clear_completion(editor);
// clearing completions might mean we want to immediately re-request them (usually
// this occurs if typing a trigger char)
trigger_auto_completion(editor, false);
} else {
editor
.handlers
.completions
.active_completions
.insert(response.provider, response.context);
}
})
.await
});
.await;
}
}
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
context: HashMap<CompletionProvider, ResponseContext>,
trigger: Trigger,
savepoint: Arc<SavePoint>,
) {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
@ -321,8 +101,9 @@ fn show_completion(
if ui.completion.is_some() {
return;
}
editor.handlers.completions.active_completions = context;
let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
let completion_area = ui.set_completion(editor, items, trigger.pos, size);
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
@ -332,11 +113,7 @@ fn show_completion(
}
}
pub fn trigger_auto_completion(
tx: &Sender<CompletionEvent>,
editor: &Editor,
trigger_char_only: bool,
) {
pub fn trigger_auto_completion(editor: &Editor, trigger_char_only: bool) {
let config = editor.config.load();
if !config.auto_completion {
return;
@ -364,15 +141,13 @@ pub fn trigger_auto_completion(
#[cfg(not(windows))]
let is_path_completion_trigger = matches!(cursor_char, Some(b'/'));
let handler = &editor.handlers.completions;
if is_trigger_char || (is_path_completion_trigger && doc.path_completion_enabled()) {
send_blocking(
tx,
CompletionEvent::TriggerChar {
cursor,
doc: doc.id(),
view: view.id,
},
);
handler.event(CompletionEvent::TriggerChar {
cursor,
doc: doc.id(),
view: view.id,
});
return;
}
@ -385,29 +160,29 @@ pub fn trigger_auto_completion(
.all(char_is_word);
if is_auto_trigger {
send_blocking(
tx,
CompletionEvent::AutoTrigger {
cursor,
doc: doc.id(),
view: view.id,
},
);
handler.event(CompletionEvent::AutoTrigger {
cursor,
doc: doc.id(),
view: view.id,
});
}
}
fn update_completions(cx: &mut commands::Context, c: Option<char>) {
fn update_completion_filter(cx: &mut commands::Context, c: Option<char>) {
cx.callback.push(Box::new(move |compositor, cx| {
let editor_view = compositor.find::<ui::EditorView>().unwrap();
if let Some(completion) = &mut editor_view.completion {
completion.update_filter(c);
if completion.is_empty() {
if completion.is_empty() || c.is_some_and(|c| !char_is_word(c)) {
editor_view.clear_completion(cx.editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
if c.is_some() {
trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
trigger_auto_completion(cx.editor, false);
}
} else {
let handle = cx.editor.handlers.completions.request_controller.restart();
request_incomplete_completion_list(cx.editor, handle)
}
}
}))
@ -421,7 +196,6 @@ fn clear_completions(cx: &mut commands::Context) {
}
fn completion_post_command_hook(
tx: &Sender<CompletionEvent>,
PostCommand { command, cx }: &mut PostCommand<'_, '_>,
) -> anyhow::Result<()> {
if cx.editor.mode == Mode::Insert {
@ -434,7 +208,7 @@ fn completion_post_command_hook(
MappableCommand::Static {
name: "delete_char_backward",
..
} => update_completions(cx, None),
} => update_completion_filter(cx, None),
_ => clear_completions(cx),
}
} else {
@ -460,33 +234,35 @@ fn completion_post_command_hook(
} => return Ok(()),
_ => CompletionEvent::Cancel,
};
send_blocking(tx, event);
cx.editor.handlers.completions.event(event);
}
}
Ok(())
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(&tx, event));
pub(super) fn register_hooks(_handlers: &Handlers) {
register_hook!(move |event: &mut PostCommand<'_, '_>| completion_post_command_hook(event));
let tx = handlers.completions.clone();
register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
if event.old_mode == Mode::Insert {
send_blocking(&tx, CompletionEvent::Cancel);
event
.cx
.editor
.handlers
.completions
.event(CompletionEvent::Cancel);
clear_completions(event.cx);
} else if event.new_mode == Mode::Insert {
trigger_auto_completion(&tx, event.cx.editor, false)
trigger_auto_completion(event.cx.editor, false)
}
Ok(())
});
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
if event.cx.editor.last_completion.is_some() {
update_completions(event.cx, Some(event.c))
update_completion_filter(event.cx, Some(event.c))
} else {
trigger_auto_completion(&tx, event.cx.editor, false);
trigger_auto_completion(event.cx.editor, false);
}
Ok(())
});

View file

@ -1,10 +1,70 @@
use std::mem;
use helix_core::completion::CompletionProvider;
use helix_lsp::{lsp, LanguageServerId};
use helix_view::handlers::completion::ResponseContext;
pub struct CompletionResponse {
pub items: CompletionItems,
pub provider: CompletionProvider,
pub context: ResponseContext,
}
pub enum CompletionItems {
Lsp(Vec<lsp::CompletionItem>),
Other(Vec<CompletionItem>),
}
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<CompletionItem>) {
match &mut self.items {
CompletionItems::Lsp(items) => dst.extend(items.drain(..).map(|item| {
CompletionItem::Lsp(LspCompletionItem {
item,
provider: match self.provider {
CompletionProvider::Lsp(provider) => provider,
_ => unreachable!(),
},
resolved: false,
provider_priority: self.context.priority,
})
})),
CompletionItems::Other(items) if dst.is_empty() => mem::swap(dst, items),
CompletionItems::Other(items) => dst.append(items),
}
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct LspCompletionItem {
pub item: lsp::CompletionItem,
pub provider: LanguageServerId,
pub resolved: bool,
// TODO: we should not be filtering and sorting incomplete completion list
// according to the spec but vscode does that anyway and most servers (
// including rust-analyzer) rely on that.. so we can't do that without
// breaking completions.
pub provider_priority: i8,
}
impl LspCompletionItem {
#[inline]
pub fn filter_text(&self) -> &str {
self.item
.filter_text
.as_ref()
.unwrap_or(&self.item.label)
.as_str()
}
}
#[derive(Debug, PartialEq, Clone)]
@ -13,6 +73,16 @@ pub enum CompletionItem {
Other(helix_core::CompletionItem),
}
impl CompletionItem {
#[inline]
pub fn filter_text(&self) -> &str {
match self {
CompletionItem::Lsp(item) => item.filter_text(),
CompletionItem::Other(item) => &item.label,
}
}
}
impl PartialEq<CompletionItem> for LspCompletionItem {
fn eq(&self, other: &CompletionItem) -> bool {
match other {
@ -32,6 +102,21 @@ impl PartialEq<CompletionItem> 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),

View file

@ -3,22 +3,23 @@ use std::{
fs,
path::{Path, PathBuf},
str::FromStr as _,
sync::Arc,
};
use futures_util::{future::BoxFuture, FutureExt as _};
use helix_core::{self as core, completion::CompletionProvider, Selection, Transaction};
use helix_event::TaskHandle;
use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix};
use helix_view::Document;
use helix_view::{document::SavePoint, handlers::completion::ResponseContext, Document};
use url::Url;
use super::item::CompletionItem;
use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems};
pub(crate) fn path_completion(
selection: Selection,
doc: &Document,
handle: TaskHandle,
) -> Option<BoxFuture<'static, anyhow::Result<Vec<CompletionItem>>>> {
savepoint: Arc<SavePoint>,
) -> Option<impl FnOnce() -> CompletionResponse> {
if !doc.path_completion_enabled() {
return None;
}
@ -67,9 +68,19 @@ pub(crate) fn path_completion(
return None;
}
let future = tokio::task::spawn_blocking(move || {
// TODO: handle properly in the future
const PRIORITY: i8 = 1;
let future = move || {
let Ok(read_dir) = std::fs::read_dir(&dir_path) else {
return Vec::new();
return CompletionResponse {
items: CompletionItems::Other(Vec::new()),
provider: CompletionProvider::Path,
context: ResponseContext {
is_incomplete: false,
priority: PRIORITY,
savepoint,
},
};
};
let edit_diff = typed_file_name
@ -77,7 +88,7 @@ pub(crate) fn path_completion(
.map(|s| s.chars().count())
.unwrap_or_default();
read_dir
let res: Vec<_> = read_dir
.filter_map(Result::ok)
.filter_map(|dir_entry| {
dir_entry
@ -106,10 +117,19 @@ pub(crate) fn path_completion(
provider: CompletionProvider::Path,
}))
})
.collect::<Vec<_>>()
});
.collect();
CompletionResponse {
items: CompletionItems::Other(res),
provider: CompletionProvider::Path,
context: ResponseContext {
is_incomplete: false,
priority: PRIORITY,
savepoint,
},
}
};
Some(async move { Ok(future.await?) }.boxed())
Some(future)
}
#[cfg(unix)]

View file

@ -0,0 +1,368 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::Future;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, TaskController, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::lsp::{CompletionContext, CompletionTriggerKind};
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::completion::{CompletionEvent, ResponseContext};
use helix_view::{Document, DocumentId, Editor, ViewId};
use tokio::task::JoinSet;
use tokio::time::{timeout_at, Instant};
use crate::compositor::Compositor;
use crate::config::Config;
use crate::handlers::completion::item::CompletionResponse;
use crate::handlers::completion::path::path_completion;
use crate::handlers::completion::{
handle_response, replace_completions, show_completion, CompletionItems,
};
use crate::job::{dispatch, dispatch_blocking};
use crate::ui;
use crate::ui::editor::InsertEvent;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(super) enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Trigger {
pub(super) pos: usize,
pub(super) view: ViewId,
pub(super) doc: DocumentId,
pub(super) kind: TriggerKind,
}
#[derive(Debug)]
pub struct CompletionHandler {
/// The currently active trigger which will cause a completion request after the timeout.
trigger: Option<Trigger>,
in_flight: Option<Trigger>,
task_controller: TaskController,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
task_controller: TaskController::new(),
trigger: None,
in_flight: None,
}
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
if self.in_flight.is_some() && !self.task_controller.is_running() {
self.in_flight = None;
}
match event {
CompletionEvent::AutoTrigger {
cursor: trigger_pos,
doc,
view,
} => {
// Technically it shouldn't be possible to switch views/documents in insert mode
// but people may create weird keymaps/use the mouse so let's be extra careful.
if self
.trigger
.or(self.in_flight)
.map_or(true, |trigger| trigger.doc != doc || trigger.view != view)
{
self.trigger = Some(Trigger {
pos: trigger_pos,
view,
doc,
kind: TriggerKind::Auto,
});
}
}
CompletionEvent::TriggerChar { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.task_controller.cancel();
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::TriggerChar,
});
}
CompletionEvent::ManualTrigger { cursor, doc, view } => {
// immediately request completions and drop all auto completion requests
self.trigger = Some(Trigger {
pos: cursor,
view,
doc,
kind: TriggerKind::Manual,
});
// stop debouncing immediately and request the completion
self.finish_debounce();
return None;
}
CompletionEvent::Cancel => {
self.trigger = None;
self.task_controller.cancel();
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger.or(self.in_flight), Some(Trigger{ pos, .. }) if cursor < pos)
{
self.trigger = None;
self.task_controller.cancel();
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let timeout = if trigger.kind == TriggerKind::Auto {
self.config.load().editor.completion_timeout
} else {
// we want almost instant completions for trigger chars
// and restarting completion requests. The small timeout here mainly
// serves to better handle cases where the completion handler
// may fall behind (so multiple events in the channel) and macros
Duration::from_millis(5)
};
Instant::now() + timeout
})
}
fn finish_debounce(&mut self) {
let trigger = self.trigger.take().expect("debounce always has a trigger");
self.in_flight = Some(trigger);
let handle = self.task_controller.restart();
dispatch_blocking(move |editor, compositor| {
request_completions(trigger, handle, editor, compositor)
});
}
}
fn request_completions(
mut trigger: Trigger,
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
) {
let (view, doc) = current_ref!(editor);
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.is_some()
|| editor.mode != Mode::Insert
{
return;
}
let text = doc.text();
let cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// This looks odd... Why are we not using the trigger position from the `trigger` here? Won't
// that mean that the trigger char doesn't get send to the language server if we type fast
// enough? Yes that is true but it's not actually a problem. The language server will resolve
// the completion to the identifier anyway (in fact sending the later position is necessary to
// get the right results from language servers that provide incomplete completion list). We
// rely on the trigger offset and primary cursor matching for multi-cursor completions so this
// is definitely necessary from our side too.
trigger.pos = cursor;
let doc = doc_mut!(editor, &doc.id());
let savepoint = doc.savepoint(view);
let text = doc.text();
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let language_servers: Vec<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.collect();
let mut requests = JoinSet::new();
for (priority, ls) in language_servers.iter().enumerate() {
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
requests.spawn(request_completions_from_language_server(
ls,
doc,
view.id,
context,
-(priority as i8),
savepoint.clone(),
));
}
if let Some(path_completion_request) = path_completion(
doc.selection(view.id).clone(),
doc,
handle.clone(),
savepoint,
) {
requests.spawn_blocking(path_completion_request);
}
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
let handle_ = handle.clone();
let request_completions = async move {
let mut context = HashMap::new();
let Some(mut response) = handle_response(&mut requests, false).await else {
return;
};
let mut items: Vec<_> = Vec::new();
response.take_items(&mut items);
context.insert(response.provider, response.context);
let deadline = Instant::now() + Duration::from_millis(100);
loop {
let Some(mut response) = timeout_at(deadline, handle_response(&mut requests, false))
.await
.ok()
.flatten()
else {
break;
};
response.take_items(&mut items);
context.insert(response.provider, response.context);
}
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, context, trigger)
})
.await;
if !requests.is_empty() {
replace_completions(handle_, requests, false).await;
}
};
tokio::spawn(cancelable_future(request_completions, handle));
}
fn request_completions_from_language_server(
ls: &helix_lsp::Client,
doc: &Document,
view: ViewId,
context: lsp::CompletionContext,
priority: i8,
savepoint: Arc<SavePoint>,
) -> impl Future<Output = CompletionResponse> {
let provider = ls.id();
let offset_encoding = ls.offset_encoding();
let text = doc.text();
let cursor = doc.selection(view).primary().cursor(text.slice(..));
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
// it's important that this is before the async block (and that this is not an async function)
// to ensure the request is dispatched right away before any new edit notifications
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let response: Option<lsp::CompletionResponse> = completion_response
.await
.and_then(|json| serde_json::from_value(json).map_err(helix_lsp::Error::Parse))
.inspect_err(|err| log::error!("completion request failed: {err}"))
.ok()
.flatten();
let (mut items, is_incomplete) = match response {
Some(lsp::CompletionResponse::Array(items)) => (items, false),
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete,
items,
})) => (items, is_incomplete),
None => (Vec::new(), false),
};
items.sort_by(|item1, item2| {
let sort_text1 = item1.sort_text.as_deref().unwrap_or(&item1.label);
let sort_text2 = item2.sort_text.as_deref().unwrap_or(&item2.label);
sort_text1.cmp(sort_text2)
});
CompletionResponse {
items: CompletionItems::Lsp(items),
context: ResponseContext {
is_incomplete,
priority,
savepoint,
},
provider: CompletionProvider::Lsp(provider),
}
}
}
pub fn request_incomplete_completion_list(editor: &mut Editor, handle: TaskHandle) {
let handler = &mut editor.handlers.completions;
let mut requests = JoinSet::new();
let mut savepoint = None;
for (&provider, context) in &handler.active_completions {
if !context.is_incomplete {
continue;
}
let CompletionProvider::Lsp(ls_id) = provider else {
log::error!("non-lsp incomplete completion lists");
continue;
};
let Some(ls) = editor.language_servers.get_by_id(ls_id) else {
continue;
};
let (view, doc) = current!(editor);
let savepoint = savepoint.get_or_insert_with(|| doc.savepoint(view)).clone();
let request = request_completions_from_language_server(
ls,
doc,
view.id,
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS,
trigger_character: None,
},
context.priority,
savepoint,
);
requests.spawn(request);
}
if !requests.is_empty() {
tokio::spawn(replace_completions(handle, requests, true));
}
}

View file

@ -1,53 +1,32 @@
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
use crate::{
compositor::{Component, Context, Event, EventResult},
handlers::{
completion::{CompletionItem, LspCompletionItem, ResolveHandler},
trigger_auto_completion,
handlers::completion::{
trigger_auto_completion, CompletionItem, CompletionResponse, LspCompletionItem,
ResolveHandler,
},
};
use helix_core::snippets::{ActiveSnippet, RenderedSnippet, Snippet};
use helix_core::{self as core, chars, fuzzy::MATCHER, Change, Transaction};
use helix_lsp::{lsp, util, OffsetEncoding};
use helix_view::{
document::SavePoint,
editor::CompleteAction,
handlers::lsp::SignatureHelpInvoked,
theme::{Color, Modifier, Style},
ViewId,
};
use tui::{
buffer::Buffer as Surface,
text::{Span, Spans},
};
use std::{borrow::Cow, sync::Arc};
use helix_core::{
self as core, chars,
snippets::{ActiveSnippet, RenderedSnippet, Snippet},
Change, Transaction,
};
use helix_view::{graphics::Rect, Document, Editor};
use nucleo::{
pattern::{Atom, AtomKind, CaseMatching, Normalization},
Config, Utf32Str,
};
use tui::text::Spans;
use tui::{buffer::Buffer as Surface, text::Span};
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
use helix_lsp::{lsp, util, OffsetEncoding};
use std::cmp::Reverse;
impl menu::Item for CompletionItem {
type Data = Style;
fn sort_text(&self, data: &Self::Data) -> Cow<str> {
self.filter_text(data)
}
#[inline]
fn filter_text(&self, _data: &Self::Data) -> Cow<str> {
match self {
CompletionItem::Lsp(LspCompletionItem { item, .. }) => item
.filter_text
.as_ref()
.unwrap_or(&item.label)
.as_str()
.into(),
CompletionItem::Other(core::CompletionItem { label, .. }) => label.clone(),
}
}
fn format(&self, dir_style: &Self::Data) -> menu::Row {
let deprecated = match self {
@ -143,22 +122,16 @@ pub struct Completion {
#[allow(dead_code)]
trigger_offset: usize,
filter: String,
// TODO: move to helix-view/central handler struct in the future
resolve_handler: ResolveHandler,
}
impl Completion {
pub const ID: &'static str = "completion";
pub fn new(
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
trigger_offset: usize,
) -> Self {
pub fn new(editor: &Editor, items: Vec<CompletionItem>, trigger_offset: usize) -> Self {
let preview_completion_insert = editor.config().preview_completion_insert;
let replace_mode = editor.config().completion_replace;
// Sort completion items according to their preselect status (given by the LSP server)
items.sort_by_key(|item| !item.preselect());
let dir_style = editor.theme.get("ui.text.directory");
@ -202,10 +175,11 @@ impl Completion {
savepoint: doc.savepoint(view),
})
}
// if more text was entered, remove it
doc.restore(view, &savepoint, false);
// always present here
let item = item.unwrap();
let context = &editor.handlers.completions.active_completions[&item.provider()];
// if more text was entered, remove it
doc.restore(view, &context.savepoint, false);
// always present here
match item {
CompletionItem::Lsp(item) => {
@ -232,13 +206,15 @@ impl Completion {
doc.restore(view, &savepoint, false);
}
let item = item.unwrap();
let context = &editor.handlers.completions.active_completions[&item.provider()];
// if more text was entered, remove it
doc.restore(view, &savepoint, true);
doc.restore(view, &context.savepoint, true);
// save an undo checkpoint before the completion
doc.append_changes_to_history(view);
// item always present here
let (transaction, additional_edits, snippet) = match item.unwrap().clone() {
let (transaction, additional_edits, snippet) = match item.clone() {
CompletionItem::Lsp(mut item) => {
let language_server = language_server!(item);
@ -302,7 +278,7 @@ impl Completion {
}
// we could have just inserted a trigger char (like a `crate::` completion for rust
// so we want to retrigger immediately when accepting a completion.
trigger_auto_completion(&editor.handlers.completions, editor, true);
trigger_auto_completion(editor, true);
}
};
@ -339,14 +315,70 @@ impl Completion {
};
// need to recompute immediately in case start_offset != trigger_offset
completion
.popup
.contents_mut()
.score(&completion.filter, false);
completion.score(false);
completion
}
fn score(&mut self, incremental: bool) {
let pattern = &self.filter;
let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT;
// slight preference towards prefix matches
matcher.config.prefer_prefix = true;
let pattern = Atom::new(
pattern,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let mut buf = Vec::new();
let (matches, options) = self.popup.contents_mut().update_options();
if incremental {
matches.retain_mut(|(index, score)| {
let option = &options[*index as usize];
let text = option.filter_text();
let new_score = pattern.score(Utf32Str::new(text, &mut buf), &mut matcher);
match new_score {
Some(new_score) => {
*score = new_score as u32 / 2;
true
}
None => false,
}
})
} else {
matches.clear();
matches.extend(options.iter().enumerate().filter_map(|(i, option)| {
let text = option.filter_text();
pattern
.score(Utf32Str::new(text, &mut buf), &mut matcher)
.map(|score| (i as u32, score as u32 / 3))
}));
}
// Nucleo is meant as an FZF-like fuzzy matcher and only hides matches that are truly
// impossible - as in the sequence of characters just doesn't appear. That doesn't work
// well for completions with multiple language servers where all completions of the next
// server are below the current one (so you would get good suggestions from the second
// server below those of the first). Setting a reasonable cutoff below which to move bad
// completions out of the way helps with that.
//
// The score computation is a heuristic derived from Nucleo internal constants that may
// move upstream in the future. I want to test this out here to settle on a good number.
let min_score = (7 + pattern.needle_text().len() as u32 * 14) / 3;
matches.sort_unstable_by_key(|&(i, score)| {
let option = &options[i as usize];
(
score <= min_score,
Reverse(option.preselect()),
option.provider_priority(),
Reverse(score),
i,
)
});
}
/// Synchronously resolve the given completion item. This is used when
/// accepting a completion.
fn resolve_completion_item(
@ -388,7 +420,24 @@ impl Completion {
}
}
}
menu.score(&self.filter, c.is_some());
self.score(c.is_some());
self.popup.contents_mut().reset_cursor();
}
pub fn replace_provider_completions(
&mut self,
response: &mut CompletionResponse,
is_incomplete: bool,
) {
let menu = self.popup.contents_mut();
let (_, options) = menu.update_options();
if is_incomplete {
options.retain(|item| item.provider() != response.provider)
}
response.take_items(options);
self.score(false);
let menu = self.popup.contents_mut();
menu.ensure_cursor_in_bounds();
}
pub fn is_empty(&self) -> bool {

View file

@ -24,14 +24,14 @@ use helix_core::{
};
use helix_view::{
annotations::diagnostics::DiagnosticFilter,
document::{Mode, SavePoint, SCRATCH_BUFFER_NAME},
document::{Mode, SCRATCH_BUFFER_NAME},
editor::{CompleteAction, CursorShapeConfig},
graphics::{Color, CursorKind, Modifier, Rect, Style},
input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
keyboard::{KeyCode, KeyModifiers},
Document, Editor, Theme, View,
};
use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc};
use tui::{buffer::Buffer as Surface, text::Span};
@ -1049,12 +1049,11 @@ impl EditorView {
pub fn set_completion(
&mut self,
editor: &mut Editor,
savepoint: Arc<SavePoint>,
items: Vec<CompletionItem>,
trigger_offset: usize,
size: Rect,
) -> Option<Rect> {
let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
let mut completion = Completion::new(editor, items, trigger_offset);
if completion.is_empty() {
// skip if we got no completion results
@ -1073,6 +1072,8 @@ impl EditorView {
pub fn clear_completion(&mut self, editor: &mut Editor) -> Option<OnKeyCallback> {
self.completion = None;
let mut on_next_key: Option<OnKeyCallback> = 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 => (),

View file

@ -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<str> {
let label: String = self.format(data).cell_text().collect();
label.into()
}
fn filter_text(&self, data: &Self::Data) -> Cow<str> {
let label: String = self.format(data).cell_text().collect();
label.into()
}
}
pub type MenuCallback<T> = Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>;
@ -77,49 +62,30 @@ impl<T: Item> Menu<T> {
}
}
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<T>) {
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();

View file

@ -1,3 +1,4 @@
use completion::{CompletionEvent, CompletionHandler};
use helix_event::send_blocking;
use tokio::sync::mpsc::Sender;
@ -17,7 +18,7 @@ pub enum AutoSaveEvent {
pub struct Handlers {
// only public because most of the actual implementation is in helix-term right now :/
pub completions: Sender<completion::CompletionEvent>,
pub completions: CompletionHandler,
pub signature_hints: Sender<lsp::SignatureHelpEvent>,
pub auto_save: Sender<AutoSaveEvent>,
}
@ -25,14 +26,11 @@ pub struct Handlers {
impl Handlers {
/// Manually trigger completion (c-x)
pub fn trigger_completions(&self, trigger_pos: usize, doc: DocumentId, view: ViewId) {
send_blocking(
&self.completions,
completion::CompletionEvent::ManualTrigger {
cursor: trigger_pos,
doc,
view,
},
);
self.completions.event(CompletionEvent::ManualTrigger {
cursor: trigger_pos,
doc,
view,
});
}
pub fn trigger_signature_help(&self, invocation: SignatureHelpInvoked, editor: &Editor) {