Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: menu sort strategy for fuzzy match score sorting in completion … #3215

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions helix-term/src/commands/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use helix_view::{editor::Action, theme::Style};
use crate::{
compositor::{self, Compositor},
ui::{
self, lsp::SignatureHelp, overlay::overlayed, FileLocation, FilePicker, Popup, PromptEvent,
self, lsp::SignatureHelp, menu::SortStrategy, overlay::overlayed, FileLocation, FilePicker,
Popup, PromptEvent,
},
};

Expand Down Expand Up @@ -461,34 +462,39 @@ pub fn code_action(cx: &mut Context) {
return;
}

let mut picker = ui::Menu::new(actions, (), move |editor, code_action, event| {
if event != PromptEvent::Validate {
return;
}
let mut picker = ui::Menu::new(
actions,
(),
SortStrategy::Text,
move |editor, code_action, event| {
if event != PromptEvent::Validate {
return;
}

// always present here
let code_action = code_action.unwrap();
// always present here
let code_action = code_action.unwrap();

match code_action {
lsp::CodeActionOrCommand::Command(command) => {
log::debug!("code action command: {:?}", command);
execute_lsp_command(editor, command.clone());
}
lsp::CodeActionOrCommand::CodeAction(code_action) => {
log::debug!("code action: {:?}", code_action);
if let Some(ref workspace_edit) = code_action.edit {
log::debug!("edit: {:?}", workspace_edit);
apply_workspace_edit(editor, offset_encoding, workspace_edit);
match code_action {
lsp::CodeActionOrCommand::Command(command) => {
log::debug!("code action command: {:?}", command);
execute_lsp_command(editor, command.clone());
}
lsp::CodeActionOrCommand::CodeAction(code_action) => {
log::debug!("code action: {:?}", code_action);
if let Some(ref workspace_edit) = code_action.edit {
log::debug!("edit: {:?}", workspace_edit);
apply_workspace_edit(editor, offset_encoding, workspace_edit);
}

// if code action provides both edit and command first the edit
// should be applied and then the command
if let Some(command) = &code_action.command {
execute_lsp_command(editor, command.clone());
// if code action provides both edit and command first the edit
// should be applied and then the command
if let Some(command) = &code_action.command {
execute_lsp_command(editor, command.clone());
}
}
}
}
});
},
);
picker.move_down(); // pre-select the first item

let popup =
Expand Down
241 changes: 126 additions & 115 deletions helix-term/src/ui/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use helix_view::{
};

use crate::commands;
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
use crate::ui::{
menu::{self, SortStrategy},
Markdown, Menu, Popup, PromptEvent,
};

use helix_lsp::{lsp, util};
use lsp::CompletionItem;
Expand Down Expand Up @@ -97,128 +100,136 @@ impl Completion {
start_offset: usize,
trigger_offset: usize,
) -> Self {
let menu = Menu::new(items, (), move |editor: &mut Editor, item, event| {
fn item_to_transaction(
doc: &Document,
item: &CompletionItem,
offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize,
trigger_offset: usize,
) -> Transaction {
let transaction = if let Some(edit) = &item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
unimplemented!("completion: insert_and_replace {:?}", item)
}
let menu = Menu::new(
items,
(),
SortStrategy::Score,
move |editor: &mut Editor, item, event| {
fn item_to_transaction(
doc: &Document,
item: &CompletionItem,
offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize,
trigger_offset: usize,
) -> Transaction {
let transaction = if let Some(edit) = &item.text_edit {
let edit = match edit {
lsp::CompletionTextEdit::Edit(edit) => edit.clone(),
lsp::CompletionTextEdit::InsertAndReplace(item) => {
unimplemented!("completion: insert_and_replace {:?}", item)
}
};

util::generate_transaction_from_edits(
doc.text(),
vec![edit],
offset_encoding, // TODO: should probably transcode in Client
)
} else {
let text = item.insert_text.as_ref().unwrap_or(&item.label);
// Some LSPs just give you an insertText with no offset ¯\_(ツ)_/¯
// in these cases we need to check for a common prefix and remove it
let prefix = Cow::from(doc.text().slice(start_offset..trigger_offset));
let text = text.trim_start_matches::<&str>(&prefix);
Transaction::change(
doc.text(),
vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(),
)
};

util::generate_transaction_from_edits(
doc.text(),
vec![edit],
offset_encoding, // TODO: should probably transcode in Client
)
} else {
let text = item.insert_text.as_ref().unwrap_or(&item.label);
// Some LSPs just give you an insertText with no offset ¯\_(ツ)_/¯
// in these cases we need to check for a common prefix and remove it
let prefix = Cow::from(doc.text().slice(start_offset..trigger_offset));
let text = text.trim_start_matches::<&str>(&prefix);
Transaction::change(
doc.text(),
vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(),
)
};

transaction
}

fn completion_changes(transaction: &Transaction, trigger_offset: usize) -> Vec<Change> {
transaction
.changes_iter()
.filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
.collect()
}
transaction
}

let (view, doc) = current!(editor);
fn completion_changes(
transaction: &Transaction,
trigger_offset: usize,
) -> Vec<Change> {
transaction
.changes_iter()
.filter(|(start, end, _)| (*start..=*end).contains(&trigger_offset))
.collect()
}

// if more text was entered, remove it
doc.restore(view.id);
let (view, doc) = current!(editor);

match event {
PromptEvent::Abort => {
doc.restore(view.id);
editor.last_completion = None;
}
PromptEvent::Update => {
// always present here
let item = item.unwrap();

let transaction = item_to_transaction(
doc,
item,
offset_encoding,
start_offset,
trigger_offset,
);

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

editor.last_completion = Some(CompleteAction {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
});
}
PromptEvent::Validate => {
// always present here
let item = item.unwrap();

let transaction = item_to_transaction(
doc,
item,
offset_encoding,
start_offset,
trigger_offset,
);

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

editor.last_completion = Some(CompleteAction {
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 more text was entered, remove it
doc.restore(view.id);

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())
{
if !additional_edits.is_empty() {
let transaction = util::generate_transaction_from_edits(
doc.text(),
additional_edits.clone(),
offset_encoding, // TODO: should probably transcode in Client
);
doc.apply(&transaction, view.id);
match event {
PromptEvent::Abort => {
doc.restore(view.id);
editor.last_completion = None;
}
PromptEvent::Update => {
// always present here
let item = item.unwrap();

let transaction = item_to_transaction(
doc,
item,
offset_encoding,
start_offset,
trigger_offset,
);

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

editor.last_completion = Some(CompleteAction {
trigger_offset,
changes: completion_changes(&transaction, trigger_offset),
});
}
PromptEvent::Validate => {
// always present here
let item = item.unwrap();

let transaction = item_to_transaction(
doc,
item,
offset_encoding,
start_offset,
trigger_offset,
);

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

editor.last_completion = Some(CompleteAction {
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())
{
if !additional_edits.is_empty() {
let transaction = util::generate_transaction_from_edits(
doc.text(),
additional_edits.clone(),
offset_encoding, // TODO: should probably transcode in Client
);
doc.apply(&transaction, view.id);
}
}
}
}
};
});
};
},
);
let popup = Popup::new(Self::ID, menu);
let mut completion = Self {
popup,
Expand Down
24 changes: 20 additions & 4 deletions helix-term/src/ui/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ use fuzzy_matcher::FuzzyMatcher;
use helix_view::{graphics::Rect, Editor};
use tui::layout::Constraint;

pub enum SortStrategy {
Text,
Score,
}

pub trait Item {
/// Additional editor state that is used for label calculation.
type Data;
Expand Down Expand Up @@ -65,6 +70,7 @@ pub struct Menu<T: Item> {
size: (u16, u16),
viewport: (u16, u16),
recalculate: bool,
sort_strategy: SortStrategy,
}

impl<T: Item> Menu<T> {
Expand All @@ -75,6 +81,7 @@ impl<T: Item> Menu<T> {
pub fn new(
options: Vec<T>,
editor_data: <T as Item>::Data,
sort_strategy: SortStrategy,
callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static,
) -> Self {
let mut menu = Self {
Expand All @@ -89,6 +96,7 @@ impl<T: Item> Menu<T> {
size: (0, 0),
viewport: (0, 0),
recalculate: true,
sort_strategy,
};

// TODO: scoring on empty input should just use a fastpath
Expand All @@ -112,10 +120,18 @@ impl<T: Item> Menu<T> {
.map(|score| (index, score))
}),
);
// matches.sort_unstable_by_key(|(_, score)| -score);
self.matches.sort_unstable_by_key(|(index, _score)| {
self.options[*index].sort_text(&self.editor_data)
});

// Match according to sorting strategy
match self.sort_strategy {
SortStrategy::Text => {
self.matches.sort_unstable_by_key(|(index, _score)| {
self.options[*index].sort_text(&self.editor_data)
});
}
SortStrategy::Score => {
self.matches.sort_unstable_by_key(|(_, score)| -score);
}
};

// reset cursor position
self.cursor = None;
Expand Down