language: Fix indent suggestions for significant indented languages like Python (#29625)

Smit Barmase created

Closes #26157

This fixes multiple cases where Python indentation breaks:
- [x] Adding a new line after `if`, `try`, etc. correctly indents in
that scope
- [x] Multi-cursor tabs correctly preserve relative indents
- [x] Adding a new line after `else`, `finally`, etc. correctly outdents
them
- [x] Existing Tests

Future Todo: I need to add new tests for all the above cases.

Before/After:

1. Multi-cursor tabs correctly preserve relative indents


https://github.com/user-attachments/assets/08a46ddf-5371-4e26-ae7d-f8aa0b31c4a2

2. Adding a new line after `if`, `try`, etc. correctly indents in that
scope


https://github.com/user-attachments/assets/9affae97-1a50-43c9-9e9f-c1ea3a747813

Release Notes:

- Fixes indentation-related issues involving tab, newline, etc for
Python.

Change summary

crates/language/src/buffer.rs           | 49 +++++++++++++++++-------
crates/language/src/language.rs         |  5 ++
crates/languages/src/python.rs          |  4 +-
crates/languages/src/python/config.toml |  5 +-
crates/languages/src/python/indents.scm | 53 +++++++++++++++++++-------
5 files changed, 83 insertions(+), 33 deletions(-)

Detailed changes

crates/language/src/buffer.rs 🔗

@@ -2857,6 +2857,7 @@ impl BufferSnapshot {
     ) -> Option<impl Iterator<Item = Option<IndentSuggestion>> + '_> {
         let config = &self.language.as_ref()?.config;
         let prev_non_blank_row = self.prev_non_blank_row(row_range.start);
+        let significant_indentation = config.significant_indentation;
 
         // Find the suggested indentation ranges based on the syntax tree.
         let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0);
