Merge pull request #1246 from zed-industries/python-autoindent

Antonio Scandurra created

Fix Python auto-indent using new auto-indent features

Change summary

Cargo.lock                                  |  10 
crates/language/Cargo.toml                  |   2 
crates/language/src/buffer.rs               | 205 ++++++++++++++++------
crates/language/src/language.rs             |  30 ++
crates/zed/Cargo.toml                       |   4 
crates/zed/src/languages/c.rs               |  38 ++++
crates/zed/src/languages/c/indents.scm      |   6 
crates/zed/src/languages/python.rs          | 100 +++++++++++
crates/zed/src/languages/python/config.toml |   4 
crates/zed/src/languages/python/indents.scm |   1 
crates/zed/src/languages/rust.rs            |  40 ++++
11 files changed, 371 insertions(+), 69 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2397,6 +2397,7 @@ dependencies = [
  "parking_lot 0.11.2",
  "postage",
  "rand 0.8.5",
+ "regex",
  "rpc",
  "serde",
  "serde_json",
@@ -2408,6 +2409,7 @@ dependencies = [
  "theme",
  "tree-sitter",
  "tree-sitter-json 0.19.0",
+ "tree-sitter-python",
  "tree-sitter-rust",
  "tree-sitter-typescript",
  "unindent",
@@ -5214,9 +5216,9 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter"
-version = "0.20.7"
+version = "0.20.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "549a9faf45679ad50b7f603253635598cf5e007d8ceb806a23f95355938f76a0"
+checksum = "268bf3e3ca0c09e5d21b59c2638e12cb6dcf7ea2681250a696a2d0936cb57ba0"
 dependencies = [
  "cc",
  "regex",
@@ -5281,9 +5283,9 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter-python"
-version = "0.20.1"
+version = "0.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "713170684ba94376b784b0c6dd23693461e15f96a806ed1848e40996e3cda7c7"
+checksum = "dda114f58048f5059dcf158aff691dffb8e113e6d2b50d94263fd68711975287"
 dependencies = [
  "cc",
  "tree-sitter",

crates/language/Cargo.toml 🔗

@@ -40,6 +40,7 @@ log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11.1"
 postage = { version = "0.4.1", features = ["futures-traits"] }
 rand = { version = "0.8.3", optional = true }
+regex = "1.5"
 serde = { version = "1.0", features = ["derive", "rc"] }
 serde_json = { version = "1", features = ["preserve_order"] }
 similar = "1.3"
@@ -61,5 +62,6 @@ env_logger = "0.9"
 rand = "0.8.3"
 tree-sitter-json = "*"
 tree-sitter-rust = "*"
+tree-sitter-python = "*"
 tree-sitter-typescript = "*"
 unindent = "0.1.7"

crates/language/src/buffer.rs 🔗

@@ -237,7 +237,7 @@ struct AutoindentRequest {
 #[derive(Debug)]
 struct IndentSuggestion {
     basis_row: u32,
-    indent: bool,
+    delta: Ordering,
 }
 
 pub(crate) struct TextProvider<'a>(pub(crate) &'a Rope);
@@ -812,19 +812,23 @@ impl Buffer {
                         .into_iter()
                         .flatten();
                     for (old_row, suggestion) in old_edited_range.zip(suggestions) {
-                        let mut suggested_indent = old_to_new_rows
-                            .get(&suggestion.basis_row)
-                            .and_then(|from_row| old_suggestions.get(from_row).copied())
-                            .unwrap_or_else(|| {
-                                request
-                                    .before_edit
-                                    .indent_size_for_line(suggestion.basis_row)
-                            });
-                        if suggestion.indent {
-                            suggested_indent += request.indent_size;
+                        if let Some(suggestion) = suggestion {
+                            let mut suggested_indent = old_to_new_rows
+                                .get(&suggestion.basis_row)
+                                .and_then(|from_row| old_suggestions.get(from_row).copied())
+                                .unwrap_or_else(|| {
+                                    request
+                                        .before_edit
+                                        .indent_size_for_line(suggestion.basis_row)
+                                });
+                            if suggestion.delta.is_gt() {
+                                suggested_indent += request.indent_size;
+                            } else if suggestion.delta.is_lt() {
+                                suggested_indent -= request.indent_size;
+                            }
+                            old_suggestions
+                                .insert(*old_to_new_rows.get(&old_row).unwrap(), suggested_indent);
                         }
-                        old_suggestions
-                            .insert(*old_to_new_rows.get(&old_row).unwrap(), suggested_indent);
                     }
                     yield_now().await;
                 }
@@ -839,18 +843,26 @@ impl Buffer {
                         .into_iter()
                         .flatten();
                     for (new_row, suggestion) in new_edited_row_range.zip(suggestions) {
-                        let mut suggested_indent = indent_sizes
-                            .get(&suggestion.basis_row)
-                            .copied()
-                            .unwrap_or_else(|| snapshot.indent_size_for_line(suggestion.basis_row));
-                        if suggestion.indent {
-                            suggested_indent += request.indent_size;
-                        }
-                        if old_suggestions
-                            .get(&new_row)
-                            .map_or(true, |old_indentation| suggested_indent != *old_indentation)
-                        {
-                            indent_sizes.insert(new_row, suggested_indent);
+                        if let Some(suggestion) = suggestion {
+                            let mut suggested_indent = indent_sizes
+                                .get(&suggestion.basis_row)
+                                .copied()
+                                .unwrap_or_else(|| {
+                                    snapshot.indent_size_for_line(suggestion.basis_row)
+                                });
+                            if suggestion.delta.is_gt() {
+                                suggested_indent += request.indent_size;
+                            } else if suggestion.delta.is_lt() {
+                                suggested_indent -= request.indent_size;
+                            }
+                            if old_suggestions
+                                .get(&new_row)
+                                .map_or(true, |old_indentation| {
+                                    suggested_indent != *old_indentation
+                                })
+                            {
+                                indent_sizes.insert(new_row, suggested_indent);
+                            }
                         }
                     }
                     yield_now().await;
@@ -870,16 +882,20 @@ impl Buffer {
                             .into_iter()
                             .flatten();
                         for (row, suggestion) in inserted_row_range.zip(suggestions) {
-                            let mut suggested_indent = indent_sizes
-                                .get(&suggestion.basis_row)
-                                .copied()
-                                .unwrap_or_else(|| {
-                                    snapshot.indent_size_for_line(suggestion.basis_row)
-                                });
-                            if suggestion.indent {
-                                suggested_indent += request.indent_size;
+                            if let Some(suggestion) = suggestion {
+                                let mut suggested_indent = indent_sizes
+                                    .get(&suggestion.basis_row)
+                                    .copied()
+                                    .unwrap_or_else(|| {
+                                        snapshot.indent_size_for_line(suggestion.basis_row)
+                                    });
+                                if suggestion.delta.is_gt() {
+                                    suggested_indent += request.indent_size;
+                                } else if suggestion.delta.is_lt() {
+                                    suggested_indent -= request.indent_size;
+                                }
+                                indent_sizes.insert(row, suggested_indent);
                             }
-                            indent_sizes.insert(row, suggested_indent);
                         }
                         yield_now().await;
                     }
@@ -1551,10 +1567,13 @@ impl BufferSnapshot {
     fn suggest_autoindents<'a>(
         &'a self,
         row_range: Range<u32>,
-    ) -> Option<impl Iterator<Item = IndentSuggestion> + 'a> {
-        // Get the "indentation ranges" that intersect this row range.
-        let grammar = self.grammar()?;
+    ) -> Option<impl Iterator<Item = Option<IndentSuggestion>> + 'a> {
+        let language = self.language.as_ref()?;
+        let grammar = language.grammar.as_ref()?;
+        let config = &language.config;
         let prev_non_blank_row = self.prev_non_blank_row(row_range.start);
+
+        // Find the suggested indentation ranges based on the syntax tree.
         let indents_query = grammar.indents_query.as_ref()?;
         let mut query_cursor = QueryCursorHandle::new();
         let indent_capture_ix = indents_query.capture_index_for_name("indent");
@@ -1563,6 +1582,7 @@ impl BufferSnapshot {
             Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0).to_ts_point()
                 ..Point::new(row_range.end, 0).to_ts_point(),
         );
+
         let mut indentation_ranges = Vec::<Range<Point>>::new();
         for mat in query_cursor.matches(
             indents_query,
@@ -1596,48 +1616,98 @@ impl BufferSnapshot {
             }
         }
 
-        let mut prev_row = prev_non_blank_row.unwrap_or(0);
+        // Find the suggested indentation increases and decreased based on regexes.
+        let mut indent_changes = Vec::<(u32, Ordering)>::new();
+        self.for_each_line(
+            Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0)
+                ..Point::new(row_range.end, 0),
+            |row, line| {
+                if config
+                    .decrease_indent_pattern
+                    .as_ref()
+                    .map_or(false, |regex| regex.is_match(line))
+                {
+                    indent_changes.push((row, Ordering::Less));
+                }
+                if config
+                    .increase_indent_pattern
+                    .as_ref()
+                    .map_or(false, |regex| regex.is_match(line))
+                {
+                    indent_changes.push((row + 1, Ordering::Greater));
+                }
+            },
+        );
+
+        let mut indent_changes = indent_changes.into_iter().peekable();
+        let mut prev_row = row_range.start.saturating_sub(1);
+        let mut prev_row_start = Point::new(prev_row, self.indent_size_for_line(prev_row).len);
         Some(row_range.map(move |row| {
             let row_start = Point::new(row, self.indent_size_for_line(row).len);
 
             let mut indent_from_prev_row = false;
+            let mut outdent_from_prev_row = false;
             let mut outdent_to_row = u32::MAX;
+
+            while let Some((indent_row, delta)) = indent_changes.peek() {
+                if *indent_row == row {
+                    match delta {
+                        Ordering::Less => outdent_from_prev_row = true,
+                        Ordering::Greater => indent_from_prev_row = true,
+                        _ => {}
+                    }
+                } else if *indent_row > row {
+                    break;
+                }
+                indent_changes.next();
+            }
+
             for range in &indentation_ranges {
                 if range.start.row >= row {
                     break;
                 }
-
                 if range.start.row == prev_row && range.end > row_start {
                     indent_from_prev_row = true;
                 }
-                if range.end.row >= prev_row && range.end <= row_start {
+                if range.end > prev_row_start && range.end <= row_start {
                     outdent_to_row = outdent_to_row.min(range.start.row);
                 }
             }
 
-            let suggestion = if outdent_to_row == prev_row {
-                IndentSuggestion {
+            let suggestion = if outdent_to_row == prev_row
+                || (outdent_from_prev_row && indent_from_prev_row)
+            {
+                Some(IndentSuggestion {
                     basis_row: prev_row,
-                    indent: false,
-                }
+                    delta: Ordering::Equal,
+                })
             } else if indent_from_prev_row {
-                IndentSuggestion {
+                Some(IndentSuggestion {
                     basis_row: prev_row,
-                    indent: true,
-                }
+                    delta: Ordering::Greater,
+                })
             } else if outdent_to_row < prev_row {
-                IndentSuggestion {
+                Some(IndentSuggestion {
                     basis_row: outdent_to_row,
-                    indent: false,
-                }
-            } else {
-                IndentSuggestion {
+                    delta: Ordering::Equal,
+                })
+            } else if outdent_from_prev_row {
+                Some(IndentSuggestion {
                     basis_row: prev_row,
-                    indent: false,
-                }
+                    delta: Ordering::Less,
+                })
+            } else if config.auto_indent_using_last_non_empty_line || !self.is_line_blank(prev_row)
+            {
+                Some(IndentSuggestion {
+                    basis_row: prev_row,
+                    delta: Ordering::Equal,
+                })
+            } else {
+                None
             };
 
             prev_row = row;
+            prev_row_start = row_start;
             suggestion
         }))
     }
@@ -1690,6 +1760,25 @@ impl BufferSnapshot {
         )
     }
 
+    pub fn for_each_line<'a>(&'a self, range: Range<Point>, mut callback: impl FnMut(u32, &str)) {
+        let mut line = String::new();
+        let mut row = range.start.row;
+        for chunk in self
+            .as_rope()
+            .chunks_in_range(range.to_offset(self))
+            .chain(["\n"])
+        {
+            for (newline_ix, text) in chunk.split('\n').enumerate() {
+                if newline_ix > 0 {
+                    callback(row, &line);
+                    row += 1;
+                    line.clear();
+                }
+                line.push_str(text);
+            }
+        }
+    }
+
     pub fn language(&self) -> Option<&Arc<Language>> {
         self.language.as_ref()
     }
@@ -2411,6 +2500,14 @@ impl std::ops::AddAssign for IndentSize {
     }
 }
 
+impl std::ops::SubAssign for IndentSize {
+    fn sub_assign(&mut self, other: IndentSize) {
+        if self.kind == other.kind && self.len >= other.len {
+            self.len -= other.len;
+        }
+    }
+}
+
 impl Completion {
     pub fn sort_key(&self) -> (usize, &str) {
         let kind_key = match self.lsp_completion.kind {

crates/language/src/language.rs 🔗

@@ -17,7 +17,8 @@ use gpui::{MutableAppContext, Task};
 use highlight_map::HighlightMap;
 use lazy_static::lazy_static;
 use parking_lot::{Mutex, RwLock};
-use serde::Deserialize;
+use regex::Regex;
+use serde::{de, Deserialize, Deserializer};
 use serde_json::Value;
 use std::{
     any::Any,
@@ -49,10 +50,7 @@ lazy_static! {
     pub static ref PLAIN_TEXT: Arc<Language> = Arc::new(Language::new(
         LanguageConfig {
             name: "Plain Text".into(),
-            path_suffixes: Default::default(),
-            brackets: Default::default(),
-            autoclose_before: Default::default(),
-            line_comment: None,
+            ..Default::default()
         },
         None,
     ));
@@ -123,6 +121,12 @@ pub struct LanguageConfig {
     pub name: Arc<str>,
     pub path_suffixes: Vec<String>,
     pub brackets: Vec<BracketPair>,
+    #[serde(default = "auto_indent_using_last_non_empty_line_default")]
+    pub auto_indent_using_last_non_empty_line: bool,
+    #[serde(default, deserialize_with = "deserialize_regex")]
+    pub increase_indent_pattern: Option<Regex>,
+    #[serde(default, deserialize_with = "deserialize_regex")]
+    pub decrease_indent_pattern: Option<Regex>,
     #[serde(default)]
     pub autoclose_before: String,
     pub line_comment: Option<String>,
@@ -134,12 +138,28 @@ impl Default for LanguageConfig {
             name: "".into(),
             path_suffixes: Default::default(),
             brackets: Default::default(),
+            auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(),
+            increase_indent_pattern: Default::default(),
+            decrease_indent_pattern: Default::default(),
             autoclose_before: Default::default(),
             line_comment: Default::default(),
         }
     }
 }
 
+fn auto_indent_using_last_non_empty_line_default() -> bool {
+    true
+}
+
+fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Regex>, D::Error> {
+    let source = Option::<String>::deserialize(d)?;
+    if let Some(source) = source {
+        Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?))
+    } else {
+        Ok(None)
+    }
+}
+
 #[cfg(any(test, feature = "test-support"))]
 pub struct FakeLspAdapter {
     pub name: &'static str,

crates/zed/Cargo.toml 🔗

@@ -87,14 +87,14 @@ tempdir = { version = "0.3.7" }
 thiserror = "1.0.29"
 tiny_http = "0.8"
 toml = "0.5"
-tree-sitter = "0.20.7"
+tree-sitter = "0.20.8"
 tree-sitter-c = "0.20.1"
 tree-sitter-cpp = "0.20.0"
 tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" }
 tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "137e1ce6a02698fc246cdb9c6b886ed1de9a1ed8" }
 tree-sitter-rust = "0.20.1"
 tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" }
-tree-sitter-python = "0.20.1"
+tree-sitter-python = "0.20.2"
 tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" }
 tree-sitter-typescript = "0.20.1"
 url = "2.2"

crates/zed/src/languages/c.rs 🔗

@@ -256,3 +256,41 @@ impl super::LspAdapter for CLspAdapter {
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use gpui::MutableAppContext;
+    use language::{Buffer, IndentSize};
+    use std::sync::Arc;
+
+    #[gpui::test]
+    fn test_c_autoindent(cx: &mut MutableAppContext) {
+        cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
+        let language = crate::languages::language("c", tree_sitter_c::language(), None);
+
+        cx.add_model(|cx| {
+            let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx);
+            let size = IndentSize::spaces(2);
+
+            // empty function
+            buffer.edit_with_autoindent([(0..0, "int main() {}")], size, cx);
+
+            // indent inside braces
+            let ix = buffer.len() - 1;
+            buffer.edit_with_autoindent([(ix..ix, "\n\n")], size, cx);
+            assert_eq!(buffer.text(), "int main() {\n  \n}");
+
+            // indent body of single-statement if statement
+            let ix = buffer.len() - 2;
+            buffer.edit_with_autoindent([(ix..ix, "if (a)\nb;")], size, cx);
+            assert_eq!(buffer.text(), "int main() {\n  if (a)\n    b;\n}");
+
+            // indent inside field expression
+            let ix = buffer.len() - 3;
+            buffer.edit_with_autoindent([(ix..ix, "\n.c")], size, cx);
+            assert_eq!(buffer.text(), "int main() {\n  if (a)\n    b\n      .c;\n}");
+
+            buffer
+        });
+    }
+}

crates/zed/src/languages/c/indents.scm 🔗

@@ -1,6 +1,8 @@
 [
-    (field_expression)
-    (assignment_expression)
+  (field_expression)
+  (assignment_expression)
+  (if_statement)
+  (for_statement)
 ] @indent
 
 (_ "{" "}" @end) @indent

crates/zed/src/languages/python.rs 🔗

@@ -151,3 +151,103 @@ impl LspAdapter for PythonLspAdapter {
         })
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use gpui::{ModelContext, MutableAppContext};
+    use language::{Buffer, IndentSize};
+    use std::sync::Arc;
+
+    #[gpui::test]
+    fn test_python_autoindent(cx: &mut MutableAppContext) {
+        cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
+        let language = crate::languages::language("python", tree_sitter_python::language(), None);
+
+        cx.add_model(|cx| {
+            let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx);
+            let size = IndentSize::spaces(2);
+            let append = |buffer: &mut Buffer, text: &str, cx: &mut ModelContext<Buffer>| {
+                let ix = buffer.len();
+                buffer.edit_with_autoindent([(ix..ix, text)], size, cx);
+            };
+
+            // indent after "def():"
+            append(&mut buffer, "def a():\n", cx);
+            assert_eq!(buffer.text(), "def a():\n  ");
+
+            // preserve indent after blank line
+            append(&mut buffer, "\n  ", cx);
+            assert_eq!(buffer.text(), "def a():\n  \n  ");
+
+            // indent after "if"
+            append(&mut buffer, "if a:\n  ", cx);
+            assert_eq!(buffer.text(), "def a():\n  \n  if a:\n    ");
+
+            // preserve indent after statement
+            append(&mut buffer, "b()\n", cx);
+            assert_eq!(buffer.text(), "def a():\n  \n  if a:\n    b()\n    ");
+
+            // preserve indent after statement
+            append(&mut buffer, "else", cx);
+            assert_eq!(buffer.text(), "def a():\n  \n  if a:\n    b()\n    else");
+
+            // dedent "else""
+            append(&mut buffer, ":", cx);
+            assert_eq!(buffer.text(), "def a():\n  \n  if a:\n    b()\n  else:");
+
+            // indent lines after else
+            append(&mut buffer, "\n", cx);
+            assert_eq!(
+                buffer.text(),
+                "def a():\n  \n  if a:\n    b()\n  else:\n    "
+            );
+
+            // indent after an open paren. the closing  paren is not indented
+            // because there is another token before it on the same line.
+            append(&mut buffer, "foo(\n1)", cx);
+            assert_eq!(
+                buffer.text(),
+                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n      1)"
+            );
+
+            // dedent the closing paren if it is shifted to the beginning of the line
+            let argument_ix = buffer.text().find("1").unwrap();
+            buffer.edit_with_autoindent([(argument_ix..argument_ix + 1, "")], size, cx);
+            assert_eq!(
+                buffer.text(),
+                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n    )"
+            );
+
+            // preserve indent after the close paren
+            append(&mut buffer, "\n", cx);
+            assert_eq!(
+                buffer.text(),
+                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n    )\n    "
+            );
+
+            // manually outdent the last line
+            let end_whitespace_ix = buffer.len() - 4;
+            buffer.edit_with_autoindent([(end_whitespace_ix..buffer.len(), "")], size, cx);
+            assert_eq!(
+                buffer.text(),
+                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n    )\n"
+            );
+
+            // preserve the newly reduced indentation on the next newline
+            append(&mut buffer, "\n", cx);
+            assert_eq!(
+                buffer.text(),
+                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n    )\n\n"
+            );
+
+            // reset to a simple if statement
+            buffer.edit([(0..buffer.len(), "if a:\n  b(\n  )")], cx);
+
+            // dedent "else" on the line after a closing paren
+            append(&mut buffer, "\n  else:\n", cx);
+            assert_eq!(buffer.text(), "if a:\n  b(\n  )\nelse:\n  ");
+
+            buffer
+        });
+    }
+}

