repl.rs (7399B)
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, DefaultHinter, FileBackedHistory, KeyCode, KeyModifiers, MenuBuilder as _, 22 Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion, Vi, 23 default_vi_insert_keybindings, default_vi_normal_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(insert, default_vi_normal_keybindings()))); 66 67 Self { 68 relp: repl, 69 phantom: PhantomData, 70 } 71 } 72 73 pub fn read_line(&mut self, left: &str, right: &str) -> Option<T> { 74 loop { 75 let Signal::Success(buf) = self.relp.read_line(&ReplPrompt(left, right)).unwrap() 76 else { 77 return None; 78 }; 79 match clap_parse(&buf) { 80 Ok(cmd) => return Some(cmd), 81 Err(e) => { 82 e.print().unwrap(); 83 } 84 } 85 } 86 } 87 } 88 89 struct ClapCompleter(clap::Command); 90 91 impl reedline::Completer for ClapCompleter { 92 fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> { 93 let mut suggestions = Vec::new(); 94 let text_before_cursor = &line[..pos]; 95 96 let words = shlex::split(text_before_cursor).unwrap_or_default(); 97 let is_whitespace_at_end = text_before_cursor.ends_with(' '); 98 99 let mut active_cmd = &self.0; 100 let mut last_word = ""; 101 102 // Determine the word currently being typed 103 if !is_whitespace_at_end && !words.is_empty() { 104 last_word = words.last().unwrap(); 105 } 106 107 // Identify which subcommand we are currently inside by walking the tokens 108 let tokens = if is_whitespace_at_end { 109 &words[..] 110 } else { 111 &words[..words.len().saturating_sub(1)] 112 }; 113 114 for token in tokens { 115 if let Some(subcmd) = active_cmd 116 .get_subcommands() 117 .find(|s| s.get_name() == *token) 118 { 119 active_cmd = subcmd; 120 } 121 } 122 123 let start = pos - last_word.len(); 124 let span = Span::new(start, pos); 125 126 // Suggest Long Flags 127 if last_word.starts_with("--") { 128 for arg in active_cmd.get_arguments() { 129 if let Some(long) = arg.get_long() { 130 let flag = format!("--{}", long); 131 if flag.starts_with(last_word) { 132 suggestions.push(Suggestion { 133 value: flag, 134 description: arg.get_help().map(|s| s.to_string()), 135 span, 136 append_whitespace: true, 137 ..Suggestion::default() 138 }); 139 } 140 } 141 } 142 } 143 // Suggest Short & Long Flags 144 else if last_word.starts_with("-") { 145 for arg in active_cmd.get_arguments() { 146 if let Some(short) = arg.get_short() { 147 let flag = format!("-{}", short); 148 if flag.starts_with(last_word) { 149 suggestions.push(Suggestion { 150 value: flag, 151 description: arg.get_help().map(|s| s.to_string()), 152 span, 153 append_whitespace: true, 154 ..Suggestion::default() 155 }); 156 } 157 } 158 if let Some(long) = arg.get_long() { 159 let flag = format!("--{}", long); 160 if flag.starts_with(last_word) { 161 suggestions.push(Suggestion { 162 value: flag, 163 description: arg.get_help().map(|s| s.to_string()), 164 span, 165 append_whitespace: true, 166 ..Suggestion::default() 167 }); 168 } 169 } 170 } 171 } 172 // Suggest Subcommands 173 else { 174 for subcmd in active_cmd.get_subcommands() { 175 if subcmd.get_name().starts_with(last_word) { 176 suggestions.push(Suggestion { 177 value: subcmd.get_name().to_string(), 178 description: subcmd.get_about().map(|s| s.to_string()), 179 span, 180 append_whitespace: true, 181 ..Suggestion::default() 182 }); 183 } 184 } 185 } 186 187 suggestions 188 } 189 } 190 191 struct ReplPrompt<'a>(&'a str, &'a str); 192 193 impl<'a> reedline::Prompt for ReplPrompt<'a> { 194 fn render_prompt_left(&self) -> Cow<'_, str> { 195 Cow::Borrowed(self.0) 196 } 197 198 fn render_prompt_right(&self) -> Cow<'_, str> { 199 Cow::Borrowed(self.1) 200 } 201 202 fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> { 203 Cow::Borrowed(">") 204 } 205 206 fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { 207 Cow::Borrowed(":") 208 } 209 210 fn render_prompt_history_search_indicator( 211 &self, 212 _: reedline::PromptHistorySearch, 213 ) -> Cow<'_, str> { 214 Cow::Borrowed(">") 215 } 216 }