cliclack_file_autocompletion/
lib.rs1#![doc = include_str!("../README.md")]
2
3use cliclack::{Input, Suggest};
4use path_clean::PathClean;
5use std::fs;
6use std::path::PathBuf;
7
8pub struct FileSettings {
10 pub show_hidden: bool,
12 pub allow_folders: bool,
14 pub allow_files: bool,
16 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
31pub struct FileSuggest {
36 root: PathBuf,
37 settings: FileSettings,
38}
39
40impl FileSuggest {
41 pub fn new(root: impl Into<PathBuf>) -> Self {
46 Self {
47 root: root.into(),
48 settings: FileSettings::default(),
49 }
50 }
51
52 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
157pub trait FileAutocompleteExt {
159 fn file_autocomplete(self) -> Self;
161 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}