Support OnTypeFormatting LSP request (#2517)

Kirill Bulatov created

Supports
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_onTypeFormatting

rust-analyzer uses this feature to add matching brackets semantically,
e.g.
before:
![Screenshot 2023-05-23 at 17 46
42](https://github.com/zed-industries/zed/assets/2690773/020e8448-23e6-4a38-8dbb-c9edf18062f7)

after:
![Screenshot 2023-05-23 at 17 46
49](https://github.com/zed-industries/zed/assets/2690773/4d140af3-aca6-451d-ac61-e2a9bb31caea)

`use_on_type_format` settings entry was added, enabled by default, to
disable the new feature.

Release Notes:

* Support `OnTypeFormatting` LSP protocol feature, allowing
rust-analyzer to add matching brackets

Change summary

assets/settings/default.json                 |   3 
crates/collab/src/rpc.rs                     |   1 
crates/collab/src/tests/integration_tests.rs | 259 ++++++++++++++++++++++
crates/editor/src/editor.rs                  |  55 ++++
crates/editor/src/editor_settings.rs         |   2 
crates/project/src/lsp_command.rs            | 162 +++++++++++++
crates/project/src/project.rs                | 175 +++++++++++++-
crates/rpc/proto/zed.proto                   |  15 +
crates/rpc/src/proto.rs                      |   4 
crates/rpc/src/rpc.rs                        |   2 
10 files changed, 664 insertions(+), 14 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -39,6 +39,9 @@
   // Whether to pop the completions menu while typing in an editor without
   // explicitly requesting it.
   "show_completions_on_input": true,
+  // Whether to use additional LSP queries to format (and amend) the code after
+  // every "trigger" symbol input, defined by LSP server capabilities.
+  "use_on_type_format": true,
   // Controls whether copilot provides suggestion immediately
   // or waits for a `copilot::Toggle`
   "show_copilot_suggestions": true,

crates/collab/src/rpc.rs 🔗

@@ -223,6 +223,7 @@ impl Server {
             .add_request_handler(forward_project_request::<proto::RenameProjectEntry>)
             .add_request_handler(forward_project_request::<proto::CopyProjectEntry>)
             .add_request_handler(forward_project_request::<proto::DeleteProjectEntry>)
+            .add_request_handler(forward_project_request::<proto::OnTypeFormatting>)
             .add_message_handler(create_buffer_for_peer)
             .add_request_handler(update_buffer)
             .add_message_handler(update_buffer_file)

crates/collab/src/tests/integration_tests.rs 🔗

@@ -7377,6 +7377,265 @@ async fn test_peers_simultaneously_following_each_other(
     });
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_on_input_format_from_host_to_guest(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // Set up a fake language server.
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+    let mut fake_language_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions {
+                    first_trigger_character: ":".to_string(),
+                    more_trigger_character: Some(vec![">".to_string()]),
+                }),
+                ..Default::default()
+            },
+            ..Default::default()
+        }))
+        .await;
+    client_a.language_registry.add(Arc::new(language));
+
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "main.rs": "fn main() { a }",
+                "other.rs": "// Test file",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+
+    // Open a file in an editor as the host.
+    let buffer_a = project_a
+        .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
+        .await
+        .unwrap();
+    let (window_a, _) = cx_a.add_window(|_| EmptyView);
+    let editor_a = cx_a.add_view(window_a, |cx| {
+        Editor::for_buffer(buffer_a, Some(project_a.clone()), cx)
+    });
+
+    let fake_language_server = fake_language_servers.next().await.unwrap();
+    cx_b.foreground().run_until_parked();
+
+    // Receive an OnTypeFormatting request as the host's language server.
+    // Return some formattings from the host's language server.
+    fake_language_server.handle_request::<lsp::request::OnTypeFormatting, _, _>(
+        |params, _| async move {
+            assert_eq!(
+                params.text_document_position.text_document.uri,
+                lsp::Url::from_file_path("/a/main.rs").unwrap(),
+            );
+            assert_eq!(
+                params.text_document_position.position,
+                lsp::Position::new(0, 14),
+            );
+
+            Ok(Some(vec![lsp::TextEdit {
+                new_text: "~<".to_string(),
+                range: lsp::Range::new(lsp::Position::new(0, 14), lsp::Position::new(0, 14)),
+            }]))
+        },
+    );
+
+    // Open the buffer on the guest and see that the formattings worked
+    let buffer_b = project_b
+        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
+        .await
+        .unwrap();
+
+    // Type a on type formatting trigger character as the guest.
+    editor_a.update(cx_a, |editor, cx| {
+        cx.focus(&editor_a);
+        editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
+        editor.handle_input(">", cx);
+    });
+
+    cx_b.foreground().run_until_parked();
+
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a>~< }")
+    });
+
+    // Undo should remove LSP edits first
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(editor.text(cx), "fn main() { a>~< }");
+        editor.undo(&Undo, cx);
+        assert_eq!(editor.text(cx), "fn main() { a> }");
+    });
+    cx_b.foreground().run_until_parked();
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a> }")
+    });
+
+    editor_a.update(cx_a, |editor, cx| {
+        assert_eq!(editor.text(cx), "fn main() { a> }");
+        editor.undo(&Undo, cx);
+        assert_eq!(editor.text(cx), "fn main() { a }");
+    });
+    cx_b.foreground().run_until_parked();
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a }")
+    });
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_on_input_format_from_guest_to_host(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // Set up a fake language server.
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+    let mut fake_language_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions {
+                    first_trigger_character: ":".to_string(),
+                    more_trigger_character: Some(vec![">".to_string()]),
+                }),
+                ..Default::default()
+            },
+            ..Default::default()
+        }))
+        .await;
+    client_a.language_registry.add(Arc::new(language));
+
+    client_a
+        .fs
+        .insert_tree(
+            "/a",
+            json!({
+                "main.rs": "fn main() { a }",
+                "other.rs": "// Test file",
+            }),
+        )
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+
+    // Open a file in an editor as the guest.
+    let buffer_b = project_b
+        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
+        .await
+        .unwrap();
+    let (window_b, _) = cx_b.add_window(|_| EmptyView);
+    let editor_b = cx_b.add_view(window_b, |cx| {
+        Editor::for_buffer(buffer_b, Some(project_b.clone()), cx)
+    });
+
+    let fake_language_server = fake_language_servers.next().await.unwrap();
+    cx_a.foreground().run_until_parked();
+    // Type a on type formatting trigger character as the guest.
+    editor_b.update(cx_b, |editor, cx| {
+        editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
+        editor.handle_input(":", cx);
+        cx.focus(&editor_b);
+    });
+
+    // Receive an OnTypeFormatting request as the host's language server.
+    // Return some formattings from the host's language server.
+    cx_a.foreground().start_waiting();
+    fake_language_server
+        .handle_request::<lsp::request::OnTypeFormatting, _, _>(|params, _| async move {
+            assert_eq!(
+                params.text_document_position.text_document.uri,
+                lsp::Url::from_file_path("/a/main.rs").unwrap(),
+            );
+            assert_eq!(
+                params.text_document_position.position,
+                lsp::Position::new(0, 14),
+            );
+
+            Ok(Some(vec![lsp::TextEdit {
+                new_text: "~:".to_string(),
+                range: lsp::Range::new(lsp::Position::new(0, 14), lsp::Position::new(0, 14)),
+            }]))
+        })
+        .next()
+        .await
+        .unwrap();
+    cx_a.foreground().finish_waiting();
+
+    // Open the buffer on the host and see that the formattings worked
+    let buffer_a = project_a
+        .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
+        .await
+        .unwrap();
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a:~: }")
+    });
+
+    // Undo should remove LSP edits first
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(editor.text(cx), "fn main() { a:~: }");
+        editor.undo(&Undo, cx);
+        assert_eq!(editor.text(cx), "fn main() { a: }");
+    });
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a: }")
+    });
+
+    editor_b.update(cx_b, |editor, cx| {
+        assert_eq!(editor.text(cx), "fn main() { a: }");
+        editor.undo(&Undo, cx);
+        assert_eq!(editor.text(cx), "fn main() { a }");
+    });
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a }")
+    });
+}
+
 #[derive(Debug, Eq, PartialEq)]
 struct RoomParticipants {
     remote: Vec<String>,

crates/editor/src/editor.rs 🔗

@@ -2122,6 +2122,15 @@ impl Editor {
             let had_active_copilot_suggestion = this.has_active_copilot_suggestion(cx);
             this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(new_selections));
 
+            // When buffer contents is updated and caret is moved, try triggering on type formatting.
+            if settings::get::<EditorSettings>(cx).use_on_type_format {
+                if let Some(on_type_format_task) =
+                    this.trigger_on_type_formatting(text.to_string(), cx)
+                {
+                    on_type_format_task.detach_and_log_err(cx);
+                }
+            }
+
             if had_active_copilot_suggestion {
                 this.refresh_copilot_suggestions(true, cx);
                 if !this.has_active_copilot_suggestion(cx) {
@@ -2500,6 +2509,52 @@ impl Editor {
         }
     }
 
+    fn trigger_on_type_formatting(
+        &self,
+        input: String,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        if input.len() != 1 {
+            return None;
+        }
+
+        let project = self.project.as_ref()?;
+        let position = self.selections.newest_anchor().head();
+        let (buffer, buffer_position) = self
+            .buffer
+            .read(cx)
+            .text_anchor_for_position(position.clone(), cx)?;
+
+        // OnTypeFormatting retuns a list of edits, no need to pass them between Zed instances,
+        // hence we do LSP request & edit on host side only — add formats to host's history.
+        let push_to_lsp_host_history = true;
+        // If this is not the host, append its history with new edits.
+        let push_to_client_history = project.read(cx).is_remote();
+
+        let on_type_formatting = project.update(cx, |project, cx| {
+            project.on_type_format(
+                buffer.clone(),
+                buffer_position,
+                input,
+                push_to_lsp_host_history,
+                cx,
+            )
+        });
+        Some(cx.spawn(|editor, mut cx| async move {
+            if let Some(transaction) = on_type_formatting.await? {
+                if push_to_client_history {
+                    buffer.update(&mut cx, |buffer, _| {
+                        buffer.push_transaction(transaction, Instant::now());
+                    });
+                }
+                editor.update(&mut cx, |editor, cx| {
+                    editor.refresh_document_highlights(cx);
+                })?;
+            }
+            Ok(())
+        }))
+    }
+
     fn show_completions(&mut self, _: &ShowCompletions, cx: &mut ViewContext<Self>) {
         if self.pending_rename.is_some() {
             return;

crates/editor/src/editor_settings.rs 🔗

@@ -7,6 +7,7 @@ pub struct EditorSettings {
     pub cursor_blink: bool,
     pub hover_popover_enabled: bool,
     pub show_completions_on_input: bool,
+    pub use_on_type_format: bool,
     pub scrollbar: Scrollbar,
 }
 
@@ -30,6 +31,7 @@ pub struct EditorSettingsContent {
     pub cursor_blink: Option<bool>,
     pub hover_popover_enabled: Option<bool>,
     pub show_completions_on_input: Option<bool>,
+    pub use_on_type_format: Option<bool>,
     pub scrollbar: Option<ScrollbarContent>,
 }
 

crates/project/src/lsp_command.rs 🔗

@@ -8,14 +8,24 @@ use client::proto::{self, PeerId};
 use fs::LineEnding;
 use gpui::{AppContext, AsyncAppContext, ModelHandle};
 use language::{
+    language_settings::language_settings,
     point_from_lsp, point_to_lsp,
     proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version},
     range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, CachedLspAdapter, CharKind, CodeAction,
-    Completion, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Unclipped,
+    Completion, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped,
 };
 use lsp::{DocumentHighlightKind, LanguageServer, LanguageServerId, ServerCapabilities};
 use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc};
 
+pub fn lsp_formatting_options(tab_size: u32) -> lsp::FormattingOptions {
+    lsp::FormattingOptions {
+        tab_size,
+        insert_spaces: true,
+        insert_final_newline: Some(true),
+        ..lsp::FormattingOptions::default()
+    }
+}
+
 #[async_trait(?Send)]
 pub(crate) trait LspCommand: 'static + Sized {
     type Response: 'static + Default + Send;
@@ -109,6 +119,25 @@ pub(crate) struct GetCodeActions {
     pub range: Range<Anchor>,
 }
 
+pub(crate) struct OnTypeFormatting {
+    pub position: PointUtf16,
+    pub trigger: String,
+    pub options: FormattingOptions,
+    pub push_to_history: bool,
+}
+
+pub(crate) struct FormattingOptions {
+    tab_size: u32,
+}
+
+impl From<lsp::FormattingOptions> for FormattingOptions {
+    fn from(value: lsp::FormattingOptions) -> Self {
+        Self {
+            tab_size: value.tab_size,
+        }
+    }
+}
+
 #[async_trait(?Send)]
 impl LspCommand for PrepareRename {
     type Response = Option<Range<Anchor>>;
@@ -1596,3 +1625,134 @@ impl LspCommand for GetCodeActions {
         message.buffer_id
     }
 }
+
+#[async_trait(?Send)]
+impl LspCommand for OnTypeFormatting {
+    type Response = Option<Transaction>;
+    type LspRequest = lsp::request::OnTypeFormatting;
+    type ProtoRequest = proto::OnTypeFormatting;
+
+    fn check_capabilities(&self, server_capabilities: &lsp::ServerCapabilities) -> bool {
+        let Some(on_type_formatting_options) = &server_capabilities.document_on_type_formatting_provider else { return false };
+        on_type_formatting_options
+            .first_trigger_character
+            .contains(&self.trigger)
+            || on_type_formatting_options
+                .more_trigger_character
+                .iter()
+                .flatten()
+                .any(|chars| chars.contains(&self.trigger))
+    }
+
+    fn to_lsp(
+        &self,
+        path: &Path,
+        _: &Buffer,
+        _: &Arc<LanguageServer>,
+        _: &AppContext,
+    ) -> lsp::DocumentOnTypeFormattingParams {
+        lsp::DocumentOnTypeFormattingParams {
+            text_document_position: lsp::TextDocumentPositionParams::new(
+                lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path(path).unwrap()),
+                point_to_lsp(self.position),
+            ),
+            ch: self.trigger.clone(),
+            options: lsp_formatting_options(self.options.tab_size),
+        }
+    }
+
+    async fn response_from_lsp(
+        self,
+        message: Option<Vec<lsp::TextEdit>>,
+        project: ModelHandle<Project>,
+        buffer: ModelHandle<Buffer>,
+        server_id: LanguageServerId,
+        mut cx: AsyncAppContext,
+    ) -> Result<Option<Transaction>> {
+        if let Some(edits) = message {
+            let (lsp_adapter, lsp_server) =
+                language_server_for_buffer(&project, &buffer, server_id, &mut cx)?;
+            Project::deserialize_edits(
+                project,
+                buffer,
+                edits,
+                self.push_to_history,
+                lsp_adapter,
+                lsp_server,
+                &mut cx,
+            )
+            .await
+        } else {
+            Ok(None)
+        }
+    }
+
+    fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::OnTypeFormatting {
+        proto::OnTypeFormatting {
+            project_id,
+            buffer_id: buffer.remote_id(),
+            position: Some(language::proto::serialize_anchor(
+                &buffer.anchor_before(self.position),
+            )),
+            trigger: self.trigger.clone(),
+            version: serialize_version(&buffer.version()),
+        }
+    }
+
+    async fn from_proto(
+        message: proto::OnTypeFormatting,
+        _: ModelHandle<Project>,
+        buffer: ModelHandle<Buffer>,
+        mut cx: AsyncAppContext,
+    ) -> Result<Self> {
+        let position = message
+            .position
+            .and_then(deserialize_anchor)
+            .ok_or_else(|| anyhow!("invalid position"))?;
+        buffer
+            .update(&mut cx, |buffer, _| {
+                buffer.wait_for_version(deserialize_version(&message.version))
+            })
+            .await?;
+
+        let tab_size = buffer.read_with(&cx, |buffer, cx| {
+            let language_name = buffer.language().map(|language| language.name());
+            language_settings(language_name.as_deref(), cx).tab_size
+        });
+
+        Ok(Self {
+            position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+            trigger: message.trigger.clone(),
+            options: lsp_formatting_options(tab_size.get()).into(),
+            push_to_history: false,
+        })
+    }
+
+    fn response_to_proto(
+        response: Option<Transaction>,
+        _: &mut Project,
+        _: PeerId,
+        _: &clock::Global,
+        _: &mut AppContext,
+    ) -> proto::OnTypeFormattingResponse {
+        proto::OnTypeFormattingResponse {
+            transaction: response
+                .map(|transaction| language::proto::serialize_transaction(&transaction)),
+        }
+    }
+
+    async fn response_from_proto(
+        self,
+        message: proto::OnTypeFormattingResponse,
+        _: ModelHandle<Project>,
+        _: ModelHandle<Buffer>,
+        _: AsyncAppContext,
+    ) -> Result<Option<Transaction>> {
+        let Some(transaction) = message.transaction else { return Ok(None) };
+        Ok(Some(language::proto::deserialize_transaction(transaction)?))
+    }
+
+    fn buffer_id_from_proto(message: &proto::OnTypeFormatting) -> u64 {
+        message.buffer_id
+    }
+}

crates/project/src/project.rs 🔗

@@ -417,6 +417,7 @@ impl Project {
         client.add_model_request_handler(Self::handle_delete_project_entry);
         client.add_model_request_handler(Self::handle_apply_additional_edits_for_completion);
         client.add_model_request_handler(Self::handle_apply_code_action);
+        client.add_model_request_handler(Self::handle_on_type_formatting);
         client.add_model_request_handler(Self::handle_reload_buffers);
         client.add_model_request_handler(Self::handle_synchronize_buffers);
         client.add_model_request_handler(Self::handle_format_buffers);
@@ -3476,12 +3477,7 @@ impl Project {
             language_server
                 .request::<lsp::request::Formatting>(lsp::DocumentFormattingParams {
                     text_document,
-                    options: lsp::FormattingOptions {
-                        tab_size: tab_size.into(),
-                        insert_spaces: true,
-                        insert_final_newline: Some(true),
-                        ..Default::default()
-                    },
+                    options: lsp_command::lsp_formatting_options(tab_size.get()),
                     work_done_progress_params: Default::default(),
                 })
                 .await?
@@ -3497,12 +3493,7 @@ impl Project {
                 .request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
                     text_document,
                     range: lsp::Range::new(buffer_start, buffer_end),
-                    options: lsp::FormattingOptions {
-                        tab_size: tab_size.into(),
-                        insert_spaces: true,
-                        insert_final_newline: Some(true),
-                        ..Default::default()
-                    },
+                    options: lsp_command::lsp_formatting_options(tab_size.get()),
                     work_done_progress_params: Default::default(),
                 })
                 .await?
@@ -4044,6 +4035,109 @@ impl Project {
         }
     }
 
+    fn apply_on_type_formatting(
+        &self,
+        buffer: ModelHandle<Buffer>,
+        position: Anchor,
+        trigger: String,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Option<Transaction>>> {
+        if self.is_local() {
+            cx.spawn(|this, mut cx| async move {
+                // Do not allow multiple concurrent formatting requests for the
+                // same buffer.
+                this.update(&mut cx, |this, cx| {
+                    this.buffers_being_formatted
+                        .insert(buffer.read(cx).remote_id())
+                });
+
+                let _cleanup = defer({
+                    let this = this.clone();
+                    let mut cx = cx.clone();
+                    let closure_buffer = buffer.clone();
+                    move || {
+                        this.update(&mut cx, |this, cx| {
+                            this.buffers_being_formatted
+                                .remove(&closure_buffer.read(cx).remote_id());
+                        });
+                    }
+                });
+
+                buffer
+                    .update(&mut cx, |buffer, _| {
+                        buffer.wait_for_edits(Some(position.timestamp))
+                    })
+                    .await?;
+                this.update(&mut cx, |this, cx| {
+                    let position = position.to_point_utf16(buffer.read(cx));
+                    this.on_type_format(buffer, position, trigger, false, cx)
+                })
+                .await
+            })
+        } else if let Some(project_id) = self.remote_id() {
+            let client = self.client.clone();
+            let request = proto::OnTypeFormatting {
+                project_id,
+                buffer_id: buffer.read(cx).remote_id(),
+                position: Some(serialize_anchor(&position)),
+                trigger,
+                version: serialize_version(&buffer.read(cx).version()),
+            };
+            cx.spawn(|_, _| async move {
+                client
+                    .request(request)
+                    .await?
+                    .transaction
+                    .map(language::proto::deserialize_transaction)
+                    .transpose()
+            })
+        } else {
+            Task::ready(Err(anyhow!("project does not have a remote id")))
+        }
+    }
+
+    async fn deserialize_edits(
+        this: ModelHandle<Self>,
+        buffer_to_edit: ModelHandle<Buffer>,
+        edits: Vec<lsp::TextEdit>,
+        push_to_history: bool,
+        _: Arc<CachedLspAdapter>,
+        language_server: Arc<LanguageServer>,
+        cx: &mut AsyncAppContext,
+    ) -> Result<Option<Transaction>> {
+        let edits = this
+            .update(cx, |this, cx| {
+                this.edits_from_lsp(
+                    &buffer_to_edit,
+                    edits,
+                    language_server.server_id(),
+                    None,
+                    cx,
+                )
+            })
+            .await?;
+
+        let transaction = buffer_to_edit.update(cx, |buffer, cx| {
+            buffer.finalize_last_transaction();
+            buffer.start_transaction();
+            for (range, text) in edits {
+                buffer.edit([(range, text)], None, cx);
+            }
+
+            if buffer.end_transaction(cx).is_some() {
+                let transaction = buffer.finalize_last_transaction().unwrap().clone();
+                if !push_to_history {
+                    buffer.forget_transaction(transaction.id);
+                }
+                Some(transaction)
+            } else {
+                None
+            }
+        });
+
+        Ok(transaction)
+    }
+
     async fn deserialize_workspace_edit(
         this: ModelHandle<Self>,
         edit: lsp::WorkspaceEdit,
@@ -4209,6 +4303,31 @@ impl Project {
         )
     }
 
+    pub fn on_type_format<T: ToPointUtf16>(
+        &self,
+        buffer: ModelHandle<Buffer>,
+        position: T,
+        trigger: String,
+        push_to_history: bool,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Option<Transaction>>> {
+        let tab_size = buffer.read_with(cx, |buffer, cx| {
+            let language_name = buffer.language().map(|language| language.name());
+            language_settings(language_name.as_deref(), cx).tab_size
+        });
+        let position = position.to_point_utf16(buffer.read(cx));
+        self.request_lsp(
+            buffer.clone(),
+            OnTypeFormatting {
+                position,
+                trigger,
+                options: lsp_command::lsp_formatting_options(tab_size.get()).into(),
+                push_to_history,
+            },
+            cx,
+        )
+    }
+
     #[allow(clippy::type_complexity)]
     pub fn search(
         &self,
@@ -5779,6 +5898,38 @@ impl Project {
         })
     }
 
+    async fn handle_on_type_formatting(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::OnTypeFormatting>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::OnTypeFormattingResponse> {
+        let on_type_formatting = this.update(&mut cx, |this, cx| {
+            let buffer = this
+                .opened_buffers
+                .get(&envelope.payload.buffer_id)
+                .and_then(|buffer| buffer.upgrade(cx))
+                .ok_or_else(|| anyhow!("unknown buffer id {}", envelope.payload.buffer_id))?;
+            let position = envelope
+                .payload
+                .position
+                .and_then(deserialize_anchor)
+                .ok_or_else(|| anyhow!("invalid position"))?;
+            Ok::<_, anyhow::Error>(this.apply_on_type_formatting(
+                buffer,
+                position,
+                envelope.payload.trigger.clone(),
+                cx,
+            ))
+        })?;
+
+        let transaction = on_type_formatting
+            .await?
+            .as_ref()
+            .map(language::proto::serialize_transaction);
+        Ok(proto::OnTypeFormattingResponse { transaction })
+    }
+
     async fn handle_lsp_command<T: LspCommand>(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<T::ProtoRequest>,

crates/rpc/proto/zed.proto 🔗

@@ -129,6 +129,9 @@ message Envelope {
         GetPrivateUserInfo get_private_user_info = 105;
         GetPrivateUserInfoResponse get_private_user_info_response = 106;
         UpdateDiffBase update_diff_base = 107;
+
+        OnTypeFormatting on_type_formatting = 111;
+        OnTypeFormattingResponse on_type_formatting_response = 112;
     }
 }
 
@@ -670,6 +673,18 @@ message PerformRename {
     repeated VectorClockEntry version = 5;
 }
 
+message OnTypeFormatting {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    Anchor position = 3;
+    string trigger = 4;
+    repeated VectorClockEntry version = 5;
+}
+
+message OnTypeFormattingResponse {
+    Transaction transaction = 1;
+}
+
 message PerformRenameResponse {
     ProjectTransaction transaction = 2;
 }

crates/rpc/src/proto.rs 🔗

@@ -195,6 +195,8 @@ messages!(
     (OpenBufferResponse, Background),
     (PerformRename, Background),
     (PerformRenameResponse, Background),
+    (OnTypeFormatting, Background),
+    (OnTypeFormattingResponse, Background),
     (Ping, Foreground),
     (PrepareRename, Background),
     (PrepareRenameResponse, Background),
@@ -279,6 +281,7 @@ request_messages!(
     (Ping, Ack),
     (PerformRename, PerformRenameResponse),
     (PrepareRename, PrepareRenameResponse),
+    (OnTypeFormatting, OnTypeFormattingResponse),
     (ReloadBuffers, ReloadBuffersResponse),
     (RequestContact, Ack),
     (RemoveContact, Ack),
@@ -323,6 +326,7 @@ entity_messages!(
     OpenBufferByPath,
     OpenBufferForSymbol,
     PerformRename,
+    OnTypeFormatting,
     PrepareRename,
     ReloadBuffers,
     RemoveProjectCollaborator,

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 55;
+pub const PROTOCOL_VERSION: u32 = 56;