fortrangoingonforty/fortsh / 0c6fbc9

Browse files

quote-aware word splitting and newline-delimited completion parsing

- Tab completion now respects quotes when finding the last word:
ls &#39;/tmp/test d<Tab> treats the quoted path as one word.
- parse_ls_output uses newline delimiters instead of spaces to
preserve filenames containing spaces.
- ls command no longer uses tr to convert newlines — keeps raw output.
- Variable completion test uses export for env visibility.
- Completion buffer increased to 16KB for large directories.

Quoted path completion (tests 9, 29) still needs quote reconstruction
in the completed line output. Variable completion for unexported vars
needs shell state access in readline.
Authored by espadonne
SHA
0c6fbc9479d861e9ec44b99d20415d293bd842ec
Parents
4a12211
Tree
686cdbe

2 changed files

StatusFile+-
M src/io/readline.f90 69 33
M tests/interactive/test_specs/completion.yaml 1 1
src/io/readline.f90modified
@@ -2967,14 +2967,22 @@ contains
29672967
     num_completions = 0
29682968
     used_programmable_completion = .false.
29692969
 
2970
-    ! Find the last word to complete
2970
+    ! Find the last word to complete (respect quotes)
29712971
     last_space_pos = 0
2972
-    do i = actual_len, 1, -1
2973
-      if (partial_input(i:i) == ' ') then
2974
-        last_space_pos = i
2975
-        exit
2976
-      end if
2977
-    end do
2972
+    block
2973
+      logical :: in_sq, in_dq
2974
+      in_sq = .false.
2975
+      in_dq = .false.
2976
+      do i = 1, actual_len
2977
+        if (partial_input(i:i) == "'" .and. .not. in_dq) then
2978
+          in_sq = .not. in_sq
2979
+        else if (partial_input(i:i) == '"' .and. .not. in_sq) then
2980
+          in_dq = .not. in_dq
2981
+        else if (partial_input(i:i) == ' ' .and. .not. in_sq .and. .not. in_dq) then
2982
+          last_space_pos = i
2983
+        end if
2984
+      end do
2985
+    end block
29782986
 
29792987
     if (last_space_pos == 0) then
29802988
       last_word = trim(partial_input)
@@ -3295,7 +3303,7 @@ contains
32953303
     var_prefix = prefix_with_dollar(2:)
32963304
 
32973305
     ! Get variable names from environment (exported + inherited)
