Merge pull request #241 from zed-industries/toggle-comments

Max Brunsfeld created

Implement toggle-comments

Change summary

crates/buffer/src/lib.rs              |  16 ++
crates/editor/src/lib.rs              | 178 +++++++++++++++++++++++++++++
crates/language/src/language.rs       |   5 
crates/language/src/lib.rs            |   5 
crates/zed/languages/rust/config.toml |   1 
5 files changed, 198 insertions(+), 7 deletions(-)

Detailed changes

crates/buffer/src/lib.rs 🔗

@@ -564,6 +564,10 @@ impl Buffer {
         self.content().line_len(row)
     }
 
+    pub fn is_line_blank(&self, row: u32) -> bool {
+        self.content().is_line_blank(row)
+    }
+
     pub fn max_point(&self) -> Point {
         self.visible_text.max_point()
     }
@@ -1557,6 +1561,10 @@ impl Snapshot {
         self.content().line_len(row)
     }
 
+    pub fn is_line_blank(&self, row: u32) -> bool {
+        self.content().is_line_blank(row)
+    }
+
     pub fn indent_column_for_line(&self, row: u32) -> u32 {
         self.content().indent_column_for_line(row)
     }
@@ -1574,8 +1582,7 @@ impl Snapshot {
     }
 
     pub fn text_for_range<T: ToOffset>(&self, range: Range<T>) -> Chunks {
-        let range = range.start.to_offset(self)..range.end.to_offset(self);
-        self.visible_text.chunks_in_range(range)
+        self.content().text_for_range(range)
     }
 
     pub fn text_summary_for_range<T>(&self, range: Range<T>) -> TextSummary
@@ -1725,6 +1732,11 @@ impl<'a> Content<'a> {
         (row_end_offset - row_start_offset) as u32
     }
 
