rustyline/
completion.rs

1//! Completion API
2use std::borrow::Cow::{self, Borrowed, Owned};
3use std::fs;
4use std::path::{self, Path};
5
6use crate::line_buffer::LineBuffer;
7use crate::{Context, Result};
8use memchr::memchr;
9
10/// A completion candidate.
11pub trait Candidate {
12    /// Text to display when listing alternatives.
13    fn display(&self) -> &str;
14    /// Text to insert in line.
15    fn replacement(&self) -> &str;
16}
17
18impl Candidate for String {
19    fn display(&self) -> &str {
20        self.as_str()
21    }
22
23    fn replacement(&self) -> &str {
24        self.as_str()
25    }
26}
27
28/// #[deprecated = "Unusable"]
29impl Candidate for str {
30    fn display(&self) -> &str {
31        self
32    }
33
34    fn replacement(&self) -> &str {
35        self
36    }
37}
38
39impl Candidate for &'_ str {
40    fn display(&self) -> &str {
41        self
42    }
43
44    fn replacement(&self) -> &str {
45        self
46    }
47}
48
49/// Completion candidate pair
50pub struct Pair {
51    /// Text to display when listing alternatives.
52    pub display: String,
53    /// Text to insert in line.
54    pub replacement: String,
55}
56
57impl Candidate for Pair {
58    fn display(&self) -> &str {
59        self.display.as_str()
60    }
61
62    fn replacement(&self) -> &str {
63        self.replacement.as_str()
64    }
65}
66
67// TODO: let the implementers customize how the candidate(s) are displayed
68// https://github.com/kkawakam/rustyline/issues/302
69
70/// To be called for tab-completion.
71pub trait Completer {
72    /// Specific completion candidate.
73    type Candidate: Candidate;
74
75    // TODO: let the implementers choose/find word boundaries ??? => Lexer
76
77    /// Takes the currently edited `line` with the cursor `pos`ition and
78    /// returns the start position and the completion candidates for the
79    /// partial word to be completed.
80    ///
81    /// ("ls /usr/loc", 11) => Ok((3, vec!["/usr/local/"]))
82    fn complete(
83        &self, // FIXME should be `&mut self`
84        line: &str,
85        pos: usize,
86        ctx: &Context<'_>,
87    ) -> Result<(usize, Vec<Self::Candidate>)> {
88        let _ = (line, pos, ctx);
89        Ok((0, Vec::with_capacity(0)))
90    }
91    /// Updates the edited `line` with the `elected` candidate.
92    fn update(&self, line: &mut LineBuffer, start: usize, elected: &str) {
93        let end = line.pos();
94        line.replace(start..end, elected);
95    }
96}
97
98impl Completer for () {
99    type Candidate = String;
100
101    fn update(&self, _line: &mut LineBuffer, _start: usize, _elected: &str) {
102        unreachable!();
103    }
104}
105
106impl<'c, C: ?Sized + Completer> Completer for &'c C {
107    type Candidate = C::Candidate;
108
109    fn complete(
110        &self,
111        line: &str,
112        pos: usize,
113        ctx: &Context<'_>,
114    ) -> Result<(usize, Vec<Self::Candidate>)> {
115        (**self).complete(line, pos, ctx)
116    }
117
118    fn update(&self, line: &mut LineBuffer, start: usize, elected: &str) {
119        (**self).update(line, start, elected);
120    }
121}
122macro_rules! box_completer {
123    ($($id: ident)*) => {
124        $(
125            impl<C: ?Sized + Completer> Completer for $id<C> {
126                type Candidate = C::Candidate;
127
128                fn complete(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Result<(usize, Vec<Self::Candidate>)> {
129                    (**self).complete(line, pos, ctx)
130                }
131                fn update(&self, line: &mut LineBuffer, start: usize, elected: &str) {
132                    (**self).update(line, start, elected)
133                }
134            }
135        )*
136    }
137}
138
139use std::rc::Rc;
140use std::sync::Arc;
141box_completer! { Box Rc Arc }
142
143/// A `Completer` for file and folder names.
144pub struct FilenameCompleter {
145    break_chars: &'static [u8],
146    double_quotes_special_chars: &'static [u8],
147}
148
149const DOUBLE_QUOTES_ESCAPE_CHAR: Option<char> = Some('\\');
150
151cfg_if::cfg_if! {
152    if #[cfg(unix)] {
153        // rl_basic_word_break_characters, rl_completer_word_break_characters
154        const DEFAULT_BREAK_CHARS: [u8; 18] = [
155            b' ', b'\t', b'\n', b'"', b'\\', b'\'', b'`', b'@', b'$', b'>', b'<', b'=', b';', b'|', b'&',
156            b'{', b'(', b'\0',
157        ];
158        const ESCAPE_CHAR: Option<char> = Some('\\');
159        // In double quotes, not all break_chars need to be escaped
160        // https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
161        const DOUBLE_QUOTES_SPECIAL_CHARS: [u8; 4] = [b'"', b'$', b'\\', b'`'];
162    } else if #[cfg(windows)] {
163        // Remove \ to make file completion works on windows
164        const DEFAULT_BREAK_CHARS: [u8; 17] = [
165            b' ', b'\t', b'\n', b'"', b'\'', b'`', b'@', b'$', b'>', b'<', b'=', b';', b'|', b'&', b'{',
166            b'(', b'\0',
167        ];
168        const ESCAPE_CHAR: Option<char> = None;
169        const DOUBLE_QUOTES_SPECIAL_CHARS: [u8; 1] = [b'"']; // TODO Validate: only '"' ?
170    } else if #[cfg(target_arch = "wasm32")] {
171        const DEFAULT_BREAK_CHARS: [u8; 0] = [];
172        const ESCAPE_CHAR: Option<char> = None;
173        const DOUBLE_QUOTES_SPECIAL_CHARS: [u8; 0] = [];
174    }
175}
176
177/// Kind of quote.
178#[derive(Clone, Copy, Debug, Eq, PartialEq)]
179pub enum Quote {
180    /// Double quote: `"`
181    Double,
182    /// Single quote: `'`
183    Single,
184    /// No quote
185    None,
186}
187
188impl FilenameCompleter {
189    /// Constructor
190    #[must_use]
191    pub fn new() -> Self {
192        Self {
193            break_chars: &DEFAULT_BREAK_CHARS,
194            double_quotes_special_chars: &DOUBLE_QUOTES_SPECIAL_CHARS,
195        }
196    }
197
198    /// Takes the currently edited `line` with the cursor `pos`ition and
199    /// returns the start position and the completion candidates for the
200    /// partial path to be completed.
201    pub fn complete_path(&self, line: &str, pos: usize) -> Result<(usize, Vec<Pair>)> {
202        let (start, path, esc_char, break_chars, quote) =
203            if let Some((idx, quote)) = find_unclosed_quote(&line[..pos]) {
204                let start = idx + 1;
205                if quote == Quote::Double {
206                    (
207                        start,
208                        unescape(&line[start..pos], DOUBLE_QUOTES_ESCAPE_CHAR),
209                        DOUBLE_QUOTES_ESCAPE_CHAR,
210                        &self.double_quotes_special_chars,
211                        quote,
212                    )
213                } else {
214                    (
215                        start,
216                        Borrowed(&line[start..pos]),
217                        None,
218                        &self.break_chars,
219                        quote,
220                    )
221                }
222            } else {
223                let (start, path) = extract_word(line, pos, ESCAPE_CHAR, self.break_chars);
224                let path = unescape(path, ESCAPE_CHAR);
225                (start, path, ESCAPE_CHAR, &self.break_chars, Quote::None)
226            };
227        let mut matches = filename_complete(&path, esc_char, break_chars, quote);
228        #[allow(clippy::unnecessary_sort_by)]
229        matches.sort_by(|a, b| a.display().cmp(b.display()));
230        Ok((start, matches))
231    }
232}
233
234impl Default for FilenameCompleter {
235    fn default() -> Self {
236        Self::new()
237    }
238}
239
240impl Completer for FilenameCompleter {
241    type Candidate = Pair;
242
243    fn complete(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Result<(usize, Vec<Pair>)> {
244        self.complete_path(line, pos)
245    }
246}
247
248/// Remove escape char
249#[must_use]
250pub fn unescape(input: &str, esc_char: Option<char>) -> Cow<'_, str> {
251    let esc_char = if let Some(c) = esc_char {
252        c
253    } else {
254        return Borrowed(input);
255    };
256    if !input.chars().any(|c| c == esc_char) {
257        return Borrowed(input);
258    }
259    let mut result = String::with_capacity(input.len());
260    let mut chars = input.chars();
261    while let Some(ch) = chars.next() {
262        if ch == esc_char {
263            if let Some(ch) = chars.next() {
264                if cfg!(windows) && ch != '"' {
265                    // TODO Validate: only '"' ?
266                    result.push(esc_char);
267                }
268                result.push(ch);
269            } else if cfg!(windows) {
270                result.push(ch);
271            }
272        } else {
273            result.push(ch);
274        }
275    }
276    Owned(result)
277}
278
279/// Escape any `break_chars` in `input` string with `esc_char`.
280/// For example, '/User Information' becomes '/User\ Information'
281/// when space is a breaking char and '\\' the escape char.
282#[must_use]
283pub fn escape(
284    mut input: String,
285    esc_char: Option<char>,
286    break_chars: &[u8],
287    quote: Quote,
288) -> String {
289    if quote == Quote::Single {
290        return input; // no escape in single quotes
291    }
292    let n = input
293        .bytes()
294        .filter(|b| memchr(*b, break_chars).is_some())
295        .count();
296    if n == 0 {
297        return input; // no need to escape
298    }
299    let esc_char = if let Some(c) = esc_char {
300        c
301    } else {
302        if cfg!(windows) && quote == Quote::None {
303            input.insert(0, '"'); // force double quote
304            return input;
305        }
306        return input;
307    };
308    let mut result = String::with_capacity(input.len() + n);
309
310    for c in input.chars() {
311        if c.is_ascii() && memchr(c as u8, break_chars).is_some() {
312            result.push(esc_char);
313        }
314        result.push(c);
315    }
316    result
317}
318
319fn filename_complete(
320    path: &str,
321    esc_char: Option<char>,
322    break_chars: &[u8],
323    quote: Quote,
324) -> Vec<Pair> {
325    #[cfg(feature = "with-dirs")]
326    use dirs_next::home_dir;
327    use std::env::current_dir;
328
329    let sep = path::MAIN_SEPARATOR;
330    let (dir_name, file_name) = match path.rfind(sep) {
331        Some(idx) => path.split_at(idx + sep.len_utf8()),
332        None => ("", path),
333    };
334
335    let dir_path = Path::new(dir_name);
336    let dir = if dir_path.starts_with("~") {
337        // ~[/...]
338        #[cfg(feature = "with-dirs")]
339        {
340            if let Some(home) = home_dir() {
341                match dir_path.strip_prefix("~") {
342                    Ok(rel_path) => home.join(rel_path),
343                    _ => home,
344                }
345            } else {
346                dir_path.to_path_buf()
347            }
348        }
349        #[cfg(not(feature = "with-dirs"))]
350        {
351            dir_path.to_path_buf()
352        }
353    } else if dir_path.is_relative() {
354        // TODO ~user[/...] (https://crates.io/crates/users)
355        if let Ok(cwd) = current_dir() {
356            cwd.join(dir_path)
357        } else {
358            dir_path.to_path_buf()
359        }
360    } else {
361        dir_path.to_path_buf()
362    };
363
364    let mut entries: Vec<Pair> = Vec::new();
365
366    // if dir doesn't exist, then don't offer any completions
367    if !dir.exists() {
368        return entries;
369    }
370
371    // if any of the below IO operations have errors, just ignore them
372    if let Ok(read_dir) = dir.read_dir() {
373        let file_name = normalize(file_name);
374        for entry in read_dir.flatten() {
375            if let Some(s) = entry.file_name().to_str() {
376                let ns = normalize(s);
377                if ns.starts_with(file_name.as_ref()) {
378                    if let Ok(metadata) = fs::metadata(entry.path()) {
379                        let mut path = String::from(dir_name) + s;
380                        if metadata.is_dir() {
381                            path.push(sep);
382                        }
383                        entries.push(Pair {
384                            display: String::from(s),
385                            replacement: escape(path, esc_char, break_chars, quote),
386                        });
387                    } // else ignore PermissionDenied
388                }
389            }
390        }
391    }
392    entries
393}
394
395#[cfg(any(windows, target_os = "macos"))]
396fn normalize(s: &str) -> Cow<str> {
397    // case insensitive
398    Owned(s.to_lowercase())
399}
400
401#[cfg(not(any(windows, target_os = "macos")))]
402fn normalize(s: &str) -> Cow<str> {
403    Cow::Borrowed(s)
404}
405
406/// Given a `line` and a cursor `pos`ition,
407/// try to find backward the start of a word.
408/// Return (0, `line[..pos]`) if no break char has been found.
409/// Return the word and its start position (idx, `line[idx..pos]`) otherwise.
410#[must_use]
411pub fn extract_word<'l>(
412    line: &'l str,
413    pos: usize,
414    esc_char: Option<char>,
415    break_chars: &[u8],
416) -> (usize, &'l str) {
417    let line = &line[..pos];
418    if line.is_empty() {
419        return (0, line);
420    }
421    let mut start = None;
422    for (i, c) in line.char_indices().rev() {
423        if let (Some(esc_char), true) = (esc_char, start.is_some()) {
424            if esc_char == c {
425                // escaped break char
426                start = None;
427                continue;
428            }
429            break;
430        }
431        if c.is_ascii() && memchr(c as u8, break_chars).is_some() {
432            start = Some(i + c.len_utf8());
433            if esc_char.is_none() {
434                break;
435            } // else maybe escaped...
436        }
437    }
438
439    match start {
440        Some(start) => (start, &line[start..]),
441        None => (0, line),
442    }
443}
444
445/// Returns the longest common prefix among all `Candidate::replacement()`s.
446pub fn longest_common_prefix<C: Candidate>(candidates: &[C]) -> Option<&str> {
447    if candidates.is_empty() {
448        return None;
449    } else if candidates.len() == 1 {
450        return Some(candidates[0].replacement());
451    }
452    let mut longest_common_prefix = 0;
453    'o: loop {
454        for (i, c1) in candidates.iter().enumerate().take(candidates.len() - 1) {
455            let b1 = c1.replacement().as_bytes();
456            let b2 = candidates[i + 1].replacement().as_bytes();
457            if b1.len() <= longest_common_prefix
458                || b2.len() <= longest_common_prefix
459                || b1[longest_common_prefix] != b2[longest_common_prefix]
460            {
461                break 'o;
462            }
463        }
464        longest_common_prefix += 1;
465    }
466    let candidate = candidates[0].replacement();
467    while !candidate.is_char_boundary(longest_common_prefix) {
468        longest_common_prefix -= 1;
469    }
470    if longest_common_prefix == 0 {
471        return None;
472    }
473    Some(&candidate[0..longest_common_prefix])
474}
475
476#[derive(Eq, PartialEq)]
477enum ScanMode {
478    DoubleQuote,
479    Escape,
480    EscapeInDoubleQuote,
481    Normal,
482    SingleQuote,
483}
484
485/// try to find an unclosed single/double quote in `s`.
486/// Return `None` if no unclosed quote is found.
487/// Return the unclosed quote position and if it is a double quote.
488fn find_unclosed_quote(s: &str) -> Option<(usize, Quote)> {
489    let char_indices = s.char_indices();
490    let mut mode = ScanMode::Normal;
491    let mut quote_index = 0;
492    for (index, char) in char_indices {
493        match mode {
494            ScanMode::DoubleQuote => {
495                if char == '"' {
496                    mode = ScanMode::Normal;
497                } else if char == '\\' {
498                    // both windows and unix support escape in double quote
499                    mode = ScanMode::EscapeInDoubleQuote;
500                }
501            }
502            ScanMode::Escape => {
503                mode = ScanMode::Normal;
504            }
505            ScanMode::EscapeInDoubleQuote => {
506                mode = ScanMode::DoubleQuote;
507            }
508            ScanMode::Normal => {
509                if char == '"' {
510                    mode = ScanMode::DoubleQuote;
511                    quote_index = index;
512                } else if char == '\\' && cfg!(not(windows)) {
513                    mode = ScanMode::Escape;
514                } else if char == '\'' && cfg!(not(windows)) {
515                    mode = ScanMode::SingleQuote;
516                    quote_index = index;
517                }
518            }
519            ScanMode::SingleQuote => {
520                if char == '\'' {
521                    mode = ScanMode::Normal;
522                } // no escape in single quotes
523            }
524        };
525    }
526    if ScanMode::DoubleQuote == mode || ScanMode::EscapeInDoubleQuote == mode {
527        return Some((quote_index, Quote::Double));
528    } else if ScanMode::SingleQuote == mode {
529        return Some((quote_index, Quote::Single));
530    }
531    None
532}
533
534#[cfg(test)]
535mod tests {
536    #[test]
537    pub fn extract_word() {
538        let break_chars: &[u8] = &super::DEFAULT_BREAK_CHARS;
539        let line = "ls '/usr/local/b";
540        assert_eq!(
541            (4, "/usr/local/b"),
542            super::extract_word(line, line.len(), Some('\\'), break_chars)
543        );
544        let line = "ls /User\\ Information";
545        assert_eq!(
546            (3, "/User\\ Information"),
547            super::extract_word(line, line.len(), Some('\\'), break_chars)
548        );
549    }
550
551    #[test]
552    pub fn unescape() {
553        use std::borrow::Cow::{self, Borrowed, Owned};
554        let input = "/usr/local/b";
555        assert_eq!(Borrowed(input), super::unescape(input, Some('\\')));
556        if cfg!(windows) {
557            let input = "c:\\users\\All Users\\";
558            let result: Cow<'_, str> = Borrowed(input);
559            assert_eq!(result, super::unescape(input, Some('\\')));
560        } else {
561            let input = "/User\\ Information";
562            let result: Cow<'_, str> = Owned(String::from("/User Information"));
563            assert_eq!(result, super::unescape(input, Some('\\')));
564        }
565    }
566
567    #[test]
568    pub fn escape() {
569        let break_chars: &[u8] = &super::DEFAULT_BREAK_CHARS;
570        let input = String::from("/usr/local/b");
571        assert_eq!(
572            input.clone(),
573            super::escape(input, Some('\\'), break_chars, super::Quote::None)
574        );
575        let input = String::from("/User Information");
576        let result = String::from("/User\\ Information");
577        assert_eq!(
578            result,
579            super::escape(input, Some('\\'), break_chars, super::Quote::None)
580        );
581    }
582
583    #[test]
584    pub fn longest_common_prefix() {
585        let mut candidates = vec![];
586        {
587            let lcp = super::longest_common_prefix(&candidates);
588            assert!(lcp.is_none());
589        }
590
591        let s = "User";
592        let c1 = String::from(s);
593        candidates.push(c1);
594        {
595            let lcp = super::longest_common_prefix(&candidates);
596            assert_eq!(Some(s), lcp);
597        }
598
599        let c2 = String::from("Users");
600        candidates.push(c2);
601        {
602            let lcp = super::longest_common_prefix(&candidates);
603            assert_eq!(Some(s), lcp);
604        }
605
606        let c3 = String::from("");
607        candidates.push(c3);
608        {
609            let lcp = super::longest_common_prefix(&candidates);
610            assert!(lcp.is_none());
611        }
612
613        let candidates = vec![String::from("fée"), String::from("fête")];
614        let lcp = super::longest_common_prefix(&candidates);
615        assert_eq!(Some("f"), lcp);
616    }
617
618    #[test]
619    pub fn find_unclosed_quote() {
620        assert_eq!(None, super::find_unclosed_quote("ls /etc"));
621        assert_eq!(
622            Some((3, super::Quote::Double)),
623            super::find_unclosed_quote("ls \"User Information")
624        );
625        assert_eq!(
626            None,
627            super::find_unclosed_quote("ls \"/User Information\" /etc")
628        );
629        assert_eq!(
630            Some((0, super::Quote::Double)),
631            super::find_unclosed_quote("\"c:\\users\\All Users\\")
632        )
633    }
634
635    #[cfg(windows)]
636    #[test]
637    pub fn normalize() {
638        assert_eq!(super::normalize("Windows"), "windows")
639    }
640}