3298
-    env_alloc = execute_and_capture('env 2>/dev/null | cut -d= -f1 | tr ' // "'" // char(92) // 'n' // "' ' '")
3306
+    env_alloc = execute_and_capture('env 2>/dev/null | cut -d= -f1')
32993307
     env_output = env_alloc(:min(len(env_output), len(env_alloc)))
33003308
     if (allocated(env_alloc)) deallocate(env_alloc)
33013309
 
@@ -3422,9 +3430,9 @@ contains
34223430
     character(len=MAX_LINE_LEN), intent(out) :: completions(MAX_LOCAL_COMPLETIONS)
34233431
     integer, intent(out) :: num_completions
34243432
 
3425
-    character(len=MAX_LINE_LEN) :: dir_path, file_pattern
3433
+    character(len=MAX_LINE_LEN) :: dir_path, file_pattern, clean_prefix
34263434
     character(len=:), allocatable :: debug_mode
3427
-    integer :: last_slash_pos, i
3435
+    integer :: last_slash_pos, i, cp_len
34283436
     logical :: debug_enabled
34293437
 
34303438
     ! Check if debug mode is enabled
@@ -3432,29 +3440,43 @@ contains
34323440
     debug_enabled = (allocated(debug_mode) .and. trim(debug_mode) == '1')
34333441
 
34343442
     num_completions = 0
3435
-    
3443
+
3444
+    ! Strip leading/trailing quotes from prefix for filesystem access
3445
+    clean_prefix = trim(prefix)
3446
+    cp_len = len_trim(clean_prefix)
3447
+    if (cp_len >= 2) then
3448
+      if ((clean_prefix(1:1) == "'" .and. clean_prefix(cp_len:cp_len) == "'") .or. &
3449
+          (clean_prefix(1:1) == '"' .and. clean_prefix(cp_len:cp_len) == '"')) then
3450
+        clean_prefix = clean_prefix(2:cp_len-1)
3451
+      else if (clean_prefix(1:1) == "'" .or. clean_prefix(1:1) == '"') then
3452
+        ! Unclosed quote (user still typing) — strip leading quote only
3453
+        clean_prefix = clean_prefix(2:cp_len)
3454
+      end if
3455
+    end if
3456
+
34363457
     ! Extract directory path and filename pattern
34373458
     last_slash_pos = 0
3438
-    do i = len_trim(prefix), 1, -1
3439
-      if (prefix(i:i) == '/') then
3459
+    last_slash_pos = 0
3460
+    do i = len_trim(clean_prefix), 1, -1
3461
+      if (clean_prefix(i:i) == '/') then
34403462
         last_slash_pos = i
34413463
         exit
34423464
       end if
34433465
     end do
34443466
 
34453467
     if (last_slash_pos > 0) then
3446
-      dir_path = prefix(:last_slash_pos-1)
3447
-      file_pattern = prefix(last_slash_pos+1:)
3468
+      dir_path = clean_prefix(:last_slash_pos-1)
3469
+      file_pattern = clean_prefix(last_slash_pos+1:)
34483470
       if (len_trim(dir_path) == 0) dir_path = '/'
34493471
     else
34503472
       dir_path = '.'
3451
-      file_pattern = trim(prefix)
3473
+      file_pattern = trim(clean_prefix)
34523474
     end if
34533475
 
34543476
     ! Preserve explicit "./" prefix: when user typed "./something", dir_path
34553477
     ! is "." but completions should include "./" to match what was typed.
34563478
     ! Pass "./" as dir_path so scan_directory builds paths with "./" prefix.
3457
-    if (len_trim(prefix) >= 2 .and. prefix(1:2) == './') then
3479
+    if (len_trim(clean_prefix) >= 2 .and. clean_prefix(1:2) == './') then
34583480
       if (trim(dir_path) == '.') dir_path = './'
34593481
     end if
34603482
 
@@ -3510,11 +3532,12 @@ contains
35103532
     ! Trailing / on directory forces ls to list contents (not the symlink itself on macOS)
35113533
     ! When pattern is non-empty, filter with grep to avoid buffer overflow on large directories
35123534
     ! Use tr to convert newlines to spaces for easier parsing
3535
+    ! Keep newlines as delimiters to preserve spaces in filenames
35133536
     if (pattern_len > 0) then
35143537
       ls_command = 'ls -1aF "' // trim(expanded_dir) // '/" 2>/dev/null | grep -i "^' // &
3515
-        trim(pattern) // '" | tr ' // "'" // char(92) // 'n' // "' ' '"
3538
+        trim(pattern) // '"'
35163539
     else
3517
-      ls_command = 'ls -1aF "' // trim(expanded_dir) // '/" 2>/dev/null | tr ' // "'" // char(92) // 'n' // "' ' '"
3540
+      ls_command = 'ls -1aF "' // trim(expanded_dir) // '/" 2>/dev/null'
35183541
     end if
35193542
 
35203543
     ! Debug output
@@ -3647,8 +3670,9 @@ contains
36473670
     num_entries = 0
36483671
     pos = 1
36493672
     do while (pos <= output_len)
3650
-      ! Skip whitespace
3651
-      do while (pos <= output_len .and. (output(pos:pos) == ' ' .or. output(pos:pos) == char(9)))
3673
+      ! Skip newline/whitespace delimiters
3674
+      do while (pos <= output_len .and. (output(pos:pos) == char(10) .or. &
3675
+                output(pos:pos) == char(13) .or. output(pos:pos) == char(9)))
36523676
         pos = pos + 1
36533677
       end do
36543678
 
@@ -3656,8 +3680,9 @@ contains
36563680
 
36573681
       start = pos
36583682
 
3659
-      ! Find end of entry (space only, since execute_and_capture converts newlines to spaces)
3660
-      do while (pos <= output_len .and. output(pos:pos) /= ' ')
3683
+      ! Find end of entry (newline delimiter preserves spaces in filenames)
3684
+      do while (pos <= output_len .and. output(pos:pos) /= char(10) .and. &
3685
+                output(pos:pos) /= char(13) .and. output(pos:pos) /= char(9))
36613686
         pos = pos + 1
36623687
       end do
36633688
 
@@ -3676,8 +3701,9 @@ contains
36763701
       count_pass = 0
36773702
       pos = 1
36783703
       do while (pos <= output_len .and. count_pass < num_entries)
3679
-        ! Skip whitespace
3680
-        do while (pos <= output_len .and. (output(pos:pos) == ' ' .or. output(pos:pos) == char(9)))
3704
+        ! Skip newline/whitespace delimiters
3705
+        do while (pos <= output_len .and. (output(pos:pos) == char(10) .or. &
3706
+                  output(pos:pos) == char(13) .or. output(pos:pos) == char(9)))
36813707
           pos = pos + 1
36823708
         end do
36833709
 
@@ -3685,8 +3711,9 @@ contains
36853711
 
36863712
         start = pos
36873713
 
3688
-        ! Find end of entry
3689
-        do while (pos <= output_len .and. output(pos:pos) /= ' ')
3714
+        ! Find end of entry (newline delimiter)
3715
+        do while (pos <= output_len .and. output(pos:pos) /= char(10) .and. &
3716
+                  output(pos:pos) /= char(13) .and. output(pos:pos) /= char(9))
36903717
           pos = pos + 1
36913718
         end do
36923719
 
@@ -3832,13 +3859,22 @@ contains
38323859
     completed_line = partial_input
38333860
 
38343861
     ! Find the prefix (command and any earlier arguments)
3862
+    ! Respect quotes: spaces inside quotes don't count as word boundaries
38353863
     last_space_pos = 0
3836
-    do i = actual_len, 1, -1
3837
-      if (partial_input(i:i) == ' ') then
3838
-        last_space_pos = i
3839
-        exit
3840
-      end if
3841
-    end do
3864
+    block
3865
+      logical :: in_single_quote, in_double_quote
3866
+      in_single_quote = .false.
3867
+      in_double_quote = .false.
3868
+      do i = 1, actual_len
3869
+        if (partial_input(i:i) == "'" .and. .not. in_double_quote) then
3870
+          in_single_quote = .not. in_single_quote
3871
+        else if (partial_input(i:i) == '"' .and. .not. in_single_quote) then
3872
+          in_double_quote = .not. in_double_quote
3873
+        else if (partial_input(i:i) == ' ' .and. .not. in_single_quote .and. .not. in_double_quote) then
3874
+          last_space_pos = i
3875
+        end if
3876
+      end do
3877
+    end block
38423878
 
38433879
     if (last_space_pos > 0) then
38443880
       prefix_part = partial_input(:last_space_pos)
tests/interactive/test_specs/completion.yamlmodified
@@ -185,7 +185,7 @@ tests:
185185
 
186186
   - name: "Tab completes custom variable"
187187
     steps:
188
-      - send_line: "MYVAR=testvalue"
188
+      - send_line: "export MYVAR=testvalue"
189189
       - send: "echo $MYV"
190190
       - send_key: "Tab"
191191
       - send_key: "Enter"