Add AutoIndent action and '=' vim operator (#21427)

Max Brunsfeld and Conrad created

Release Notes:

- vim: Added the `=` operator, for auto-indent

Co-authored-by: Conrad <conrad@zed.dev>

Change summary

assets/keymaps/vim.json                           |  10 
crates/editor/src/actions.rs                      |   1 
crates/editor/src/editor.rs                       |  19 +
crates/editor/src/editor_tests.rs                 | 100 ++++
crates/editor/src/element.rs                      |   1 
crates/editor/src/inlay_hint_cache.rs             |  11 
crates/editor/src/test/editor_lsp_test_context.rs |  82 ++--
crates/language/src/buffer.rs                     |  56 ++
crates/multi_buffer/src/multi_buffer.rs           | 292 ++++++++++------
crates/vim/src/indent.rs                          |  85 ++++
crates/vim/src/normal.rs                          |   6 
crates/vim/src/state.rs                           |   3 
crates/vim/src/vim.rs                             |   1 
13 files changed, 481 insertions(+), 186 deletions(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -55,10 +55,10 @@
       "n": "vim::MoveToNextMatch",
       "shift-n": "vim::MoveToPrevMatch",
       "%": "vim::Matching",
-      "] }": ["vim::UnmatchedForward", { "char": "}" } ],
-      "[ {": ["vim::UnmatchedBackward", { "char": "{" } ],
-      "] )": ["vim::UnmatchedForward", { "char": ")" } ],
-      "[ (": ["vim::UnmatchedBackward", { "char": "(" } ],
+      "] }": ["vim::UnmatchedForward", { "char": "}" }],
+      "[ {": ["vim::UnmatchedBackward", { "char": "{" }],
+      "] )": ["vim::UnmatchedForward", { "char": ")" }],
+      "[ (": ["vim::UnmatchedBackward", { "char": "(" }],
       "f": ["vim::PushOperator", { "FindForward": { "before": false } }],
       "t": ["vim::PushOperator", { "FindForward": { "before": true } }],
       "shift-f": ["vim::PushOperator", { "FindBackward": { "after": false } }],
@@ -209,6 +209,7 @@
       "shift-s": "vim::SubstituteLine",
       ">": ["vim::PushOperator", "Indent"],
       "<": ["vim::PushOperator", "Outdent"],
+      "=": ["vim::PushOperator", "AutoIndent"],
       "g u": ["vim::PushOperator", "Lowercase"],
       "g shift-u": ["vim::PushOperator", "Uppercase"],
       "g ~": ["vim::PushOperator", "OppositeCase"],
@@ -275,6 +276,7 @@
       "ctrl-[": ["vim::SwitchMode", "Normal"],
       ">": "vim::Indent",
       "<": "vim::Outdent",
+      "=": "vim::AutoIndent",
       "i": ["vim::PushOperator", { "Object": { "around": false } }],
       "a": ["vim::PushOperator", { "Object": { "around": true } }],
       "g c": "vim::ToggleComments",

crates/editor/src/editor.rs ๐Ÿ”—

@@ -6362,6 +6362,25 @@ impl Editor {
         });
     }
 
