lsp: Add support for linked editing range edits (HTML tag autorenaming) (#12769)

Piotr Osiewicz and Bennet created

This PR adds support for [linked editing of
ranges](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_linkedEditingRange),
which in short means that editing one part of a file can now change
related parts in that same file. Think of automatically renaming
HTML/TSX closing tags when the opening one is changed.
TODO:
- [x] proto changes
- [x] Allow disabling linked editing ranges on a per language basis.

Fixes #4535 

Release Notes:
- Added support for linked editing ranges LSP request. Editing opening
tags in HTML/TSX files (with vtsls) performs the same edit on the
closing tag as well (and vice versa). It can be turned off on a language-by-language basis with the following setting:
```
  "languages": {
    "HTML": {
      "linked_edits": true
    },
  }
```

---------

Co-authored-by: Bennet <bennet@zed.dev>

Change summary

assets/settings/default.json               |  12 +
crates/collab/src/rpc.rs                   |   3 
crates/editor/src/editor.rs                | 168 +++++++++++++++++++++++
crates/editor/src/linked_editing_ranges.rs | 150 +++++++++++++++++++++
crates/language/src/language_settings.rs   |   8 +
crates/project/src/lsp_command.rs          | 153 +++++++++++++++++++++
crates/project/src/project.rs              |  61 ++++++++
crates/proto/proto/zed.proto               |  23 +++
crates/proto/src/proto.rs                  |   4 
9 files changed, 574 insertions(+), 8 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -131,7 +131,14 @@
   // The default number of lines to expand excerpts in the multibuffer by.
   "expand_excerpt_lines": 3,
   // Globs to match against file paths to determine if a file is private.
-  "private_files": ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"],
+  "private_files": [
+    "**/.env*",
+    "**/*.pem",
+    "**/*.key",
+    "**/*.cert",
+    "**/*.crt",
+    "**/secrets.yml"
+  ],
   // 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,
@@ -354,6 +361,9 @@
   "show_call_status_icon": true,
   // Whether to use language servers to provide code intelligence.
   "enable_language_server": true,
+  // Whether to perform linked edits of associated ranges, if the language server supports it.
+  // For example, when editing opening <html> tag, the contents of the closing </html> tag will be edited as well.
+  "linked_edits": true,
   // The list of language servers to use (or disable) for all languages.
   //
   // This is typically customized on a per-language basis.

crates/collab/src/rpc.rs 🔗

@@ -548,6 +548,9 @@ impl Server {
             .add_request_handler(user_handler(
                 forward_mutating_project_request::<proto::RestartLanguageServers>,
             ))
+            .add_request_handler(user_handler(
+                forward_mutating_project_request::<proto::LinkedEditingRange>,
+            ))
             .add_message_handler(create_buffer_for_peer)
             .add_request_handler(update_buffer)
             .add_message_handler(broadcast_project_message_from_host::<proto::RefreshInlayHints>)

crates/editor/src/editor.rs 🔗

@@ -28,6 +28,7 @@ mod indent_guides;
 mod inlay_hint_cache;
 mod inline_completion_provider;
 pub mod items;
+mod linked_editing_ranges;
 mod mouse_context_menu;
 pub mod movement;
 mod persistence;
@@ -88,6 +89,7 @@ use language::{
     Point, Selection, SelectionGoal, TransactionId,
 };
 use language::{BufferRow, Runnable, RunnableRange};
+use linked_editing_ranges::refresh_linked_ranges;
 use task::{ResolvedTask, TaskTemplate, TaskVariables};
 
 use hover_links::{HoverLink, HoveredLinkState, InlayHighlight};
@@ -478,6 +480,8 @@ pub struct Editor {
     available_code_actions: Option<(Location, Arc<[CodeAction]>)>,
     code_actions_task: Option<Task<()>>,
     document_highlights_task: Option<Task<()>>,
+    linked_editing_range_task: Option<Task<Option<()>>>,
+    linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges,
     pending_rename: Option<RenameState>,
     searchable: bool,
     cursor_shape: CursorShape,
@@ -1768,6 +1772,7 @@ impl Editor {
             available_code_actions: Default::default(),
             code_actions_task: Default::default(),
             document_highlights_task: Default::default(),
+            linked_editing_range_task: Default::default(),
             pending_rename: Default::default(),
             searchable: true,
             cursor_shape: Default::default(),
@@ -1828,6 +1833,7 @@ impl Editor {
                 }),
             ],
             tasks_update_task: None,
+            linked_edit_ranges: Default::default(),
             previous_search_ranges: None,
         };
         this.tasks_update_task = Some(this.refresh_runnables(cx));
@@ -2208,7 +2214,6 @@ impl Editor {
                 )
             });
         }
