From b6ea393d140b52bab05892cf2718e1aef965bcb1 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Tue, 11 Jun 2024 15:52:38 +0200 Subject: [PATCH] lsp: Add support for linked editing range edits (HTML tag autorenaming) (#12769) 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 --- 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(-) create mode 100644 crates/editor/src/linked_editing_ranges.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 488ce9f8b1..0ab3b1d977 100644 --- a/assets/settings/default.json +++ b/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 tag, the contents of the closing 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. diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 2d38067ed3..45a7052f0a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -548,6 +548,9 @@ impl Server { .add_request_handler(user_handler( forward_mutating_project_request::, )) + .add_request_handler(user_handler( + forward_mutating_project_request::, + )) .add_message_handler(create_buffer_for_peer) .add_request_handler(update_buffer) .add_message_handler(broadcast_project_message_from_host::) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1e02312ecb..0500e6643e 100644 --- a/crates/editor/src/editor.rs +++ b/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>, document_highlights_task: Option>, + linked_editing_range_task: Option>>, + linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges, pending_rename: Option, 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, + cx: &AppContext, + ) -> Option, Vec>>> { + 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) { let text: Arc = 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::>(); + 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> = 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::>(); + 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.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::(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::(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 = 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::>(); + 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(); } diff --git a/crates/editor/src/linked_editing_ranges.rs b/crates/editor/src/linked_editing_ranges.rs new file mode 100644 index 0000000000..3a3a9abba0 --- /dev/null +++ b/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, Vec>)>>, +); + +impl LinkedEditingRanges { + pub(super) fn get( + &self, + id: BufferId, + anchor: Range, + snapshot: &text::BufferSnapshot, + ) -> Option<&(Range, Vec>)> { + 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) -> 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::(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` - 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, Vec<_>> = Default::default(); + let mut insert_sorted_anchor = + |key: &Range, value: &Range| { + 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 +} diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index a8462f8752..4e5b10edb8 100644 --- a/crates/language/src/language_settings.rs +++ b/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, + /// 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>, + /// Whether to perform linked edits of associated ranges, if the language server supports it. + /// For example, when editing opening tag, the contents of the closing tag will be edited as well. + /// + /// Default: true + pub linked_edits: Option, } /// 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, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index a3917e4ef1..7a0ddbe22a 100644 --- a/crates/project/src/lsp_command.rs +++ b/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 for FormattingOptions { } } +pub(crate) struct LinkedEditingRange { + pub position: Anchor, +} + #[async_trait(?Send)] impl LspCommand for PrepareRename { type Response = Option>; @@ -2559,3 +2563,150 @@ impl LspCommand for InlayHints { BufferId::new(message.buffer_id) } } + +#[async_trait(?Send)] +impl LspCommand for LinkedEditingRange { + type Response = Vec>; + 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, + _: &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, + _project: Model, + buffer: Model, + _server_id: LanguageServerId, + cx: AsyncAppContext, + ) -> Result>> { + 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, + buffer: Model, + mut cx: AsyncAppContext, + ) -> Result { + 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>, + _: &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, + buffer: Model, + mut cx: AsyncAppContext, + ) -> Result>> { + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(deserialize_version(&message.version)) + })? + .await?; + let items: Vec> = 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::new(message.buffer_id) + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5c6c74e408..efe196cc82 100644 --- a/crates/project/src/project.rs +++ b/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::); } pub fn local( @@ -5804,6 +5807,62 @@ impl Project { self.hover_impl(buffer, position, cx) } + fn linked_edit_impl( + &self, + buffer: &Model, + position: Anchor, + cx: &mut ModelContext, + ) -> Task>>> { + 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, + position: Anchor, + cx: &mut ModelContext, + ) -> Task>>> { + self.linked_edit_impl(buffer, position, cx) + } + #[inline(never)] fn completions_impl( &self, diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index e1c8c665ea..f78ae824d9 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 3afa56f43a..6457f3fa0d 100644 --- a/crates/proto/src/proto.rs +++ b/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,