Improve auto indentation in situations with multiline comments present (#61669)

## Objective

Fix incorrect indentation after multiline comments and when pasting
multiline code immediately after a comment.

Fixes #61668 
Fixes #55597 

## Solution

- Calculate logical indentation from the opening line of block comments,
documentation comments, and multiline strings.
- Reuse the logical indentation for both newline handling and multiline
paste auto-indent.
- Preserve the relative indentation of pasted multiline content while
using the logical indentation of the surrounding code as its basis.

## Testing

- `cargo test -p editor --lib test_newline -- --nocapture`
- `cargo test -p editor --lib test_paste -- --nocapture`
- `cargo test -p language --lib test_autoindent -- --nocapture`
- `./script/clippy -p language`
- `./script/clippy -p editor`
- Regression coverage includes Go, TypeScript, and Python comment/string
indentation, plus Go multiline paste after a documentation comment.
- No platform-specific behavior is involved; the fix is implemented in
shared editor and language auto-indent logic.

The demonstration video is as follows:


https://github.com/user-attachments/assets/2a2ee494-d4ea-4b16-8644-f868b9f31272


https://github.com/user-attachments/assets/9487f893-e584-448d-a1c7-a94c76c03f5b


## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed incorrect indentation after multiline comments and multiline
paste.
This commit is contained in:
Yifeng Xu authored and GitHub committed 2026-08-26 14:33:25 +00:00
1 parent e9b18cd2d5
commit 918e494e61
3 files changed
+268 -5

No files matched your search

+164 -2
View File
@@ -5712,9 +5712,13 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
/**
*
*/
ˇ
ˇ
"});
cx.set_state("fn test() {\n /**\n *\n */ˇ\n}");
cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
cx.assert_editor_state("fn test() {\n /**\n *\n */\n ˇ\n}");
// Ensure that inline comment followed by code
// doesn't add comment prefix on newline
cx.set_state(indoc! {"
@@ -5738,7 +5742,7 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
/**
*
*/
ˇtext
ˇtext
"});
// Ensure if not comment block it doesn't
@@ -5767,6 +5771,71 @@ async fn test_newline_documentation_comments(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_newline_closing_comment_indent_across_languages(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
let go_language = languages::language("go", tree_sitter_go::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(go_language), cx));
cx.set_state("func test() {\n\t/**\n\t * doc\n\t */ˇ\n}");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.assert_editor_state("func test() {\n\t/**\n\t * doc\n\t */\n\tˇ\n}");
let typescript_language = languages::language(
"typescript",
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
);
cx.update_buffer(|buffer, cx| buffer.set_language(Some(typescript_language), cx));
cx.set_state("function test() {\n /**\n * doc\n */ˇ\n}");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.assert_editor_state("function test() {\n /**\n * doc\n */\n ˇ\n}");
let python_language = languages::language("python", tree_sitter_python::LANGUAGE.into());
cx.update_buffer(|buffer, cx| buffer.set_language(Some(python_language), cx));
cx.set_state("def test():\n \"\"\"doc\n \"\"\"ˇ\n");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.assert_editor_state("def test():\n \"\"\"doc\n \"\"\"\n ˇ\n");
}
#[gpui::test]
async fn test_newline_after_closing_delimiter_lookalike_in_string(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language(
Some(languages::language("go", tree_sitter_go::LANGUAGE.into())),
cx,
)
});
// A line inside a string literal that happens to look like a block comment's
// closing delimiter does not close anything, so it keeps its own indentation.
cx.set_state("func test() {\n\tvar s = `\n*/ˇ\n`\n}");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.assert_editor_state("func test() {\n\tvar s = `\n*/\nˇ\n`\n}");
}
#[gpui::test]
async fn test_newline_twice_inside_block(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language(
Some(languages::language("go", tree_sitter_go::LANGUAGE.into())),
cx,
)
});
cx.set_state("func test() {ˇ}");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.assert_editor_state("func test() {\n ˇ\n}");
// The blank line the cursor sits on holds nothing but auto-indent
// whitespace, but the line that replaces it still belongs inside the block.
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.assert_editor_state("func test() {\n\n ˇ\n}");
}
#[gpui::test]
async fn test_newline_comments_with_block_comment(cx: &mut TestAppContext) {
init_test(cx, |settings| {
@@ -6092,6 +6161,24 @@ async fn test_tab_in_leading_whitespace_auto_indents_lines(cx: &mut TestAppConte
"});
}
#[gpui::test]
async fn test_tab_after_closing_comment_delimiter(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language(
Some(languages::language("go", tree_sitter_go::LANGUAGE.into())),
cx,
)
});
// The suggested indent is taken from the line the comment opened on, so that
// it agrees with what pressing enter after the closing delimiter produces.
cx.set_state("func test() {\n\t/**\n\t * doc\n\t */\nˇ\n}");
cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
cx.assert_editor_state("func test() {\n\t/**\n\t * doc\n\t */\n\tˇ\n}");
}
#[gpui::test]
async fn test_tab_with_mixed_whitespace_txt(cx: &mut TestAppContext) {
init_test(cx, |settings| {
@@ -10821,6 +10908,81 @@ async fn test_paste_content_from_other_app(cx: &mut TestAppContext) {
"});
}
#[gpui::test]
async fn test_paste_after_closing_documentation_comment(cx: &mut TestAppContext) {
init_test(cx, |_| {});
cx.write_to_clipboard(ClipboardItem::new_string(
"func find() {\n\treturn 1\n}\n".into(),
));
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language(
Some(languages::language("go", tree_sitter_go::LANGUAGE.into())),
cx,
)
});
cx.set_state("package test\n\n/**\n * test\n */ˇ");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.assert_editor_state("package test\n\n/**\n * test\n */\nfunc find() {\n\treturn 1\n}\nˇ");
cx.write_to_clipboard(ClipboardItem::new_string(
" func find() {\n \treturn 1\n }".into(),
));
cx.set_state("package test\n\n/**\n * test\n */ˇ");
cx.update_editor(|editor, window, cx| editor.newline(&Newline, window, cx));
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.assert_editor_state("package test\n\n/**\n * test\n */\nfunc find() {\n\treturn 1\n}ˇ");
}
#[gpui::test]
async fn test_paste_shifts_block_by_first_line_delta(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language(
Some(languages::language("go", tree_sitter_go::LANGUAGE.into())),
cx,
)
});
cx.write_to_clipboard(ClipboardItem::new_string(
" func find() {\n \treturn 1\n }".into(),
));
cx.set_state("package test\n\nˇ");
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.assert_editor_state("package test\n\nfunc find() {\n\treturn 1\n}ˇ");
// The block moves by however far its first line moved, so a first line that
// is indented deeper than the lines below it stays deeper.
cx.write_to_clipboard(ClipboardItem::new_string(" foo()\n bar()".into()));
cx.set_state("func test() {\nˇ\n}");
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.assert_editor_state("func test() {\n foo()\nbar()ˇ\n}");
}
#[gpui::test]
async fn test_paste_after_closing_comment_delimiter(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| {
buffer.set_language(
Some(languages::language("go", tree_sitter_go::LANGUAGE.into())),
cx,
)
});
// The closing delimiter's line is not part of the paste, so it keeps its own
// indentation even though that indentation differs from the opening line's.
cx.write_to_clipboard(ClipboardItem::new_string("x := 1\ny := 2".into()));
cx.set_state("package test\n\n/*\n * test\n */ˇ");
cx.update_editor(|editor, window, cx| editor.paste(&Paste, window, cx));
cx.assert_editor_state("package test\n\n/*\n * test\n */x := 1\n y := 2ˇ");
}
#[gpui::test]
async fn test_paste_multiline_from_other_app_into_matching_cursors(cx: &mut TestAppContext) {
init_test(cx, |_| {});
+24
View File
@@ -558,6 +558,8 @@ impl Editor {
let end = selection.end;
let selection_is_empty = start == end;
let language_scope = buffer.language_scope_at(start);
existing_indent =
logical_indent_for_newline(&start_point, &buffer, existing_indent);
let (delimiter, newline_config) = if let Some(language) = &language_scope {
let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
&buffer,
@@ -2619,6 +2621,28 @@ fn documentation_delimiter_for_newline(
}
}
/// The indentation a line inserted at `start_point` should start at, which is
/// `existing_indent` unless the cursor sits after the closing delimiter of a
/// multi-line block comment. See [`language::BufferSnapshot::block_comment_closing_indent`].
fn logical_indent_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
existing_indent: IndentSize,
) -> IndentSize {
let Some((snapshot, line_range)) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))
else {
return existing_indent;
};
// Columns agree between the multi-buffer and the underlying buffer for a line
// an excerpt shows in full, which is the case we care about. For a partial
// first line the column comes out too small and the lookup below declines,
// leaving the indent alone.
let position = Point::new(line_range.start.row, start_point.column);
snapshot
.block_comment_closing_indent(position)
.unwrap_or(existing_indent)
}
fn list_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
+80 -3
View File
@@ -2099,7 +2099,7 @@ impl Buffer {
.unwrap_or_else(|| {
request
.before_edit
.indent_size_for_line(suggestion.basis_row)
.logical_indent_size_for_line(suggestion.basis_row)
})
.with_delta(suggestion.delta, language_indent_size);
old_suggestions
@@ -2140,7 +2140,7 @@ impl Buffer {
.copied()
.map(|e| e.0)
.unwrap_or_else(|| {
snapshot.indent_size_for_line(suggestion.basis_row)
snapshot.logical_indent_size_for_line(suggestion.basis_row)
})
.with_delta(suggestion.delta, language_indent_size);
@@ -3602,6 +3602,83 @@ impl BufferSnapshot {
indent_size_for_line(self, row)
}
/// The indentation that the block comment closed on `position`'s row belongs
/// at, or `None` if that row is not the closing line of a multi-line block
/// comment.
///
/// A closing delimiter is conventionally indented one column past its opening
/// delimiter, so that it lines up with the comment's prefixes:
///
/// ```text
/// /**
/// * doc
/// */
/// ```
///
/// That extra column belongs to the comment rather than to the surrounding
/// code, which makes the closing row's own indentation a poor basis for
/// indenting whatever follows it. The opening row's indentation is used
/// instead.
///
/// Requires the row to hold nothing but whitespace and the closing delimiter,
/// and `position` to be at or past the end of that delimiter, so that
/// splitting the delimiter itself is left alone. Also requires the syntax node
/// containing the delimiter to end there, so that a line which merely looks
/// like a closing delimiter is not mistaken for one.
pub fn block_comment_closing_indent(&self, position: Point) -> Option<IndentSize> {
let row = position.row;
let indent_len = self.indent_size_for_line(row).len;
let delimiter_start = Point::new(row, indent_len);
let language = self.language_scope_at(delimiter_start)?;
// A few languages describe a string literal in `block_comment` rather
// than a comment (Python's `"""`), so either scope is accepted. Relying
// on the override scope keeps this out of the business of guessing at
// grammar node names.
if !matches!(language.override_name(), Some("comment" | "string")) {
return None;
}
let delimiter_len = [language.documentation_comment(), language.block_comment()]
.into_iter()
.flatten()
.find_map(|config| {
let delimiter = config.end.trim_start();
if delimiter.is_empty() {
return None;
}
let mut chars = self.chars_at(delimiter_start);
if !delimiter
.chars()
.all(|expected| chars.next() == Some(expected))
{
return None;
}
if !chars.take_while(|c| *c != '\n').all(char::is_whitespace) {
return None;
}
Some(delimiter.len() as u32)
})?;
if position.column < indent_len + delimiter_len {
return None;
}
let delimiter_end = Point::new(row, indent_len + delimiter_len);
let node = self.syntax_ancestor(delimiter_start..delimiter_end)?;
if Point::from_ts_point(node.end_position()) != delimiter_end {
return None;
}
let opening_row = Point::from_ts_point(node.start_position()).row;
(opening_row < row).then(|| self.indent_size_for_line(opening_row))
}
/// Like [`Self::indent_size_for_line`], but reports the indentation a row
/// logically sits at, which differs from its physical indentation on the
/// closing line of a block comment. See [`Self::block_comment_closing_indent`].
fn logical_indent_size_for_line(&self, row: u32) -> IndentSize {
self.block_comment_closing_indent(Point::new(row, self.line_len(row)))
.unwrap_or_else(|| self.indent_size_for_line(row))
}
/// Returns [`IndentSize`] for a given position that respects user settings
/// and language preferences.
pub fn language_indent_size_at<T: ToOffset>(&self, position: T, cx: &App) -> IndentSize {
@@ -3633,7 +3710,7 @@ impl BufferSnapshot {
result
.get(&suggestion.basis_row)
.copied()
.unwrap_or_else(|| self.indent_size_for_line(suggestion.basis_row))
.unwrap_or_else(|| self.logical_indent_size_for_line(suggestion.basis_row))
.with_delta(suggestion.delta, single_indent_size)
} else {
self.indent_size_for_line(row)