Add `code_actions` as `formatter` type (#10121)

Thorsten Ball , JH Chabran , and Marshall Bowers created

This fixes #8992 and solves a problem that ESLint/Prettier/... users
have been running into:

They want to format _only_ with ESLint, which is *not* a primary
language server (so `formatter: language server` does not help) and it
is not a formatter.

What they want to use is what they get when they have configured
something like this:

```json
{
  "languages": {
    "JavaScript": {
      "code_actions_on_format": {
        "source.fixAll.eslint": true
      }
    }
  }
}
```

BUT they don't want to run the formatter.

So what this PR does is to add a new formatter type: `code_actions`.

With that, users can only use code actions to format:

```json
{
  "languages": {
    "JavaScript": {
      "formatter": {
        "code_actions": {
          "source.fixAll.eslint": true
        }
      }
    }
  }
}
```

This means that when formatting (via `editor: format` or on-save) only
the code actions that are specified are being executed, no formatter.


Release Notes:

- Added a new `formatter`/`format_on_save` option: `code_actions`. When
configured, this uses language server code actions to format a buffer.
This can be used if one wants to, for example, format a buffer with
ESLint and *not* run prettier or another formatter afterwards. Example
configuration: `{"languages": {"JavaScript": {"formatter":
{"code_actions": {"source.fixAll.eslint": true}}}}}`
([#8992](https://github.com/zed-industries/zed/issues/8992)).

---------

Co-authored-by: JH Chabran <jh@chabran.fr>
Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>

Change summary

crates/language/src/language_settings.rs |   7 
crates/project/src/prettier_support.rs   |   4 
crates/project/src/project.rs            | 205 +++++++++++++++----------
3 files changed, 131 insertions(+), 85 deletions(-)

Detailed changes

crates/language/src/language_settings.rs 🔗

@@ -241,7 +241,8 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: false
     pub always_treat_brackets_as_autoclosed: Option<bool>,
-    /// Which code actions to run on save
+    /// Which code actions to run on save after the formatter.
+    /// These are not run if formatting is off.
     ///
     /// Default: {} (or {"source.organizeImports": true} for Go).
     pub code_actions_on_format: Option<HashMap<String, bool>>,
@@ -292,6 +293,8 @@ pub enum FormatOnSave {
         /// The arguments to pass to the program.
         arguments: Arc<[String]>,
     },
+    /// Files should be formatted using code actions executed by language servers.
+    CodeActions(HashMap<String, bool>),
 }
 
 /// Controls how whitespace should be displayedin the editor.
@@ -325,6 +328,8 @@ pub enum Formatter {
         /// The arguments to pass to the program.
         arguments: Arc<[String]>,
     },
+    /// Files should be formatted using code actions executed by language servers.
+    CodeActions(HashMap<String, bool>),
 }
 
 /// The settings for inlay hints.

crates/project/src/prettier_support.rs 🔗

@@ -31,7 +31,9 @@ pub fn prettier_plugins_for_language<'a>(
 ) -> Option<&'a Vec<Arc<str>>> {
     match &language_settings.formatter {
         Formatter::Prettier { .. } | Formatter::Auto => {}
-        Formatter::LanguageServer | Formatter::External { .. } => return None,
+        Formatter::LanguageServer | Formatter::External { .. } | Formatter::CodeActions(_) => {
+            return None
+        }
     };
     if language.prettier_parser_name().is_some() {
         Some(language.prettier_plugins())

crates/project/src/project.rs 🔗

@@ -4539,93 +4539,27 @@ impl Project {
                 buffer.end_transaction(cx)
             })?;
 
