repl.rs (7570B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 13 You should have received a copy of the GNU Affero General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 use std::{borrow::Cow, marker::PhantomData}; 18 19 use nu_ansi_term::Color; 20 use reedline::{ 21 ColumnarMenu, CompletionResult, DefaultHinter, FileBackedHistory, KeyCode, KeyModifiers, 22 MenuBuilder as _, Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion, Vi, 23 default_vi_insert_keybindings, default_vi_normal_keybindings, default_vi_visual_keybindings, 24 }; 25 26 use crate::cli::clap_parse; 27 28 pub struct Repl<T: clap::Parser> { 29 relp: Reedline, 30 phantom: PhantomData<T>, 31 } 32 33 impl<T: clap::Parser> Repl<T> { 34 pub fn new(history: &str) -> Self { 35 let history = Box::new( 36 FileBackedHistory::with_file(1000, history.into()) 37 .expect("Error configuring history with file"), 38 ); 39 let completion_menu = Box::new( 40 ColumnarMenu::default() 41 .with_name("completion_menu") 42 .with_columns(4) 43 .with_column_width(None) 44 .with_column_padding(2), 45 ); 46 47 let mut insert = default_vi_insert_keybindings(); 48 insert.add_binding( 49 KeyModifiers::NONE, 50 KeyCode::Tab, 51 ReedlineEvent::UntilFound(vec![ 52 ReedlineEvent::Menu("completion_menu".to_string()), 53 ReedlineEvent::MenuNext, 54 ]), 55 ); 56 let repl = Reedline::create() 57 .with_history(history) 58 .with_completer(Box::new(ClapCompleter(T::command().clone()))) 59 .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) 60 .with_quick_completions(true) 61 .with_partial_completions(true) 62 .with_hinter(Box::new(DefaultHinter::default().with_style( 63 nu_ansi_term::Style::new().italic().fg(Color::LightGray), 64 ))) 65 .with_edit_mode(Box::new(Vi::new( 66 insert, 67 default_vi_normal_keybindings(), 68 default_vi_visual_keybindings(), 69 ))); 70 71 Self { 72 relp: repl, 73 phantom: PhantomData, 74 } 75 } 76 77 pub fn read_line(&mut self, left: &str, right: &str) -> Option<T> { 78 loop { 79 let Signal::Success(buf) = self.relp.read_line(&ReplPrompt(left, right)).unwrap() 80 else { 81 return None; 82 }; 83 match clap_parse(&buf) { 84 Ok(cmd) => return Some(cmd), 85 Err(e) => { 86 e.print().unwrap(); 87 } 88 } 89 } 90 } 91 } 92 93 struct ClapCompleter(clap::Command); 94 95 impl reedline::Completer for ClapCompleter { 96 fn complete(&mut self, line: &str, pos: usize) -> CompletionResult { 97 let mut suggestions = Vec::new(); 98 let text_before_cursor = &line[..pos]; 99 100 let words = shlex::split(text_before_cursor).unwrap_or_default(); 101 let is_whitespace_at_end = text_before_cursor.ends_with(' '); 102 103 let mut active_cmd = &self.0; 104 let mut last_word = ""; 105 106 // Determine the word currently being typed 107 if !is_whitespace_at_end && !words.is_empty() { 108 last_word = words.last().unwrap(); 109 } 110 111 // Identify which subcommand we are currently inside by walking the tokens 112 let tokens = if is_whitespace_at_end { 113 &words[..] 114 } else { 115 &words[..words.len().saturating_sub(1)] 116 }; 117 118 for token in tokens { 119 if let Some(subcmd) = active_cmd 120 .get_subcommands() 121 .find(|s| s.get_name() == *token) 122 { 123 active_cmd = subcmd; 124 } 125 } 126 127 let start = pos - last_word.len(); 128 let span = Span::new(start, pos); 129 130 // Suggest Long Flags 131 if last_word.starts_with("--") { 132 for arg in active_cmd.get_arguments() { 133 if let Some(long) = arg.get_long() { 134 let flag = format!("--{}", long); 135 if flag.starts_with(last_word) { 136 suggestions.push(Suggestion { 137 value: flag, 138 description: arg.get_help().map(|s| s.to_string()), 139 span, 140 append_whitespace: true, 141 ..Suggestion::default() 142 }); 143 } 144 } 145 } 146 } 147 // Suggest Short & Long Flags 148 else if last_word.starts_with("-") { 149 for arg in active_cmd.get_arguments() { 150 if let Some(short) = arg.get_short() { 151 let flag = format!("-{}", short); 152 if flag.starts_with(last_word) { 153 suggestions.push(Suggestion { 154 value: flag, 155 description: arg.get_help().map(|s| s.to_string()), 156 span, 157 append_whitespace: true, 158 ..Suggestion::default() 159 }); 160 } 161 } 162 if let Some(long) = arg.get_long() { 163 let flag = format!("--{}", long); 164 if flag.starts_with(last_word) { 165 suggestions.push(Suggestion { 166 value: flag, 167 description: arg.get_help().map(|s| s.to_string()), 168 span, 169 append_whitespace: true, 170 ..Suggestion::default() 171 }); 172 } 173 } 174 } 175 } 176 // Suggest Subcommands 177 else { 178 for subcmd in active_cmd.get_subcommands() { 179 if subcmd.get_name().starts_with(last_word) { 180 suggestions.push(Suggestion { 181 value: subcmd.get_name().to_string(), 182 description: subcmd.get_about().map(|s| s.to_string()), 183 span, 184 append_whitespace: true, 185 ..Suggestion::default() 186 }); 187 } 188 } 189 } 190 191 CompletionResult::fresh(suggestions) 192 } 193 } 194 195 struct ReplPrompt<'a>(&'a str, &'a str); 196 197 impl<'a> reedline::Prompt for ReplPrompt<'a> { 198 fn render_prompt_left(&self) -> Cow<'_, str> { 199 Cow::Borrowed(self.0) 200 } 201 202 fn render_prompt_right(&self) -> Cow<'_, str> { 203 Cow::Borrowed(self.1) 204 } 205 206 fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> { 207 Cow::Borrowed(">") 208 } 209 210 fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { 211 Cow::Borrowed(":") 212 } 213 214 fn render_prompt_history_search_indicator( 215 &self, 216 _: reedline::PromptHistorySearch, 217 ) -> Cow<'_, str> { 218 Cow::Borrowed(">") 219 } 220 }