diff --git a/cli/src/reedline.rs b/cli/src/reedline.rs new file mode 100644 index 00000000..8f3a27af --- /dev/null +++ b/cli/src/reedline.rs @@ -0,0 +1,191 @@ +use std::{borrow::Cow, sync::LazyLock}; + +use color_eyre::eyre::{self, bail}; +use fuzzt::{algorithms::JaroWinkler, get_top_n}; +use magic_wormhole::wordlist::default_wordlist_flatned; +use reedline::{ + default_emacs_keybindings, ColumnarMenu, Completer, Emacs, KeyCode, KeyModifiers, MenuBuilder, + Prompt, PromptEditMode, PromptHistorySearch, Reedline, ReedlineEvent, ReedlineMenu, Signal, + Span, Suggestion, +}; + +static WORDLIST: LazyLock> = LazyLock::new(|| default_wordlist_flatned()); + +struct CodePrompt {} + +impl CodePrompt { + fn default() -> Self { + CodePrompt {} + } +} + +impl Prompt for CodePrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + Cow::Borrowed("Wormhole Code: ") + } + + // Not needed + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + + // Not needed + fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> { + Cow::Borrowed("") + } + + // Not needed + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed("... ") + } + + fn render_prompt_history_search_indicator( + &self, + _history_search: PromptHistorySearch, + ) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn get_prompt_color(&self) -> reedline::Color { + reedline::Color::Grey + } +} + +struct CodeCompleter {} + +impl CodeCompleter { + fn default() -> Self { + CodeCompleter {} + } +} + +impl Completer for CodeCompleter { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + // Skip autocomplete for the channel number (first part) + if !line.contains('-') { + return Vec::new(); + } + + // Find the start and end of the current word + let current_word_start = line[..pos].rfind('-').map(|i| i + 1).unwrap_or(0); + let current_word_end = line[pos..] + .find('-') + .map(|i| i + pos) + .unwrap_or_else(|| line.len()); + + let current_part = &line[current_word_start..current_word_end]; + + let list = &*WORDLIST.iter().map(|s| s.as_str()).collect::>(); + + // Use fuzzy matching to find the best matches + // Use cutoff for the menu system to be useful + // If cutoff is high enough and only one word is left, use Tab to complete directly + // We use the Jaro-Winkler algorithm because it places more emphasis on the beginning part of the code word + let matches = get_top_n( + current_part, + list, + Some(0.8), + None, + None, + Some(&JaroWinkler), + ); + + matches + .into_iter() + .map(|word| { + let suggestion = word.to_string(); + + // Incase suggestion word length is larger then the current typed part + // Otherwise we get index out of range error in Span + let span_end = + std::cmp::min(current_word_start + suggestion.len(), current_word_end); + + Suggestion { + value: suggestion, + description: None, + extra: None, + span: Span { + start: current_word_start, + end: span_end, + }, + append_whitespace: false, + style: None, + } + }) + .collect() + } +} + +pub fn enter_code() -> eyre::Result { + // Set up the required keybindings + let mut keybindings = default_emacs_keybindings(); + + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), + ); + + let edit_mode = Box::new(Emacs::new(keybindings)); + + let completion_menu = Box::new(ColumnarMenu::default().with_name("completion_menu")); + + let mut line_editor = Reedline::create() + .with_completer(Box::new(CodeCompleter::default())) + .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) + .with_quick_completions(true) + .with_edit_mode(edit_mode); + + let prompt = CodePrompt::default(); + + let sig = line_editor.read_line(&prompt); + match sig { + Ok(Signal::Success(buffer)) => return Ok(buffer), + // TODO: fix temporary work around + Ok(Signal::CtrlC) => bail!("Ctrl-C received"), + Ok(Signal::CtrlD) => bail!("Ctrl-D received"), + Err(e) => bail!(e), + } +} + +#[cfg(test)] +mod test { + use super::*; + #[test] + fn test_tab_completion_complete_word() { + let mut completer = CodeCompleter::default(); + let input = "22-trombonist"; + let cursor_pos = input.len(); + + let suggestions = completer.complete(&input, cursor_pos); + + assert_eq!(suggestions.len(), 3); + + assert_eq!(suggestions.first().unwrap().value, "trombonist"); + } + + #[test] + fn test_tab_completion_partial_word() { + let mut completer = CodeCompleter::default(); + let input = "22-trmbn"; + let cursor_pos = input.len(); + + let suggestions = completer.complete(&input, cursor_pos); + + assert_eq!(suggestions.first().unwrap().value, "trombonist"); + } + + #[test] + fn test_tab_completion_partial_in_middle() { + let mut completer = CodeCompleter::default(); + let input = "22-trbis-zulu"; + let cursor_pos = input.len() - "-zulu".len(); + + let suggestions = completer.complete(&input, cursor_pos); + + assert_eq!(suggestions.first().unwrap().value, "trombonist"); + } +}