Merge branch 'incomplete_completion' into batteries

This commit is contained in:
Pascal Kuthe 2024-12-08 04:30:10 +01:00
commit 9edd87bdbb
No known key found for this signature in database
GPG Key ID: 4E01BF060BDE0D8B
12 changed files with 766 additions and 424 deletions

View File

@ -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<LanguageServerId> for CompletionProvider {
fn from(id: LanguageServerId) -> Self {
CompletionProvider::Lsp(id)
}
}

View File

@ -426,29 +426,32 @@ fn call_with_timeout<R: lsp::request::Request>(
let server_tx = self.server_tx.clone();
let id = self.next_request_id();
let params = serde_json::to_value(params);
async move {
use std::time::Duration;
use tokio::time::timeout;
// 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?),
params: Self::value_into_params(params),
};
let (tx, mut rx) = channel::<Result<Value>>(1);
let (tx, rx) = channel::<Result<Value>>(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;
// 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,7 +468,11 @@ pub fn notify<R: lsp::notification::Notification>(
{
let server_tx = self.server_tx.clone();
async move {
// 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 {
@ -473,13 +480,13 @@ pub fn notify<R: lsp::notification::Notification>(
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(())
}
.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 @@ pub fn reply(
let server_tx = self.server_tx.clone();
async move {
let output = match result {
Ok(result) => Output::Success(Success {
Ok(result) => serde_json::to_value(result).map(|result| {
Output::Success(Success {
jsonrpc: Some(Version::V2),
id,
result: serde_json::to_value(result)?,
result,
})
}),
Err(error) => Output::Failure(Failure {
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 }
}
// -------------------------------------------------------------------------------------------

View File

@ -9,7 +9,6 @@
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;

View File

@ -1,307 +1,86 @@
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use futures_util::FutureExt;
use anyhow::Result;
use helix_core::chars::char_is_word;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, register_hook, send_blocking, TaskController, TaskHandle};
use helix_event::{register_hook, send_blocking, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::{Mode, SavePoint};
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{DocumentId, Editor, ViewId};
use path::path_completion;
use helix_view::Editor;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt as _;
use tokio::task::JoinSet;
use crate::commands;
use crate::compositor::Compositor;
use crate::config::Config;
use crate::events::{OnModeSwitch, PostCommand, PostInsertChar};
use crate::job::{dispatch, dispatch_blocking};
use crate::handlers::completion::request::{request_incomplete_completion_list, Trigger};
use crate::job::dispatch;
use crate::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, Popup};
use super::Handlers;
pub use item::{CompletionItem, LspCompletionItem};
pub use item::{CompletionItem, CompletionItems, CompletionResponse, LspCompletionItem};
pub use request::CompletionHandler;
pub use resolve::ResolveHandler;
mod item;
mod path;
mod request;
mod resolve;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TriggerKind {
Auto,
TriggerChar,
Manual,
async fn handle_response(
requests: &mut JoinSet<CompletionResponse>,
incomplete: bool,
) -> Option<CompletionResponse> {
loop {
let response = requests.join_next().await?.unwrap();
if !incomplete && !response.incomplete && response.items.is_empty() {
continue;
}
#[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,
}
}
}
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)
});
return Some(response);
}
}
fn request_completion(
mut trigger: Trigger,
async fn replace_completions(
handle: TaskHandle,
editor: &mut Editor,
compositor: &mut Compositor,
mut requests: JoinSet<CompletionResponse>,
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 cursor = doc.selection(view.id).primary().cursor(text.slice(..));
if trigger.view != view.id || trigger.doc != doc.id() || cursor < trigger.pos {
return;
}
// this looks odd... Why are we not using the trigger position from
// the `trigger` here? Won't that mean that the trigger char doesn't get
// send to the LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let mut futures: FuturesUnordered<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.map(|ls| {
let language_server_id = ls.id();
let offset_encoding = ls.offset_encoding();
let pos = pos_to_lsp_pos(text, cursor, offset_encoding);
let doc_id = doc.identifier();
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
let completion_response = ls.completion(doc_id, pos, None, context).unwrap();
async move {
let json = completion_response.await?;
let response: Option<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(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::<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(response) = handle_response(&mut requests, incomplete).await {
let handle = handle.clone();
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint);
drop(handle)
let editor_view = compositor.find::<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(response);
if completion.is_empty() {
editor_view.clear_completion(editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
trigger_auto_completion(&editor.handlers.completions, editor, false);
}
})
.await
});
.await;
}
}
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
incomplete_completion_lists: HashMap<CompletionProvider, i8>,
trigger: Trigger,
savepoint: Arc<SavePoint>,
) {
@ -321,7 +100,14 @@ fn show_completion(
return;
}
let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
let completion_area = ui.set_completion(
editor,
savepoint,
items,
incomplete_completion_lists,
trigger.pos,
size,
);
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
@ -395,18 +181,21 @@ pub fn trigger_auto_completion(
}
}
fn update_completions(cx: &mut commands::Context, c: Option<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 let Some(ui) = &mut editor_view.completion {
ui.update_filter(c);
if ui.is_empty() || c.is_some_and(|c| !char_is_word(c)) {
editor_view.clear_completion(cx.editor);
// clearing completions might mean we want to immediately rerequest them (usually
// this occurs if typing a trigger char)
if c.is_some() {
trigger_auto_completion(&cx.editor.handlers.completions, cx.editor, false);
}
} else {
let handle = ui.incomplete_list_controller.restart();
request_incomplete_completion_list(cx.editor, ui, handle)
}
}
}))
@ -422,7 +211,7 @@ fn clear_completions(cx: &mut commands::Context) {
fn completion_post_command_hook(
tx: &Sender<CompletionEvent>,
PostCommand { command, cx }: &mut PostCommand<'_, '_>,
) -> anyhow::Result<()> {
) -> Result<()> {
if cx.editor.mode == Mode::Insert {
if cx.editor.last_completion.is_some() {
match command {
@ -433,7 +222,7 @@ fn completion_post_command_hook(
MappableCommand::Static {
name: "delete_char_backward",
..
} => update_completions(cx, None),
} => update_completion_filter(cx, None),
_ => clear_completions(cx),
}
} else {
@ -483,7 +272,7 @@ pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.completions.clone();
register_hook!(move |event: &mut PostInsertChar<'_, '_>| {
if event.cx.editor.last_completion.is_some() {
update_completions(event.cx, Some(event.c))
update_completion_filter(event.cx, Some(event.c))
} else {
trigger_auto_completion(&tx, event.cx.editor, false);
}

View File

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

@ -5,22 +5,21 @@
str::FromStr as _,
};
use futures_util::{future::BoxFuture, FutureExt as _};
use helix_core as core;
use helix_core::Transaction;
use helix_core::{self as core, completion::CompletionProvider};
use helix_event::TaskHandle;
use helix_stdx::path::{self, canonicalize, fold_home_dir, get_path_suffix};
use helix_view::Document;
use url::Url;
use super::item::CompletionItem;
use crate::handlers::completion::{item::CompletionResponse, CompletionItem, CompletionItems};
pub(crate) fn path_completion(
cursor: usize,
text: core::Rope,
doc: &Document,
handle: TaskHandle,
) -> Option<BoxFuture<'static, anyhow::Result<Vec<CompletionItem>>>> {
) -> Option<impl Fn() -> CompletionResponse> {
if !doc.path_completion_enabled() {
return None;
}
@ -67,12 +66,19 @@ pub(crate) fn path_completion(
return None;
}
let future = tokio::task::spawn_blocking(move || {
// TODO: handle properly in the future
const PRIORITY: i8 = 1;
let future = move || {
let Ok(read_dir) = std::fs::read_dir(&dir_path) else {
return Vec::new();
return CompletionResponse {
items: CompletionItems::Other(Vec::new()),
incomplete: false,
provider: CompletionProvider::PathCompletions,
priority: PRIORITY, // TODO: hand
};
};
read_dir
let res: Vec<_> = read_dir
.filter_map(Result::ok)
.filter_map(|dir_entry| {
dir_entry
@ -103,12 +109,19 @@ pub(crate) fn path_completion(
label: file_name.into(),
transaction,
documentation,
provider: CompletionProvider::PathCompletions,
}))
})
.collect::<Vec<_>>()
});
.collect();
CompletionResponse {
items: CompletionItems::Other(res),
incomplete: false,
provider: CompletionProvider::PathCompletions,
priority: PRIORITY, // TODO: hand
}
};
Some(async move { Ok(future.await?) }.boxed())
Some(future)
}
#[cfg(unix)]

View File

@ -0,0 +1,373 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::Future;
use helix_core::completion::CompletionProvider;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{cancelable_future, TaskController, TaskHandle};
use helix_lsp::lsp;
use helix_lsp::lsp::{CompletionContext, CompletionTriggerKind};
use helix_lsp::util::pos_to_lsp_pos;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::Mode;
use helix_view::handlers::lsp::CompletionEvent;
use helix_view::{Document, DocumentId, Editor, ViewId};
use tokio::task::JoinSet;
use tokio::time::{timeout_at, Instant};
use crate::compositor::Compositor;
use crate::config::Config;
use crate::handlers::completion::item::CompletionResponse;
use crate::handlers::completion::path::path_completion;
use crate::handlers::completion::{
handle_response, replace_completions, show_completion, CompletionItems,
};
use crate::job::{dispatch, dispatch_blocking};
use crate::ui;
use crate::ui::editor::InsertEvent;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(super) enum TriggerKind {
Auto,
TriggerChar,
Manual,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Trigger {
pub(super) pos: usize,
pub(super) view: ViewId,
pub(super) doc: DocumentId,
pub(super) kind: TriggerKind,
}
#[derive(Debug)]
pub struct CompletionHandler {
/// currently active trigger which will cause a
/// completion request after the timeout
trigger: Option<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,
} => {
// 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::<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 LS if we type fast enougn? Yes that is true but it's
// not actually a problem. The LSP will resolve the completion to the identifier
// anyway (in fact sending the later position is necessary to get the right results
// from LSPs that provide incomplete completion list). We rely on trigger offset
// and primary cursor matching for multi-cursor completions so this is definitely
// necessary from our side too.
trigger.pos = cursor;
let trigger_text = text.slice(..cursor);
let mut seen_language_servers = HashSet::new();
let language_servers: Vec<_> = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.filter(|ls| seen_language_servers.insert(ls.id()))
.collect();
let mut requests = JoinSet::new();
for (priority, ls) in language_servers.iter().enumerate() {
let context = if trigger.kind == TriggerKind::Manual {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
} else {
let trigger_char =
ls.capabilities()
.completion_provider
.as_ref()
.and_then(|provider| {
provider
.trigger_characters
.as_deref()?
.iter()
.find(|&trigger| trigger_text.ends_with(trigger))
});
if trigger_char.is_some() {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
} else {
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::INVOKED,
trigger_character: None,
}
}
};
requests.spawn(request_completions_from_language_server(
ls,
doc,
view.id,
context,
-(priority as i8),
));
}
if let Some(path_completion_request) =
path_completion(cursor, text.clone(), doc, handle.clone())
{
requests.spawn_blocking(path_completion_request);
}
let savepoint = doc.savepoint(view);
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
let handle_ = handle.clone();
let request_completions = async move {
let mut incomplete_completion_lists = HashMap::new();
let Some(response) = handle_response(&mut requests, false).await else {
return;
};
if response.incomplete {
incomplete_completion_lists.insert(response.provider, response.priority);
}
let mut items: Vec<_> = Vec::new();
response.into_items(&mut items);
let deadline = Instant::now() + Duration::from_millis(100);
loop {
let Some(response) = timeout_at(deadline, handle_response(&mut requests, false))
.await
.ok()
.flatten()
else {
break;
};
if response.incomplete {
incomplete_completion_lists.insert(response.provider, response.priority);
}
response.into_items(&mut items);
}
dispatch(move |editor, compositor| {
show_completion(
editor,
compositor,
items,
incomplete_completion_lists,
trigger,
savepoint,
)
})
.await;
if !requests.is_empty() {
replace_completions(handle_, requests, false).await;
}
};
tokio::spawn(cancelable_future(request_completions, handle));
}
fn request_completions_from_language_server(
ls: &helix_lsp::Client,
doc: &Document,
view: ViewId,
context: lsp::CompletionContext,
priority: i8,
) -> impl Future<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 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<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, incomplete) = match response {
Some(lsp::CompletionResponse::Array(items)) => (items, false),
Some(lsp::CompletionResponse::List(lsp::CompletionList {
is_incomplete,
items,
})) => (items, is_incomplete),
None => (Vec::new(), false),
};
items.sort_by(|item1, item2| {
let sort_text1 = item1.sort_text.as_deref().unwrap_or(&item1.label);
let sort_text2 = item2.sort_text.as_deref().unwrap_or(&item2.label);
sort_text1.cmp(sort_text2)
});
CompletionResponse {
items: CompletionItems::Lsp(items),
incomplete,
provider: CompletionProvider::Lsp(provider),
priority,
}
}
}
pub fn request_incomplete_completion_list(
editor: &mut Editor,
ui: &mut ui::Completion,
handle: TaskHandle,
) {
if ui.incomplete_completion_lists.is_empty() {
return;
}
let (view, doc) = current_ref!(editor);
let mut requests = JoinSet::new();
log::error!("request incomplete completions");
ui.incomplete_completion_lists
.retain(|&provider, &mut priority| {
let CompletionProvider::Lsp(ls_id) = provider else {
unimplemented!("non-lsp incomplete completion lists")
};
let Some(ls) = editor.language_server_by_id(ls_id) else {
return false;
};
log::error!("request incomplete completions2");
let request = request_completions_from_language_server(
ls,
doc,
view.id,
CompletionContext {
trigger_kind: CompletionTriggerKind::TRIGGER_FOR_INCOMPLETE_COMPLETIONS,
trigger_character: None,
},
priority,
);
requests.spawn(request);
true
});
tokio::spawn(replace_completions(handle, requests, true));
}