-
         let display_map = self
             .display_map
             .update(cx, |display_map, cx| display_map.snapshot(cx));
@@ -2296,6 +2301,7 @@ impl Editor {
             self.refresh_document_highlights(cx);
             refresh_matching_bracket_highlights(self, cx);
             self.discard_inline_completion(false, cx);
+            linked_editing_ranges::refresh_linked_ranges(self, cx);
             if self.git_blame_inline_enabled {
                 self.start_inline_blame_timer(cx);
             }
@@ -2307,7 +2313,6 @@ impl Editor {
         if self.selections.disjoint_anchors().len() == 1 {
             cx.emit(SearchEvent::ActiveMatchChanged)
         }
-
         cx.notify();
     }
 
@@ -2777,6 +2782,49 @@ impl Editor {
         false
     }
 
+    fn linked_editing_ranges_for(
+        &self,
+        selection: Range<text::Anchor>,
+        cx: &AppContext,
+    ) -> Option<HashMap<Model<Buffer>, Vec<Range<text::Anchor>>>> {
+        if self.linked_edit_ranges.is_empty() {
+            return None;
+        }
+        let ((base_range, linked_ranges), buffer_snapshot, buffer) =
+            selection.end.buffer_id.and_then(|end_buffer_id| {
+                if selection.start.buffer_id != Some(end_buffer_id) {
+                    return None;
+                }
+                let buffer = self.buffer.read(cx).buffer(end_buffer_id)?;
+                let snapshot = buffer.read(cx).snapshot();
+                self.linked_edit_ranges
+                    .get(end_buffer_id, selection.start..selection.end, &snapshot)
+                    .map(|ranges| (ranges, snapshot, buffer))
+            })?;
+        use text::ToOffset as TO;
+        // find offset from the start of current range to current cursor position
+        let start_byte_offset = TO::to_offset(&base_range.start, &buffer_snapshot);
+
+        let start_offset = TO::to_offset(&selection.start, &buffer_snapshot);
+        let start_difference = start_offset - start_byte_offset;
+        let end_offset = TO::to_offset(&selection.end, &buffer_snapshot);
+        let end_difference = end_offset - start_byte_offset;
+        // Current range has associated linked ranges.
+        let mut linked_edits = HashMap::<_, Vec<_>>::default();
+        for range in linked_ranges.iter() {
+            let start_offset = TO::to_offset(&range.start, &buffer_snapshot);
+            let end_offset = start_offset + end_difference;
+            let start_offset = start_offset + start_difference;
+            let start = buffer_snapshot.anchor_after(start_offset);
+            let end = buffer_snapshot.anchor_after(end_offset);
+            linked_edits
+                .entry(buffer.clone())
+                .or_default()
+                .push(start..end);
+        }
+        Some(linked_edits)
+    }
+
     pub fn handle_input(&mut self, text: &str, cx: &mut ViewContext<Self>) {
         let text: Arc<str> = text.into();
 
@@ -2787,6 +2835,7 @@ impl Editor {
         let selections = self.selections.all_adjusted(cx);
         let mut brace_inserted = false;
         let mut edits = Vec::new();
+        let mut linked_edits = HashMap::<_, Vec<_>>::default();
         let mut new_selections = Vec::with_capacity(selections.len());
         let mut new_autoclose_regions = Vec::new();
         let snapshot = self.buffer.read(cx).read(cx);
@@ -2967,16 +3016,46 @@ impl Editor {
             // text with the given input and move the selection to the end of the
             // newly inserted text.
             let anchor = snapshot.anchor_after(selection.end);
+            if !self.linked_edit_ranges.is_empty() {
+                let start_anchor = snapshot.anchor_before(selection.start);
+                if let Some(ranges) =
+                    self.linked_editing_ranges_for(start_anchor.text_anchor..anchor.text_anchor, cx)
+                {
+                    for (buffer, edits) in ranges {
+                        linked_edits
+                            .entry(buffer.clone())
+                            .or_default()
+                            .extend(edits.into_iter().map(|range| (range, text.clone())));
+                    }
+                }
+            }
+
             new_selections.push((selection.map(|_| anchor), 0));
             edits.push((selection.start..selection.end, text.clone()));
         }
 
         drop(snapshot);
+
         self.transact(cx, |this, cx| {
             this.buffer.update(cx, |buffer, cx| {
                 buffer.edit(edits, this.autoindent_mode.clone(), cx);
             });
-
+            for (buffer, edits) in linked_edits {
+                buffer.update(cx, |buffer, cx| {
+                    let snapshot = buffer.snapshot();
+                    let edits = edits
+                        .into_iter()
+                        .map(|(range, text)| {
+                            use text::ToPoint as TP;
+                            let end_point = TP::to_point(&range.end, &snapshot);
+                            let start_point = TP::to_point(&range.start, &snapshot);
+                            (start_point..end_point, text)
+                        })
+                        .sorted_by_key(|(range, _)| range.start)
+                        .collect::<Vec<_>>();
+                    buffer.edit(edits, None, cx);
+                })
+            }
             let new_anchor_selections = new_selections.iter().map(|e| &e.0);
             let new_selection_deltas = new_selections.iter().map(|e| e.1);
             let snapshot = this.buffer.read(cx).read(cx);
@@ -3033,6 +3112,7 @@ impl Editor {
 
             let trigger_in_words = !had_active_inline_completion;
             this.trigger_completion_on_input(&text, trigger_in_words, cx);
+            linked_editing_ranges::refresh_linked_ranges(this, cx);
             this.refresh_inline_completion(true, cx);
         });
     }
@@ -3970,6 +4050,7 @@ impl Editor {
         let snapshot = self.buffer.read(cx).snapshot(cx);
         let mut range_to_replace: Option<Range<isize>> = None;
         let mut ranges = Vec::new();
+        let mut linked_edits = HashMap::<_, Vec<_>>::default();
         for selection in &selections {
             if snapshot.contains_str_at(selection.start.saturating_sub(lookbehind), &old_text) {
                 let start = selection.start.saturating_sub(lookbehind);
@@ -3999,6 +4080,21 @@ impl Editor {
                 }));
                 break;
             }
+            if !self.linked_edit_ranges.is_empty() {
+                let start_anchor = snapshot.anchor_before(selection.head());
+                let end_anchor = snapshot.anchor_after(selection.tail());
+                if let Some(ranges) = self
+                    .linked_editing_ranges_for(start_anchor.text_anchor..end_anchor.text_anchor, cx)
+                {
+                    for (buffer, edits) in ranges {
+                        linked_edits.entry(buffer.clone()).or_default().extend(
+                            edits
+                                .into_iter()
+                                .map(|range| (range, text[common_prefix_len..].to_owned())),
+                        );
+                    }
+                }
+            }
         }
         let text = &text[common_prefix_len..];
 
@@ -4025,6 +4121,22 @@ impl Editor {
                     );
                 });
             }
