Skip to content

Commit 7f99c6b

Browse files
authored
feat: add support for fish-like abbreviations (#1060)
* feat: add support for fish-like abbreviations * feat(editor): detect if cursor is in unclosed string * refactor: move is_inside_string_literal check up * refactor(engine): prefer String over Vec<u8> * add example * fix: use byte offset and don't expand on empty buf * tests: add tests for abbreviation expansion and quote parsing * tests: add string lit tests
1 parent fc3a274 commit 7f99c6b

3 files changed

Lines changed: 302 additions & 1 deletion

File tree

examples/abbreviations.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Create a default reedline object to handle user input
2+
// cargo run --example abbreviations
3+
//
4+
// You can browse the local (non persistent) history using Up/Down or Ctrl n/p.
5+
6+
use reedline::{DefaultPrompt, Reedline, Signal};
7+
use std::{collections::HashMap, io};
8+
9+
fn main() -> io::Result<()> {
10+
let mut abbrevs = HashMap::new();
11+
abbrevs.insert(String::from("ll"), String::from("ls -l"));
12+
abbrevs.insert(String::from("gs"), String::from("git status"));
13+
// Create a new Reedline engine with a local History that is not synchronized to a file and our
14+
// abbreviations
15+
let mut line_editor = Reedline::create().with_abbreviations(abbrevs);
16+
let prompt = DefaultPrompt::default();
17+
18+
loop {
19+
let sig = line_editor.read_line(&prompt)?;
20+
match sig {
21+
Signal::Success(buffer) => {
22+
println!("We processed: {buffer}");
23+
}
24+
Signal::CtrlD | Signal::CtrlC => {
25+
println!("\nAborted!");
26+
break Ok(());
27+
}
28+
_ => {}
29+
}
30+
}
31+
}

src/core_editor/editor.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,53 @@ impl Editor {
273273
self.line_buffer.get_buffer()
274274
}
275275

276+
/// Check if `position` (a byte offset) is inside an unclosed string literal.
277+
pub fn is_inside_string_literal(&self, position: usize) -> bool {
278+
let buffer = self.get_buffer();
279+
280+
if buffer.is_empty() || position == 0 {
281+
return false;
282+
}
283+
if !buffer.contains('"') && !buffer.contains('\'') {
284+
return false;
285+
}
286+
287+
let bytes = buffer.as_bytes();
288+
let mut in_single_quote = false;
289+
let mut in_double_quote = false;
290+
let mut escaped = false;
291+
let mut byte_pos = 0;
292+
293+
for &byte in bytes {
294+
if byte_pos > position {
295+
break;
296+
}
297+
298+
if escaped {
299+
escaped = false;
300+
byte_pos += 1;
301+
continue;
302+
}
303+
304+
match byte {
305+
b'\\' => {
306+
escaped = true;
307+
}
308+
b'\'' if !in_double_quote => {
309+
in_single_quote = !in_single_quote;
310+
}
311+
b'"' if !in_single_quote => {
312+
in_double_quote = !in_double_quote;
313+
}
314+
_ => {}
315+
}
316+
317+
byte_pos += 1;
318+
}
319+
320+
in_single_quote || in_double_quote
321+
}
322+
276323
/// Edit the [`LineBuffer`] in an undo-safe manner.
277324
pub fn edit_buffer<F>(&mut self, func: F, undo_behavior: UndoBehavior)
278325
where
@@ -2168,4 +2215,55 @@ mod test {
21682215
assert_eq!(bracket_result, expected_bracket);
21692216
assert_eq!(quote_result, expected_quote);
21702217
}
2218+
2219+
#[rstest]
2220+
// Not inside any string
2221+
#[case("hello world", 5, false)]
2222+
#[case("", 0, false)]
2223+
#[case("no quotes here", 0, false)]
2224+
// Closed double-quoted string — position is after the closing quote
2225+
#[case(r#""hello" world"#, 8, false)]
2226+
// Inside an unclosed double-quoted string
2227+
#[case(r#""hello world"#, 5, true)]
2228+
// Closed double-quoted string — position is inside it
2229+
#[case(r#""hello" world"#, 3, true)]
2230+
// Inside an unclosed single-quoted string
2231+
#[case("'hello world", 5, true)]
2232+
// Closed single-quoted string
2233+
#[case("'hello' world", 8, false)]
2234+
// Escaped quote does not open/close a string
2235+
#[case(r#"say \"hello"#, 6, false)]
2236+
// Escaped quote inside an open string — still inside
2237+
#[case(r#""say \"hello"#, 9, true)]
2238+
// Mixed: single inside double (single quote is literal)
2239+
#[case(r#""it's fine""#, 5, true)]
2240+
// Mixed: double inside single (double quote is literal)
2241+
#[case("'say \"hi\"'", 6, true)]
2242+
// Position 0 is never inside a string
2243+
#[case(r#""open"#, 0, false)]
2244+
// Cyrillic (2-byte UTF-8 chars): char boundary positions
2245+
#[case("Сегодня хороший день.", 14, false)]
2246+
#[case("Сегодня 'хороший' день.", 16, true)]
2247+
#[case("Сегодня 'хороший' день.", 31, false)]
2248+
#[case("'Сегодня хороший день.", 2, true)]
2249+
// Emoji (4-byte UTF-8 chars): char boundary positions
2250+
#[case("this is fire 🔥", 3, false)]
2251+
#[case("'hello there' 👋🏼", 13, false)]
2252+
#[case("'this is fire 🔥", 14, true)]
2253+
#[case("'hello there 👋🏼", 13, true)]
2254+
// Japanese (3-byte UTF-8 chars): char boundary and mid-char positions
2255+
#[case("今日はいい日だ。", 6, false)]
2256+
#[case("'今日はいい日だ。", 6, true)]
2257+
#[case("'今日はいい日だ。", 7, true)]
2258+
// Mid-char byte positions within multi-byte sequences are also handled correctly
2259+
#[case("Сегодня 'хороший' день.", 19, true)]
2260+
#[case("'hello there 👋🏼", 14, true)]
2261+
fn test_is_inside_string_literal(
2262+
#[case] buffer: &str,
2263+
#[case] position: usize,
2264+
#[case] expected: bool,
2265+
) {
2266+
let editor = editor_with(buffer);
2267+
assert_eq!(editor.is_inside_string_literal(position), expected);
2268+
}
21712269
}