View File

@ -1,10 +1,11 @@
use crate::{
compositor::{Component, Context, Event, EventResult},
handlers::{
completion::{CompletionItem, LspCompletionItem, ResolveHandler},
trigger_auto_completion,
handlers::completion::{
trigger_auto_completion, CompletionItem, CompletionResponse, LspCompletionItem,
ResolveHandler,
},
};
use helix_event::TaskController;
use helix_view::{
document::SavePoint,
editor::CompleteAction,
@ -12,12 +13,18 @@
theme::{Modifier, Style},
ViewId,
};
use nucleo::{
pattern::{Atom, AtomKind, CaseMatching, Normalization},
Config, Utf32Str,
};
use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc};
use std::{cmp::Reverse, collections::HashMap, sync::Arc};
use helix_core::{
self as core, chars,
completion::CompletionProvider,
fuzzy::MATCHER,
snippets::{ActiveSnippet, RenderedSnippet, Snippet},
Change, Transaction,
};
@ -29,22 +36,6 @@
impl menu::Item for CompletionItem {
type Data = ();
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, _data: &Self::Data) -> menu::Row {
let deprecated = match self {
@ -119,6 +110,9 @@ pub struct Completion {
trigger_offset: usize,
filter: String,
resolve_handler: ResolveHandler,
pub incomplete_completion_lists: HashMap<CompletionProvider, i8>,
// controller for requesting updates for incomplete completion lists
pub incomplete_list_controller: TaskController,
}
impl Completion {
@ -127,13 +121,12 @@ impl Completion {
pub fn new(
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
items: Vec<CompletionItem>,
incomplete_completion_lists: HashMap<CompletionProvider, i8>,
trigger_offset: usize,
) -> Self {
let preview_completion_insert = editor.config().preview_completion_insert;
let replace_mode = editor.config().completion_replace;
// Sort completion items according to their preselect status (given by the LSP server)
items.sort_by_key(|item| !item.preselect());
// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
@ -309,17 +302,77 @@ macro_rules! language_server {
// and avoid allocation during matching
filter: String::from(fragment),
resolve_handler: ResolveHandler::new(),
incomplete_completion_lists,
incomplete_list_controller: TaskController::new(),
};
// need to recompute immediately in case start_offset != trigger_offset
completion
.popup
.contents_mut()
.score(&completion.filter, false);
completion.score(false);
completion
}
fn score(&mut self, incremental: bool) {
let pattern = &self.filter;
let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT;
// slight preference towards prefix matches
matcher.config.prefer_prefix = true;
let pattern = Atom::new(
pattern,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
);
let mut buf = Vec::new();
let (matches, options) = self.popup.contents_mut().update_options();
if incremental {
matches.retain_mut(|(index, score)| {
let option = &options[*index as usize];
let text = option.filter_text();
let new_score = pattern.score(Utf32Str::new(text, &mut buf), &mut matcher);
match new_score {
Some(new_score) => {
*score = new_score as u32 / 2;
true
}
None => false,
}
})
} else {
matches.clear();
matches.extend(options.iter().enumerate().filter_map(|(i, option)| {
let text = option.filter_text();
pattern
.score(Utf32Str::new(text, &mut buf), &mut matcher)
.map(|score| (i as u32, score as u32 / 3))
}));
}
// nuclueo is meant as an fzf-like fuzzy matcher and only hides
// matches that are truely impossible (as in the sequence of char
// just doens't appeart) that doesn't work well for completions
// with multi lsps where all completions of the next lsp are below
// the current one (so you would good suggestions from the second lsp below those
// of the first). Setting a reasonable cutoff below which to move
// bad completions out of the way helps with that.
//
// The score computation is a heuristic dervied from nucleo internal
// constants and may move upstream in the future. I want to test this out
// here to settle on a good number
let min_score = (7 + pattern.needle_text().len() as u32 * 14) / 3;
matches.sort_unstable_by_key(|&(i, score)| {
let option = &options[i as usize];
(
score <= min_score,
Reverse(option.preselect()),
option.provider_priority(),
Reverse(score),
i,
)
});
}
/// Synchronously resolve the given completion item. This is used when
/// accepting a completion.
fn resolve_completion_item(
@ -361,7 +414,28 @@ pub fn update_filter(&mut self, c: Option<char>) {
}
}
}
menu.score(&self.filter, c.is_some());
self.score(c.is_some());
self.popup.contents_mut().reset_cursor();
}
pub fn replace_provider_completions(&mut self, response: CompletionResponse) {
let menu = self.popup.contents_mut();
let (_, options) = menu.update_options();
if self
.incomplete_completion_lists
.remove(&response.provider)
.is_some()
{
options.retain(|item| item.provider() != response.provider)
}
if response.incomplete {
self.incomplete_completion_lists
.insert(response.provider, response.priority);
}
response.into_items(options);
self.score(false);
let menu = self.popup.contents_mut();
menu.ensure_cursor_in_bounds();
}
pub fn is_empty(&self) -> bool {

View File

@ -14,6 +14,7 @@
};
use helix_core::{
completion::CompletionProvider,
diagnostic::NumberOrString,
graphemes::{next_grapheme_boundary, prev_grapheme_boundary},
movement::Direction,
@ -31,7 +32,7 @@
keyboard::{KeyCode, KeyModifiers},
Document, Editor, Theme, View,
};
use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use std::{collections::HashMap, mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use tui::{buffer::Buffer as Surface, text::Span};
@ -1057,10 +1058,17 @@ pub fn set_completion(
editor: &mut Editor,
savepoint: Arc<SavePoint>,
items: Vec<CompletionItem>,
incomplete_completion_lists: HashMap<CompletionProvider, i8>,
trigger_offset: usize,
size: Rect,
) -> Option<Rect> {
let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
let mut completion = Completion::new(
editor,
savepoint,
items,
incomplete_completion_lists,
trigger_offset,
);
if completion.is_empty() {
// skip if we got no completion results

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 @@ pub fn new(
}
}
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

@ -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"] }

View File

@ -1435,16 +1435,12 @@ fn apply_impl(
// TODO: move to hook
// emit lsp notification
for language_server in self.language_servers() {
let notify = language_server.text_document_did_change(
let _ = language_server.text_document_did_change(
self.versioned_identifier(),
&old_doc,
self.text(),
changes,
);
if let Some(notify) = notify {
tokio::spawn(notify);
}
}
}
@ -1761,6 +1757,25 @@ pub fn language_servers_with_feature(
})
}
pub fn language_servers_with_feature_owned(
&self,
feature: LanguageServerFeature,
) -> impl Iterator<Item = Arc<helix_lsp::Client>> + '_ {
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)
}