rpn_cli/core/
helper.rs

1use crate::core::interface::Completion;
2use rustyline::completion::{Completer, FilenameCompleter};
3use rustyline::highlight::Highlighter;
4use rustyline::hint::Hinter;
5use rustyline::history::DefaultHistory;
6use rustyline::line_buffer::LineBuffer;
7use rustyline::validate::Validator;
8use rustyline::{Changeset, Context, Editor, Helper};
9use std::collections::HashMap;
10
11pub type CommandEditor = Editor<CommandHelper, DefaultHistory>;
12
13pub struct CommandHelper {
14    commands: Vec<String>,
15    completions: HashMap<String, Completion>,
16    completer: FilenameCompleter,
17}
18
19// noinspection RsLift
20impl CommandHelper {
21    pub fn new() -> Self {
22        let commands = Vec::new();
23        let completions = HashMap::new();
24        let completer = FilenameCompleter::new();
25        Self { commands, completions, completer }
26    }
27
28    pub fn set_commands(&mut self, commands: Vec<String>) {
29        self.commands = commands;
30    }
31
32    pub fn set_completions(&mut self, completions: HashMap<String, Completion>) {
33        self.completions = completions;
34    }
35
36    fn requires_filename(&self, line: &str) -> bool {
37        let line = line.trim_start();
38        if let Some((command, remainder)) = line.split_once(' ') {
39            if let Some(Completion::Filename) = self.completions.get(command) {
40                let remainder = remainder.trim_start();
41                return !remainder.contains(' ');
42            }
43        }
44        false
45    }
46
47    fn complete_line(&self, line: &str, pos: usize) -> rustyline::Result<(usize, Vec<String>)> {
48        let line = &line[..pos];
49        if self.requires_filename(line) {
50            let (start, candidates) = self.completer.complete_path(line, pos)?;
51            let candidates = candidates.into_iter().map(|x| x.replacement).collect();
52            Ok((start, candidates))
53        } else {
54            if let Some((start, initial)) = line.rsplit_once(' ') {
55                let start = start.len() + 1;
56                let candidates = self.complete_command(initial);
57                Ok((start, candidates))
58            } else {
59                let candidates = self.complete_command(line);
60                Ok((0, candidates))
61            }
62        }
63    }
64
65    fn complete_command(&self, initial: &str) -> Vec<String> {
66        self.commands.iter()
67            .filter(|x| x.starts_with(initial))
68            .map(|x| x.to_string())
69            .collect()
70    }
71
72    fn adjust_candidates(candidates: Vec<String>) -> Vec<String> {
73        if candidates.len() == 1 {
74            candidates.into_iter().map(Self::adjust_candidate).collect()
75        } else {
76            candidates
77        }
78    }
79
80    fn adjust_candidate(candidate: String) -> String {
81        if candidate.ends_with(&['\\', '/']) {
82            candidate
83        } else {
84            candidate + " "
85        }
86    }
87}
88
89impl Helper for CommandHelper {
90}
91
92// noinspection RsLift
93impl Completer for CommandHelper {
94    type Candidate = String;
95
96    fn complete(
97        &self,
98        line: &str,
99        pos: usize,
100        _context: &Context<'_>,
101    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
102        let (start, candidates) = self.complete_line(line, pos)?;
103        let candidates = Self::adjust_candidates(candidates);
104        Ok((start, candidates))
105    }
106
107    fn update(
108        &self,
109        line: &mut LineBuffer,
110        start: usize,
111        elected: &str,
112        changes: &mut Changeset,
113    ) {
114        let end = line.pos();
115        line.replace(start..end, &elected, changes);
116    }
117}
118
119impl Highlighter for CommandHelper {
120}
121
122impl Hinter for CommandHelper {
123    type Hint = String;
124}
125
126impl Validator for CommandHelper {
127}
128
129#[cfg(test)]
130pub mod tests {
131    use crate::core::helper::CommandHelper;
132    use crate::core::interface::Completion;
133    use std::collections::HashMap;
134
135    #[test]
136    fn test_requires_filename_for_import_directive() {
137        let helper = create_helper();
138        assert_eq!(false, helper.requires_filename(""));
139        assert_eq!(false, helper.requires_filename("import"));
140        assert_eq!(true, helper.requires_filename("import "));
141        assert_eq!(true, helper.requires_filename("import file"));
142        assert_eq!(false, helper.requires_filename("import file "));
143        assert_eq!(false, helper.requires_filename("import file 999"));
144        assert_eq!(false, helper.requires_filename("   "));
145        assert_eq!(false, helper.requires_filename("   import"));
146        assert_eq!(true, helper.requires_filename("   import   "));
147        assert_eq!(true, helper.requires_filename("   import   file"));
148        assert_eq!(false, helper.requires_filename("   import   file   "));
149        assert_eq!(false, helper.requires_filename("   import   file   999"));
150    }
151
152    #[test]
153    fn test_requires_filename_for_define_directive() {
154        let helper = create_helper();
155        assert_eq!(false, helper.requires_filename(""));
156        assert_eq!(false, helper.requires_filename("define"));
157        assert_eq!(false, helper.requires_filename("define "));
158        assert_eq!(false, helper.requires_filename("define file"));
159        assert_eq!(false, helper.requires_filename("define file "));
160        assert_eq!(false, helper.requires_filename("define file 999"));
161        assert_eq!(false, helper.requires_filename("   "));
162        assert_eq!(false, helper.requires_filename("   define"));
163        assert_eq!(false, helper.requires_filename("   define   "));
164        assert_eq!(false, helper.requires_filename("   define   file"));
165        assert_eq!(false, helper.requires_filename("   define   file   "));
166        assert_eq!(false, helper.requires_filename("   define   file   999"));
167    }
168
169    #[test]
170    fn test_requires_filename_for_unknown_directive() {
171        let helper = create_helper();
172        assert_eq!(false, helper.requires_filename(""));
173        assert_eq!(false, helper.requires_filename("unknown"));
174        assert_eq!(false, helper.requires_filename("unknown "));
175        assert_eq!(false, helper.requires_filename("unknown file"));
176        assert_eq!(false, helper.requires_filename("unknown file "));
177        assert_eq!(false, helper.requires_filename("unknown file 999"));
178        assert_eq!(false, helper.requires_filename("   "));
179        assert_eq!(false, helper.requires_filename("   unknown"));
180        assert_eq!(false, helper.requires_filename("   unknown   "));
181        assert_eq!(false, helper.requires_filename("   unknown   file"));
182        assert_eq!(false, helper.requires_filename("   unknown   file   "));
183        assert_eq!(false, helper.requires_filename("   unknown   file   999"));
184    }
185
186    #[test]
187    fn test_only_token_is_completed_with_no_matches() {
188        let helper = create_helper();
189        let (start, candidates) = helper.complete_line("xxx 999", 3).unwrap();
190        assert_eq!(start, 0);
191        assert!(candidates.is_empty())
192    }
193
194    #[test]
195    fn test_final_token_is_completed_with_no_matches() {
196        let helper = create_helper();
197        let (start, candidates) = helper.complete_line("1 2 xxx 999", 7).unwrap();
198        assert_eq!(start, 4);
199        assert!(candidates.is_empty())
200    }
201
202    #[test]
203    fn test_only_token_is_completed_with_one_match() {
204        let helper = create_helper();
205        let (start, candidates) = helper.complete_line("aaa 999", 3).unwrap();
206        assert_eq!(start, 0);
207        assert_eq!(candidates, vec![String::from("aaaaa123")]);
208    }
209
210    #[test]
211    fn test_final_token_is_completed_with_one_match() {
212        let helper = create_helper();
213        let (start, candidates) = helper.complete_line("1 2 aaa 999", 7).unwrap();
214        assert_eq!(start, 4);
215        assert_eq!(candidates, vec![String::from("aaaaa123")]);
216    }
217
218    #[test]
219    fn test_only_token_is_completed_with_multiple_matches() {
220        let helper = create_helper();
221        let (start, candidates) = helper.complete_line("bbb 999", 3).unwrap();
222        assert_eq!(start, 0);
223        assert_eq!(candidates, vec![String::from("bbbbb456"), String::from("bbbbb789")]);
224    }
225
226    #[test]
227    fn test_final_token_is_completed_with_multiple_matches() {
228        let helper = create_helper();
229        let (start, candidates) = helper.complete_line("1 2 bbb 999", 7).unwrap();
230        assert_eq!(start, 4);
231        assert_eq!(candidates, vec![String::from("bbbbb456"), String::from("bbbbb789")]);
232    }
233
234    fn create_helper() -> CommandHelper {
235        let mut helper = CommandHelper::new();
236        helper.set_commands(vec![
237            String::from("aaaaa123"),
238            String::from("bbbbb456"),
239            String::from("bbbbb789"),
240        ]);
241        helper.set_completions(HashMap::from([
242            (String::from("import"), Completion::Filename),
243            (String::from("export"), Completion::Filename),
244            (String::from("define"), Completion::Keyword),
245        ]));
246        helper
247    }
248
249    #[test]
250    fn test_space_is_added_to_single_candidate() {
251        assert_eq!(Vec::<String>::new(), adjust_candidates(vec![]));
252        assert_eq!(vec!["foo "], adjust_candidates(vec!["foo"]));
253        assert_eq!(vec!["foo", "bar"], adjust_candidates(vec!["foo", "bar"]));
254        assert_eq!(vec!["subdir\\"], adjust_candidates(vec!["subdir\\"]));
255        assert_eq!(vec!["subdir/"], adjust_candidates(vec!["subdir/"]));
256    }
257
258    fn adjust_candidates(candidates: Vec<&str>) -> Vec<String> {
259        let candidates = candidates.into_iter().map(String::from).collect();
260        CommandHelper::adjust_candidates(candidates)
261    }
262}