diff --git a/Cargo.lock b/Cargo.lock index c9e9adbf..eff47941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.153" @@ -700,8 +706,10 @@ dependencies = [ "fd-lock", "gethostname", "itertools", + "lazy_static", "nu-ansi-term", "pretty_assertions", + "regex", "rstest", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5c387c50..04dffb2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,9 @@ crossbeam = { version = "0.8.2", optional = true } crossterm = { version = "0.28.1", features = ["serde"] } fd-lock = "4.0.2" itertools = "0.13.0" +lazy_static = "1.5.0" nu-ansi-term = "0.50.0" +regex = "1.11" rusqlite = { version = "0.31.0", optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0.79", optional = true } diff --git a/examples/fuzzy_completions.rs b/examples/fuzzy_completions.rs new file mode 100644 index 00000000..7a2fb5a6 --- /dev/null +++ b/examples/fuzzy_completions.rs @@ -0,0 +1,138 @@ +// Modifies the completions example to demonstrate highlighting of fuzzy completions +// cargo run --example fuzzy_completions +// +// One of the suggestions is "multiple 汉 by̆tes字👩🏾". Try typing in "y" or "👩" and note how +// the entire grapheme "y̆" or "👩🏾" is highlighted (might not look right in your terminal). + +use nu_ansi_term::{Color, Style}; +use reedline::{ + default_emacs_keybindings, ColumnarMenu, Completer, DefaultPrompt, EditCommand, Emacs, KeyCode, + KeyModifiers, Keybindings, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, + Suggestion, +}; +use std::io; +use unicode_segmentation::UnicodeSegmentation; + +struct HomegrownFuzzyCompleter(Vec<String>); + +impl Completer for HomegrownFuzzyCompleter { + fn complete(&mut self, line: &str, pos: usize) -> Vec<reedline::Suggestion> { + // Grandma's fuzzy matching recipe. She swears it's better than that crates.io-bought stuff + self.0 + .iter() + .filter_map(|command_str| { + let command = command_str.graphemes(true).collect::<Vec<_>>(); + let mut ind = 0; + let mut match_indices = Vec::new(); + for g in line[..pos].graphemes(true) { + while ind < command.len() && command[ind] != g { + ind += 1; + } + if ind == command.len() { + return None; + } + match_indices.push(ind); + ind += 1; + } + + Some(Suggestion { + value: command_str.to_string(), + description: None, + style: None, + extra: None, + span: Span::new(0, pos), + append_whitespace: false, + match_indices: Some(match_indices), + }) + }) + .collect() + } +} + +fn add_menu_keybindings(keybindings: &mut Keybindings) { + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), + ); + keybindings.add_binding( + KeyModifiers::ALT, + KeyCode::Enter, + ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), + ); +} + +fn main() -> io::Result<()> { + // Number of columns + let columns: u16 = 4; + // Column width + let col_width: Option<usize> = None; + // Column padding + let col_padding: usize = 2; + + let commands = vec![ + "test".into(), + "clear".into(), + "exit".into(), + "history 1".into(), + "history 2".into(), + "logout".into(), + "login".into(), + "hello world".into(), + "hello world reedline".into(), + "hello world something".into(), + "hello world another".into(), + "hello world 1".into(), + "hello world 2".into(), + "hello another very large option for hello word that will force one column".into(), + "this is the reedline crate".into(), + "abaaabas".into(), + "abaaacas".into(), + "ababac".into(), + "abacaxyc".into(), + "abadarabc".into(), + "multiple 汉 by̆tes字👩🏾".into(), + "ab汉 by̆tes👩🏾".into(), + ]; + + let completer = Box::new(HomegrownFuzzyCompleter(commands)); + + // Use the interactive menu to select options from the completer + let columnar_menu = ColumnarMenu::default() + .with_name("completion_menu") + .with_columns(columns) + .with_column_width(col_width) + .with_column_padding(col_padding) + .with_text_style(Style::new().italic().on(Color::LightGreen)) + .with_match_text_style(Style::new().on(Color::LightBlue)); + + let completion_menu = Box::new(columnar_menu); + + let mut keybindings = default_emacs_keybindings(); + add_menu_keybindings(&mut keybindings); + + let edit_mode = Box::new(Emacs::new(keybindings)); + + let mut line_editor = Reedline::create() + .with_completer(completer) + .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) + .with_edit_mode(edit_mode); + + 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/completion/base.rs b/src/completion/base.rs index 909c467b..bfbec118 100644 --- a/src/completion/base.rs +++ b/src/completion/base.rs @@ -90,4 +90,7 @@ pub struct Suggestion { /// Whether to append a space after selecting this suggestion. /// This helps to avoid that a completer repeats the complete suggestion. pub append_whitespace: bool, + /// Indices of the graphemes in the suggestion that matched the typed text. + /// Useful if using fuzzy matching. + pub match_indices: Option<Vec<usize>>, } diff --git a/src/completion/default.rs b/src/completion/default.rs index 882debb6..a4f4d607 100644 --- a/src/completion/default.rs +++ b/src/completion/default.rs @@ -55,17 +55,17 @@ impl Completer for DefaultCompleter { /// assert_eq!( /// completions.complete("bat",3), /// vec![ - /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, - /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, - /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, + /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, /// ]); /// /// assert_eq!( /// completions.complete("to the\r\nbat",11), /// vec![ - /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, - /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, - /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, + /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, /// ]); /// ``` fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> { @@ -110,6 +110,7 @@ impl Completer for DefaultCompleter { extra: None, span, append_whitespace: false, + ..Default::default() } }) .filter(|t| t.value.len() > (t.span.end - t.span.start)) @@ -182,15 +183,15 @@ impl DefaultCompleter { /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( /// completions.complete("te",2), - /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}]); + /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}]); /// /// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( /// completions.complete("te",2), /// vec![ - /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, - /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, + /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, /// ]); /// ``` pub fn with_inclusions(incl: &[char]) -> Self { @@ -384,6 +385,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "number".into(), @@ -392,6 +394,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "nushell".into(), @@ -400,6 +403,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + ..Default::default() }, ] ); @@ -428,6 +432,7 @@ mod tests { extra: None, span: Span { start: 8, end: 9 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "this is the reedline crate".into(), @@ -436,6 +441,7 @@ mod tests { extra: None, span: Span { start: 8, end: 9 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "this is the reedline crate".into(), @@ -444,6 +450,7 @@ mod tests { extra: None, span: Span { start: 0, end: 9 }, append_whitespace: false, + ..Default::default() }, ] ); diff --git a/src/completion/history.rs b/src/completion/history.rs index a29e2b04..a48cffca 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -65,6 +65,7 @@ impl<'menu> HistoryCompleter<'menu> { extra: None, span, append_whitespace: false, + ..Default::default() } } } diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 8aae2f08..ea1154cd 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -1,7 +1,9 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, - menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, + menu_functions::{ + can_partially_complete, completer_input, replace_in_buffer, style_suggestion, + }, painting::Painter, Completer, Suggestion, }; @@ -307,50 +309,31 @@ impl ColumnarMenu { .unwrap_or(shortest_base); let match_len = shortest_base.len(); - // Find match position - look for the base string in the suggestion (case-insensitive) - let match_position = suggestion - .value - .to_lowercase() - .find(&shortest_base.to_lowercase()) - .unwrap_or(0); - - // The match is just the part that matches the shortest_base - let match_str = &suggestion.value[match_position - ..match_position + match_len.min(suggestion.value.len() - match_position)]; - - // Prefix is everything before the match - let prefix = &suggestion.value[..match_position]; - - // Remaining is everything after the match - let remaining_str = &suggestion.value[match_position + match_str.len()..]; - - let suggestion_style_prefix = suggestion - .style - .unwrap_or(self.settings.color.text_style) - .prefix(); + let suggestion_style = suggestion.style.unwrap_or(self.settings.color.text_style); let left_text_size = self.longest_suggestion + self.default_details.col_padding; let right_text_size = self.get_width().saturating_sub(left_text_size); - let max_remaining = left_text_size.saturating_sub(match_str.width() + prefix.width()); - let max_match = max_remaining.saturating_sub(remaining_str.width()); + let default_indices = (0..match_len).collect(); + let match_indices = suggestion + .match_indices + .as_ref() + .unwrap_or(&default_indices); if index == self.index() { if let Some(description) = &suggestion.description { format!( - "{}{}{}{}{}{}{}{}{}{:max_match$}{:max_remaining$}{}{}{}{}{}{}", - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, - RESET, + "{:left_text_size$}{}{}{}{}{}", + style_suggestion( + &self + .settings + .color + .selected_text_style + .paint(&suggestion.value) + .to_string(), + match_indices, + &self.settings.color.selected_match_style, + ), self.settings.color.description_style.prefix(), self.settings.color.selected_text_style.prefix(), description @@ -363,19 +346,17 @@ impl ColumnarMenu { ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}{:>empty$}{}", - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, - RESET, + "{}{:>empty$}{}", + style_suggestion( + &self + .settings + .color + .selected_text_style + .paint(&suggestion.value) + .to_string(), + match_indices, + &self.settings.color.selected_match_style, + ), "", self.end_of_line(column), empty = empty_space, @@ -383,17 +364,12 @@ impl ColumnarMenu { } } else if let Some(description) = &suggestion.description { format!( - "{}{}{}{}{}{}{}{:max_match$}{:max_remaining$}{}{}{}{}{}", - suggestion_style_prefix, - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, - RESET, + "{:left_text_size$}{}{}{}{}", + style_suggestion( + &suggestion_style.paint(&suggestion.value).to_string(), + match_indices, + &self.settings.color.match_style, + ), self.settings.color.description_style.prefix(), description .chars() @@ -405,17 +381,12 @@ impl ColumnarMenu { ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}{:>empty$}{}{}", - suggestion_style_prefix, - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, - RESET, + "{}{}{:>empty$}{}{}", + style_suggestion( + &suggestion_style.paint(&suggestion.value).to_string(), + match_indices, + &self.settings.color.match_style, + ), self.settings.color.description_style.prefix(), "", RESET, @@ -784,6 +755,7 @@ mod tests { extra: None, span: Span { start: 0, end: pos }, append_whitespace: false, + ..Default::default() } } diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs index 24c82378..a3696538 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -1,7 +1,9 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, - menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, + menu_functions::{ + can_partially_complete, completer_input, replace_in_buffer, style_suggestion, + }, painting::Painter, Completer, Suggestion, }; @@ -520,62 +522,45 @@ impl IdeMenu { .unwrap_or(shortest_base); let match_len = shortest_base.len().min(string.len()); - // Find match position - look for the base string in the suggestion (case-insensitive) - let match_position = suggestion - .value - .to_lowercase() - .find(&shortest_base.to_lowercase()) - .unwrap_or(0); - - // The match is just the part that matches the shortest_base - let match_str = &string - [match_position..match_position + match_len.min(string.len() - match_position)]; - - // Prefix is everything before the match - let prefix = &string[..match_position]; + let default_indices = (0..match_len).collect(); + let match_indices = suggestion + .match_indices + .as_ref() + .unwrap_or(&default_indices); - // Remaining is everything after the match - let remaining_str = &string[match_position + match_str.len()..]; - - let suggestion_style_prefix = suggestion - .style - .unwrap_or(self.settings.color.text_style) - .prefix(); + let suggestion_style = suggestion.style.unwrap_or(self.settings.color.text_style); if index == self.index() { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", vertical_border, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - prefix, - RESET, - suggestion_style_prefix, + suggestion_style.prefix(), " ".repeat(padding), - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, + style_suggestion( + &self + .settings + .color + .selected_text_style + .paint(&suggestion.value) + .to_string(), + match_indices, + &self.settings.color.selected_match_style, + ), " ".repeat(padding_right), RESET, vertical_border, ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", vertical_border, - suggestion_style_prefix, - prefix, - RESET, - suggestion_style_prefix, + suggestion_style.prefix(), " ".repeat(padding), - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, + style_suggestion( + &suggestion_style.paint(&suggestion.value).to_string(), + match_indices, + &self.settings.color.match_style, + ), " ".repeat(padding_right), RESET, vertical_border, @@ -1405,6 +1390,7 @@ mod tests { extra: None, span: Span { start: 0, end: pos }, append_whitespace: false, + ..Default::default() } } diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index 84575f0c..676f7df0 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -1,6 +1,17 @@ //! Collection of common functions that can be used to create menus +use nu_ansi_term::{ansi::RESET, Style}; +use regex::Regex; +use lazy_static::lazy_static; +use unicode_segmentation::UnicodeSegmentation; + use crate::{Editor, Suggestion, UndoBehavior}; +lazy_static! { + /// Matches ANSI escapes. Stolen from https://github.com/dbkaplun/parse-ansi, which got it from + /// https://github.com/nodejs/node/blob/641d4a4159aaa96eece8356e03ec6c7248ae3e73/lib/internal/readline.js#L9 + static ref ANSI_REGEX: Regex = Regex::new(r"[\x1b\x9b]\[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]").unwrap(); +} + /// Index result obtained from parsing a string with an index marker /// For example, the next string: /// "this is an example :10" @@ -354,10 +365,65 @@ pub fn can_partially_complete(values: &[Suggestion], editor: &mut Editor) -> boo } } +/// Style a suggestion to be shown in a completer menu +/// +/// * `match_indices` - Indices of graphemes (NOT bytes or chars) that matched the typed text +/// * `match_style` - Style to use for matched characters +pub fn style_suggestion(suggestion: &str, match_indices: &[usize], match_style: &Style) -> String { + let escapes = ANSI_REGEX.find_iter(suggestion).collect::<Vec<_>>(); + let mut segments = Vec::new(); + if escapes.is_empty() { + segments.push((0, 0, suggestion.len())); + } else if escapes[0].start() > 0 { + segments.push((0, 0, escapes[0].start())); + } + for (i, m) in escapes.iter().enumerate() { + let next = if i + 1 == escapes.len() { + suggestion.len() + } else { + escapes[i + 1].start() + }; + segments.push((m.start(), m.end(), next)); + } + + let mut res = String::new(); + + let mut offset = 0; + for (escape_start, text_start, text_end) in segments { + let escape = &suggestion[escape_start..text_start]; + let text = &suggestion[text_start..text_end]; + let graphemes = text.graphemes(true).collect::<Vec<_>>(); + let mut prev_matched = false; + + res.push_str(escape); + for (i, grapheme) in graphemes.iter().enumerate() { + let is_match = match_indices.contains(&(i + offset)); + + if is_match && !prev_matched { + res.push_str(&match_style.prefix().to_string()); + } else if !is_match && prev_matched && i != 0 { + res.push_str(RESET); + res.push_str(escape); + } + res.push_str(grapheme); + prev_matched = is_match; + } + + if prev_matched { + res.push_str(RESET); + } + + offset += graphemes.len(); + } + + res +} + #[cfg(test)] mod tests { use super::*; use crate::{EditCommand, LineBuffer, Span}; + use nu_ansi_term::Color; use rstest::rstest; #[test] @@ -612,6 +678,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + ..Default::default() }) .collect(); let res = find_common_string(&input); @@ -632,6 +699,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + ..Default::default() }) .collect(); let res = find_common_string(&input); @@ -687,6 +755,7 @@ mod tests { extra: None, span: Span::new(start, end), append_whitespace: false, + ..Default::default() }), &mut editor, ); @@ -697,4 +766,52 @@ mod tests { assert_eq!(orig_buffer, editor.get_buffer()); assert_eq!(orig_insertion_point, editor.insertion_point()); } + + #[test] + fn parse_ansi() { + assert_eq!( + ANSI_REGEX + .find_iter("before \x1b[31;4mred underline\x1b[0m after") + .map(|m| (m.start(), m.end(), m.as_str())) + .collect::<Vec<_>>(), + vec![(7, 14, "\x1b[31;4m"), (27, 31, "\x1b[0m")] + ); + } + + #[test] + fn style_fuzzy_suggestion() { + let match_style = Style::new().underline(); + let style1 = Style::new().on(Color::Blue); + let style2 = Style::new().on(Color::Green); + + let expected = format!( + "{}{}{}{}{}{}{}{}{}{}{}{}{}", + style1.prefix(), + "ab", + match_style.paint("汉"), + style1.prefix(), + "d", + RESET, + style2.prefix(), + match_style.paint("y̆👩🏾"), + style2.prefix(), + "e", + RESET, + "b@", + match_style.paint("r"), + ); + let match_indices = &[ + 2, // 汉 + 4, 5, // y̆👩🏾 + 9, // r + ]; + assert_eq!( + expected, + style_suggestion( + &format!("{}{}{}", style1.paint("ab汉d"), style2.paint("y̆👩🏾e"), "b@r"), + match_indices, + &match_style + ) + ); + } }