+    fn is_line_blank(&self, row: u32) -> bool {
+        self.text_for_range(Point::new(row, 0)..Point::new(row, self.line_len(row)))
+            .all(|chunk| chunk.matches(|c: char| !c.is_whitespace()).next().is_none())
+    }
+
     pub fn indent_column_for_line(&self, row: u32) -> u32 {
         let mut result = 0;
         for c in self.chars_at(Point::new(row, 0)) {

crates/editor/src/lib.rs 🔗

@@ -83,6 +83,7 @@ action!(SelectLine);
 action!(SplitSelectionIntoLines);
 action!(AddSelectionAbove);
 action!(AddSelectionBelow);
+action!(ToggleComments);
 action!(SelectLargerSyntaxNode);
 action!(SelectSmallerSyntaxNode);
 action!(MoveToEnclosingBracket);
@@ -184,6 +185,7 @@ pub fn init(cx: &mut MutableAppContext) {
         Binding::new("cmd-ctrl-p", AddSelectionAbove, Some("Editor")),
         Binding::new("cmd-alt-down", AddSelectionBelow, Some("Editor")),
         Binding::new("cmd-ctrl-n", AddSelectionBelow, Some("Editor")),
+        Binding::new("cmd-/", ToggleComments, Some("Editor")),
         Binding::new("alt-up", SelectLargerSyntaxNode, Some("Editor")),
         Binding::new("ctrl-w", SelectLargerSyntaxNode, Some("Editor")),
         Binding::new("alt-down", SelectSmallerSyntaxNode, Some("Editor")),
@@ -244,6 +246,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::split_selection_into_lines);
     cx.add_action(Editor::add_selection_above);
     cx.add_action(Editor::add_selection_below);
+    cx.add_action(Editor::toggle_comments);
     cx.add_action(Editor::select_larger_syntax_node);
     cx.add_action(Editor::select_smaller_syntax_node);
     cx.add_action(Editor::move_to_enclosing_bracket);
@@ -2127,6 +2130,96 @@ impl Editor {
         }
     }
 
+    pub fn toggle_comments(&mut self, _: &ToggleComments, cx: &mut ViewContext<Self>) {
+        // Get the line comment prefix. Split its trailing whitespace into a separate string,
+        // as that portion won't be used for detecting if a line is a comment.
+        let full_comment_prefix =
+            if let Some(prefix) = self.language(cx).and_then(|l| l.line_comment_prefix()) {
+                prefix.to_string()
+            } else {
+                return;
+            };
+        let comment_prefix = full_comment_prefix.trim_end_matches(' ');
+        let comment_prefix_whitespace = &full_comment_prefix[comment_prefix.len()..];
+
+        self.start_transaction(cx);
+        let mut selections = self.selections::<Point>(cx).collect::<Vec<_>>();
+        let mut all_selection_lines_are_comments = true;
+        let mut edit_ranges = Vec::new();
+        let mut last_toggled_row = None;
+        self.buffer.update(cx, |buffer, cx| {
+            for selection in &mut selections {
+                edit_ranges.clear();
+
+                let end_row =
+                    if selection.end.row > selection.start.row && selection.end.column == 0 {
+                        selection.end.row
+                    } else {
+                        selection.end.row + 1
+                    };
+
+                for row in selection.start.row..end_row {
+                    // If multiple selections contain a given row, avoid processing that
+                    // row more than once.
+                    if last_toggled_row == Some(row) {
+                        continue;
+                    } else {
+                        last_toggled_row = Some(row);
+                    }
+
+                    if buffer.is_line_blank(row) {
+                        continue;
+                    }
+
+                    let start = Point::new(row, buffer.indent_column_for_line(row));
+                    let mut line_bytes = buffer.bytes_at(start);
+
+                    // If this line currently begins with the line comment prefix, then record
+                    // the range containing the prefix.
+                    if all_selection_lines_are_comments
+                        && line_bytes
+                            .by_ref()
+                            .take(comment_prefix.len())
+                            .eq(comment_prefix.bytes())
+                    {
+                        // Include any whitespace that matches the comment prefix.
+                        let matching_whitespace_len = line_bytes
+                            .zip(comment_prefix_whitespace.bytes())
+                            .take_while(|(a, b)| a == b)
+                            .count() as u32;
+                        let end = Point::new(
+                            row,
+                            start.column + comment_prefix.len() as u32 + matching_whitespace_len,
+                        );
+                        edit_ranges.push(start..end);
+                    }
+                    // If this line does not begin with the line comment prefix, then record
+                    // the position where the prefix should be inserted.
+                    else {
+                        all_selection_lines_are_comments = false;
+                        edit_ranges.push(start..start);
+                    }
+                }
+
+                if !edit_ranges.is_empty() {
+                    if all_selection_lines_are_comments {
+                        buffer.edit(edit_ranges.iter().cloned(), "", cx);
+                    } else {
+                        let min_column = edit_ranges.iter().map(|r| r.start.column).min().unwrap();
+                        let edit_ranges = edit_ranges.iter().map(|range| {
+                            let position = Point::new(range.start.row, min_column);
+                            position..position
+                        });
+                        buffer.edit(edit_ranges, &full_comment_prefix, cx);
+                    }
+                }
+            }
+        });
+
+        self.update_selections(self.selections::<usize>(cx).collect(), true, cx);
+        self.end_transaction(cx);
+    }
+
     pub fn select_larger_syntax_node(
         &mut self,
         _: &SelectLargerSyntaxNode,
@@ -4890,6 +4983,91 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_toggle_comment(mut cx: gpui::TestAppContext) {
+        let settings = cx.read(EditorSettings::test);
+        let language = Some(Arc::new(Language::new(
+            LanguageConfig {
+                line_comment: Some("// ".to_string()),
+                ..Default::default()
+            },
+            tree_sitter_rust::language(),
+        )));
+
+        let text = "
+            fn a() {
+                //b();
+                // c();
+                //  d();
+            }
+        "
+        .unindent();
+
+        let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, None, cx));
+        let (_, view) = cx.add_window(|cx| build_editor(buffer, settings, cx));
+
+        view.update(&mut cx, |editor, cx| {
+            // If multiple selections intersect a line, the line is only
+            // toggled once.
+            editor
+                .select_display_ranges(
+                    &[
+                        DisplayPoint::new(1, 3)..DisplayPoint::new(2, 3),
+                        DisplayPoint::new(3, 5)..DisplayPoint::new(3, 6),
+                    ],
+                    cx,
+                )
+                .unwrap();
+            editor.toggle_comments(&ToggleComments, cx);
+            assert_eq!(
+                editor.text(cx),
+                "
+                    fn a() {
+                        b();
+                        c();
+                         d();
+                    }
+                "
+                .unindent()
+            );
+
+            // The comment prefix is inserted at the same column for every line
+            // in a selection.
+            editor
+                .select_display_ranges(&[DisplayPoint::new(1, 3)..DisplayPoint::new(3, 6)], cx)
+                .unwrap();
+            editor.toggle_comments(&ToggleComments, cx);
+            assert_eq!(
+                editor.text(cx),
+                "
+                    fn a() {
+                        // b();
+                        // c();
+                        //  d();
+                    }
+                "
+                .unindent()
+            );
+
+            // If a selection ends at the beginning of a line, that line is not toggled.
+            editor
+                .select_display_ranges(&[DisplayPoint::new(2, 0)..DisplayPoint::new(3, 0)], cx)
+                .unwrap();
+            editor.toggle_comments(&ToggleComments, cx);
+            assert_eq!(
+                editor.text(cx),
+                "
+                        fn a() {
+                            // b();
+                            c();
+                            //  d();
+                        }
+                    "
+                .unindent()
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_extra_newline_insertion(mut cx: gpui::TestAppContext) {
         let settings = cx.read(EditorSettings::test);

crates/language/src/language.rs 🔗

@@ -14,6 +14,7 @@ pub struct LanguageConfig {
     pub name: String,
     pub path_suffixes: Vec<String>,
     pub brackets: Vec<BracketPair>,
+    pub line_comment: Option<String>,
     pub language_server: Option<LanguageServerConfig>,
 }
 
@@ -115,6 +116,10 @@ impl Language {
         self.config.name.as_str()
     }
 
+    pub fn line_comment_prefix(&self) -> Option<&str> {
+        self.config.line_comment.as_deref()
+    }
+
     pub fn start_server(
         &self,
         root_path: &Path,

crates/language/src/lib.rs 🔗

@@ -1654,11 +1654,6 @@ impl Snapshot {
         None
     }
 
-    fn is_line_blank(&self, row: u32) -> bool {
-        self.text_for_range(Point::new(row, 0)..Point::new(row, self.line_len(row)))
-            .all(|chunk| chunk.matches(|c: char| !c.is_whitespace()).next().is_none())
-    }
-
     pub fn chunks<'a, T: ToOffset>(
         &'a self,
         range: Range<T>,

crates/zed/languages/rust/config.toml 🔗

@@ -1,5 +1,6 @@
 name = "Rust"
 path_suffixes = ["rs"]
+line_comment = "// "
 brackets = [
     { start = "{", end = "}", close = true, newline = true },
     { start = "[", end = "]", close = true, newline = true },