language: Add context-aware decrease indent for Python (#33370)

Closes #33238, follow-up to
https://github.com/zed-industries/zed/pull/29625.

Changes:

- Removed `significant_indentation`, which was the way to introduce
indentation scoping in languages like Python. However, it turned out to
be unnecessarily complicated to define and maintain.
- Introduced `decrease_indent_patterns`, which takes a `pattern` keyword
to automatically outdent and `valid_after` keywords to treat as valid
code points to snap to. The outdent happens to the most recent
`valid_after` keyword that also has less or equal indentation than the
currently typed keyword.

Fixes:

1. In Python, typing `except`, `finally`, `else`, and so on now
automatically indents intelligently based on the context in which it
appears. For instance:

```py
try:
    if a == 1:
        try:
             b = 2
             ^  # <-- typing "except:" here would indent it to inner try block
```

but,

```py
try:
    if a == 1:
        try:
             b = 2
    ^  # <-- typing "except:" here would indent it to outer try block
```

2. Fixes comments not maintaining indent.

Release Notes:

- Improved auto outdent for Python while typing keywords like `except`,
`else`, `finally`, etc.
- Fixed the issue where comments in Python would not maintain their
indentation.
This commit is contained in:
Smit Barmase 2025-06-26 11:11:03 +05:30 committed by GitHub
parent 1753432406
commit d09c7eb317
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 211 additions and 181 deletions

View file

@ -21771,9 +21771,9 @@ async fn test_tab_in_leading_whitespace_auto_indents_for_python(cx: &mut TestApp
cx.set_state(indoc! {" cx.set_state(indoc! {"
def main(): def main():
ˇ try: ˇ try:
ˇ fetch() ˇ fetch()
ˇ except ValueError: ˇ except ValueError:
ˇ handle_error() ˇ handle_error()
ˇ else: ˇ else:
ˇ match value: ˇ match value:
ˇ case _: ˇ case _:
@ -21901,74 +21901,101 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) {
finally:ˇ finally:ˇ
"}); "});
// TODO: test `except` auto outdents when typed inside `try` block right after for block // test `else` does not outdents when typed inside `except` block right after for block
// cx.set_state(indoc! {" cx.set_state(indoc! {"
// def main(): def main():
// try: try:
// for i in range(n): i = 2
// pass except:
// ˇ for i in range(n):
// "}); pass
// cx.update_editor(|editor, window, cx| { ˇ
// editor.handle_input("except:", window, cx); "});
// }); cx.update_editor(|editor, window, cx| {
// cx.assert_editor_state(indoc! {" editor.handle_input("else:", window, cx);
// def main(): });
// try: cx.assert_editor_state(indoc! {"
// for i in range(n): def main():
// pass try:
// except:ˇ i = 2
// "}); except:
for i in range(n):
pass
else:ˇ
"});
// TODO: test `else` auto outdents when typed inside `except` block right after for block // test `finally` auto outdents when typed inside `else` block right after for block
// cx.set_state(indoc! {" cx.set_state(indoc! {"
// def main(): def main():
// try: try:
// i = 2 i = 2
// except: except:
// for i in range(n): j = 2
// pass else:
// ˇ for i in range(n):
// "}); pass
// cx.update_editor(|editor, window, cx| { ˇ
// editor.handle_input("else:", window, cx); "});
// }); cx.update_editor(|editor, window, cx| {
// cx.assert_editor_state(indoc! {" editor.handle_input("finally:", window, cx);
// def main(): });
// try: cx.assert_editor_state(indoc! {"
// i = 2 def main():
// except: try:
// for i in range(n): i = 2
// pass except:
// else:ˇ j = 2
// "}); else:
for i in range(n):
pass
finally:ˇ
"});
// TODO: test `finally` auto outdents when typed inside `else` block right after for block // test `except` outdents to inner "try" block
// cx.set_state(indoc! {" cx.set_state(indoc! {"
// def main(): def main():
// try: try:
// i = 2 i = 2
// except: if i == 2:
// j = 2 try:
// else: i = 3
// for i in range(n): ˇ
// pass "});
// ˇ cx.update_editor(|editor, window, cx| {
// "}); editor.handle_input("except:", window, cx);
// cx.update_editor(|editor, window, cx| { });
// editor.handle_input("finally:", window, cx); cx.assert_editor_state(indoc! {"
// }); def main():
// cx.assert_editor_state(indoc! {" try:
// def main(): i = 2
// try: if i == 2:
// i = 2 try:
// except: i = 3
// j = 2 except:ˇ
// else: "});
// for i in range(n):
// pass // test `except` outdents to outer "try" block
// finally:ˇ cx.set_state(indoc! {"
// "}); def main():
try:
i = 2
if i == 2:
try:
i = 3
ˇ
"});
cx.update_editor(|editor, window, cx| {
editor.handle_input("except:", window, cx);
});
cx.assert_editor_state(indoc! {"
def main():
try:
i = 2
if i == 2:
try:
i = 3
except:ˇ
"});
// test `else` stays at correct indent when typed after `for` block // test `else` stays at correct indent when typed after `for` block
cx.set_state(indoc! {" cx.set_state(indoc! {"

View file

@ -2913,7 +2913,12 @@ impl BufferSnapshot {
) -> Option<impl Iterator<Item = Option<IndentSuggestion>> + '_> { ) -> Option<impl Iterator<Item = Option<IndentSuggestion>> + '_> {
let config = &self.language.as_ref()?.config; let config = &self.language.as_ref()?.config;
let prev_non_blank_row = self.prev_non_blank_row(row_range.start); let prev_non_blank_row = self.prev_non_blank_row(row_range.start);
let significant_indentation = config.significant_indentation;
#[derive(Debug, Clone)]
struct StartPosition {
start: Point,
suffix: SharedString,
}
// Find the suggested indentation ranges based on the syntax tree. // Find the suggested indentation ranges based on the syntax tree.
let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0); let start = Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0);
@ -2929,13 +2934,13 @@ impl BufferSnapshot {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut indent_ranges = Vec::<Range<Point>>::new(); let mut indent_ranges = Vec::<Range<Point>>::new();
let mut start_positions = Vec::<StartPosition>::new();
let mut outdent_positions = Vec::<Point>::new(); let mut outdent_positions = Vec::<Point>::new();
while let Some(mat) = matches.peek() { while let Some(mat) = matches.peek() {
let mut start: Option<Point> = None; let mut start: Option<Point> = None;
let mut end: Option<Point> = None; let mut end: Option<Point> = None;
let mut outdent: Option<Point> = None;
let config = &indent_configs[mat.grammar_index]; let config = indent_configs[mat.grammar_index];
for capture in mat.captures { for capture in mat.captures {
if capture.index == config.indent_capture_ix { if capture.index == config.indent_capture_ix {
start.get_or_insert(Point::from_ts_point(capture.node.start_position())); start.get_or_insert(Point::from_ts_point(capture.node.start_position()));
@ -2945,21 +2950,18 @@ impl BufferSnapshot {
} else if Some(capture.index) == config.end_capture_ix { } else if Some(capture.index) == config.end_capture_ix {
end = Some(Point::from_ts_point(capture.node.start_position())); end = Some(Point::from_ts_point(capture.node.start_position()));
} else if Some(capture.index) == config.outdent_capture_ix { } else if Some(capture.index) == config.outdent_capture_ix {
let point = Point::from_ts_point(capture.node.start_position()); outdent_positions.push(Point::from_ts_point(capture.node.start_position()));
outdent.get_or_insert(point); } else if let Some(suffix) = config.suffixed_start_captures.get(&capture.index) {
outdent_positions.push(point); start_positions.push(StartPosition {
start: Point::from_ts_point(capture.node.start_position()),
suffix: suffix.clone(),
});
} }
} }
matches.advance(); 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 let Some((start, end)) = start.zip(end) {
if start.row == end.row && (!significant_indentation || start.column < end.column) { if start.row == end.row {
continue; continue;
} }
let range = start..end; let range = start..end;
@ -2997,24 +2999,26 @@ impl BufferSnapshot {
matches.advance(); matches.advance();
} }
// we don't use outdent positions to truncate in case of significant indentation outdent_positions.sort();
// rather we use them to expand (handled above) for outdent_position in outdent_positions {
if !significant_indentation { // find the innermost indent range containing this outdent_position
outdent_positions.sort(); // set its end to the outdent position
for outdent_position in outdent_positions { if let Some(range_to_truncate) = indent_ranges
// find the innermost indent range containing this outdent_position .iter_mut()
// set its end to the outdent position .filter(|indent_range| indent_range.contains(&outdent_position))
if let Some(range_to_truncate) = indent_ranges .next_back()
.iter_mut() {
.filter(|indent_range| indent_range.contains(&outdent_position)) range_to_truncate.end = outdent_position;
.next_back()
{
range_to_truncate.end = outdent_position;
}
} }
} }
start_positions.sort_by_key(|b| b.start);
// Find the suggested indentation increases and decreased based on regexes. // Find the suggested indentation increases and decreased based on regexes.
let mut regex_outdent_map = HashMap::default();
let mut last_seen_suffix: HashMap<String, Vec<Point>> = HashMap::default();
let mut start_positions_iter = start_positions.iter().peekable();
let mut indent_change_rows = Vec::<(u32, Ordering)>::new(); let mut indent_change_rows = Vec::<(u32, Ordering)>::new();
self.for_each_line( self.for_each_line(
Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0) Point::new(prev_non_blank_row.unwrap_or(row_range.start), 0)
@ -3034,6 +3038,33 @@ impl BufferSnapshot {
{ {
indent_change_rows.push((row + 1, Ordering::Greater)); indent_change_rows.push((row + 1, Ordering::Greater));
} }
while let Some(pos) = start_positions_iter.peek() {
if pos.start.row < row {
let pos = start_positions_iter.next().unwrap();
last_seen_suffix
.entry(pos.suffix.to_string())
.or_default()
.push(pos.start);
} else {
break;
}
}
for rule in &config.decrease_indent_patterns {
if rule.pattern.as_ref().map_or(false, |r| r.is_match(line)) {
let row_start_column = self.indent_size_for_line(row).len;
let basis_row = rule
.valid_after
.iter()
.filter_map(|valid_suffix| last_seen_suffix.get(valid_suffix))
.flatten()
.filter(|start_point| start_point.column <= row_start_column)
.max_by_key(|start_point| start_point.row);
if let Some(outdent_to_row) = basis_row {
regex_outdent_map.insert(row, outdent_to_row.row);
}
break;
}
}
}, },
); );
@ -3043,6 +3074,7 @@ impl BufferSnapshot {
} else { } else {
row_range.start.saturating_sub(1) row_range.start.saturating_sub(1)
}; };
let mut prev_row_start = Point::new(prev_row, self.indent_size_for_line(prev_row).len); let mut prev_row_start = Point::new(prev_row, self.indent_size_for_line(prev_row).len);
Some(row_range.map(move |row| { Some(row_range.map(move |row| {
let row_start = Point::new(row, self.indent_size_for_line(row).len); let row_start = Point::new(row, self.indent_size_for_line(row).len);
@ -3080,17 +3112,17 @@ impl BufferSnapshot {
if range.start.row == prev_row && range.end > row_start { if range.start.row == prev_row && range.end > row_start {
indent_from_prev_row = true; indent_from_prev_row = true;
} }
if significant_indentation && self.is_line_blank(row) && range.start.row == prev_row if range.end > prev_row_start && range.end <= row_start {
{ outdent_to_row = outdent_to_row.min(range.start.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);
}
} }
} }
if let Some(basis_row) = regex_outdent_map.get(&row) {
indent_from_prev_row = false;
outdent_to_row = *basis_row;
from_regex = true;
}
let within_error = error_ranges let within_error = error_ranges
.iter() .iter()
.any(|e| e.start.row < row && e.end > row_start); .any(|e| e.start.row < row && e.end > row_start);

View file

@ -696,10 +696,6 @@ pub struct LanguageConfig {
#[serde(default)] #[serde(default)]
#[schemars(schema_with = "bracket_pair_config_json_schema")] #[schemars(schema_with = "bracket_pair_config_json_schema")]
pub brackets: BracketPairConfig, 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 /// If set to true, auto indentation uses last non empty line to determine
/// the indentation level for a new line. /// the indentation level for a new line.
#[serde(default = "auto_indent_using_last_non_empty_line_default")] #[serde(default = "auto_indent_using_last_non_empty_line_default")]
@ -717,6 +713,12 @@ pub struct LanguageConfig {
#[serde(default, deserialize_with = "deserialize_regex")] #[serde(default, deserialize_with = "deserialize_regex")]
#[schemars(schema_with = "regex_json_schema")] #[schemars(schema_with = "regex_json_schema")]
pub decrease_indent_pattern: Option<Regex>, pub decrease_indent_pattern: Option<Regex>,
/// A list of rules for decreasing indentation. Each rule pairs a regex with a set of valid
/// "block-starting" tokens. When a line matches a pattern, its indentation is aligned with
/// the most recent line that began with a corresponding token. This enables context-aware
/// outdenting, like aligning an `else` with its `if`.
#[serde(default)]
pub decrease_indent_patterns: Vec<DecreaseIndentConfig>,
/// A list of characters that trigger the automatic insertion of a closing /// A list of characters that trigger the automatic insertion of a closing
/// bracket when they immediately precede the point where an opening /// bracket when they immediately precede the point where an opening
/// bracket is inserted. /// bracket is inserted.
@ -776,6 +778,15 @@ pub struct LanguageConfig {
pub documentation: Option<DocumentationConfig>, pub documentation: Option<DocumentationConfig>,
} }
#[derive(Clone, Debug, Deserialize, Default, JsonSchema)]
pub struct DecreaseIndentConfig {
#[serde(default, deserialize_with = "deserialize_regex")]
#[schemars(schema_with = "regex_json_schema")]
pub pattern: Option<Regex>,
#[serde(default)]
pub valid_after: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
pub struct LanguageMatcher { pub struct LanguageMatcher {
/// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
@ -899,6 +910,7 @@ impl Default for LanguageConfig {
auto_indent_on_paste: None, auto_indent_on_paste: None,
increase_indent_pattern: Default::default(), increase_indent_pattern: Default::default(),
decrease_indent_pattern: Default::default(), decrease_indent_pattern: Default::default(),
decrease_indent_patterns: Default::default(),
autoclose_before: Default::default(), autoclose_before: Default::default(),
line_comments: Default::default(), line_comments: Default::default(),
block_comment: Default::default(), block_comment: Default::default(),
@ -914,7 +926,6 @@ impl Default for LanguageConfig {
jsx_tag_auto_close: None, jsx_tag_auto_close: None,
completion_query_characters: Default::default(), completion_query_characters: Default::default(),
debuggers: Default::default(), debuggers: Default::default(),
significant_indentation: Default::default(),
documentation: None, documentation: None,
} }
} }
@ -1092,6 +1103,7 @@ struct IndentConfig {
start_capture_ix: Option<u32>, start_capture_ix: Option<u32>,
end_capture_ix: Option<u32>, end_capture_ix: Option<u32>,
outdent_capture_ix: Option<u32>, outdent_capture_ix: Option<u32>,
suffixed_start_captures: HashMap<u32, SharedString>,
} }
pub struct OutlineConfig { pub struct OutlineConfig {
@ -1522,6 +1534,14 @@ impl Language {
("outdent", &mut outdent_capture_ix), ("outdent", &mut outdent_capture_ix),
], ],
); );
let mut suffixed_start_captures = HashMap::default();
for (ix, name) in query.capture_names().iter().enumerate() {
if let Some(suffix) = name.strip_prefix("start.") {
suffixed_start_captures.insert(ix as u32, suffix.to_owned().into());
}
}
if let Some(indent_capture_ix) = indent_capture_ix { if let Some(indent_capture_ix) = indent_capture_ix {
grammar.indents_config = Some(IndentConfig { grammar.indents_config = Some(IndentConfig {
query, query,
@ -1529,6 +1549,7 @@ impl Language {
start_capture_ix, start_capture_ix,
end_capture_ix, end_capture_ix,
outdent_capture_ix, outdent_capture_ix,
suffixed_start_captures,
}); });
} }
Ok(self) Ok(self)

View file

@ -1395,7 +1395,7 @@ mod tests {
// dedent "else" on the line after a closing paren // dedent "else" on the line after a closing paren
append(&mut buffer, "\n else:\n", cx); 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 buffer
}); });

View file

@ -28,6 +28,11 @@ brackets = [
auto_indent_using_last_non_empty_line = false auto_indent_using_last_non_empty_line = false
debuggers = ["Debugpy"] debuggers = ["Debugpy"]
significant_indentation = true increase_indent_pattern = "^[^#].*:\\s*$"
increase_indent_pattern = "^\\s*(try)\\b.*:" decrease_indent_patterns = [
decrease_indent_pattern = "^\\s*(else|elif|except|finally)\\b.*:" { pattern = "^\\s*elif\\b.*:", valid_after = ["if", "elif"] },
{ pattern = "^\\s*else\\b.*:", valid_after = ["if", "elif", "for", "while", "except"] },
{ pattern = "^\\s*except\\b.*:", valid_after = ["try", "except"] },
{ pattern = "^\\s*finally\\b.*:", valid_after = ["try", "except", "else"] },
{ pattern = "^\\s*case\\b.*:", valid_after = ["match", "case"] }
]

View file

@ -1,72 +1,17 @@
(_ "(" ")" @end) @indent
(_ "[" "]" @end) @indent (_ "[" "]" @end) @indent
(_ "{" "}" @end) @indent (_ "{" "}" @end) @indent
(_ "(" ")" @end) @indent
(function_definition (function_definition) @start.def
":" @start (class_definition) @start.class
body: (block) @indent (if_statement) @start.if
) (for_statement) @start.for
(while_statement) @start.while
(if_statement (with_statement) @start.with
":" @start (match_statement) @start.match
consequence: (block) @indent (try_statement) @start.try
alternative: (_)? @outdent (elif_clause) @start.elif
) (else_clause) @start.else
(except_clause) @start.except
(else_clause (finally_clause) @start.finally
":" @start (case_pattern) @start.case
body: (block) @indent
)
(elif_clause
":" @start
consequence: (block) @indent
)
(for_statement
":" @start
body: (block) @indent
)
(with_statement
":" @start
body: (block) @indent
)
(while_statement
":" @start
body: (block) @indent
)
(match_statement
":" @start
body: (block) @indent
)
(class_definition
":" @start
body: (block) @indent
)
(case_clause
":" @start
consequence: (block) @indent
)
(try_statement
":" @start
body: (block) @indent
(except_clause)? @outdent
(else_clause)? @outdent
(finally_clause)? @outdent
)
(except_clause
":" @start
(block) @indent
)
(finally_clause
":" @start
(block) @indent
)