Skip to content

Commit

Permalink
Add code actions on save
Browse files Browse the repository at this point in the history
  • Loading branch information
jpttrssn committed Jun 17, 2023
1 parent b4fe31c commit 1f6a002
Show file tree
Hide file tree
Showing 5 changed files with 165 additions and 55 deletions.
1 change: 1 addition & 0 deletions book/src/languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ These configuration keys are available:
| `formatter` | The formatter for the language, it will take precedence over the lsp when defined. The formatter must be able to take the original file as input from stdin and write the formatted file to stdout |
| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set, defaults to `editor.text-width` |
| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml`. Overwrites the setting of the same name in `config.toml` if set. |
| `code-actions-on-save` | List of LSP code actions to be run in order on save, for example `["source.organizeImports"]` |

### File-type detection and the `file-types` key

Expand Down
2 changes: 2 additions & 0 deletions helix-core/src/syntax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ pub struct LanguageConfiguration {
pub comment_token: Option<String>,
pub text_width: Option<usize>,
pub soft_wrap: Option<SoftWrap>,
#[serde(default)]
pub code_actions_on_save: HashSet<String>, // List of LSP code actions to be run in order upon saving

#[serde(default)]
pub auto_format: bool,
Expand Down
20 changes: 20 additions & 0 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2905,6 +2905,26 @@ async fn make_format_callback(
Ok(call)
}

async fn make_code_actions_on_save_callback(
mut futures: FuturesUnordered<
impl Future<Output = Result<Vec<CodeActionOrCommandItem>, anyhow::Error>>,
>,
) -> anyhow::Result<job::Callback> {
let mut code_actions = Vec::new();
// TODO if one code action request errors, all other requests are ignored (even if they're valid)
while let Some(mut lsp_items) = futures.try_next().await? {
code_actions.append(&mut lsp_items);
}
let call: job::Callback = Callback::Editor(Box::new(move |editor: &mut Editor| {
log::debug!("Applying code actions on save {:?}", code_actions);
code_actions
.iter()
.map(|code_action| apply_code_action(editor, code_action))
.collect()
}));
Ok(call)
}

#[derive(PartialEq, Eq)]
pub enum Open {
Below,
Expand Down
187 changes: 132 additions & 55 deletions helix-term/src/commands/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use tui::{
use super::{align_view, push_jump, Align, Context, Editor, Open};

use helix_core::{
path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Selection,
path, syntax::LanguageServerFeature, text_annotations::InlineAnnotation, Range, Selection,
};
use helix_view::{
document::{DocumentInlayHints, DocumentInlayHintsId, Mode},
Expand Down Expand Up @@ -542,7 +542,8 @@ pub fn workspace_diagnostics_picker(cx: &mut Context) {
cx.push_layer(Box::new(overlaid(picker)));
}

struct CodeActionOrCommandItem {
#[derive(Debug)]
pub struct CodeActionOrCommandItem {
lsp_item: lsp::CodeActionOrCommand,
language_server_id: usize,
}
Expand Down Expand Up @@ -619,34 +620,8 @@ pub fn code_action(cx: &mut Context) {

let selection_range = doc.selection(view.id).primary();

let mut seen_language_servers = HashSet::new();

let mut futures: FuturesUnordered<_> = 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))
})
let mut futures: FuturesUnordered<_> = code_actions_for_range(doc, selection_range)
.into_iter()
.map(|(request, ls_id)| async move {
let json = request.await?;
let response: Option<lsp::CodeActionResponse> = serde_json::from_value(json)?;
Expand Down Expand Up @@ -734,31 +709,7 @@ pub fn code_action(cx: &mut Context) {

// 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);
execute_lsp_command(editor, action.language_server_id, 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);
let _ = 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, action.language_server_id, command.clone());
}
}
}
apply_code_action(editor, action);
});
picker.move_down(); // pre-select the first item

Expand All @@ -770,6 +721,132 @@ pub fn code_action(cx: &mut Context) {
});
}

pub fn code_actions_for_range(
doc: &mut Document,
range: helix_core::Range,
) -> Vec<(impl Future<Output = Result<Value, helix_lsp::Error>>, usize)> {
let mut seen_language_servers = HashSet::new();

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 lsp_range = range_to_lsp_range(doc.text(), range, offset_encoding);

match language_server.code_actions(
doc.identifier(),
lsp_range,
// Filter and convert overlapping diagnostics
lsp::CodeActionContext {
diagnostics: doc
.diagnostics()
.iter()
.filter(|&diag| {
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),
},
) {
Some(request) => Some((request, language_server.id())),
None => None,
}
})
.collect::<Vec<_>>()
}

pub fn code_actions_on_save(
doc: &mut Document,
) -> Option<
FuturesUnordered<impl Future<Output = Result<Vec<CodeActionOrCommandItem>, anyhow::Error>>>,
> {
let code_actions_on_save_cfg = doc
.language_config()
.map(|c| c.code_actions_on_save.clone())?;

if code_actions_on_save_cfg.is_empty() {
return None;
}

let full_range = Range::new(0, doc.text().len_chars());

Some(
code_actions_for_range(doc, full_range)
.into_iter()
.map(|(request, ls_id)| {
let code_actions_on_save = code_actions_on_save_cfg.clone();
async move {
log::debug!("Configured code actions on save {:?}", code_actions_on_save);
let json = request.await?;
let response: Option<helix_lsp::lsp::CodeActionResponse> =
serde_json::from_value(json)?;
let available_code_actions = match response {
Some(value) => value,
None => helix_lsp::lsp::CodeActionResponse::default(),
};
log::debug!("Available code actions {:?}", available_code_actions);

let code_actions: Vec<CodeActionOrCommand> = available_code_actions
.into_iter()
.filter(|action| match action {
helix_lsp::lsp::CodeActionOrCommand::CodeAction(x)
if x.disabled.is_none() =>
{
match &x.kind {
Some(kind) => code_actions_on_save.get(kind.as_str()).is_some(),
None => false,
}
}
_ => false,
})
.collect();

Ok(code_actions
.into_iter()
.map(|lsp_item| CodeActionOrCommandItem {
lsp_item,
language_server_id: ls_id,
})
.collect())
}
})
.collect(),
)
}

pub fn apply_code_action(editor: &mut Editor, code_action_item: &CodeActionOrCommandItem) {
let Some(language_server) = editor.language_server_by_id(code_action_item.language_server_id) else {
editor.set_error("Language Server disappeared");
return;
};

let offset_encoding = language_server.offset_encoding();

match code_action_item.lsp_item.clone() {
lsp::CodeActionOrCommand::Command(command) => {
log::debug!("code action command: {:?}", command);
execute_lsp_command(editor, code_action_item.language_server_id, command);
}
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);
let _ = 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, code_action_item.language_server_id, command);
}
}
}
}

impl ui::menu::Item for lsp::Command {
type Data = ();
fn format(&self, _data: &Self::Data) -> Row {
Expand Down
10 changes: 10 additions & 0 deletions helix-term/src/commands/typed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ fn write_impl(
let (view, doc) = current!(cx.editor);
let path = path.map(AsRef::as_ref);

if let Some(futures) = code_actions_on_save(doc) {
let callback = make_code_actions_on_save_callback(futures);
jobs.add(Job::with_callback(callback).wait_before_exiting());
}

let fmt = if editor_auto_fmt {
doc.auto_format().map(|fmt| {
let callback = make_format_callback(
Expand Down Expand Up @@ -677,6 +682,11 @@ pub fn write_all_impl(
return None;
}

if let Some(futures) = code_actions_on_save(doc) {
let callback = make_code_actions_on_save_callback(futures);
jobs.add(Job::with_callback(callback).wait_before_exiting());
}

// Look for a view to apply the formatting change to. If the document
// is in the current view, just use that. Otherwise, since we don't
// have any other metric available for better selection, just pick
Expand Down

0 comments on commit 1f6a002

Please sign in to comment.