Unnamed repository; edit this file 'description' to name the repository.
-rw-r--r--helix-term/src/commands.rs41
-rw-r--r--helix-term/src/commands/lsp.rs252
-rw-r--r--helix-view/src/action.rs239
-rw-r--r--helix-view/src/lib.rs2
4 files changed, 286 insertions, 248 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 2cbdeb45..9fbb61be 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -6817,3 +6817,44 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) {
}
jump_to_label(cx, words, behaviour)
}
+
+pub fn code_action(cx: &mut Context) {
+ impl ui::menu::Item for helix_view::Action {
+ type Data = ();
+ fn format(&self, _data: &Self::Data) -> ui::menu::Row {
+ self.title().into()
+ }
+ }
+
+ let Some(future) = cx.editor.actions() else {
+ cx.editor.set_error("No code actions available");
+ return;
+ };
+
+ cx.jobs.callback(async move {
+ let actions = future.await;
+
+ let call = move |editor: &mut Editor, compositor: &mut Compositor| {
+ if actions.is_empty() {
+ editor.set_error("No code actions available");
+ return;
+ }
+ let mut picker = ui::Menu::new(actions, (), move |editor, action, event| {
+ if event != PromptEvent::Validate {
+ return;
+ }
+
+ // always present here
+ let action = action.unwrap();
+ action.execute(editor);
+ });
+ picker.move_down(); // pre-select the first item
+
+ let popup = Popup::new("code-action", picker).with_scrollbar(false);
+
+ compositor.replace_or_push("code-action", popup);
+ };
+
+ Ok(Callback::EditorCompositor(Box::new(call)))
+ });
+}
diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs
index a6d4b424..78c420c6 100644
--- a/helix-term/src/commands/lsp.rs
+++ b/helix-term/src/commands/lsp.rs
@@ -1,15 +1,12 @@
use futures_util::{stream::FuturesOrdered, FutureExt};
use helix_lsp::{
block_on,
- lsp::{
- self, CodeAction, CodeActionOrCommand, CodeActionTriggerKind, DiagnosticSeverity,
- NumberOrString,
- },
- util::{diagnostic_to_lsp_diagnostic, lsp_range_to_range, range_to_lsp_range},
+ lsp::{self, DiagnosticSeverity, NumberOrString},
+ util::lsp_range_to_range,
Client, LanguageServerId, OffsetEncoding,
};
use tokio_stream::StreamExt;
-use tui::{text::Span, widgets::Row};
+use tui::text::Span;
use super::{align_view, push_jump, Align, Context, Editor};
@@ -32,7 +29,7 @@ use crate::{
ui::{self, overlay::overlaid, FileLocation, Picker, Popup, PromptEvent},
};
-use std::{cmp::Ordering, collections::HashSet, fmt::Display, future::Future, path::Path};
+use std::{collections::HashSet, fmt::Display, future::Future, path::Path};
/// Gets the first language server that is attached to a document which supports a specific feature.
/// If there is no configured language server that supports the feature, this displays a status message.
@@ -575,247 +572,6 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) {
cx.push_layer(Box::new(overlaid(picker)));
}
-struct CodeActionOrCommandItem {
- lsp_item: lsp::CodeActionOrCommand,
- language_server_id: LanguageServerId,
-}
-
-impl ui::menu::Item for CodeActionOrCommandItem {
- type Data = ();
- fn format(&self, _data: &Self::Data) -> Row {
- match &self.lsp_item {
- lsp::CodeActionOrCommand::CodeAction(action) => action.title.as_str().into(),
- lsp::CodeActionOrCommand::Command(command) => command.title.as_str().into(),
- }
- }
-}
-
-/// Determines the category of the `CodeAction` using the `CodeAction::kind` field.
-/// Returns a number that represent these categories.
-/// Categories with a lower number should be displayed first.
-///
-///
-/// While the `kind` field is defined as open ended in the LSP spec (any value may be used)
-/// in practice a closed set of common values (mostly suggested in the LSP spec) are used.
-/// VSCode displays each of these categories separately (separated by a heading in the codeactions picker)
-/// to make them easier to navigate. Helix does not display these headings to the user.
-/// However it does sort code actions by their categories to achieve the same order as the VScode picker,
-/// just without the headings.
-///
-/// The order used here is modeled after the [vscode sourcecode](https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>)
-fn action_category(action: &CodeActionOrCommand) -> u32 {
- if let CodeActionOrCommand::CodeAction(CodeAction {
- kind: Some(kind), ..
- }) = action
- {
- let mut components = kind.as_str().split('.');
- match components.next() {
- Some("quickfix") => 0,
- Some("refactor") => match components.next() {
- Some("extract") => 1,
- Some("inline") => 2,
- Some("rewrite") => 3,
- Some("move") => 4,
- Some("surround") => 5,
- _ => 7,
- },
- Some("source") => 6,
- _ => 7,
- }
- } else {
- 7
- }
-}
-
-fn action_preferred(action: &CodeActionOrCommand) -> bool {
- matches!(
- action,
- CodeActionOrCommand::CodeAction(CodeAction {
- is_preferred: Some(true),
- ..
- })
- )
-}
-
-fn action_fixes_diagnostics(action: &CodeActionOrCommand) -> bool {
- matches!(
- action,
- CodeActionOrCommand::CodeAction(CodeAction {
- diagnostics: Some(diagnostics),
- ..
- }) if !diagnostics.is_empty()
- )
-}
-
-pub fn code_action(cx: &mut Context) {
- let (view, doc) = current!(cx.editor);
-
- let selection_range = doc.selection(view.id).primary();
-
- let mut seen_language_servers = HashSet::new();
-
- let mut futures: FuturesOrdered<_> = doc
- .language_servers_with_feature(LanguageServerFeature::CodeAction)
- .filter(|ls| seen_language_servers.insert(ls.id()))
- // TODO this should probably already been filtered in something like "language_servers_with_feature"
- .filter_map(|language_server| {
- let offset_encoding = language_server.offset_encoding();
- let language_server_id = language_server.id();
- let range = range_to_lsp_range(doc.text(), selection_range, offset_encoding);
- // Filter and convert overlapping diagnostics
- let code_action_context = lsp::CodeActionContext {
- diagnostics: doc
- .diagnostics()
- .iter()
- .filter(|&diag| {
- selection_range
- .overlaps(&helix_core::Range::new(diag.range.start, diag.range.end))
- })
- .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
- .collect(),
- only: None,
- trigger_kind: Some(CodeActionTriggerKind::INVOKED),
- };
- let code_action_request =
- language_server.code_actions(doc.identifier(), range, code_action_context)?;
- Some((code_action_request, language_server_id))
- })
- .map(|(request, ls_id)| async move {
- let Some(mut actions) = request.await? else {
- return anyhow::Ok(Vec::new());
- };
-
- // remove disabled code actions
- actions.retain(|action| {
- matches!(
- action,
- CodeActionOrCommand::Command(_)
- | CodeActionOrCommand::CodeAction(CodeAction { disabled: None, .. })
- )
- });
-
- // Sort codeactions into a useful order. This behaviour is only partially described in the LSP spec.
- // Many details are modeled after vscode because language servers are usually tested against it.
- // VScode sorts the codeaction two times:
- //
- // First the codeactions that fix some diagnostics are moved to the front.
- // If both codeactions fix some diagnostics (or both fix none) the codeaction
- // that is marked with `is_preferred` is shown first. The codeactions are then shown in separate
- // submenus that only contain a certain category (see `action_category`) of actions.
- //
- // Below this done in in a single sorting step
- actions.sort_by(|action1, action2| {
- // sort actions by category
- let order = action_category(action1).cmp(&action_category(action2));
- if order != Ordering::Equal {
- return order;
- }
- // within the categories sort by relevancy.
- // Modeled after the `codeActionsComparator` function in vscode:
- // https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeAction.ts
-
- // if one code action fixes a diagnostic but the other one doesn't show it first
- let order = action_fixes_diagnostics(action1)
- .cmp(&action_fixes_diagnostics(action2))
- .reverse();
- if order != Ordering::Equal {
- return order;
- }
-
- // if one of the codeactions is marked as preferred show it first
- // otherwise keep the original LSP sorting
- action_preferred(action1)
- .cmp(&action_preferred(action2))
- .reverse()
- });
-
- Ok(actions
- .into_iter()
- .map(|lsp_item| CodeActionOrCommandItem {
- lsp_item,
- language_server_id: ls_id,
- })
- .collect())
- })
- .collect();
-
- if futures.is_empty() {
- cx.editor
- .set_error("No configured language server supports code actions");
- return;
- }
-
- cx.jobs.callback(async move {
- let mut actions = Vec::new();
-
- while let Some(output) = futures.next().await {
- match output {
- Ok(mut lsp_items) => actions.append(&mut lsp_items),
- Err(err) => log::error!("while gathering code actions: {err}"),
- }
- }
-
- let call = move |editor: &mut Editor, compositor: &mut Compositor| {
- if actions.is_empty() {
- editor.set_error("No code actions available");
- return;
- }
- let mut picker = ui::Menu::new(actions, (), move |editor, action, event| {
- if event != PromptEvent::Validate {
- return;
- }
-
- // always present here
- let action = action.unwrap();
- let Some(language_server) = editor.language_server_by_id(action.language_server_id)
- else {
- editor.set_error("Language Server disappeared");
- return;
- };
- let offset_encoding = language_server.offset_encoding();
-
- match &action.lsp_item {
- lsp::CodeActionOrCommand::Command(command) => {
- log::debug!("code action command: {:?}", command);
- editor.execute_lsp_command(command.clone(), action.language_server_id);
- }
- lsp::CodeActionOrCommand::CodeAction(code_action) => {
- log::debug!("code action: {:?}", code_action);
- // we support lsp "codeAction/resolve" for `edit` and `command` fields
- let mut resolved_code_action = None;
- if code_action.edit.is_none() || code_action.command.is_none() {
- if let Some(future) = language_server.resolve_code_action(code_action) {
- if let Ok(code_action) = helix_lsp::block_on(future) {
- resolved_code_action = Some(code_action);
- }
- }
- }
- let resolved_code_action =
- resolved_code_action.as_ref().unwrap_or(code_action);
-
- if let Some(ref workspace_edit) = resolved_code_action.edit {
- let _ = editor.apply_workspace_edit(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 {
- editor.execute_lsp_command(command.clone(), action.language_server_id);
- }
- }
- }
- });
- picker.move_down(); // pre-select the first item
-
- let popup = Popup::new("code-action", picker).with_scrollbar(false);
-
- compositor.replace_or_push("code-action", popup);
- };
-
- Ok(Callback::EditorCompositor(Box::new(call)))
- });
-}
-
#[derive(Debug)]
pub struct ApplyEditError {
pub kind: ApplyEditErrorKind,
diff --git a/helix-view/src/action.rs b/helix-view/src/action.rs
new file mode 100644
index 00000000..64ab75e0
--- /dev/null
+++ b/helix-view/src/action.rs
@@ -0,0 +1,239 @@
+use std::{borrow::Cow, collections::HashSet, fmt, future::Future};
+
+use futures_util::{stream::FuturesOrdered, FutureExt as _};
+use helix_core::syntax::config::LanguageServerFeature;
+use helix_lsp::{
+ lsp,
+ util::{diagnostic_to_lsp_diagnostic, range_to_lsp_range},
+ LanguageServerId,
+};
+use tokio_stream::StreamExt as _;
+
+use crate::Editor;
+
+/// A generic action against the editor.
+///
+/// This corresponds to the LSP code action feature. LSP code actions are implemented in terms of
+/// `Action` but `Action` is generic and may be used for internal actions as well.
+pub struct Action {
+ title: Cow<'static, str>,
+ priority: u8,
+ action: Box<dyn Fn(&mut Editor) + Send + Sync + 'static>,
+}
+
+impl fmt::Debug for Action {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("CodeAction")
+ .field("title", &self.title)
+ .field("priority", &self.priority)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Action {
+ pub fn new<T: Into<Cow<'static, str>>, F: Fn(&mut Editor) + Send + Sync + 'static>(
+ title: T,
+ priority: u8,
+ action: F,
+ ) -> Self {
+ Self {
+ title: title.into(),
+ priority,
+ action: Box::new(action),
+ }
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn execute(&self, editor: &mut Editor) {
+ (self.action)(editor);
+ }
+
+ fn lsp(server_id: LanguageServerId, action: lsp::CodeActionOrCommand) -> Self {
+ let title = match &action {
+ lsp::CodeActionOrCommand::CodeAction(action) => action.title.clone(),
+ lsp::CodeActionOrCommand::Command(command) => command.title.clone(),
+ };
+ let priority = lsp_code_action_priority(&action);
+
+ Self::new(title, priority, move |editor| {
+ let Some(language_server) = editor.language_server_by_id(server_id) else {
+ editor.set_error("Language Server disappeared");
+ return;
+ };
+ let offset_encoding = language_server.offset_encoding();
+ match &action {
+ lsp::CodeActionOrCommand::Command(command) => {
+ log::debug!("code action command: {:?}", command);
+ editor.execute_lsp_command(command.clone(), server_id);
+ }
+ lsp::CodeActionOrCommand::CodeAction(code_action) => {
+ log::debug!("code action: {:?}", code_action);
+ // we support lsp "codeAction/resolve" for `edit` and `command` fields
+ let code_action = if code_action.edit.is_none() || code_action.command.is_none()
+ {
+ language_server
+ .resolve_code_action(code_action)
+ .and_then(|future| helix_lsp::block_on(future).ok())
+ .unwrap_or(code_action.clone())
+ } else {
+ code_action.clone()
+ };
+
+ if let Some(ref workspace_edit) = code_action.edit {
+ let _ = editor.apply_workspace_edit(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 {
+ editor.execute_lsp_command(command, server_id);
+ }
+ }
+ }
+ })
+ }
+}
+
+/// Computes a priority score for LSP code actions.
+///
+/// This roughly matches how VSCode should behave: <https://github.com/microsoft/vscode/blob/eaec601dd69aeb4abb63b9601a6f44308c8d8c6e/src/vs/editor/contrib/codeAction/browser/codeActionWidget.ts>.
+/// The scoring is basically equivalent to comparing code actions by:
+/// `(category, fixes_diagnostic, is_preferred)`. First code actions are sorted by the category
+/// declared on the `kind` field (if present), then whether the action fixes a diagnostic and then
+/// whether it is marked as `is_preferred`.
+fn lsp_code_action_priority(action: &lsp::CodeActionOrCommand) -> u8 {
+ // The `kind` field is defined as open ended in the LSP spec - any value may be used. In
+ // practice a closed set of common values (mostly suggested in the LSP spec) are used.
+ // VSCode displays these components as menu headers. We don't do the same but we aim to sort
+ // the code actions in the same way.
+ let category = if let lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
+ kind: Some(kind),
+ ..
+ }) = action
+ {
+ let mut components = kind.as_str().split('.');
+ match components.next() {
+ Some("quickfix") => 7,
+ Some("refactor") => match components.next() {
+ Some("extract") => 6,
+ Some("inline") => 5,
+ Some("rewrite") => 4,
+ Some("move") => 3,
+ Some("surround") => 2,
+ _ => 1,
+ },
+ Some("source") => 1,
+ _ => 0,
+ }
+ } else {
+ 0
+ };
+ let fixes_diagnostic = matches!(
+ action,
+ lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
+ diagnostics: Some(diagnostics),
+ ..
+ }) if !diagnostics.is_empty()
+ );
+ let is_preferred = matches!(
+ action,
+ lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
+ is_preferred: Some(true),
+ ..
+ })
+ );
+
+ // The constants here weigh the three criteria so that their scores can't overlap:
+ // two code actions in the same category should be sorted closer than code actions in
+ // separate categories. `fixes_diagnostic` and `is_preferred` break ties.
+ let mut priority = category * 4;
+ if fixes_diagnostic {
+ priority += 2;
+ }
+ if is_preferred {
+ priority += 1;
+ }
+ priority
+}
+
+impl Editor {
+ /// Finds the available actions given the current selection range.
+ pub fn actions(&self) -> Option<impl Future<Output = Vec<Action>>> {
+ let (view, doc) = current_ref!(self);
+ let selection = doc.selection(view.id).primary();
+ let mut seen_language_servers = HashSet::new();
+
+ let mut futures: FuturesOrdered<_> = doc
+ .language_servers_with_feature(LanguageServerFeature::CodeAction)
+ .filter(|ls| seen_language_servers.insert(ls.id()))
+ .map(|language_server| {
+ let offset_encoding = language_server.offset_encoding();
+ let language_server_id = language_server.id();
+ let range = range_to_lsp_range(doc.text(), selection, offset_encoding);
+ // Filter and convert overlapping diagnostics
+ let context = lsp::CodeActionContext {
+ diagnostics: doc
+ .diagnostics()
+ .iter()
+ .filter(|&diag| {
+ diag.provider.language_server_id() == Some(language_server_id)
+ && selection.overlaps(&helix_core::Range::new(
+ diag.range.start,
+ diag.range.end,
+ ))
+ })
+ .map(|diag| diagnostic_to_lsp_diagnostic(doc.text(), diag, offset_encoding))
+ .collect(),
+ only: None,
+ trigger_kind: Some(lsp::CodeActionTriggerKind::INVOKED),
+ };
+ let future = language_server
+ .code_actions(doc.identifier(), range, context)
+ .unwrap();
+ async move {
+ let Some(actions) = future.await? else {
+ return anyhow::Ok(Vec::new());
+ };
+
+ let actions: Vec<_> = actions
+ .into_iter()
+ .filter(|action| {
+ // remove disabled code actions
+ matches!(
+ action,
+ lsp::CodeActionOrCommand::Command(_)
+ | lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
+ disabled: None,
+ ..
+ })
+ )
+ })
+ .map(move |action| Action::lsp(language_server_id, action))
+ .collect();
+
+ Ok(actions)
+ }
+ .boxed()
+ })
+ .collect();
+
+ if futures.is_empty() {
+ return None;
+ }
+
+ Some(async move {
+ let mut actions = Vec::new();
+ while let Some(response) = futures.next().await {
+ match response {
+ Ok(mut items) => actions.append(&mut items),
+ Err(err) => log::error!("Error requesting code actions: {err}"),
+ }
+ }
+ actions.sort_by_key(|action| std::cmp::Reverse(action.priority));
+ actions
+ })
+ }
+}
diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs
index e30a2338..6d0903b1 100644
--- a/helix-view/src/lib.rs
+++ b/helix-view/src/lib.rs
@@ -1,6 +1,7 @@
#[macro_use]
pub mod macros;
+mod action;
pub mod annotations;
pub mod base64;
pub mod clipboard;
@@ -73,6 +74,7 @@ pub fn align_view(doc: &mut Document, view: &View, align: Align) {
doc.set_view_offset(view.id, view_offset);
}
+pub use action::Action;
pub use document::Document;
pub use editor::Editor;
use helix_core::char_idx_at_visual_offset;