diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 721ac0441f..a31733823d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1101,6 +1101,12 @@ pub enum MultibufferSelectionMode { All, } +#[derive(Clone, Copy, Debug, Default)] +pub struct RewrapOptions { + pub override_language_settings: bool, + pub preserve_existing_whitespace: bool, +} + impl Editor { pub fn single_line(window: &mut Window, cx: &mut Context) -> Self { let buffer = cx.new(|cx| Buffer::local("", cx)); @@ -3246,7 +3252,13 @@ impl Editor { .line_len(MultiBufferRow(latest.start.row)) == latest.start.column { - this.rewrap_impl(true, cx) + this.rewrap_impl( + RewrapOptions { + override_language_settings: true, + preserve_existing_whitespace: true, + }, + cx, + ) } } this.trigger_completion_on_input(&text, trigger_in_words, window, cx); @@ -9172,10 +9184,10 @@ impl Editor { } pub fn rewrap(&mut self, _: &Rewrap, _: &mut Window, cx: &mut Context) { - self.rewrap_impl(false, cx) + self.rewrap_impl(RewrapOptions::default(), cx) } - pub fn rewrap_impl(&mut self, override_language_settings: bool, cx: &mut Context) { + pub fn rewrap_impl(&mut self, options: RewrapOptions, cx: &mut Context) { let buffer = self.buffer.read(cx).snapshot(cx); let selections = self.selections.all::(cx); let mut selections = selections.iter().peekable(); @@ -9249,7 +9261,7 @@ impl Editor { RewrapBehavior::Anywhere => true, }; - let should_rewrap = override_language_settings + let should_rewrap = options.override_language_settings || allow_rewrap_based_on_language || self.hard_wrap.is_some(); if !should_rewrap { @@ -9306,15 +9318,16 @@ impl Editor { }); let wrapped_text = wrap_with_prefix( line_prefix, - lines_without_prefixes.join(" "), + lines_without_prefixes.join("\n"), wrap_column, tab_size, + options.preserve_existing_whitespace, ); // TODO: should always use char-based diff while still supporting cursor behavior that // matches vim. let mut diff_options = DiffOptions::default(); - if override_language_settings { + if options.override_language_settings { diff_options.max_word_diff_len = 0; diff_options.max_word_diff_line_count = 0; } else { @@ -17280,10 +17293,10 @@ fn should_stay_with_preceding_ideograph(text: &str) -> bool { } #[derive(PartialEq, Eq, Debug, Clone, Copy)] -struct WordBreakToken<'a> { - token: &'a str, - grapheme_len: usize, - is_whitespace: bool, +enum WordBreakToken<'a> { + Word { token: &'a str, grapheme_len: usize }, + InlineWhitespace { token: &'a str, grapheme_len: usize }, + Newline, } impl<'a> Iterator for WordBreakingTokenizer<'a> { @@ -17299,16 +17312,17 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> { let mut iter = self.input.graphemes(true).peekable(); let mut offset = 0; - let mut graphemes = 0; + let mut grapheme_len = 0; if let Some(first_grapheme) = iter.next() { + let is_newline = first_grapheme == "\n"; let is_whitespace = is_grapheme_whitespace(first_grapheme); offset += first_grapheme.len(); - graphemes += 1; + grapheme_len += 1; if is_grapheme_ideographic(first_grapheme) && !is_whitespace { if let Some(grapheme) = iter.peek().copied() { if should_stay_with_preceding_ideograph(grapheme) { offset += grapheme.len(); - graphemes += 1; + grapheme_len += 1; } } } else { @@ -17321,27 +17335,29 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> { if next_word_bound.map_or(false, |(i, _)| i == offset) { break; }; - if is_grapheme_whitespace(grapheme) != is_whitespace { + if is_grapheme_whitespace(grapheme) != is_whitespace + || (grapheme == "\n") != is_newline + { break; }; offset += grapheme.len(); - graphemes += 1; + grapheme_len += 1; iter.next(); } } let token = &self.input[..offset]; self.input = &self.input[offset..]; - if is_whitespace { - Some(WordBreakToken { - token: " ", - grapheme_len: 1, - is_whitespace: true, + if token == "\n" { + Some(WordBreakToken::Newline) + } else if is_whitespace { + Some(WordBreakToken::InlineWhitespace { + token, + grapheme_len, }) } else { - Some(WordBreakToken { + Some(WordBreakToken::Word { token, - grapheme_len: graphemes, - is_whitespace: false, + grapheme_len, }) } } else { @@ -17352,66 +17368,75 @@ impl<'a> Iterator for WordBreakingTokenizer<'a> { #[test] fn test_word_breaking_tokenizer() { - let tests: &[(&str, &[(&str, usize, bool)])] = &[ + let tests: &[(&str, &[WordBreakToken<'static>])] = &[ ("", &[]), - (" ", &[(" ", 1, true)]), - ("Ʒ", &[("Ʒ", 1, false)]), - ("Ǽ", &[("Ǽ", 1, false)]), - ("⋑", &[("⋑", 1, false)]), - ("⋑⋑", &[("⋑⋑", 2, false)]), + (" ", &[whitespace(" ", 2)]), + ("Ʒ", &[word("Ʒ", 1)]), + ("Ǽ", &[word("Ǽ", 1)]), + ("⋑", &[word("⋑", 1)]), + ("⋑⋑", &[word("⋑⋑", 2)]), ( "原理,进而", - &[ - ("原", 1, false), - ("理,", 2, false), - ("进", 1, false), - ("而", 1, false), - ], + &[word("原", 1), word("理,", 2), word("进", 1), word("而", 1)], ), ( "hello world", - &[("hello", 5, false), (" ", 1, true), ("world", 5, false)], + &[word("hello", 5), whitespace(" ", 1), word("world", 5)], ), ( "hello, world", - &[("hello,", 6, false), (" ", 1, true), ("world", 5, false)], + &[word("hello,", 6), whitespace(" ", 1), word("world", 5)], ), ( " hello world", &[ - (" ", 1, true), - ("hello", 5, false), - (" ", 1, true), - ("world", 5, false), + whitespace(" ", 2), + word("hello", 5), + whitespace(" ", 1), + word("world", 5), ], ), ( "这是什么 \n 钢笔", &[ - ("这", 1, false), - ("是", 1, false), - ("什", 1, false), - ("么", 1, false), - (" ", 1, true), - ("钢", 1, false), - ("笔", 1, false), + word("这", 1), + word("是", 1), + word("什", 1), + word("么", 1), + whitespace(" ", 1), + newline(), + whitespace(" ", 1), + word("钢", 1), + word("笔", 1), ], ), - (" mutton", &[(" ", 1, true), ("mutton", 6, false)]), + (" mutton", &[whitespace(" ", 1), word("mutton", 6)]), ]; + fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { + WordBreakToken::Word { + token, + grapheme_len, + } + } + + fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> { + WordBreakToken::InlineWhitespace { + token, + grapheme_len, + } + } + + fn newline() -> WordBreakToken<'static> { + WordBreakToken::Newline + } + for (input, result) in tests { assert_eq!( - WordBreakingTokenizer::new(input).collect::>(), - result - .iter() - .copied() - .map(|(token, grapheme_len, is_whitespace)| WordBreakToken { - token, - grapheme_len, - is_whitespace, - }) + WordBreakingTokenizer::new(input) .collect::>() + .as_slice(), + *result, ); } } @@ -17421,6 +17446,7 @@ fn wrap_with_prefix( unwrapped_text: String, wrap_column: usize, tab_size: NonZeroU32, + preserve_existing_whitespace: bool, ) -> String { let line_prefix_len = char_len_with_expanded_tabs(0, &line_prefix, tab_size); let mut wrapped_text = String::new(); @@ -17428,27 +17454,68 @@ fn wrap_with_prefix( let tokenizer = WordBreakingTokenizer::new(&unwrapped_text); let mut current_line_len = line_prefix_len; - for WordBreakToken { - token, - grapheme_len, - is_whitespace, - } in tokenizer - { - if current_line_len + grapheme_len > wrap_column && current_line_len != line_prefix_len { - wrapped_text.push_str(current_line.trim_end()); - wrapped_text.push('\n'); - current_line.truncate(line_prefix.len()); - current_line_len = line_prefix_len; - if !is_whitespace { + let mut in_whitespace = false; + for token in tokenizer { + let have_preceding_whitespace = in_whitespace; + match token { + WordBreakToken::Word { + token, + grapheme_len, + } => { + in_whitespace = false; + if current_line_len + grapheme_len > wrap_column + && current_line_len != line_prefix_len + { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } current_line.push_str(token); current_line_len += grapheme_len; } - } else if !is_whitespace { - current_line.push_str(token); - current_line_len += grapheme_len; - } else if current_line_len != line_prefix_len { - current_line.push(' '); - current_line_len += 1; + WordBreakToken::InlineWhitespace { + mut token, + mut grapheme_len, + } => { + in_whitespace = true; + if have_preceding_whitespace && !preserve_existing_whitespace { + continue; + } + if !preserve_existing_whitespace { + token = " "; + grapheme_len = 1; + } + if current_line_len + grapheme_len > wrap_column { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } else if current_line_len != line_prefix_len || preserve_existing_whitespace { + current_line.push_str(token); + current_line_len += grapheme_len; + } + } + WordBreakToken::Newline => { + in_whitespace = true; + if preserve_existing_whitespace { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } else if have_preceding_whitespace { + continue; + } else if current_line_len + 1 > wrap_column && current_line_len != line_prefix_len + { + wrapped_text.push_str(current_line.trim_end()); + wrapped_text.push('\n'); + current_line.truncate(line_prefix.len()); + current_line_len = line_prefix_len; + } else if current_line_len != line_prefix_len { + current_line.push(' '); + current_line_len += 1; + } + } } } @@ -17465,7 +17532,8 @@ fn test_wrap_with_prefix() { "# ".to_string(), "abcdefg".to_string(), 4, - NonZeroU32::new(4).unwrap() + NonZeroU32::new(4).unwrap(), + false, ), "# abcdefg" ); @@ -17474,7 +17542,8 @@ fn test_wrap_with_prefix() { "".to_string(), "\thello world".to_string(), 8, - NonZeroU32::new(4).unwrap() + NonZeroU32::new(4).unwrap(), + false, ), "hello\nworld" ); @@ -17483,7 +17552,8 @@ fn test_wrap_with_prefix() { "// ".to_string(), "xx \nyy zz aa bb cc".to_string(), 12, - NonZeroU32::new(4).unwrap() + NonZeroU32::new(4).unwrap(), + false, ), "// xx yy zz\n// aa bb cc" ); @@ -17492,7 +17562,8 @@ fn test_wrap_with_prefix() { String::new(), "这是什么 \n 钢笔".to_string(), 3, - NonZeroU32::new(4).unwrap() + NonZeroU32::new(4).unwrap(), + false, ), "这是什\n么 钢\n笔" ); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e9c48a11cc..735489dfcd 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -2,8 +2,10 @@ use super::*; use crate::{ scroll::scroll_amount::ScrollAmount, test::{ - assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext, - editor_test_context::EditorTestContext, select_ranges, + assert_text_with_selections, build_editor, + editor_lsp_test_context::{git_commit_lang, EditorLspTestContext}, + editor_test_context::EditorTestContext, + select_ranges, }, JoinLines, }; @@ -4746,6 +4748,7 @@ async fn test_hard_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(git_commit_lang()), cx)); cx.update_editor(|editor, _, cx| { editor.set_hard_wrap(Some(14), cx); }); @@ -4764,6 +4767,69 @@ async fn test_hard_wrap(cx: &mut TestAppContext) { fourˇ " )); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + ˇ + " + )); + + cx.simulate_input("five"); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + fiveˇ + " + )); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.simulate_input("# "); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + five + # ˇ + " + )); + + cx.update_editor(|editor, window, cx| { + editor.newline(&Default::default(), window, cx); + }); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + five + #\x20 + #ˇ + " + )); + + cx.simulate_input(" 6"); + cx.run_until_parked(); + cx.assert_editor_state(indoc!( + " + one two three + four + five + # + # 6ˇ + " + )); } #[gpui::test] diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 45e28fa23a..34b722ddf0 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -79,6 +79,19 @@ pub(crate) fn rust_lang() -> Arc { .expect("Could not parse queries"); Arc::new(language) } + +#[cfg(test)] +pub(crate) fn git_commit_lang() -> Arc { + Arc::new(Language::new( + LanguageConfig { + name: "Git Commit".into(), + line_comments: vec!["#".into()], + ..Default::default() + }, + None, + )) +} + impl EditorLspTestContext { pub async fn new( language: Language, diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index a889b0bf99..a7f5d939f7 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -1,6 +1,6 @@ use crate::{motion::Motion, object::Object, state::Mode, Vim}; use collections::HashMap; -use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias, Editor}; +use editor::{display_map::ToDisplayPoint, scroll::Autoscroll, Bias, Editor, RewrapOptions}; use gpui::{actions, Context, Window}; use language::SelectionGoal; @@ -14,7 +14,13 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context) { vim.update_editor(window, cx, |vim, editor, window, cx| { editor.transact(window, cx, |editor, window, cx| { let mut positions = vim.save_selection_starts(editor, cx); - editor.rewrap_impl(true, cx); + editor.rewrap_impl( + RewrapOptions { + override_language_settings: true, + ..Default::default() + }, + cx, + ); editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { s.move_with(|map, selection| { if let Some(anchor) = positions.remove(&selection.id) { @@ -52,7 +58,13 @@ impl Vim { motion.expand_selection(map, selection, times, false, &text_layout_details); }); }); - editor.rewrap_impl(true, cx); + editor.rewrap_impl( + RewrapOptions { + override_language_settings: true, + ..Default::default() + }, + cx, + ); editor.change_selections(None, window, cx, |s| { s.move_with(|map, selection| { let anchor = selection_starts.remove(&selection.id).unwrap(); @@ -83,7 +95,13 @@ impl Vim { object.expand_selection(map, selection, around); }); }); - editor.rewrap_impl(true, cx); + editor.rewrap_impl( + RewrapOptions { + override_language_settings: true, + ..Default::default() + }, + cx, + ); editor.change_selections(None, window, cx, |s| { s.move_with(|map, selection| { let anchor = original_positions.remove(&selection.id).unwrap();