+            for (buffer, edits) in linked_edits {
+                buffer.update(cx, |buffer, cx| {
+                    let snapshot = buffer.snapshot();
+                    let edits = edits
+                        .into_iter()
+                        .map(|(range, text)| {
+                            use text::ToPoint as TP;
+                            let end_point = TP::to_point(&range.end, &snapshot);
+                            let start_point = TP::to_point(&range.start, &snapshot);
+                            (start_point..end_point, text)
+                        })
+                        .sorted_by_key(|(range, _)| range.start)
+                        .collect::<Vec<_>>();
+                    buffer.edit(edits, None, cx);
+                })
+            }
 
             this.refresh_inline_completion(true, cx);
         });
@@ -5009,6 +5121,27 @@ impl Editor {
     pub fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
             this.select_autoclose_pair(cx);
+            let mut linked_ranges = HashMap::<_, Vec<_>>::default();
+            if !this.linked_edit_ranges.is_empty() {
+                let selections = this.selections.all::<MultiBufferPoint>(cx);
+                let snapshot = this.buffer.read(cx).snapshot(cx);
+
+                for selection in selections.iter() {
+                    let selection_start = snapshot.anchor_before(selection.start).text_anchor;
+                    let selection_end = snapshot.anchor_after(selection.end).text_anchor;
+                    if selection_start.buffer_id != selection_end.buffer_id {
+                        continue;
+                    }
+                    if let Some(ranges) =
+                        this.linked_editing_ranges_for(selection_start..selection_end, cx)
+                    {
+                        for (buffer, entries) in ranges {
+                            linked_ranges.entry(buffer).or_default().extend(entries);
+                        }
+                    }
+                }
+            }
+
             let mut selections = this.selections.all::<MultiBufferPoint>(cx);
             if !this.selections.line_mode {
                 let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx));
