Rust · 14601 bytes Raw Blame History
1 use crate::completion_spec::{with_registry, CompletionSource};
2 use reedline::{Completer, Span, Suggestion};
3 use std::env;
4 use std::fs;
5 use std::path::PathBuf;
6
7 /// Smart tab completer for Rush shell
8 ///
9 /// Provides context-aware completions:
10 /// - Command names from PATH (when first word)
11 /// - Command-specific completions (from `complete` builtin)
12 /// - File/directory names (for arguments)
13 /// - Variable names (when typing $VAR)
14 pub struct RushCompleter;
15
16 impl RushCompleter {
17 pub fn new() -> Self {
18 Self
19 }
20
21 /// Get all executable commands from PATH
22 fn get_commands_from_path() -> Vec<String> {
23 let mut commands = Vec::new();
24
25 if let Some(path_var) = env::var_os("PATH") {
26 for dir in env::split_paths(&path_var) {
27 if let Ok(entries) = fs::read_dir(dir) {
28 for entry in entries.flatten() {
29 if let Ok(file_type) = entry.file_type() {
30 // Include both regular files and symlinks (many executables are symlinks)
31 if file_type.is_file() || file_type.is_symlink() {
32 // Use fs::metadata(path) which follows symlinks, NOT entry.metadata()
33 // entry.metadata() does NOT follow symlinks (like lstat)
34 if let Ok(metadata) = fs::metadata(entry.path()) {
35 // Only include if target is a file (not a directory symlink)
36 if !metadata.is_file() {
37 continue;
38 }
39 #[cfg(unix)]
40 {
41 use std::os::unix::fs::PermissionsExt;
42 if metadata.permissions().mode() & 0o111 != 0 {
43 if let Some(name) = entry.file_name().to_str() {
44 commands.push(name.to_string());
45 }
46 }
47 }
48 #[cfg(not(unix))]
49 {
50 if let Some(name) = entry.file_name().to_str() {
51 commands.push(name.to_string());
52 }
53 }
54 }
55 }
56 }
57 }
58 }
59 }
60 }
61
62 // Add built-in commands
63 let builtins = [
64 "cd", "pwd", "exit", "true", "false", "test", "[",
65 "eval", "alias", "unalias", "trap", "set", "shopt",
66 "export", "unset", "readonly", "declare", "typeset", "local",
67 "read", "shift", "wait", "kill", "times", "umask", "hash",
68 "getopts", "exec", "command", "jobs", "fg", "bg",
69 "coproc", "disown", "printf", "mapfile", "readarray",
70 "break", "continue", "return", "source", ".", "complete",
71 "pushd", "popd", "dirs",
72 ];
73 commands.extend(builtins.iter().map(|s| s.to_string()));
74
75 // Sort and deduplicate
76 commands.sort();
77 commands.dedup();
78 commands
79 }
80
81 /// Get file/directory completions for a partial path
82 fn get_file_completions(partial: &str) -> Vec<String> {
83 let mut completions = Vec::new();
84
85 // Determine the directory to search and the prefix to match
86 let (search_dir, prefix) = if partial.is_empty() {
87 // Empty partial - list current directory
88 (PathBuf::from("."), String::new())
89 } else if partial.ends_with('/') {
90 // Path ends with / - list contents of that directory
91 (PathBuf::from(partial), String::new())
92 } else if partial.contains('/') {
93 // Path contains / but doesn't end with it - split into dir and partial filename
94 let path = PathBuf::from(partial);
95 let parent = path.parent()
96 .unwrap_or_else(|| std::path::Path::new("."))
97 .to_path_buf();
98 let file_name = path.file_name()
99 .and_then(|n| n.to_str())
100 .unwrap_or("")
101 .to_string();
102 (parent, file_name)
103 } else {
104 // No / - search current directory
105 (PathBuf::from("."), partial.to_string())
106 };
107
108 // Should we show hidden files? Only if partial starts with '.'
109 let show_hidden = prefix.starts_with('.');
110
111 // Read directory and find matches
112 if let Ok(entries) = fs::read_dir(&search_dir) {
113 for entry in entries.flatten() {
114 if let Some(name) = entry.file_name().to_str() {
115 // Skip hidden files unless explicitly requested
116 if name.starts_with('.') && !show_hidden {
117 continue;
118 }
119
120 if name.starts_with(&prefix) {
121 // Build the full completion path
122 let mut completion = if partial.is_empty() {
123 // Empty partial - just the name
124 name.to_string()
125 } else if partial.ends_with('/') {
126 // partial is "dir/" - completion is "dir/name"
127 format!("{}{}", partial, name)
128 } else if partial.contains('/') {
129 // partial is "dir/partial" - completion is "dir/name"
130 let parent_path = PathBuf::from(partial);
131 let parent = parent_path
132 .parent()
133 .unwrap_or_else(|| std::path::Path::new("."));
134 parent.join(name)
135 .to_string_lossy()
136 .to_string()
137 } else {
138 // partial is just "name" - completion is "name"
139 name.to_string()
140 };
141
142 // Add trailing slash for directories
143 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
144 completion.push('/');
145 }
146
147 completions.push(completion);
148 }
149 }
150 }
151 }
152
153 completions.sort();
154 completions
155 }
156
157 /// Check if we're completing the first word (command name)
158 fn is_first_word(line: &str, pos: usize) -> bool {
159 let before_cursor = &line[..pos];
160 !before_cursor.contains(char::is_whitespace)
161 }
162
163 /// Get the partial word being completed
164 fn get_partial_word(line: &str, pos: usize) -> (usize, &str) {
165 let before_cursor = &line[..pos];
166 let start = before_cursor
167 .rfind(|c: char| c.is_whitespace())
168 .map(|i| i + 1)
169 .unwrap_or(0);
170 (start, &line[start..pos])
171 }
172
173 /// Parse the command line to get command name and argument position
174 fn parse_command_line(line: &str, pos: usize) -> Option<(String, Vec<String>, usize)> {
175 let before_cursor = &line[..pos];
176 let words: Vec<&str> = before_cursor.split_whitespace().collect();
177
178 if words.is_empty() {
179 return None;
180 }
181
182 let command = words[0].to_string();
183 let args: Vec<String> = words[1..].iter().map(|s| s.to_string()).collect();
184 let arg_index = if args.is_empty() { 0 } else { args.len() - 1 };
185
186 Some((command, args, arg_index))
187 }
188
189 /// Get command-specific completions from the registry
190 fn get_command_completions(command: &str, partial: &str) -> Vec<(String, Option<String>)> {
191 let mut completions = Vec::new();
192
193 with_registry(|registry| {
194 if let Some(specs) = registry.get(command) {
195 for spec in specs {
196 // TODO: Check condition if specified
197 // For now, we'll skip condition checking
198
199 match &spec.source {
200 CompletionSource::Static(items) => {
201 for item in items {
202 if item.starts_with(partial) {
203 completions.push((
204 item.clone(),
205 spec.description.clone(),
206 ));
207 }
208 }
209 }
210 CompletionSource::Dynamic(cmd) => {
211 // Execute the command and use output lines as completions
212 if let Ok(output) = std::process::Command::new("sh")
213 .arg("-c")
214 .arg(cmd)
215 .output()
216 {
217 let stdout = String::from_utf8_lossy(&output.stdout);
218 for line in stdout.lines() {
219 let trimmed = line.trim();
220 if !trimmed.is_empty() && trimmed.starts_with(partial) {
221 completions.push((
222 trimmed.to_string(),
223 spec.description.clone(),
224 ));
225 }
226 }
227 }
228 }
229 CompletionSource::ShortOption(c) => {
230 let opt = format!("-{}", c);
231 if opt.starts_with(partial) {
232 completions.push((opt, spec.description.clone()));
233 }
234 }
235 CompletionSource::LongOption(s) => {
236 let opt = format!("--{}", s);
237 if opt.starts_with(partial) {
238 completions.push((opt, spec.description.clone()));
239 }
240 }
241 CompletionSource::Option { short, long } => {
242 if let Some(c) = short {
243 let opt = format!("-{}", c);
244 if opt.starts_with(partial) {
245 completions.push((opt, spec.description.clone()));
246 }
247 }
248 if let Some(l) = long {
249 let opt = format!("--{}", l);
250 if opt.starts_with(partial) {
251 completions.push((opt, spec.description.clone()));
252 }
253 }
254 }
255 }
256 }
257 }
258 });
259
260 completions
261 }
262
263 /// Check if a command has file completion disabled
264 fn has_no_files(command: &str) -> bool {
265 with_registry(|registry| registry.has_no_files(command))
266 }
267 }
268
269 impl Default for RushCompleter {
270 fn default() -> Self {
271 Self::new()
272 }
273 }
274
275 impl Completer for RushCompleter {
276 fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
277 let (start, partial) = Self::get_partial_word(line, pos);
278
279 let span = Span::new(start, pos);
280 let mut suggestions = Vec::new();
281
282 if Self::is_first_word(line, pos) {
283 // Complete command names - skip if nothing typed yet
284 if partial.is_empty() {
285 return vec![];
286 }
287 for cmd in Self::get_commands_from_path() {
288 if cmd.starts_with(partial) {
289 suggestions.push(Suggestion {
290 value: cmd.clone(),
291 description: None,
292 style: None,
293 extra: None,
294 span,
295 append_whitespace: true,
296 });
297 }
298 }
299 } else {
300 // Parse the command line to get the command being completed
301 if let Some((command, _args, _arg_idx)) = Self::parse_command_line(line, pos) {
302 // Get command-specific completions
303 let cmd_completions = Self::get_command_completions(&command, partial);
304
305 for (value, desc) in cmd_completions {
306 suggestions.push(Suggestion {
307 value,
308 description: desc,
309 style: None,
310 extra: None,
311 span,
312 append_whitespace: true,
313 });
314 }
315
316 // Add file completions unless disabled for this command
317 if !Self::has_no_files(&command) {
318 for file in Self::get_file_completions(partial) {
319 suggestions.push(Suggestion {
320 value: file.clone(),
321 description: None,
322 style: None,
323 extra: None,
324 span,
325 append_whitespace: !file.ends_with('/'),
326 });
327 }
328 }
329 } else {
330 // Fallback to file completions if we can't parse the command
331 for file in Self::get_file_completions(partial) {
332 suggestions.push(Suggestion {
333 value: file.clone(),
334 description: None,
335 style: None,
336 extra: None,
337 span,
338 append_whitespace: !file.ends_with('/'),
339 });
340 }
341 }
342 }
343
344 // Remove duplicates (command completions might overlap with files)
345 suggestions.sort_by(|a, b| a.value.cmp(&b.value));
346 suggestions.dedup_by(|a, b| a.value == b.value);
347
348 suggestions
349 }
350 }
351