From 918e494e6157e29033e17f42a3c71e6990437e5a Mon Sep 17 00:00:00 2001 From: Yifeng Xu Date: Wed, 26 Aug 2026 14:33:25 +0000 Subject: [PATCH] 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. --- crates/editor/src/editor_tests.rs | 166 +++++++++++++++++++++++++++++- crates/editor/src/input.rs | 24 +++++ crates/language/src/buffer.rs | 83 ++++++++++++++- 3 files changed, 268 insertions(+), 5 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ae9a42b9b2..5de63628ce 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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, |_| {}); diff --git a/crates/editor/src/input.rs b/crates/editor/src/input.rs index 14d3cae720..1b6fb4ed55 100644 --- a/crates/editor/src/input.rs +++ b/crates/editor/src/input.rs @@ -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, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index f5515f3378..6c9b914200 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -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 { + 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(&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)