Skip to content

Commit

Permalink
squash&merge: Robust completions: helix-editor#6594
Browse files Browse the repository at this point in the history
  • Loading branch information
poliorcetics committed Apr 15, 2023
1 parent 4954e85 commit 91c0b48
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 101 deletions.
16 changes: 11 additions & 5 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use helix_core::{
use helix_view::{
clipboard::ClipboardType,
document::{FormatterError, Mode, SCRATCH_BUFFER_NAME},
editor::{Action, Motion},
editor::{Action, CompleteAction, Motion},
info::Info,
input::KeyEvent,
keyboard::KeyCode,
Expand Down Expand Up @@ -4178,16 +4178,23 @@ pub fn completion(cx: &mut Context) {

let (view, doc) = current!(cx.editor);

let savepoint = if let Some(CompleteAction::Selected { savepoint }) = &cx.editor.last_completion
{
savepoint.clone()
} else {
doc.savepoint(view)
};

let language_server = match doc.language_server() {
Some(language_server) => language_server,
None => return,
};

let offset_encoding = language_server.offset_encoding();
let text = doc.text().slice(..);
let cursor = doc.selection(view.id).primary().cursor(text);
let text = savepoint.text.clone();
let cursor = savepoint.cursor();

let pos = pos_to_lsp_pos(doc.text(), cursor, offset_encoding);
let pos = pos_to_lsp_pos(&text, cursor, offset_encoding);

let future = match language_server.completion(doc.identifier(), pos, None) {
Some(future) => future,
Expand Down Expand Up @@ -4222,7 +4229,6 @@ pub fn completion(cx: &mut Context) {
iter.reverse();
let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
let start_offset = cursor.saturating_sub(offset);
let savepoint = doc.savepoint(view);

let trigger_doc = doc.id();
let trigger_view = view.id;
Expand Down
143 changes: 84 additions & 59 deletions helix-term/src/ui/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ use crate::commands;
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};

use helix_lsp::{lsp, util};
use lsp::CompletionItem;

impl menu::Item for CompletionItem {
type Data = ();
Expand All @@ -26,28 +25,29 @@ impl menu::Item for CompletionItem {

#[inline]
fn filter_text(&self, _data: &Self::Data) -> Cow<str> {
self.filter_text
self.item
.filter_text
.as_ref()
.unwrap_or(&self.label)
.unwrap_or(&self.item.label)
.as_str()
.into()
}

fn format(&self, _data: &Self::Data) -> menu::Row {
let deprecated = self.deprecated.unwrap_or_default()
|| self.tags.as_ref().map_or(false, |tags| {
let deprecated = self.item.deprecated.unwrap_or_default()
|| self.item.tags.as_ref().map_or(false, |tags| {
tags.contains(&lsp::CompletionItemTag::DEPRECATED)
});
menu::Row::new(vec![
menu::Cell::from(Span::styled(
self.label.as_str(),
self.item.label.as_str(),
if deprecated {
Style::default().add_modifier(Modifier::CROSSED_OUT)
} else {
Style::default()
},
)),
menu::Cell::from(match self.kind {
menu::Cell::from(match self.item.kind {
Some(lsp::CompletionItemKind::TEXT) => "text",
Some(lsp::CompletionItemKind::METHOD) => "method",
Some(lsp::CompletionItemKind::FUNCTION) => "function",
Expand Down Expand Up @@ -88,6 +88,12 @@ impl menu::Item for CompletionItem {
}
}

#[derive(Debug, PartialEq, Default, Clone)]
struct CompletionItem {
item: lsp::CompletionItem,
resolved: bool,
}

/// Wraps a Menu.
pub struct Completion {
popup: Popup<Menu<CompletionItem>>,
Expand All @@ -103,14 +109,21 @@ impl Completion {
pub fn new(
editor: &Editor,
savepoint: Arc<SavePoint>,
mut items: Vec<CompletionItem>,
mut items: Vec<lsp::CompletionItem>,
offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize,
trigger_offset: usize,
) -> Self {
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.unwrap_or(false));
let items = items
.into_iter()
.map(|item| CompletionItem {
item,
resolved: false,
})
.collect();

// Then create the menu
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
Expand All @@ -128,7 +141,7 @@ impl Completion {
let text = doc.text().slice(..);
let primary_cursor = selection.primary().cursor(text);

let (edit_offset, new_text) = if let Some(edit) = &item.text_edit {
let (edit_offset, new_text) = if let Some(edit) = &item.item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
Expand All @@ -151,9 +164,10 @@ impl Completion {
(Some((start_offset, end_offset)), edit.new_text)
} else {
let new_text = item
.item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
.unwrap_or_else(|| item.item.label.clone());
// check that we are still at the correct savepoint
// we can still generate a transaction regardless but if the
// document changed (and not just the selection) then we will
Expand All @@ -162,9 +176,9 @@ impl Completion {
(None, new_text)
};

if matches!(item.kind, Some(lsp::CompletionItemKind::SNIPPET))
if matches!(item.item.kind, Some(lsp::CompletionItemKind::SNIPPET))
|| matches!(
item.insert_text_format,
item.item.insert_text_format,
Some(lsp::InsertTextFormat::SNIPPET)
)
{
Expand Down Expand Up @@ -209,14 +223,27 @@ impl Completion {

let (view, doc) = current!(editor);

// if more text was entered, remove it
doc.restore(view, &savepoint);

match event {
PromptEvent::Abort => {
editor.last_completion = None;
}
PromptEvent::Abort => {}
PromptEvent::Update => {
// Update creates "ghost" transactions which are not sent to the
// lsp server to avoid messing up re-requesting completions. Once a
// completion has been selected (with tab, c-n or c-p) it's always accepted whenever anything
// is typed. The only way to avoid that is to explicitly abort the completion
// with c-c. This will remove the "ghost" transaction.
//
// The ghost transaction is modeled with a transaction that is not sent to the LS.
// (apply_temporary) and a savepoint. It's extremely important this savepoint is restored
// (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
// is applied to).
if editor.last_completion.is_none() {
editor.last_completion = Some(CompleteAction::Selected {
savepoint: doc.savepoint(view),
})
}
// if more text was entered, remove it
doc.restore(view, &savepoint, false);
// always present here
let item = item.unwrap();

Expand All @@ -229,57 +256,49 @@ impl Completion {
true,
replace_mode,
);

// initialize a savepoint
doc.apply(&transaction, view.id);

editor.last_completion = Some(CompleteAction {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
});
doc.apply_temporary(&transaction, view.id);
}
PromptEvent::Validate => {
if let Some(CompleteAction::Selected { savepoint }) =
editor.last_completion.take()
{
doc.restore(view, &savepoint, false);
}
// always present here
let item = item.unwrap();

let mut item = item.unwrap().clone();

// resolve item if not yet resolved
if !item.resolved {
if let Some(resolved) =
Self::resolve_completion_item(doc, item.item.clone())
{
item.item = resolved;
}
};
// if more text was entered, remove it
doc.restore(view, &savepoint, true);
let transaction = item_to_transaction(
doc,
view.id,
item,
&item,
offset_encoding,
trigger_offset,
false,
replace_mode,
);

doc.apply(&transaction, view.id);

editor.last_completion = Some(CompleteAction {
editor.last_completion = Some(CompleteAction::Applied {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
});

// apply additional edits, mostly used to auto import unqualified types
let resolved_item = if item
.additional_text_edits
.as_ref()
.map(|edits| !edits.is_empty())
.unwrap_or(false)
{
None
} else {
Self::resolve_completion_item(doc, item.clone())
};

if let Some(additional_edits) = resolved_item
.as_ref()
.and_then(|item| item.additional_text_edits.as_ref())
.or(item.additional_text_edits.as_ref())
{
// TODO: add additional _edits to completion_changes?
if let Some(additional_edits) = item.item.additional_text_edits {
if !additional_edits.is_empty() {
let transaction = util::generate_transaction_from_edits(
doc.text(),
additional_edits.clone(),
additional_edits,
offset_encoding, // TODO: should probably transcode in Client
);
doc.apply(&transaction, view.id);
Expand All @@ -306,7 +325,7 @@ impl Completion {
fn resolve_completion_item(
doc: &Document,
completion_item: lsp::CompletionItem,
) -> Option<CompletionItem> {
) -> Option<lsp::CompletionItem> {
let language_server = doc.language_server()?;

let future = language_server.resolve_completion_item(completion_item)?;
Expand Down Expand Up @@ -359,7 +378,7 @@ impl Completion {
self.popup.contents().is_empty()
}

fn replace_item(&mut self, old_item: lsp::CompletionItem, new_item: lsp::CompletionItem) {
fn replace_item(&mut self, old_item: CompletionItem, new_item: CompletionItem) {
self.popup.contents_mut().replace_option(old_item, new_item);
}

Expand All @@ -375,7 +394,7 @@ impl Completion {
// > The returned completion item should have the documentation property filled in.
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion
let current_item = match self.popup.contents().selection() {
Some(item) if item.documentation.is_none() => item.clone(),
Some(item) if !item.resolved => item.clone(),
_ => return false,
};

Expand All @@ -385,7 +404,7 @@ impl Completion {
};

// This method should not block the compositor so we handle the response asynchronously.
let future = match language_server.resolve_completion_item(current_item.clone()) {
let future = match language_server.resolve_completion_item(current_item.item.clone()) {
Some(future) => future,
None => return false,
};
Expand All @@ -403,7 +422,13 @@ impl Completion {
.unwrap()
.completion
{
completion.replace_item(current_item, resolved_item);
completion.replace_item(
current_item,
CompletionItem {
item: resolved_item,
resolved: true,
},
);
}
},
);
Expand Down Expand Up @@ -457,25 +482,25 @@ impl Component for Completion {
Markdown::new(md, cx.editor.syn_loader.clone())
};

let mut markdown_doc = match &option.documentation {
let mut markdown_doc = match &option.item.documentation {
Some(lsp::Documentation::String(contents))
| Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::PlainText,
value: contents,
})) => {
// TODO: convert to wrapped text
markdowned(language, option.detail.as_deref(), Some(contents))
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
kind: lsp::MarkupKind::Markdown,
value: contents,
})) => {
// TODO: set language based on doc scope
markdowned(language, option.detail.as_deref(), Some(contents))
markdowned(language, option.item.detail.as_deref(), Some(contents))
}
None if option.detail.is_some() => {
None if option.item.detail.is_some() => {
// TODO: set language based on doc scope
markdowned(language, option.detail.as_deref(), None)
markdowned(language, option.item.detail.as_deref(), None)
}
None => return,
};
Expand Down
Loading

0 comments on commit 91c0b48

Please sign in to comment.