Unnamed repository; edit this file 'description' to name the repository.
| -rw-r--r-- | helix-term/src/commands.rs | 41 | ||||
| -rw-r--r-- | helix-term/src/commands/lsp.rs | 252 | ||||
| -rw-r--r-- | helix-view/src/action.rs | 239 | ||||
| -rw-r--r-- | helix-view/src/lib.rs | 2 |
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; |