From a7e07010e58bc53bcc8a33adbb0e5e31d251c432 Mon Sep 17 00:00:00 2001 From: "Raduan A." <36044389+0xRaduan@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:14:02 +0100 Subject: [PATCH] editor: Add automatic markdown list continuation on newline and indent on tab (#42800) Closes #5089 Release notes: - Markdown lists now continue automatically when you press Enter (unordered, ordered, and task lists). This can be configured with `extend_list_on_newline` (default: true). - You can now indent list markers with Tab to quickly create nested lists. This can be configured with `indent_list_on_tab` (default: true). --------- Co-authored-by: Claude Co-authored-by: Smit Barmase --- assets/settings/default.json | 4 + crates/editor/src/editor.rs | 459 +++++++++++--- crates/editor/src/editor_tests.rs | 588 ++++++++++++++++-- crates/language/src/language.rs | 45 ++ crates/language/src/language_settings.rs | 6 + crates/languages/src/markdown/config.toml | 3 + .../settings/src/settings_content/language.rs | 8 + crates/settings/src/vscode_import.rs | 2 + docs/src/configuring-zed.md | 20 + docs/src/languages/markdown.md | 34 + 10 files changed, 1021 insertions(+), 148 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 154fe2d6e34e6573e95e7ffedbb46df8bbf10634..746ccb5986d0fd1d5ef11df525303e344a7393d2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1178,6 +1178,10 @@ "remove_trailing_whitespace_on_save": true, // Whether to start a new line with a comment when a previous line is a comment as well. "extend_comment_on_newline": true, + // Whether to continue markdown lists when pressing enter. + "extend_list_on_newline": true, + // Whether to indent list items when pressing tab after a list marker. + "indent_list_on_tab": true, // Removes any lines containing only whitespace at the end of the file and // ensures just one newline at the end. "ensure_final_newline_on_save": true, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8560705802264dad55b87dbf21e1f9aa7625edf8..6e4744335b8e9fba50a6c2c8b241607b0e05d276 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -163,6 +163,7 @@ use project::{ project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings}, }; use rand::seq::SliceRandom; +use regex::Regex; use rpc::{ErrorCode, ErrorExt, proto::PeerId}; use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager}; use selections_collection::{MutableSelectionsCollection, SelectionsCollection}; @@ -4787,82 +4788,146 @@ impl Editor { let end = selection.end; let selection_is_empty = start == end; let language_scope = buffer.language_scope_at(start); - let (comment_delimiter, doc_delimiter, newline_formatting) = - if let Some(language) = &language_scope { - let mut newline_formatting = - NewlineFormatting::new(&buffer, start..end, language); - - // Comment extension on newline is allowed only for cursor selections - let comment_delimiter = maybe!({ - if !selection_is_empty { - return None; - } + let (delimiter, newline_config) = if let Some(language) = &language_scope { + let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets( + &buffer, + start..end, + language, + ) + || NewlineConfig::insert_extra_newline_tree_sitter( + &buffer, + start..end, + ); - if !multi_buffer.language_settings(cx).extend_comment_on_newline - { - return None; - } + let mut newline_config = NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: if needs_extra_newline { + Some(IndentSize::spaces(0)) + } else { + None + }, + prevent_auto_indent: false, + }; - return comment_delimiter_for_newline( - &start_point, - &buffer, - language, - ); - }); + let comment_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - let doc_delimiter = maybe!({ - if !selection_is_empty { - return None; - } + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } - if !multi_buffer.language_settings(cx).extend_comment_on_newline - { - return None; - } + return comment_delimiter_for_newline( + &start_point, + &buffer, + language, + ); + }); - return documentation_delimiter_for_newline( - &start_point, - &buffer, - language, - &mut newline_formatting, - ); - }); + let doc_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - (comment_delimiter, doc_delimiter, newline_formatting) - } else { - (None, None, NewlineFormatting::default()) - }; + if !multi_buffer.language_settings(cx).extend_comment_on_newline { + return None; + } - let prevent_auto_indent = doc_delimiter.is_some(); - let delimiter = comment_delimiter.or(doc_delimiter); + return documentation_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); - let capacity_for_delimiter = - delimiter.as_deref().map(str::len).unwrap_or_default(); - let mut new_text = String::with_capacity( - 1 + capacity_for_delimiter - + existing_indent.len as usize - + newline_formatting.indent_on_newline.len as usize - + newline_formatting.indent_on_extra_newline.len as usize, - ); - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(newline_formatting.indent_on_newline.chars()); + let list_delimiter = maybe!({ + if !selection_is_empty { + return None; + } - if let Some(delimiter) = &delimiter { - new_text.push_str(delimiter); - } + if !multi_buffer.language_settings(cx).extend_list_on_newline { + return None; + } - if newline_formatting.insert_extra_newline { - new_text.push('\n'); - new_text.extend(existing_indent.chars()); - new_text.extend(newline_formatting.indent_on_extra_newline.chars()); - } + return list_delimiter_for_newline( + &start_point, + &buffer, + language, + &mut newline_config, + ); + }); + + ( + comment_delimiter.or(doc_delimiter).or(list_delimiter), + newline_config, + ) + } else { + ( + None, + NewlineConfig::Newline { + additional_indent: IndentSize::spaces(0), + extra_line_additional_indent: None, + prevent_auto_indent: false, + }, + ) + }; + + let (edit_start, new_text, prevent_auto_indent) = match &newline_config { + NewlineConfig::ClearCurrentLine => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + (row_start, String::new(), false) + } + NewlineConfig::UnindentCurrentLine { continuation } => { + let row_start = + buffer.point_to_offset(Point::new(start_point.row, 0)); + let tab_size = buffer.language_settings_at(start, cx).tab_size; + let tab_size_indent = IndentSize::spaces(tab_size.get()); + let reduced_indent = + existing_indent.with_delta(Ordering::Less, tab_size_indent); + let mut new_text = String::new(); + new_text.extend(reduced_indent.chars()); + new_text.push_str(continuation); + (row_start, new_text, true) + } + NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent, + prevent_auto_indent, + } => { + let capacity_for_delimiter = + delimiter.as_deref().map(str::len).unwrap_or_default(); + let extra_line_len = extra_line_additional_indent + .map(|i| 1 + existing_indent.len as usize + i.len as usize) + .unwrap_or(0); + let mut new_text = String::with_capacity( + 1 + capacity_for_delimiter + + existing_indent.len as usize + + additional_indent.len as usize + + extra_line_len, + ); + new_text.push('\n'); + new_text.extend(existing_indent.chars()); + new_text.extend(additional_indent.chars()); + if let Some(delimiter) = &delimiter { + new_text.push_str(delimiter); + } + if let Some(extra_indent) = extra_line_additional_indent { + new_text.push('\n'); + new_text.extend(existing_indent.chars()); + new_text.extend(extra_indent.chars()); + } + (start, new_text, *prevent_auto_indent) + } + }; let anchor = buffer.anchor_after(end); let new_selection = selection.map(|_| anchor); ( - ((start..end, new_text), prevent_auto_indent), - (newline_formatting.insert_extra_newline, new_selection), + ((edit_start..end, new_text), prevent_auto_indent), + (newline_config.has_extra_line(), new_selection), ) }) .unzip() @@ -10387,6 +10452,22 @@ impl Editor { } prev_edited_row = selection.end.row; + // If cursor is after a list prefix, make selection non-empty to trigger line indent + if selection.is_empty() { + let cursor = selection.head(); + let settings = buffer.language_settings_at(cursor, cx); + if settings.indent_list_on_tab { + if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) { + if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) { + row_delta = Self::indent_selection( + buffer, &snapshot, selection, &mut edits, row_delta, cx, + ); + continue; + } + } + } + } + // If the selection is non-empty, then increase the indentation of the selected lines. if !selection.is_empty() { row_delta = @@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline( start_point: &Point, buffer: &MultiBufferSnapshot, language: &LanguageScope, - newline_formatting: &mut NewlineFormatting, + newline_config: &mut NewlineConfig, ) -> Option> { let BlockCommentConfig { start: start_tag, @@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline( } }; + let mut needs_extra_line = false; + let mut extra_line_additional_indent = IndentSize::spaces(0); + let cursor_is_before_end_tag_if_exists = { let mut char_position = 0u32; let mut end_tag_offset = None; @@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline( let cursor_is_before_end_tag = column <= end_tag_offset; if cursor_is_after_start_tag { if cursor_is_before_end_tag { - newline_formatting.insert_extra_newline = true; + needs_extra_line = true; } let cursor_is_at_start_of_end_tag = column == end_tag_offset; if cursor_is_at_start_of_end_tag { - newline_formatting.indent_on_extra_newline.len = *len; + extra_line_additional_indent.len = *len; } } cursor_is_before_end_tag @@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline( if (cursor_is_after_start_tag || cursor_is_after_delimiter) && cursor_is_before_end_tag_if_exists { - if cursor_is_after_start_tag { - newline_formatting.indent_on_newline.len = *len; - } + let additional_indent = if cursor_is_after_start_tag { + IndentSize::spaces(*len) + } else { + IndentSize::spaces(0) + }; + + *newline_config = NewlineConfig::Newline { + additional_indent, + extra_line_additional_indent: if needs_extra_line { + Some(extra_line_additional_indent) + } else { + None + }, + prevent_auto_indent: true, + }; Some(delimiter.clone()) } else { None } } -#[derive(Debug, Default)] -struct NewlineFormatting { - insert_extra_newline: bool, - indent_on_newline: IndentSize, - indent_on_extra_newline: IndentSize, +const ORDERED_LIST_MAX_MARKER_LEN: usize = 16; + +fn list_delimiter_for_newline( + start_point: &Point, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, + newline_config: &mut NewlineConfig, +) -> Option> { + let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_entries: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|prefix| (prefix.as_ref(), config.continuation.as_ref())) + }) + .collect(); + let unordered_list_entries: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| (marker.as_ref(), marker.as_ref())) + .collect(); + + let all_entries: Vec<_> = task_list_entries + .into_iter() + .chain(unordered_list_entries) + .collect(); + + if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + + if let Some((prefix, continuation)) = all_entries + .iter() + .filter(|(prefix, _)| candidate.starts_with(*prefix)) + .max_by_key(|(prefix, _)| prefix.len()) + { + let end_of_prefix = num_of_whitespaces + prefix.len(); + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + return Some((*continuation).into()); + } + + if start_point.column as usize == end_of_prefix { + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: (*continuation).into(), + }; + } + } + + return None; + } + } + + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + + if let Some(captures) = regex.captures(&candidate) { + let full_match = captures.get(0)?; + let marker_len = full_match.len(); + let end_of_prefix = num_of_whitespaces + marker_len; + let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize; + + let has_content_after_marker = snapshot + .chars_for_range(range) + .skip(end_of_prefix) + .any(|c| !c.is_whitespace()); + + if has_content_after_marker && cursor_is_after_prefix { + let number: u32 = captures.get(1)?.as_str().parse().ok()?; + let continuation = ordered_config + .format + .replace("{1}", &(number + 1).to_string()); + return Some(continuation.into()); + } + + if start_point.column as usize == end_of_prefix { + let continuation = ordered_config.format.replace("{1}", "1"); + if num_of_whitespaces == 0 { + *newline_config = NewlineConfig::ClearCurrentLine; + } else { + *newline_config = NewlineConfig::UnindentCurrentLine { + continuation: continuation.into(), + }; + } + } + + return None; + } + } + + None } -impl NewlineFormatting { - fn new( - buffer: &MultiBufferSnapshot, - range: Range, - language: &LanguageScope, - ) -> Self { - Self { - insert_extra_newline: Self::insert_extra_newline_brackets( - buffer, - range.clone(), - language, - ) || Self::insert_extra_newline_tree_sitter(buffer, range), - indent_on_newline: IndentSize::spaces(0), - indent_on_extra_newline: IndentSize::spaces(0), +fn is_list_prefix_row( + row: MultiBufferRow, + buffer: &MultiBufferSnapshot, + language: &LanguageScope, +) -> bool { + let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else { + return false; + }; + + let num_of_whitespaces = snapshot + .chars_for_range(range.clone()) + .take_while(|c| c.is_whitespace()) + .count(); + + let task_list_prefixes: Vec<_> = language + .task_list() + .into_iter() + .flat_map(|config| { + config + .prefixes + .iter() + .map(|p| p.as_ref()) + .collect::>() + }) + .collect(); + let unordered_list_markers: Vec<_> = language + .unordered_list() + .iter() + .map(|marker| marker.as_ref()) + .collect(); + let all_prefixes: Vec<_> = task_list_prefixes + .into_iter() + .chain(unordered_list_markers) + .collect(); + if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() { + let candidate: String = snapshot + .chars_for_range(range.clone()) + .skip(num_of_whitespaces) + .take(max_prefix_len) + .collect(); + if all_prefixes + .iter() + .any(|prefix| candidate.starts_with(*prefix)) + { + return true; } } + let ordered_list_candidate: String = snapshot + .chars_for_range(range) + .skip(num_of_whitespaces) + .take(ORDERED_LIST_MAX_MARKER_LEN) + .collect(); + for ordered_config in language.ordered_list() { + let regex = match Regex::new(&ordered_config.pattern) { + Ok(r) => r, + Err(_) => continue, + }; + if let Some(captures) = regex.captures(&ordered_list_candidate) { + return captures.get(0).is_some(); + } + } + + false +} + +#[derive(Debug)] +enum NewlineConfig { + /// Insert newline with optional additional indent and optional extra blank line + Newline { + additional_indent: IndentSize, + extra_line_additional_indent: Option, + prevent_auto_indent: bool, + }, + /// Clear the current line + ClearCurrentLine, + /// Unindent the current line and add continuation + UnindentCurrentLine { continuation: Arc }, +} + +impl NewlineConfig { + fn has_extra_line(&self) -> bool { + matches!( + self, + Self::Newline { + extra_line_additional_indent: Some(_), + .. + } + ) + } + fn insert_extra_newline_brackets( buffer: &MultiBufferSnapshot, range: Range, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index c0112c5eda406c9cb3b3b9d004d20853b710f6e1..87674d8c507b1c294779b1f9ddba458320fc7671 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28021,7 +28021,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { " }); - // Case 2: Test adding new line after nested list preserves indent of previous line + // Case 2: Test adding new line after nested list continues the list with unchecked task cx.set_state(&indoc! {" - [ ] Item 1 - [ ] Item 1.a @@ -28038,32 +28038,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - ˇ" + - [ ] ˇ" }); - // Case 3: Test adding a new nested list item preserves indent - cx.set_state(&indoc! {" - - [ ] Item 1 - - [ ] Item 1.a - - [x] Item 2 - - [x] Item 2.a - - [x] Item 2.b - ˇ" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input("-", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - - [ ] Item 1 - - [ ] Item 1.a - - [x] Item 2 - - [x] Item 2.a - - [x] Item 2.b - -ˇ" - }); + // Case 3: Test adding content to continued list item cx.update_editor(|editor, window, cx| { - editor.handle_input(" [x] Item 2.c", window, cx); + editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -28072,10 +28052,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { - [x] Item 2 - [x] Item 2.a - [x] Item 2.b - - [x] Item 2.cˇ" + - [ ] Item 2.cˇ" }); - // Case 4: Test adding new line after nested ordered list preserves indent of previous line + // Case 4: Test adding new line after nested ordered list continues with next number cx.set_state(indoc! {" 1. Item 1 1. Item 1.a @@ -28092,44 +28072,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) { 2. Item 2 1. Item 2.a 2. Item 2.b - ˇ" + 3. ˇ" }); - // Case 5: Adding new ordered list item preserves indent - cx.set_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - ˇ" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input("3", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - 3ˇ" - }); - cx.update_editor(|editor, window, cx| { - editor.handle_input(".", window, cx); - }); - cx.run_until_parked(); - cx.assert_editor_state(indoc! {" - 1. Item 1 - 1. Item 1.a - 2. Item 2 - 1. Item 2.a - 2. Item 2.b - 3.ˇ" - }); + // Case 5: Adding content to continued ordered list item cx.update_editor(|editor, window, cx| { - editor.handle_input(" Item 2.c", window, cx); + editor.handle_input("Item 2.c", window, cx); }); cx.run_until_parked(); cx.assert_editor_state(indoc! {" @@ -29497,6 +29445,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) { cx.assert_editor_state(after); } +#[gpui::test] +async fn test_newline_task_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker + cx.set_state(indoc! {" + - [ ] taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] ˇ + "}); + + // Case 2: Works with checked task items too + cx.set_state(indoc! {" + - [x] completed taskˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [x] completed task + - [ ] ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - [ ] taˇsk + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] ta + - [ ] ˇsk + "}); + + // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - [ ]$$ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - [ ] task + - [ ] indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] indented + - [ ] ˇ + "}); + + // Case 6: Adding newline with cursor right after prefix, unindents + cx.set_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after prefix, removes marker + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + - [ ] ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ ] task + - [ ] sub task + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- [ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- [ ] task + "}); + + cx.set_state(indoc! {" + - [ˇ ] task + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - [ + ˇ + ] task + "}); +} + +#[gpui::test] +async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - ˇ + "}); + + // Case 2: Works with different markers + cx.set_state(indoc! {" + * starred itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + * starred item + * ˇ + "}); + + cx.set_state(indoc! {" + + plus itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + plus item + + ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + - itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - it + - ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + - $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + - item + - indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - indented + - ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + - item + - sub item + - ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - item + - sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ- item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ- item + "}); + + cx.set_state(indoc! {" + -ˇ item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + - + ˇitem + "}); +} + +#[gpui::test] +async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 2. ˇ + "}); + + // Case 2: Works with larger numbers + cx.set_state(indoc! {" + 10. tenth itemˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 10. tenth item + 11. ˇ + "}); + + // Case 3: Cursor position doesn't matter - content after marker is what counts + cx.set_state(indoc! {" + 1. itˇem + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. it + 2. ˇem + "}); + + // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker + cx.set_state(indoc! {" + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state( + indoc! {" + 1. $ + ˇ + "} + .replace("$", " ") + .as_str(), + ); + + // Case 5: Adding newline with content adds marker preserving indentation + cx.set_state(indoc! {" + 1. item + 2. indentedˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. indented + 3. ˇ + "}); + + // Case 6: Adding newline with cursor right after marker, unindents + cx.set_state(indoc! {" + 1. item + 2. sub item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + + // Case 7: Adding newline with cursor right after marker, removes marker + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + 1. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. item + 2. sub item + ˇ + "}); + + // Case 8: Cursor before or inside prefix does not add marker + cx.set_state(indoc! {" + ˇ1. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + + ˇ1. item + "}); + + cx.set_state(indoc! {" + 1ˇ. item + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1 + ˇ. item + "}); +} + +#[gpui::test] +async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number + cx.set_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 3. ˇ + "}); + cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx)); + cx.wait_for_autoindent_applied().await; + cx.assert_editor_state(indoc! {" + 1. first item + 1. sub first item + 2. sub second item + 1. ˇ + "}); +} + +#[gpui::test] +async fn test_tab_list_indent(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.tab_size = Some(2.try_into().unwrap()); + }); + + let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into()); + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx)); + + // Case 1: Unordered list - cursor after prefix, adds indent before prefix + cx.set_state(indoc! {" + - ˇitem + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 2: Task list - cursor after prefix + cx.set_state(indoc! {" + - [ ] ˇtask + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- [ ] ˇtask + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 3: Ordered list - cursor after prefix + cx.set_state(indoc! {" + 1. ˇfirst + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$1. ˇfirst + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 4: With existing indentation - adds more indent + let initial = indoc! {" + $$- ˇitem + "}; + cx.set_state(initial.replace("$", " ").as_str()); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$$$- ˇitem + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 5: Empty list item + cx.set_state(indoc! {" + - ˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- ˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 6: Cursor at end of line with content + cx.set_state(indoc! {" + - itemˇ + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + $$- itemˇ + "}; + cx.assert_editor_state(expected.replace("$", " ").as_str()); + + // Case 7: Cursor at start of list item, indents it + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ - sub item + "}; + cx.assert_editor_state(expected); + + // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false + cx.update_editor(|_, _, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.indent_list_on_tab = Some(false); + }); + }); + }); + cx.set_state(indoc! {" + - item + ˇ - sub item + "}); + cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx)); + cx.wait_for_autoindent_applied().await; + let expected = indoc! {" + - item + ˇ- sub item + "}; + cx.assert_editor_state(expected); +} + #[gpui::test] async fn test_local_worktree_trust(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a573e3d78a4de03c6ccf382c80bc33eaf0b5690d..290cad4e4497015ef63f79e58a0dacf231168c9f 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -827,6 +827,15 @@ pub struct LanguageConfig { /// Delimiters and configuration for recognizing and formatting documentation comments. #[serde(default, alias = "documentation")] pub documentation_comment: Option, + /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + #[serde(default)] + pub unordered_list: Vec>, + /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). + #[serde(default)] + pub ordered_list: Vec, + /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). + #[serde(default)] + pub task_list: Option, /// A list of additional regex patterns that should be treated as prefixes /// for creating boundaries during rewrapping, ensuring content from one /// prefixed section doesn't merge with another (e.g., markdown list items). @@ -898,6 +907,24 @@ pub struct DecreaseIndentConfig { pub valid_after: Vec, } +/// Configuration for continuing ordered lists with auto-incrementing numbers. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct OrderedListConfig { + /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). + pub pattern: String, + /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). + pub format: String, +} + +/// Configuration for continuing task lists on newline. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct TaskListConfig { + /// The list markers to match (e.g., `- [ ] `, `- [x] `). + pub prefixes: Vec>, + /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). + pub continuation: Arc, +} + #[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] 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`. @@ -1068,6 +1095,9 @@ impl Default for LanguageConfig { line_comments: Default::default(), block_comment: Default::default(), documentation_comment: Default::default(), + unordered_list: Default::default(), + ordered_list: Default::default(), + task_list: Default::default(), rewrap_prefixes: Default::default(), scope_opt_in_language_servers: Default::default(), overrides: Default::default(), @@ -2153,6 +2183,21 @@ impl LanguageScope { self.language.config.documentation_comment.as_ref() } + /// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + pub fn unordered_list(&self) -> &[Arc] { + &self.language.config.unordered_list + } + + /// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `). + pub fn ordered_list(&self) -> &[OrderedListConfig] { + &self.language.config.ordered_list + } + + /// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `). + pub fn task_list(&self) -> Option<&TaskListConfig> { + self.language.config.task_list.as_ref() + } + /// Returns additional regex patterns that act as prefix markers for creating /// boundaries during rewrapping. /// diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index fccaa545b79c1f24589889df8fcd163fbc5b6c7d..205f2431c6d9deeaa7661b583caa516bdc77ae79 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -122,6 +122,10 @@ pub struct LanguageSettings { pub whitespace_map: WhitespaceMap, /// Whether to start a new line with a comment when a previous line is a comment as well. pub extend_comment_on_newline: bool, + /// Whether to continue markdown lists when pressing enter. + pub extend_list_on_newline: bool, + /// Whether to indent list items when pressing tab after a list marker. + pub indent_list_on_tab: bool, /// Inlay hint related settings. pub inlay_hints: InlayHintSettings, /// Whether to automatically close brackets. @@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings { tab: SharedString::new(whitespace_map.tab.unwrap().to_string()), }, extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(), + extend_list_on_newline: settings.extend_list_on_newline.unwrap(), + indent_list_on_tab: settings.indent_list_on_tab.unwrap(), inlay_hints: InlayHintSettings { enabled: inlay_hints.enabled.unwrap(), show_value_hints: inlay_hints.show_value_hints.unwrap(), diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 84c79d2538a0af470ec16d55fe9cf2d1ae05805b..423a4c008f6e8a64f3c4e883b0d6e2bde65c88ae 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -20,6 +20,9 @@ rewrap_prefixes = [ ">\\s*", "[-*+]\\s+\\[[\\sx]\\]\\s+" ] +unordered_list = ["- ", "* ", "+ "] +ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }] +task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " } auto_indent_on_paste = false auto_indent_using_last_non_empty_line = false diff --git a/crates/settings/src/settings_content/language.rs b/crates/settings/src/settings_content/language.rs index f9c85f18f380a7ad82b0d8bc202fe3763ba3a832..cf8cf7b63589e84a96e6b9d92f23a4488479d1f3 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -363,6 +363,14 @@ pub struct LanguageSettingsContent { /// /// Default: true pub extend_comment_on_newline: Option, + /// Whether to continue markdown lists when pressing enter. + /// + /// Default: true + pub extend_list_on_newline: Option, + /// Whether to indent list items when pressing tab after a list marker. + /// + /// Default: true + pub indent_list_on_tab: Option, /// Inlay hint related settings. pub inlay_hints: Option, /// Whether to automatically type closing characters for you. For example, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb..64343b05fd57c33eb9cfb0d8cb8674971266b464 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -430,6 +430,8 @@ impl VsCodeSettings { enable_language_server: None, ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"), extend_comment_on_newline: None, + extend_list_on_newline: None, + indent_list_on_tab: None, format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| { if b { FormatOnSave::On diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 8a638d9f7857e1a55aaa5589a77110a7b803bbfe..81318aa8885fe883acc394e7fe983d7721dd33a5 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1585,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be `boolean` values +## Extend List On Newline + +- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list. +- Setting: `extend_list_on_newline` +- Default: `true` + +**Options** + +`boolean` values + +## Indent List On Tab + +- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists. +- Setting: `indent_list_on_tab` +- Default: `true` + +**Options** + +`boolean` values + ## Status Bar - Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere. diff --git a/docs/src/languages/markdown.md b/docs/src/languages/markdown.md index 36ce734f7cfbcc066bb8026568209738655a6be9..64c9e7070569a23daa5bcb8aa4dace12e0021b03 100644 --- a/docs/src/languages/markdown.md +++ b/docs/src/languages/markdown.md @@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c }, ``` +### List Continuation + +Zed automatically continues lists when you press Enter at the end of a list item. Supported list types: + +- Unordered lists (`-`, `*`, or `+` markers) +- Ordered lists (numbers are auto-incremented) +- Task lists (`- [ ]` and `- [x]`) + +Pressing Enter on an empty list item removes the marker and exits the list. + +To disable this behavior: + +```json [settings] + "languages": { + "Markdown": { + "extend_list_on_newline": false + } + }, +``` + +### List Indentation + +Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists. + +To disable this behavior: + +```json [settings] + "languages": { + "Markdown": { + "indent_list_on_tab": false + } + }, +``` + ### Trailing Whitespace By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `
` in Markdown files you can disable this behavior with: