Add sort lines command (#2786)

Joseph T. Lyons created

This PR adds command palette actions for:

- `sort lines case sensitive`
- `sort lines case insensitive`
- `reverse lines`
- `shuffle lines`

Closes out:

- https://github.com/zed-industries/community/issues/658

and partially closing out:

- https://github.com/zed-industries/community/issues/57 (which is
currently a top-ranked issue)

There are issues with dedupe lines and I didn't try to tackle converting
variable names between different conventions. I'll likely close out 57
with a note to just upvote the remaining individual issues

Release Notes:

- added command palette actions for `sort lines case sensitive`, `sort
lines case insensitive`, `reverse lines`, and`shuffle lines`
(([#57](https://github.com/zed-industries/community/issues/57)),
([#658](https://github.com/zed-industries/community/issues/658)))

Change summary

crates/editor/Cargo.toml                   |   3 
crates/editor/src/editor.rs                |  99 +++++++++++++++
crates/editor/src/editor_tests.rs          | 150 ++++++++++++++++++++++++
crates/editor/src/selections_collection.rs |   2 
4 files changed, 251 insertions(+), 3 deletions(-)

Detailed changes

crates/editor/Cargo.toml 🔗

@@ -10,7 +10,6 @@ doctest = false
 
 [features]
 test-support = [
-    "rand",
     "copilot/test-support",
     "text/test-support",
     "language/test-support",
@@ -62,8 +61,8 @@ serde.workspace = true
 serde_derive.workspace = true
 smallvec.workspace = true
 smol.workspace = true
+rand.workspace = true
 
-rand = { workspace = true, optional = true }
 tree-sitter-rust = { workspace = true, optional = true }
 tree-sitter-html = { workspace = true, optional = true }
 tree-sitter-typescript = { workspace = true, optional = true }

crates/editor/src/editor.rs 🔗

@@ -74,6 +74,7 @@ pub use multi_buffer::{
 };
 use ordered_float::OrderedFloat;
 use project::{FormatTrigger, Location, LocationLink, Project, ProjectPath, ProjectTransaction};
+use rand::{seq::SliceRandom, thread_rng};
 use scroll::{
     autoscroll::Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide,
 };
@@ -226,6 +227,10 @@ actions!(
         MoveLineUp,
         MoveLineDown,
         JoinLines,
+        SortLinesCaseSensitive,
+        SortLinesCaseInsensitive,
+        ReverseLines,
+        ShuffleLines,
         Transpose,
         Cut,
         Copy,
@@ -344,6 +349,10 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(Editor::outdent);
     cx.add_action(Editor::delete_line);
     cx.add_action(Editor::join_lines);
+    cx.add_action(Editor::sort_lines_case_sensitive);
+    cx.add_action(Editor::sort_lines_case_insensitive);
+    cx.add_action(Editor::reverse_lines);
+    cx.add_action(Editor::shuffle_lines);
     cx.add_action(Editor::delete_to_previous_word_start);
     cx.add_action(Editor::delete_to_previous_subword_start);
     cx.add_action(Editor::delete_to_next_word_end);
@@ -4205,6 +4214,96 @@ impl Editor {
         });
     }
 
+    pub fn sort_lines_case_sensitive(
+        &mut self,
+        _: &SortLinesCaseSensitive,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.manipulate_lines(cx, |text| text.sort())
+    }
+
+    pub fn sort_lines_case_insensitive(
+        &mut self,
+        _: &SortLinesCaseInsensitive,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.manipulate_lines(cx, |text| text.sort_by_key(|line| line.to_lowercase()))
+    }
+
+    pub fn reverse_lines(&mut self, _: &ReverseLines, cx: &mut ViewContext<Self>) {
+        self.manipulate_lines(cx, |lines| lines.reverse())
+    }
+
+    pub fn shuffle_lines(&mut self, _: &ShuffleLines, cx: &mut ViewContext<Self>) {
+        self.manipulate_lines(cx, |lines| lines.shuffle(&mut thread_rng()))
+    }
+
+    fn manipulate_lines<Fn>(&mut self, cx: &mut ViewContext<Self>, mut callback: Fn)
+    where
+        Fn: FnMut(&mut [&str]),
+    {
+        let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
+        let buffer = self.buffer.read(cx).snapshot(cx);
+
+        let mut edits = Vec::new();
+
+        let selections = self.selections.all::<Point>(cx);
+        let mut selections = selections.iter().peekable();
+        let mut contiguous_row_selections = Vec::new();
+        let mut new_selections = Vec::new();
+
+        while let Some(selection) = selections.next() {
+            let (start_row, end_row) = consume_contiguous_rows(
+                &mut contiguous_row_selections,
+                selection,
+                &display_map,
+                &mut selections,
+            );
+
+            let start_point = Point::new(start_row, 0);
+            let end_point = Point::new(end_row - 1, buffer.line_len(end_row - 1));
+            let text = buffer
+                .text_for_range(start_point..end_point)
+                .collect::<String>();
+            let mut text = text.split("\n").collect_vec();
+
+            let text_len = text.len();
+            callback(&mut text);
+
+            // This is a current limitation with selections.
+            // If we wanted to support removing or adding lines, we'd need to fix the logic associated with selections.
+            debug_assert!(
+                text.len() == text_len,
+                "callback should not change the number of lines"
+            );
+
+            edits.push((start_point..end_point, text.join("\n")));
+            let start_anchor = buffer.anchor_after(start_point);
+            let end_anchor = buffer.anchor_before(end_point);
+
+            // Make selection and push
+            new_selections.push(Selection {
+                id: selection.id,
+                start: start_anchor.to_offset(&buffer),
+                end: end_anchor.to_offset(&buffer),
+                goal: SelectionGoal::None,
+                reversed: selection.reversed,
+            });
+        }
+
+        self.transact(cx, |this, cx| {
+            this.buffer.update(cx, |buffer, cx| {
+                buffer.edit(edits, None, cx);
+            });
+
+            this.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                s.select(new_selections);
+            });
+
+            this.request_autoscroll(Autoscroll::fit(), cx);
+        });
+    }
+
     pub fn duplicate_line(&mut self, _: &DuplicateLine, cx: &mut ViewContext<Self>) {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let buffer = &display_map.buffer_snapshot;

crates/editor/src/editor_tests.rs 🔗

@@ -2500,6 +2500,156 @@ fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    // Test sort_lines_case_insensitive()
+    cx.set_state(indoc! {"
+        «z
+        y
+        x
+        Z
+        Y
+        Xˇ»
+    "});
+    cx.update_editor(|e, cx| e.sort_lines_case_insensitive(&SortLinesCaseInsensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «x
+        X
+        y
+        Y
+        z
+        Zˇ»
+    "});
+
+    // Test reverse_lines()
+    cx.set_state(indoc! {"
+        «5
+        4
+        3
+        2
+        1ˇ»
+    "});
+    cx.update_editor(|e, cx| e.reverse_lines(&ReverseLines, cx));
+    cx.assert_editor_state(indoc! {"
+        «1
+        2
+        3
+        4
+        5ˇ»
+    "});
+
+    // Skip testing shuffle_line()
+
+    // From here on out, test more complex cases of manipulate_lines() with a single driver method: sort_lines_case_sensitive()
+    // Since all methods calling manipulate_lines() are doing the exact same general thing (reordering lines)
+
+    // Don't manipulate when cursor is on single line, but expand the selection
+    cx.set_state(indoc! {"
+        ddˇdd
+        ccc
+        bb
+        a
+    "});
+    cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «ddddˇ»
+        ccc
+        bb
+        a
+    "});
+
+    // Basic manipulate case
+    // Start selection moves to column 0
+    // End of selection shrinks to fit shorter line
+    cx.set_state(indoc! {"
+        dd«d
+        ccc
+        bb
+        aaaaaˇ»
+    "});
+    cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «aaaaa
+        bb
+        ccc
+        dddˇ»
+    "});
+
+    // Manipulate case with newlines
+    cx.set_state(indoc! {"
+        dd«d
+        ccc
+
+        bb
+        aaaaa
+
+        ˇ»
+    "});
+    cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «
+
+        aaaaa
+        bb
+        ccc
+        dddˇ»
+
+    "});
+}
+
+#[gpui::test]
+async fn test_manipulate_lines_with_multi_selection(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    // Manipulate with multiple selections on a single line
+    cx.set_state(indoc! {"
+        dd«dd
+        cˇ»c«c
+        bb
+        aaaˇ»aa
+    "});
+    cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «aaaaa
+        bb
+        ccc
+        ddddˇ»
+    "});
+
+    // Manipulate with multiple disjoin selections
+    cx.set_state(indoc! {"
+        5«
+        4
+        3
+        2
+        1ˇ»
+
+        dd«dd
+        ccc
+        bb
+        aaaˇ»aa
+    "});
+    cx.update_editor(|e, cx| e.sort_lines_case_sensitive(&SortLinesCaseSensitive, cx));
+    cx.assert_editor_state(indoc! {"
+        «1
+        2
+        3
+        4
+        5ˇ»
+
+        «aaaaa
+        bb
+        ccc
+        ddddˇ»
+    "});
+}
+
 #[gpui::test]
 fn test_duplicate_line(cx: &mut TestAppContext) {
     init_test(cx, |_| {});

crates/editor/src/selections_collection.rs 🔗

@@ -138,7 +138,7 @@ impl SelectionsCollection {
         .collect()
     }
 
-    // Returns all of the selections, adjusted to take into account the selection line_mode
+    /// Returns all of the selections, adjusted to take into account the selection line_mode
     pub fn all_adjusted(&self, cx: &mut AppContext) -> Vec<Selection<Point>> {
         let mut selections = self.all::<Point>(cx);
         if self.line_mode {