Skip to main content

gshell/completion/
mod.rs

1use std::{
2    collections::BTreeSet,
3    env,
4    ffi::OsString,
5    fs,
6    path::{Path, PathBuf},
7};
8
9#[cfg(unix)]
10use std::os::unix::fs::PermissionsExt;
11
12use nu_ansi_term::Style;
13use reedline::{Completer, Hinter, SearchQuery, Span, Suggestion};
14
15use crate::{
16    builtins::BuiltinRegistry,
17    shell::{SharedShellState, ShellState},
18};
19
20#[derive(Clone)]
21pub struct ShellCompleter {
22    state: SharedShellState,
23}
24
25impl ShellCompleter {
26    pub fn new(state: SharedShellState) -> Self {
27        Self { state }
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum CompletionKind {
33    Command,
34    Path,
35    EnvVar,
36}
37
38#[derive(Debug, Clone)]
39struct CompletionContext {
40    kind: CompletionKind,
41    token: String,
42    span: Span,
43}
44
45impl Completer for ShellCompleter {
46    fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
47        let context = completion_context(line, pos);
48
49        let values = match context.kind {
50            CompletionKind::Command => self.complete_commands(&context.token),
51            CompletionKind::Path => self.complete_paths(&context.token),
52            CompletionKind::EnvVar => self.complete_env_vars(&context.token),
53        };
54
55        values
56            .into_iter()
57            .map(|value| Suggestion {
58                value,
59                display_override: None,
60                description: None,
61                style: None,
62                extra: None,
63                span: context.span,
64                append_whitespace: context.kind != CompletionKind::Path,
65                match_indices: None,
66            })
67            .collect()
68    }
69}
70
71impl ShellCompleter {
72    fn complete_commands(&self, prefix: &str) -> Vec<String> {
73        let mut out = BTreeSet::new();
74
75        for builtin in BuiltinRegistry::defaults().names() {
76            if builtin.starts_with(prefix) {
77                out.insert(builtin);
78            }
79        }
80
81        for function in self.read_state(|state| state.functions().names()) {
82            if function.starts_with(prefix) {
83                out.insert(function);
84            }
85        }
86
87        let path_var = self.current_path_var();
88
89        for dir in env::split_paths(&OsString::from(path_var)) {
90            let Ok(entries) = fs::read_dir(dir) else {
91                continue;
92            };
93
94            for entry in entries.flatten() {
95                let path = entry.path();
96
97                if !is_executable_file(&path) {
98                    continue;
99                }
100
101                let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
102                    continue;
103                };
104
105                if name.starts_with(prefix) {
106                    out.insert(name.to_string());
107                }
108            }
109        }
110
111        out.into_iter().collect()
112    }
113
114    fn complete_paths(&self, prefix: &str) -> Vec<String> {
115        let cwd = self.read_state(|state| state.cwd().to_path_buf());
116
117        let expanded = expand_tilde(prefix);
118        let typed_path = PathBuf::from(&expanded);
119
120        let (dir, needle, replace_base) = if prefix.is_empty() {
121            (cwd.clone(), String::new(), String::new())
122        } else if prefix.ends_with('/') {
123            let dir = absolutize_path(&cwd, &typed_path);
124            (dir, String::new(), prefix.to_string())
125        } else {
126            let parent = typed_path.parent().unwrap_or_else(|| Path::new(""));
127            let dir = if parent.as_os_str().is_empty() {
128                cwd.clone()
129            } else {
130                absolutize_path(&cwd, &PathBuf::from(parent))
131            };
132
133            let needle = typed_path
134                .file_name()
135                .and_then(|name| name.to_str())
136                .unwrap_or("")
137                .to_string();
138
139            let replace_base = prefix
140                .rsplit_once('/')
141                .map(|(base, _)| format!("{base}/"))
142                .unwrap_or_default();
143
144            (dir, needle, replace_base)
145        };
146
147        let Ok(entries) = fs::read_dir(&dir) else {
148            return Vec::new();
149        };
150
151        let mut out = BTreeSet::new();
152
153        for entry in entries.flatten() {
154            let path = entry.path();
155            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
156                continue;
157            };
158
159            if !name.starts_with(&needle) {
160                continue;
161            }
162
163            let mut value = format!("{replace_base}{name}");
164            if path.is_dir() {
165                value.push('/');
166            }
167
168            out.insert(value);
169        }
170
171        out.into_iter().collect()
172    }
173
174    fn complete_env_vars(&self, prefix: &str) -> Vec<String> {
175        let needle = prefix.strip_prefix('$').unwrap_or(prefix);
176
177        let mut out = BTreeSet::new();
178
179        let env_keys = self.read_state(|state| state.vars().keys().cloned().collect::<Vec<_>>());
180
181        for key in env_keys {
182            if key.starts_with(needle) {
183                out.insert(format!("${key}"));
184            }
185        }
186
187        out.into_iter().collect()
188    }
189
190    fn current_path_var(&self) -> String {
191        self.read_state(|state| {
192            state
193                .env_var("PATH")
194                .map(ToOwned::to_owned)
195                .unwrap_or_else(|| env::var("PATH").unwrap_or_default())
196        })
197    }
198
199    fn read_state<T, F>(&self, selector: F) -> T
200    where
201        T: Send + 'static,
202        F: FnOnce(&ShellState) -> T + Send + 'static,
203    {
204        if let Ok(guard) = self.state.try_read() {
205            return selector(&guard);
206        }
207
208        let state = self.state.clone();
209        std::thread::spawn(move || {
210            tokio::runtime::Builder::new_current_thread()
211                .enable_all()
212                .build()
213                .expect("temporary runtime should initialize")
214                .block_on(async move {
215                    let guard = state.read().await;
216                    selector(&guard)
217                })
218        })
219        .join()
220        .expect("state reader thread should not panic")
221    }
222}
223
224#[derive(Debug, Clone)]
225pub struct ShellHinter {
226    style: Style,
227    current_hint: String,
228}
229
230impl Default for ShellHinter {
231    fn default() -> Self {
232        Self {
233            style: Style::new(),
234            current_hint: String::new(),
235        }
236    }
237}
238
239impl ShellHinter {
240    pub fn with_style(mut self, style: Style) -> Self {
241        self.style = style;
242        self
243    }
244}
245
246impl Hinter for ShellHinter {
247    fn handle(
248        &mut self,
249        line: &str,
250        pos: usize,
251        history: &dyn reedline::History,
252        use_ansi: bool,
253        _cwd: &str,
254    ) -> String {
255        if pos != line.len() || line.trim().is_empty() {
256            self.current_hint.clear();
257            return String::new();
258        }
259
260        let search = line.to_string();
261        let hint = history
262            .search(SearchQuery::last_with_prefix(
263                search.clone(),
264                history.session(),
265            ))
266            .ok()
267            .and_then(|entries| entries.into_iter().next())
268            .and_then(|entry| {
269                if entry.command_line == search {
270                    None
271                } else {
272                    entry
273                        .command_line
274                        .get(search.len()..)
275                        .map(ToOwned::to_owned)
276                }
277            })
278            .unwrap_or_default();
279
280        self.current_hint = hint.clone();
281        if use_ansi && !hint.is_empty() {
282            self.style.paint(hint).to_string()
283        } else {
284            hint
285        }
286    }
287
288    fn complete_hint(&self) -> String {
289        self.current_hint.clone()
290    }
291
292    fn next_hint_token(&self) -> String {
293        self.current_hint
294            .split_whitespace()
295            .next()
296            .unwrap_or("")
297            .to_string()
298    }
299}
300
301fn completion_context(line: &str, pos: usize) -> CompletionContext {
302    let safe_pos = pos.min(line.len());
303    let before = &line[..safe_pos];
304
305    let token_start = before
306        .char_indices()
307        .rev()
308        .find(|(_, ch)| is_token_break(*ch))
309        .map(|(idx, ch)| idx + ch.len_utf8())
310        .unwrap_or(0);
311
312    let token = line[token_start..safe_pos].to_string();
313    let span = Span::new(token_start, safe_pos);
314
315    let before_token = before[..token_start].trim_end();
316
317    let kind = if token.starts_with('$') {
318        CompletionKind::EnvVar
319    } else if needs_path_completion(before_token, &token) {
320        CompletionKind::Path
321    } else if is_command_position(before_token) {
322        CompletionKind::Command
323    } else {
324        CompletionKind::Path
325    };
326
327    CompletionContext { kind, token, span }
328}
329
330fn is_token_break(ch: char) -> bool {
331    ch.is_whitespace() || matches!(ch, '|' | ';' | '(' | ')' | '<' | '>')
332}
333
334fn is_command_position(before_token: &str) -> bool {
335    before_token.is_empty()
336        || before_token.ends_with('|')
337        || before_token.ends_with("&&")
338        || before_token.ends_with("||")
339        || before_token.ends_with(';')
340        || before_token.ends_with('(')
341}
342
343fn needs_path_completion(before_token: &str, token: &str) -> bool {
344    token.contains('/')
345        || token.starts_with('.')
346        || token.starts_with('~')
347        || before_token.ends_with('<')
348        || before_token.ends_with('>')
349        || before_token.ends_with(">>")
350        || before_token.ends_with("2>")
351        || before_token.ends_with("2>>")
352}
353
354fn expand_tilde(input: &str) -> String {
355    if (input == "~" || input.starts_with("~/"))
356        && let Ok(home) = env::var("HOME")
357    {
358        return format!("{home}{}", &input[1..]);
359    }
360
361    input.to_string()
362}
363
364fn absolutize_path(cwd: &Path, path: &Path) -> PathBuf {
365    if path.is_absolute() {
366        path.to_path_buf()
367    } else {
368        cwd.join(path)
369    }
370}
371
372fn is_executable_file(path: &Path) -> bool {
373    let Ok(metadata) = path.metadata() else {
374        return false;
375    };
376
377    if !metadata.is_file() {
378        return false;
379    }
380
381    #[cfg(unix)]
382    {
383        metadata.permissions().mode() & 0o111 != 0
384    }
385
386    #[cfg(not(unix))]
387    {
388        false
389    }
390}