@@ -5049,7 +5182,33 @@ impl Editor {
 
             this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections));
             this.insert("", cx);
+            let empty_str: Arc<str> = Arc::from("");
+            for (buffer, edits) in linked_ranges {
+                let snapshot = buffer.read(cx).snapshot();
+                use text::ToPoint as TP;
+
+                let edits = edits
+                    .into_iter()
+                    .map(|range| {
+                        let end_point = TP::to_point(&range.end, &snapshot);
+                        let mut start_point = TP::to_point(&range.start, &snapshot);
+
+                        if end_point == start_point {
+                            let offset = text::ToOffset::to_offset(&range.start, &snapshot)
+                                .saturating_sub(1);
+                            start_point = TP::to_point(&offset, &snapshot);
+                        };
+
+                        (start_point..end_point, empty_str.clone())
+                    })
+                    .sorted_by_key(|(range, _)| range.start)
+                    .collect::<Vec<_>>();
+                buffer.update(cx, |this, cx| {
+                    this.edit(edits, None, cx);
+                })
+            }
             this.refresh_inline_completion(true, cx);
+            linked_editing_ranges::refresh_linked_ranges(this, cx);
         });
     }
 
@@ -10604,7 +10763,6 @@ impl Editor {
                 }
                 cx.emit(EditorEvent::BufferEdited);
                 cx.emit(SearchEvent::MatchesInvalidated);