src/engine.rs

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::path::PathBuf;
1+
use std::{collections::HashMap, path::PathBuf};
22

33
use itertools::Itertools;
44
use nu_ansi_term::{Color, Style};
@@ -187,6 +187,8 @@ pub struct Reedline {
187187
// Engine Menus
188188
menus: Vec<ReedlineMenu>,
189189

190+
abbreviations: HashMap<String, String>,
191+
190192
// Text editor used to open the line buffer for editing
191193
buffer_editor: Option<BufferEditor>,
192194

@@ -287,6 +289,7 @@ impl Reedline {
287289
mouse_click_mode: MouseClickMode::default(),
288290
cwd: None,
289291
menus: Vec::new(),
292+
abbreviations: HashMap::new(),
290293
buffer_editor: None,
291294
cursor_shapes: None,
292295
bracketed_paste: BracketedPasteGuard::default(),
@@ -619,6 +622,14 @@ impl Reedline {
619622
self
620623
}
621624

625+
/// A builder that adds abbreviations to the Reedline engine
626+
///
627+
/// Overwrites any existing abbreviations with the same key.
628+
pub fn with_abbreviations(mut self, abbreviations: HashMap<String, String>) -> Self {
629+
self.abbreviations.extend(abbreviations);
630+
self
631+
}
632+
622633
/// A builder that adds the history item id
623634
#[must_use]
624635
pub fn with_history_session_id(mut self, session: Option<HistorySessionId>) -> Self {
@@ -1278,6 +1289,9 @@ impl Reedline {
12781289
if let Some(event) = self.parse_bang_command() {
12791290
return self.handle_editor_event(prompt, event);
12801291
}
1292+
if let Some(event) = self.try_expand_abbreviation_at_cursor(true) {
1293+
return self.handle_editor_event(prompt, event);
1294+
}
12811295

12821296
let buffer = self.editor.get_buffer().to_string();
12831297
match self.validator.as_mut().map(|v| v.validate(&buffer)) {
@@ -1294,13 +1308,21 @@ impl Reedline {
12941308
if let Some(event) = self.parse_bang_command() {
12951309
return self.handle_editor_event(prompt, event);
12961310
}
1311+
if let Some(event) = self.try_expand_abbreviation_at_cursor(true) {
1312+
return self.handle_editor_event(prompt, event);
1313+
}
1314+
12971315
Ok(self.submit_buffer(prompt)?)
12981316
}
12991317
ReedlineEvent::SubmitOrNewline => {
13001318
#[cfg(feature = "bashisms")]
13011319
if let Some(event) = self.parse_bang_command() {
13021320
return self.handle_editor_event(prompt, event);
13031321
}
1322+
if let Some(event) = self.try_expand_abbreviation_at_cursor(true) {
1323+
return self.handle_editor_event(prompt, event);
1324+
}
1325+
13041326
let cursor_position_in_buffer = self.editor.insertion_point();
13051327
let buffer = self.editor.get_buffer().to_string();
13061328
if cursor_position_in_buffer < buffer.len() {
@@ -1322,6 +1344,12 @@ impl Reedline {
13221344
Ok(EventStatus::Exits(Signal::HostCommand(host_command)))
13231345
}
13241346
ReedlineEvent::Edit(commands) => {
1347+
// Check if a space was just inserted and try to expand abbreviations
1348+
if let Some(EditCommand::InsertChar(' ')) = commands.first() {
1349+
if let Some(event) = self.try_expand_abbreviation_at_cursor(false) {
1350+
return self.handle_editor_event(prompt, event);
1351+
}
1352+
}
13251353
self.run_edit_commands(&commands);
13261354
if let Some(menu) = self.menus.iter_mut().find(|men| men.is_active()) {
13271355
if self.quick_completions && menu.can_quick_complete() {
@@ -1729,6 +1757,56 @@ impl Reedline {
17291757
}
17301758
}
17311759

1760+
/// Expands an abbreviation at the word before the cursor, if any exists
1761+
///
1762+
/// Note, expansion does not occur when inside a string.
1763+
fn try_expand_abbreviation_at_cursor(&mut self, submitted: bool) -> Option<ReedlineEvent> {
1764+
let buffer = self.editor.get_buffer();
1765+
let cursor_position_in_buffer = self.editor.insertion_point();
1766+
if cursor_position_in_buffer == 0 {
1767+
return None;
1768+
}
1769+
1770+
let (offset, suffix) = match submitted {
1771+
true => (0, ""), // expand on <enter>
1772+
false => (1, " "), // expand on <space>
1773+
};
1774+
1775+
let word_end = cursor_position_in_buffer - offset;
1776+
let prefix = &buffer[..word_end];
1777+
let word_start = prefix
1778+
.char_indices()
1779+
.rev()
1780+
.find(|(_, ch)| ch.is_whitespace())
1781+
.map(|(idx, ch)| idx + ch.len_utf8())
1782+
.unwrap_or(0); // byte offset of word start
1783+
1784+
if word_start >= word_end {
1785+
// The first char in the buffer is a space or there are consecutive spaces
1786+
return None;
1787+
}
1788+
if self.editor.is_inside_string_literal(word_start) {
1789+
return None;
1790+
}
1791+
1792+
let word = &buffer[word_start..word_end];
1793+
if let Some(expansion) = self.abbreviations.get(word) {
1794+
return Some(ReedlineEvent::Edit(vec![
1795+
EditCommand::MoveToPosition {
1796+
position: word_start,
1797+
select: false,
1798+
},
1799+
EditCommand::MoveToPosition {
1800+
position: word_end,
1801+
select: true,
1802+
},
1803+
EditCommand::InsertString(format!("{}{}", expansion, suffix)),
1804+
]));
1805+
}
1806+
1807+
None
1808+
}
1809+
17321810
#[cfg(feature = "bashisms")]
17331811
/// Parses the ! command to replace entries from the history
17341812
fn parse_bang_command(&mut self) -> Option<ReedlineEvent> {
@@ -2358,4 +2436,98 @@ mod tests {
23582436
_ => panic!("Expected Signal::ExternalBreak"),
23592437
}
23602438
}
2439+
2440+
fn reedline_with_abbrevs(abbrevs: &[(&str, &str)]) -> Reedline {
2441+
let map = abbrevs
2442+
.iter()
2443+
.map(|(k, v)| (k.to_string(), v.to_string()))
2444+
.collect();
2445+
Reedline::create().with_abbreviations(map)
2446+
}
2447+
2448+
fn set_buffer_at_end(reedline: &mut Reedline, text: &str) {
2449+
reedline.run_edit_commands(&[
2450+
EditCommand::Clear,
2451+
EditCommand::InsertString(text.to_string()),
2452+
]);
2453+
}
2454+
2455+
#[test]
2456+
fn abbreviation_expands_on_submit() {
2457+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2458+
set_buffer_at_end(&mut reedline, "gc");
2459+
let event = reedline.try_expand_abbreviation_at_cursor(true);
2460+
assert!(event.is_some(), "expected expansion on submit");
2461+
reedline.run_edit_commands(&match event.unwrap() {
2462+
ReedlineEvent::Edit(cmds) => cmds,
2463+
_ => panic!("expected Edit event"),
2464+
});
2465+
assert_eq!(reedline.current_buffer_contents(), "git commit");
2466+
}
2467+
2468+
#[test]
2469+
fn abbreviation_no_match_returns_none() {
2470+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2471+
set_buffer_at_end(&mut reedline, "gx");
2472+
assert!(reedline.try_expand_abbreviation_at_cursor(true).is_none());
2473+
}
2474+
2475+
#[test]
2476+
fn abbreviation_empty_buffer_returns_none() {
2477+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2478+
assert!(reedline.try_expand_abbreviation_at_cursor(true).is_none());
2479+
}
2480+
2481+
#[test]
2482+
fn abbreviation_expands_last_word_only() {
2483+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2484+
set_buffer_at_end(&mut reedline, "sudo gc");
2485+
let event = reedline.try_expand_abbreviation_at_cursor(true);
2486+
assert!(event.is_some());
2487+
reedline.run_edit_commands(&match event.unwrap() {
2488+
ReedlineEvent::Edit(cmds) => cmds,
2489+
_ => panic!("expected Edit event"),
2490+
});
2491+
assert_eq!(reedline.current_buffer_contents(), "sudo git commit");
2492+
}
2493+
2494+
#[test]
2495+
fn abbreviation_no_expansion_inside_double_quoted_string() {
2496+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2497+
set_buffer_at_end(&mut reedline, "\"gc");
2498+
assert!(
2499+
reedline.try_expand_abbreviation_at_cursor(true).is_none(),
2500+
"must not expand inside an unclosed double-quoted string"
2501+
);
2502+
}
2503+
2504+
#[test]
2505+
fn abbreviation_no_expansion_inside_single_quoted_string() {
2506+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2507+
set_buffer_at_end(&mut reedline, "'gc");
2508+
assert!(
2509+
reedline.try_expand_abbreviation_at_cursor(true).is_none(),
2510+
"must not expand inside an unclosed single-quoted string"
2511+
);
2512+
}
2513+
2514+
#[test]
2515+
fn abbreviation_non_ascii_key_and_expansion() {
2516+
let mut reedline = reedline_with_abbrevs(&[("café", "coffee shop")]);
2517+
set_buffer_at_end(&mut reedline, "café");
2518+
let event = reedline.try_expand_abbreviation_at_cursor(true);
2519+
assert!(event.is_some(), "expected expansion for non-ASCII key");
2520+
reedline.run_edit_commands(&match event.unwrap() {
2521+
ReedlineEvent::Edit(cmds) => cmds,
2522+
_ => panic!("expected Edit event"),
2523+
});
2524+
assert_eq!(reedline.current_buffer_contents(), "coffee shop");
2525+
}
2526+
2527+
#[test]
2528+
fn abbreviation_leading_spaces_returns_none() {
2529+
let mut reedline = reedline_with_abbrevs(&[("gc", "git commit")]);
2530+
set_buffer_at_end(&mut reedline, " ");
2531+
assert!(reedline.try_expand_abbreviation_at_cursor(true).is_none());
2532+
}
23612533
}

0 commit comments

Comments
 (0)