diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 0d7673ba..d6e063fa 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -273,6 +273,59 @@ impl Editor { self.line_buffer.get_buffer() } + /// Check if a position in the buffer is inside an unclosed string literal + pub fn is_inside_string_literal(&self, position: usize) -> bool { + let buffer = self.get_buffer(); + + if buffer.is_empty() || position == 0 { + return false; + } + if !buffer.contains('"') && !buffer.contains('\'') { + return false; + } + + let target_byte_pos = buffer + .char_indices() + .nth(position) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(buffer.len()); + + let bytes = buffer.as_bytes(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + let mut byte_pos = 0; + + for &byte in bytes { + if byte_pos > target_byte_pos { + break; + } + + if escaped { + escaped = false; + byte_pos += 1; + continue; + } + + match byte { + b'\\' => { + escaped = true; + } + b'\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + b'"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + _ => {} + } + + byte_pos += 1; + } + + in_single_quote || in_double_quote + } + /// Edit the [`LineBuffer`] in an undo-safe manner. pub fn edit_buffer(&mut self, func: F, undo_behavior: UndoBehavior) where diff --git a/src/engine.rs b/src/engine.rs index ac7ccf84..470679c7 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; use itertools::Itertools; use nu_ansi_term::{Color, Style}; @@ -187,6 +187,8 @@ pub struct Reedline { // Engine Menus menus: Vec, + abbreviations: HashMap, + // Text editor used to open the line buffer for editing buffer_editor: Option, @@ -287,6 +289,7 @@ impl Reedline { mouse_click_mode: MouseClickMode::default(), cwd: None, menus: Vec::new(), + abbreviations: HashMap::new(), buffer_editor: None, cursor_shapes: None, bracketed_paste: BracketedPasteGuard::default(), @@ -619,6 +622,14 @@ impl Reedline { self } + /// A builder that adds abbreviations to the Reedline engine + /// + /// Overwrites any existing abbreviations with the same key. + pub fn with_abbreviations(mut self, abbreviations: HashMap) -> Self { + self.abbreviations.extend(abbreviations); + self + } + /// A builder that adds the history item id #[must_use] pub fn with_history_session_id(mut self, session: Option) -> Self { @@ -1278,6 +1289,9 @@ impl Reedline { if let Some(event) = self.parse_bang_command() { return self.handle_editor_event(prompt, event); } + if let Some(event) = self.try_expand_abbreviation_at_cursor(true) { + return self.handle_editor_event(prompt, event); + } let buffer = self.editor.get_buffer().to_string(); match self.validator.as_mut().map(|v| v.validate(&buffer)) { @@ -1294,6 +1308,10 @@ impl Reedline { if let Some(event) = self.parse_bang_command() { return self.handle_editor_event(prompt, event); } + if let Some(event) = self.try_expand_abbreviation_at_cursor(true) { + return self.handle_editor_event(prompt, event); + } + Ok(self.submit_buffer(prompt)?) } ReedlineEvent::SubmitOrNewline => { @@ -1301,6 +1319,10 @@ impl Reedline { if let Some(event) = self.parse_bang_command() { return self.handle_editor_event(prompt, event); } + if let Some(event) = self.try_expand_abbreviation_at_cursor(true) { + return self.handle_editor_event(prompt, event); + } + let cursor_position_in_buffer = self.editor.insertion_point(); let buffer = self.editor.get_buffer().to_string(); if cursor_position_in_buffer < buffer.len() { @@ -1323,6 +1345,13 @@ impl Reedline { } ReedlineEvent::Edit(commands) => { self.run_edit_commands(&commands); + + // Check if a space was just inserted and try to expand abbreviations + if let Some(EditCommand::InsertChar(' ')) = commands.first() { + if let Some(event) = self.try_expand_abbreviation_at_cursor(false) { + return self.handle_editor_event(prompt, event); + } + } if let Some(menu) = self.menus.iter_mut().find(|men| men.is_active()) { if self.quick_completions && menu.can_quick_complete() { match commands.first() { @@ -1729,6 +1758,51 @@ impl Reedline { } } + /// Expands an abbreviation at the word before the cursor, if any exists + /// + /// Note, expansion does not occur when inside a string. + fn try_expand_abbreviation_at_cursor(&mut self, submitted: bool) -> Option { + let buffer = self.editor.get_buffer(); + let cursor_position_in_buffer = self.editor.insertion_point(); + + let chars: Vec = buffer.chars().collect(); + let (offset, suffix) = match submitted { + true => (0, ""), // expand on + false => (1, " "), // expand on + }; + + let mut word_start = cursor_position_in_buffer - 1; + while word_start > 0 && !chars[word_start - 1].is_whitespace() { + word_start -= 1; + } + let word_end = cursor_position_in_buffer - offset; + + if word_start >= word_end { + // The first char in the buffer is a space or there are consecutive spaces + return None; + } + if self.editor.is_inside_string_literal(word_start) { + return None; + } + + let word: String = chars[word_start..word_end].iter().collect(); + if let Some(expansion) = self.abbreviations.get(&word) { + return Some(ReedlineEvent::Edit(vec![ + EditCommand::MoveToPosition { + position: word_start, + select: false, + }, + EditCommand::MoveToPosition { + position: word_end, + select: true, + }, + EditCommand::InsertString(format!("{}{}", expansion, suffix)), + ])); + } + + None + } + #[cfg(feature = "bashisms")] /// Parses the ! command to replace entries from the history fn parse_bang_command(&mut self) -> Option {