mirror of https://github.com/helix-editor/helix
375 lines
13 KiB
Rust
375 lines
13 KiB
Rust
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::config::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;
|
|
|
|
use super::word;
|
|
|
|
#[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.clone(),
|
|
) {
|
|
requests.spawn_blocking(path_completion_request);
|
|
}
|
|
if let Some(word_completion_request) =
|
|
word::completion(editor, trigger, handle.clone(), savepoint)
|
|
{
|
|
requests.spawn_blocking(word_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
|
|
.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));
|
|
}
|
|
}
|