@@ -2876,6 +2877,7 @@ impl BufferSnapshot {
         while let Some(mat) = matches.peek() {
             let mut start: Option<Point> = None;
             let mut end: Option<Point> = None;
+            let mut outdent: Option<Point> = None;
 
             let config = &indent_configs[mat.grammar_index];
             for capture in mat.captures {
@@ -2887,16 +2889,23 @@ impl BufferSnapshot {
                 } else if Some(capture.index) == config.end_capture_ix {
                     end = Some(Point::from_ts_point(capture.node.start_position()));
                 } else if Some(capture.index) == config.outdent_capture_ix {
-                    outdent_positions.push(Point::from_ts_point(capture.node.start_position()));
+                    let point = Point::from_ts_point(capture.node.start_position());
+                    outdent.get_or_insert(point);
+                    outdent_positions.push(point);
                 }
             }
 
             matches.advance();
+            // in case of significant indentation expand end to outdent position
+            let end = if significant_indentation {
+                outdent.or(end)
+            } else {
+                end
+            };
             if let Some((start, end)) = start.zip(end) {
-                if start.row == end.row {
+                if start.row == end.row && !significant_indentation {
                     continue;
                 }
-
                 let range = start..end;
                 match indent_ranges.binary_search_by_key(&range.start, |r| r.start) {
                     Err(ix) => indent_ranges.insert(ix, range),
@@ -2932,16 +2941,20 @@ impl BufferSnapshot {
             matches.advance();
         }
 
-        outdent_positions.sort();
-        for outdent_position in outdent_positions {
-            // find the innermost indent range containing this outdent_position
-            // set its end to the outdent position
-            if let Some(range_to_truncate) = indent_ranges
-                .iter_mut()
-                .filter(|indent_range| indent_range.contains(&outdent_position))
-                .next_back()
-            {
-                range_to_truncate.end = outdent_position;
+        // we don't use outdent positions to truncate in case of significant indentation
+        // rather we use them to expand (handled above)
+        if !significant_indentation {
+            outdent_positions.sort();
+            for outdent_position in outdent_positions {
+                // find the innermost indent range containing this outdent_position
+                // set its end to the outdent position
+                if let Some(range_to_truncate) = indent_ranges
+                    .iter_mut()
+                    .filter(|indent_range| indent_range.contains(&outdent_position))
+                    .next_back()
+                {
+                    range_to_truncate.end = outdent_position;
+                }
             }
         }
 
@@ -3011,8 +3024,14 @@ impl BufferSnapshot {
                 if range.start.row == prev_row && range.end > row_start {
                     indent_from_prev_row = true;
                 }
-                if range.end > prev_row_start && range.end <= row_start {
-                    outdent_to_row = outdent_to_row.min(range.start.row);
+                if significant_indentation && self.is_line_blank(row) && range.start.row == prev_row
+                {
+                    indent_from_prev_row = true;
+                }
+                if !significant_indentation || !self.is_line_blank(row) {
+                    if range.end > prev_row_start && range.end <= row_start {
+                        outdent_to_row = outdent_to_row.min(range.start.row);
+                    }
                 }
             }
 

crates/language/src/language.rs 🔗

@@ -681,6 +681,10 @@ pub struct LanguageConfig {
     #[serde(default)]
     #[schemars(schema_with = "bracket_pair_config_json_schema")]
     pub brackets: BracketPairConfig,
+    /// If set to true, indicates the language uses significant whitespace/indentation
+    /// for syntax structure (like Python) rather than brackets/braces for code blocks.
+    #[serde(default)]
+    pub significant_indentation: bool,
     /// If set to true, auto indentation uses last non empty line to determine
     /// the indentation level for a new line.
     #[serde(default = "auto_indent_using_last_non_empty_line_default")]
@@ -884,6 +888,7 @@ impl Default for LanguageConfig {
             jsx_tag_auto_close: None,
             completion_query_characters: Default::default(),
             debuggers: Default::default(),
+            significant_indentation: Default::default(),
         }
     }
 }

crates/languages/src/python.rs 🔗

@@ -1209,7 +1209,7 @@ mod tests {
             append(&mut buffer, "foo(\n1)", cx);
             assert_eq!(
                 buffer.text(),
-                "def a():\n  \n  if a:\n    b()\n  else:\n    foo(\n      1)"
+                "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
@@ -1255,7 +1255,7 @@ mod tests {
 
             // 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  ");
+            assert_eq!(buffer.text(), "if a:\n  b(\n  )\nelse:\n");
 
             buffer
         });

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

@@ -27,6 +27,7 @@ brackets = [
 ]
 
 auto_indent_using_last_non_empty_line = false
-increase_indent_pattern = "^[^#].*:\\s*$"
-decrease_indent_pattern = "^\\s*(else|elif|except|finally)\\b.*:"
 debuggers = ["Debugpy"]
+significant_indentation = true
+increase_indent_pattern = "^\\s*(try)\\b.*:"
+decrease_indent_pattern = "^\\s*(else|elif|except|finally)\\b.*:"

crates/languages/src/python/indents.scm 🔗

@@ -1,18 +1,43 @@
-(_ "[" "]" @end) @indent
-(_ "{" "}" @end) @indent
-(_ "(" ")" @end) @indent
+(function_definition
+  ":" @start
+  body: (block) @indent
+)
+
+(if_statement
+  ":" @start
+  consequence: (block) @indent
+  alternative: (_)? @outdent
+)
+
+(else_clause
+  ":" @start
+  body: (block) @indent
+)
+
+(elif_clause
+  ":" @start
+  consequence: (block) @indent
+)
+
+(for_statement
+  ":" @start
+  body: (block) @indent
+)
 
 (try_statement
-    body: (_) @start
-    [(except_clause) (finally_clause)] @end
-    ) @indent
+  ":" @start
+  body: (block) @indent
+  (except_clause)? @outdent
+  (else_clause)? @outdent
+  (finally_clause)? @outdent
+)
 
-(if_statement
-    consequence: (_) @start
-    alternative: (_) @end
-    ) @indent
+(except_clause
+  ":" @start
+  (block) @indent
+)
 
-(_
-    alternative: (elif_clause) @start
-    alternative: (_) @end
-    ) @indent
+(finally_clause
+  ":" @start
+  (block) @indent
+)