-            for (lsp_adapter, language_server) in adapters_and_servers.iter() {
-                // Apply the code actions on
-                let code_actions: Vec<lsp::CodeActionKind> = settings
-                    .code_actions_on_format
-                    .iter()
-                    .flat_map(|(kind, enabled)| {
-                        if *enabled {
-                            Some(kind.clone().into())
-                        } else {
-                            None
-                        }
-                    })
-                    .collect();
-
-                #[allow(clippy::nonminimal_bool)]
-                if !code_actions.is_empty()
-                    && !(trigger == FormatTrigger::Save
-                        && settings.format_on_save == FormatOnSave::Off)
-                {
-                    let actions = project
-                        .update(&mut cx, |this, cx| {
-                            this.request_lsp(
-                                buffer.clone(),
-                                LanguageServerToQuery::Other(language_server.server_id()),
-                                GetCodeActions {
-                                    range: text::Anchor::MIN..text::Anchor::MAX,
-                                    kinds: Some(code_actions),
-                                },
-                                cx,
-                            )
-                        })?
-                        .await?;
-
-                    for mut action in actions {
-                        Self::try_resolve_code_action(&language_server, &mut action)
-                            .await
-                            .context("resolving a formatting code action")?;
-                        if let Some(edit) = action.lsp_action.edit {
-                            if edit.changes.is_none() && edit.document_changes.is_none() {
-                                continue;
-                            }
-
-                            let new = Self::deserialize_workspace_edit(
-                                project
-                                    .upgrade()
-                                    .ok_or_else(|| anyhow!("project dropped"))?,
-                                edit,
-                                push_to_history,
-                                lsp_adapter.clone(),
-                                language_server.clone(),
-                                &mut cx,
-                            )
-                            .await?;
-                            project_transaction.0.extend(new.0);
-                        }
-
-                        if let Some(command) = action.lsp_action.command {
-                            project.update(&mut cx, |this, _| {
-                                this.last_workspace_edits_by_language_server
-                                    .remove(&language_server.server_id());
-                            })?;
-
-                            language_server
-                                .request::<lsp::request::ExecuteCommand>(
-                                    lsp::ExecuteCommandParams {
-                                        command: command.command,
-                                        arguments: command.arguments.unwrap_or_default(),
-                                        ..Default::default()
-                                    },
-                                )
-                                .await?;
-
-                            project.update(&mut cx, |this, _| {
-                                project_transaction.0.extend(
-                                    this.last_workspace_edits_by_language_server
-                                        .remove(&language_server.server_id())
-                                        .unwrap_or_default()
-                                        .0,
-                                )
-                            })?;
-                        }
-                    }
-                }
+            // Apply the `code_actions_on_format` before we run the formatter.
+            let code_actions = deserialize_code_actions(&settings.code_actions_on_format);
+            #[allow(clippy::nonminimal_bool)]
+            if !code_actions.is_empty()
+                && !(trigger == FormatTrigger::Save && settings.format_on_save == FormatOnSave::Off)
+            {
+                Self::execute_code_actions_on_servers(
+                    &project,
+                    &adapters_and_servers,
+                    code_actions,
+                    buffer,
+                    push_to_history,
+                    &mut project_transaction,
+                    &mut cx,
+                )
+                .await?;
             }
 
             // Apply language-specific formatting using either the primary language server
             // or external command.
+            // Except for code actions, which are applied with all connected language servers.
             let primary_language_server = adapters_and_servers
                 .first()
                 .cloned()
@@ -4638,6 +4572,22 @@ impl Project {
             match (&settings.formatter, &settings.format_on_save) {
                 (_, FormatOnSave::Off) if trigger == FormatTrigger::Save => {}
 
+                (Formatter::CodeActions(code_actions), FormatOnSave::On | FormatOnSave::Off)
+                | (_, FormatOnSave::CodeActions(code_actions)) => {
+                    let code_actions = deserialize_code_actions(code_actions);
+                    if !code_actions.is_empty() {
+                        Self::execute_code_actions_on_servers(
+                            &project,
+                            &adapters_and_servers,
+                            code_actions,
+                            buffer,
+                            push_to_history,
+                            &mut project_transaction,
+                            &mut cx,
+                        )
+                        .await?;
+                    }
+                }
                 (Formatter::LanguageServer, FormatOnSave::On | FormatOnSave::Off)
                 | (_, FormatOnSave::LanguageServer) => {
                     if let Some((language_server, buffer_abs_path)) = server_and_buffer {
@@ -8832,6 +8782,82 @@ impl Project {
         anyhow::Ok(())
     }
 
+    async fn execute_code_actions_on_servers(
+        project: &WeakModel<Project>,
+        adapters_and_servers: &Vec<(Arc<CachedLspAdapter>, Arc<LanguageServer>)>,
+        code_actions: Vec<lsp::CodeActionKind>,
+        buffer: &Model<Buffer>,
+        push_to_history: bool,
+        project_transaction: &mut ProjectTransaction,
+        cx: &mut AsyncAppContext,
+    ) -> Result<(), anyhow::Error> {
+        for (lsp_adapter, language_server) in adapters_and_servers.iter() {
+            let code_actions = code_actions.clone();
+
+            let actions = project
+                .update(cx, move |this, cx| {
+                    let request = GetCodeActions {
+                        range: text::Anchor::MIN..text::Anchor::MAX,
+                        kinds: Some(code_actions),
+                    };
+                    let server = LanguageServerToQuery::Other(language_server.server_id());
+                    this.request_lsp(buffer.clone(), server, request, cx)
+                })?
+                .await?;
+
+            for mut action in actions {
+                Self::try_resolve_code_action(&language_server, &mut action)
+                    .await
+                    .context("resolving a formatting code action")?;
+
+                if let Some(edit) = action.lsp_action.edit {
+                    if edit.changes.is_none() && edit.document_changes.is_none() {
+                        continue;
+                    }
+
+                    let new = Self::deserialize_workspace_edit(
+                        project
+                            .upgrade()
+                            .ok_or_else(|| anyhow!("project dropped"))?,
+                        edit,
+                        push_to_history,
+                        lsp_adapter.clone(),
+                        language_server.clone(),
+                        cx,
+                    )
+                    .await?;
+                    project_transaction.0.extend(new.0);
+                }
+
+                if let Some(command) = action.lsp_action.command {
+                    project.update(cx, |this, _| {
+                        this.last_workspace_edits_by_language_server
+                            .remove(&language_server.server_id());
+                    })?;
+
+                    language_server
+                        .request::<lsp::request::ExecuteCommand>(lsp::ExecuteCommandParams {
+                            command: command.command,
+                            arguments: command.arguments.unwrap_or_default(),
+                            ..Default::default()
+                        })
+                        .await?;
+
+                    project.update(cx, |this, _| {
+                        project_transaction.0.extend(
+                            this.last_workspace_edits_by_language_server
+                                .remove(&language_server.server_id())
+                                .unwrap_or_default()
+                                .0,
+                        )
+                    })?;
+                }
+            }
+        }
+
+        Ok(())
+    }
+
     async fn handle_refresh_inlay_hints(
         this: Model<Self>,
         _: TypedEnvelope<proto::RefreshInlayHints>,
@@ -9671,6 +9697,19 @@ impl Project {
     }
 }
 
+fn deserialize_code_actions(code_actions: &HashMap<String, bool>) -> Vec<lsp::CodeActionKind> {
+    code_actions
+        .iter()
+        .flat_map(|(kind, enabled)| {
+            if *enabled {
+                Some(kind.clone().into())
+            } else {
+                None
+            }
+        })
+        .collect()
+}
+
 #[allow(clippy::too_many_arguments)]
 async fn search_snapshots(
     snapshots: &Vec<LocalSnapshot>,