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); + +impl Completer for HomegrownFuzzyCompleter { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + // 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::>(); + 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 = 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>, } 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 { @@ -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 42c0ddf3..d45b2c2f 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -1,13 +1,17 @@ +use std::borrow::Cow; + use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{ can_partially_complete, completer_input, floor_char_boundary, replace_in_buffer, + style_suggestion, }, painting::Painter, Completer, Suggestion, }; use nu_ansi_term::ansi::RESET; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; /// The traversal direction of the menu @@ -377,136 +381,70 @@ impl ColumnarMenu { &self, suggestion: &Suggestion, index: usize, - empty_space: usize, use_ansi_coloring: bool, ) -> String { + let selected = index == self.index(); + let empty_space = self.get_width().saturating_sub(suggestion.value.width()); + if use_ansi_coloring { - // strip quotes + // TODO(ysthakur): let the user strip quotes, rather than doing it here let is_quote = |c: char| "`'\"".contains(c); let shortest_base = &self.working_details.shortest_base_string; let shortest_base = shortest_base .strip_prefix(is_quote) .unwrap_or(shortest_base); - let match_len = shortest_base.chars().count(); - // Find match position - look for the base string in the suggestion (case-insensitive) - let match_position = suggestion + let match_indices = if let Some(match_indices) = &suggestion.match_indices { + Cow::Borrowed(match_indices) + } else if let Some(match_pos) = 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 = { - let match_str = &suggestion.value[match_position..]; - let match_len_bytes = match_str - .char_indices() - .nth(match_len) - .map(|(i, _)| i) - .unwrap_or_else(|| match_str.len()); - &suggestion.value[match_position..match_position + match_len_bytes] + { + // Highlight matched substring if one is found + let match_len = shortest_base.graphemes(true).count(); + Cow::Owned((match_pos..match_pos + match_len).collect()) + } else { + // Don't highlight anything if no match + Cow::Owned(vec![]) }; - // 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 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 description_size = self.get_width().saturating_sub(left_text_size); + let padding = left_text_size.saturating_sub(suggestion.value.len()); - if index == self.index() { - if let Some(description) = &suggestion.description { + let value_style = if selected { + &self.settings.color.selected_text_style + } else { + &suggestion.style.unwrap_or(self.settings.color.text_style) + }; + let styled_value = style_suggestion( + value_style.paint(&suggestion.value).as_str(), + match_indices.as_ref(), + &self.settings.color.match_style, + ); + + if let Some(description) = &suggestion.description { + let styled_desc = self.settings.color.description_style.paint( + description + .chars() + .take(description_size) + .collect::() + .replace('\n', " "), + ); + if selected { 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, + "{}{}{}{}", + styled_value, + " ".repeat(padding), self.settings.color.selected_text_style.prefix(), - remaining_str, - RESET, - self.settings.color.description_style.prefix(), - self.settings.color.selected_text_style.prefix(), - description - .chars() - .take(right_text_size) - .collect::() - .replace('\n', " "), - RESET, + styled_desc, ) } 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 = empty_space, - ) + format!("{}{}{}", styled_value, " ".repeat(padding), styled_desc) } - } 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, - self.settings.color.description_style.prefix(), - description - .chars() - .take(right_text_size) - .collect::() - .replace('\n', " "), - RESET, - ) } 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, - self.settings.color.description_style.prefix(), - "", - RESET, - empty = empty_space, - ) + format!("{}{:>empty$}", styled_value, "", empty = empty_space,) } } else { // If no ansi coloring is found, then the selection word is the line in uppercase @@ -538,7 +476,7 @@ impl ColumnarMenu { ) }; - if index == self.index() { + if selected { line.to_uppercase() } else { line @@ -789,14 +727,7 @@ impl Menu for ColumnarMenu { .step_by(num_rows) .take(self.get_cols().into()) .map(|(index, suggestion)| { - let empty_space = - self.get_width().saturating_sub(suggestion.value.width()); - self.create_string( - suggestion, - index, - empty_space, - use_ansi_coloring, - ) + self.create_string(suggestion, index, use_ansi_coloring) }) .collect(); menu_string.push_str(&row_string); @@ -817,8 +748,6 @@ impl Menu for ColumnarMenu { // Correcting the enumerate index based on the number of skipped values let index = index + skip_values; let column = index % self.get_cols() as usize; - let empty_space = - self.get_width().saturating_sub(suggestion.value.width()); let end_of_line = if column == self.get_cols().saturating_sub(1) as usize { @@ -828,12 +757,7 @@ impl Menu for ColumnarMenu { }; format!( "{}{}", - self.create_string( - suggestion, - index, - empty_space, - use_ansi_coloring - ), + self.create_string(suggestion, index, use_ansi_coloring), end_of_line ) }) @@ -931,6 +855,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 6a636263..9e918ed0 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -2,7 +2,7 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, menu_functions::{ - can_partially_complete, completer_input, floor_char_boundary, replace_in_buffer, + can_partially_complete, completer_input, floor_char_boundary, replace_in_buffer, style_suggestion, }, painting::Painter, Completer, Suggestion, @@ -518,12 +518,27 @@ impl IdeMenu { }; if use_ansi_coloring { - // strip quotes + // TODO(ysthakur): let the user strip quotes, rather than doing it here let is_quote = |c: char| "`'\"".contains(c); let shortest_base = &self.working_details.shortest_base_string; let shortest_base = shortest_base .strip_prefix(is_quote) .unwrap_or(shortest_base); + + // Highlight the first match of the shortest base string by default + let match_len = shortest_base + .graphemes(true) + .count() + .min(string.graphemes(true).count()); + let default_indices = string + .to_lowercase() + .find(shortest_base) + .map(|match_pos| (match_pos..match_pos + match_len).collect()) + .unwrap_or_default(); + let match_indices = suggestion + .match_indices + .as_ref() + .unwrap_or(&default_indices); let match_len = shortest_base.chars().count().min(string.chars().count()); // Find match position - look for the base string in the suggestion (case-insensitive) @@ -543,51 +558,39 @@ impl IdeMenu { &string[match_position..match_position + match_len_bytes] }; - // Prefix is everything before the match - let prefix = &string[..match_position]; - - // 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(&string) + .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(&string).to_string(), + match_indices, + &self.settings.color.match_style, + ), " ".repeat(padding_right), RESET, vertical_border, @@ -1434,6 +1437,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 ecf2e6f1..d929781b 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -1,4 +1,10 @@ //! Collection of common functions that can be used to create menus +use std::borrow::Cow; + +use itertools::Itertools; +use nu_ansi_term::{ansi::RESET, Style}; +use unicode_segmentation::UnicodeSegmentation; + use crate::{Editor, Suggestion, UndoBehavior}; /// Index result obtained from parsing a string with an index marker @@ -372,10 +378,102 @@ pub fn can_partially_complete(values: &[Suggestion], editor: &mut Editor) -> boo } } +/// Parse ANSI sequences for setting display attributes in the given string. +/// Each returned item is a tuple (escape start, escape end, text end), for +/// finding each sequence and the text affected by it. +/// +/// Essentially just looks for 'ESC [' followed by /[0-9;]*m/, ignoring other ANSI sequences. +fn parse_ansi(s: &str) -> Vec<(usize, usize, usize)> { + let mut segments = Vec::new(); + + let mut last_escape_start = 0; + let mut last_escape_end = 0; + let mut offset = 0; + while offset < s.len() { + let Some(start) = &s[offset..].find("\x1b[") else { + break; + }; + let escape_start = offset + start; + + let after_params = &s[escape_start + 2..] + .trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';']); + if !after_params.starts_with('m') { + // Not a valid Select Graphic Rendition sequence + offset = s.len() - after_params.len(); + continue; + } + + if escape_start != 0 { + segments.push((last_escape_start, last_escape_end, escape_start)); + } + last_escape_start = escape_start; + last_escape_end = s.len() - after_params.len() + 1; + offset = last_escape_end; + } + + segments.push((last_escape_start, last_escape_end, s.len())); + segments +} + +/// 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 mut res = String::new(); + let mut offset = 0; + for (escape_start, text_start, text_end) in parse_ansi(suggestion) { + let escape = &suggestion[escape_start..text_start]; + let text = &suggestion[text_start..text_end]; + let graphemes = text.graphemes(true).collect::>(); + 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 +} + +pub fn get_match_indices<'a>( + value: &str, + match_indices: &'a Option>, + typed_text: &str, +) -> Cow<'a, Vec> { + if let Some(inds) = match_indices { + Cow::Borrowed(inds) + } else { + let Some(match_pos) = value.to_lowercase().find(&typed_text.to_lowercase()) else { + // Don't highlight anything if no match + return Cow::Owned(vec![]); + }; + let match_len = typed_text.graphemes(true).count(); + Cow::Owned((match_pos..match_pos + match_len).collect()) + } +} + #[cfg(test)] mod tests { use super::*; use crate::{EditCommand, LineBuffer, Span}; + use nu_ansi_term::Color; use rstest::rstest; #[test] @@ -630,6 +728,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + ..Default::default() }) .collect(); let res = find_common_string(&input); @@ -650,6 +749,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + ..Default::default() }) .collect(); let res = find_common_string(&input); @@ -705,6 +805,7 @@ mod tests { extra: None, span: Span::new(start, end), append_whitespace: false, + ..Default::default() }), &mut editor, ); @@ -715,4 +816,78 @@ mod tests { assert_eq!(orig_buffer, editor.get_buffer()); assert_eq!(orig_insertion_point, editor.insertion_point()); } + + #[rstest] + #[case("plain", vec![(0, 0, 5)])] + #[case("\x1b[mempty", vec![(0, 3, 8)])] + #[case("\x1b[\x1b[minvalid", vec![(0, 0, 2), (2, 5, 12)])] + #[case("a\x1b[1;mb\x1b[;mc", vec![(0, 0, 1), (1, 6, 7), (7, 11, 12)])] + fn test_parse_ansi(#[case] s: &str, #[case] expected: Vec<(usize, usize, usize)>) { + assert_eq!(parse_ansi(s), expected); + } + + #[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 + ) + ); + } + + #[test] + fn style_fuzzy_suggestion_out_of_bounds() { + 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"), + ); + assert_eq!( + expected, + style_suggestion("foo", &[2, 3, 4, 6], &match_style) + ); + } }