linked_editing_ranges.rs

  1use collections::HashMap;
  2use gpui::{AppContext, Context, Entity, Window};
  3use itertools::Itertools;
  4use language::Buffer;
  5use multi_buffer::MultiBufferOffset;
  6use std::{ops::Range, sync::Arc, time::Duration};
  7use text::{Anchor, AnchorRangeExt, Bias, BufferId, ToOffset, ToPoint};
  8use util::ResultExt;
  9
 10use crate::Editor;
 11
 12#[derive(Clone, Default)]
 13pub(super) struct LinkedEditingRanges(
 14    /// Ranges are non-overlapping and sorted by .0 (thus, [x + 1].start > [x].end must hold)
 15    pub HashMap<BufferId, Vec<(Range<Anchor>, Vec<Range<Anchor>>)>>,
 16);
 17
 18impl LinkedEditingRanges {
 19    pub(super) fn get(
 20        &self,
 21        id: BufferId,
 22        anchor: Range<Anchor>,
 23        snapshot: &text::BufferSnapshot,
 24    ) -> Option<&(Range<Anchor>, Vec<Range<Anchor>>)> {
 25        let ranges_for_buffer = self.0.get(&id)?;
 26        let lower_bound = ranges_for_buffer
 27            .partition_point(|(range, _)| range.start.cmp(&anchor.start, snapshot).is_le());
 28        if lower_bound == 0 {
 29            // None of the linked ranges contains `anchor`.
 30            return None;
 31        }
 32        ranges_for_buffer
 33            .get(lower_bound - 1)
 34            .filter(|(range, _)| range.end.cmp(&anchor.end, snapshot).is_ge())
 35    }
 36    pub(super) fn is_empty(&self) -> bool {
 37        self.0.is_empty()
 38    }
 39
 40    pub(super) fn clear(&mut self) {
 41        self.0.clear();
 42    }
 43}
 44
 45const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 46
 47// TODO do not refresh anything at all, if the settings/capabilities do not have it enabled.
 48pub(super) fn refresh_linked_ranges(
 49    editor: &mut Editor,
 50    window: &mut Window,
 51    cx: &mut Context<Editor>,
 52) -> Option<()> {
 53    if !editor.mode().is_full() || editor.pending_rename.is_some() {
 54        return None;
 55    }
 56    let project = editor.project()?.downgrade();
 57
 58    editor.linked_editing_range_task = Some(cx.spawn_in(window, async move |editor, cx| {
 59        cx.background_executor().timer(UPDATE_DEBOUNCE).await;
 60
 61        let mut applicable_selections = Vec::new();
 62        editor
 63            .update(cx, |editor, cx| {
 64                let display_snapshot = editor.display_snapshot(cx);
 65                let selections = editor
 66                    .selections
 67                    .all::<MultiBufferOffset>(&display_snapshot);
 68                let snapshot = display_snapshot.buffer_snapshot();
 69                let buffer = editor.buffer.read(cx);
 70                for selection in selections {
 71                    let cursor_position = selection.head();
 72                    let start_position = snapshot.anchor_before(cursor_position);
 73                    let end_position = snapshot.anchor_after(selection.tail());
 74                    if start_position.text_anchor.buffer_id != end_position.text_anchor.buffer_id
 75                        || end_position.text_anchor.buffer_id.is_none()
 76                    {
 77                        // Throw away selections spanning multiple buffers.
 78                        continue;
 79                    }
 80                    if let Some(buffer) = buffer.buffer_for_anchor(end_position, cx) {
 81                        applicable_selections.push((
 82                            buffer,
 83                            start_position.text_anchor,
 84                            end_position.text_anchor,
 85                        ));
 86                    }
 87                }
 88            })
 89            .ok()?;
 90
 91        if applicable_selections.is_empty() {
 92            return None;
 93        }
 94
 95        let highlights = project
 96            .update(cx, |project, cx| {
 97                let mut linked_edits_tasks = vec![];
 98                for (buffer, start, end) in &applicable_selections {
 99                    let linked_edits_task = project.linked_edits(buffer, *start, cx);
100                    let cx = cx.to_async();
101                    let highlights = async move {
102                        let edits = linked_edits_task.await.log_err()?;
103                        let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot());
104                        let buffer_id = snapshot.remote_id();
105
106                        // Find the range containing our current selection.
107                        // We might not find one, because the selection contains both the start and end of the contained range
108                        // (think of selecting <`html>foo`</html> - even though there's a matching closing tag, the selection goes beyond the range of the opening tag)
109                        // or the language server may not have returned any ranges.
110
111                        let start_point = start.to_point(&snapshot);
112                        let end_point = end.to_point(&snapshot);
113                        let _current_selection_contains_range = edits.iter().find(|range| {
114                            range.start.to_point(&snapshot) <= start_point
115                                && range.end.to_point(&snapshot) >= end_point
116                        });
117                        _current_selection_contains_range?;
118                        // Now link every range as each-others sibling.
119                        let mut siblings: HashMap<Range<Anchor>, Vec<_>> = Default::default();
120                        let mut insert_sorted_anchor =
121                            |key: &Range<Anchor>, value: &Range<Anchor>| {
122                                siblings.entry(key.clone()).or_default().push(value.clone());
123                            };
124                        for items in edits.into_iter().combinations(2) {
125                            let Ok([first, second]): Result<[_; 2], _> = items.try_into() else {
126                                unreachable!()
127                            };
128
129                            insert_sorted_anchor(&first, &second);
130                            insert_sorted_anchor(&second, &first);
131                        }
132                        let mut siblings: Vec<(_, _)> = siblings.into_iter().collect();
133                        siblings.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot));
134                        Some((buffer_id, siblings))
135                    };
136                    linked_edits_tasks.push(highlights);
137                }
138                linked_edits_tasks
139            })
140            .ok()?;
141
142        let highlights = futures::future::join_all(highlights).await;
143
144        editor
145            .update(cx, |this, cx| {
146                this.linked_edit_ranges.0.clear();
147                if this.pending_rename.is_some() {
148                    return;
149                }
150                for (buffer_id, ranges) in highlights.into_iter().flatten() {
151                    this.linked_edit_ranges
152                        .0
153                        .entry(buffer_id)
154                        .or_default()
155                        .extend(ranges);
156                }
157                for (buffer_id, values) in this.linked_edit_ranges.0.iter_mut() {
158                    let Some(snapshot) = this
159                        .buffer
160                        .read(cx)
161                        .buffer(*buffer_id)
162                        .map(|buffer| buffer.read(cx).snapshot())
163                    else {
164                        continue;
165                    };
166                    values.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0, &snapshot));
167                }
168
169                cx.notify();
170            })
171            .ok()?;
172
173        Some(())
174    }));
175    None
176}
177
178/// Accumulates edits destined for linked editing ranges, for example, matching
179/// HTML/JSX tags, across one or more buffers. Edits are stored as anchor ranges
180/// so they track buffer changes and are only resolved to concrete points at
181/// apply time.
182pub struct LinkedEdits(HashMap<Entity<Buffer>, Vec<(Range<Anchor>, Arc<str>)>>);
183
184impl LinkedEdits {
185    pub fn new() -> Self {
186        Self(HashMap::default())
187    }
188
189    /// Queries the editor's linked editing ranges for the given anchor range and, if any
190    /// are found, records them paired with `text` for later application.
191    pub(crate) fn push(
192        &mut self,
193        editor: &Editor,
194        anchor_range: Range<Anchor>,
195        text: Arc<str>,
196        cx: &gpui::App,
197    ) {
198        if let Some(editing_ranges) = editor.linked_editing_ranges_for(anchor_range, cx) {
199            for (buffer, ranges) in editing_ranges {
200                self.0
201                    .entry(buffer)
202                    .or_default()
203                    .extend(ranges.into_iter().map(|range| (range, text.clone())));
204            }
205        }
206    }
207
208    /// Resolves all stored anchor ranges to points using the current buffer snapshot,
209    /// sorts them, and applies the edits.
210    pub fn apply(self, cx: &mut Context<Editor>) {
211        self.apply_inner(false, cx);
212    }
213
214    /// Like [`apply`](Self::apply), but empty ranges (where start == end) are
215    /// expanded one character to the left before applying. For context, this
216    /// was introduced in order to be available to `backspace` so as to delete a
217    /// character in each linked range even when the selection was a cursor.
218    pub fn apply_with_left_expansion(self, cx: &mut Context<Editor>) {
219        self.apply_inner(true, cx);
220    }
221
222    fn apply_inner(self, expand_empty_ranges_left: bool, cx: &mut Context<Editor>) {
223        for (buffer, ranges_edits) in self.0 {
224            buffer.update(cx, |buffer, cx| {
225                let snapshot = buffer.snapshot();
226                let edits = ranges_edits
227                    .into_iter()
228                    .map(|(range, text)| {
229                        let mut start = range.start.to_point(&snapshot);
230                        let end = range.end.to_point(&snapshot);
231
232                        if expand_empty_ranges_left && start == end {
233                            let offset = range.start.to_offset(&snapshot).saturating_sub(1);
234                            start = snapshot.clip_point(offset.to_point(&snapshot), Bias::Left);
235                        }
236
237                        (start..end, text)
238                    })
239                    .sorted_by_key(|(range, _)| range.start);
240
241                buffer.edit(edits, None, cx);
242            });
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use crate::{editor_tests::init_test, test::editor_test_context::EditorTestContext};
250    use gpui::TestAppContext;
251    use text::Point;
252
253    #[gpui::test]
254    async fn test_linked_edits_push_and_apply(cx: &mut TestAppContext) {
255        init_test(cx, |_| {});
256        let mut cx = EditorTestContext::new(cx).await;
257
258        cx.set_state("<diˇv></div>");
259        cx.update_editor(|editor, _window, cx| {
260            editor
261                .set_linked_edit_ranges_for_testing(
262                    vec![(
263                        Point::new(0, 1)..Point::new(0, 4),
264                        vec![Point::new(0, 7)..Point::new(0, 10)],
265                    )],
266                    cx,
267                )
268                .unwrap();
269        });
270
271        cx.simulate_keystroke("x");
272        cx.assert_editor_state("<dixˇv></dixv>");
273    }
274
275    #[gpui::test]
276    async fn test_linked_edits_backspace(cx: &mut TestAppContext) {
277        init_test(cx, |_| {});
278        let mut cx = EditorTestContext::new(cx).await;
279
280        cx.set_state("<divˇ></div>");
281        cx.update_editor(|editor, _window, cx| {
282            editor
283                .set_linked_edit_ranges_for_testing(
284                    vec![(
285                        Point::new(0, 1)..Point::new(0, 4),
286                        vec![Point::new(0, 7)..Point::new(0, 10)],
287                    )],
288                    cx,
289                )
290                .unwrap();
291        });
292
293        cx.update_editor(|editor, window, cx| {
294            editor.backspace(&Default::default(), window, cx);
295        });
296        cx.assert_editor_state("<diˇ></di>");
297    }
298
299    #[gpui::test]
300    async fn test_linked_edits_delete(cx: &mut TestAppContext) {
301        init_test(cx, |_| {});
302        let mut cx = EditorTestContext::new(cx).await;
303
304        cx.set_state("<ˇdiv></div>");
305        cx.update_editor(|editor, _window, cx| {
306            editor
307                .set_linked_edit_ranges_for_testing(
308                    vec![(
309                        Point::new(0, 1)..Point::new(0, 4),
310                        vec![Point::new(0, 7)..Point::new(0, 10)],
311                    )],
312                    cx,
313                )
314                .unwrap();
315        });
316
317        cx.update_editor(|editor, window, cx| {
318            editor.delete(&Default::default(), window, cx);
319        });
320        cx.assert_editor_state("<ˇiv></iv>");
321    }
322
323    #[gpui::test]
324    async fn test_linked_edits_selection(cx: &mut TestAppContext) {
325        init_test(cx, |_| {});
326        let mut cx = EditorTestContext::new(cx).await;
327
328        cx.set_state("<«divˇ»></div>");
329        cx.update_editor(|editor, _window, cx| {
330            editor
331                .set_linked_edit_ranges_for_testing(
332                    vec![(
333                        Point::new(0, 1)..Point::new(0, 4),
334                        vec![Point::new(0, 7)..Point::new(0, 10)],
335                    )],
336                    cx,
337                )
338                .unwrap();
339        });
340
341        cx.simulate_keystrokes("s p a n");
342        cx.assert_editor_state("<spanˇ></span>");
343    }
344}