@@ -50,12 +50,43 @@ 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+ // 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
6192fn 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+
170217fn 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