Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-view/src/action.rs')
| -rw-r--r-- | helix-view/src/action.rs | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/helix-view/src/action.rs b/helix-view/src/action.rs new file mode 100644 index 00000000..d294cefe --- /dev/null +++ b/helix-view/src/action.rs @@ -0,0 +1,236 @@ +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::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.inner.provider.language_server_id() == Some(language_server_id) + && selection.overlaps(&helix_core::Range::new( + diag.range.start, + diag.range.end, + )) + }) + .map(|diag| diag.inner.to_lsp_diagnostic(doc.text(), 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() + }) + .chain(self.spelling_actions()) + .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 + }) + } +} |