+    pub fn autoindent(&mut self, _: &AutoIndent, cx: &mut ViewContext<Self>) {
+        if self.read_only(cx) {
+            return;
+        }
+        let selections = self
+            .selections
+            .all::<usize>(cx)
+            .into_iter()
+            .map(|s| s.range());
+
+        self.transact(cx, |this, cx| {
+            this.buffer.update(cx, |buffer, cx| {
+                buffer.autoindent_ranges(selections, cx);
+            });
+            let selections = this.selections.all::<usize>(cx);
+            this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections));
+        });
+    }
+
     pub fn delete_line(&mut self, _: &DeleteLine, cx: &mut ViewContext<Self>) {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let selections = self.selections.all::<Point>(cx);

crates/editor/src/editor_tests.rs ๐Ÿ”—

@@ -34,6 +34,7 @@ use serde_json::{self, json};
 use std::sync::atomic;
 use std::sync::atomic::AtomicUsize;
 use std::{cell::RefCell, future::Future, rc::Rc, time::Instant};
+use test::editor_lsp_test_context::rust_lang;
 use unindent::Unindent;
 use util::{
     assert_set_eq,
@@ -5458,7 +5459,7 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
+async fn test_autoindent(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});
 
     let language = Arc::new(
@@ -5520,6 +5521,89 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    {
+        let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await;
+        cx.set_state(indoc! {"
+            impl A {
+
+                fn b() {}
+
+            ยซfn c() {
+
+            }ห‡ยป
+            }
+        "});
+
+        cx.update_editor(|editor, cx| {
+            editor.autoindent(&Default::default(), cx);
+        });
+
+        cx.assert_editor_state(indoc! {"
+            impl A {
+
+                fn b() {}
+
+                ยซfn c() {
+
+                }ห‡ยป
+            }
+        "});
+    }
+
+    {
+        let mut cx = EditorTestContext::new_multibuffer(
+            cx,
+            [indoc! { "
+                impl A {
+                ยซ
+                // a
+                fn b(){}
+                ยป
+                ยซ
+                    }
+                    fn c(){}
+                ยป
+            "}],
+        );
+
+        let buffer = cx.update_editor(|editor, cx| {
+            let buffer = editor.buffer().update(cx, |buffer, _| {
+                buffer.all_buffers().iter().next().unwrap().clone()
+            });
+            buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx));
+            buffer
+        });
+
+        cx.run_until_parked();
+        cx.update_editor(|editor, cx| {
+            editor.select_all(&Default::default(), cx);
+            editor.autoindent(&Default::default(), cx)
+        });
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            pretty_assertions::assert_eq!(
+                buffer.read(cx).text(),
+                indoc! { "
+                    impl A {
+
+                        // a
+                        fn b(){}
+
+
+                    }
+                    fn c(){}
+
+                " }
+            )
+        });
+    }
+}
+
 #[gpui::test]
 async fn test_autoclose_and_auto_surround_pairs(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});
@@ -13912,20 +13996,6 @@ pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsC
     update_test_language_settings(cx, f);
 }
 
-pub(crate) fn rust_lang() -> Arc<Language> {
-    Arc::new(Language::new(
-        LanguageConfig {
-            name: "Rust".into(),
-            matcher: LanguageMatcher {
-                path_suffixes: vec!["rs".to_string()],
-                ..Default::default()
-            },
-            ..Default::default()
-        },
-        Some(tree_sitter_rust::LANGUAGE.into()),
-    ))
-}
-
 #[track_caller]
 fn assert_hunk_revert(
     not_reverted_text_with_selections: &str,

crates/editor/src/element.rs ๐Ÿ”—

@@ -189,6 +189,7 @@ impl EditorElement {
         register_action(view, cx, Editor::tab_prev);
         register_action(view, cx, Editor::indent);
         register_action(view, cx, Editor::outdent);
+        register_action(view, cx, Editor::autoindent);
         register_action(view, cx, Editor::delete_line);
         register_action(view, cx, Editor::join_lines);
         register_action(view, cx, Editor::sort_lines_case_sensitive);

crates/editor/src/inlay_hint_cache.rs ๐Ÿ”—

@@ -1258,6 +1258,7 @@ pub mod tests {
 
     use crate::{
         scroll::{scroll_amount::ScrollAmount, Autoscroll},
+        test::editor_lsp_test_context::rust_lang,
         ExcerptRange,
     };
     use futures::StreamExt;
@@ -2274,7 +2275,7 @@ pub mod tests {
         let project = Project::test(fs, ["/a".as_ref()], cx).await;
 
         let language_registry = project.read_with(cx, |project, _| project.languages().clone());
-        language_registry.add(crate::editor_tests::rust_lang());
+        language_registry.add(rust_lang());
         let mut fake_servers = language_registry.register_fake_lsp(
             "Rust",
             FakeLspAdapter {
@@ -2570,7 +2571,7 @@ pub mod tests {
         let project = Project::test(fs, ["/a".as_ref()], cx).await;
 
         let language_registry = project.read_with(cx, |project, _| project.languages().clone());
-        let language = crate::editor_tests::rust_lang();
+        let language = rust_lang();
         language_registry.add(language);
         let mut fake_servers = language_registry.register_fake_lsp(
             "Rust",
@@ -2922,7 +2923,7 @@ pub mod tests {
         let project = Project::test(fs, ["/a".as_ref()], cx).await;
 
         let language_registry = project.read_with(cx, |project, _| project.languages().clone());
-        language_registry.add(crate::editor_tests::rust_lang());
+        language_registry.add(rust_lang());
         let mut fake_servers = language_registry.register_fake_lsp(
             "Rust",
             FakeLspAdapter {
@@ -3153,7 +3154,7 @@ pub mod tests {
         let project = Project::test(fs, ["/a".as_ref()], cx).await;
 
         let language_registry = project.read_with(cx, |project, _| project.languages().clone());
-        language_registry.add(crate::editor_tests::rust_lang());
+        language_registry.add(rust_lang());
         let mut fake_servers = language_registry.register_fake_lsp(
             "Rust",
             FakeLspAdapter {
@@ -3396,7 +3397,7 @@ pub mod tests {
         let project = Project::test(fs, ["/a".as_ref()], cx).await;
 
         let language_registry = project.read_with(cx, |project, _| project.languages().clone());
-        language_registry.add(crate::editor_tests::rust_lang());
+        language_registry.add(rust_lang());
         let mut fake_servers = language_registry.register_fake_lsp(
             "Rust",
             FakeLspAdapter {

crates/editor/src/test/editor_lsp_test_context.rs ๐Ÿ”—

@@ -31,6 +31,47 @@ pub struct EditorLspTestContext {
     pub buffer_lsp_url: lsp::Url,
 }
 
+pub(crate) fn rust_lang() -> Arc<Language> {
+    let language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            matcher: LanguageMatcher {
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::LANGUAGE.into()),
+    )
+    .with_queries(LanguageQueries {
+        indents: Some(Cow::from(indoc! {r#"
+            [
+                ((where_clause) _ @end)
+                (field_expression)
+                (call_expression)
+                (assignment_expression)
+                (let_declaration)
+                (let_chain)
+                (await_expression)
+            ] @indent
+
+            (_ "[" "]" @end) @indent
+            (_ "<" ">" @end) @indent
+            (_ "{" "}" @end) @indent
+            (_ "(" ")" @end) @indent"#})),
+        brackets: Some(Cow::from(indoc! {r#"
+            ("(" @open ")" @close)
+            ("[" @open "]" @close)
+            ("{" @open "}" @close)
+            ("<" @open ">" @close)
+            ("\"" @open "\"" @close)
+            (closure_parameters "|" @open "|" @close)"#})),
+        ..Default::default()
+    })
+    .expect("Could not parse queries");
+    Arc::new(language)
+}
 impl EditorLspTestContext {
     pub async fn new(
         language: Language,
@@ -119,46 +160,7 @@ impl EditorLspTestContext {
         capabilities: lsp::ServerCapabilities,
         cx: &mut gpui::TestAppContext,
     ) -> EditorLspTestContext {
-        let language = Language::new(
-            LanguageConfig {
-                name: "Rust".into(),
-                matcher: LanguageMatcher {
-                    path_suffixes: vec!["rs".to_string()],
-                    ..Default::default()
-                },
-                line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()],
-                ..Default::default()
-            },
-            Some(tree_sitter_rust::LANGUAGE.into()),
-        )
-        .with_queries(LanguageQueries {
-            indents: Some(Cow::from(indoc! {r#"
-                [
-                    ((where_clause) _ @end)
-                    (field_expression)
-                    (call_expression)
-                    (assignment_expression)
-                    (let_declaration)
-                    (let_chain)
-                    (await_expression)
-                ] @indent
-
-                (_ "[" "]" @end) @indent
-                (_ "<" ">" @end) @indent
-                (_ "{" "}" @end) @indent
-                (_ "(" ")" @end) @indent"#})),
-            brackets: Some(Cow::from(indoc! {r#"
-                ("(" @open ")" @close)
-                ("[" @open "]" @close)
-                ("{" @open "}" @close)
-                ("<" @open ">" @close)
-                ("\"" @open "\"" @close)
-                (closure_parameters "|" @open "|" @close)"#})),
-            ..Default::default()
-        })
-        .expect("Could not parse queries");
-
-        Self::new(language, capabilities, cx).await
+        Self::new(Arc::into_inner(rust_lang()).unwrap(), capabilities, cx).await
     }
 
     pub async fn new_typescript(

crates/language/src/buffer.rs ๐Ÿ”—

@@ -467,6 +467,7 @@ struct AutoindentRequest {
     before_edit: BufferSnapshot,
     entries: Vec<AutoindentRequestEntry>,
     is_block_mode: bool,
+    ignore_empty_lines: bool,
 }
 
 #[derive(Debug, Clone)]
@@ -1381,7 +1382,7 @@ impl Buffer {
 
         let autoindent_requests = self.autoindent_requests.clone();
         Some(async move {
-            let mut indent_sizes = BTreeMap::new();
+            let mut indent_sizes = BTreeMap::<u32, (IndentSize, bool)>::new();
             for request in autoindent_requests {
                 // Resolve each edited range to its row in the current buffer and in the
                 // buffer before this batch of edits.
@@ -1475,10 +1476,12 @@ impl Buffer {
                             let suggested_indent = indent_sizes
                                 .get(&suggestion.basis_row)
                                 .copied()
+                                .map(|e| e.0)
                                 .unwrap_or_else(|| {
                                     snapshot.indent_size_for_line(suggestion.basis_row)
                                 })
                                 .with_delta(suggestion.delta, language_indent_size);
+
                             if old_suggestions.get(&new_row).map_or(
                                 true,
                                 |(old_indentation, was_within_error)| {
@@ -1486,7 +1489,10 @@ impl Buffer {
                                         && (!suggestion.within_error || *was_within_error)
                                 },
                             ) {
-                                indent_sizes.insert(new_row, suggested_indent);
+                                indent_sizes.insert(
+                                    new_row,
+                                    (suggested_indent, request.ignore_empty_lines),
+                                );
                             }
                         }
                     }
@@ -1494,10 +1500,12 @@ impl Buffer {
                     if let (true, Some(original_indent_column)) =
                         (request.is_block_mode, original_indent_column)
                     {
-                        let new_indent = indent_sizes
-                            .get(&row_range.start)
-                            .copied()
-                            .unwrap_or_else(|| snapshot.indent_size_for_line(row_range.start));
+                        let new_indent =
+                            if let Some((indent, _)) = indent_sizes.get(&row_range.start) {
+                                *indent
+                            } else {
+                                snapshot.indent_size_for_line(row_range.start)
+                            };
                         let delta = new_indent.len as i64 - original_indent_column as i64;
                         if delta != 0 {
                             for row in row_range.skip(1) {
@@ -1512,7 +1520,7 @@ impl Buffer {
                                             Ordering::Equal => {}
                                         }
                                     }
-                                    size
+                                    (size, request.ignore_empty_lines)
                                 });
                             }
                         }
@@ -1523,6 +1531,15 @@ impl Buffer {
             }
 
             indent_sizes
+                .into_iter()
+                .filter_map(|(row, (indent, ignore_empty_lines))| {
+                    if ignore_empty_lines && snapshot.line_len(row) == 0 {
+                        None
+                    } else {
+                        Some((row, indent))
+                    }
+                })
+                .collect()
         })
     }
 
@@ -2067,6 +2084,7 @@ impl Buffer {
                 before_edit,
                 entries,
                 is_block_mode: matches!(mode, AutoindentMode::Block { .. }),
+                ignore_empty_lines: false,
             }));
         }
 
@@ -2094,6 +2112,30 @@ impl Buffer {
         cx.notify();
     }
 
+    pub fn autoindent_ranges<I, T>(&mut self, ranges: I, cx: &mut ModelContext<Self>)
+    where
+        I: IntoIterator<Item = Range<T>>,
+        T: ToOffset + Copy,
+    {
+        let before_edit = self.snapshot();
+        let entries = ranges
+            .into_iter()
+            .map(|range| AutoindentRequestEntry {
+                range: before_edit.anchor_before(range.start)..before_edit.anchor_after(range.end),
+                first_line_is_new: true,
+                indent_size: before_edit.language_indent_size_at(range.start, cx),
+                original_indent_column: None,
+            })
+            .collect();
+        self.autoindent_requests.push(Arc::new(AutoindentRequest {
+            before_edit,
+            entries,
+            is_block_mode: false,
+            ignore_empty_lines: true,
+        }));
+        self.request_autoindent(cx);
+    }
+
     // Inserts newlines at the given position to create an empty line, returning the start of the new line.
     // You can also request the insertion of empty lines above and below the line starting at the returned point.
     pub fn insert_empty_line(

crates/multi_buffer/src/multi_buffer.rs ๐Ÿ”—

@@ -325,6 +325,13 @@ struct ExcerptBytes<'a> {
     reversed: bool,
 }
 
+struct BufferEdit {
+    range: Range<usize>,
+    new_text: Arc<str>,
+    is_insertion: bool,
+    original_indent_column: u32,
+}
+
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub enum ExpandExcerptDirection {
     Up,
@@ -525,57 +532,146 @@ impl MultiBuffer {
     pub fn edit<I, S, T>(
         &self,
         edits: I,
-        mut autoindent_mode: Option<AutoindentMode>,
+        autoindent_mode: Option<AutoindentMode>,
         cx: &mut ModelContext<Self>,
     ) where
         I: IntoIterator<Item = (Range<S>, T)>,
         S: ToOffset,
         T: Into<Arc<str>>,
     {
-        if self.read_only() {
-            return;
-        }
-        if self.buffers.borrow().is_empty() {
-            return;
-        }
-
         let snapshot = self.read(cx);
-        let edits = edits.into_iter().map(|(range, new_text)| {
-            let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
-            if range.start > range.end {
-                mem::swap(&mut range.start, &mut range.end);
+        let edits = edits
+            .into_iter()
+            .map(|(range, new_text)| {
+                let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
+                if range.start > range.end {
+                    mem::swap(&mut range.start, &mut range.end);
+                }
+                (range, new_text.into())
+            })
+            .collect::<Vec<_>>();
+
+        return edit_internal(self, snapshot, edits, autoindent_mode, cx);
+
+        // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR.
+        fn edit_internal(
+            this: &MultiBuffer,
+            snapshot: Ref<MultiBufferSnapshot>,
+            edits: Vec<(Range<usize>, Arc<str>)>,
+            mut autoindent_mode: Option<AutoindentMode>,
+            cx: &mut ModelContext<MultiBuffer>,
+        ) {
+            if this.read_only() || this.buffers.borrow().is_empty() {
+                return;
+            }
+
+            if let Some(buffer) = this.as_singleton() {
+                buffer.update(cx, |buffer, cx| {
+                    buffer.edit(edits, autoindent_mode, cx);
+                });
+                cx.emit(Event::ExcerptsEdited {
+                    ids: this.excerpt_ids(),
+                });
+                return;
+            }
+
+            let original_indent_columns = match &mut autoindent_mode {
+                Some(AutoindentMode::Block {
+                    original_indent_columns,
+                }) => mem::take(original_indent_columns),
+                _ => Default::default(),
+            };
+
+            let (buffer_edits, edited_excerpt_ids) =
+                this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns);
+            drop(snapshot);
+
+            for (buffer_id, mut edits) in buffer_edits {
+                edits.sort_unstable_by_key(|edit| edit.range.start);
+                this.buffers.borrow()[&buffer_id]
+                    .buffer
+                    .update(cx, |buffer, cx| {
+                        let mut edits = edits.into_iter().peekable();
+                        let mut insertions = Vec::new();
+                        let mut original_indent_columns = Vec::new();
+                        let mut deletions = Vec::new();
+                        let empty_str: Arc<str> = Arc::default();
+                        while let Some(BufferEdit {
+                            mut range,
+                            new_text,
+                            mut is_insertion,
+                            original_indent_column,
+                        }) = edits.next()
+                        {
+                            while let Some(BufferEdit {
+                                range: next_range,
+                                is_insertion: next_is_insertion,
+                                ..
+                            }) = edits.peek()
+                            {
+                                if range.end >= next_range.start {
+                                    range.end = cmp::max(next_range.end, range.end);
+                                    is_insertion |= *next_is_insertion;
+                                    edits.next();
+                                } else {
+                                    break;
+                                }
+                            }
+
+                            if is_insertion {
+                                original_indent_columns.push(original_indent_column);
+                                insertions.push((
+                                    buffer.anchor_before(range.start)
+                                        ..buffer.anchor_before(range.end),
+                                    new_text.clone(),
+                                ));
+                            } else if !range.is_empty() {
+                                deletions.push((
+                                    buffer.anchor_before(range.start)
+                                        ..buffer.anchor_before(range.end),
+                                    empty_str.clone(),
+                                ));
+                            }
+                        }
+
+                        let deletion_autoindent_mode =
+                            if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
+                                Some(AutoindentMode::Block {
+                                    original_indent_columns: Default::default(),
+                                })
+                            } else {
+                                autoindent_mode.clone()
+                            };
+                        let insertion_autoindent_mode =
+                            if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
+                                Some(AutoindentMode::Block {
+                                    original_indent_columns,
+                                })
+                            } else {
+                                autoindent_mode.clone()
+                            };
+
+                        buffer.edit(deletions, deletion_autoindent_mode, cx);
+                        buffer.edit(insertions, insertion_autoindent_mode, cx);
+                    })
             }
-            (range, new_text)
-        });
 
-        if let Some(buffer) = self.as_singleton() {
-            buffer.update(cx, |buffer, cx| {
-                buffer.edit(edits, autoindent_mode, cx);
-            });
             cx.emit(Event::ExcerptsEdited {
-                ids: self.excerpt_ids(),
+                ids: edited_excerpt_ids,
             });
-            return;
         }
+    }
 
-        let original_indent_columns = match &mut autoindent_mode {
-            Some(AutoindentMode::Block {
-                original_indent_columns,
-            }) => mem::take(original_indent_columns),
-            _ => Default::default(),
-        };
-
-        struct BufferEdit {
-            range: Range<usize>,
-            new_text: Arc<str>,
-            is_insertion: bool,
-            original_indent_column: u32,
-        }
+    fn convert_edits_to_buffer_edits(
+        &self,
+        edits: Vec<(Range<usize>, Arc<str>)>,
+        snapshot: &MultiBufferSnapshot,
+        original_indent_columns: &[u32],
+    ) -> (HashMap<BufferId, Vec<BufferEdit>>, Vec<ExcerptId>) {
         let mut buffer_edits: HashMap<BufferId, Vec<BufferEdit>> = Default::default();
         let mut edited_excerpt_ids = Vec::new();
         let mut cursor = snapshot.excerpts.cursor::<usize>(&());
-        for (ix, (range, new_text)) in edits.enumerate() {
-            let new_text: Arc<str> = new_text.into();
+        for (ix, (range, new_text)) in edits.into_iter().enumerate() {
             let original_indent_column = original_indent_columns.get(ix).copied().unwrap_or(0);
             cursor.seek(&range.start, Bias::Right, &());
             if cursor.item().is_none() && range.start == *cursor.start() {
@@ -667,84 +763,71 @@ impl MultiBuffer {
                 }
             }
         }
+        (buffer_edits, edited_excerpt_ids)
+    }
 
-        drop(cursor);
-        drop(snapshot);
-        // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR.
-        fn tail(
+    pub fn autoindent_ranges<I, S>(&self, ranges: I, cx: &mut ModelContext<Self>)
+    where
+        I: IntoIterator<Item = Range<S>>,
+        S: ToOffset,
+    {
+        let snapshot = self.read(cx);
+        let empty = Arc::<str>::from("");
+        let edits = ranges
+            .into_iter()
+            .map(|range| {
+                let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
+                if range.start > range.end {
+                    mem::swap(&mut range.start, &mut range.end);
+                }
+                (range, empty.clone())
+            })
+            .collect::<Vec<_>>();
+
+        return autoindent_ranges_internal(self, snapshot, edits, cx);
+
+        fn autoindent_ranges_internal(
             this: &MultiBuffer,
-            buffer_edits: HashMap<BufferId, Vec<BufferEdit>>,
-            autoindent_mode: Option<AutoindentMode>,
-            edited_excerpt_ids: Vec<ExcerptId>,
+            snapshot: Ref<MultiBufferSnapshot>,
+            edits: Vec<(Range<usize>, Arc<str>)>,
             cx: &mut ModelContext<MultiBuffer>,
         ) {
+            if this.read_only() || this.buffers.borrow().is_empty() {
+                return;
+            }
+
+            if let Some(buffer) = this.as_singleton() {
+                buffer.update(cx, |buffer, cx| {
+                    buffer.autoindent_ranges(edits.into_iter().map(|e| e.0), cx);
+                });
+                cx.emit(Event::ExcerptsEdited {
+                    ids: this.excerpt_ids(),
+                });
+                return;
+            }
+
+            let (buffer_edits, edited_excerpt_ids) =
+                this.convert_edits_to_buffer_edits(edits, &snapshot, &[]);
+            drop(snapshot);
+
             for (buffer_id, mut edits) in buffer_edits {
                 edits.sort_unstable_by_key(|edit| edit.range.start);
-                this.buffers.borrow()[&buffer_id]
-                    .buffer
-                    .update(cx, |buffer, cx| {
-                        let mut edits = edits.into_iter().peekable();
-                        let mut insertions = Vec::new();
-                        let mut original_indent_columns = Vec::new();
-                        let mut deletions = Vec::new();
-                        let empty_str: Arc<str> = Arc::default();
-                        while let Some(BufferEdit {
-                            mut range,
-                            new_text,
-                            mut is_insertion,
-                            original_indent_column,
-                        }) = edits.next()
-                        {
-                            while let Some(BufferEdit {
-                                range: next_range,
-                                is_insertion: next_is_insertion,
-                                ..
-                            }) = edits.peek()
-                            {
-                                if range.end >= next_range.start {
-                                    range.end = cmp::max(next_range.end, range.end);
-                                    is_insertion |= *next_is_insertion;
-                                    edits.next();
-                                } else {
-                                    break;
-                                }
-                            }
 
-                            if is_insertion {
-                                original_indent_columns.push(original_indent_column);
-                                insertions.push((
-                                    buffer.anchor_before(range.start)
-                                        ..buffer.anchor_before(range.end),
-                                    new_text.clone(),
-                                ));
-                            } else if !range.is_empty() {
-                                deletions.push((
-                                    buffer.anchor_before(range.start)
-                                        ..buffer.anchor_before(range.end),
-                                    empty_str.clone(),
-                                ));
-                            }
+                let mut ranges: Vec<Range<usize>> = Vec::new();
+                for edit in edits {
+                    if let Some(last_range) = ranges.last_mut() {
+                        if edit.range.start <= last_range.end {
+                            last_range.end = last_range.end.max(edit.range.end);
+                            continue;
                         }
+                    }
+                    ranges.push(edit.range);
+                }
 
-                        let deletion_autoindent_mode =
-                            if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
-                                Some(AutoindentMode::Block {
-                                    original_indent_columns: Default::default(),
-                                })
-                            } else {
-                                autoindent_mode.clone()
-                            };
-                        let insertion_autoindent_mode =
-                            if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
-                                Some(AutoindentMode::Block {
-                                    original_indent_columns,
-                                })
-                            } else {
-                                autoindent_mode.clone()
-                            };
-
-                        buffer.edit(deletions, deletion_autoindent_mode, cx);
-                        buffer.edit(insertions, insertion_autoindent_mode, cx);
+                this.buffers.borrow()[&buffer_id]
+                    .buffer
+                    .update(cx, |buffer, cx| {
+                        buffer.autoindent_ranges(ranges, cx);
                     })
             }
 
@@ -752,7 +835,6 @@ impl MultiBuffer {
                 ids: edited_excerpt_ids,
             });
         }
-        tail(self, buffer_edits, autoindent_mode, edited_excerpt_ids, cx);
     }
 
     // Inserts newlines at the given position to create an empty line, returning the start of the new line.

crates/vim/src/indent.rs ๐Ÿ”—

@@ -9,9 +9,10 @@ use ui::ViewContext;
 pub(crate) enum IndentDirection {
     In,
     Out,
+    Auto,
 }
 
-actions!(vim, [Indent, Outdent,]);
+actions!(vim, [Indent, Outdent, AutoIndent]);
 
 pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
     Vim::action(editor, cx, |vim, _: &Indent, cx| {
@@ -49,6 +50,24 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
             vim.switch_mode(Mode::Normal, true, cx)
         }
     });
+
+    Vim::action(editor, cx, |vim, _: &AutoIndent, cx| {
+        vim.record_current_action(cx);
+        let count = Vim::take_count(cx).unwrap_or(1);
+        vim.store_visual_marks(cx);
+        vim.update_editor(cx, |vim, editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                let original_positions = vim.save_selection_starts(editor, cx);
+                for _ in 0..count {
+                    editor.autoindent(&Default::default(), cx);
+                }
+                vim.restore_selection_cursors(editor, cx, original_positions);
+            });
+        });
+        if vim.mode.is_visual() {
+            vim.switch_mode(Mode::Normal, true, cx)
+        }
+    });
 }
 
 impl Vim {
@@ -71,10 +90,10 @@ impl Vim {
                         motion.expand_selection(map, selection, times, false, &text_layout_details);
                     });
                 });
-                if dir == IndentDirection::In {
-                    editor.indent(&Default::default(), cx);
-                } else {
-                    editor.outdent(&Default::default(), cx);
+                match dir {
+                    IndentDirection::In => editor.indent(&Default::default(), cx),
+                    IndentDirection::Out => editor.outdent(&Default::default(), cx),
+                    IndentDirection::Auto => editor.autoindent(&Default::default(), cx),
                 }
                 editor.change_selections(None, cx, |s| {
                     s.move_with(|map, selection| {
@@ -104,10 +123,10 @@ impl Vim {
                         object.expand_selection(map, selection, around);
                     });
                 });
-                if dir == IndentDirection::In {
-                    editor.indent(&Default::default(), cx);
-                } else {
-                    editor.outdent(&Default::default(), cx);
+                match dir {
+                    IndentDirection::In => editor.indent(&Default::default(), cx),
+                    IndentDirection::Out => editor.outdent(&Default::default(), cx),
+                    IndentDirection::Auto => editor.autoindent(&Default::default(), cx),
                 }
                 editor.change_selections(None, cx, |s| {
                     s.move_with(|map, selection| {
@@ -122,7 +141,11 @@ impl Vim {
 
 #[cfg(test)]
 mod test {
-    use crate::test::NeovimBackedTestContext;
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
+    use indoc::indoc;
 
     #[gpui::test]
     async fn test_indent_gv(cx: &mut gpui::TestAppContext) {
@@ -135,4 +158,46 @@ mod test {
             .await
             .assert_eq("ยซ    hello\n ห‡ยป   world\n");
     }
+
+    #[gpui::test]
+    async fn test_autoindent_op(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state(
+            indoc!(
+                "
+            fn a() {
+                b();
+                c();
+
+                    d();
+                    ห‡e();
+                    f();
+
+                g();
+            }
+        "
+            ),
+            Mode::Normal,
+        );
+
+        cx.simulate_keystrokes("= a p");
+        cx.assert_state(
+            indoc!(
+                "
+                fn a() {
+                    b();
+                    c();
+
+                    d();
+                    ห‡e();
+                    f();
+
+                    g();
+                }
+            "
+            ),
+            Mode::Normal,
+        );
+    }
 }

crates/vim/src/normal.rs ๐Ÿ”—

@@ -170,6 +170,9 @@ impl Vim {
             Some(Operator::Indent) => self.indent_motion(motion, times, IndentDirection::In, cx),
             Some(Operator::Rewrap) => self.rewrap_motion(motion, times, cx),
             Some(Operator::Outdent) => self.indent_motion(motion, times, IndentDirection::Out, cx),
+            Some(Operator::AutoIndent) => {
+                self.indent_motion(motion, times, IndentDirection::Auto, cx)
+            }
             Some(Operator::Lowercase) => {
                 self.change_case_motion(motion, times, CaseTarget::Lowercase, cx)
             }
@@ -202,6 +205,9 @@ impl Vim {
                 Some(Operator::Outdent) => {
                     self.indent_object(object, around, IndentDirection::Out, cx)
                 }
+                Some(Operator::AutoIndent) => {
+                    self.indent_object(object, around, IndentDirection::Auto, cx)
+                }
                 Some(Operator::Rewrap) => self.rewrap_object(object, around, cx),
                 Some(Operator::Lowercase) => {
                     self.change_case_object(object, around, CaseTarget::Lowercase, cx)

crates/vim/src/state.rs ๐Ÿ”—

@@ -72,6 +72,7 @@ pub enum Operator {
     Jump { line: bool },
     Indent,
     Outdent,
+    AutoIndent,
     Rewrap,
     Lowercase,
     Uppercase,
@@ -465,6 +466,7 @@ impl Operator {
             Operator::Jump { line: true } => "'",
             Operator::Jump { line: false } => "`",
             Operator::Indent => ">",
+            Operator::AutoIndent => "eq",
             Operator::Rewrap => "gq",
             Operator::Outdent => "<",
             Operator::Uppercase => "gU",
@@ -510,6 +512,7 @@ impl Operator {
             | Operator::Rewrap
             | Operator::Indent
             | Operator::Outdent
+            | Operator::AutoIndent
             | Operator::Lowercase
             | Operator::Uppercase
             | Operator::Object { .. }

crates/vim/src/vim.rs ๐Ÿ”—

@@ -470,6 +470,7 @@ impl Vim {
                 | Operator::Replace
                 | Operator::Indent
                 | Operator::Outdent
+                | Operator::AutoIndent
                 | Operator::Lowercase
                 | Operator::Uppercase
                 | Operator::OppositeCase