-
                 if *singleton_buffer_edited {
                     if let Some(project) = &self.project {
                         let project = project.read(cx);
@@ -10636,6 +10794,7 @@ impl Editor {
 
                 let Some(project) = &self.project else { return };
                 let telemetry = project.read(cx).client().telemetry().clone();
+                refresh_linked_ranges(self, cx);
                 telemetry.log_edit_event("editor");
             }
             multi_buffer::Event::ExcerptsAdded {
@@ -10661,6 +10820,7 @@ impl Editor {
                 cx.emit(EditorEvent::Reparsed);
             }
             multi_buffer::Event::LanguageChanged => {
+                linked_editing_ranges::refresh_linked_ranges(self, cx);
                 cx.emit(EditorEvent::Reparsed);
                 cx.notify();
             }

crates/editor/src/linked_editing_ranges.rs 🔗

@@ -0,0 +1,150 @@
+use std::ops::Range;
+
+use collections::HashMap;
+use itertools::Itertools;
+use text::{AnchorRangeExt, BufferId, ToPoint};
+use ui::ViewContext;
+use util::ResultExt;
+
+use crate::Editor;
+
+#[derive(Clone, Default)]
+pub(super) struct LinkedEditingRanges(
+    /// Ranges are non-overlapping and sorted by .0 (thus, [x + 1].start > [x].end must hold)
+    pub HashMap<BufferId, Vec<(Range<text::Anchor>, Vec<Range<text::Anchor>>)>>,
+);
+
+impl LinkedEditingRanges {
+    pub(super) fn get(
+        &self,
+        id: BufferId,
+        anchor: Range<text::Anchor>,
+        snapshot: &text::BufferSnapshot,
+    ) -> Option<&(Range<text::Anchor>, Vec<Range<text::Anchor>>)> {
+        let ranges_for_buffer = self.0.get(&id)?;
+        let lower_bound = ranges_for_buffer
+            .partition_point(|(range, _)| range.start.cmp(&anchor.start, snapshot).is_le());
+        if lower_bound == 0 {
+            // None of the linked ranges contains `anchor`.
+            return None;
+        }
+        ranges_for_buffer
+            .get(lower_bound - 1)
+            .filter(|(range, _)| range.end.cmp(&anchor.end, snapshot).is_ge())
+    }
+    pub(super) fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+}
+pub(super) fn refresh_linked_ranges(this: &mut Editor, cx: &mut ViewContext<Editor>) -> Option<()> {
+    if this.pending_rename.is_some() {
+        return None;
+    }
+    let project = this.project.clone()?;
+    let buffer = this.buffer.read(cx);
+    let mut applicable_selections = vec![];
+    let selections = this.selections.all::<usize>(cx);
+    let snapshot = buffer.snapshot(cx);
+    for selection in selections {
+        let cursor_position = selection.head();
+        let start_position = snapshot.anchor_before(cursor_position);
+        let end_position = snapshot.anchor_after(selection.tail());
+        if start_position.buffer_id != end_position.buffer_id || end_position.buffer_id.is_none() {
+            // Throw away selections spanning multiple buffers.
+            continue;
+        }
+        if let Some(buffer) = end_position.buffer_id.and_then(|id| buffer.buffer(id)) {
+            applicable_selections.push((
+                buffer,
+                start_position.text_anchor,
+                end_position.text_anchor,
+            ));
+        }
+    }
+    if applicable_selections.is_empty() {
+        return None;
+    }
+    this.linked_editing_range_task = Some(cx.spawn(|this, mut cx| async move {
+        let highlights = project
+            .update(&mut cx, |project, cx| {
+                let mut linked_edits_tasks = vec![];
+
+                for (buffer, start, end) in &applicable_selections {
+                    let snapshot = buffer.read(cx).snapshot();
+                    let buffer_id = buffer.read(cx).remote_id();
+
+                    let linked_edits_task = project.linked_edit(&buffer, *start, cx);
+                    let highlights = move || async move {
+                        let edits = linked_edits_task.await.log_err()?;
+                        // Find the range containing our current selection.
+                        // We might not find one, because the selection contains both the start and end of the contained range
+                        // (think of selecting <`html>foo`</html> - even though there's a matching closing tag, the selection goes beyond the range of the opening tag)
+                        // or the language server may not have returned any ranges.
+
+                        let start_point = start.to_point(&snapshot);
+                        let end_point = end.to_point(&snapshot);
+                        let _current_selection_contains_range = edits.iter().find(|range| {
+                            range.start.to_point(&snapshot) <= start_point
+                                && range.end.to_point(&snapshot) >= end_point
+                        });
+                        if _current_selection_contains_range.is_none() {
+                            return None;
+                        }
+                        // Now link every range as each-others sibling.
+                        let mut siblings: HashMap<Range<text::Anchor>, Vec<_>> = Default::default();
+                        let mut insert_sorted_anchor =
+                            |key: &Range<text::Anchor>, value: &Range<text::Anchor>| {
+                                siblings.entry(key.clone()).or_default().push(value.clone());
+                            };
+                        for items in edits.into_iter().combinations(2) {
+                            let Ok([first, second]): Result<[_; 2], _> = items.try_into() else {
+                                unreachable!()
+                            };
+
+                            insert_sorted_anchor(&first, &second);
+                            insert_sorted_anchor(&second, &first);
+                        }
+                        let mut siblings: Vec<(_, _)> = siblings.into_iter().collect();
+                        siblings.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot));
+                        Some((buffer_id, siblings))
+                    };
+                    linked_edits_tasks.push(highlights());
+                }
+                linked_edits_tasks
+            })
+            .log_err()?;
+
+        let highlights = futures::future::join_all(highlights).await;
+
+        this.update(&mut cx, |this, cx| {
+            this.linked_edit_ranges.0.clear();
+            if this.pending_rename.is_some() {
+                return;
+            }
+            for (buffer_id, ranges) in highlights.into_iter().flatten() {
+                this.linked_edit_ranges
+                    .0
+                    .entry(buffer_id)
+                    .or_default()
+                    .extend(ranges);
+            }
+            for (buffer_id, values) in this.linked_edit_ranges.0.iter_mut() {
+                let Some(snapshot) = this
+                    .buffer
+                    .read(cx)
+                    .buffer(*buffer_id)
+                    .map(|buffer| buffer.read(cx).snapshot())
+                else {
+                    continue;
+                };
+                values.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot));
+            }
+
+            cx.notify();
+        })
+        .log_err();
+
+        Some(())
+    }));
+    None
+}

