Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions examples/auto_pairs.rs
Original file line number Diff line number Diff line change
@@ -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(());
}
_ => {}
}
}
}
38 changes: 38 additions & 0 deletions src/core_editor/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down
185 changes: 185 additions & 0 deletions src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<I>(pairs: I) -> Self
where
I: IntoIterator<Item = (char, char)>,
{
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<Item = (char, char)> + '_ {
self.pairs.iter().copied()
}
}

/// Line editor engine
///
/// ## Example usage
Expand Down Expand Up @@ -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<AutoPairs>,

// Whether to enable mouse click-to-cursor functionality
mouse_click_mode: MouseClickMode,

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<EditCommand> {
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() {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/enums.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"`)
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Loading