crates/zed/src/languages/python/config.toml 🔗

@@ -9,3 +9,7 @@ brackets = [
   { start = "\"", end = "\"", close = true, newline = false },
   { start = "'", end = "'", close = false, newline = false },
 ]
+
+auto_indent_using_last_non_empty_line = false
+increase_indent_pattern = ":$"
+decrease_indent_pattern = "^\\s*(else|elif|except|finally)\\b.*:"

crates/zed/src/languages/rust.rs 🔗

@@ -270,7 +270,7 @@ impl LspAdapter for RustLspAdapter {
 mod tests {
     use super::*;
     use crate::languages::{language, LspAdapter};
-    use gpui::color::Color;
+    use gpui::{color::Color, MutableAppContext};
     use theme::SyntaxTheme;
 
     #[test]
@@ -432,4 +432,42 @@ mod tests {
             })
         );
     }
+
+    #[gpui::test]
+    fn test_rust_autoindent(cx: &mut MutableAppContext) {
+        cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
+        let language = crate::languages::language("rust", tree_sitter_rust::language(), None);
+
+        cx.add_model(|cx| {
+            let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx);
+            let size = IndentSize::spaces(2);
+
+            // start with empty function
+            buffer.edit_with_autoindent([(0..0, "fn a() {}")], size, cx);
+
+            // indent between braces
+            let ix = buffer.len() - 1;
+            buffer.edit_with_autoindent([(ix..ix, "\n\n")], size, cx);
+            assert_eq!(buffer.text(), "fn a() {\n  \n}");
+
+            // indent field expression
+            let ix = buffer.len() - 2;
+            buffer.edit_with_autoindent([(ix..ix, "b\n.c")], size, cx);
+            assert_eq!(buffer.text(), "fn a() {\n  b\n    .c\n}");
+
+            // indent chained field expression preceded by blank line
+            let ix = buffer.len() - 2;
+            buffer.edit_with_autoindent([(ix..ix, "\n\n.d")], size, cx);
+            assert_eq!(buffer.text(), "fn a() {\n  b\n    .c\n    \n    .d\n}");
+
+            // dedent line after the field expression
+            let ix = buffer.len() - 2;
+            buffer.edit_with_autoindent([(ix..ix, ";\ne")], size, cx);
+            assert_eq!(
+                buffer.text(),
+                "fn a() {\n  b\n    .c\n    \n    .d;\n  e\n}"
+            );
+            buffer
+        });
+    }
 }