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