crates/language/src/language_settings.rs 🔗

@@ -116,6 +116,8 @@ pub struct LanguageSettings {
     pub always_treat_brackets_as_autoclosed: bool,
     /// Which code actions to run on save
     pub code_actions_on_format: HashMap<String, bool>,
+    /// Whether to perform linked edits
+    pub linked_edits: bool,
 }
 
 impl LanguageSettings {
@@ -326,6 +328,11 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: {} (or {"source.organizeImports": true} for Go).
     pub code_actions_on_format: Option<HashMap<String, bool>>,
+    /// Whether to perform linked edits of associated ranges, if the language server supports it.
+    /// For example, when editing opening <html> tag, the contents of the closing </html> tag will be edited as well.
+    ///
+    /// Default: true
+    pub linked_edits: Option<bool>,
 }
 
 /// The contents of the inline completion settings.
@@ -785,6 +792,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
         &mut settings.code_actions_on_format,
         src.code_actions_on_format.clone(),
     );
+    merge(&mut settings.linked_edits, src.linked_edits);
 
     merge(
         &mut settings.preferred_line_length,

crates/project/src/lsp_command.rs 🔗

@@ -17,7 +17,7 @@ use language::{
 };
 use lsp::{
     CompletionListItemDefaultsEditRange, DocumentHighlightKind, LanguageServer, LanguageServerId,
-    OneOf, ServerCapabilities,
+    LinkedEditingRangeServerCapabilities, OneOf, ServerCapabilities,
 };
 use std::{cmp::Reverse, ops::Range, path::Path, sync::Arc};
 use text::{BufferId, LineEnding};
@@ -158,6 +158,10 @@ impl From<lsp::FormattingOptions> for FormattingOptions {
     }
 }
 
+pub(crate) struct LinkedEditingRange {
+    pub position: Anchor,
+}
+
 #[async_trait(?Send)]
 impl LspCommand for PrepareRename {
     type Response = Option<Range<Anchor>>;
@@ -2559,3 +2563,150 @@ impl LspCommand for InlayHints {
         BufferId::new(message.buffer_id)
     }
 }
