@@ -50,12 +50,44 @@ impl Completer for ShellCompleter {
5050}
5151
5252fn 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
6193fn escape_for_shell ( s : & str ) -> String {
@@ -156,17 +188,43 @@ fn is_executable(entry: &fs::DirEntry) -> bool {
156188}
157189
158190fn 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+
170228fn 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