You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helix/helix-term/src/handlers/signature_help.rs

372 lines
12 KiB
Rust

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::{self, SignatureInformation};
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::{Signature, 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 => (),
}
});
}
fn active_param_range(
signature: &SignatureInformation,
response_active_parameter: Option<u32>,
) -> 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))
}
}
}
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("");
if response.signatures.is_empty() {
return;
}
let signatures: Vec<Signature> = response
.signatures
.into_iter()
.map(|s| {
let active_param_range = active_param_range(&s, response.active_parameter);
let signature_doc = if config.lsp.display_signature_help_docs {
s.documentation.map(|doc| match doc {
lsp::Documentation::String(s) => s,
lsp::Documentation::MarkupContent(markup) => markup.value,
})
} else {
None
};
Signature {
signature: s.label,
signature_doc,
active_param_range,
}
})
.collect();
let old_popup = compositor.find_id::<Popup<SignatureHelp>>(SignatureHelp::ID);
let lsp_signature = response.active_signature.map(|s| s as usize);
// take the new suggested lsp signature if changed
// otherwise take the old signature if possible
// otherwise the last one (in case there is less signatures than before)
let active_signature = old_popup
.as_ref()
.map(|popup| {
let old_lsp_sig = popup.contents().lsp_signature();
let old_sig = popup
.contents()
.active_signature()
.min(signatures.len() - 1);
if old_lsp_sig != lsp_signature {
lsp_signature.unwrap_or(old_sig)
} else {
old_sig
}
})
.unwrap_or(lsp_signature.unwrap_or_default());
let contents = SignatureHelp::new(
language.to_string(),
Arc::clone(&editor.syn_loader),
active_signature,
lsp_signature,
signatures,
);
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(())
});
}