From a33fd4d22e87494a7b373b7a712b69890a4d02be Mon Sep 17 00:00:00 2001 From: ymcx <89810988+ymcx@users.noreply.github.com> Date: Sat, 16 May 2026 13:02:44 +0300 Subject: [PATCH 1/5] Restore "Fix move to line start in multi-line history entries" --- src/engine.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 14 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 2f33a09f..62cb9e1e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1696,20 +1696,6 @@ impl Reedline { /// Executes [`EditCommand`] actions by modifying the internal state appropriately. Does not output itself. pub fn run_edit_commands(&mut self, commands: &[EditCommand]) { if self.input_mode == InputMode::HistoryTraversal { - if matches!( - self.history_cursor.get_navigation(), - HistoryNavigationQuery::Normal(_) - ) { - if let Some(string) = self.history_cursor.string_at_cursor() { - // NOTE: `set_buffer` resets the insertion point, - // which we should avoid during history navigation through the same buffer - // https://github.com/nushell/reedline/pull/899 - if string != self.editor.get_buffer() { - self.editor - .set_buffer(string, UndoBehavior::HistoryNavigation); - } - } - } self.input_mode = InputMode::Regular; } @@ -2601,4 +2587,121 @@ mod tests { "must not expand !prefix inside an unclosed single-quoted string" ); } + + #[test] + fn test_move_to_line_start() { + let cases = [ + "", + "line of text", +"longgggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg line of text" + ]; + + for input in cases { + let mut reedline = Reedline::create(); + let prompt = DefaultPrompt::default(); + + let insert_input_event = + ReedlineEvent::Edit(vec![EditCommand::InsertString(input.to_string())]); + let move_to_line_start_event = + ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { select: false }]); + + // Have to resize, or painting.utils.estimate_single_line_wraps panics with divide-by-zero. + reedline + .handle_event(&prompt, ReedlineEvent::Resize(u16::MAX, u16::MAX)) + .unwrap(); + + // Write the string, and then move to the start of the line. + reedline.handle_event(&prompt, insert_input_event).unwrap(); + reedline + .handle_event(&prompt, move_to_line_start_event.clone()) + .unwrap(); + assert_eq!(reedline.editor.line_buffer().insertion_point(), 0); + + // Enter the string into history, then scroll back up and move to the start of the line. + reedline + .handle_event(&prompt, ReedlineEvent::Enter) + .unwrap(); + reedline.handle_event(&prompt, ReedlineEvent::Up).unwrap(); + reedline + .handle_event(&prompt, move_to_line_start_event) + .unwrap(); + assert_eq!(reedline.editor.line_buffer().insertion_point(), 0); + } + } + + #[test] + fn test_move_to_line_start_multiline() { + let cases = [ + ("a\nb", 2, 2), + ("123456789\n123456789\n123456789", 10, 20), + ("0\n1\n2\n3\n4\n5\n6\n7\n8\n9", 2, 18), + ]; + + for (input, second_line_start, last_line_start) in cases { + let mut reedline = Reedline::create(); + let prompt = DefaultPrompt::default(); + + let insert_input_event = + ReedlineEvent::Edit(vec![EditCommand::InsertString(input.to_string())]); + let move_to_line_start_event = + ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { select: false }]); + let move_to_end_event = + ReedlineEvent::Edit(vec![EditCommand::MoveToEnd { select: false }]); + + // Have to resize, or painting.utils.estimate_single_line_wraps panics with divide-by-zero. + reedline + .handle_event(&prompt, ReedlineEvent::Resize(u16::MAX, u16::MAX)) + .unwrap(); + + // Write the string, and then move to the start of the last line. + reedline.handle_event(&prompt, insert_input_event).unwrap(); + reedline + .handle_event(&prompt, move_to_line_start_event.clone()) + .unwrap(); + assert_eq!( + reedline.editor.line_buffer().insertion_point(), + last_line_start + ); + + // Enter the string into history, then scroll back up and move to the start of the first line. + reedline + .handle_event(&prompt, ReedlineEvent::Enter) + .unwrap(); + reedline.handle_event(&prompt, ReedlineEvent::Up).unwrap(); + reedline + .handle_event(&prompt, move_to_line_start_event.clone()) + .unwrap(); + assert_eq!(reedline.editor.line_buffer().insertion_point(), 0); + + // Enter the string again, then scroll up in history, move down one line, + // and move to the start of the second line. + reedline + .handle_event(&prompt, ReedlineEvent::Enter) + .unwrap(); + reedline.handle_event(&prompt, ReedlineEvent::Up).unwrap(); + reedline.handle_event(&prompt, ReedlineEvent::Down).unwrap(); + reedline + .handle_event(&prompt, move_to_line_start_event.clone()) + .unwrap(); + assert_eq!( + reedline.editor.line_buffer().insertion_point(), + second_line_start + ); + + // Enter the string again, then scroll up in history, move to the end of the text, + // and move to the start of the last line. + reedline + .handle_event(&prompt, ReedlineEvent::Enter) + .unwrap(); + reedline.handle_event(&prompt, ReedlineEvent::Up).unwrap(); + reedline.handle_event(&prompt, move_to_end_event).unwrap(); + reedline + .handle_event(&prompt, move_to_line_start_event) + .unwrap(); + assert_eq!( + reedline.editor.line_buffer().insertion_point(), + last_line_start + ); + } + } } From 985d57b3578f53eac05bfa5ff81275c8c56ac994 Mon Sep 17 00:00:00 2001 From: ymcx <89810988+ymcx@users.noreply.github.com> Date: Sat, 16 May 2026 13:30:02 +0300 Subject: [PATCH 2/5] don't mess up stdout when running unit tests --- src/engine.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/engine.rs b/src/engine.rs index 62cb9e1e..3b2200c7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2204,6 +2204,7 @@ impl Reedline { Ok(messages) } + #[allow(unused_variables)] fn submit_buffer(&mut self, prompt: &dyn Prompt) -> io::Result { let buffer = self.editor.get_buffer().to_string(); self.hide_hints = true; @@ -2212,6 +2213,8 @@ impl Reedline { self.repaint(transient_prompt.as_ref())?; self.transient_prompt = Some(transient_prompt); } else { + // Don't repaint when running unit test as it will fill up stdout with garbage + #[cfg(not(test))] self.repaint(prompt)?; } if !buffer.is_empty() { From cf3f6332bd82b7b7d2cf3f3cd10f28b9274124c1 Mon Sep 17 00:00:00 2001 From: ymcx <89810988+ymcx@users.noreply.github.com> Date: Sat, 16 May 2026 14:56:31 +0300 Subject: [PATCH 3/5] add unit test for history line completion --- src/engine.rs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/engine.rs b/src/engine.rs index 3b2200c7..4bb7e1e1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2247,7 +2247,7 @@ impl Reedline { mod tests { use super::*; use crate::terminal_extensions::semantic_prompt::PromptKind; - use crate::DefaultPrompt; + use crate::{ColumnarMenu, DefaultPrompt, MenuBuilder}; #[test] fn test_cursor_position_after_multiline_history_navigation() { @@ -2707,4 +2707,39 @@ mod tests { ); } } + + #[test] + fn test_complete_line_from_history() { + let history = Box::new(FileBackedHistory::new(1).unwrap()); + let completer = Box::new(DefaultCompleter::new(vec!["67".into()])); + let completion_menu = ReedlineMenu::EngineCompleter(Box::new( + ColumnarMenu::default().with_name("completion_menu"), + )); + + let prompt = DefaultPrompt::default(); + let mut reedline = Reedline::create() + .with_quick_completions(true) + .with_history(history) + .with_completer(completer) + .with_menu(completion_menu); + + let insert_6 = ReedlineEvent::Edit(vec![EditCommand::InsertString("6".into())]); + let submit = ReedlineEvent::Submit; + let up = ReedlineEvent::Up; + let tab = ReedlineEvent::Menu("completion_menu".into()); + let insert_x = ReedlineEvent::Edit(vec![EditCommand::InsertString("x".into())]); + + // Insert 6, press enter, then re-select it from history. + // Press tab to automatically complete 67 using quick completion without actually showing the menu. + // Anything typed after this should be appended to the end of the string rather than replacing the completion. + // 67 + x should be 67x and not 6x + + reedline.handle_event(&prompt, insert_6).unwrap(); + reedline.handle_event(&prompt, submit).unwrap(); + reedline.handle_event(&prompt, up).unwrap(); + reedline.handle_event(&prompt, tab).unwrap(); + reedline.handle_event(&prompt, insert_x).unwrap(); + + assert_eq!(reedline.editor.get_buffer(), "67x"); + } } From bdc353a86e3d5f9d049fe96f261eea94954c0a05 Mon Sep 17 00:00:00 2001 From: ymcx <89810988+ymcx@users.noreply.github.com> Date: Sat, 16 May 2026 17:13:37 +0300 Subject: [PATCH 4/5] fix ci by rewriting rstests into normal tests --- src/engine.rs | 149 ++++++++++++++++++++++++++++---------------------- 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 8e11aae8..1cc52643 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2512,42 +2512,50 @@ mod tests { assert_eq!(reedline.current_buffer_contents(), "sudo git commit"); } - #[rstest] - #[case("\"hello gc", false)] - #[case("'hello gc", false)] - #[case("\"hello\" gc", true)] - #[case("'Сегодня хороший gc", false)] - #[case("'Сегодня' gc", true)] - #[case("'今日はいい日だ gc", false)] - #[case("'🔥🎉 gc", false)] - fn abbreviation_string_detection_with_override( - #[case] buffer: &str, - #[case] should_expand: bool, - ) { - let mut reedline = reedline_with_abbrevs_and_string_lit_override(&[("gc", "git commit")]); - set_buffer_at_end(&mut reedline, buffer); - assert_eq!( - reedline.try_expand_abbreviation_at_cursor(true).is_some(), - should_expand - ); + #[test] + fn abbreviation_string_detection_with_override() { + let cases = [ + ("\"hello gc", false), + ("'hello gc", false), + ("\"hello\" gc", true), + ("'Сегодня хороший gc", false), + ("'Сегодня' gc", true), + ("'今日はいい日だ gc", false), + ("'🔥🎉 gc", false), + ]; + + for (buffer, should_expand) in cases { + let mut reedline = + reedline_with_abbrevs_and_string_lit_override(&[("gc", "git commit")]); + set_buffer_at_end(&mut reedline, buffer); + assert_eq!( + reedline.try_expand_abbreviation_at_cursor(true).is_some(), + should_expand + ); + } } - #[rstest] - #[case("\"hello gc")] - #[case("'hello gc")] - #[case("\"hello\" gc")] - #[case("'Сегодня хороший gc")] - #[case("'Сегодня' gc")] - #[case("'今日はいい日だ gc")] - #[case("'🔥🎉 gc")] - fn abbreviation_string_detection_default(#[case] buffer: &str) { - let mut reedline = - reedline_with_abbrevs_and_default_string_lit_check(&[("gc", "git commit")]); - set_buffer_at_end(&mut reedline, buffer); - assert!( - reedline.try_expand_abbreviation_at_cursor(true).is_some(), - "must expand when highlighter does not override is_inside_string_literal" - ); + #[test] + fn abbreviation_string_detection_default() { + let cases = [ + ("\"hello gc"), + ("'hello gc"), + ("\"hello\" gc"), + ("'Сегодня хороший gc"), + ("'Сегодня' gc"), + ("'今日はいい日だ gc"), + ("'🔥🎉 gc"), + ]; + + for buffer in cases { + let mut reedline = + reedline_with_abbrevs_and_default_string_lit_check(&[("gc", "git commit")]); + set_buffer_at_end(&mut reedline, buffer); + assert!( + reedline.try_expand_abbreviation_at_cursor(true).is_some(), + "must expand when highlighter does not override is_inside_string_literal" + ); + } } #[test] @@ -2598,40 +2606,49 @@ mod tests { reedline } - #[rstest] - #[case("!!", true)] - #[case("\"echo !!", false)] - #[case("'echo !!", false)] - #[case("'echo' !!", true)] - #[case("\"echo !git", false)] - #[case("'echo !git", false)] - #[case("'Сегодня !!", false)] - #[case("'今日は !!", false)] - #[case("'🔥 !!", false)] #[cfg(feature = "bashisms")] - fn bang_string_detection_with_override(#[case] buffer: &str, #[case] should_expand: bool) { - let mut reedline = reedline_with_history_and_string_lit_check(&["git status"]); - set_buffer_at_end(&mut reedline, buffer); - assert_eq!(reedline.parse_bang_command().is_some(), should_expand); - } - - #[rstest] - #[case("\"echo !!")] - #[case("'echo !!")] - #[case("'echo' !!")] - #[case("\"echo !git")] - #[case("'echo !git")] - #[case("'Сегодня !!")] - #[case("'今日は !!")] - #[case("'🔥 !!")] + fn bang_string_detection_with_override() { + let cases = [ + ("!!", true), + ("\"echo !!", false), + ("'echo !!", false), + ("'echo' !!", true), + ("\"echo !git", false), + ("'echo !git", false), + ("'Сегодня !!", false), + ("'今日は !!", false), + ("'🔥 !!", false), + ]; + + for (buffer, should_expand) in cases { + let mut reedline = reedline_with_history_and_string_lit_check(&["git status"]); + set_buffer_at_end(&mut reedline, buffer); + assert_eq!(reedline.parse_bang_command().is_some(), should_expand); + } + } + + #[test] #[cfg(feature = "bashisms")] - fn bang_always_expands_without_override(#[case] buffer: &str) { - let mut reedline = reedline_with_history_default(&["git status"]); - set_buffer_at_end(&mut reedline, buffer); - assert!( - reedline.parse_bang_command().is_some(), - "must expand when highlighter does not override is_inside_string_literal" - ); + fn bang_always_expands_without_override() { + let cases = [ + "\"echo !!", + "'echo !!", + "'echo' !!", + "\"echo !git", + "'echo !git", + "'Сегодня !!", + "'今日は !!", + "'🔥 !!", + ]; + + for buffer in cases { + let mut reedline = reedline_with_history_default(&["git status"]); + set_buffer_at_end(&mut reedline, buffer); + assert!( + reedline.parse_bang_command().is_some(), + "must expand when highlighter does not override is_inside_string_literal" + ); + } } #[test] From 90de868de811c7584bb95f85df1337c9610ff731 Mon Sep 17 00:00:00 2001 From: ymcx <89810988+ymcx@users.noreply.github.com> Date: Sun, 17 May 2026 00:36:06 +0300 Subject: [PATCH 5/5] fix ci 2 --- src/engine.rs | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 1cc52643..06ddc5a9 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2580,19 +2580,6 @@ mod tests { assert!(reedline.try_expand_abbreviation_at_cursor(true).is_none()); } - #[cfg(feature = "bashisms")] - fn reedline_with_history_and_string_lit_check(entries: &[&str]) -> Reedline { - let mut reedline = - Reedline::create().with_highlighter(Box::new(ExampleHighlighter::default())); - for entry in entries { - reedline - .history - .save(HistoryItem::from_command_line(*entry)) - .expect("failed to save history"); - } - reedline - } - #[cfg(feature = "bashisms")] fn reedline_with_history_default(entries: &[&str]) -> Reedline { let mut reedline = @@ -2606,27 +2593,6 @@ mod tests { reedline } - #[cfg(feature = "bashisms")] - fn bang_string_detection_with_override() { - let cases = [ - ("!!", true), - ("\"echo !!", false), - ("'echo !!", false), - ("'echo' !!", true), - ("\"echo !git", false), - ("'echo !git", false), - ("'Сегодня !!", false), - ("'今日は !!", false), - ("'🔥 !!", false), - ]; - - for (buffer, should_expand) in cases { - let mut reedline = reedline_with_history_and_string_lit_check(&["git status"]); - set_buffer_at_end(&mut reedline, buffer); - assert_eq!(reedline.parse_bang_command().is_some(), should_expand); - } - } - #[test] #[cfg(feature = "bashisms")] fn bang_always_expands_without_override() {