Skip to content

Commit 5b3c7b4

Browse files
committed
Fix completion for " "
1 parent a9fedf3 commit 5b3c7b4

File tree

1 file changed

+109
-6
lines changed

1 file changed

+109
-6
lines changed

crates/shell/src/completion.rs

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,43 @@ 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+
// Count preceding backslashes to see if this character is escaped
68+
let mut num_backslashes = 0;
69+
let mut j = i;
70+
while j > 0 {
71+
j -= 1;
72+
if bytes[j] == b'\\' {
73+
num_backslashes += 1;
74+
} else {
75+
break;
76+
}
77+
}
78+
79+
// If even number of backslashes (including 0), the character is NOT escaped
80+
if num_backslashes % 2 == 0 {
81+
// This is an unescaped word boundary
82+
return (i + 1, &line[i + 1..pos]);
83+
}
84+
// Odd number of backslashes means the character is escaped, continue
85+
}
86+
}
87+
88+
// Reached the beginning of the line
89+
(0, &line[0..pos])
5990
}
6091

6192
fn escape_for_shell(s: &str) -> String {
@@ -167,6 +198,22 @@ fn resolve_dir_path(dir_path: &str) -> PathBuf {
167198
}
168199
}
169200

201+
fn unescape_for_completion(s: &str) -> String {
202+
let mut result = String::with_capacity(s.len());
203+
let mut chars = s.chars();
204+
while let Some(ch) = chars.next() {
205+
if ch == '\\' {
206+
// Skip the backslash and take the next character literally
207+
if let Some(next_ch) = chars.next() {
208+
result.push(next_ch);
209+
}
210+
} else {
211+
result.push(ch);
212+
}
213+
}
214+
result
215+
}
216+
170217
fn complete_filenames(is_start: bool, word: &str, matches: &mut Vec<Pair>) {
171218
let (dir_path, partial_name) = match word.rfind('/') {
172219
Some(last_slash) => (&word[..=last_slash], &word[last_slash + 1..]),
@@ -177,12 +224,15 @@ fn complete_filenames(is_start: bool, word: &str, matches: &mut Vec<Pair>) {
177224
let only_executable = (word.starts_with("./") || word.starts_with('/')) && is_start;
178225
let show_hidden = partial_name.starts_with('.');
179226

227+
// Unescape the partial name for matching against actual filenames
228+
let unescaped_partial = unescape_for_completion(partial_name);
229+
180230
let files: Vec<FileMatch> = fs::read_dir(&search_dir)
181231
.into_iter()
182232
.flatten()
183233
.flatten()
184234
.filter_map(|entry| FileMatch::from_entry(entry, &search_dir, show_hidden))
185-
.filter(|f| f.name.starts_with(partial_name))
235+
.filter(|f| f.name.starts_with(&unescaped_partial))
186236
.filter(|f| !only_executable || f.is_executable || f.is_dir)
187237
.collect();
188238

@@ -362,4 +412,57 @@ mod tests {
362412
assert!(displays.contains(&".bashrc"));
363413
assert!(displays.contains(&".config/"));
364414
}
415+
416+
#[tokio::test]
417+
async fn test_complete_files_with_spaces() {
418+
let temp_dir = TempDir::new().unwrap();
419+
let temp_path = temp_dir.path();
420+
421+
// Create two files with spaces in names
422+
fs::File::create(temp_path.join("some file.txt")).unwrap();
423+
fs::File::create(temp_path.join("some fact.txt")).unwrap();
424+
425+
let completer = ShellCompleter::new(HashSet::new());
426+
let history = DefaultHistory::new();
427+
428+
// Test 1: completion of "s" should suggest both files
429+
let line = format!("cat {}/s", temp_path.display());
430+
let pos = line.len();
431+
let (_start, matches) = completer
432+
.complete(&line, pos, &Context::new(&history))
433+
.unwrap();
434+
assert_eq!(matches.len(), 2);
435+
436+
// Test 2: completion of "some\ fi" (escaped space) should complete to full path
437+
let line = format!("cat {}/some\\ fi", temp_path.display());
438+
let pos = line.len();
439+
let (_start, matches) = completer
440+
.complete(&line, pos, &Context::new(&history))
441+
.unwrap();
442+
assert_eq!(matches.len(), 1);
443+
assert_eq!(
444+
matches[0].replacement,
445+
format!("{}/some\\ file.txt", temp_path.display())
446+
);
447+
448+
// Test 3: completion of "some\ fa" (escaped space) should complete to full path
449+
let line = format!("cat {}/some\\ fa", temp_path.display());
450+
let pos = line.len();
451+
let (_start, matches) = completer
452+
.complete(&line, pos, &Context::new(&history))
453+
.unwrap();
454+
assert_eq!(matches.len(), 1);
455+
assert_eq!(
456+
matches[0].replacement,
457+
format!("{}/some\\ fact.txt", temp_path.display())
458+
);
459+
460+
// Test 4: completion of "some\ fx" (escaped space) should return no matches
461+
let line = format!("cat {}/some\\ fx", temp_path.display());
462+
let pos = line.len();
463+
let (_start, matches) = completer
464+
.complete(&line, pos, &Context::new(&history))
465+
.unwrap();
466+
assert_eq!(matches.len(), 0);
467+
}
365468
}

0 commit comments

Comments
 (0)