refactor completion and signature help using hooks

This commit is contained in:
Pascal Kuthe 2023-12-01 00:03:27 +01:00 committed by Blaž Hrastnik
parent 13ed4f6c47
commit 8e592a151f
19 changed files with 1022 additions and 560 deletions

1
Cargo.lock generated
View file

@ -1165,6 +1165,7 @@ version = "23.10.0"
dependencies = [ dependencies = [
"dunce", "dunce",
"etcetera", "etcetera",
"ropey",
"tempfile", "tempfile",
] ]

View file

@ -51,7 +51,8 @@ Its settings will be merged with the configuration directory `config.toml` and t
| `auto-completion` | Enable automatic pop up of auto-completion | `true` | | `auto-completion` | Enable automatic pop up of auto-completion | `true` |
| `auto-format` | Enable automatic formatting on save | `true` | | `auto-format` | Enable automatic formatting on save | `true` |
| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` | | `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `250` | | `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. | `250` |
| `completion-timeout` | Time in milliseconds after typing a word character before completions are shown, set to 5 for instant. | `250` |
| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` | | `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` | | `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` | | `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |

View file

@ -9,7 +9,7 @@ use helix_loader::{self, VERSION_AND_GIT_HASH};
use helix_stdx::path; use helix_stdx::path;
use lsp::{ use lsp::{
notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport, notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport,
DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, WorkspaceFolder, DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, WorkspaceFolder,
WorkspaceFoldersChangeEvent, WorkspaceFoldersChangeEvent,
}; };
use lsp_types as lsp; use lsp_types as lsp;
@ -999,6 +999,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
context: lsp::CompletionContext,
) -> Option<impl Future<Output = Result<Value>>> { ) -> Option<impl Future<Output = Result<Value>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
@ -1010,13 +1011,12 @@ impl Client {
text_document, text_document,
position, position,
}, },
context: Some(context),
// TODO: support these tokens by async receiving and updating the choice list // TODO: support these tokens by async receiving and updating the choice list
work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token }, work_done_progress_params: lsp::WorkDoneProgressParams { work_done_token },
partial_result_params: lsp::PartialResultParams { partial_result_params: lsp::PartialResultParams {
partial_result_token: None, partial_result_token: None,
}, },
context: None,
// lsp::CompletionContext { trigger_kind: , trigger_character: Some(), }
}; };
Some(self.call::<lsp::request::Completion>(params)) Some(self.call::<lsp::request::Completion>(params))
@ -1063,7 +1063,7 @@ impl Client {
text_document: lsp::TextDocumentIdentifier, text_document: lsp::TextDocumentIdentifier,
position: lsp::Position, position: lsp::Position,
work_done_token: Option<lsp::ProgressToken>, work_done_token: Option<lsp::ProgressToken>,
) -> Option<impl Future<Output = Result<Value>>> { ) -> Option<impl Future<Output = Result<Option<SignatureHelp>>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.capabilities.get().unwrap();
// Return early if the server does not support signature help. // Return early if the server does not support signature help.
@ -1079,7 +1079,8 @@ impl Client {
// lsp::SignatureHelpContext // lsp::SignatureHelpContext
}; };
Some(self.call::<lsp::request::SignatureHelpRequest>(params)) let res = self.call::<lsp::request::SignatureHelpRequest>(params);
Some(async move { Ok(serde_json::from_value(res.await?)?) })
} }
pub fn text_document_range_inlay_hints( pub fn text_document_range_inlay_hints(

View file

@ -14,6 +14,7 @@ homepage.workspace = true
[dependencies] [dependencies]
dunce = "1.0" dunce = "1.0"
etcetera = "0.8" etcetera = "0.8"
ropey = { version = "1.6.1", default-features = false }
[dev-dependencies] [dev-dependencies]
tempfile = "3.9" tempfile = "3.9"

View file

@ -1,2 +1,3 @@
pub mod env; pub mod env;
pub mod path; pub mod path;
pub mod rope;

26
helix-stdx/src/rope.rs Normal file
View file

@ -0,0 +1,26 @@
use ropey::RopeSlice;
pub trait RopeSliceExt: Sized {
fn ends_with(self, text: &str) -> bool;
fn starts_with(self, text: &str) -> bool;
}
impl RopeSliceExt for RopeSlice<'_> {
fn ends_with(self, text: &str) -> bool {
let len = self.len_bytes();
if len < text.len() {
return false;
}
self.get_byte_slice(len - text.len()..)
.map_or(false, |end| end == text)
}
fn starts_with(self, text: &str) -> bool {
let len = self.len_bytes();
if len < text.len() {
return false;
}
self.get_byte_slice(..len - text.len())
.map_or(false, |start| start == text)
}
}

View file

@ -1,10 +1,6 @@
use arc_swap::{access::Map, ArcSwap}; use arc_swap::{access::Map, ArcSwap};
use futures_util::Stream; use futures_util::Stream;
use helix_core::{ use helix_core::{diagnostic::Severity, pos_at_coords, syntax, Selection};
chars::char_is_word,
diagnostic::{DiagnosticTag, NumberOrString},
pos_at_coords, syntax, Selection,
};
use helix_lsp::{ use helix_lsp::{
lsp::{self, notification::Notification}, lsp::{self, notification::Notification},
util::lsp_range_to_range, util::lsp_range_to_range,

View file

@ -5,7 +5,6 @@ pub(crate) mod typed;
pub use dap::*; pub use dap::*;
use helix_vcs::Hunk; use helix_vcs::Hunk;
pub use lsp::*; pub use lsp::*;
use tokio::sync::oneshot;
use tui::widgets::Row; use tui::widgets::Row;
pub use typed::*; pub use typed::*;
@ -33,7 +32,7 @@ use helix_core::{
}; };
use helix_view::{ use helix_view::{
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
editor::{Action, CompleteAction}, editor::Action,
info::Info, info::Info,
input::KeyEvent, input::KeyEvent,
keyboard::KeyCode, keyboard::KeyCode,
@ -52,14 +51,10 @@ use crate::{
filter_picker_entry, filter_picker_entry,
job::Callback, job::Callback,
keymap::ReverseKeymap, keymap::ReverseKeymap,
ui::{ ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent},
self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker,
Popup, Prompt, PromptEvent,
},
}; };
use crate::job::{self, Jobs}; use crate::job::{self, Jobs};
use futures_util::{stream::FuturesUnordered, TryStreamExt};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fmt, fmt,
@ -2593,7 +2588,6 @@ fn delete_by_selection_insert_mode(
); );
} }
doc.apply(&transaction, view.id); doc.apply(&transaction, view.id);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
} }
fn delete_selection(cx: &mut Context) { fn delete_selection(cx: &mut Context) {
@ -2667,10 +2661,6 @@ fn insert_mode(cx: &mut Context) {
.transform(|range| Range::new(range.to(), range.from())); .transform(|range| Range::new(range.to(), range.from()));
doc.set_selection(view.id, selection); doc.set_selection(view.id, selection);
// [TODO] temporary workaround until we're not using the idle timer to
// trigger auto completions any more
cx.editor.clear_idle_timer();
} }
// inserts at the end of each selection // inserts at the end of each selection
@ -3497,9 +3487,9 @@ fn hunk_range(hunk: Hunk, text: RopeSlice) -> Range {
pub mod insert { pub mod insert {
use crate::events::PostInsertChar; use crate::events::PostInsertChar;
use super::*; use super::*;
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>; pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
pub type PostHook = fn(&mut Context, char);
/// Exclude the cursor in range. /// Exclude the cursor in range.
fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range {
@ -3513,88 +3503,6 @@ pub mod insert {
} }
} }
// It trigger completion when idle timer reaches deadline
// Only trigger completion if the word under cursor is longer than n characters
pub fn idle_completion(cx: &mut Context) {
let config = cx.editor.config();
let (view, doc) = current!(cx.editor);
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
use helix_core::chars::char_is_word;
let mut iter = text.chars_at(cursor);
iter.reverse();
for _ in 0..config.completion_trigger_len {
match iter.next() {
Some(c) if char_is_word(c) => {}
_ => return,
}
}
super::completion(cx);
}
fn language_server_completion(cx: &mut Context, ch: char) {
let config = cx.editor.config();
if !config.auto_completion {
return;
}
use helix_lsp::lsp;
// if ch matches completion char, trigger completion
let doc = doc_mut!(cx.editor);
let trigger_completion = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.any(|ls| {
// TODO: what if trigger is multiple chars long
matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
trigger_characters: Some(triggers),
..
}) if triggers.iter().any(|trigger| trigger.contains(ch)))
});
if trigger_completion {
cx.editor.clear_idle_timer();
super::completion(cx);
}
}
fn signature_help(cx: &mut Context, ch: char) {
use helix_lsp::lsp;
// if ch matches signature_help char, trigger
let doc = doc_mut!(cx.editor);
// TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
let Some(language_server) = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.next()
else {
return;
};
let capabilities = language_server.capabilities();
if let lsp::ServerCapabilities {
signature_help_provider:
Some(lsp::SignatureHelpOptions {
trigger_characters: Some(triggers),
// TODO: retrigger_characters
..
}),
..
} = capabilities
{
// TODO: what if trigger is multiple chars long
let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch));
// lsp doesn't tell us when to close the signature help, so we request
// the help information again after common close triggers which should
// return None, which in turn closes the popup.
let close_triggers = &[')', ';', '.'];
if is_trigger || close_triggers.contains(&ch) {
super::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
}
}
}
// The default insert hook: simply insert the character // The default insert hook: simply insert the character
#[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature
fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> { fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> {
@ -3624,12 +3532,6 @@ pub mod insert {
doc.apply(&t, view.id); doc.apply(&t, view.id);
} }
// TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc)
// this could also generically look at Transaction, but it's a bit annoying to look at
// Operation instead of Change.
for hook in &[language_server_completion, signature_help] {
hook(cx, c);
}
helix_event::dispatch(PostInsertChar { c, cx }); helix_event::dispatch(PostInsertChar { c, cx });
} }
@ -3855,8 +3757,6 @@ pub mod insert {
}); });
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
doc.apply(&transaction, view.id); doc.apply(&transaction, view.id);
lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic);
} }
pub fn delete_char_forward(cx: &mut Context) { pub fn delete_char_forward(cx: &mut Context) {
@ -4510,151 +4410,14 @@ fn remove_primary_selection(cx: &mut Context) {
} }
pub fn completion(cx: &mut Context) { pub fn completion(cx: &mut Context) {
use helix_lsp::{lsp, util::pos_to_lsp_pos};
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
let range = doc.selection(view.id).primary();
let text = doc.text().slice(..);
let cursor = range.cursor(text);
let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion cx.editor
{ .handlers
savepoint.clone() .trigger_completions(cursor, doc.id(), view.id);
} else {
doc.savepoint(view)
};
let text = savepoint.text.clone();
let cursor = savepoint.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(|language_server| {
let language_server_id = language_server.id();
let offset_encoding = language_server.offset_encoding();
let pos = pos_to_lsp_pos(&text, cursor, offset_encoding);
let doc_id = doc.identifier();
let completion_request = language_server.completion(doc_id, pos, None).unwrap();
async move {
let json = completion_request.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 {
item,
language_server_id,
resolved: false,
})
.collect();
anyhow::Ok(items)
}
})
.collect();
// setup a channel that allows the request to be canceled
let (tx, rx) = oneshot::channel();
// set completion_request so that this request can be canceled
// by setting completion_request, the old channel stored there is dropped
// and the associated request is automatically dropped
cx.editor.completion_request_handle = Some(tx);
let future = async move {
let items_future = async move {
let mut items = Vec::new();
// TODO if one completion request errors, all other completion requests are discarded (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? {
items.append(&mut lsp_items);
}
anyhow::Ok(items)
};
tokio::select! {
biased;
_ = rx => {
Ok(Vec::new())
}
res = items_future => {
res
}
}
};
let trigger_offset = cursor;
// TODO: trigger_offset should be the cursor offset but we also need a starting offset from where we want to apply
// completion filtering. For example logger.te| should filter the initial suggestion list with "te".
use helix_core::chars;
let mut iter = text.chars_at(cursor);
iter.reverse();
let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
let start_offset = cursor.saturating_sub(offset);
let trigger_doc = doc.id();
let trigger_view = view.id;
// FIXME: The commands Context can only have a single callback
// which means it gets overwritten when executing keybindings
// with multiple commands or macros. This would mean that completion
// might be incorrectly applied when repeating the insertmode action
//
// TODO: to solve this either make cx.callback a Vec of callbacks or
// alternatively move `last_insert` to `helix_view::Editor`
cx.callback = Some(Box::new(
move |compositor: &mut Compositor, _cx: &mut compositor::Context| {
let ui = compositor.find::<ui::EditorView>().unwrap();
ui.last_insert.1.push(InsertEvent::RequestCompletion);
},
));
cx.jobs.callback(async move {
let items = future.await?;
let call = move |editor: &mut Editor, compositor: &mut Compositor| {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
//
// Completions are completed asynchronously and therefore the user could
//switch document/view or leave insert mode. In all of thoise cases the
// completion should be discarded
if editor.mode != Mode::Insert || view.id != trigger_view || doc.id() != trigger_doc {
return;
}
if items.is_empty() {
// editor.set_error("No completion available");
return;
}
let size = compositor.size();
let ui = compositor.find::<ui::EditorView>().unwrap();
let completion_area = ui.set_completion(
editor,
savepoint,
items,
start_offset,
trigger_offset,
size,
);
let size = compositor.size();
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
// Delete the signature help popup if they intersect.
if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b))
{
compositor.remove(SignatureHelp::ID);
}
};
Ok(Callback::EditorCompositor(Box::new(call)))
});
} }
// comments // comments
@ -4833,10 +4596,6 @@ fn move_node_bound_impl(cx: &mut Context, dir: Direction, movement: Movement) {
); );
doc.set_selection(view.id, selection); doc.set_selection(view.id, selection);
// [TODO] temporary workaround until we're not using the idle timer to
// trigger auto completions any more
editor.clear_idle_timer();
} }
}; };

View file

@ -1,4 +1,4 @@
use futures_util::{future::BoxFuture, stream::FuturesUnordered, FutureExt}; use futures_util::{stream::FuturesUnordered, FutureExt};
use helix_lsp::{ use helix_lsp::{
block_on, block_on,
lsp::{ lsp::{
@ -8,21 +8,21 @@ use helix_lsp::{
util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range}, util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
Client, OffsetEncoding, Client, OffsetEncoding,
}; };
use serde_json::Value;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tui::{ use tui::{
text::{Span, Spans}, text::{Span, Spans},
widgets::Row, widgets::Row,
}; };
use super::{align_view, push_jump, Align, Context, Editor, Open}; use super::{align_view, push_jump, Align, Context, Editor};
use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection}; use helix_core::{syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection};
use helix_stdx::path; use helix_stdx::path;
use helix_view::{ use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId, Mode}, document::{DocumentInlayHints, DocumentInlayHintsId},
editor::Action, editor::Action,
graphics::Margin, graphics::Margin,
handlers::lsp::SignatureHelpInvoked,
theme::Style, theme::Style,
Document, View, Document, View,
}; };
@ -30,10 +30,7 @@ use helix_view::{
use crate::{ use crate::{
compositor::{self, Compositor}, compositor::{self, Compositor},
job::Callback, job::Callback,
ui::{ ui::{self, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup, PromptEvent},
self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup,
PromptEvent,
},
}; };
use std::{ use std::{
@ -42,7 +39,6 @@ use std::{
fmt::Write, fmt::Write,
future::Future, future::Future,
path::PathBuf, path::PathBuf,
sync::Arc,
}; };
/// Gets the first language server that is attached to a document which supports a specific feature. /// Gets the first language server that is attached to a document which supports a specific feature.
@ -1132,146 +1128,10 @@ pub fn goto_reference(cx: &mut Context) {
); );
} }
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum SignatureHelpInvoked {
Manual,
Automatic,
}
pub fn signature_help(cx: &mut Context) { pub fn signature_help(cx: &mut Context) {
signature_help_impl(cx, SignatureHelpInvoked::Manual) cx.editor
} .handlers
.trigger_signature_help(SignatureHelpInvoked::Manual, cx.editor)
pub fn signature_help_impl(cx: &mut Context, invoked: SignatureHelpInvoked) {
let (view, doc) = current!(cx.editor);
// TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
let future = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.find_map(|language_server| {
let pos = doc.position(view.id, language_server.offset_encoding());
language_server.text_document_signature_help(doc.identifier(), pos, None)
});
let Some(future) = future else {
// Do not show the message if signature help was invoked
// automatically on backspace, trigger characters, etc.
if invoked == SignatureHelpInvoked::Manual {
cx.editor
.set_error("No configured language server supports signature-help");
}
return;
};
signature_help_impl_with_future(cx, future.boxed(), invoked);
}
pub fn signature_help_impl_with_future(
cx: &mut Context,
future: BoxFuture<'static, helix_lsp::Result<Value>>,
invoked: SignatureHelpInvoked,
) {
cx.callback(
future,
move |editor, compositor, response: Option<lsp::SignatureHelp>| {
let config = &editor.config();
if !(config.lsp.auto_signature_help
|| SignatureHelp::visible_popup(compositor).is_some()
|| invoked == SignatureHelpInvoked::Manual)
{
return;
}
// If the signature help invocation is automatic, don't show it outside of Insert Mode:
// it very probably means the server was a little slow to respond and the user has
// already moved on to something else, making a signature help popup will just be an
// annoyance, see https://github.com/helix-editor/helix/issues/3112
if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
return;
}
let response = match response {
// According to the spec the response should be None if there
// are no signatures, but some servers don't follow this.
Some(s) if !s.signatures.is_empty() => s,
_ => {
compositor.remove(SignatureHelp::ID);
return;
}
};
let doc = doc!(editor);
let language = doc.language_name().unwrap_or("");
let signature = match response
.signatures
.get(response.active_signature.unwrap_or(0) as usize)
{
Some(s) => s,
None => return,
};
let mut contents = SignatureHelp::new(
signature.label.clone(),
language.to_string(),
Arc::clone(&editor.syn_loader),
);
let signature_doc = if config.lsp.display_signature_help_docs {
signature.documentation.as_ref().map(|doc| match doc {
lsp::Documentation::String(s) => s.clone(),
lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
})
} else {
None
};
contents.set_signature_doc(signature_doc);
let active_param_range = || -> Option<(usize, usize)> {
let param_idx = signature
.active_parameter
.or(response.active_parameter)
.unwrap_or(0) as usize;
let param = signature.parameters.as_ref()?.get(param_idx)?;
match &param.label {
lsp::ParameterLabel::Simple(string) => {
let start = signature.label.find(string.as_str())?;
Some((start, start + string.len()))
}
lsp::ParameterLabel::LabelOffsets([start, end]) => {
// LS sends offsets based on utf-16 based string representation
// but highlighting in helix is done using byte offset.
use helix_core::str_utils::char_to_byte_idx;
let from = char_to_byte_idx(&signature.label, *start as usize);
let to = char_to_byte_idx(&signature.label, *end as usize);
Some((from, to))
}
}
};
contents.set_active_param_range(active_param_range());
let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
let mut popup = Popup::new(SignatureHelp::ID, contents)
.position(old_popup.and_then(|p| p.get_position()))
.position_bias(Open::Above)
.ignore_escape_key(true);
// Don't create a popup if it intersects the auto-complete menu.
let size = compositor.size();
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.as_mut()
.map(|completion| completion.area(size, editor))
.filter(|area| area.intersects(popup.area(size, editor)))
.is_some()
{
return;
}
compositor.replace_or_push(SignatureHelp::ID, popup);
},
);
} }
pub fn hover(cx: &mut Context) { pub fn hover(cx: &mut Context) {

View file

@ -1,15 +1,30 @@
use std::sync::Arc; use std::sync::Arc;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use helix_event::AsyncHook;
use crate::config::Config; use crate::config::Config;
use crate::events; use crate::events;
use crate::handlers::completion::CompletionHandler;
use crate::handlers::signature_help::SignatureHelpHandler;
pub use completion::trigger_auto_completion;
pub use helix_view::handlers::lsp::SignatureHelpInvoked;
pub use helix_view::handlers::Handlers;
mod completion;
mod signature_help;
}
pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers { pub fn setup(config: Arc<ArcSwap<Config>>) -> Handlers {
events::register(); events::register();
let completions = CompletionHandler::new(config).spawn();
let signature_hints = SignatureHelpHandler::new().spawn();
let handlers = Handlers { let handlers = Handlers {
completions,
signature_hints,
}; };
completion::register_hooks(&handlers);
signature_help::register_hooks(&handlers);
handlers handlers
} }

View file

@ -0,0 +1,465 @@
use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use arc_swap::ArcSwap;
use futures_util::stream::FuturesUnordered;
use helix_core::chars::char_is_word;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
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 tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use tokio_stream::StreamExt;
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::keymap::MappableCommand;
use crate::ui::editor::InsertEvent;
use crate::ui::lsp::SignatureHelp;
use crate::ui::{self, CompletionItem, Popup};
use super::Handlers;
#[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>,
/// A handle for currently active completion request.
/// This can be used to determine whether the current
/// request is still active (and new triggers should be
/// ignored) and can also be used to abort the current
/// request (by dropping the handle)
request: Option<CancelTx>,
config: Arc<ArcSwap<Config>>,
}
impl CompletionHandler {
pub fn new(config: Arc<ArcSwap<Config>>) -> CompletionHandler {
Self {
config,
request: None,
trigger: None,
}
}
}
impl helix_event::AsyncHook for CompletionHandler {
type Event = CompletionEvent;
fn handle_event(
&mut self,
event: Self::Event,
_old_timeout: Option<Instant>,
) -> Option<Instant> {
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
.as_ref()
.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.request = None;
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.request = None;
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.request = None;
}
CompletionEvent::DeleteText { cursor } => {
// if we deleted the original trigger, abort the completion
if matches!(self.trigger, Some(Trigger{ pos, .. }) if cursor < pos) {
self.trigger = None;
self.request = None;
}
}
}
self.trigger.map(|trigger| {
// if the current request was closed forget about it
// otherwise immediately restart the completion request
let cancel = self.request.take().map_or(false, |req| !req.is_closed());
let timeout = if trigger.kind == TriggerKind::Auto && !cancel {
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");
let (tx, rx) = cancelation();
self.request = Some(tx);
dispatch_blocking(move |editor, compositor| {
request_completion(trigger, rx, editor, compositor)
});
}
}
fn request_completion(
mut trigger: Trigger,
cancel: CancelRx,
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 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))
});
lsp::CompletionContext {
trigger_kind: lsp::CompletionTriggerKind::TRIGGER_CHARACTER,
trigger_character: trigger_char.cloned(),
}
};
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 {
item,
language_server_id,
resolved: false,
})
.collect();
anyhow::Ok(items)
}
})
.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, cancel).await.unwrap_or_default();
if items.is_empty() {
return;
}
dispatch(move |editor, compositor| {
show_completion(editor, compositor, items, trigger, savepoint)
})
.await
});
}
fn show_completion(
editor: &mut Editor,
compositor: &mut Compositor,
items: Vec<CompletionItem>,
trigger: Trigger,
savepoint: Arc<SavePoint>,
) {
let (view, doc) = current_ref!(editor);
// check if the completion request is stale.
//
// Completions are completed asynchronously and therefore the user could
//switch document/view or leave insert mode. In all of thoise cases the
// completion should be discarded
if editor.mode != Mode::Insert || view.id != trigger.view || doc.id() != trigger.doc {
return;
}
let size = compositor.size();
let ui = compositor.find::<ui::EditorView>().unwrap();
if ui.completion.is_some() {
return;
}
let completion_area = ui.set_completion(editor, savepoint, items, trigger.pos, size);
let signature_help_area = compositor
.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID)
.map(|signature_help| signature_help.area(size, editor));
// Delete the signature help popup if they intersect.
if matches!((completion_area, signature_help_area),(Some(a), Some(b)) if a.intersects(b)) {
compositor.remove(SignatureHelp::ID);
}
}
pub fn trigger_auto_completion(
tx: &Sender<CompletionEvent>,
editor: &Editor,
trigger_char_only: bool,
) {
let config = editor.config.load();
if !config.auto_completion {
return;
}
let (view, doc): (&helix_view::View, &helix_view::Document) = current_ref!(editor);
let mut text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
text = doc.text().slice(..cursor);
let is_trigger_char = doc
.language_servers_with_feature(LanguageServerFeature::Completion)
.any(|ls| {
matches!(&ls.capabilities().completion_provider, Some(lsp::CompletionOptions {
trigger_characters: Some(triggers),
..
}) if triggers.iter().any(|trigger| text.ends_with(trigger)))
});
if is_trigger_char {
send_blocking(
tx,
CompletionEvent::TriggerChar {
cursor,
doc: doc.id(),
view: view.id,
},
);
return;
}
let is_auto_trigger = !trigger_char_only
&& doc
.text()
.chars_at(cursor)
.reversed()
.take(config.completion_trigger_len as usize)
.all(char_is_word);
if is_auto_trigger {
send_blocking(
tx,
CompletionEvent::AutoTrigger {
cursor,
doc: doc.id(),
view: view.id,
},
);
}
}
fn update_completions(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() {
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);
}
}
}
}))
}
fn clear_completions(cx: &mut commands::Context) {
cx.callback.push(Box::new(|compositor, cx| {
let editor_view = compositor.find::<ui::EditorView>().unwrap();
editor_view.clear_completion(cx.editor);
}))
}
fn completion_post_command_hook(
tx: &Sender<CompletionEvent>,
PostCommand { command, cx }: &mut PostCommand<'_, '_>,
) -> anyhow::Result<()> {
if cx.editor.mode == Mode::Insert {
if cx.editor.last_completion.is_some() {
match command {
MappableCommand::Static {
name: "delete_word_forward" | "delete_char_forward" | "completion",
..
} => (),
MappableCommand::Static {
name: "delete_char_backward",
..
} => update_completions(cx, None),
_ => clear_completions(cx),
}
} else {
let event = match command {
MappableCommand::Static {
name: "delete_char_backward" | "delete_word_forward" | "delete_char_forward",
..
} => {
let (view, doc) = current!(cx.editor);
let primary_cursor = doc
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
CompletionEvent::DeleteText {
cursor: primary_cursor,
}
}
// hacks: some commands are handeled elsewhere and we don't want to
// cancel in that case
MappableCommand::Static {
name: "completion" | "insert_mode" | "append_mode",
..
} => return Ok(()),
_ => CompletionEvent::Cancel,
};
send_blocking(tx, 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));
let tx = handlers.completions.clone();
register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
if event.old_mode == Mode::Insert {
send_blocking(&tx, CompletionEvent::Cancel);
clear_completions(event.cx);
} else if event.new_mode == Mode::Insert {
trigger_auto_completion(&tx, 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))
} else {
trigger_auto_completion(&tx, event.cx.editor, false);
}
Ok(())
});
}

View file

@ -0,0 +1,335 @@
use std::sync::Arc;
use std::time::Duration;
use helix_core::syntax::LanguageServerFeature;
use helix_event::{
cancelable_future, cancelation, register_hook, send_blocking, CancelRx, CancelTx,
};
use helix_lsp::lsp;
use helix_stdx::rope::RopeSliceExt;
use helix_view::document::Mode;
use helix_view::events::{DocumentDidChange, SelectionDidChange};
use helix_view::handlers::lsp::{SignatureHelpEvent, SignatureHelpInvoked};
use helix_view::Editor;
use tokio::sync::mpsc::Sender;
use tokio::time::Instant;
use crate::commands::Open;
use crate::compositor::Compositor;
use crate::events::{OnModeSwitch, PostInsertChar};
use crate::handlers::Handlers;
use crate::ui::lsp::SignatureHelp;
use crate::ui::Popup;
use crate::{job, ui};
#[derive(Debug)]
enum State {
Open,
Closed,
Pending { request: CancelTx },
}
/// debounce timeout in ms, value taken from VSCode
/// TODO: make this configurable?
const TIMEOUT: u64 = 120;
#[derive(Debug)]
pub(super) struct SignatureHelpHandler {
trigger: Option<SignatureHelpInvoked>,
state: State,
}
impl SignatureHelpHandler {
pub fn new() -> SignatureHelpHandler {
SignatureHelpHandler {
trigger: None,
state: State::Closed,
}
}
}
impl helix_event::AsyncHook for SignatureHelpHandler {
type Event = SignatureHelpEvent;
fn handle_event(
&mut self,
event: Self::Event,
timeout: Option<tokio::time::Instant>,
) -> Option<Instant> {
match event {
SignatureHelpEvent::Invoked => {
self.trigger = Some(SignatureHelpInvoked::Manual);
self.state = State::Closed;
self.finish_debounce();
return None;
}
SignatureHelpEvent::Trigger => {}
SignatureHelpEvent::ReTrigger => {
// don't retrigger if we aren't open/pending yet
if matches!(self.state, State::Closed) {
return timeout;
}
}
SignatureHelpEvent::Cancel => {
self.state = State::Closed;
return None;
}
SignatureHelpEvent::RequestComplete { open } => {
// don't cancel rerequest that was already triggered
if let State::Pending { request } = &self.state {
if !request.is_closed() {
return timeout;
}
}
self.state = if open { State::Open } else { State::Closed };
return timeout;
}
}
if self.trigger.is_none() {
self.trigger = Some(SignatureHelpInvoked::Automatic)
}
Some(Instant::now() + Duration::from_millis(TIMEOUT))
}
fn finish_debounce(&mut self) {
let invocation = self.trigger.take().unwrap();
let (tx, rx) = cancelation();
self.state = State::Pending { request: tx };
job::dispatch_blocking(move |editor, _| request_signature_help(editor, invocation, rx))
}
}
pub fn request_signature_help(
editor: &mut Editor,
invoked: SignatureHelpInvoked,
cancel: CancelRx,
) {
let (view, doc) = current!(editor);
// TODO merge multiple language server signature help into one instead of just taking the first language server that supports it
let future = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.find_map(|language_server| {
let pos = doc.position(view.id, language_server.offset_encoding());
language_server.text_document_signature_help(doc.identifier(), pos, None)
});
let Some(future) = future else {
// Do not show the message if signature help was invoked
// automatically on backspace, trigger characters, etc.
if invoked == SignatureHelpInvoked::Manual {
editor
.set_error("No configured language server supports signature-help");
}
return;
};
tokio::spawn(async move {
match cancelable_future(future, cancel).await {
Some(Ok(res)) => {
job::dispatch(move |editor, compositor| {
show_signature_help(editor, compositor, invoked, res)
})
.await
}
Some(Err(err)) => log::error!("signature help request failed: {err}"),
None => (),
}
});
}
pub fn show_signature_help(
editor: &mut Editor,
compositor: &mut Compositor,
invoked: SignatureHelpInvoked,
response: Option<lsp::SignatureHelp>,
) {
let config = &editor.config();
if !(config.lsp.auto_signature_help
|| SignatureHelp::visible_popup(compositor).is_some()
|| invoked == SignatureHelpInvoked::Manual)
{
return;
}
// If the signature help invocation is automatic, don't show it outside of Insert Mode:
// it very probably means the server was a little slow to respond and the user has
// already moved on to something else, making a signature help popup will just be an
// annoyance, see https://github.com/helix-editor/helix/issues/3112
// For the most part this should not be needed as the request gets canceled automatically now
// but it's technically possible for the mode change to just preempt this callback so better safe than sorry
if invoked == SignatureHelpInvoked::Automatic && editor.mode != Mode::Insert {
return;
}
let response = match response {
// According to the spec the response should be None if there
// are no signatures, but some servers don't follow this.
Some(s) if !s.signatures.is_empty() => s,
_ => {
send_blocking(
&editor.handlers.signature_hints,
SignatureHelpEvent::RequestComplete { open: false },
);
compositor.remove(SignatureHelp::ID);
return;
}
};
send_blocking(
&editor.handlers.signature_hints,
SignatureHelpEvent::RequestComplete { open: true },
);
let doc = doc!(editor);
let language = doc.language_name().unwrap_or("");
let signature = match response
.signatures
.get(response.active_signature.unwrap_or(0) as usize)
{
Some(s) => s,
None => return,
};
let mut contents = SignatureHelp::new(
signature.label.clone(),
language.to_string(),
Arc::clone(&editor.syn_loader),
);
let signature_doc = if config.lsp.display_signature_help_docs {
signature.documentation.as_ref().map(|doc| match doc {
lsp::Documentation::String(s) => s.clone(),
lsp::Documentation::MarkupContent(markup) => markup.value.clone(),
})
} else {
None
};
contents.set_signature_doc(signature_doc);
let active_param_range = || -> Option<(usize, usize)> {
let param_idx = signature
.active_parameter
.or(response.active_parameter)
.unwrap_or(0) as usize;
let param = signature.parameters.as_ref()?.get(param_idx)?;
match &param.label {
lsp::ParameterLabel::Simple(string) => {
let start = signature.label.find(string.as_str())?;
Some((start, start + string.len()))
}
lsp::ParameterLabel::LabelOffsets([start, end]) => {
// LS sends offsets based on utf-16 based string representation
// but highlighting in helix is done using byte offset.
use helix_core::str_utils::char_to_byte_idx;
let from = char_to_byte_idx(&signature.label, *start as usize);
let to = char_to_byte_idx(&signature.label, *end as usize);
Some((from, to))
}
}
};
contents.set_active_param_range(active_param_range());
let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
let mut popup = Popup::new(SignatureHelp::ID, contents)
.position(old_popup.and_then(|p| p.get_position()))
.position_bias(Open::Above)
.ignore_escape_key(true);
// Don't create a popup if it intersects the auto-complete menu.
let size = compositor.size();
if compositor
.find::<ui::EditorView>()
.unwrap()
.completion
.as_mut()
.map(|completion| completion.area(size, editor))
.filter(|area| area.intersects(popup.area(size, editor)))
.is_some()
{
return;
}
compositor.replace_or_push(SignatureHelp::ID, popup);
}
fn signature_help_post_insert_char_hook(
tx: &Sender<SignatureHelpEvent>,
PostInsertChar { cx, .. }: &mut PostInsertChar<'_, '_>,
) -> anyhow::Result<()> {
if !cx.editor.config().lsp.auto_signature_help {
return Ok(());
}
let (view, doc) = current!(cx.editor);
// TODO support multiple language servers (not just the first that is found), likely by merging UI somehow
let Some(language_server) = doc
.language_servers_with_feature(LanguageServerFeature::SignatureHelp)
.next()
else {
return Ok(());
};
let capabilities = language_server.capabilities();
if let lsp::ServerCapabilities {
signature_help_provider:
Some(lsp::SignatureHelpOptions {
trigger_characters: Some(triggers),
// TODO: retrigger_characters
..
}),
..
} = capabilities
{
let mut text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
text = text.slice(..cursor);
if triggers.iter().any(|trigger| text.ends_with(trigger)) {
send_blocking(tx, SignatureHelpEvent::Trigger)
}
}
Ok(())
}
pub(super) fn register_hooks(handlers: &Handlers) {
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut OnModeSwitch<'_, '_>| {
match (event.old_mode, event.new_mode) {
(Mode::Insert, _) => {
send_blocking(&tx, SignatureHelpEvent::Cancel);
event.cx.callback.push(Box::new(|compositor, _| {
compositor.remove(SignatureHelp::ID);
}));
}
(_, Mode::Insert) => {
if event.cx.editor.config().lsp.auto_signature_help {
send_blocking(&tx, SignatureHelpEvent::Trigger);
}
}
_ => (),
}
Ok(())
});
let tx = handlers.signature_hints.clone();
register_hook!(
move |event: &mut PostInsertChar<'_, '_>| signature_help_post_insert_char_hook(&tx, event)
);
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut DocumentDidChange<'_>| {
if event.doc.config.load().lsp.auto_signature_help {
send_blocking(&tx, SignatureHelpEvent::ReTrigger);
}
Ok(())
});
let tx = handlers.signature_hints.clone();
register_hook!(move |event: &mut SelectionDidChange<'_>| {
if event.doc.config.load().lsp.auto_signature_help {
send_blocking(&tx, SignatureHelpEvent::ReTrigger);
}
Ok(())
});
}

View file

@ -1,8 +1,12 @@
use crate::compositor::{Component, Context, Event, EventResult}; use crate::{
compositor::{Component, Context, Event, EventResult},
handlers::trigger_auto_completion,
};
use helix_view::{ use helix_view::{
document::SavePoint, document::SavePoint,
editor::CompleteAction, editor::CompleteAction,
graphics::Margin, graphics::Margin,
handlers::lsp::SignatureHelpInvoked,
theme::{Modifier, Style}, theme::{Modifier, Style},
ViewId, ViewId,
}; };
@ -10,7 +14,7 @@ use tui::{buffer::Buffer as Surface, text::Span};
use std::{borrow::Cow, sync::Arc}; use std::{borrow::Cow, sync::Arc};
use helix_core::{Change, Transaction}; use helix_core::{chars, Change, Transaction};
use helix_view::{graphics::Rect, Document, Editor}; use helix_view::{graphics::Rect, Document, Editor};
use crate::commands; use crate::commands;
@ -95,10 +99,9 @@ pub struct CompletionItem {
/// Wraps a Menu. /// Wraps a Menu.
pub struct Completion { pub struct Completion {
popup: Popup<Menu<CompletionItem>>, popup: Popup<Menu<CompletionItem>>,
start_offset: usize,
#[allow(dead_code)] #[allow(dead_code)]
trigger_offset: usize, trigger_offset: usize,
// TODO: maintain a completioncontext with trigger kind & trigger char filter: String,
} }
impl Completion { impl Completion {
@ -108,7 +111,6 @@ impl Completion {
editor: &Editor, editor: &Editor,
savepoint: Arc<SavePoint>, savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>, mut items: Vec<CompletionItem>,
start_offset: usize,
trigger_offset: usize, trigger_offset: usize,
) -> Self { ) -> Self {
let preview_completion_insert = editor.config().preview_completion_insert; let preview_completion_insert = editor.config().preview_completion_insert;
@ -246,7 +248,7 @@ impl Completion {
// (also without sending the transaction to the LS) *before any further transaction is applied*. // (also without sending the transaction to the LS) *before any further transaction is applied*.
// Otherwise incremental sync breaks (since the state of the LS doesn't match the state the transaction // Otherwise incremental sync breaks (since the state of the LS doesn't match the state the transaction
// is applied to). // is applied to).
if editor.last_completion.is_none() { if matches!(editor.last_completion, Some(CompleteAction::Triggered)) {
editor.last_completion = Some(CompleteAction::Selected { editor.last_completion = Some(CompleteAction::Selected {
savepoint: doc.savepoint(view), savepoint: doc.savepoint(view),
}) })
@ -324,8 +326,18 @@ impl Completion {
doc.apply(&transaction, view.id); doc.apply(&transaction, view.id);
} }
} }
// 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);
} }
}; };
// In case the popup was deleted because of an intersection w/ the auto-complete menu.
if event != PromptEvent::Update {
editor
.handlers
.trigger_signature_help(SignatureHelpInvoked::Automatic, editor);
}
}); });
let margin = if editor.menu_border() { let margin = if editor.menu_border() {
@ -339,14 +351,30 @@ impl Completion {
.ignore_escape_key(true) .ignore_escape_key(true)
.margin(margin); .margin(margin);
let (view, doc) = current_ref!(editor);
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
let offset = text
.chars_at(cursor)
.reversed()
.take_while(|ch| chars::char_is_word(*ch))
.count();
let start_offset = cursor.saturating_sub(offset);
let fragment = doc.text().slice(start_offset..cursor);
let mut completion = Self { let mut completion = Self {
popup, popup,
start_offset,
trigger_offset, trigger_offset,
// TODO: expand nucleo api to allow moving straight to a Utf32String here
// and avoid allocation during matching
filter: String::from(fragment),
}; };
// need to recompute immediately in case start_offset != trigger_offset // need to recompute immediately in case start_offset != trigger_offset
completion.recompute_filter(editor); completion
.popup
.contents_mut()
.score(&completion.filter, false);
completion completion
} }
@ -366,39 +394,22 @@ impl Completion {
} }
} }
pub fn recompute_filter(&mut self, editor: &Editor) { /// Appends (`c: Some(c)`) or removes (`c: None`) a character to/from the filter
/// this should be called whenever the user types or deletes a character in insert mode.
pub fn update_filter(&mut self, c: Option<char>) {
// recompute menu based on matches // recompute menu based on matches
let menu = self.popup.contents_mut(); let menu = self.popup.contents_mut();
let (view, doc) = current_ref!(editor); match c {
Some(c) => self.filter.push(c),
// cx.hooks() None => {
// cx.add_hook(enum type, ||) self.filter.pop();
// cx.trigger_hook(enum type, &str, ...) <-- there has to be enough to identify doc/view if self.filter.is_empty() {
// callback with editor & compositor menu.clear();
// return;
// trigger_hook sends event into channel, that's consumed in the global loop and }
// triggers all registered callbacks }
// TODO: hooks should get processed immediately so maybe do it after select!(), before
// looping?
let cursor = doc
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
if self.trigger_offset <= cursor {
let fragment = doc.text().slice(self.start_offset..cursor);
let text = Cow::from(fragment);
// TODO: logic is same as ui/picker
menu.score(&text);
} else {
// we backspaced before the start offset, clear the menu
// this will cause the editor to remove the completion popup
menu.clear();
} }
} menu.score(&self.filter, c.is_some());
pub fn update(&mut self, cx: &mut commands::Context) {
self.recompute_filter(cx.editor)
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {

View file

@ -1,7 +1,6 @@
use crate::{ use crate::{
commands::{self, OnKeyCallback}, commands::{self, OnKeyCallback},
compositor::{Component, Context, Event, EventResult}, compositor::{Component, Context, Event, EventResult},
job::{self, Callback},
events::{OnModeSwitch, PostCommand}, events::{OnModeSwitch, PostCommand},
key, key,
keymap::{KeymapResult, Keymaps}, keymap::{KeymapResult, Keymaps},
@ -34,8 +33,8 @@ use std::{mem::take, num::NonZeroUsize, path::PathBuf, rc::Rc, sync::Arc};
use tui::{buffer::Buffer as Surface, text::Span}; use tui::{buffer::Buffer as Surface, text::Span};
use super::document::LineDecoration;
use super::{completion::CompletionItem, statusline}; use super::{completion::CompletionItem, statusline};
use super::{document::LineDecoration, lsp::SignatureHelp};
pub struct EditorView { pub struct EditorView {
pub keymaps: Keymaps, pub keymaps: Keymaps,
@ -837,11 +836,8 @@ impl EditorView {
let mut execute_command = |command: &commands::MappableCommand| { let mut execute_command = |command: &commands::MappableCommand| {
command.execute(cxt); command.execute(cxt);
helix_event::dispatch(PostCommand { command, cx: cxt }); helix_event::dispatch(PostCommand { command, cx: cxt });
let current_mode = cxt.editor.mode(); let current_mode = cxt.editor.mode();
match (last_mode, current_mode) {
(Mode::Normal, Mode::Insert) => {
// HAXX: if we just entered insert mode from normal, clear key buf
// and record the command that got us into this mode.
if current_mode != last_mode { if current_mode != last_mode {
helix_event::dispatch(OnModeSwitch { helix_event::dispatch(OnModeSwitch {
old_mode: last_mode, old_mode: last_mode,
@ -849,29 +845,16 @@ impl EditorView {
cx: cxt, cx: cxt,
}); });
// HAXX: if we just entered insert mode from normal, clear key buf
// and record the command that got us into this mode.
if current_mode == Mode::Insert {
// how we entered insert mode is important, and we should track that so // how we entered insert mode is important, and we should track that so
// we can repeat the side effect. // we can repeat the side effect.
self.last_insert.0 = command.clone(); self.last_insert.0 = command.clone();
self.last_insert.1.clear(); self.last_insert.1.clear();
commands::signature_help_impl(cxt, commands::SignatureHelpInvoked::Automatic);
} }
(Mode::Insert, Mode::Normal) => {
// if exiting insert mode, remove completion
self.clear_completion(cxt.editor);
cxt.editor.completion_request_handle = None;
// TODO: Use an on_mode_change hook to remove signature help
cxt.jobs.callback(async {
let call: job::Callback =
Callback::EditorCompositor(Box::new(|_editor, compositor| {
compositor.remove(SignatureHelp::ID);
}));
Ok(call)
});
}
_ => (),
} }
last_mode = current_mode; last_mode = current_mode;
}; };
@ -999,12 +982,10 @@ impl EditorView {
editor: &mut Editor, editor: &mut Editor,
savepoint: Arc<SavePoint>, savepoint: Arc<SavePoint>,
items: Vec<CompletionItem>, items: Vec<CompletionItem>,
start_offset: usize,
trigger_offset: usize, trigger_offset: usize,
size: Rect, size: Rect,
) -> Option<Rect> { ) -> Option<Rect> {
let mut completion = let mut completion = Completion::new(editor, savepoint, items, trigger_offset);
Completion::new(editor, savepoint, items, start_offset, trigger_offset);
if completion.is_empty() { if completion.is_empty() {
// skip if we got no completion results // skip if we got no completion results
@ -1025,6 +1006,7 @@ impl EditorView {
self.completion = None; self.completion = None;
if let Some(last_completion) = editor.last_completion.take() { if let Some(last_completion) = editor.last_completion.take() {
match last_completion { match last_completion {
CompleteAction::Triggered => (),
CompleteAction::Applied { CompleteAction::Applied {
trigger_offset, trigger_offset,
changes, changes,
@ -1038,9 +1020,6 @@ impl EditorView {
} }
} }
} }
// Clear any savepoints
editor.clear_idle_timer(); // don't retrigger
} }
pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult {
@ -1054,13 +1033,7 @@ impl EditorView {
}; };
} }
if cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion { EventResult::Ignored(None)
return EventResult::Ignored(None);
}
crate::commands::insert::idle_completion(cx);
EventResult::Consumed(None)
} }
} }
@ -1346,12 +1319,6 @@ impl Component for EditorView {
if callback.is_some() { if callback.is_some() {
// assume close_fn // assume close_fn
self.clear_completion(cx.editor); self.clear_completion(cx.editor);
// In case the popup was deleted because of an intersection w/ the auto-complete menu.
commands::signature_help_impl(
&mut cx,
commands::SignatureHelpInvoked::Automatic,
);
} }
} }
} }
@ -1362,14 +1329,6 @@ impl Component for EditorView {
// record last_insert key // record last_insert key
self.last_insert.1.push(InsertEvent::Key(key)); self.last_insert.1.push(InsertEvent::Key(key));
// lastly we recalculate completion
if let Some(completion) = &mut self.completion {
completion.update(&mut cx);
if completion.is_empty() {
self.clear_completion(cx.editor);
}
}
} }
} }
mode => self.command_mode(mode, &mut cx, key), mode => self.command_mode(mode, &mut cx, key),

View file

@ -96,20 +96,34 @@ impl<T: Item> Menu<T> {
} }
} }
pub fn score(&mut self, pattern: &str) { pub fn score(&mut self, pattern: &str, incremental: bool) {
// reuse the matches allocation
self.matches.clear();
let mut matcher = MATCHER.lock(); let mut matcher = MATCHER.lock();
matcher.config = Config::DEFAULT; matcher.config = Config::DEFAULT;
let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false); let pattern = Atom::new(pattern, CaseMatching::Ignore, AtomKind::Fuzzy, false);
let mut buf = Vec::new(); let mut buf = Vec::new();
let matches = self.options.iter().enumerate().filter_map(|(i, option)| { if incremental {
let text = option.filter_text(&self.editor_data); self.matches.retain_mut(|(index, score)| {
pattern let option = &self.options[*index as usize];
.score(Utf32Str::new(&text, &mut buf), &mut matcher) let text = option.filter_text(&self.editor_data);
.map(|score| (i as u32, score as u32)) let new_score = pattern.score(Utf32Str::new(&text, &mut buf), &mut matcher);
}); match new_score {
self.matches.extend(matches); 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 self.matches
.sort_unstable_by_key(|&(i, score)| (Reverse(score), i)); .sort_unstable_by_key(|&(i, score)| (Reverse(score), i));

View file

@ -115,19 +115,6 @@ pub struct SavePoint {
/// The view this savepoint is associated with /// The view this savepoint is associated with
pub view: ViewId, pub view: ViewId,
revert: Mutex<Transaction>, revert: Mutex<Transaction>,
pub text: Rope,
}
impl SavePoint {
pub fn cursor(&self) -> usize {
// we always create transactions with selections
self.revert
.lock()
.selection()
.unwrap()
.primary()
.cursor(self.text.slice(..))
}
} }
pub struct Document { pub struct Document {
@ -1404,7 +1391,6 @@ impl Document {
let savepoint = Arc::new(SavePoint { let savepoint = Arc::new(SavePoint {
view: view.id, view: view.id,
revert: Mutex::new(revert), revert: Mutex::new(revert),
text: self.text.clone(),
}); });
self.savepoints.push(Arc::downgrade(&savepoint)); self.savepoints.push(Arc::downgrade(&savepoint));
savepoint savepoint

View file

@ -31,10 +31,7 @@ use std::{
}; };
use tokio::{ use tokio::{
sync::{ sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
oneshot,
},
time::{sleep, Duration, Instant, Sleep}, time::{sleep, Duration, Instant, Sleep},
}; };
@ -244,12 +241,19 @@ pub struct Config {
/// Set a global text_width /// Set a global text_width
pub text_width: usize, pub text_width: usize,
/// Time in milliseconds since last keypress before idle timers trigger. /// Time in milliseconds since last keypress before idle timers trigger.
/// Used for autocompletion, set to 0 for instant. Defaults to 250ms. /// Used for various UI timeouts. Defaults to 250ms.
#[serde( #[serde(
serialize_with = "serialize_duration_millis", serialize_with = "serialize_duration_millis",
deserialize_with = "deserialize_duration_millis" deserialize_with = "deserialize_duration_millis"
)] )]
pub idle_timeout: Duration, pub idle_timeout: Duration,
/// Time in milliseconds after typing a word character before auto completions
/// are shown, set to 5 for instant. Defaults to 250ms.
#[serde(
serialize_with = "serialize_duration_millis",
deserialize_with = "deserialize_duration_millis"
)]
pub completion_timeout: Duration,
/// Whether to insert the completion suggestion on hover. Defaults to true. /// Whether to insert the completion suggestion on hover. Defaults to true.
pub preview_completion_insert: bool, pub preview_completion_insert: bool,
pub completion_trigger_len: u8, pub completion_trigger_len: u8,
@ -829,6 +833,7 @@ impl Default for Config {
auto_format: true, auto_format: true,
auto_save: false, auto_save: false,
idle_timeout: Duration::from_millis(250), idle_timeout: Duration::from_millis(250),
completion_timeout: Duration::from_millis(250),
preview_completion_insert: true, preview_completion_insert: true,
completion_trigger_len: 2, completion_trigger_len: 2,
auto_info: true, auto_info: true,
@ -953,14 +958,6 @@ pub struct Editor {
/// avoid calculating the cursor position multiple /// avoid calculating the cursor position multiple
/// times during rendering and should not be set by other functions. /// times during rendering and should not be set by other functions.
pub cursor_cache: Cell<Option<Option<Position>>>, pub cursor_cache: Cell<Option<Option<Position>>>,
/// When a new completion request is sent to the server old
/// unfinished request must be dropped. Each completion
/// request is associated with a channel that cancels
/// when the channel is dropped. That channel is stored
/// here. When a new completion request is sent this
/// field is set and any old requests are automatically
/// canceled as a result
pub completion_request_handle: Option<oneshot::Sender<()>>,
pub handlers: Handlers, pub handlers: Handlers,
} }
@ -989,13 +986,16 @@ enum ThemeAction {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum CompleteAction { pub enum CompleteAction {
Triggered,
/// A savepoint of the currently selected completion. The savepoint
/// MUST be restored before sending any event to the LSP
Selected {
savepoint: Arc<SavePoint>,
},
Applied { Applied {
trigger_offset: usize, trigger_offset: usize,
changes: Vec<Change>, changes: Vec<Change>,
}, },
/// A savepoint of the currently selected completion. The savepoint
/// MUST be restored before sending any event to the LSP
Selected { savepoint: Arc<SavePoint> },
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
@ -1029,6 +1029,7 @@ impl Editor {
theme_loader: Arc<theme::Loader>, theme_loader: Arc<theme::Loader>,
syn_loader: Arc<syntax::Loader>, syn_loader: Arc<syntax::Loader>,
config: Arc<dyn DynAccess<Config>>, config: Arc<dyn DynAccess<Config>>,
handlers: Handlers,
) -> Self { ) -> Self {
let language_servers = helix_lsp::Registry::new(syn_loader.clone()); let language_servers = helix_lsp::Registry::new(syn_loader.clone());
let conf = config.load(); let conf = config.load();
@ -1073,7 +1074,7 @@ impl Editor {
config_events: unbounded_channel(), config_events: unbounded_channel(),
needs_redraw: false, needs_redraw: false,
cursor_cache: Cell::new(None), cursor_cache: Cell::new(None),
completion_request_handle: None, handlers,
} }
} }

View file

@ -1,12 +1,41 @@
use std::sync::Arc;
use helix_event::send_blocking; use helix_event::send_blocking;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use crate::handlers::lsp::SignatureHelpInvoked; use crate::handlers::lsp::SignatureHelpInvoked;
use crate::Editor; use crate::{DocumentId, Editor, ViewId};
pub mod dap; pub mod dap;
pub mod lsp; pub mod lsp;
pub struct Handlers {} pub struct Handlers {
// only public because most of the actual implementation is in helix-term right now :/
pub completions: Sender<lsp::CompletionEvent>,
pub signature_hints: Sender<lsp::SignatureHelpEvent>,
}
impl Handlers {
/// Manually trigger completion (c-x)
pub fn trigger_completions(&self, trigger_pos: usize, doc: DocumentId, view: ViewId) {
send_blocking(
&self.completions,
lsp::CompletionEvent::ManualTrigger {
cursor: trigger_pos,
doc,
view,
},
);
}
pub fn trigger_signature_help(&self, invocation: SignatureHelpInvoked, editor: &Editor) {
let event = match invocation {
SignatureHelpInvoked::Automatic => {
if !editor.config().lsp.auto_signature_help {
return;
}
lsp::SignatureHelpEvent::Trigger
}
SignatureHelpInvoked::Manual => lsp::SignatureHelpEvent::Invoked,
};
send_blocking(&self.signature_hints, event)
}
}

View file

@ -1,26 +1,27 @@
use crate::{DocumentId, ViewId}; use crate::{DocumentId, ViewId};
#[derive(Debug, Clone, Copy)]
pub struct CompletionTrigger {
/// The char position of the primary cursor when the
/// completion was triggered
pub trigger_pos: usize,
pub doc: DocumentId,
pub view: ViewId,
/// Whether the cause of the trigger was an automatic completion (any word
/// char for words longer than minimum word length).
/// This is false for trigger chars send by the LS
pub auto: bool,
}
pub enum CompletionEvent { pub enum CompletionEvent {
/// Auto completion was triggered by typing a word char /// Auto completion was triggered by typing a word char
/// or a completion trigger AutoTrigger {
Trigger(CompletionTrigger), cursor: usize,
doc: DocumentId,
view: ViewId,
},
/// Auto completion was triggered by typing a trigger char
/// specified by the LSP
TriggerChar {
cursor: usize,
doc: DocumentId,
view: ViewId,
},
/// A completion was manually requested (c-x) /// A completion was manually requested (c-x)
Manual, ManualTrigger {
cursor: usize,
doc: DocumentId,
view: ViewId,
},
/// Some text was deleted and the cursor is now at `pos` /// Some text was deleted and the cursor is now at `pos`
DeleteText { pos: usize }, DeleteText { cursor: usize },
/// Invalidate the current auto completion trigger /// Invalidate the current auto completion trigger
Cancel, Cancel,
} }