fm/modes/menu/
completion.rs

1use std::fmt;
2use std::fs::{self, ReadDir};
3use std::path::Path;
4
5use ratatui::style::{Modifier, Style};
6
7use crate::common::{is_in_path, tilde, UtfWidth, ZOXIDE};
8use crate::event::ActionMap;
9use crate::io::{execute_and_capture_output_with_path, DrawMenu};
10use crate::modes::{CursorOffset, Leave};
11use crate::{impl_content, impl_selectable};
12
13/// Different kind of completions
14#[derive(Clone, Default, Copy, Eq, PartialEq)]
15pub enum InputCompleted {
16    #[default]
17    /// Complete a directory path in filesystem
18    Cd,
19    /// Complete a filename from current directory
20    Search,
21    /// Complete an executable name from $PATH
22    Exec,
23    /// Complete with an existing action
24    Action,
25}
26
27impl fmt::Display for InputCompleted {
28    #[rustfmt::skip]
29    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
30        match *self {
31            Self::Exec      => write!(f, "Open with: "),
32            Self::Cd        => write!(f, "Cd:        "),
33            Self::Search    => write!(f, "Search:    "),
34            Self::Action    => write!(f, "Action:    "),
35        }
36    }
37}
38
39impl CursorOffset for InputCompleted {
40    fn cursor_offset(&self) -> u16 {
41        self.to_string().utf_width_u16() + 2
42    }
43}
44
45impl Leave for InputCompleted {
46    fn must_refresh(&self) -> bool {
47        true
48    }
49
50    fn must_reset_mode(&self) -> bool {
51        !matches!(self, Self::Action)
52    }
53}
54
55/// Holds a `Vec<String>` of possible completions and an `usize` index
56/// showing where the user is in the vec.
57#[derive(Clone, Default)]
58pub struct Completion {
59    /// Possible completions
60    pub content: Vec<String>,
61    /// Which completion is selected by the user
62    pub index: usize,
63}
64
65impl Completion {
66    /// Is there any completion option ?
67    pub fn is_empty(&self) -> bool {
68        self.content.is_empty()
69    }
70
71    /// Returns the currently selected proposition.
72    /// Returns an empty string if `proposals` is empty.
73    pub fn current_proposition(&self) -> &str {
74        if self.content.is_empty() {
75            return "";
76        }
77        &self.content[self.index]
78    }
79
80    /// Updates the proposition with a new `Vec`.
81    /// Reset the index to 0.
82    fn update(&mut self, proposals: Vec<String>) {
83        self.index = 0;
84        self.content = proposals;
85        self.content.dedup()
86    }
87
88    fn extend(&mut self, proposals: &[String]) {
89        self.index = 0;
90        self.content.extend_from_slice(proposals);
91        self.content.dedup()
92    }
93
94    /// Empty the proposals `Vec`.
95    /// Reset the index.
96    pub fn reset(&mut self) {
97        self.index = 0;
98        self.content.clear();
99    }
100
101    /// Cd completion.
102    /// Looks for the valid path completing what the user typed.
103    pub fn cd(&mut self, current_path: &str, input_string: &str) {
104        self.cd_update_from_input(input_string, current_path);
105        let (parent, last_name) = split_input_string(input_string);
106        if !last_name.is_empty() {
107            self.extend_absolute_paths(&parent, &last_name);
108            self.extend_relative_paths(current_path, &last_name);
109        }
110        self.extend_with_children(input_string);
111    }
112
113    fn cd_update_from_input(&mut self, input_string: &str, current_path: &str) {
114        self.content = vec![];
115        self.cd_update_from_zoxide(input_string, current_path);
116        if let Some(expanded_input) = self.expand_input(input_string) {
117            let formated = Self::attach_slash_to_dirs(&expanded_input);
118            self.content.push(formated);
119        }
120        if let Some(cannonicalized_input) = self.canonicalize_input(input_string, current_path) {
121            let formated = Self::attach_slash_to_dirs(&cannonicalized_input);
122            self.content.push(formated);
123        }
124    }
125
126    /// Children of current input if it ends with  a /
127    fn extend_with_children(&mut self, input_string: &str) {
128        if !input_string.ends_with('/') {
129            return;
130        }
131        let input_path = std::path::Path::new(input_string);
132        if !input_path.is_dir() {
133            return;
134        }
135        let Ok(entries) = fs::read_dir(input_path) else {
136            return;
137        };
138        let children: Vec<String> = entries
139            .filter_map(|e| e.ok())
140            .map(|e| e.path().to_string_lossy().into_owned())
141            .map(|path_str| Self::attach_slash_to_dirs(&path_str))
142            .collect();
143        self.extend(&children);
144    }
145
146    fn cd_update_from_zoxide(&mut self, input_string: &str, current_path: &str) {
147        if !is_in_path(ZOXIDE) {
148            return;
149        }
150        let mut args = vec!["query"];
151        args.extend(input_string.split(' '));
152        let Ok(zoxide_output) = execute_and_capture_output_with_path(ZOXIDE, current_path, &args)
153        else {
154            return;
155        };
156        if !zoxide_output.is_empty() {
157            self.content
158                .push(Self::attach_slash_to_dirs(zoxide_output.trim()));
159        }
160    }
161
162    fn attach_slash_to_dirs<T: AsRef<str> + std::string::ToString + std::fmt::Display>(
163        path_str: T,
164    ) -> String {
165        let p = Path::new(path_str.as_ref());
166        if !path_str.as_ref().ends_with('/') && p.exists() && p.is_dir() {
167            format!("{path_str}/")
168        } else {
169            path_str.to_string()
170        }
171    }
172
173    fn expand_input(&mut self, input_string: &str) -> Option<String> {
174        let expanded_input = tilde(input_string).into_owned();
175        if std::path::PathBuf::from(&expanded_input).exists() {
176            Some(expanded_input)
177        } else {
178            None
179        }
180    }
181
182    fn canonicalize_input(&mut self, input_string: &str, current_path: &str) -> Option<String> {
183        let mut path = fs::canonicalize(current_path).unwrap_or_default();
184        path.push(input_string);
185        let path = fs::canonicalize(path).unwrap_or_default();
186        if path.exists() {
187            Some(path.to_str().unwrap_or_default().to_owned())
188        } else {
189            None
190        }
191    }
192
193    fn extend_absolute_paths(&mut self, parent: &str, last_name: &str) {
194        let Ok(path) = std::fs::canonicalize(parent) else {
195            return;
196        };
197        let Ok(entries) = fs::read_dir(path) else {
198            return;
199        };
200        self.extend(&Self::entries_matching_filename(entries, last_name))
201    }
202
203    fn extend_relative_paths(&mut self, current_path: &str, last_name: &str) {
204        if let Ok(entries) = fs::read_dir(current_path) {
205            self.extend(&Self::entries_matching_filename(entries, last_name))
206        }
207    }
208
209    fn entries_matching_filename(entries: ReadDir, last_name: &str) -> Vec<String> {
210        entries
211            .filter_map(|e| e.ok())
212            .filter(|e| e.file_type().is_ok())
213            .filter(|e| e.file_type().unwrap().is_dir() && filename_startswith(e, last_name))
214            .map(|e| e.path().to_string_lossy().into_owned())
215            .map(|path_str| Self::attach_slash_to_dirs(&path_str))
216            .collect()
217    }
218
219    /// Looks for programs in $PATH completing the one typed by the user.
220    pub fn exec(&mut self, input_string: &str) {
221        let mut proposals: Vec<String> = vec![];
222        if let Some(paths) = std::env::var_os("PATH") {
223            for path in std::env::split_paths(&paths).filter(|path| path.exists()) {
224                proposals.extend(Self::find_completion_in_path(path, input_string));
225            }
226        }
227        self.update(proposals);
228    }
229
230    /// Looks for fm actions completing the one typed by the user.
231    pub fn action(&mut self, input_string: &str) {
232        self.update(ActionMap::actions_matching(input_string.to_lowercase()));
233    }
234
235    fn find_completion_in_path(path: std::path::PathBuf, input_string: &str) -> Vec<String> {
236        let Ok(entries) = fs::read_dir(path) else {
237            return vec![];
238        };
239        entries
240            .filter_map(|e| e.ok())
241            .filter(|e| file_match_input(e, input_string))
242            .map(|e| e.path().to_string_lossy().into_owned())
243            .collect()
244    }
245
246    /// Looks for file within current folder completing what the user typed.
247    pub fn search(&mut self, files: Vec<String>) {
248        self.update(files);
249    }
250
251    /// Complete the input string with current_proposition if possible.
252    /// Returns the optional last chars of the current_proposition.
253    /// If the current_proposition doesn't start with input_string, it returns None.
254    pub fn complete_input_string(&self, input_string: &str) -> Option<&str> {
255        self.current_proposition().strip_prefix(input_string)
256    }
257
258    /// Reverse the received effect if the index match the selected index.
259    pub fn style(&self, index: usize, style: &Style) -> Style {
260        let mut style = *style;
261        if index == self.index {
262            style.add_modifier |= Modifier::REVERSED;
263        }
264        style
265    }
266}
267
268fn file_match_input(dir_entry: &std::fs::DirEntry, input_string: &str) -> bool {
269    let Ok(file_type) = dir_entry.file_type() else {
270        return false;
271    };
272    (file_type.is_file() || file_type.is_symlink()) && filename_startswith(dir_entry, input_string)
273}
274
275/// true if the filename starts with a pattern
276fn filename_startswith(entry: &std::fs::DirEntry, pattern: &str) -> bool {
277    entry
278        .file_name()
279        .to_string_lossy()
280        .as_ref()
281        .starts_with(pattern)
282}
283
284fn split_input_string(input_string: &str) -> (String, String) {
285    let steps = input_string.split('/');
286    let mut vec_steps: Vec<&str> = steps.collect();
287    let last_name = vec_steps.pop().unwrap_or("").to_owned();
288    let parent = create_parent(vec_steps);
289    (parent, last_name)
290}
291
292fn create_parent(vec_steps: Vec<&str>) -> String {
293    let mut parent = if vec_steps.is_empty() || vec_steps.len() == 1 && vec_steps[0] != "~" {
294        "/".to_owned()
295    } else {
296        "".to_owned()
297    };
298    parent.push_str(&vec_steps.join("/"));
299    tilde(&parent).to_string()
300}
301
302impl_selectable!(Completion);
303impl_content!(Completion, String);
304
305impl DrawMenu<String> for Completion {}