+
+#[async_trait(?Send)]
+impl LspCommand for LinkedEditingRange {
+    type Response = Vec<Range<Anchor>>;
+    type LspRequest = lsp::request::LinkedEditingRange;
+    type ProtoRequest = proto::LinkedEditingRange;
+
+    fn check_capabilities(&self, server_capabilities: &lsp::ServerCapabilities) -> bool {
+        let Some(linked_editing_options) = &server_capabilities.linked_editing_range_provider
+        else {
+            return false;
+        };
+        if let LinkedEditingRangeServerCapabilities::Simple(false) = linked_editing_options {
+            return false;
+        }
+        return true;
+    }
+
+    fn to_lsp(
+        &self,
+        path: &Path,
+        buffer: &Buffer,
+        _server: &Arc<LanguageServer>,
+        _: &AppContext,
+    ) -> lsp::LinkedEditingRangeParams {
+        let position = self.position.to_point_utf16(&buffer.snapshot());
+        lsp::LinkedEditingRangeParams {
+            text_document_position_params: lsp::TextDocumentPositionParams::new(
+                lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path(path).unwrap()),
+                point_to_lsp(position),
+            ),
+            work_done_progress_params: Default::default(),
+        }
+    }
+
+    async fn response_from_lsp(
+        self,
+        message: Option<lsp::LinkedEditingRanges>,
+        _project: Model<Project>,
+        buffer: Model<Buffer>,
+        _server_id: LanguageServerId,
+        cx: AsyncAppContext,
+    ) -> Result<Vec<Range<Anchor>>> {
+        if let Some(lsp::LinkedEditingRanges { mut ranges, .. }) = message {
+            ranges.sort_by_key(|range| range.start);
+            let ranges = buffer.read_with(&cx, |buffer, _| {
+                ranges
+                    .into_iter()
+                    .map(|range| {
+                        let start =
+                            buffer.clip_point_utf16(point_from_lsp(range.start), Bias::Left);
+                        let end = buffer.clip_point_utf16(point_from_lsp(range.end), Bias::Left);
+                        buffer.anchor_before(start)..buffer.anchor_after(end)
+                    })
+                    .collect()
+            });
+
+            ranges
+        } else {
+            Ok(vec![])
+        }
+    }
+
+    fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::LinkedEditingRange {
+        proto::LinkedEditingRange {
+            project_id,
+            buffer_id: buffer.remote_id().to_proto(),
+            position: Some(serialize_anchor(&self.position)),
+            version: serialize_version(&buffer.version()),
+        }
+    }
+
+    async fn from_proto(
+        message: proto::LinkedEditingRange,
+        _project: Model<Project>,
+        buffer: Model<Buffer>,
+        mut cx: AsyncAppContext,
+    ) -> Result<Self> {
+        let position = message
+            .position
+            .ok_or_else(|| anyhow!("invalid position"))?;
+        buffer
+            .update(&mut cx, |buffer, _| {
+                buffer.wait_for_version(deserialize_version(&message.version))
+            })?
+            .await?;
+        let position = deserialize_anchor(position).ok_or_else(|| anyhow!("invalid position"))?;
+        buffer
+            .update(&mut cx, |buffer, _| buffer.wait_for_anchors([position]))?
+            .await?;
+        Ok(Self { position })
+    }
+
+    fn response_to_proto(
+        response: Vec<Range<Anchor>>,
+        _: &mut Project,
+        _: PeerId,
+        buffer_version: &clock::Global,
+        _: &mut AppContext,
+    ) -> proto::LinkedEditingRangeResponse {
+        proto::LinkedEditingRangeResponse {
+            items: response
+                .into_iter()
+                .map(|range| proto::AnchorRange {
+                    start: Some(serialize_anchor(&range.start)),
+                    end: Some(serialize_anchor(&range.end)),
+                })
+                .collect(),
+            version: serialize_version(buffer_version),
+        }
+    }
+
+    async fn response_from_proto(
+        self,
+        message: proto::LinkedEditingRangeResponse,
+        _: Model<Project>,
+        buffer: Model<Buffer>,
+        mut cx: AsyncAppContext,
+    ) -> Result<Vec<Range<Anchor>>> {
+        buffer
+            .update(&mut cx, |buffer, _| {
+                buffer.wait_for_version(deserialize_version(&message.version))
+            })?
+            .await?;
+        let items: Vec<Range<Anchor>> = message
+            .items
+            .into_iter()
+            .filter_map(|range| {
+                let start = deserialize_anchor(range.start?)?;
+                let end = deserialize_anchor(range.end?)?;
+                Some(start..end)
+            })
+            .collect();
+        for range in &items {
+            buffer
+                .update(&mut cx, |buffer, _| {
+                    buffer.wait_for_anchors([range.start, range.end])
+                })?
+                .await?;
+        }
+        Ok(items)
+    }
+
+    fn buffer_id_from_proto(message: &proto::LinkedEditingRange) -> Result<BufferId> {
+        BufferId::new(message.buffer_id)
+    }
+}

crates/project/src/project.rs 🔗

