| 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 |