1 use auto_enums::auto_enum;
2 use glob::{glob_with, MatchOptions};
3 use ion_shell::{
4 expansion::{unescape, Expander},
5 Shell,
6 };
7 use liner::{Completer, CursorPosition, Event, EventKind};
8 use std::{env, iter, path::PathBuf, str};
9
10 pub struct IonCompleter<'a, 'b> {
11 shell: &'b Shell<'a>,
12 completion: CompletionType,
13 }
14
15 /// Escapes filenames from the completer so that special characters will be properly escaped.
16 ///
17 /// NOTE: Perhaps we should submit a PR to Liner to add a &'static [u8] field to
18 /// `FilenameCompleter` so that we don't have to perform the escaping ourselves?
escape(input: &str) -> String19 fn escape(input: &str) -> String {
20 let mut output = Vec::with_capacity(input.len());
21 for character in input.bytes() {
22 match character {
23 b'(' | b')' | b'[' | b']' | b'&' | b'$' | b'@' | b'{' | b'}' | b'<' | b'>' | b';'
24 | b'"' | b'\'' | b'#' | b'^' | b'*' | b' ' => output.push(b'\\'),
25 _ => (),
26 }
27 output.push(character);
28 }
29 unsafe { String::from_utf8_unchecked(output) }
30 }
31
32 enum CompletionType {
33 Nothing,
34 Command,
35 VariableAndFiles,
36 }
37
38 impl<'a, 'b> IonCompleter<'a, 'b> {
new(shell: &'b Shell<'a>) -> Self39 pub fn new(shell: &'b Shell<'a>) -> Self {
40 IonCompleter { shell, completion: CompletionType::Nothing }
41 }
42 }
43
44 impl<'a, 'b> Completer for IonCompleter<'a, 'b> {
completions(&mut self, start: &str) -> Vec<String>45 fn completions(&mut self, start: &str) -> Vec<String> {
46 let mut completions = IonFileCompleter::new(None, &self.shell).completions(start);
47 let vars = self.shell.variables();
48
49 match self.completion {
50 CompletionType::VariableAndFiles => {
51 // Initialize a new completer from the definitions collected.
52 // Creates a list of definitions from the shell environment that
53 // will be used
54 // in the creation of a custom completer.
55 if start.is_empty() {
56 completions.extend(vars.string_vars().map(|(s, _)| format!("${}", s)));
57 completions.extend(vars.arrays().map(|(s, _)| format!("@{}", s)));
58 } else if start.starts_with('$') {
59 completions.extend(
60 // Add the list of available variables to the completer's
61 // definitions. TODO: We should make
62 // it free to do String->SmallString
63 // and mostly free to go back (free if allocated)
64 vars.string_vars()
65 .filter(|(s, _)| s.starts_with(&start[1..]))
66 .map(|(s, _)| format!("${}", &s)),
67 );
68 } else if start.starts_with('@') {
69 completions.extend(
70 vars.arrays()
71 .filter(|(s, _)| s.starts_with(&start[1..]))
72 .map(|(s, _)| format!("@{}", &s)),
73 );
74 }
75 }
76 CompletionType::Command => {
77 // Initialize a new completer from the definitions collected.
78 // Creates a list of definitions from the shell environment that
79 // will be used
80 // in the creation of a custom completer.
81 completions.extend(
82 self.shell
83 .builtins()
84 .keys()
85 // Add built-in commands to the completer's definitions.
86 .map(ToString::to_string)
87 // Add the aliases to the completer's definitions.
88 .chain(vars.aliases().map(|(key, _)| key.to_string()))
89 // Add the list of available functions to the completer's
90 // definitions.
91 .chain(vars.functions().map(|(key, _)| key.to_string()))
92 .filter(|s| s.starts_with(start)),
93 );
94 // Creates completers containing definitions from all directories
95 // listed
96 // in the environment's **$PATH** variable.
97 let file_completers: Vec<_> = if let Some(paths) = env::var_os("PATH") {
98 env::split_paths(&paths)
99 .map(|s| {
100 let s = if !s.to_string_lossy().ends_with('/') {
101 let mut oss = s.into_os_string();
102 oss.push("/");
103 oss.into()
104 } else {
105 s
106 };
107 IonFileCompleter::new(Some(s), &self.shell)
108 })
109 .collect()
110 } else {
111 vec![IonFileCompleter::new(Some("/bin/".into()), &self.shell)]
112 };
113 // Merge the collected definitions with the file path definitions.
114 completions.extend(MultiCompleter::new(file_completers).completions(start));
115 }
116 CompletionType::Nothing => (),
117 }
118
119 completions
120 }
121
on_event<W: std::io::Write>(&mut self, event: Event<'_, '_, W>)122 fn on_event<W: std::io::Write>(&mut self, event: Event<'_, '_, W>) {
123 if let EventKind::BeforeComplete = event.kind {
124 let (words, pos) = event.editor.get_words_and_cursor_position();
125 self.completion = match pos {
126 _ if words.is_empty() => CompletionType::Nothing,
127 CursorPosition::InWord(0) => CompletionType::Command,
128 CursorPosition::OnWordRightEdge(index) => {
129 if index == 0 {
130 CompletionType::Command
131 } else {
132 let is_pipe = words
133 .into_iter()
134 .nth(index - 1)
135 .map(|(start, end)| event.editor.current_buffer().range(start, end))
136 .filter(|filename| {
137 filename.ends_with('|')
138 || filename.ends_with('&')
139 || filename.ends_with(';')
140 })
141 .is_some();
142 if is_pipe {
143 CompletionType::Command
144 } else {
145 CompletionType::VariableAndFiles
146 }
147 }
148 }
149 _ => CompletionType::VariableAndFiles,
150 };
151 }
152 }
153 }
154
155 /// Performs escaping to an inner `FilenameCompleter` to enable a handful of special cases
156 /// needed by the shell, such as expanding '~' to a home directory, or adding a backslash
157 /// when a special character is contained within an expanded filename.
158 pub struct IonFileCompleter<'a, 'b> {
159 shell: &'b Shell<'a>,
160 /// The directory the expansion takes place in
161 path: PathBuf,
162 for_command: bool,
163 }
164
165 impl<'a, 'b> IonFileCompleter<'a, 'b> {
new(path: Option<PathBuf>, shell: &'b Shell<'a>) -> Self166 pub fn new(path: Option<PathBuf>, shell: &'b Shell<'a>) -> Self {
167 // The only time a path is Some is when looking for a command not a directory
168 // so save this fact to strip the paths when completing commands.
169 let for_command = path.is_some();
170 let path = path.unwrap_or_default();
171 IonFileCompleter { shell, path, for_command }
172 }
173 }
174
175 impl<'a, 'b> Completer for IonFileCompleter<'a, 'b> {
176 /// When the tab key is pressed, **Liner** will use this method to perform completions of
177 /// filenames. As our `IonFileCompleter` is a wrapper around **Liner**'s
178 /// `FilenameCompleter`,
179 /// the purpose of our custom `Completer` is to expand possible `~` characters in the
180 /// `start`
181 /// value that we receive from the prompt, grab completions from the inner
182 /// `FilenameCompleter`,
183 /// and then escape the resulting filenames, as well as remove the expanded form of the `~`
184 /// character and re-add the `~` character in it's place.
completions(&mut self, start: &str) -> Vec<String>185 fn completions(&mut self, start: &str) -> Vec<String> {
186 // Dereferencing the raw pointers here should be entirely safe, theoretically,
187 // because no changes will occur to either of the underlying references in the
188 // duration between creation of the completers and execution of their
189 // completions.
190 let expanded = match self.shell.tilde(start) {
191 Ok(expanded) => expanded,
192 Err(why) => {
193 eprintln!("ion: {}", why);
194 return vec![start.into()];
195 }
196 };
197 // Now we obtain completions for the `expanded` form of the `start` value.
198 let completions = filename_completion(&expanded, &self.path);
199 if expanded == start {
200 return if self.for_command {
201 completions
202 .map(|s| s.rsplit('/').next().map(|s| s.to_string()).unwrap_or(s))
203 .collect()
204 } else {
205 completions.collect()
206 };
207 }
208 // We can do that by obtaining the index position where the tilde character
209 // ends. We don't search with `~` because we also want to
210 // handle other tilde variants.
211 let t_index = start.find('/').unwrap_or(1);
212 // `tilde` is the tilde pattern, and `search` is the pattern that follows.
213 let (tilde, search) = start.split_at(t_index);
214
215 if search.len() < 2 {
216 // If the length of the search pattern is less than 2, the search pattern is
217 // empty, and thus the completions actually contain files and directories in
218 // the home directory.
219
220 // The tilde pattern will actually be our `start` command in itself,
221 // and the completed form will be all of the characters beyond the length of
222 // the expanded form of the tilde pattern.
223 completions.map(|completion| [start, &completion[expanded.len()..]].concat()).collect()
224 // To save processing time, we should get obtain the index position where our
225 // search pattern begins, and re-use that index to slice the completions so
226 // that we may re-add the tilde character with the completion that follows.
227 } else if let Some(e_index) = expanded.rfind(search) {
228 // And then we will need to take those completions and remove the expanded form
229 // of the tilde pattern and replace it with that pattern yet again.
230 completions
231 .map(|completion| escape(&[tilde, &completion[e_index..]].concat()))
232 .collect()
233 } else {
234 Vec::new()
235 }
236 }
237 }
238
239 #[auto_enum]
filename_completion<'a>(start: &'a str, path: &'a PathBuf) -> impl Iterator<Item = String> + 'a240 fn filename_completion<'a>(start: &'a str, path: &'a PathBuf) -> impl Iterator<Item = String> + 'a {
241 let unescaped_start = unescape(start);
242
243 let mut split_start = unescaped_start.split('/');
244 let mut string = String::with_capacity(128);
245
246 // When 'start' is an absolute path, "/..." gets split to ["", "..."]
247 // So we skip the first element and add "/" to the start of the string
248 if unescaped_start.starts_with('/') {
249 split_start.next();
250 string.push('/');
251 } else {
252 string.push_str(&path.to_string_lossy());
253 }
254
255 for element in split_start {
256 string.push_str(element);
257 if element != "." && element != ".." {
258 string.push('*');
259 }
260 string.push('/');
261 }
262
263 string.pop(); // pop out the last '/' character
264 if string.ends_with('.') {
265 string.push('*')
266 }
267 let globs = glob_with(
268 &string,
269 MatchOptions {
270 case_sensitive: true,
271 require_literal_separator: true,
272 require_literal_leading_dot: false,
273 },
274 )
275 .ok()
276 .map(|completions| {
277 completions.filter_map(Result::ok).filter_map(move |file| {
278 let out = file.to_str()?;
279 let mut joined = String::with_capacity(out.len() + 3); // worst case senario
280 if unescaped_start.starts_with("./") {
281 joined.push_str("./");
282 }
283 joined.push_str(out);
284 if file.is_dir() {
285 joined.push('/');
286 }
287 Some(escape(&joined))
288 })
289 });
290
291 #[auto_enum(Iterator)]
292 match globs {
293 Some(iter) => iter,
294 None => iter::once(escape(start)),
295 }
296 }
297
298 /// A completer that combines suggestions from multiple completers.
299 #[derive(Clone, Debug, Eq, PartialEq)]
300 pub struct MultiCompleter<A>(Vec<A>);
301
302 impl<A> MultiCompleter<A> {
new(completions: Vec<A>) -> Self303 pub fn new(completions: Vec<A>) -> Self { MultiCompleter(completions) }
304 }
305
306 impl<A> Completer for MultiCompleter<A>
307 where
308 A: Completer,
309 {
completions(&mut self, start: &str) -> Vec<String>310 fn completions(&mut self, start: &str) -> Vec<String> {
311 self.0.iter_mut().flat_map(|comp| comp.completions(start)).collect()
312 }
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use super::*;
318
319 #[test]
filename_completion()320 fn filename_completion() {
321 let shell = Shell::default();
322 let mut completer = IonFileCompleter::new(None, &shell);
323 assert_eq!(completer.completions("testing"), vec!["testing/"]);
324 assert_eq!(completer.completions("testing/file"), vec!["testing/file_with_text"]);
325 if cfg!(not(target_os = "redox")) {
326 assert_eq!(completer.completions("~"), vec!["~/"]);
327 }
328 assert_eq!(completer.completions("tes/fil"), vec!["testing/file_with_text"]);
329 }
330 }
331