@@ -42,7 +42,9 @@ use gpui::{
 use http::{HttpClient, Url};
 use itertools::Itertools;
 use language::{
-    language_settings::{language_settings, FormatOnSave, Formatter, InlayHintKind},
+    language_settings::{
+        language_settings, AllLanguageSettings, FormatOnSave, Formatter, InlayHintKind,
+    },
     markdown, point_to_lsp, prepare_completion_documentation,
     proto::{
         deserialize_anchor, deserialize_line_ending, deserialize_version, serialize_anchor,
@@ -712,6 +714,7 @@ impl Project {
         client.add_model_request_handler(Self::handle_restart_language_servers);
         client.add_model_request_handler(Self::handle_task_context_for_location);
         client.add_model_request_handler(Self::handle_task_templates);
+        client.add_model_request_handler(Self::handle_lsp_command::<LinkedEditingRange>);
     }
 
     pub fn local(
@@ -5804,6 +5807,62 @@ impl Project {
         self.hover_impl(buffer, position, cx)
     }
 
+    fn linked_edit_impl(
+        &self,
+        buffer: &Model<Buffer>,
+        position: Anchor,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Range<Anchor>>>> {
+        let snapshot = buffer.read(cx).snapshot();
+        let scope = snapshot.language_scope_at(position);
+        let Some(server_id) = self
+            .language_servers_for_buffer(buffer.read(cx), cx)
+            .filter(|(_, server)| {
+                server
+                    .capabilities()
+                    .linked_editing_range_provider
+                    .is_some()
+            })
+            .filter(|(adapter, _)| {
+                scope
+                    .as_ref()
+                    .map(|scope| scope.language_allowed(&adapter.name))
+                    .unwrap_or(true)
+            })
+            .map(|(_, server)| LanguageServerToQuery::Other(server.server_id()))
+            .next()
+            .or_else(|| self.is_remote().then_some(LanguageServerToQuery::Primary))
+            .filter(|_| {
+                maybe!({
+                    let language_name = buffer.read(cx).language_at(position)?.name();
+                    Some(
+                        AllLanguageSettings::get_global(cx)
+                            .language(Some(&language_name))
+                            .linked_edits,
+                    )
+                }) == Some(true)
+            })
+        else {
+            return Task::ready(Ok(vec![]));
+        };
+
+        self.request_lsp(
+            buffer.clone(),
+            server_id,
+            LinkedEditingRange { position },
+            cx,
+        )
+    }
+
+    pub fn linked_edit(
+        &self,
+        buffer: &Model<Buffer>,
+        position: Anchor,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<Range<Anchor>>>> {
+        self.linked_edit_impl(buffer, position, cx)
+    }
+
     #[inline(never)]
     fn completions_impl(
         &self,

crates/proto/proto/zed.proto 🔗

@@ -220,7 +220,7 @@ message Envelope {
 
         MultiLspQuery multi_lsp_query = 175;
         MultiLspQueryResponse multi_lsp_query_response = 176;
-        RestartLanguageServers restart_language_servers = 208;  // current max
+        RestartLanguageServers restart_language_servers = 208;
 
         CreateDevServerProject create_dev_server_project = 177;
         CreateDevServerProjectResponse create_dev_server_project_response = 188;
@@ -253,6 +253,9 @@ message Envelope {
         TaskContext task_context = 204;
         TaskTemplatesResponse task_templates_response = 205;
         TaskTemplates task_templates = 206;
+
+        LinkedEditingRange linked_editing_range = 209;
+        LinkedEditingRangeResponse linked_editing_range_response = 210; // current max
     }
 
     reserved 158 to 161;
@@ -987,6 +990,24 @@ message OnTypeFormattingResponse {
     Transaction transaction = 1;
 }
 
+
+message LinkedEditingRange {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    Anchor position = 3;
+    repeated VectorClockEntry version = 4;
+}
+
+message AnchorRange {
+    Anchor start = 1;
+    Anchor end = 2;
+}
+
+message LinkedEditingRangeResponse {
+    repeated AnchorRange items = 1;
+    repeated VectorClockEntry version = 4;
+}
+
 message InlayHints {
     uint64 project_id = 1;
     uint64 buffer_id = 2;

crates/proto/src/proto.rs 🔗

@@ -336,6 +336,8 @@ messages!(
     (RenameDevServer, Foreground),
     (OpenNewBuffer, Foreground),
     (RestartLanguageServers, Foreground),
+    (LinkedEditingRange, Background),
+    (LinkedEditingRangeResponse, Background)
 );
 
 request_messages!(
@@ -376,6 +378,7 @@ request_messages!(
     (GetReferences, GetReferencesResponse),
     (GetSupermavenApiKey, GetSupermavenApiKeyResponse),
     (GetTypeDefinition, GetTypeDefinitionResponse),
+    (LinkedEditingRange, LinkedEditingRangeResponse),
     (GetUsers, UsersResponse),
     (IncomingCall, Ack),
     (InlayHints, InlayHintsResponse),
@@ -475,6 +478,7 @@ entity_messages!(
     InlayHints,
     JoinProject,
     LeaveProject,
+    LinkedEditingRange,
     MultiLspQuery,
     RestartLanguageServers,
     OnTypeFormatting,