diff --git a/examples/auto_pairs.rs b/examples/auto_pairs.rs new file mode 100644 index 000000000..8d2129186 --- /dev/null +++ b/examples/auto_pairs.rs @@ -0,0 +1,25 @@ +// Create a reedline object with automatic pairs. +// cargo run --example auto_pairs + +use reedline::{AutoPairs, DefaultPrompt, Reedline, Signal}; +use std::io; + +fn main() -> io::Result<()> { + let auto_pairs = AutoPairs::new([('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')]); + let mut line_editor = Reedline::create().with_auto_pairs(auto_pairs); + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + } + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + } + _ => {} + } + } +} diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index d13b64916..42a4e2008 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -168,12 +168,14 @@ impl Editor { EditCommand::InsertChar(c) => self.insert_char(*c), EditCommand::Complete => {} EditCommand::InsertString(str) => self.insert_str(str), + EditCommand::InsertPair { left, right } => self.insert_pair(*left, *right), EditCommand::InsertNewline => self.insert_newline(), EditCommand::InsertNewlineAbove => self.insert_newline_above(), EditCommand::InsertNewlineBelow => self.insert_newline_below(), EditCommand::ReplaceChar(chr) => self.replace_char(*chr), EditCommand::ReplaceChars(n_chars, str) => self.replace_chars(*n_chars, str), EditCommand::Backspace => self.backspace(), + EditCommand::BackspacePair { left, right } => self.backspace_pair(*left, *right), EditCommand::Delete => self.delete(), EditCommand::CutChar => self.cut_char(), EditCommand::BackspaceWord => self.line_buffer.delete_word_left(), @@ -1254,6 +1256,42 @@ impl Editor { .head } + fn insert_pair(&mut self, open: char, close: char) { + if let Some((start, end)) = self.get_selection() { + let selected = self.line_buffer.get_buffer()[start..end].to_string(); + let replacement = format!("{open}{selected}{close}"); + self.line_buffer.replace_range(start..end, &replacement); + self.line_buffer.set_cursor(Cursor::point( + start + open.len_utf8() + selected.len() + close.len_utf8(), + )); + } else { + self.line_buffer.insert_char(open); + let inner = self.line_buffer.insertion_point(); + self.line_buffer.insert_char(close); + self.line_buffer.set_cursor(Cursor::point(inner)); + } + } + + pub(crate) fn is_auto_pair_closer_at_cursor(&self, close: char) -> bool { + self.line_buffer.selection_anchor().is_none() + && self.line_buffer.grapheme_right().starts_with(close) + } + + pub(crate) fn is_empty_auto_pair_at_cursor(&self, open: char, close: char) -> bool { + self.line_buffer.selection_anchor().is_none() + && self.line_buffer.grapheme_left().starts_with(open) + && self.line_buffer.grapheme_right().starts_with(close) + } + + fn backspace_pair(&mut self, open: char, close: char) { + if !self.is_empty_auto_pair_at_cursor(open, close) { + return; + } + + self.line_buffer.delete_right_grapheme(); + self.line_buffer.delete_left_grapheme(); + } + fn insert_char(&mut self, c: char) { self.delete_selection(); self.line_buffer.insert_char(c); diff --git a/src/engine.rs b/src/engine.rs index 81bb098af..aa94ecd22 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -110,6 +110,36 @@ impl MouseClickMode { } } +/// Configuration for automatic pair insertion. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct AutoPairs { + pairs: Vec<(char, char)>, +} + +impl AutoPairs { + /// Create automatic pair insertion configuration from `(open, close)` pairs. + pub fn new(pairs: I) -> Self + where + I: IntoIterator, + { + Self { + pairs: pairs.into_iter().collect(), + } + } + + fn opening_pair(&self, ch: char) -> Option<(char, char)> { + self.pairs.iter().find(|pair| pair.0 == ch).copied() + } + + fn closing_pair(&self, ch: char) -> Option<(char, char)> { + self.pairs.iter().find(|pair| pair.1 == ch).copied() + } + + fn pairs(&self) -> impl Iterator + '_ { + self.pairs.iter().copied() + } +} + /// Line editor engine /// /// ## Example usage @@ -177,6 +207,9 @@ pub struct Reedline { // Use ansi coloring or not use_ansi_coloring: bool, + // Automatically insert and manage configured character pairs. + auto_pairs: Option, + // Whether to enable mouse click-to-cursor functionality mouse_click_mode: MouseClickMode, @@ -289,6 +322,7 @@ impl Reedline { hide_hints: false, validator, use_ansi_coloring: true, + auto_pairs: None, mouse_click_mode: MouseClickMode::default(), cwd: None, menus: Vec::new(), @@ -450,6 +484,20 @@ impl Reedline { self } + /// A builder that configures automatic pair insertion for the Reedline engine. + #[must_use] + pub fn with_auto_pairs(mut self, auto_pairs: AutoPairs) -> Self { + self.auto_pairs = Some(auto_pairs); + self + } + + /// Disable automatic pair insertion. + #[must_use] + pub fn disable_auto_pairs(mut self) -> Self { + self.auto_pairs = None; + self + } + /// Configure mouse click-to-cursor support. /// /// Use [`MouseClickMode::Enabled`] to handle click events when your host shell @@ -1774,10 +1822,38 @@ impl Reedline { // Run the commands over the edit buffer for command in commands { + if let Some(command) = self.auto_pair_command(command) { + self.editor.run_edit_command(&command); + continue; + } + self.editor.run_edit_command(command); } } + fn auto_pair_command(&self, command: &EditCommand) -> Option { + let auto_pairs = self.auto_pairs.as_ref()?; + + match command { + EditCommand::InsertChar(ch) => { + if let Some((_, close)) = auto_pairs.closing_pair(*ch) { + if self.editor.is_auto_pair_closer_at_cursor(close) { + return Some(EditCommand::MoveRight { select: false }); + } + } + + auto_pairs + .opening_pair(*ch) + .map(|(left, right)| EditCommand::InsertPair { left, right }) + } + EditCommand::Backspace => auto_pairs + .pairs() + .find(|(left, right)| self.editor.is_empty_auto_pair_at_cursor(*left, *right)) + .map(|(left, right)| EditCommand::BackspacePair { left, right }), + _ => None, + } + } + fn up_command(&mut self) { // If we're at the top, then: if self.editor.is_cursor_at_first_line() { @@ -2404,6 +2480,115 @@ mod tests { } } + fn auto_pair_engine(pairs: &[(char, char)]) -> Reedline { + Reedline::create().with_auto_pairs(AutoPairs::new(pairs.iter().copied())) + } + + #[test] + fn auto_pairs_disabled_keeps_literal_typing() { + let mut rl = Reedline::create(); + rl.run_edit_commands(&[EditCommand::InsertChar('(')]); + + assert_eq!(rl.editor.get_buffer(), "("); + assert_eq!(rl.editor.insertion_point(), 1); + } + + #[test] + fn auto_pairs_ignore_unconfigured_openers() { + let mut rl = auto_pair_engine(&[('[', ']')]); + rl.run_edit_commands(&[EditCommand::InsertChar('(')]); + + assert_eq!(rl.editor.get_buffer(), "("); + assert_eq!(rl.editor.insertion_point(), 1); + } + + #[test] + fn auto_pairs_insert_pair_and_continue_inside() { + let mut rl = auto_pair_engine(&[('(', ')')]); + rl.run_edit_commands(&[EditCommand::InsertChar('(')]); + + assert_eq!(rl.editor.get_buffer(), "()"); + assert_eq!(rl.editor.insertion_point(), 1); + + rl.run_edit_commands(&[EditCommand::InsertChar('a')]); + + assert_eq!(rl.editor.get_buffer(), "(a)"); + assert_eq!(rl.editor.insertion_point(), 2); + } + + #[test] + fn auto_pairs_skip_existing_closer() { + let mut rl = auto_pair_engine(&[('(', ')')]); + rl.run_edit_commands(&[EditCommand::InsertChar('('), EditCommand::InsertChar(')')]); + + assert_eq!(rl.editor.get_buffer(), "()"); + assert_eq!(rl.editor.insertion_point(), 2); + } + + #[test] + fn auto_pairs_backspace_removes_empty_pair_as_one_undo_step() { + let mut rl = auto_pair_engine(&[('(', ')')]); + rl.run_edit_commands(&[EditCommand::InsertChar('(')]); + + rl.run_edit_commands(&[EditCommand::Backspace]); + + assert_eq!(rl.editor.get_buffer(), ""); + assert_eq!(rl.editor.insertion_point(), 0); + + rl.run_edit_commands(&[EditCommand::Undo]); + + assert_eq!(rl.editor.get_buffer(), "()"); + assert_eq!(rl.editor.insertion_point(), 1); + } + + #[test] + fn auto_pairs_wrap_selection_with_opener() { + let mut rl = auto_pair_engine(&[('(', ')')]); + rl.run_edit_commands(&[ + EditCommand::InsertString("abc".into()), + EditCommand::MoveToStart { select: false }, + EditCommand::MoveRight { select: true }, + EditCommand::MoveRight { select: true }, + EditCommand::MoveRight { select: true }, + EditCommand::InsertChar('('), + ]); + + assert_eq!(rl.editor.get_buffer(), "(abc)"); + assert_eq!(rl.editor.get_selection(), None); + assert_eq!(rl.editor.insertion_point(), 5); + } + + #[test] + fn auto_pairs_do_not_rewrite_insert_string() { + let mut rl = auto_pair_engine(&[('(', ')')]); + rl.run_edit_commands(&[EditCommand::InsertString("()".into())]); + + assert_eq!(rl.editor.get_buffer(), "()"); + assert_eq!(rl.editor.insertion_point(), 2); + } + + #[test] + fn auto_pairs_support_same_character_pairs() { + let mut rl = auto_pair_engine(&[('"', '"')]); + rl.run_edit_commands(&[EditCommand::InsertChar('"')]); + + assert_eq!(rl.editor.get_buffer(), "\"\""); + assert_eq!(rl.editor.insertion_point(), 1); + + rl.run_edit_commands(&[EditCommand::InsertChar('"')]); + + assert_eq!(rl.editor.get_buffer(), "\"\""); + assert_eq!(rl.editor.insertion_point(), 2); + + rl.run_edit_commands(&[ + EditCommand::MoveLeft { select: false }, + EditCommand::Backspace, + ]); + + assert_eq!(rl.editor.get_buffer(), ""); + assert_eq!(rl.editor.insertion_point(), 0); + } + // FLIP SAFETY NET (Group C) — visual operability at the engine seam. // RED until the cursor-as-truth flip: `v` emits Esc which clears the // selection, so visual mode starts anchorless and `d` cuts nothing. The diff --git a/src/enums.rs b/src/enums.rs index b774a08b0..9a9ce375c 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -390,6 +390,14 @@ pub enum EditCommand { /// Insert a string at the current insertion point InsertString(String), + /// Insert a character pair around the selection or insertion point + InsertPair { + /// Left character of the pair + left: char, + /// Right character of the pair + right: char, + }, + /// Inserts the system specific new line character /// /// - On Unix systems LF (`"\n"`) @@ -417,6 +425,14 @@ pub enum EditCommand { /// Backspace delete from the current insertion point Backspace, + /// Backspace delete an empty character pair + BackspacePair { + /// Left character of the pair + left: char, + /// Right character of the pair + right: char, + }, + /// Delete in-place from the current insertion point Delete, @@ -743,9 +759,11 @@ impl EditCommand { // Text edits EditCommand::InsertChar(_) | EditCommand::Backspace + | EditCommand::BackspacePair { .. } | EditCommand::Delete | EditCommand::CutChar | EditCommand::InsertString(_) + | EditCommand::InsertPair { .. } | EditCommand::InsertNewline | EditCommand::InsertNewlineAbove | EditCommand::InsertNewlineBelow diff --git a/src/lib.rs b/src/lib.rs index 41601664b..5c0bc31ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -242,7 +242,7 @@ mod painting; pub use painting::{Painter, StyledText}; mod engine; -pub use engine::{MouseClickMode, Reedline}; +pub use engine::{AutoPairs, MouseClickMode, Reedline}; mod result; pub use result::{ReedlineError, ReedlineErrorVariants, Result};