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