Skip to main content

cliclack_file_autocompletion/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use cliclack::{Input, Suggest};
4use path_clean::PathClean;
5use std::fs;
6use std::path::PathBuf;
7
8/// Controls which filesystem entries are shown and accepted.
9pub struct FileSettings {
10    /// Show entries whose names begin with `.`. Defaults to `false`.
11    pub show_hidden: bool,
12    /// Allow the user to submit a directory as the final value. Defaults to `true`.
13    pub allow_folders: bool,
14    /// Allow the user to submit a file as the final value. Defaults to `true`.
15    pub allow_files: bool,
16    /// Restrict suggestions to files with these extensions. `None` means all extensions.
17    pub extensions: Option<Vec<String>>,
18}
19
20impl Default for FileSettings {
21    fn default() -> Self {
22        Self {
23            show_hidden: false,
24            allow_folders: true,
25            allow_files: true,
26            extensions: None,
27        }
28    }
29}
30
31/// A [`cliclack::Suggest`] implementation that reads the filesystem.
32///
33/// Pass to [`cliclack::Input::autocomplete`] directly, or use the
34/// [`FileAutocompleteExt`] convenience methods.
35pub struct FileSuggest {
36    root: PathBuf,
37    settings: FileSettings,
38}
39
40impl FileSuggest {
41    /// Create a new `FileSuggest` rooted at `root`.
42    ///
43    /// Relative paths typed by the user are resolved against `root`.
44    /// Absolute paths bypass `root` entirely.
45    pub fn new(root: impl Into<PathBuf>) -> Self {
46        Self {
47            root: root.into(),
48            settings: FileSettings::default(),
49        }
50    }
51
52    /// Replace the default [`FileSettings`].
53    pub fn with_settings(mut self, settings: FileSettings) -> Self {
54        self.settings = settings;
55        self
56    }
57}
58
59impl Suggest for FileSuggest {
60    type Result = String;
61
62    fn suggest(&self, input: &str) -> Vec<String> {
63        let path = if input.is_empty() {
64            self.root.clone()
65        } else {
66            let expanded = shellexpand::tilde(input).to_string();
67            let raw = PathBuf::from(&expanded);
68            if raw.is_absolute() {
69                raw.clean()
70            } else {
71                self.root.join(raw).clean()
72            }
73        };
74
75        let (search_dir, fragment) = if path.is_dir() {
76            (path, String::new())
77        } else {
78            let parent = path
79                .parent()
80                .map(|p| p.to_path_buf())
81                .unwrap_or_else(|| self.root.clone());
82            let fragment = path
83                .file_name()
84                .and_then(|f| f.to_str())
85                .unwrap_or("")
86                .to_lowercase();
87            (parent, fragment)
88        };
89
90        let entries = match fs::read_dir(&search_dir) {
91            Ok(e) => e,
92            Err(_) => return vec![],
93        };
94
95        entries
96            .filter_map(|e| e.ok())
97            .filter(|entry| {
98                let name = entry.file_name();
99                let name_str = name.to_string_lossy();
100
101                if !self.settings.show_hidden && name_str.starts_with('.') {
102                    return false;
103                }
104
105                let Ok(file_type) = entry.file_type() else {
106                    return false;
107                };
108
109                if file_type.is_file() && !self.settings.allow_files {
110                    return false;
111                }
112
113                if file_type.is_dir() && fragment.is_empty() {
114                    return false;
115                }
116
117                if file_type.is_file() {
118                    if let Some(exts) = &self.settings.extensions {
119                        let entry_path = entry.path();
120                        let ext = entry_path
121                            .extension()
122                            .and_then(|e| e.to_str())
123                            .unwrap_or("");
124                        if !exts.iter().any(|e| e == ext) {
125                            return false;
126                        }
127                    }
128                }
129
130                if !fragment.is_empty() {
131                    return name_str.to_lowercase().starts_with(&fragment);
132                }
133
134                true
135            })
136            .filter_map(|entry| entry.path().to_str().map(|s| s.to_string()))
137            .collect()
138    }
139}
140
141fn make_validator(
142    allow_folders: bool,
143    allow_files: bool,
144) -> impl Fn(&String) -> Result<(), &'static str> {
145    move |input: &String| {
146        let path = PathBuf::from(shellexpand::tilde(input).as_ref()).clean();
147        if !allow_folders && path.is_dir() {
148            return Err("Cannot select a folder");
149        }
150        if !allow_files && path.is_file() {
151            return Err("Cannot select a file");
152        }
153        Ok(())
154    }
155}
156
157/// Extension methods on [`cliclack::Input`] for file path autocompletion.
158pub trait FileAutocompleteExt {
159    /// Attach file autocompletion with default [`FileSettings`].
160    fn file_autocomplete(self) -> Self;
161    /// Attach file autocompletion with custom [`FileSettings`].
162    ///
163    /// `allow_folders` and `allow_files` are enforced at submission via validation.
164    fn file_autocomplete_with(self, settings: FileSettings) -> Self;
165}
166
167impl FileAutocompleteExt for Input {
168    fn file_autocomplete(self) -> Self {
169        let defaults = FileSettings::default();
170        self.autocomplete(FileSuggest::new("."))
171            .validate(make_validator(defaults.allow_folders, defaults.allow_files))
172    }
173
174    fn file_autocomplete_with(self, settings: FileSettings) -> Self {
175        let allow_folders = settings.allow_folders;
176        let allow_files = settings.allow_files;
177        self.autocomplete(FileSuggest::new(".").with_settings(settings))
178            .validate(make_validator(allow_folders, allow_files))
179    }
180}