Strip comment and list prefixes in vim: join lines (#49295)

Closes #ISSUE

Before you mark this PR as ready for review, make sure that you have:
- [ ] Added a solid test coverage and/or screenshots from doing manual
testing
- [ ] Done a self-review taking into account security and performance
aspects
- [ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Improved the behavior of the `vim: join lines` and `editor: join lines`. Comment and other language specific line prefixes are now stripped from the join line
This commit is contained in:
Ben Kunkle authored and GitHub committed 2026-02-17 11:24:45 -05:00
1 parent b0ffa2f642
commit 4b160fc862
2 files changed
+216 -7

No files matched your search

+59 -7
View File
@@ -11334,14 +11334,66 @@ impl Editor {
let end_of_line = Point::new(row.0, snapshot.line_len(row));
let next_line_row = row.next_row();
let indent = snapshot.indent_size_for_line(next_line_row);
let start_of_next_line = Point::new(next_line_row.0, indent.len);
let mut join_start_column = indent.len;
let replace =
if snapshot.line_len(next_line_row) > indent.len && insert_whitespace {
" "
} else {
""
};
if let Some(language_scope) =
snapshot.language_scope_at(Point::new(next_line_row.0, indent.len))
{
let line_end =
Point::new(next_line_row.0, snapshot.line_len(next_line_row));
let line_text_after_indent = snapshot
.text_for_range(Point::new(next_line_row.0, indent.len)..line_end)
.collect::<String>();
if !line_text_after_indent.is_empty() {
let block_prefix = language_scope
.block_comment()
.map(|c| c.prefix.as_ref())
.filter(|p| !p.is_empty());
let doc_prefix = language_scope
.documentation_comment()
.map(|c| c.prefix.as_ref())
.filter(|p| !p.is_empty());
let all_prefixes = language_scope
.line_comment_prefixes()
.iter()
.map(|p| p.as_ref())
.chain(block_prefix)
.chain(doc_prefix)
.chain(language_scope.unordered_list().iter().map(|p| p.as_ref()));
let mut longest_prefix_len = None;
for prefix in all_prefixes {
let trimmed = prefix.trim_end();
if line_text_after_indent.starts_with(trimmed) {
let candidate_len =
if line_text_after_indent.starts_with(prefix) {
prefix.len()
} else {
trimmed.len()
};
if longest_prefix_len.map_or(true, |len| candidate_len > len) {
longest_prefix_len = Some(candidate_len);
}
}
}
if let Some(prefix_len) = longest_prefix_len {
join_start_column =
join_start_column.saturating_add(prefix_len as u32);
}
}
}
let start_of_next_line = Point::new(next_line_row.0, join_start_column);
let replace = if snapshot.line_len(next_line_row) > join_start_column
&& insert_whitespace
{
" "
} else {
""
};
this.buffer.update(cx, |buffer, cx| {
buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx)
+157
View File
@@ -5014,6 +5014,163 @@ async fn test_join_lines_with_git_diff_base(executor: BackgroundExecutor, cx: &m
);
}
#[gpui::test]
async fn test_join_lines_strips_comment_prefix(cx: &mut TestAppContext) {
init_test(cx, |_| {});
{
let language = Arc::new(Language::new(
LanguageConfig {
line_comments: vec!["// ".into(), "/// ".into()],
documentation_comment: Some(BlockCommentConfig {
start: "/*".into(),
end: "*/".into(),
prefix: "* ".into(),
tab_size: 1,
}),
..LanguageConfig::default()
},
None,
));
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
// Strips the comment prefix (with trailing space) from the joined-in line.
cx.set_state(indoc! {"
// ˇfoo
// bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
// fooˇ bar
"});
// Strips the longer doc-comment prefix when both `//` and `///` match.
cx.set_state(indoc! {"
/// ˇfoo
/// bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
/// fooˇ bar
"});
// Does not strip when the second line is a regular line (no comment prefix).
cx.set_state(indoc! {"
// ˇfoo
bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
// fooˇ bar
"});
// No-whitespace join also strips the comment prefix.
cx.set_state(indoc! {"
// ˇfoo
// bar
"});
cx.update_editor(|e, window, cx| e.join_lines_impl(false, window, cx));
cx.assert_editor_state(indoc! {"
// fooˇbar
"});
// Strips even when the joined-in line is just the bare prefix (no trailing space).
cx.set_state(indoc! {"
// ˇfoo
//
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
// fooˇ
"});
// Mixed line comment prefix types: the longer matching prefix is stripped.
cx.set_state(indoc! {"
// ˇfoo
/// bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
// fooˇ bar
"});
// Strips block comment body prefix (`* `) from the joined-in line.
cx.set_state(indoc! {"
* ˇfoo
* bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
* fooˇ bar
"});
// Strips bare block comment body prefix (`*` without trailing space).
cx.set_state(indoc! {"
* ˇfoo
*
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
* fooˇ
"});
}
{
let markdown_language = Arc::new(Language::new(
LanguageConfig {
unordered_list: vec!["- ".into(), "* ".into(), "+ ".into()],
..LanguageConfig::default()
},
None,
));
let mut cx = EditorTestContext::new(cx).await;
cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
// Strips the `- ` list marker from the joined-in line.
cx.set_state(indoc! {"
- ˇfoo
- bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
- fooˇ bar
"});
// Strips the `* ` list marker from the joined-in line.
cx.set_state(indoc! {"
* ˇfoo
* bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
* fooˇ bar
"});
// Strips the `+ ` list marker from the joined-in line.
cx.set_state(indoc! {"
+ ˇfoo
+ bar
"});
cx.update_editor(|e, window, cx| e.join_lines(&JoinLines, window, cx));
cx.assert_editor_state(indoc! {"
+ fooˇ bar
"});
// No-whitespace join also strips the list marker.
cx.set_state(indoc! {"
- ˇfoo
- bar
"});
cx.update_editor(|e, window, cx| e.join_lines_impl(false, window, cx));
cx.assert_editor_state(indoc! {"
- fooˇbar
"});
}
}
#[gpui::test]
async fn test_custom_newlines_cause_no_false_positive_diffs(
executor: BackgroundExecutor,