Skip to content

Commit 7a197ad

Browse files
authored
Fix tab completion with spaces (#272)
1 parent a9fedf3 commit 7a197ad

File tree

1 file changed

+186
-14
lines changed

1 file changed

+186
-14
lines changed

crates/shell/src/completion.rs

Lines changed: 186 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,44 @@ impl Completer for ShellCompleter {
5050
}
5151

5252
fn extract_word(line: &str, pos: usize) -> (usize, &str) {
53-
if line.ends_with(' ') {
54-
return (pos, "");
53+
if pos == 0 {
54+
return (0, "");
5555
}
56-
let words: Vec<_> = line[..pos].split_whitespace().collect();
57-
let word_start = words.last().map_or(0, |w| line.rfind(w).unwrap());
58-
(word_start, &line[word_start..pos])
56+
57+
let bytes = line.as_bytes();
58+
59+
// Walk backwards from pos to find the start of the word
60+
let mut i = pos;
61+
while i > 0 {
62+
i -= 1;
63+
let ch = bytes[i] as char;
64+
65+
// Check for word boundary characters
66+
if ch == ' ' || ch == '|' || ch == '&' || ch == ';' || ch == '<' || ch == '>' || ch == '\t'
67+
{
68+
// Count preceding backslashes to see if this character is escaped
69+
let mut num_backslashes = 0;
70+
let mut j = i;
71+
while j > 0 {
72+
j -= 1;
73+
if bytes[j] == b'\\' {
74+
num_backslashes += 1;
75+
} else {
76+
break;
77+
}
78+
}
79+
80+
// If even number of backslashes (including 0), the character is NOT escaped
81+
if num_backslashes % 2 == 0 {
82+
// This is an unescaped word boundary
83+
return (i + 1, &line[i + 1..pos]);
84+
}
85+
// Odd number of backslashes means the character is escaped, continue
86+
}
87+
}
88+
89+
// Reached the beginning of the line
90+
(0, &line[0..pos])
5991
}
6092

6193
fn escape_for_shell(s: &str) -> String {
@@ -156,17 +188,43 @@ fn is_executable(entry: &fs::DirEntry) -> bool {
156188
}
157189

158190
fn resolve_dir_path(dir_path: &str) -> PathBuf {
191+
// Unescape the directory path to handle spaces and other special characters
192+
let unescaped = unescape_for_completion(dir_path);
193+
159194
if dir_path.starts_with('/') {
160-
PathBuf::from(dir_path)
195+
PathBuf::from(unescaped)
161196
} else if let Some(stripped) = dir_path.strip_prefix('~') {
197+
let unescaped_stripped = unescape_for_completion(stripped);
162198
dirs::home_dir()
163-
.map(|h| h.join(stripped.strip_prefix('/').unwrap_or(stripped)))
164-
.unwrap_or_else(|| PathBuf::from(dir_path))
199+
.map(|h| {
200+
h.join(
201+
unescaped_stripped
202+
.strip_prefix('/')
203+
.unwrap_or(&unescaped_stripped),
204+
)
205+
})
206+
.unwrap_or_else(|| PathBuf::from(unescaped))
165207
} else {
166-
PathBuf::from(".").join(dir_path)
208+
PathBuf::from(".").join(unescaped)
167209
}
168210
}
169211

212+
fn unescape_for_completion(s: &str) -> String {
213+
let mut result = String::with_capacity(s.len());
214+
let mut chars = s.chars();
215+
while let Some(ch) = chars.next() {
216+
if ch == '\\' {
217+
// Skip the backslash and take the next character literally
218+
if let Some(next_ch) = chars.next() {
219+
result.push(next_ch);
220+
}
221+
} else {
222+
result.push(ch);
223+
}
224+
}
225+
result
226+
}
227+
170228
fn complete_filenames(is_start: bool, word: &str, matches: &mut Vec<Pair>) {
171229
let (dir_path, partial_name) = match word.rfind('/') {
172230
Some(last_slash) => (&word[..=last_slash], &word[last_slash + 1..]),
@@ -177,12 +235,15 @@ fn complete_filenames(is_start: bool, word: &str, matches: &mut Vec<Pair>) {
177235
let only_executable = (word.starts_with("./") || word.starts_with('/')) && is_start;
178236
let show_hidden = partial_name.starts_with('.');
179237

238+
// Unescape the partial name for matching against actual filenames
239+
let unescaped_partial = unescape_for_completion(partial_name);
240+
180241
let files: Vec<FileMatch> = fs::read_dir(&search_dir)
181242
.into_iter()
182243
.flatten()
183244
.flatten()
184245
.filter_map(|entry| FileMatch::from_entry(entry, &search_dir, show_hidden))
185-
.filter(|f| f.name.starts_with(partial_name))
246+
.filter(|f| f.name.starts_with(&unescaped_partial))
186247
.filter(|f| !only_executable || f.is_executable || f.is_dir)
187248
.collect();
188249

@@ -257,8 +318,14 @@ mod tests {
257318
use super::*;
258319
use rustyline::history::DefaultHistory;
259320
use std::fs;
321+
use std::path::Path;
260322
use tempfile::TempDir;
261323

324+
// Helper function to convert a path to a shell-escaped string
325+
fn path_to_escaped_string(path: &Path) -> String {
326+
escape_for_shell(&path.display().to_string())
327+
}
328+
262329
#[tokio::test]
263330
async fn test_complete_hidden_files_when_starting_with_dot() {
264331
let temp_dir = TempDir::new().unwrap();
@@ -273,7 +340,8 @@ mod tests {
273340
// Test completion with "." prefix
274341
let completer = ShellCompleter::new(HashSet::new());
275342
let history = DefaultHistory::new();
276-
let line = format!("cat {}/.gi", temp_path.display());
343+
let escaped_path = path_to_escaped_string(temp_path);
344+
let line = format!("cat {}/.gi", escaped_path);
277345
let pos = line.len();
278346
let (_start, matches) = completer
279347
.complete(&line, pos, &Context::new(&history))
@@ -300,7 +368,8 @@ mod tests {
300368
// Test completion without "." prefix
301369
let completer = ShellCompleter::new(HashSet::new());
302370
let history = DefaultHistory::new();
303-
let line = format!("cat {}/", temp_path.display());
371+
let escaped_path = path_to_escaped_string(temp_path);
372+
let line = format!("cat {}/", escaped_path);
304373
let pos = line.len();
305374
let (_start, matches) = completer
306375
.complete(&line, pos, &Context::new(&history))
@@ -325,7 +394,8 @@ mod tests {
325394
// Test completion with ".gith" prefix
326395
let completer = ShellCompleter::new(HashSet::new());
327396
let history = DefaultHistory::new();
328-
let line = format!("cd {}/.gith", temp_path.display());
397+
let escaped_path = path_to_escaped_string(temp_path);
398+
let line = format!("cd {}/.gith", escaped_path);
329399
let pos = line.len();
330400
let (_start, matches) = completer
331401
.complete(&line, pos, &Context::new(&history))
@@ -349,7 +419,8 @@ mod tests {
349419
// Test completion with just "." prefix
350420
let completer = ShellCompleter::new(HashSet::new());
351421
let history = DefaultHistory::new();
352-
let line = format!("ls {}/.", temp_path.display());
422+
let escaped_path = path_to_escaped_string(temp_path);
423+
let line = format!("ls {}/.", escaped_path);
353424
let pos = line.len();
354425
let (_start, matches) = completer
355426
.complete(&line, pos, &Context::new(&history))
@@ -362,4 +433,105 @@ mod tests {
362433
assert!(displays.contains(&".bashrc"));
363434
assert!(displays.contains(&".config/"));
364435
}
436+
437+
#[tokio::test]
438+
async fn test_complete_files_with_spaces() {
439+
let temp_dir = TempDir::new().unwrap();
440+
let temp_path = temp_dir.path();
441+
442+
// Create two files with spaces in names
443+
fs::File::create(temp_path.join("some file.txt")).unwrap();
444+
fs::File::create(temp_path.join("some fact.txt")).unwrap();
445+
446+
let completer = ShellCompleter::new(HashSet::new());
447+
let history = DefaultHistory::new();
448+
let escaped_path = path_to_escaped_string(temp_path);
449+
450+
// Test 1: completion of "s" should suggest both files
451+
let line = format!("cat {}/s", escaped_path);
452+
let pos = line.len();
453+
let (_start, matches) = completer
454+
.complete(&line, pos, &Context::new(&history))
455+
.unwrap();
456+
assert_eq!(matches.len(), 2);
457+
458+
// Test 2: completion of "some\ fi" (escaped space) should complete to full path
459+
let line = format!("cat {}/some\\ fi", escaped_path);
460+
let pos = line.len();
461+
let (_start, matches) = completer
462+
.complete(&line, pos, &Context::new(&history))
463+
.unwrap();
464+
assert_eq!(matches.len(), 1);
465+
assert_eq!(
466+
matches[0].replacement,
467+
format!("{}/some\\ file.txt", escaped_path)
468+
);
469+
470+
// Test 3: completion of "some\ fa" (escaped space) should complete to full path
471+
let line = format!("cat {}/some\\ fa", escaped_path);
472+
let pos = line.len();
473+
let (_start, matches) = completer
474+
.complete(&line, pos, &Context::new(&history))
475+
.unwrap();
476+
assert_eq!(matches.len(), 1);
477+
assert_eq!(
478+
matches[0].replacement,
479+
format!("{}/some\\ fact.txt", escaped_path)
480+
);
481+
482+
// Test 4: completion of "some\ fx" (escaped space) should return no matches
483+
let line = format!("cat {}/some\\ fx", escaped_path);
484+
let pos = line.len();
485+
let (_start, matches) = completer
486+
.complete(&line, pos, &Context::new(&history))
487+
.unwrap();
488+
assert_eq!(matches.len(), 0);
489+
}
490+
491+
#[tokio::test]
492+
async fn test_complete_files_in_directory_with_spaces() {
493+
let temp_dir = TempDir::new().unwrap();
494+
let temp_path = temp_dir.path();
495+
496+
// Create a directory with a space in its name
497+
fs::create_dir(temp_path.join("some dir")).unwrap();
498+
fs::File::create(temp_path.join("some dir/file1.txt")).unwrap();
499+
fs::File::create(temp_path.join("some dir/file2.txt")).unwrap();
500+
501+
let completer = ShellCompleter::new(HashSet::new());
502+
let history = DefaultHistory::new();
503+
let escaped_path = path_to_escaped_string(temp_path);
504+
505+
// Test 1: completion of "some\ d" should suggest the directory
506+
let line = format!("cd {}/some\\ d", escaped_path);
507+
let pos = line.len();
508+
let (_start, matches) = completer
509+
.complete(&line, pos, &Context::new(&history))
510+
.unwrap();
511+
assert_eq!(matches.len(), 1);
512+
assert_eq!(
513+
matches[0].replacement,
514+
format!("{}/some\\ dir/", escaped_path)
515+
);
516+
517+
// Test 2: completion of "some\ dir/f" should suggest both files
518+
let line = format!("cat {}/some\\ dir/f", escaped_path);
519+
let pos = line.len();
520+
let (_start, matches) = completer
521+
.complete(&line, pos, &Context::new(&history))
522+
.unwrap();
523+
assert_eq!(matches.len(), 2);
524+
525+
// Test 3: completion of "some\ dir/file1" should complete to file1.txt
526+
let line = format!("cat {}/some\\ dir/file1", escaped_path);
527+
let pos = line.len();
528+
let (_start, matches) = completer
529+
.complete(&line, pos, &Context::new(&history))
530+
.unwrap();
531+
assert_eq!(matches.len(), 1);
532+
assert_eq!(
533+
matches[0].replacement,
534+
format!("{}/some\\ dir/file1.txt", escaped_path)
535+
);
536+
}
365537
}

0 commit comments

Comments
 (0)