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}