Deduplicate edits from WorkspaceEdit LSP responses (#22512)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/21515

Release Notes:

- Fixed zls renames applying duplicate edits

Change summary

crates/editor/src/editor_tests.rs | 58 ++++++++++++++++++++++++++++++++
crates/project/src/lsp_store.rs   | 19 ++++++++--
2 files changed, 72 insertions(+), 5 deletions(-)

Detailed changes

crates/editor/src/editor_tests.rs 🔗

@@ -11260,7 +11260,7 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
             },
             ..Default::default()
         },
-        Some(tree_sitter_rust::LANGUAGE.into()),
+        Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
     )));
     update_test_language_settings(cx, |settings| {
         settings.defaults.prettier = Some(PrettierSettings {
@@ -14732,6 +14732,62 @@ fn test_inline_completion_text_with_deletions(cx: &mut TestAppContext) {
     }
 }
 
+#[gpui::test]
+async fn test_rename_with_duplicate_edits(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+    let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
+
+    cx.set_state(indoc! {"
+        struct Fˇoo {}
+    "});
+
+    cx.update_editor(|editor, cx| {
+        let highlight_range = Point::new(0, 7)..Point::new(0, 10);
+        let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx));
+        editor.highlight_background::<DocumentHighlightRead>(
+            &[highlight_range],
+            |c| c.editor_document_highlight_read_background,
+            cx,
+        );
+    });
+
+    cx.update_editor(|e, cx| e.rename(&Rename, cx))
+        .expect("Rename was not started")
+        .await
+        .expect("Rename failed");
+    let mut rename_handler =
+        cx.handle_request::<lsp::request::Rename, _, _>(move |url, _, _| async move {
+            let edit = lsp::TextEdit {
+                range: lsp::Range {
+                    start: lsp::Position {
+                        line: 0,
+                        character: 7,
+                    },
+                    end: lsp::Position {
+                        line: 0,
+                        character: 10,
+                    },
+                },
+                new_text: "FooRenamed".to_string(),
+            };
+            Ok(Some(lsp::WorkspaceEdit::new(
+                // Specify the same edit twice
+                std::collections::HashMap::from_iter(Some((url, vec![edit.clone(), edit]))),
+            )))
+        });
+    cx.update_editor(|e, cx| e.confirm_rename(&ConfirmRename, cx))
+        .expect("Confirm rename was not started")
+        .await
+        .expect("Confirm rename failed");
+    rename_handler.next().await.unwrap();
+    cx.run_until_parked();
+
+    // Despite two edits, only one is actually applied as those are identical
+    cx.assert_editor_state(indoc! {"
+        struct FooRenamedˇ {}
+    "});
+}
+
 fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
     let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
     point..point

crates/project/src/lsp_store.rs 🔗

@@ -2353,8 +2353,16 @@ impl LocalLspStore {
                             let (mut edits, mut snippet_edits) = (vec![], vec![]);
                             for edit in op.edits {
                                 match edit {
-                                    Edit::Plain(edit) => edits.push(edit),
-                                    Edit::Annotated(edit) => edits.push(edit.text_edit),
+                                    Edit::Plain(edit) => {
+                                        if !edits.contains(&edit) {
+                                            edits.push(edit)
+                                        }
+                                    }
+                                    Edit::Annotated(edit) => {
+                                        if !edits.contains(&edit.text_edit) {
+                                            edits.push(edit.text_edit)
+                                        }
+                                    }
                                     Edit::Snippet(edit) => {
                                         let Ok(snippet) = Snippet::parse(&edit.snippet.value)
                                         else {
@@ -2365,10 +2373,13 @@ impl LocalLspStore {
                                             snippet_edits.push((edit.range, snippet));
                                         } else {
                                             // Since this buffer is not focused, apply a normal edit.
-                                            edits.push(TextEdit {
+                                            let new_edit = TextEdit {
                                                 range: edit.range,
                                                 new_text: snippet.text,
-                                            });
+                                            };
+                                            if !edits.contains(&new_edit) {
+                                                edits.push(new_edit);
+                                            }
                                         }
                                     }
                                 }