Text · 153373 bytes Raw Blame History
1 ! ==============================================================================
2 ! Module: readline
3 ! Purpose: Advanced input handling with command history and line editing
4 ! ==============================================================================
5 module readline
6 use shell_types
7 use system_interface
8 use completion, only: get_completion_spec, generate_completions, completion_spec_t, MAX_COMPLETIONS
9 use syntax_highlight, only: highlight_command_line, init_syntax_highlighting
10 use abbreviations, only: try_expand_abbreviation
11 use glob, only: pattern_matches
12 use iso_fortran_env, only: input_unit, output_unit, error_unit
13 use iso_c_binding
14 implicit none
15
16 ! Constants for special keys
17 integer, parameter :: KEY_ENTER = 10
18 integer, parameter :: KEY_BACKSPACE = 127
19 integer, parameter :: KEY_DELETE = 127 ! Same as backspace on most terminals
20 integer, parameter :: KEY_TAB = 9
21 integer, parameter :: KEY_CTRL_C = 3
22 integer, parameter :: KEY_CTRL_D = 4
23 integer, parameter :: KEY_CTRL_A = 1 ! Home (beginning of line)
24 integer, parameter :: KEY_CTRL_E = 5 ! End (end of line)
25 integer, parameter :: KEY_CTRL_K = 11 ! Kill to end of line
26 integer, parameter :: KEY_CTRL_L = 12 ! Clear screen
27 integer, parameter :: KEY_CTRL_W = 23 ! Kill previous word
28 integer, parameter :: KEY_CTRL_U = 21 ! Kill entire line
29 integer, parameter :: KEY_CTRL_Y = 25 ! Yank (paste) killed text
30 integer, parameter :: KEY_CTRL_F = 6 ! Forward character (same as right arrow)
31 integer, parameter :: KEY_CTRL_B = 2 ! Backward character (same as left arrow)
32 integer, parameter :: KEY_CTRL_R = 18 ! Reverse-i-search
33 integer, parameter :: KEY_CTRL_S = 19 ! Forward-i-search
34 integer, parameter :: KEY_CTRL_G = 7 ! Cancel (alternate to Ctrl+C)
35 integer, parameter :: KEY_CTRL_T = 20 ! Transpose characters
36 integer, parameter :: KEY_ESC = 27
37 integer, parameter :: KEY_UP = 65
38 integer, parameter :: KEY_DOWN = 66
39 integer, parameter :: KEY_RIGHT = 67
40 integer, parameter :: KEY_LEFT = 68
41
42 ! History and line management
43 integer, parameter :: MAX_HISTORY = 1000
44 integer, parameter :: MAX_LINE_LEN = 1024
45
46 ! Glob expansion constants (from glob module)
47 integer, parameter :: MAX_GLOB_MATCHES = 1000
48 ! MAX_TOKEN_LEN is already defined in shell_types
49
50 ! Input state management
51 ! Editing mode constants
52 integer, parameter :: EDITING_MODE_EMACS = 1
53 integer, parameter :: EDITING_MODE_VI = 2
54 integer, parameter :: VI_MODE_INSERT = 1
55 integer, parameter :: VI_MODE_COMMAND = 2
56
57 type :: input_state_t
58 character(len=MAX_LINE_LEN) :: buffer = ''
59 character(len=MAX_LINE_LEN) :: original_buffer = '' ! Save original input during history navigation
60 character(len=MAX_LINE_LEN) :: kill_buffer = '' ! Kill ring buffer for cut/paste
61 character(len=MAX_LINE_LEN) :: last_completion_buffer = '' ! Buffer when we last showed completions
62 integer :: length = 0
63 integer :: cursor_pos = 0 ! 0-based position in buffer
64 integer :: history_pos = 0 ! Current position in history (0 = not browsing)
65 integer :: kill_length = 0 ! Length of text in kill buffer
66 logical :: dirty = .false. ! Needs redraw
67 logical :: in_history = .false. ! Currently browsing history
68 logical :: completions_shown = .false. ! Have we shown completion list for current buffer?
69
70 ! Reverse-i-search state
71 logical :: in_search = .false. ! Currently in i-search mode (forward or reverse)
72 logical :: search_forward = .false. ! True = forward, False = reverse
73 character(len=MAX_LINE_LEN) :: search_string = '' ! Current search query
74 integer :: search_length = 0 ! Length of search string
75 integer :: search_match_index = 0 ! Current history match index
76
77 ! Editing mode support
78 integer :: editing_mode = EDITING_MODE_EMACS
79 integer :: vi_mode = VI_MODE_INSERT
80 character(len=MAX_LINE_LEN) :: vi_command_buffer = ''
81 integer :: vi_command_count = 0
82 logical :: vi_repeat_pending = .false.
83
84 ! Advanced vi mode features
85 character(len=MAX_LINE_LEN) :: vi_yank_buffer = '' ! Vi-style yank buffer
86 integer :: vi_yank_length = 0
87 integer :: vi_marks(26) = 0 ! Mark positions for 'a'-'z' (0 = not set)
88 character(len=MAX_LINE_LEN) :: vi_search_pattern = ''
89 integer :: vi_search_length = 0
90 logical :: vi_search_forward = .true.
91 logical :: vi_in_vi_search = .false.
92
93 ! Autosuggestion support (fish-style)
94 character(len=MAX_LINE_LEN) :: suggestion = '' ! Current suggestion from history
95 integer :: suggestion_length = 0 ! Length of suggestion
96
97 ! Menu selection support (zsh/fish-style interactive completion)
98 logical :: in_menu_select = .false. ! Currently in menu selection mode
99 character(len=MAX_LINE_LEN) :: menu_items(50) = '' ! Completion items for menu
100 integer :: menu_num_items = 0 ! Number of items in menu
101 integer :: menu_selection = 1 ! Currently selected item (1-based)
102 character(len=MAX_LINE_LEN) :: menu_prefix = '' ! Command prefix before completion word
103 integer :: menu_prefix_len = 0 ! Actual length of prefix INCLUDING trailing space
104 end type input_state_t
105
106 type :: history_t
107 character(len=MAX_LINE_LEN) :: lines(MAX_HISTORY)
108 integer :: count = 0
109 integer :: current = 0 ! Current position in history navigation
110 end type history_t
111
112 type(history_t), save :: command_history
113
114 ! Type to hold completion candidates with scores for fuzzy matching
115 type :: scored_completion_t
116 character(len=MAX_LINE_LEN) :: text
117 integer :: score
118 end type scored_completion_t
119
120 ! Module-level HISTCONTROL setting (set by shell)
121 character(len=256), save :: current_histcontrol = ''
122
123 ! Module-level editing mode (set by shell via option_vi)
124 integer, save :: global_editing_mode = EDITING_MODE_EMACS
125
126 contains
127
128 ! Set the HISTCONTROL setting for history management
129 subroutine set_histcontrol(histcontrol)
130 character(len=*), intent(in) :: histcontrol
131 current_histcontrol = histcontrol
132 end subroutine
133
134 ! Set the global editing mode (vi or emacs)
135 subroutine set_global_editing_mode(vi_mode)
136 logical, intent(in) :: vi_mode
137 if (vi_mode) then
138 global_editing_mode = EDITING_MODE_VI
139 else
140 global_editing_mode = EDITING_MODE_EMACS
141 end if
142 end subroutine
143
144 ! Enhanced readline with character-by-character input processing
145 subroutine readline_enhanced(prompt, line, iostat)
146 character(len=*), intent(in) :: prompt
147 character(len=*), intent(out) :: line
148 integer, intent(out) :: iostat
149
150 type(input_state_t) :: input_state
151 type(termios_t) :: original_termios
152 character :: ch
153 logical :: success, done, raw_enabled
154 integer :: char_code
155
156 iostat = 0
157 done = .false.
158 raw_enabled = .false.
159
160 ! Try to enable raw mode (only works in interactive mode)
161 success = enable_raw_mode(original_termios)
162 if (success) then
163 raw_enabled = .true.
164 end if
165
166 ! Print prompt
167 write(output_unit, '(a)', advance='no') prompt
168 flush(output_unit)
169
170 ! Initialize input state
171 input_state%buffer = ''
172 input_state%original_buffer = ''
173 input_state%kill_buffer = ''
174 input_state%last_completion_buffer = ''
175 input_state%length = 0
176 input_state%cursor_pos = 0
177 input_state%history_pos = 0
178 input_state%kill_length = 0
179 input_state%dirty = .false.
180 input_state%in_history = .false.
181 input_state%completions_shown = .false.
182 input_state%in_search = .false.
183 input_state%search_string = ''
184 input_state%search_length = 0
185 input_state%search_match_index = 0
186 input_state%editing_mode = global_editing_mode ! Initialize from global state
187 input_state%vi_mode = VI_MODE_INSERT
188 input_state%suggestion = ''
189 input_state%suggestion_length = 0
190
191 if (raw_enabled) then
192 ! Enhanced input processing
193 do while (.not. done)
194 success = read_single_char(ch)
195 if (.not. success) then
196 iostat = -1
197 exit
198 end if
199
200 char_code = iachar(ch)
201
202 select case(char_code)
203 case(KEY_ENTER)
204 ! Enter - accept menu selection, finish input, or accept search
205 if (input_state%in_menu_select) then
206 call handle_menu_navigation(input_state, KEY_ENTER, done)
207 else if (input_state%in_search) then
208 call accept_search(input_state, prompt)
209 done = .true.
210 else
211 write(output_unit, '()') ! New line
212 done = .true.
213 end if
214
215 case(KEY_CTRL_D)
216 ! Ctrl+D - EOF
217 if (input_state%length == 0) then
218 iostat = -1
219 done = .true.
220 end if
221
222 case(KEY_CTRL_C)
223 ! Ctrl+C - cancel and clear line (bash-compatible)
224 ! Move to beginning, clear line, print ^C on new line
225 write(output_unit, '(a)', advance='no') ESC_MOVE_BOL // ESC_CLEAR_LINE
226 write(output_unit, '(a)') '^C'
227
228 ! Clear buffer and exit search mode if active
229 if (input_state%in_search) then
230 input_state%in_search = .false.
231 input_state%search_string = ''
232 input_state%search_length = 0
233 input_state%search_match_index = 0
234 end if
235
236 ! Clear buffer and return empty line
237 input_state%buffer = ''
238 input_state%length = 0
239 input_state%cursor_pos = 0
240 done = .true.
241
242 case(KEY_BACKSPACE)
243 ! Backspace
244 if (input_state%in_search) then
245 call search_backspace(input_state, prompt)
246 else
247 call handle_backspace(input_state)
248 end if
249
250 case(KEY_TAB)
251 ! Tab completion or menu navigation
252 if (input_state%in_menu_select) then
253 call handle_menu_navigation(input_state, KEY_TAB, done)
254 else
255 call handle_tab_completion(input_state)
256 end if
257
258 case(KEY_ESC)
259 ! Escape sequence - parse it (will route to menu if needed)
260 call handle_escape_sequence(input_state, done)
261
262 case(KEY_CTRL_A)
263 ! Home - move to beginning of line
264 call handle_home(input_state)
265
266 case(KEY_CTRL_E)
267 ! End - move to end of line
268 call handle_end(input_state)
269
270 case(KEY_CTRL_F)
271 ! Forward character (same as right arrow)
272 call handle_cursor_right(input_state)
273
274 case(KEY_CTRL_B)
275 ! Backward character (same as left arrow)
276 call handle_cursor_left(input_state)
277
278 case(KEY_CTRL_K)
279 ! Kill to end of line (exit menu mode first if active)
280 if (input_state%in_menu_select) then
281 call exit_menu_select_mode(input_state)
282 end if
283 call handle_kill_to_end(input_state)
284
285 case(KEY_CTRL_U)
286 ! Kill entire line (exit menu mode first if active)
287 if (input_state%in_menu_select) then
288 call exit_menu_select_mode(input_state)
289 end if
290 call handle_kill_line(input_state)
291
292 case(KEY_CTRL_W)
293 ! Kill previous word (exit menu mode first if active)
294 if (input_state%in_menu_select) then
295 call exit_menu_select_mode(input_state)
296 end if
297 call handle_kill_word(input_state)
298
299 case(KEY_CTRL_Y)
300 ! Yank (paste) killed text
301 call handle_yank(input_state)
302
303 case(KEY_CTRL_L)
304 ! Clear screen and redraw
305 call handle_clear_screen(input_state, prompt)
306
307 case(KEY_CTRL_R)
308 ! Reverse-i-search
309 call handle_isearch(input_state, prompt, .false.)
310 case(KEY_CTRL_S)
311 ! Forward-i-search
312 call handle_isearch(input_state, prompt, .true.)
313
314 case(KEY_CTRL_G)
315 ! Cancel search if active (bash-compatible)
316 if (input_state%in_search) then
317 ! Clear line and exit search mode
318 write(output_unit, '(a)', advance='no') ESC_MOVE_BOL // ESC_CLEAR_LINE
319 write(output_unit, '(a)') '^C'
320
321 input_state%in_search = .false.
322 input_state%search_string = ''
323 input_state%search_length = 0
324 input_state%search_match_index = 0
325
326 ! Clear buffer and return empty line
327 input_state%buffer = ''
328 input_state%length = 0
329 input_state%cursor_pos = 0
330 done = .true.
331 end if
332
333 case(KEY_CTRL_T)
334 ! Transpose characters (swap current char with previous)
335 call handle_transpose_chars(input_state)
336
337 case(32:126)
338 ! Regular printable characters
339 if (input_state%in_menu_select) then
340 ! Exit menu mode and process character normally
341 call exit_menu_select_mode(input_state)
342 call insert_char(input_state, ch)
343 else if (input_state%in_search) then
344 call search_add_char(input_state, ch, prompt)
345 else if (input_state%editing_mode == EDITING_MODE_VI .and. &
346 input_state%vi_mode == VI_MODE_COMMAND) then
347 ! In Vi command mode - route to command handler
348 call handle_vi_command_mode(input_state, char_code)
349 ! Check if we switched back to insert mode
350 if (input_state%vi_mode == VI_MODE_INSERT) then
351 call handle_vi_mode_switch(input_state, char_code)
352 end if
353 else
354 call insert_char(input_state, ch)
355 end if
356
357 case default
358 ! Ignore other control characters for now
359 end select
360
361 ! Redraw line if needed
362 if (input_state%dirty) then
363 call redraw_line(prompt, input_state)
364 input_state%dirty = .false.
365 end if
366 end do
367
368 ! Restore terminal
369 if (.not. restore_terminal(original_termios)) then
370 ! Warning but don't fail
371 end if
372 else
373 ! Fallback to line-based input
374 read(input_unit, '(a)', iostat=iostat) input_state%buffer
375 if (iostat == 0) input_state%length = len_trim(input_state%buffer)
376 end if
377
378 ! Return the result
379 if (iostat == 0) then
380 line = input_state%buffer(:input_state%length)
381 ! Note: History addition is now handled in the main loop AFTER expansion
382 ! This prevents history expansion commands like !! from referencing themselves
383 else
384 line = ''
385 end if
386 end subroutine
387
388 ! Simple fallback readline - uses standard input for now
389 ! This is a placeholder for a full readline implementation
390 subroutine readline_simple(prompt, line, iostat)
391 character(len=*), intent(in) :: prompt
392 character(len=*), intent(out) :: line
393 integer, intent(out) :: iostat
394
395 ! Print prompt
396 write(output_unit, '(a)', advance='no') prompt
397 flush(output_unit)
398
399 ! Read line using standard input (no special key handling yet)
400 read(input_unit, '(a)', iostat=iostat) line
401
402 ! Note: History addition is now handled in the main loop AFTER expansion
403 end subroutine
404
405 ! Enhanced readline with tab completion support
406 ! Note: This is a simplified version that detects tab in the input
407 subroutine readline_with_completion(prompt, line, iostat)
408 character(len=*), intent(in) :: prompt
409 character(len=*), intent(out) :: line
410 integer, intent(out) :: iostat
411
412 character(len=MAX_LINE_LEN) :: temp_line
413 character(len=MAX_LINE_LEN) :: completions(50)
414 integer :: num_completions, tab_pos
415
416 ! Print prompt
417 write(output_unit, '(a)', advance='no') prompt
418 flush(output_unit)
419
420 ! Read line using standard input
421 read(input_unit, '(a)', iostat=iostat) temp_line
422
423 if (iostat /= 0) then
424 line = ''
425 return
426 end if
427
428 ! Check for tab character in input (simplified detection)
429 tab_pos = index(temp_line, char(KEY_TAB))
430 if (tab_pos > 0) then
431 ! Extract partial input before tab
432 if (tab_pos == 1) then
433 temp_line = ''
434 else
435 temp_line = temp_line(:tab_pos-1)
436 end if
437
438 ! Perform tab completion
439 call tab_complete(temp_line, completions, num_completions)
440
441 if (num_completions > 0) then
442 if (num_completions == 1) then
443 ! Single completion - auto-complete
444 line = trim(temp_line) // trim(completions(1))
445 write(output_unit, '(a)') trim(line)
446 else
447 ! Multiple completions - show options
448 call show_completions(completions, num_completions)
449 line = temp_line
450 end if
451 else
452 line = temp_line
453 end if
454 else
455 line = temp_line
456 end if
457
458 ! Note: History addition is now handled in the main loop AFTER expansion
459 end subroutine
460
461 subroutine add_to_history(line)
462 character(len=*), intent(in) :: line
463 ! Call enhanced version with current histcontrol setting
464 call add_to_history_with_control(line, current_histcontrol)
465 end subroutine
466
467 ! Add command to history with HISTCONTROL support
468 subroutine add_to_history_with_control(line, histcontrol)
469 character(len=*), intent(in) :: line
470 character(len=*), intent(in) :: histcontrol
471 integer :: i
472 logical :: ignorespace, ignoredups, ignoreboth, erasedups
473
474 ! Parse HISTCONTROL settings
475 ignorespace = index(histcontrol, 'ignorespace') > 0
476 ignoredups = index(histcontrol, 'ignoredups') > 0
477 ignoreboth = index(histcontrol, 'ignoreboth') > 0
478 erasedups = index(histcontrol, 'erasedups') > 0
479
480 ! Apply ignoreboth
481 if (ignoreboth) then
482 ignorespace = .true.
483 ignoredups = .true.
484 end if
485
486 ! Check ignorespace: don't add if line starts with space
487 if (ignorespace .and. len_trim(line) > 0) then
488 if (line(1:1) == ' ') return
489 end if
490
491 ! Check ignoredups: don't add if duplicate of last command
492 if (ignoredups .and. command_history%count > 0) then
493 if (trim(command_history%lines(command_history%count)) == trim(line)) then
494 return
495 end if
496 end if
497
498 ! Check erasedups: remove all previous instances of this command
499 if (erasedups) then
500 do i = 1, command_history%count
501 if (trim(command_history%lines(i)) == trim(line)) then
502 call delete_history_entry(i)
503 exit ! Only one match possible after this
504 end if
505 end do
506 end if
507
508 ! Shift history if at max capacity
509 if (command_history%count >= MAX_HISTORY) then
510 do i = 1, MAX_HISTORY - 1
511 command_history%lines(i) = command_history%lines(i + 1)
512 end do
513 command_history%count = MAX_HISTORY - 1
514 end if
515
516 ! Add new command
517 command_history%count = command_history%count + 1
518 command_history%lines(command_history%count) = line
519
520 ! Reset current position
521 command_history%current = command_history%count + 1
522 end subroutine
523
524 ! Delete a history entry by index
525 subroutine delete_history_entry(index)
526 integer, intent(in) :: index
527 integer :: i
528
529 if (index < 1 .or. index > command_history%count) return
530
531 ! Shift remaining entries down
532 do i = index, command_history%count - 1
533 command_history%lines(i) = command_history%lines(i + 1)
534 end do
535
536 ! Decrement count
537 command_history%count = command_history%count - 1
538
539 ! Adjust current position if needed
540 if (command_history%current > command_history%count + 1) then
541 command_history%current = command_history%count + 1
542 end if
543 end subroutine
544
545 subroutine get_history_line(index, line, found)
546 integer, intent(in) :: index
547 character(len=*), intent(out) :: line
548 logical, intent(out) :: found
549
550 if (index >= 1 .and. index <= command_history%count) then
551 line = command_history%lines(index)
552 found = .true.
553 else
554 line = ''
555 found = .false.
556 end if
557 end subroutine
558
559 function get_history_count() result(count)
560 integer :: count
561 count = command_history%count
562 end function
563
564 ! Show command history (for 'history' builtin)
565 subroutine show_history()
566 integer :: i
567
568 if (command_history%count == 0) then
569 write(output_unit, '(a)') 'No commands in history.'
570 else
571 do i = 1, command_history%count
572 write(output_unit, '(i4,2x,a)') i, trim(command_history%lines(i))
573 end do
574 end if
575 end subroutine
576
577 ! Clear history
578 subroutine clear_history()
579 command_history%count = 0
580 command_history%current = 0
581 end subroutine
582
583 ! Save history to file
584 subroutine save_history_to_file(filepath, max_lines)
585 character(len=*), intent(in) :: filepath
586 integer, intent(in) :: max_lines
587 integer :: unit, iostat, i, start_index
588
589 ! Don't save if no history
590 if (command_history%count == 0) return
591
592 ! Calculate starting index based on max_lines
593 if (max_lines > 0 .and. command_history%count > max_lines) then
594 start_index = command_history%count - max_lines + 1
595 else
596 start_index = 1
597 end if
598
599 ! Open file for writing (truncate existing)
600 open(newunit=unit, file=trim(filepath), status='replace', action='write', iostat=iostat)
601 if (iostat /= 0) then
602 write(error_unit, '(a)') 'fortsh: warning: could not save history to ' // trim(filepath)
603 return
604 end if
605
606 ! Write history lines
607 do i = start_index, command_history%count
608 write(unit, '(a)', iostat=iostat) trim(command_history%lines(i))
609 if (iostat /= 0) exit
610 end do
611
612 close(unit)
613 end subroutine
614
615 ! Load history from file
616 subroutine load_history_from_file(filepath, max_lines)
617 character(len=*), intent(in) :: filepath
618 integer, intent(in) :: max_lines
619 integer :: unit, iostat
620 character(len=MAX_LINE_LEN) :: line
621 logical :: file_exists
622
623 ! Check if file exists
624 inquire(file=filepath, exist=file_exists)
625 if (.not. file_exists) return
626
627 ! Open file for reading
628 open(newunit=unit, file=trim(filepath), status='old', action='read', iostat=iostat)
629 if (iostat /= 0) return
630
631 ! Clear existing history
632 command_history%count = 0
633 command_history%current = 0
634
635 ! Read lines
636 do
637 read(unit, '(a)', iostat=iostat) line
638 if (iostat /= 0) exit ! EOF or error
639
640 ! Skip empty lines
641 if (len_trim(line) == 0) cycle
642
643 ! Add to history (respecting max_lines)
644 if (max_lines > 0 .and. command_history%count >= max_lines) then
645 ! Shift history to make room
646 command_history%lines(1:MAX_HISTORY-1) = command_history%lines(2:MAX_HISTORY)
647 command_history%count = command_history%count - 1
648 end if
649
650 ! Add to history without duplicate check (loading from file)
651 command_history%count = command_history%count + 1
652 command_history%lines(command_history%count) = line
653 end do
654
655 close(unit)
656 command_history%current = command_history%count + 1
657 end subroutine
658
659 ! Append new history entries to file (for concurrent shells)
660 subroutine append_history_to_file(filepath, start_index)
661 character(len=*), intent(in) :: filepath
662 integer, intent(in) :: start_index
663 integer :: unit, iostat, i
664
665 if (start_index > command_history%count) return
666
667 ! Open file for appending
668 open(newunit=unit, file=trim(filepath), status='old', position='append', action='write', iostat=iostat)
669 if (iostat /= 0) then
670 ! File doesn't exist, create it
671 open(newunit=unit, file=trim(filepath), status='new', action='write', iostat=iostat)
672 if (iostat /= 0) return
673 end if
674
675 ! Append new entries
676 do i = start_index, command_history%count
677 write(unit, '(a)', iostat=iostat) trim(command_history%lines(i))
678 if (iostat /= 0) exit
679 end do
680
681 close(unit)
682 end subroutine
683
684 ! History expansion functions
685 function expand_history(input_line) result(expanded_line)
686 character(len=*), intent(in) :: input_line
687 character(len=len(input_line)) :: expanded_line
688
689 character(len=len(input_line)) :: work_line
690 integer :: pos, expansion_start, expansion_end, out_pos
691 character(len=256) :: expansion, replacement
692 logical :: found_expansion
693 integer :: repl_len
694
695 work_line = input_line
696 expanded_line = ''
697 pos = 1
698 out_pos = 1
699
700 do while (pos <= len_trim(work_line))
701 if (work_line(pos:pos) == '!' .and. pos <= len_trim(work_line)) then
702 ! Skip if this is $! (special variable for last background PID)
703 if (pos > 1 .and. work_line(pos-1:pos-1) == '$') then
704 ! This is $!, not a history expansion - copy the ! as-is
705 expanded_line(out_pos:out_pos) = '!'
706 out_pos = out_pos + 1
707 pos = pos + 1
708 else
709 ! Found potential history expansion
710 expansion_start = pos
711 expansion_end = find_history_expansion_end(work_line, pos)
712
713 if (expansion_end > expansion_start) then
714 expansion = work_line(expansion_start:expansion_end)
715 call process_history_expansion(expansion, replacement, found_expansion)
716
717 if (found_expansion) then
718 repl_len = len_trim(replacement)
719 if (out_pos + repl_len - 1 <= len(expanded_line)) then
720 expanded_line(out_pos:out_pos+repl_len-1) = trim(replacement)
721 out_pos = out_pos + repl_len
722 end if
723 pos = expansion_end + 1
724 else
725 expanded_line(out_pos:out_pos) = '!'
726 out_pos = out_pos + 1
727 pos = pos + 1
728 end if
729 else
730 expanded_line(out_pos:out_pos) = '!'
731 out_pos = out_pos + 1
732 pos = pos + 1
733 end if
734 end if
735 else
736 expanded_line(out_pos:out_pos) = work_line(pos:pos)
737 out_pos = out_pos + 1
738 pos = pos + 1
739 end if
740 end do
741 end function
742
743 function find_history_expansion_end(line, start_pos) result(end_pos)
744 character(len=*), intent(in) :: line
745 integer, intent(in) :: start_pos
746 integer :: end_pos
747
748 integer :: pos
749 character :: ch
750
751 pos = start_pos + 1 ! Skip the '!'
752 end_pos = start_pos
753
754 if (pos > len_trim(line)) return
755
756 ch = line(pos:pos)
757
758 if (ch == '!') then
759 ! !! expansion
760 end_pos = pos
761 else if (ch >= '0' .and. ch <= '9') then
762 ! !n expansion (number)
763 do while (pos <= len_trim(line) .and. line(pos:pos) >= '0' .and. line(pos:pos) <= '9')
764 end_pos = pos
765 pos = pos + 1
766 end do
767 else if (ch == '-') then
768 ! !-n expansion (negative number)
769 pos = pos + 1
770 if (pos <= len_trim(line) .and. line(pos:pos) >= '0' .and. line(pos:pos) <= '9') then
771 do while (pos <= len_trim(line) .and. line(pos:pos) >= '0' .and. line(pos:pos) <= '9')
772 end_pos = pos
773 pos = pos + 1
774 end do
775 end if
776 else if ((ch >= 'a' .and. ch <= 'z') .or. (ch >= 'A' .and. ch <= 'Z') .or. ch == '_') then
777 ! !string expansion
778 do while (pos <= len_trim(line) .and. &
779 ((line(pos:pos) >= 'a' .and. line(pos:pos) <= 'z') .or. &
780 (line(pos:pos) >= 'A' .and. line(pos:pos) <= 'Z') .or. &
781 (line(pos:pos) >= '0' .and. line(pos:pos) <= '9') .or. &
782 line(pos:pos) == '_' .or. line(pos:pos) == '-'))
783 end_pos = pos
784 pos = pos + 1
785 end do
786 end if
787 end function
788
789 subroutine process_history_expansion(expansion, replacement, found)
790 character(len=*), intent(in) :: expansion
791 character(len=*), intent(out) :: replacement
792 logical, intent(out) :: found
793
794 character(len=256) :: search_pattern
795 integer :: history_num, i, search_len
796
797 replacement = ''
798 found = .false.
799
800 if (len_trim(expansion) < 2) return
801
802 select case (expansion(2:2))
803 case ('!')
804 ! !! - last command
805 if (command_history%count > 0) then
806 replacement = command_history%lines(command_history%count)
807 found = .true.
808 end if
809
810 case ('0':'9')
811 ! !n - command number n
812 read(expansion(2:), *, iostat=i) history_num
813 if (i == 0 .and. history_num >= 1 .and. history_num <= command_history%count) then
814 replacement = command_history%lines(history_num)
815 found = .true.
816 end if
817
818 case ('-')
819 ! !-n - n commands back
820 if (len_trim(expansion) > 2) then
821 read(expansion(3:), *, iostat=i) history_num
822 if (i == 0 .and. history_num > 0) then
823 history_num = command_history%count - history_num + 1
824 if (history_num >= 1 .and. history_num <= command_history%count) then
825 replacement = command_history%lines(history_num)
826 found = .true.
827 end if
828 end if
829 end if
830
831 case default
832 ! !string - last command starting with string
833 search_pattern = expansion(2:)
834 search_len = len_trim(search_pattern)
835
836 if (search_len > 0) then
837 ! Search backwards through history
838 do i = command_history%count, 1, -1
839 if (len_trim(command_history%lines(i)) >= search_len) then
840 if (command_history%lines(i)(1:search_len) == search_pattern) then
841 replacement = command_history%lines(i)
842 found = .true.
843 exit
844 end if
845 end if
846 end do
847 end if
848 end select
849 end subroutine
850
851 function needs_history_expansion(line) result(needs_expansion)
852 character(len=*), intent(in) :: line
853 logical :: needs_expansion
854
855 integer :: pos, old_pos
856
857 needs_expansion = .false.
858 pos = index(line, '!')
859
860 do while (pos > 0 .and. pos <= len_trim(line))
861 ! Check if this ! is the start of a history expansion
862 ! Skip if it's part of $! (special variable for last background PID)
863 if (pos > 1 .and. line(pos-1:pos-1) == '$') then
864 ! This is $!, not a history expansion
865 else if (pos == 1 .or. line(pos-1:pos-1) == ' ' .or. line(pos-1:pos-1) == char(9)) then
866 ! Check what follows the ! (if there is something after it)
867 if (pos < len_trim(line)) then
868 if (line(pos+1:pos+1) == '!' .or. &
869 (line(pos+1:pos+1) >= '0' .and. line(pos+1:pos+1) <= '9') .or. &
870 line(pos+1:pos+1) == '-' .or. &
871 (line(pos+1:pos+1) >= 'a' .and. line(pos+1:pos+1) <= 'z') .or. &
872 (line(pos+1:pos+1) >= 'A' .and. line(pos+1:pos+1) <= 'Z')) then
873 needs_expansion = .true.
874 return
875 end if
876 end if
877 end if
878
879 ! Look for next !
880 old_pos = pos
881 pos = index(line(pos+1:), '!')
882 if (pos > 0) pos = pos + old_pos
883 end do
884 end function
885
886 ! Editing mode control functions
887 subroutine set_editing_mode(input_state, mode)
888 type(input_state_t), intent(inout) :: input_state
889 integer, intent(in) :: mode
890
891 if (mode == EDITING_MODE_EMACS .or. mode == EDITING_MODE_VI) then
892 input_state%editing_mode = mode
893 if (mode == EDITING_MODE_VI) then
894 input_state%vi_mode = VI_MODE_INSERT
895 end if
896 end if
897 end subroutine
898
899 subroutine handle_vi_mode_switch(input_state, key)
900 type(input_state_t), intent(inout) :: input_state
901 integer, intent(in) :: key
902
903 if (input_state%editing_mode /= EDITING_MODE_VI) return
904
905 select case (input_state%vi_mode)
906 case (VI_MODE_INSERT)
907 if (key == KEY_ESC) then
908 input_state%vi_mode = VI_MODE_COMMAND
909 ! Move cursor back one position in command mode
910 if (input_state%cursor_pos > 0) then
911 input_state%cursor_pos = input_state%cursor_pos - 1
912 end if
913 input_state%dirty = .true.
914 end if
915
916 case (VI_MODE_COMMAND)
917 select case (key)
918 case (ichar('i'))
919 ! Insert mode
920 input_state%vi_mode = VI_MODE_INSERT
921 case (ichar('a'))
922 ! Append mode
923 input_state%vi_mode = VI_MODE_INSERT
924 if (input_state%cursor_pos < input_state%length) then
925 input_state%cursor_pos = input_state%cursor_pos + 1
926 end if
927 case (ichar('I'))
928 ! Insert at beginning
929 input_state%vi_mode = VI_MODE_INSERT
930 input_state%cursor_pos = 0
931 case (ichar('A'))
932 ! Append at end
933 input_state%vi_mode = VI_MODE_INSERT
934 input_state%cursor_pos = input_state%length
935 case (ichar('o'))
936 ! Open new line below (simplified)
937 input_state%vi_mode = VI_MODE_INSERT
938 input_state%cursor_pos = input_state%length
939 case (ichar('O'))
940 ! Open new line above (simplified)
941 input_state%vi_mode = VI_MODE_INSERT
942 input_state%cursor_pos = 0
943 end select
944 input_state%dirty = .true.
945 end select
946 end subroutine
947
948 subroutine handle_vi_command_mode(input_state, key)
949 type(input_state_t), intent(inout) :: input_state
950 integer, intent(in) :: key
951 character :: key_char
952 integer :: repeat_count, i
953
954 if (input_state%editing_mode /= EDITING_MODE_VI .or. input_state%vi_mode /= VI_MODE_COMMAND) return
955
956 key_char = char(key)
957
958 ! Handle pending two-character commands first
959 if (len_trim(input_state%vi_command_buffer) > 0) then
960 select case (input_state%vi_command_buffer(1:1))
961 case ('m')
962 ! Setting a mark
963 call handle_vi_mark_set(input_state, key_char)
964 return
965 case ("'")
966 ! Jumping to a mark
967 call handle_vi_mark_jump(input_state, key_char)
968 return
969 case ('d')
970 ! Delete with motion
971 call handle_vi_delete_with_motion(input_state, key_char)
972 return
973 case ('y')
974 ! Yank with motion
975 call handle_vi_yank_with_motion(input_state, key_char)
976 return
977 case ('c')
978 ! Change with motion
979 call handle_vi_change_with_motion(input_state, key_char)
980 return
981 case ('r')
982 ! Replace character
983 call handle_vi_replace_char(input_state, key_char)
984 return
985 end select
986 end if
987
988 ! Handle repeat counts (1-9)
989 if (key >= ichar('1') .and. key <= ichar('9') .and. .not. input_state%vi_repeat_pending) then
990 input_state%vi_repeat_pending = .true.
991 input_state%vi_command_count = key - ichar('0')
992 return
993 else if (key >= ichar('0') .and. key <= ichar('9') .and. input_state%vi_repeat_pending) then
994 input_state%vi_command_count = input_state%vi_command_count * 10 + (key - ichar('0'))
995 return
996 end if
997
998 ! Get repeat count (default to 1)
999 if (input_state%vi_repeat_pending) then
1000 repeat_count = input_state%vi_command_count
1001 input_state%vi_repeat_pending = .false.
1002 input_state%vi_command_count = 0
1003 else
1004 repeat_count = 1
1005 end if
1006
1007 select case (key)
1008 ! Navigation (with repeat)
1009 case (ichar('h'))
1010 ! Move left
1011 do i = 1, repeat_count
1012 if (input_state%cursor_pos > 0) then
1013 input_state%cursor_pos = input_state%cursor_pos - 1
1014 end if
1015 end do
1016 input_state%dirty = .true.
1017 case (ichar('l'))
1018 ! Move right
1019 do i = 1, repeat_count
1020 if (input_state%cursor_pos < input_state%length - 1) then
1021 input_state%cursor_pos = input_state%cursor_pos + 1
1022 end if
1023 end do
1024 input_state%dirty = .true.
1025 case (ichar('j'))
1026 ! Move down (history down)
1027 do i = 1, repeat_count
1028 call handle_history_down(input_state)
1029 end do
1030 case (ichar('k'))
1031 ! Move up (history up)
1032 do i = 1, repeat_count
1033 call handle_history_up(input_state)
1034 end do
1035 case (ichar('0'))
1036 ! Beginning of line (no repeat)
1037 input_state%cursor_pos = 0
1038 input_state%dirty = .true.
1039 case (ichar('$'))
1040 ! End of line (no repeat)
1041 input_state%cursor_pos = input_state%length
1042 input_state%dirty = .true.
1043 case (ichar('w'))
1044 ! Next word
1045 do i = 1, repeat_count
1046 call move_to_next_word(input_state)
1047 end do
1048 case (ichar('b'))
1049 ! Previous word
1050 do i = 1, repeat_count
1051 call move_to_previous_word(input_state)
1052 end do
1053 case (ichar('e'))
1054 ! End of current word
1055 do i = 1, repeat_count
1056 call move_to_word_end(input_state)
1057 end do
1058
1059 ! Deletion (with repeat)
1060 case (ichar('x'))
1061 ! Delete character at cursor
1062 do i = 1, repeat_count
1063 call delete_char_at_cursor(input_state)
1064 end do
1065 case (ichar('X'))
1066 ! Delete character before cursor
1067 do i = 1, repeat_count
1068 if (input_state%cursor_pos > 0) then
1069 input_state%cursor_pos = input_state%cursor_pos - 1
1070 call delete_char_at_cursor(input_state)
1071 end if
1072 end do
1073 case (ichar('d'))
1074 ! Delete with motion - set up for next character
1075 input_state%vi_command_buffer = 'd'
1076 input_state%vi_command_count = repeat_count
1077
1078 ! Change (with repeat)
1079 case (ichar('c'))
1080 ! Change with motion - set up for next character
1081 input_state%vi_command_buffer = 'c'
1082 input_state%vi_command_count = repeat_count
1083 case (ichar('C'))
1084 ! Change to end of line
1085 call handle_vi_change_to_eol(input_state)
1086
1087 ! Undo
1088 case (ichar('u'))
1089 ! Undo (simplified)
1090 input_state%buffer = input_state%original_buffer
1091 input_state%length = len_trim(input_state%original_buffer)
1092 input_state%cursor_pos = min(input_state%cursor_pos, input_state%length)
1093 input_state%dirty = .true.
1094
1095 ! Yank and Put (vi-style copy/paste)
1096 case (ichar('y'))
1097 ! Yank with motion - set up for next character
1098 input_state%vi_command_buffer = 'y'
1099 input_state%vi_command_count = repeat_count
1100 case (ichar('p'))
1101 ! Put (paste) after cursor
1102 do i = 1, repeat_count
1103 call handle_vi_put(input_state, .false.)
1104 end do
1105 case (ichar('P'))
1106 ! Put (paste) before cursor
1107 do i = 1, repeat_count
1108 call handle_vi_put(input_state, .true.)
1109 end do
1110
1111 ! Replace
1112 case (ichar('r'))
1113 ! Replace character - wait for next character
1114 input_state%vi_command_buffer = 'r'
1115 input_state%vi_command_count = repeat_count
1116 case (ichar('R'))
1117 ! Replace mode - enter insert mode with replace behavior
1118 input_state%vi_mode = VI_MODE_INSERT
1119 ! TODO: Add replace mode flag for overwrite behavior
1120
1121 ! Marks
1122 case (ichar('m'))
1123 ! Set mark - next character will be the mark name
1124 input_state%vi_command_buffer = 'm'
1125 input_state%vi_command_count = 1
1126 case (ichar("'"))
1127 ! Jump to mark - next character will be the mark name
1128 input_state%vi_command_buffer = "'"
1129 input_state%vi_command_count = 1
1130
1131 ! Vi search
1132 case (ichar('/'))
1133 ! Forward search
1134 call handle_vi_search_start(input_state, .true.)
1135 case (ichar('?'))
1136 ! Backward search
1137 call handle_vi_search_start(input_state, .false.)
1138 case (ichar('n'))
1139 ! Next search match
1140 call handle_vi_search_next(input_state, .true.)
1141 case (ichar('N'))
1142 ! Previous search match
1143 call handle_vi_search_next(input_state, .false.)
1144
1145 ! Mode switches (with proper cursor positioning)
1146 case (ichar('i'))
1147 ! Insert at cursor
1148 input_state%vi_mode = VI_MODE_INSERT
1149 case (ichar('a'))
1150 ! Insert after cursor
1151 if (input_state%cursor_pos < input_state%length) then
1152 input_state%cursor_pos = input_state%cursor_pos + 1
1153 end if
1154 input_state%vi_mode = VI_MODE_INSERT
1155 case (ichar('I'))
1156 ! Insert at beginning of line
1157 input_state%cursor_pos = 0
1158 input_state%vi_mode = VI_MODE_INSERT
1159 case (ichar('A'))
1160 ! Insert at end of line
1161 input_state%cursor_pos = input_state%length
1162 input_state%vi_mode = VI_MODE_INSERT
1163 case (ichar('o'))
1164 ! Open line below (simplified - just go to end)
1165 input_state%cursor_pos = input_state%length
1166 input_state%vi_mode = VI_MODE_INSERT
1167 case (ichar('O'))
1168 ! Open line above (simplified - just go to beginning)
1169 input_state%cursor_pos = 0
1170 input_state%vi_mode = VI_MODE_INSERT
1171 end select
1172 end subroutine
1173
1174 ! Motion-based delete command
1175 subroutine handle_vi_delete_with_motion(input_state, motion)
1176 type(input_state_t), intent(inout) :: input_state
1177 character, intent(in) :: motion
1178 integer :: start_pos, end_pos, delete_len, i, repeat_count
1179
1180 repeat_count = max(1, input_state%vi_command_count)
1181
1182 select case (motion)
1183 case ('d')
1184 ! dd - delete entire line
1185 input_state%vi_yank_buffer = input_state%buffer(:input_state%length)
1186 input_state%vi_yank_length = input_state%length
1187 input_state%buffer = ''
1188 input_state%length = 0
1189 input_state%cursor_pos = 0
1190 input_state%dirty = .true.
1191
1192 case ('w')
1193 ! dw - delete to next word
1194 do i = 1, repeat_count
1195 start_pos = input_state%cursor_pos + 1
1196 call move_to_next_word(input_state)
1197 end_pos = input_state%cursor_pos + 1
1198 delete_len = end_pos - start_pos
1199 if (delete_len > 0) then
1200 call yank_range(input_state, start_pos, end_pos)
1201 call delete_range(input_state, start_pos, end_pos)
1202 end if
1203 end do
1204
1205 case ('$')
1206 ! d$ - delete to end of line
1207 start_pos = input_state%cursor_pos + 1
1208 end_pos = input_state%length + 1
1209 call yank_range(input_state, start_pos, end_pos)
1210 call delete_range(input_state, start_pos, end_pos)
1211
1212 case ('0')
1213 ! d0 - delete to beginning of line
1214 start_pos = 1
1215 end_pos = input_state%cursor_pos + 1
1216 call yank_range(input_state, start_pos, end_pos)
1217 call delete_range(input_state, start_pos, end_pos)
1218
1219 case ('b')
1220 ! db - delete to previous word
1221 do i = 1, repeat_count
1222 end_pos = input_state%cursor_pos + 1
1223 call move_to_previous_word(input_state)
1224 start_pos = input_state%cursor_pos + 1
1225 call yank_range(input_state, start_pos, end_pos)
1226 call delete_range(input_state, start_pos, end_pos)
1227 end do
1228
1229 case ('e')
1230 ! de - delete to end of word
1231 do i = 1, repeat_count
1232 start_pos = input_state%cursor_pos + 1
1233 call move_to_word_end(input_state)
1234 end_pos = input_state%cursor_pos + 2
1235 call yank_range(input_state, start_pos, end_pos)
1236 call delete_range(input_state, start_pos, end_pos)
1237 end do
1238 end select
1239
1240 ! Clear command buffer
1241 input_state%vi_command_buffer = ''
1242 input_state%vi_command_count = 0
1243 end subroutine
1244
1245 ! Motion-based yank command
1246 subroutine handle_vi_yank_with_motion(input_state, motion)
1247 type(input_state_t), intent(inout) :: input_state
1248 character, intent(in) :: motion
1249 integer :: start_pos, end_pos, saved_cursor, repeat_count, i
1250
1251 repeat_count = max(1, input_state%vi_command_count)
1252 saved_cursor = input_state%cursor_pos
1253
1254 select case (motion)
1255 case ('y')
1256 ! yy - yank entire line
1257 input_state%vi_yank_buffer = input_state%buffer(:input_state%length)
1258 input_state%vi_yank_length = input_state%length
1259
1260 case ('w')
1261 ! yw - yank to next word
1262 start_pos = input_state%cursor_pos + 1
1263 do i = 1, repeat_count
1264 call move_to_next_word(input_state)
1265 end do
1266 end_pos = input_state%cursor_pos + 1
1267 call yank_range(input_state, start_pos, end_pos)
1268 input_state%cursor_pos = saved_cursor
1269
1270 case ('$')
1271 ! y$ - yank to end of line
1272 start_pos = input_state%cursor_pos + 1
1273 end_pos = input_state%length + 1
1274 call yank_range(input_state, start_pos, end_pos)
1275
1276 case ('0')
1277 ! y0 - yank to beginning of line
1278 start_pos = 1
1279 end_pos = input_state%cursor_pos + 1
1280 call yank_range(input_state, start_pos, end_pos)
1281
1282 case ('b')
1283 ! yb - yank to previous word
1284 end_pos = input_state%cursor_pos + 1
1285 do i = 1, repeat_count
1286 call move_to_previous_word(input_state)
1287 end do
1288 start_pos = input_state%cursor_pos + 1
1289 call yank_range(input_state, start_pos, end_pos)
1290 input_state%cursor_pos = saved_cursor
1291
1292 case ('e')
1293 ! ye - yank to end of word
1294 start_pos = input_state%cursor_pos + 1
1295 do i = 1, repeat_count
1296 call move_to_word_end(input_state)
1297 end do
1298 end_pos = input_state%cursor_pos + 2
1299 call yank_range(input_state, start_pos, end_pos)
1300 input_state%cursor_pos = saved_cursor
1301 end select
1302
1303 ! Clear command buffer
1304 input_state%vi_command_buffer = ''
1305 input_state%vi_command_count = 0
1306 end subroutine
1307
1308 ! Motion-based change command
1309 subroutine handle_vi_change_with_motion(input_state, motion)
1310 type(input_state_t), intent(inout) :: input_state
1311 character, intent(in) :: motion
1312
1313 ! Change is like delete + insert mode
1314 call handle_vi_delete_with_motion(input_state, motion)
1315 input_state%vi_mode = VI_MODE_INSERT
1316 end subroutine
1317
1318 ! Change to end of line
1319 subroutine handle_vi_change_to_eol(input_state)
1320 type(input_state_t), intent(inout) :: input_state
1321 integer :: start_pos, end_pos
1322
1323 start_pos = input_state%cursor_pos + 1
1324 end_pos = input_state%length + 1
1325 call yank_range(input_state, start_pos, end_pos)
1326 call delete_range(input_state, start_pos, end_pos)
1327 input_state%vi_mode = VI_MODE_INSERT
1328 end subroutine
1329
1330 ! Replace single character
1331 subroutine handle_vi_replace_char(input_state, replace_char)
1332 type(input_state_t), intent(inout) :: input_state
1333 character, intent(in) :: replace_char
1334 integer :: i, repeat_count
1335
1336 repeat_count = max(1, input_state%vi_command_count)
1337
1338 ! Replace up to repeat_count characters
1339 do i = 1, repeat_count
1340 if (input_state%cursor_pos + i - 1 < input_state%length) then
1341 input_state%buffer(input_state%cursor_pos+i:input_state%cursor_pos+i) = replace_char
1342 input_state%dirty = .true.
1343 end if
1344 end do
1345
1346 ! Clear command buffer
1347 input_state%vi_command_buffer = ''
1348 input_state%vi_command_count = 0
1349 end subroutine
1350
1351 ! Helper: Yank a range of characters
1352 subroutine yank_range(input_state, start_pos, end_pos)
1353 type(input_state_t), intent(inout) :: input_state
1354 integer, intent(in) :: start_pos, end_pos
1355 integer :: yank_len
1356
1357 yank_len = max(0, min(end_pos - start_pos, MAX_LINE_LEN))
1358 if (yank_len > 0 .and. start_pos >= 1 .and. start_pos <= input_state%length) then
1359 input_state%vi_yank_buffer = input_state%buffer(start_pos:start_pos+yank_len-1)
1360 input_state%vi_yank_length = yank_len
1361 end if
1362 end subroutine
1363
1364 ! Helper: Delete a range of characters
1365 subroutine delete_range(input_state, start_pos, end_pos)
1366 type(input_state_t), intent(inout) :: input_state
1367 integer, intent(in) :: start_pos, end_pos
1368 integer :: delete_len, i
1369
1370 delete_len = end_pos - start_pos
1371 if (delete_len <= 0) return
1372
1373 ! Shift remaining characters left
1374 do i = start_pos, input_state%length - delete_len
1375 if (end_pos + i - start_pos <= input_state%length) then
1376 input_state%buffer(i:i) = input_state%buffer(end_pos+i-start_pos:end_pos+i-start_pos)
1377 end if
1378 end do
1379
1380 input_state%length = input_state%length - delete_len
1381 input_state%cursor_pos = max(0, min(start_pos - 1, input_state%length))
1382 input_state%dirty = .true.
1383 end subroutine
1384
1385 ! Move to end of current word
1386 subroutine move_to_word_end(input_state)
1387 type(input_state_t), intent(inout) :: input_state
1388 integer :: pos
1389
1390 pos = input_state%cursor_pos + 1
1391
1392 ! If on whitespace, skip to next word
1393 do while (pos <= input_state%length .and. input_state%buffer(pos:pos) == ' ')
1394 pos = pos + 1
1395 end do
1396
1397 ! Find end of word
1398 do while (pos <= input_state%length .and. input_state%buffer(pos:pos) /= ' ')
1399 pos = pos + 1
1400 end do
1401
1402 input_state%cursor_pos = max(0, min(pos - 2, input_state%length - 1))
1403 input_state%dirty = .true.
1404 end subroutine
1405
1406 subroutine move_to_next_word(input_state)
1407 type(input_state_t), intent(inout) :: input_state
1408 integer :: pos
1409
1410 pos = input_state%cursor_pos + 1
1411
1412 ! Skip current word
1413 do while (pos <= input_state%length .and. input_state%buffer(pos:pos) /= ' ')
1414 pos = pos + 1
1415 end do
1416
1417 ! Skip spaces
1418 do while (pos <= input_state%length .and. input_state%buffer(pos:pos) == ' ')
1419 pos = pos + 1
1420 end do
1421
1422 input_state%cursor_pos = min(pos - 1, input_state%length)
1423 input_state%dirty = .true.
1424 end subroutine
1425
1426 subroutine move_to_previous_word(input_state)
1427 type(input_state_t), intent(inout) :: input_state
1428 integer :: pos
1429
1430 if (input_state%cursor_pos <= 0) return
1431
1432 pos = input_state%cursor_pos - 1
1433
1434 ! Skip spaces
1435 do while (pos > 0 .and. input_state%buffer(pos:pos) == ' ')
1436 pos = pos - 1
1437 end do
1438
1439 ! Find beginning of word
1440 do while (pos > 0 .and. input_state%buffer(pos:pos) /= ' ')
1441 pos = pos - 1
1442 end do
1443
1444 ! pos is now at a space (or 0 if at beginning)
1445 ! cursor_pos represents position between characters,
1446 ! so space position is correct (cursor will be after space, before first char of word)
1447 input_state%cursor_pos = pos
1448 input_state%dirty = .true.
1449 end subroutine
1450
1451 subroutine delete_char_at_cursor(input_state)
1452 type(input_state_t), intent(inout) :: input_state
1453 integer :: i
1454
1455 if (input_state%cursor_pos >= input_state%length) return
1456
1457 ! Shift characters left
1458 do i = input_state%cursor_pos + 1, input_state%length - 1
1459 input_state%buffer(i:i) = input_state%buffer(i+1:i+1)
1460 end do
1461
1462 input_state%length = input_state%length - 1
1463 input_state%buffer(input_state%length+1:input_state%length+1) = ' '
1464 input_state%dirty = .true.
1465 end subroutine
1466
1467 function get_editing_mode_name(input_state) result(mode_name)
1468 type(input_state_t), intent(in) :: input_state
1469 character(len=16) :: mode_name
1470
1471 select case (input_state%editing_mode)
1472 case (EDITING_MODE_EMACS)
1473 mode_name = 'emacs'
1474 case (EDITING_MODE_VI)
1475 if (input_state%vi_mode == VI_MODE_INSERT) then
1476 mode_name = 'vi-insert'
1477 else
1478 mode_name = 'vi-command'
1479 end if
1480 case default
1481 mode_name = 'unknown'
1482 end select
1483 end function
1484
1485 ! Basic tab completion - simplified implementation
1486 subroutine tab_complete(partial_input, completions, num_completions)
1487 character(len=*), intent(in) :: partial_input
1488 character(len=MAX_LINE_LEN), intent(out) :: completions(50) ! Max 50 completions
1489 integer, intent(out) :: num_completions
1490
1491 character(len=MAX_LINE_LEN) :: last_word, dir_path, file_pattern
1492 integer :: last_space_pos, i
1493
1494 num_completions = 0
1495
1496 ! Find the last word to complete
1497 last_space_pos = 0
1498 do i = len_trim(partial_input), 1, -1
1499 if (partial_input(i:i) == ' ') then
1500 last_space_pos = i
1501 exit
1502 end if
1503 end do
1504
1505 if (last_space_pos == 0) then
1506 last_word = trim(partial_input)
1507 else
1508 last_word = trim(partial_input(last_space_pos+1:))
1509 end if
1510
1511 ! If it's the first word, complete commands
1512 if (last_space_pos == 0) then
1513 call complete_commands(last_word, completions, num_completions)
1514 else
1515 ! Otherwise, complete files/directories
1516 call complete_files(last_word, completions, num_completions)
1517 end if
1518 end subroutine
1519
1520 ! Enhanced tab completion with programmable completion system integration
1521 subroutine enhanced_tab_complete(partial_input, completions, num_completions, shell)
1522 character(len=*), intent(in) :: partial_input
1523 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
1524 integer, intent(out) :: num_completions
1525 type(shell_state_t), intent(inout), optional :: shell
1526
1527 character(len=MAX_LINE_LEN) :: last_word, prefix_part, command_name
1528 character(len=256) :: temp_completions(MAX_COMPLETIONS)
1529 integer :: last_space_pos, i, first_space_pos, temp_count
1530 logical :: is_command, used_programmable_completion
1531 type(completion_spec_t) :: spec
1532
1533 num_completions = 0
1534 used_programmable_completion = .false.
1535
1536 ! Find the last word to complete
1537 last_space_pos = 0
1538 do i = len_trim(partial_input), 1, -1
1539 if (partial_input(i:i) == ' ') then
1540 last_space_pos = i
1541 exit
1542 end if
1543 end do
1544
1545 if (last_space_pos == 0) then
1546 last_word = trim(partial_input)
1547 prefix_part = ''
1548 is_command = .true.
1549 command_name = ''
1550 else
1551 last_word = trim(partial_input(last_space_pos+1:))
1552 prefix_part = partial_input(:last_space_pos)
1553 is_command = .false.
1554
1555 ! Extract command name (first word)
1556 first_space_pos = index(partial_input, ' ')
1557 if (first_space_pos > 0) then
1558 command_name = partial_input(:first_space_pos-1)
1559 else
1560 command_name = trim(partial_input)
1561 end if
1562 end if
1563
1564 ! Try programmable completion first (if shell state available and not completing command)
1565 if (.not. is_command .and. present(shell)) then
1566 spec = get_completion_spec(trim(command_name))
1567 if (spec%is_active) then
1568 ! Use our programmable completion system!
1569 call generate_completions(trim(command_name), trim(last_word), temp_completions, temp_count, shell)
1570 if (temp_count > 0) then
1571 ! Copy completions (convert from 256 to MAX_LINE_LEN)
1572 do i = 1, min(temp_count, 50)
1573 completions(i) = trim(temp_completions(i))
1574 end do
1575 num_completions = min(temp_count, 50)
1576 used_programmable_completion = .true.
1577 end if
1578 end if
1579 end if
1580
1581 ! Fall back to default completion if programmable completion didn't produce results
1582 if (.not. used_programmable_completion) then
1583 if (is_command) then
1584 ! Complete commands (builtins + PATH executables)
1585 call complete_commands_enhanced(last_word, completions, num_completions)
1586
1587 ! Add prefix back to completions
1588 do i = 1, num_completions
1589 completions(i) = trim(completions(i))
1590 end do
1591 else
1592 ! Check if last_word contains glob characters
1593 if (has_glob_chars(last_word)) then
1594 ! Expand glob pattern instead of regular file completion
1595 call expand_glob_for_completion(last_word, completions, num_completions)
1596 else
1597 ! Complete files and directories normally
1598 call complete_files_enhanced(last_word, completions, num_completions)
1599 end if
1600
1601 ! Filter completions based on command type
1602 ! cd, pushd, popd should only show directories
1603 if (trim(command_name) == 'cd' .or. trim(command_name) == 'pushd' .or. &
1604 trim(command_name) == 'popd') then
1605 call filter_directories_only(completions, num_completions)
1606 end if
1607
1608 ! Don't add prefix to completions - they are for display only
1609 ! The prefix will be added when constructing the completed line
1610 end if
1611 end if
1612 end subroutine
1613
1614 ! Filter completions to only keep directories (entries ending with /)
1615 subroutine filter_directories_only(completions, num_completions)
1616 character(len=MAX_LINE_LEN), intent(inout) :: completions(50)
1617 integer, intent(inout) :: num_completions
1618
1619 character(len=MAX_LINE_LEN) :: temp_completions(50)
1620 integer :: i, new_count, original_count
1621
1622 original_count = num_completions
1623 new_count = 0
1624 do i = 1, num_completions
1625 ! Keep only entries that end with / (directories)
1626 if (len_trim(completions(i)) > 0) then
1627 if (completions(i)(len_trim(completions(i)):len_trim(completions(i))) == '/') then
1628 new_count = new_count + 1
1629 temp_completions(new_count) = completions(i)
1630 end if
1631 end if
1632 end do
1633
1634 ! Copy filtered results back
1635 do i = 1, new_count
1636 completions(i) = temp_completions(i)
1637 end do
1638 num_completions = new_count
1639 end subroutine
1640
1641 ! Check if a string contains glob characters
1642 function has_glob_chars(str) result(has_globs)
1643 character(len=*), intent(in) :: str
1644 logical :: has_globs
1645
1646 has_globs = (index(str, '*') > 0 .or. &
1647 index(str, '?') > 0 .or. &
1648 index(str, '[') > 0)
1649 end function has_glob_chars
1650
1651 ! Expand glob pattern for tab completion using real filesystem
1652 subroutine expand_glob_for_completion(pattern, completions, num_completions)
1653 character(len=*), intent(in) :: pattern
1654 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
1655 integer, intent(out) :: num_completions
1656
1657 character(len=MAX_LINE_LEN) :: dir_path, file_pattern
1658 character(len=MAX_LINE_LEN) :: ls_command, ls_output
1659 character(len=MAX_LINE_LEN) :: entries(100)
1660 integer :: num_entries, i, last_slash_pos
1661 character(len=MAX_LINE_LEN) :: full_path
1662 logical :: is_dir
1663
1664 num_completions = 0
1665
1666 ! Extract directory path and filename pattern (same logic as complete_files_enhanced)
1667 last_slash_pos = 0
1668 do i = len_trim(pattern), 1, -1
1669 if (pattern(i:i) == '/') then
1670 last_slash_pos = i
1671 exit
1672 end if
1673 end do
1674
1675 if (last_slash_pos > 0) then
1676 dir_path = pattern(:last_slash_pos-1)
1677 file_pattern = pattern(last_slash_pos+1:)
1678 if (len_trim(dir_path) == 0) dir_path = '/'
1679 else
1680 dir_path = '.'
1681 file_pattern = trim(pattern)
1682 end if
1683
1684 ! Use ls command to get directory listing (same as scan_directory)
1685 ls_command = 'ls -1a "' // trim(dir_path) // '" 2>/dev/null'
1686 ls_output = execute_and_capture(ls_command)
1687
1688 ! Parse ls output into individual entries
1689 call parse_ls_output(ls_output, entries, num_entries)
1690
1691 ! Match entries against glob pattern
1692 do i = 1, num_entries
1693 if (num_completions >= 50) exit
1694
1695 ! Skip . and ..
1696 if (trim(entries(i)) == '.' .or. trim(entries(i)) == '..') cycle
1697
1698 ! Use pattern_matches from glob module to match against pattern
1699 if (pattern_matches(file_pattern, trim(entries(i)))) then
1700 ! Build full path
1701 if (trim(dir_path) == '.') then
1702 full_path = trim(entries(i))
1703 else
1704 full_path = trim(dir_path) // '/' // trim(entries(i))
1705 end if
1706
1707 ! Check if it's a directory and add trailing slash
1708 is_dir = is_directory(full_path)
1709 num_completions = num_completions + 1
1710 if (is_dir) then
1711 completions(num_completions) = trim(full_path) // '/'
1712 else
1713 completions(num_completions) = trim(full_path)
1714 end if
1715 end if
1716 end do
1717 end subroutine expand_glob_for_completion
1718
1719 subroutine complete_commands(prefix, completions, num_completions)
1720 character(len=*), intent(in) :: prefix
1721 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
1722 integer, intent(out) :: num_completions
1723
1724 character(len=50), parameter :: builtin_commands(19) = [ &
1725 'cd ', 'echo ', 'exit ', 'export ', &
1726 'pwd ', 'jobs ', 'fg ', 'bg ', &
1727 'history ', 'source ', 'test ', 'if ', &
1728 'kill ', 'wait ', 'trap ', 'config ', &
1729 'alias ', 'unalias ', 'help ' &
1730 ]
1731 integer :: i, prefix_len
1732
1733 num_completions = 0
1734 prefix_len = len_trim(prefix)
1735
1736 ! Complete builtin commands
1737 do i = 1, size(builtin_commands)
1738 if (prefix_len == 0 .or. &
1739 index(trim(builtin_commands(i)), prefix(1:prefix_len)) == 1) then
1740 num_completions = num_completions + 1
1741 if (num_completions <= 50) then
1742 completions(num_completions) = trim(builtin_commands(i))
1743 end if
1744 end if
1745 end do
1746
1747 ! TODO: Add external command completion from PATH
1748 end subroutine
1749
1750 subroutine complete_files(prefix, completions, num_completions)
1751 character(len=*), intent(in) :: prefix
1752 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
1753 integer, intent(out) :: num_completions
1754
1755 character(len=MAX_LINE_LEN) :: dir_path, file_pattern, current_dir
1756 integer :: last_slash_pos, i
1757
1758 num_completions = 0
1759
1760 ! Extract directory path and filename pattern
1761 last_slash_pos = 0
1762 do i = len_trim(prefix), 1, -1
1763 if (prefix(i:i) == '/') then
1764 last_slash_pos = i
1765 exit
1766 end if
1767 end do
1768
1769 if (last_slash_pos > 0) then
1770 dir_path = prefix(:last_slash_pos-1)
1771 file_pattern = prefix(last_slash_pos+1:)
1772 if (len_trim(dir_path) == 0) dir_path = '/'
1773 else
1774 dir_path = '.'
1775 file_pattern = trim(prefix)
1776 end if
1777
1778 ! Add common directory completions
1779 if (len_trim(file_pattern) == 0 .or. file_pattern(1:1) == '.') then
1780 if (num_completions < 50) then
1781 num_completions = num_completions + 1
1782 if (trim(dir_path) == '.') then
1783 completions(num_completions) = './'
1784 else
1785 completions(num_completions) = trim(dir_path) // '/./'
1786 end if
1787 end if
1788
1789 if (len_trim(file_pattern) == 0 .or. index(file_pattern, '..') == 1) then
1790 if (num_completions < 50) then
1791 num_completions = num_completions + 1
1792 if (trim(dir_path) == '.') then
1793 completions(num_completions) = '../'
1794 else
1795 completions(num_completions) = trim(dir_path) // '/../'
1796 end if
1797 end if
1798 end if
1799 end if
1800
1801 ! Add some common file extensions for demonstration
1802 if (len_trim(file_pattern) == 0) then
1803 if (num_completions < 47) then
1804 completions(num_completions + 1) = 'Makefile'
1805 completions(num_completions + 2) = 'README'
1806 completions(num_completions + 3) = 'LICENSE'
1807 num_completions = num_completions + 3
1808 end if
1809 end if
1810 end subroutine
1811
1812 ! Enhanced command completion with PATH executable scanning
1813 subroutine complete_commands_enhanced(prefix, completions, num_completions)
1814 character(len=*), intent(in) :: prefix
1815 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
1816 integer, intent(out) :: num_completions
1817
1818 character(len=50), parameter :: builtin_commands(20) = [ &
1819 'cd ', 'echo ', 'exit ', 'export ', &
1820 'pwd ', 'jobs ', 'fg ', 'bg ', &
1821 'history ', 'source ', 'test ', 'if ', &
1822 'kill ', 'wait ', 'trap ', 'config ', &
1823 'alias ', 'unalias ', 'help ', 'rawtest ' &
1824 ]
1825 type(scored_completion_t) :: scored(100) ! Temp storage for scoring
1826 integer :: i, num_scored, score
1827
1828 num_completions = 0
1829 num_scored = 0
1830
1831 ! Score builtin commands using fuzzy matching
1832 do i = 1, size(builtin_commands)
1833 score = fuzzy_match_score(prefix, trim(builtin_commands(i)))
1834 if (score >= 0) then ! Negative score = no match
1835 num_scored = num_scored + 1
1836 if (num_scored <= 100) then
1837 scored(num_scored)%text = trim(builtin_commands(i))
1838 scored(num_scored)%score = score
1839 end if
1840 end if
1841 end do
1842
1843 ! Add common system commands
1844 call add_system_commands_fuzzy(prefix, scored, num_scored)
1845
1846 ! Sort by score
1847 if (num_scored > 0) then
1848 call sort_completions_by_score(scored, num_scored)
1849 end if
1850
1851 ! Copy top matches to output (limit to 50)
1852 num_completions = min(num_scored, 50)
1853 do i = 1, num_completions
1854 completions(i) = scored(i)%text
1855 end do
1856 end subroutine
1857
1858 subroutine add_system_commands(prefix, completions, num_completions)
1859 character(len=*), intent(in) :: prefix
1860 character(len=MAX_LINE_LEN), intent(inout) :: completions(50)
1861 integer, intent(inout) :: num_completions
1862
1863 character(len=50), parameter :: common_commands(15) = [ &
1864 'ls ', 'cat ', 'grep ', 'find ', &
1865 'sort ', 'head ', 'tail ', 'wc ', &
1866 'cp ', 'mv ', 'rm ', 'mkdir ', &
1867 'rmdir ', 'chmod ', 'which ' &
1868 ]
1869 integer :: i, prefix_len
1870
1871 prefix_len = len_trim(prefix)
1872
1873 do i = 1, size(common_commands)
1874 if (num_completions >= 50) exit
1875 if (prefix_len == 0 .or. &
1876 index(trim(common_commands(i)), prefix(1:prefix_len)) == 1) then
1877 num_completions = num_completions + 1
1878 completions(num_completions) = trim(common_commands(i))
1879 end if
1880 end do
1881 end subroutine
1882
1883 ! Fuzzy version of add_system_commands
1884 subroutine add_system_commands_fuzzy(prefix, scored, num_scored)
1885 character(len=*), intent(in) :: prefix
1886 type(scored_completion_t), intent(inout) :: scored(:)
1887 integer, intent(inout) :: num_scored
1888
1889 character(len=50), parameter :: common_commands(15) = [ &
1890 'ls ', 'cat ', 'grep ', 'find ', &
1891 'sort ', 'head ', 'tail ', 'wc ', &
1892 'cp ', 'mv ', 'rm ', 'mkdir ', &
1893 'rmdir ', 'chmod ', 'which ' &
1894 ]
1895 integer :: i, score
1896
1897 do i = 1, size(common_commands)
1898 if (num_scored >= size(scored)) exit
1899 score = fuzzy_match_score(prefix, trim(common_commands(i)))
1900 if (score >= 0) then ! Negative score = no match
1901 num_scored = num_scored + 1
1902 scored(num_scored)%text = trim(common_commands(i))
1903 scored(num_scored)%score = score
1904 end if
1905 end do
1906 end subroutine
1907
1908 ! Enhanced file completion with real filesystem access
1909 subroutine complete_files_enhanced(prefix, completions, num_completions)
1910 character(len=*), intent(in) :: prefix
1911 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
1912 integer, intent(out) :: num_completions
1913
1914 character(len=MAX_LINE_LEN) :: dir_path, file_pattern
1915 character(len=:), allocatable :: debug_mode
1916 integer :: last_slash_pos, i
1917 logical :: debug_enabled
1918
1919 ! Check if debug mode is enabled
1920 debug_mode = get_environment_var('FORTSH_DEBUG_COMPLETION')
1921 debug_enabled = (allocated(debug_mode) .and. trim(debug_mode) == '1')
1922
1923 num_completions = 0
1924
1925 ! Extract directory path and filename pattern
1926 last_slash_pos = 0
1927 do i = len_trim(prefix), 1, -1
1928 if (prefix(i:i) == '/') then
1929 last_slash_pos = i
1930 exit
1931 end if
1932 end do
1933
1934 if (last_slash_pos > 0) then
1935 dir_path = prefix(:last_slash_pos-1)
1936 file_pattern = prefix(last_slash_pos+1:)
1937 if (len_trim(dir_path) == 0) dir_path = '/'
1938 else
1939 dir_path = '.'
1940 file_pattern = trim(prefix)
1941 end if
1942
1943 ! Add directory navigation options ONLY when explicitly requested
1944 ! Don't add ./ when user is trying to complete dotfiles like .fortshrc
1945 if (len_trim(file_pattern) == 0) then
1946 ! Empty pattern - offer . and ..
1947 if (num_completions < 50) then
1948 num_completions = num_completions + 1
1949 if (trim(dir_path) == '.') then
1950 completions(num_completions) = './'
1951 else
1952 completions(num_completions) = trim(dir_path) // '/./'
1953 end if
1954 end if
1955
1956 if (num_completions < 50) then
1957 num_completions = num_completions + 1
1958 if (trim(dir_path) == '.') then
1959 completions(num_completions) = '../'
1960 else
1961 completions(num_completions) = trim(dir_path) // '/../'
1962 end if
1963 end if
1964 else if (trim(file_pattern) == '.' .or. trim(file_pattern) == '..') then
1965 ! Exact match for . or .. - complete with /
1966 if (trim(file_pattern) == '.') then
1967 if (num_completions < 50) then
1968 num_completions = num_completions + 1
1969 if (trim(dir_path) == '.') then
1970 completions(num_completions) = './'
1971 else
1972 completions(num_completions) = trim(dir_path) // '/./'
1973 end if
1974 end if
1975 else if (trim(file_pattern) == '..') then
1976 if (num_completions < 50) then
1977 num_completions = num_completions + 1
1978 if (trim(dir_path) == '.') then
1979 completions(num_completions) = '../'
1980 else
1981 completions(num_completions) = trim(dir_path) // '/../'
1982 end if
1983 end if
1984 end if
1985 end if
1986 ! Otherwise, let scan_directory handle ALL matches including dotfiles
1987
1988 ! Get actual filesystem entries
1989 call scan_directory(dir_path, file_pattern, completions, num_completions)
1990 end subroutine
1991
1992 ! Scan directory for matching files and directories (with fuzzy matching)
1993 subroutine scan_directory(dir_path, pattern, completions, num_completions)
1994 character(len=*), intent(in) :: dir_path, pattern
1995 character(len=MAX_LINE_LEN), intent(inout) :: completions(50)
1996 integer, intent(inout) :: num_completions
1997
1998 character(len=MAX_LINE_LEN) :: ls_command, ls_output, expanded_dir
1999 character(len=MAX_LINE_LEN) :: entries(100) ! Temp storage for directory entries
2000 character(len=MAX_LINE_LEN) :: full_path, check_path
2001 character(len=:), allocatable :: home_dir, debug_mode
2002 type(scored_completion_t) :: scored(100)
2003 integer :: num_entries, i, pattern_len, num_scored, score, j
2004 logical :: is_dir, debug_enabled
2005
2006 ! Check if debug mode is enabled
2007 debug_mode = get_environment_var('FORTSH_DEBUG_COMPLETION')
2008 debug_enabled = (allocated(debug_mode) .and. trim(debug_mode) == '1')
2009
2010 pattern_len = len_trim(pattern)
2011
2012 ! Expand tilde if present (shell doesn't expand ~ inside quotes)
2013 expanded_dir = dir_path
2014 if (len_trim(dir_path) > 0 .and. dir_path(1:1) == '~') then
2015 home_dir = get_environment_var('HOME')
2016 if (allocated(home_dir) .and. len(home_dir) > 0) then
2017 if (len_trim(dir_path) == 1) then
2018 ! Just ~
2019 expanded_dir = home_dir
2020 else if (dir_path(2:2) == '/') then
2021 ! ~/something
2022 expanded_dir = trim(home_dir) // dir_path(2:)
2023 else
2024 ! ~user (not supported for now, just use as-is)
2025 expanded_dir = dir_path
2026 end if
2027 end if
2028 end if
2029
2030 ! Use ls command to get directory listing
2031 ls_command = 'ls -1a "' // trim(expanded_dir) // '" 2>/dev/null'
2032 ls_output = execute_and_capture(ls_command)
2033
2034 ! Parse ls output into individual entries
2035 call parse_ls_output(ls_output, entries, num_entries)
2036
2037 ! Score entries using fuzzy matching
2038 num_scored = 0
2039 do i = 1, num_entries
2040 if (num_scored >= 100) exit
2041
2042 ! Skip . and .. unless explicitly requested
2043 if (trim(entries(i)) == '.' .or. trim(entries(i)) == '..') then
2044 if (pattern_len == 0 .or. (pattern_len > 0 .and. pattern(1:1) /= '.')) then
2045 cycle
2046 end if
2047 end if
2048
2049 ! Calculate fuzzy match score
2050 score = fuzzy_match_score(pattern, trim(entries(i)))
2051 if (score >= 0) then ! Negative score = no match
2052 ! Build full path for directory check (use original dir_path to preserve ~ in display)
2053 if (trim(dir_path) == '.') then
2054 full_path = trim(entries(i))
2055 else
2056 full_path = trim(dir_path) // '/' // trim(entries(i))
2057 end if
2058
2059 ! Check if it's a directory using expanded path
2060 if (trim(expanded_dir) == '.') then
2061 check_path = trim(entries(i))
2062 else
2063 check_path = trim(expanded_dir) // '/' // trim(entries(i))
2064 end if
2065 is_dir = is_directory(check_path)
2066
2067 num_scored = num_scored + 1
2068 if (is_dir) then
2069 scored(num_scored)%text = trim(full_path) // '/'
2070 else
2071 scored(num_scored)%text = trim(full_path)
2072 end if
2073 scored(num_scored)%score = score
2074
2075 ! Bonus for directories (make them appear first in same score bracket)
2076 if (is_dir) then
2077 scored(num_scored)%score = scored(num_scored)%score + 5
2078 end if
2079 end if
2080 end do
2081
2082 ! Sort by score
2083 if (num_scored > 0) then
2084 call sort_completions_by_score(scored, num_scored)
2085 end if
2086
2087 ! Copy to output (add to existing completions, limit total to 50)
2088 do j = 1, num_scored
2089 if (num_completions >= 50) exit
2090 num_completions = num_completions + 1
2091 completions(num_completions) = scored(j)%text
2092 end do
2093 end subroutine
2094
2095 ! Check if a path is a directory
2096 function is_directory(path) result(is_dir)
2097 character(len=*), intent(in) :: path
2098 logical :: is_dir
2099 character(len=MAX_LINE_LEN) :: test_command, output
2100
2101 ! Use test command to check if path is a directory
2102 test_command = 'test -d "' // trim(path) // '" && echo "yes" || echo "no"'
2103 output = execute_and_capture(test_command)
2104 is_dir = (index(output, 'yes') > 0)
2105 end function
2106
2107 ! Parse ls output into individual entries
2108 subroutine parse_ls_output(output, entries, num_entries)
2109 character(len=*), intent(in) :: output
2110 character(len=MAX_LINE_LEN), intent(out) :: entries(100)
2111 integer, intent(out) :: num_entries
2112
2113 integer :: pos, start, output_len
2114
2115 num_entries = 0
2116 pos = 1
2117 output_len = len_trim(output)
2118
2119 do while (pos <= output_len .and. num_entries < 100)
2120 ! Skip whitespace
2121 do while (pos <= output_len .and. (output(pos:pos) == ' ' .or. output(pos:pos) == char(9)))
2122 pos = pos + 1
2123 end do
2124
2125 if (pos > output_len) exit
2126
2127 start = pos
2128
2129 ! Find end of entry (newline or space)
2130 do while (pos <= output_len .and. output(pos:pos) /= char(10) .and. output(pos:pos) /= ' ')
2131 pos = pos + 1
2132 end do
2133
2134 if (pos > start) then
2135 num_entries = num_entries + 1
2136 entries(num_entries) = output(start:pos-1)
2137 end if
2138
2139 pos = pos + 1
2140 end do
2141 end subroutine
2142
2143 subroutine show_completions(completions, num_completions)
2144 character(len=MAX_LINE_LEN), intent(in) :: completions(50)
2145 integer, intent(in) :: num_completions
2146 integer :: i
2147
2148 if (num_completions > 1) then
2149 write(output_unit, '(a)') ''
2150 do i = 1, num_completions
2151 write(output_unit, '(a)', advance='no') trim(completions(i)) // ' '
2152 if (mod(i, 8) == 0) write(output_unit, '(a)') '' ! New line every 8 items
2153 end do
2154 write(output_unit, '(a)') ''
2155 end if
2156 end subroutine
2157
2158 ! Find common prefix among completions
2159 function get_common_prefix(completions, num_completions) result(prefix)
2160 character(len=MAX_LINE_LEN), intent(in) :: completions(50)
2161 integer, intent(in) :: num_completions
2162 character(len=MAX_LINE_LEN) :: prefix
2163
2164 integer :: i, j, min_len, common_len
2165 logical :: matches
2166
2167 prefix = ''
2168 if (num_completions == 0) return
2169
2170 if (num_completions == 1) then
2171 prefix = trim(completions(1))
2172 return
2173 end if
2174
2175 ! Find minimum length
2176 min_len = len_trim(completions(1))
2177 do i = 2, num_completions
2178 min_len = min(min_len, len_trim(completions(i)))
2179 end do
2180
2181 ! Find common prefix length
2182 common_len = 0
2183 do j = 1, min_len
2184 matches = .true.
2185 do i = 2, num_completions
2186 if (completions(1)(j:j) /= completions(i)(j:j)) then
2187 matches = .false.
2188 exit
2189 end if
2190 end do
2191
2192 if (matches) then
2193 common_len = j
2194 else
2195 exit
2196 end if
2197 end do
2198
2199 if (common_len > 0) then
2200 prefix = completions(1)(:common_len)
2201 end if
2202 end function
2203
2204 ! Enhanced tab completion that handles partial completion
2205 subroutine smart_tab_complete(partial_input, completions, num_completions, completed_line, completed)
2206 character(len=*), intent(in) :: partial_input
2207 character(len=MAX_LINE_LEN), intent(out) :: completions(50)
2208 integer, intent(out) :: num_completions
2209 character(len=*), intent(out) :: completed_line
2210 logical, intent(out) :: completed
2211
2212 character(len=MAX_LINE_LEN) :: common_prefix, prefix_part, last_word
2213 character(len=4096) :: expanded_matches
2214 integer :: last_space_pos, i, pos, j
2215 logical :: is_glob_pattern
2216
2217 completed = .false.
2218 completed_line = partial_input
2219
2220 ! Find the prefix (command and any earlier arguments)
2221 last_space_pos = 0
2222 do i = len_trim(partial_input), 1, -1
2223 if (partial_input(i:i) == ' ') then
2224 last_space_pos = i
2225 exit
2226 end if
2227 end do
2228
2229 if (last_space_pos > 0) then
2230 prefix_part = partial_input(:last_space_pos)
2231 last_word = partial_input(last_space_pos+1:)
2232 else
2233 prefix_part = ''
2234 last_word = trim(partial_input)
2235 end if
2236
2237 ! Check if we're completing a glob pattern
2238 is_glob_pattern = has_glob_chars(last_word)
2239
2240 call enhanced_tab_complete(partial_input, completions, num_completions)
2241
2242 if (num_completions == 0) then
2243 ! No completions found
2244 return
2245 else if (num_completions == 1) then
2246 ! Single completion - add prefix back (preserve spacing)
2247 if (last_space_pos > 0) then
2248 completed_line = prefix_part(:last_space_pos) // trim(completions(1))
2249 else
2250 completed_line = trim(completions(1))
2251 end if
2252 completed = .true.
2253 else
2254 ! Multiple completions
2255 if (is_glob_pattern) then
2256 ! For glob patterns: expand all matches into command line (like bash)
2257 ! Build space-separated list of all matches
2258 expanded_matches = ''
2259 pos = 1
2260
2261 do j = 1, num_completions
2262 if (j > 1) then
2263 ! Add space separator
2264 expanded_matches(pos:pos) = ' '
2265 pos = pos + 1
2266 end if
2267
2268 ! Add this match
2269 expanded_matches(pos:pos+len_trim(completions(j))-1) = trim(completions(j))
2270 pos = pos + len_trim(completions(j))
2271 end do
2272
2273 ! Replace glob pattern with expanded matches
2274 if (last_space_pos > 0) then
2275 completed_line = prefix_part(:last_space_pos) // expanded_matches(:pos-1)
2276 else
2277 completed_line = expanded_matches(:pos-1)
2278 end if
2279 completed = .true.
2280 else
2281 ! For regular completion: try common prefix
2282 common_prefix = get_common_prefix(completions, num_completions)
2283
2284 if (len_trim(common_prefix) > len_trim(last_word)) then
2285 ! We have a common prefix that extends what user typed - use it
2286 if (last_space_pos > 0) then
2287 completed_line = prefix_part(:last_space_pos) // trim(common_prefix)
2288 else
2289 completed_line = trim(common_prefix)
2290 end if
2291 completed = .true.
2292 else
2293 ! No useful common prefix - we'll show the completions list instead
2294 ! Keep completed = .false. but don't treat as "no completions"
2295 ! The caller will see num_completions > 0 and should show them
2296 completed = .false.
2297 end if
2298 end if
2299 end if
2300 end subroutine
2301
2302 ! Helper functions for enhanced readline
2303 subroutine insert_char(input_state, ch)
2304 type(input_state_t), intent(inout) :: input_state
2305 character, intent(in) :: ch
2306 integer :: i
2307
2308 ! Check if we have room
2309 if (input_state%length >= MAX_LINE_LEN) return
2310
2311 ! If we're browsing history, exit history mode when typing
2312 if (input_state%in_history) then
2313 input_state%in_history = .false.
2314 input_state%history_pos = 0
2315 end if
2316
2317 ! Reset completion state when buffer changes
2318 input_state%completions_shown = .false.
2319
2320 ! Check for abbreviation expansion BEFORE inserting space
2321 if (ch == ' ') then
2322 call try_expand_abbreviation_at_cursor(input_state)
2323 end if
2324
2325 ! If cursor is at end, simple append
2326 if (input_state%cursor_pos >= input_state%length) then
2327 input_state%length = input_state%length + 1
2328 input_state%buffer(input_state%length:input_state%length) = ch
2329 input_state%cursor_pos = input_state%length
2330 ! Always trigger highlighting for real-time color updates
2331 input_state%dirty = .true.
2332 else
2333 ! Insert in middle - shift characters right
2334 do i = input_state%length, input_state%cursor_pos + 1, -1
2335 input_state%buffer(i+1:i+1) = input_state%buffer(i:i)
2336 end do
2337 input_state%cursor_pos = input_state%cursor_pos + 1
2338 input_state%buffer(input_state%cursor_pos:input_state%cursor_pos) = ch
2339 input_state%length = input_state%length + 1
2340 input_state%dirty = .true.
2341 end if
2342
2343 ! Update autosuggestion after inserting character
2344 call update_autosuggestion(input_state)
2345 end subroutine
2346
2347 subroutine handle_backspace(input_state)
2348 type(input_state_t), intent(inout) :: input_state
2349 integer :: i
2350
2351 if (input_state%cursor_pos <= 0) return
2352
2353 ! If we're browsing history, exit history mode when editing
2354 if (input_state%in_history) then
2355 input_state%in_history = .false.
2356 input_state%history_pos = 0
2357 end if
2358
2359 ! Reset completion state when buffer changes
2360 input_state%completions_shown = .false.
2361
2362 ! If cursor is at end, simple deletion
2363 if (input_state%cursor_pos >= input_state%length) then
2364 input_state%length = input_state%length - 1
2365 input_state%cursor_pos = input_state%cursor_pos - 1
2366 input_state%buffer(input_state%length+1:input_state%length+1) = ' '
2367 ! Always trigger highlighting for real-time color updates
2368 input_state%dirty = .true.
2369 else
2370 ! Delete in middle - shift characters left
2371 do i = input_state%cursor_pos, input_state%length - 1
2372 input_state%buffer(i:i) = input_state%buffer(i+1:i+1)
2373 end do
2374 input_state%cursor_pos = input_state%cursor_pos - 1
2375 input_state%length = input_state%length - 1
2376 input_state%buffer(input_state%length+1:input_state%length+1) = ' '
2377 input_state%dirty = .true.
2378 end if
2379
2380 ! Update autosuggestion after deleting character
2381 call update_autosuggestion(input_state)
2382 end subroutine
2383
2384 subroutine handle_tab_completion(input_state)
2385 type(input_state_t), intent(inout) :: input_state
2386 character(len=MAX_LINE_LEN) :: partial_input
2387 character(len=MAX_LINE_LEN) :: completions(50)
2388 character(len=MAX_LINE_LEN) :: completed_line
2389 character(len=MAX_LINE_LEN) :: saved_input
2390 integer :: num_completions
2391 logical :: completed, made_progress, buffer_changed
2392
2393 ! Exit history mode if we're browsing
2394 if (input_state%in_history) then
2395 input_state%in_history = .false.
2396 input_state%history_pos = 0
2397 end if
2398
2399 ! Get the current buffer content
2400 partial_input = input_state%buffer(:input_state%length)
2401 saved_input = partial_input
2402
2403 ! Check if buffer has changed since we last showed completions
2404 buffer_changed = (trim(input_state%buffer(:input_state%length)) /= &
2405 trim(input_state%last_completion_buffer))
2406
2407 ! Attempt smart completion
2408 call smart_tab_complete(partial_input, completions, num_completions, completed_line, completed)
2409
2410 if (num_completions == 0) then
2411 ! No completions found - ring bell (ASCII 7)
2412 write(output_unit, '(a)', advance='no') char(7) ! Bell for audio feedback
2413 flush(output_unit)
2414 else if (completed) then
2415 ! We have a completed line - update buffer
2416 ! Check if we made actual progress
2417 made_progress = (len_trim(completed_line) > len_trim(saved_input))
2418
2419 ! Update the input buffer with completion
2420 input_state%buffer = completed_line
2421 input_state%length = len_trim(completed_line)
2422 input_state%cursor_pos = input_state%length
2423 input_state%dirty = .true.
2424
2425 ! Update autosuggestion to account for the completion
2426 ! If the completed line still matches a history entry, show the rest
2427 call update_autosuggestion(input_state)
2428
2429 if (num_completions > 1) then
2430 if (made_progress) then
2431 ! We completed to common prefix - don't show options yet
2432 ! User can press tab again to see options
2433 input_state%completions_shown = .false.
2434 else
2435 ! At common prefix already - show available options only if not already shown
2436 if (.not. input_state%completions_shown .or. buffer_changed) then
2437 write(output_unit, '()') ! New line
2438 call show_completions(completions, num_completions)
2439 input_state%last_completion_buffer = input_state%buffer(:input_state%length)
2440 input_state%completions_shown = .true.
2441 input_state%dirty = .true.
2442 else
2443 ! Second tab (double-tab) at common prefix - enter menu selection mode!
2444 call enter_menu_select_mode(input_state, completions, num_completions, completed_line)
2445 end if
2446 end if
2447 else
2448 ! Single completion - reset flag
2449 input_state%completions_shown = .false.
2450 end if
2451 else
2452 ! We have completions but no single completion to apply
2453 ! Show the available options
2454 if (.not. input_state%completions_shown .or. buffer_changed) then
2455 ! First tab - show completions
2456 write(output_unit, '()') ! New line
2457 call show_completions(completions, num_completions)
2458 input_state%last_completion_buffer = input_state%buffer(:input_state%length)
2459 input_state%completions_shown = .true.
2460 input_state%dirty = .true.
2461 else
2462 ! Second tab (double-tab) - enter menu selection mode!
2463 call enter_menu_select_mode(input_state, completions, num_completions, partial_input)
2464 end if
2465 end if
2466 end subroutine
2467
2468 ! ===========================================================================
2469 ! Menu Selection Mode (zsh/fish-style interactive completion)
2470 ! ===========================================================================
2471
2472 subroutine enter_menu_select_mode(input_state, completions, num_completions, current_input)
2473 type(input_state_t), intent(inout) :: input_state
2474 character(len=MAX_LINE_LEN), intent(in) :: completions(50)
2475 integer, intent(in) :: num_completions
2476 character(len=*), intent(in) :: current_input
2477 integer :: i, last_space_pos
2478
2479 ! macOS workaround: Don't enter menu mode due to gfortran bug
2480 block
2481 character(len=256) :: ostype
2482 integer :: status
2483 logical :: is_macos
2484
2485 ! Check if we're on macOS at runtime
2486 call get_environment_variable("OSTYPE", ostype, status=status)
2487 if (status == 0) then
2488 is_macos = (index(ostype, "darwin") > 0)
2489 else
2490 ! Alternative check using uname
2491 call execute_command_line("uname -s | grep -q Darwin", wait=.true., exitstat=status)
2492 is_macos = (status == 0)
2493 end if
2494
2495 if (is_macos) then
2496 write(output_unit, '()')
2497 write(output_unit, '(a)') "Note: Menu selection disabled on macOS (gfortran bug workaround)"
2498 call show_completions(completions, num_completions)
2499 input_state%completions_shown = .true.
2500 input_state%dirty = .true.
2501 return
2502 end if
2503 end block
2504
2505 ! Store menu items
2506 input_state%in_menu_select = .true.
2507 input_state%menu_num_items = num_completions
2508 input_state%menu_selection = 1 ! Start with first item selected
2509
2510 do i = 1, num_completions
2511 input_state%menu_items(i) = completions(i)
2512 end do
2513
2514 ! Find the prefix (everything before the last word being completed)
2515 last_space_pos = 0
2516 do i = len_trim(current_input), 1, -1
2517 if (current_input(i:i) == ' ') then
2518 last_space_pos = i
2519 exit
2520 end if
2521 end do
2522
2523 if (last_space_pos > 0) then
2524 input_state%menu_prefix = current_input(:last_space_pos)
2525 input_state%menu_prefix_len = last_space_pos ! Store length WITH the space
2526 else
2527 input_state%menu_prefix = ''
2528 input_state%menu_prefix_len = 0
2529 end if
2530
2531 ! Draw the menu with first item highlighted
2532 call draw_completion_menu(input_state, .true.)
2533 end subroutine
2534
2535 subroutine draw_completion_menu(input_state, initial_draw)
2536 type(input_state_t), intent(in) :: input_state
2537 logical, intent(in) :: initial_draw
2538 integer :: i, cols_per_item, items_per_row, row, col, item_idx
2539 integer :: term_rows, term_cols
2540 logical :: success
2541
2542 ! Get terminal size
2543 success = get_terminal_size(term_rows, term_cols)
2544 if (.not. success .or. term_cols <= 0) then
2545 term_cols = 80
2546 end if
2547
2548 ! Add newline only on initial draw, not on redraw
2549 if (initial_draw) then
2550 write(output_unit, '()') ! New line
2551 end if
2552
2553 ! Calculate layout - try to fit multiple items per row
2554 cols_per_item = 0
2555 do i = 1, input_state%menu_num_items
2556 cols_per_item = max(cols_per_item, len_trim(input_state%menu_items(i)))
2557 end do
2558 cols_per_item = cols_per_item + 2 ! Add spacing
2559
2560 items_per_row = max(1, term_cols / cols_per_item)
2561
2562 ! Draw menu items
2563 item_idx = 1
2564 do while (item_idx <= input_state%menu_num_items)
2565 ! Draw one row
2566 do col = 1, items_per_row
2567 if (item_idx > input_state%menu_num_items) exit
2568
2569 ! Highlight selected item with reverse video
2570 if (item_idx == input_state%menu_selection) then
2571 write(output_unit, '(a)', advance='no') char(27) // '[7m' ! Reverse video
2572 end if
2573
2574 write(output_unit, '(a)', advance='no') trim(input_state%menu_items(item_idx))
2575
2576 if (item_idx == input_state%menu_selection) then
2577 write(output_unit, '(a)', advance='no') char(27) // '[0m' ! Reset
2578 end if
2579
2580 ! Add spacing between columns
2581 if (col < items_per_row .and. item_idx < input_state%menu_num_items) then
2582 write(output_unit, '(a)', advance='no') ' '
2583 end if
2584
2585 item_idx = item_idx + 1
2586 end do
2587 write(output_unit, '()') ! New line after each row
2588 end do
2589
2590 ! Mark that we need to redraw the command line
2591 flush(output_unit)
2592 end subroutine
2593
2594 subroutine handle_menu_navigation(input_state, key, done)
2595 type(input_state_t), intent(inout) :: input_state
2596 integer, intent(in) :: key
2597 logical, intent(inout) :: done
2598 integer :: old_selection
2599
2600 if (.not. input_state%in_menu_select) return
2601
2602 old_selection = input_state%menu_selection
2603
2604 select case (key)
2605 case (KEY_UP)
2606 ! Move up (previous item)
2607 input_state%menu_selection = input_state%menu_selection - 1
2608 if (input_state%menu_selection < 1) then
2609 input_state%menu_selection = input_state%menu_num_items ! Wrap to end
2610 end if
2611
2612 case (KEY_DOWN)
2613 ! Move down (next item)
2614 input_state%menu_selection = input_state%menu_selection + 1
2615 if (input_state%menu_selection > input_state%menu_num_items) then
2616 input_state%menu_selection = 1 ! Wrap to beginning
2617 end if
2618
2619 case (KEY_RIGHT, KEY_TAB)
2620 ! Move to next item (like Tab cycling)
2621 input_state%menu_selection = input_state%menu_selection + 1
2622 if (input_state%menu_selection > input_state%menu_num_items) then
2623 input_state%menu_selection = 1
2624 end if
2625
2626 case (KEY_LEFT)
2627 ! Move to previous item
2628 input_state%menu_selection = input_state%menu_selection - 1
2629 if (input_state%menu_selection < 1) then
2630 input_state%menu_selection = input_state%menu_num_items
2631 end if
2632
2633 case (10, 13) ! Enter (LF or CR)
2634 ! Accept selection - insert into command line
2635 call accept_menu_selection(input_state)
2636 return
2637
2638 case (KEY_ESC)
2639 ! Cancel menu mode
2640 call exit_menu_select_mode(input_state)
2641 return
2642
2643 case default
2644 ! Any other key exits menu mode and processes normally
2645 call exit_menu_select_mode(input_state)
2646 return
2647 end select
2648
2649 ! Redraw menu if selection changed
2650 if (old_selection /= input_state%menu_selection) then
2651 call redraw_menu(input_state)
2652 end if
2653 end subroutine
2654
2655 subroutine accept_menu_selection(input_state)
2656 type(input_state_t), intent(inout) :: input_state
2657 character(len=MAX_LINE_LEN) :: completed_line
2658
2659 ! Build completed command with selected item
2660 if (input_state%menu_prefix_len > 0) then
2661 ! Use stored prefix_len which includes the trailing space
2662 completed_line = input_state%menu_prefix(:input_state%menu_prefix_len) // &
2663 trim(input_state%menu_items(input_state%menu_selection))
2664 else
2665 completed_line = trim(input_state%menu_items(input_state%menu_selection))
2666 end if
2667
2668 ! Update buffer
2669 input_state%buffer = completed_line
2670 input_state%length = len_trim(completed_line)
2671 input_state%cursor_pos = input_state%length
2672
2673 ! Exit menu mode
2674 call exit_menu_select_mode(input_state)
2675
2676 ! Update autosuggestion
2677 call update_autosuggestion(input_state)
2678
2679 ! Mark for redraw
2680 input_state%dirty = .true.
2681 end subroutine
2682
2683 subroutine exit_menu_select_mode(input_state)
2684 type(input_state_t), intent(inout) :: input_state
2685
2686 input_state%in_menu_select = .false.
2687 input_state%menu_num_items = 0
2688 input_state%menu_selection = 1
2689 input_state%menu_prefix_len = 0
2690 input_state%completions_shown = .false.
2691 input_state%dirty = .true.
2692 end subroutine
2693
2694 subroutine redraw_menu(input_state)
2695 type(input_state_t), intent(in) :: input_state
2696 integer :: i, num_rows
2697
2698 ! Calculate how many rows the menu uses
2699 num_rows = (input_state%menu_num_items + 7) / 8 ! Assuming 8 items per row for now
2700
2701 ! Move cursor up to clear previous menu
2702 do i = 1, num_rows
2703 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
2704 end do
2705 write(output_unit, '(a)', advance='no') ESC_MOVE_BOL
2706 write(output_unit, '(a)', advance='no') char(27) // '[J' ! Clear from cursor down
2707
2708 ! Redraw menu (without adding extra newline)
2709 call draw_completion_menu(input_state, .false.)
2710 end subroutine
2711
2712 subroutine handle_escape_sequence(input_state, done)
2713 type(input_state_t), intent(inout) :: input_state
2714 logical, intent(inout) :: done
2715 character :: ch1, ch2
2716 logical :: success
2717
2718 ! Check if we're in menu select mode - route arrow keys to menu navigation
2719 if (input_state%in_menu_select) then
2720 ! Try to read the next character to see if it's an arrow key
2721 success = read_single_char(ch1)
2722 if (.not. success) then
2723 ! Just ESC by itself - exit menu
2724 call handle_menu_navigation(input_state, KEY_ESC, done)
2725 return
2726 end if
2727
2728 if (ch1 == '[') then
2729 ! ANSI escape sequence
2730 success = read_single_char(ch2)
2731 if (.not. success) return
2732
2733 select case(ch2)
2734 case('A') ! Up arrow
2735 call handle_menu_navigation(input_state, KEY_UP, done)
2736 case('B') ! Down arrow
2737 call handle_menu_navigation(input_state, KEY_DOWN, done)
2738 case('C') ! Right arrow
2739 call handle_menu_navigation(input_state, KEY_RIGHT, done)
2740 case('D') ! Left arrow
2741 call handle_menu_navigation(input_state, KEY_LEFT, done)
2742 case default
2743 ! Unknown escape sequence in menu mode
2744 continue
2745 end select
2746 end if
2747 return
2748 end if
2749
2750 ! Check if we're in Vi insert mode - ESC switches to command mode
2751 if (input_state%editing_mode == EDITING_MODE_VI .and. &
2752 input_state%vi_mode == VI_MODE_INSERT) then
2753 call handle_vi_mode_switch(input_state, KEY_ESC)
2754 return
2755 end if
2756
2757 ! Try to read the next character
2758 success = read_single_char(ch1)
2759 if (.not. success) return
2760
2761 if (ch1 == '[') then
2762 ! ANSI escape sequence
2763 success = read_single_char(ch2)
2764 if (.not. success) return
2765
2766 select case(ch2)
2767 case('A') ! Up arrow
2768 call handle_history_up(input_state)
2769 case('B') ! Down arrow
2770 call handle_history_down(input_state)
2771 case('C') ! Right arrow
2772 call handle_cursor_right(input_state)
2773 case('D') ! Left arrow
2774 call handle_cursor_left(input_state)
2775 case('1', '2', '3', '4', '5', '6')
2776 ! Extended escape sequence (e.g., Ctrl+Arrow = ESC[1;5C)
2777 ! Parse it to check if it's a key we care about
2778 call handle_extended_escape_sequence(input_state)
2779 case default
2780 ! Unknown escape sequence - ignore it
2781 continue
2782 end select
2783 else
2784 ! Not '[', so it's an Alt+key combination (ESC followed by character)
2785 select case(ch1)
2786 case('.')
2787 ! Alt+. - Insert last argument from previous command
2788 call handle_yank_last_arg(input_state)
2789 case('b')
2790 ! Alt+b - Move backward one word
2791 call move_to_previous_word(input_state)
2792 case('f')
2793 ! Alt+f - Move forward one word
2794 call move_to_next_word(input_state)
2795 case('d')
2796 ! Alt+d - Delete word forward
2797 call handle_delete_word_forward(input_state)
2798 case('u')
2799 ! Alt+u - Uppercase word (from cursor to end of word)
2800 call handle_uppercase_word(input_state)
2801 case('l')
2802 ! Alt+l - Lowercase word (from cursor to end of word)
2803 call handle_lowercase_word(input_state)
2804 case('c')
2805 ! Alt+c - Capitalize word (uppercase first char, lowercase rest)
2806 call handle_capitalize_word(input_state)
2807 case(char(127))
2808 ! Alt+Backspace - Delete word backward (same as Ctrl+W)
2809 call handle_kill_word(input_state)
2810 case default
2811 ! Unknown Alt+key combination
2812 continue
2813 end select
2814 end if
2815 end subroutine
2816
2817 ! Handle extended escape sequences like ESC[1;5C (Ctrl+Right Arrow)
2818 subroutine handle_extended_escape_sequence(input_state)
2819 type(input_state_t), intent(inout) :: input_state
2820 character :: ch, modifier, terminator
2821 logical :: success
2822 integer :: count
2823
2824 ! Extended sequences have format: ESC[1;5C
2825 ! We've already read '1' (or similar), now read rest of sequence
2826 ! Format: [digit];[modifier][letter]
2827
2828 ! Read until we find a semicolon or letter
2829 count = 0
2830 do while (count < 10) ! Safety limit
2831 success = read_single_char(ch)
2832 if (.not. success) return
2833
2834 if (ch == ';') then
2835 ! Found semicolon, next char is the modifier
2836 success = read_single_char(modifier)
2837 if (.not. success) return
2838
2839 ! Read the terminating letter
2840 success = read_single_char(terminator)
2841 if (.not. success) return
2842
2843 ! Check for Ctrl+Right arrow (modifier=5, terminator=C)
2844 if (modifier == '5' .and. terminator == 'C') then
2845 ! Ctrl+Right arrow - accept one word from autosuggestion
2846 if (input_state%cursor_pos == input_state%length .and. &
2847 input_state%suggestion_length > 0) then
2848 call accept_autosuggestion_word(input_state)
2849 end if
2850 ! Check for Alt+Up arrow (modifier=3, terminator=A)
2851 else if (modifier == '3' .and. terminator == 'A') then
2852 ! Alt+Up - Go to parent directory (cd ..)
2853 call handle_alt_up(input_state)
2854 ! Check for Alt+Left arrow (modifier=3, terminator=D)
2855 else if (modifier == '3' .and. terminator == 'D') then
2856 ! Alt+Left - Go to previous directory (prevd)
2857 call handle_alt_left(input_state)
2858 ! Check for Alt+Right arrow (modifier=3, terminator=C)
2859 else if (modifier == '3' .and. terminator == 'C') then
2860 ! Alt+Right - Go to next directory (nextd)
2861 call handle_alt_right(input_state)
2862 end if
2863 ! For other extended sequences, we just consume them
2864 return
2865 else if ((ch >= 'A' .and. ch <= 'Z') .or. (ch >= 'a' .and. ch <= 'z')) then
2866 ! Found letter terminator without semicolon, done
2867 return
2868 end if
2869
2870 count = count + 1
2871 end do
2872 end subroutine
2873
2874 subroutine handle_cursor_left(input_state)
2875 type(input_state_t), intent(inout) :: input_state
2876
2877 if (input_state%cursor_pos > 0) then
2878 input_state%cursor_pos = input_state%cursor_pos - 1
2879 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
2880 flush(output_unit)
2881 end if
2882 end subroutine
2883
2884 subroutine handle_cursor_right(input_state)
2885 type(input_state_t), intent(inout) :: input_state
2886
2887 if (input_state%cursor_pos < input_state%length) then
2888 input_state%cursor_pos = input_state%cursor_pos + 1
2889 write(output_unit, '(a)', advance='no') ESC_CURSOR_RIGHT
2890 flush(output_unit)
2891 else if (input_state%cursor_pos == input_state%length .and. input_state%suggestion_length > 0) then
2892 ! At end of line with suggestion - accept it
2893 call accept_autosuggestion(input_state)
2894 end if
2895 end subroutine
2896
2897 subroutine handle_history_up(input_state)
2898 type(input_state_t), intent(inout) :: input_state
2899 character(len=MAX_LINE_LEN) :: history_line
2900 logical :: found
2901
2902 ! If not currently browsing history, save the current input
2903 if (.not. input_state%in_history) then
2904 input_state%original_buffer = input_state%buffer
2905 input_state%history_pos = command_history%count + 1
2906 input_state%in_history = .true.
2907 end if
2908
2909 ! Move up in history
2910 if (input_state%history_pos > 1) then
2911 input_state%history_pos = input_state%history_pos - 1
2912 call get_history_line(input_state%history_pos, history_line, found)
2913
2914 if (found) then
2915 input_state%buffer = history_line
2916 input_state%length = len_trim(history_line)
2917 input_state%cursor_pos = input_state%length
2918 input_state%dirty = .true.
2919 end if
2920 end if
2921 end subroutine
2922
2923 subroutine handle_history_down(input_state)
2924 type(input_state_t), intent(inout) :: input_state
2925 character(len=MAX_LINE_LEN) :: history_line
2926 logical :: found
2927
2928 ! Only navigate down if we're currently in history
2929 if (.not. input_state%in_history) return
2930
2931 ! Move down in history
2932 if (input_state%history_pos < command_history%count) then
2933 input_state%history_pos = input_state%history_pos + 1
2934 call get_history_line(input_state%history_pos, history_line, found)
2935
2936 if (found) then
2937 input_state%buffer = history_line
2938 input_state%length = len_trim(history_line)
2939 input_state%cursor_pos = input_state%length
2940 input_state%dirty = .true.
2941 end if
2942 else if (input_state%history_pos <= command_history%count) then
2943 ! Reached the end of history, restore original input
2944 input_state%buffer = input_state%original_buffer
2945 input_state%length = len_trim(input_state%original_buffer)
2946 input_state%cursor_pos = input_state%length
2947 input_state%history_pos = command_history%count + 1
2948 input_state%in_history = .false.
2949 input_state%dirty = .true.
2950 end if
2951 end subroutine
2952
2953 ! Calculate visual length of string (excluding ANSI escape codes)
2954 function visual_length(str) result(vlen)
2955 character(len=*), intent(in) :: str
2956 integer :: vlen
2957 integer :: i, slen
2958 logical :: in_escape
2959
2960 vlen = 0
2961 slen = len_trim(str)
2962 in_escape = .false.
2963
2964 do i = 1, slen
2965 if (in_escape) then
2966 ! Inside escape sequence, skip until we find the terminator
2967 if (str(i:i) >= 'A' .and. str(i:i) <= 'Z') then
2968 in_escape = .false. ! Capital letter terminates escape sequence
2969 else if (str(i:i) >= 'a' .and. str(i:i) <= 'z') then
2970 in_escape = .false. ! Lowercase letter terminates escape sequence
2971 end if
2972 else if (str(i:i) == char(27)) then
2973 ! ESC character starts escape sequence
2974 in_escape = .true.
2975 else if (str(i:i) == char(13)) then
2976 ! Carriage return - doesn't add to visual length
2977 continue
2978 else
2979 ! Regular character
2980 vlen = vlen + 1
2981 end if
2982 end do
2983 end function
2984
2985 ! ===========================================================================
2986 ! Fuzzy Matching Functions
2987 ! ===========================================================================
2988
2989 ! Calculate fuzzy match score (higher = better match)
2990 ! Returns -1 if no match (pattern chars not found in order)
2991 ! Returns 0+ for matches with bonus points for:
2992 ! - Consecutive character matches
2993 ! - Matches at word boundaries
2994 ! - Matches at start of string
2995 function fuzzy_match_score(pattern, candidate) result(score)
2996 character(len=*), intent(in) :: pattern, candidate
2997 integer :: score
2998
2999 integer :: pattern_len, candidate_len
3000 integer :: pattern_idx, candidate_idx
3001 integer :: match_positions(MAX_LINE_LEN)
3002 integer :: num_matches, i
3003 integer :: consecutive_bonus, boundary_bonus
3004 logical :: case_match, is_prefix_match
3005 character :: pattern_char, candidate_char
3006
3007 pattern_len = len_trim(pattern)
3008 candidate_len = len_trim(candidate)
3009
3010 ! Empty pattern matches everything with base score
3011 if (pattern_len == 0) then
3012 score = 100
3013 return
3014 end if
3015
3016 ! Pattern longer than candidate = no match
3017 if (pattern_len > candidate_len) then
3018 score = -1
3019 return
3020 end if
3021
3022 ! For short patterns (1-2 chars), require prefix match for better UX
3023 ! This prevents "RE" from matching "parser_enhanced.mod"
3024 if (pattern_len <= 2) then
3025 is_prefix_match = .true.
3026 do i = 1, pattern_len
3027 if (to_lowercase(pattern(i:i)) /= to_lowercase(candidate(i:i))) then
3028 is_prefix_match = .false.
3029 exit
3030 end if
3031 end do
3032 if (.not. is_prefix_match) then
3033 score = -1
3034 return
3035 end if
3036 end if
3037
3038 ! Find all pattern characters in order
3039 pattern_idx = 1
3040 num_matches = 0
3041
3042 do candidate_idx = 1, candidate_len
3043 if (pattern_idx > pattern_len) exit
3044
3045 pattern_char = pattern(pattern_idx:pattern_idx)
3046 candidate_char = candidate(candidate_idx:candidate_idx)
3047
3048 ! Case-insensitive comparison
3049 if (to_lowercase(pattern_char) == to_lowercase(candidate_char)) then
3050 num_matches = num_matches + 1
3051 match_positions(num_matches) = candidate_idx
3052 pattern_idx = pattern_idx + 1
3053 end if
3054 end do
3055
3056 ! Not all pattern characters found = no match
3057 if (pattern_idx <= pattern_len) then
3058 score = -1
3059 return
3060 end if
3061
3062 ! Base score: 100 points for matching
3063 score = 100
3064
3065 ! Bonus for matching at start
3066 if (match_positions(1) == 1) then
3067 score = score + 50
3068 end if
3069
3070 ! Bonus for consecutive matches
3071 consecutive_bonus = 0
3072 do i = 2, num_matches
3073 if (match_positions(i) == match_positions(i-1) + 1) then
3074 consecutive_bonus = consecutive_bonus + 10
3075 end if
3076 end do
3077 score = score + consecutive_bonus
3078
3079 ! Bonus for matches at word boundaries (after space, -, _, /)
3080 boundary_bonus = 0
3081 do i = 1, num_matches
3082 if (match_positions(i) > 1) then
3083 candidate_char = candidate(match_positions(i)-1:match_positions(i)-1)
3084 if (candidate_char == ' ' .or. candidate_char == '-' .or. &
3085 candidate_char == '_' .or. candidate_char == '/') then
3086 boundary_bonus = boundary_bonus + 15
3087 end if
3088 end if
3089 end do
3090 score = score + boundary_bonus
3091
3092 ! Bonus for case-sensitive match
3093 case_match = .true.
3094 do i = 1, num_matches
3095 pattern_char = pattern(i:i)
3096 candidate_char = candidate(match_positions(i):match_positions(i))
3097 if (pattern_char /= candidate_char) then
3098 case_match = .false.
3099 exit
3100 end if
3101 end do
3102 if (case_match) then
3103 score = score + 20
3104 end if
3105
3106 ! Penalty for longer candidates (prefer shorter matches)
3107 score = score - (candidate_len - pattern_len)
3108
3109 ! Penalty for gaps between matches
3110 do i = 2, num_matches
3111 score = score - (match_positions(i) - match_positions(i-1) - 1)
3112 end do
3113 end function
3114
3115 ! Helper: convert character to lowercase
3116 function to_lowercase(c) result(lower)
3117 character, intent(in) :: c
3118 character :: lower
3119 integer :: ascii_val
3120
3121 ascii_val = ichar(c)
3122 if (ascii_val >= ichar('A') .and. ascii_val <= ichar('Z')) then
3123 lower = char(ascii_val + 32)
3124 else
3125 lower = c
3126 end if
3127 end function
3128
3129 ! Sort completions by fuzzy match score (bubble sort - good enough for small arrays)
3130 subroutine sort_completions_by_score(scored_completions, count)
3131 type(scored_completion_t), intent(inout) :: scored_completions(:)
3132 integer, intent(in) :: count
3133
3134 type(scored_completion_t) :: temp
3135 integer :: i, j
3136 logical :: swapped
3137
3138 ! Bubble sort (descending order - highest scores first)
3139 do i = 1, count - 1
3140 swapped = .false.
3141 do j = 1, count - i
3142 if (scored_completions(j)%score < scored_completions(j+1)%score) then
3143 temp = scored_completions(j)
3144 scored_completions(j) = scored_completions(j+1)
3145 scored_completions(j+1) = temp
3146 swapped = .true.
3147 end if
3148 end do
3149 if (.not. swapped) exit
3150 end do
3151 end subroutine
3152
3153 subroutine redraw_line(prompt, input_state)
3154 character(len=*), intent(in) :: prompt
3155 type(input_state_t), intent(in) :: input_state
3156 character(len=:), allocatable :: highlighted
3157 integer :: term_rows, term_cols, total_visual_chars
3158 integer :: prompt_visual_len, current_line, end_line
3159 integer :: cursor_visual_pos, cursor_line, cursor_col
3160 integer :: i, suggestion_display_len, available_space
3161 logical :: success
3162
3163 ! Get terminal size
3164 success = get_terminal_size(term_rows, term_cols)
3165 if (.not. success .or. term_cols <= 0) then
3166 term_cols = 80 ! Fallback
3167 end if
3168
3169 ! Additional safety check
3170 if (term_cols < 20) then
3171 term_cols = 80 ! Ensure reasonable minimum
3172 end if
3173
3174 ! Calculate visual length of prompt (excluding ANSI codes)
3175 prompt_visual_len = visual_length(prompt)
3176
3177 ! Safety check for prompt length
3178 if (prompt_visual_len < 0) then
3179 prompt_visual_len = 0
3180 end if
3181
3182 ! Calculate current cursor position in visual characters
3183 cursor_visual_pos = prompt_visual_len + input_state%cursor_pos
3184
3185 ! Calculate which line the cursor is currently on (0-indexed)
3186 ! Extra safety: ensure term_cols is positive before division
3187 if (term_cols > 0) then
3188 current_line = cursor_visual_pos / term_cols
3189 else
3190 current_line = 0
3191 end if
3192
3193 ! Safety check: limit current_line to reasonable value
3194 if (current_line < 0) current_line = 0
3195 if (current_line > 100) current_line = 0 ! Probably an error
3196
3197 ! Move cursor up to the first line (where prompt starts)
3198 ! IMPORTANT: Only move up if we're not already at top (avoid negative positioning)
3199 if (current_line > 0) then
3200 do i = 1, current_line
3201 write(output_unit, '(a)', advance='no') char(27) // '[A' ! Cursor up
3202 end do
3203 end if
3204
3205 ! Move to beginning of current line
3206 write(output_unit, '(a)', advance='no') ESC_MOVE_BOL
3207
3208 ! Clear from cursor to end of screen (clears all wrapped lines)
3209 write(output_unit, '(a)', advance='no') char(27) // '[J'
3210
3211 ! Redraw prompt and full buffer with syntax highlighting
3212 write(output_unit, '(a)', advance='no') prompt
3213 if (input_state%length > 0) then
3214 highlighted = highlight_command_line(input_state%buffer(:input_state%length))
3215 write(output_unit, '(a)', advance='no') highlighted
3216 end if
3217
3218 ! Display autosuggestion if cursor is at end
3219 ! IMPORTANT: Truncate suggestion to prevent wrapping beyond terminal width
3220 if (input_state%suggestion_length > 0 .and. input_state%cursor_pos == input_state%length) then
3221 ! Calculate available space on current line
3222 available_space = term_cols - mod(prompt_visual_len + input_state%length, term_cols)
3223
3224 ! Safety check: ensure available_space is positive
3225 if (available_space < 0) available_space = 0
3226
3227 ! Ensure we have enough space (need at least 2 chars: 1 for suggestion + 1 for cursor)
3228 if (available_space > 2) then
3229 ! Truncate suggestion if it would overflow the line
3230 suggestion_display_len = min(input_state%suggestion_length, available_space - 1)
3231
3232 ! Additional safety check
3233 if (suggestion_display_len < 0) suggestion_display_len = 0
3234 if (suggestion_display_len > MAX_LINE_LEN) suggestion_display_len = 0
3235
3236 if (suggestion_display_len > 0) then
3237 ! Gray color (ANSI code 90 or dim mode)
3238 write(output_unit, '(a)', advance='no') char(27) // '[2m' ! Dim mode
3239 write(output_unit, '(a)', advance='no') input_state%suggestion(:suggestion_display_len)
3240 write(output_unit, '(a)', advance='no') char(27) // '[0m' ! Reset
3241
3242 ! Move cursor back to where it should be (after suggestion)
3243 do i = 1, suggestion_display_len
3244 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3245 end do
3246 end if
3247 end if
3248 end if
3249
3250 ! Position cursor correctly (if not at end of input)
3251 if (input_state%cursor_pos < input_state%length) then
3252 ! Cursor not at end - move back to correct position
3253 do i = 1, input_state%length - input_state%cursor_pos
3254 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3255 end do
3256 end if
3257
3258 flush(output_unit)
3259 end subroutine
3260
3261 ! Partial redraw - only from cursor to end (reduces flashing)
3262 subroutine redraw_from_cursor(input_state)
3263 use syntax_highlight, only: highlight_command_line
3264 type(input_state_t), intent(in) :: input_state
3265 character(len=:), allocatable :: highlighted
3266 integer :: i, cursor_col
3267
3268 if (input_state%length == 0) return
3269
3270 ! Save current cursor column (we're already at the right position)
3271 cursor_col = input_state%cursor_pos
3272
3273 ! Move to just before cursor position (account for prompt already displayed)
3274 ! We need to move back to start of buffer to redraw with highlighting
3275 if (cursor_col > 0) then
3276 do i = 1, cursor_col
3277 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3278 end do
3279 end if
3280
3281 ! Clear from here to end of line
3282 write(output_unit, '(a)', advance='no') char(27) // '[K'
3283
3284 ! Redraw buffer with highlighting
3285 highlighted = highlight_command_line(input_state%buffer(:input_state%length))
3286 write(output_unit, '(a)', advance='no') highlighted
3287
3288 ! Move cursor back to correct position
3289 do i = input_state%length, cursor_col + 1, -1
3290 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3291 end do
3292
3293 flush(output_unit)
3294 end subroutine
3295
3296 ! Helper to convert integer to string
3297 function int_to_str(n) result(str)
3298 integer, intent(in) :: n
3299 character(len=20) :: str
3300 write(str, '(i0)') n
3301 end function
3302
3303 ! Advanced line editing functions for Phase 5
3304 subroutine handle_home(input_state)
3305 type(input_state_t), intent(inout) :: input_state
3306
3307 ! Move cursor to beginning of line
3308 if (input_state%cursor_pos > 0) then
3309 do while (input_state%cursor_pos > 0)
3310 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3311 input_state%cursor_pos = input_state%cursor_pos - 1
3312 end do
3313 flush(output_unit)
3314 end if
3315 end subroutine
3316
3317 subroutine handle_end(input_state)
3318 type(input_state_t), intent(inout) :: input_state
3319
3320 ! Move cursor to end of line
3321 do while (input_state%cursor_pos < input_state%length)
3322 write(output_unit, '(a)', advance='no') ESC_CURSOR_RIGHT
3323 input_state%cursor_pos = input_state%cursor_pos + 1
3324 end do
3325 flush(output_unit)
3326 end subroutine
3327
3328 subroutine handle_kill_to_end(input_state)
3329 type(input_state_t), intent(inout) :: input_state
3330
3331 ! Save text from cursor to end of line in kill buffer
3332 if (input_state%cursor_pos < input_state%length) then
3333 input_state%kill_buffer = input_state%buffer(input_state%cursor_pos+1:input_state%length)
3334 input_state%kill_length = input_state%length - input_state%cursor_pos
3335
3336 ! Clear from cursor to end of line
3337 input_state%length = input_state%cursor_pos
3338 input_state%dirty = .true.
3339 else
3340 ! Nothing to kill
3341 input_state%kill_length = 0
3342 end if
3343 end subroutine
3344
3345 subroutine handle_kill_line(input_state)
3346 type(input_state_t), intent(inout) :: input_state
3347
3348 ! Save entire line in kill buffer
3349 if (input_state%length > 0) then
3350 input_state%kill_buffer = input_state%buffer(:input_state%length)
3351 input_state%kill_length = input_state%length
3352
3353 ! Clear the line
3354 input_state%buffer = ''
3355 input_state%length = 0
3356 input_state%cursor_pos = 0
3357 input_state%dirty = .true.
3358 else
3359 input_state%kill_length = 0
3360 end if
3361 end subroutine
3362
3363 subroutine handle_kill_word(input_state)
3364 type(input_state_t), intent(inout) :: input_state
3365 integer :: word_start, i
3366
3367 if (input_state%cursor_pos == 0) then
3368 input_state%kill_length = 0
3369 return
3370 end if
3371
3372 ! Find start of current word (skip trailing spaces first)
3373 word_start = input_state%cursor_pos
3374
3375 ! Skip any trailing whitespace
3376 do while (word_start > 0 .and. input_state%buffer(word_start:word_start) == ' ')
3377 word_start = word_start - 1
3378 end do
3379
3380 ! Find beginning of word (non-space characters)
3381 do while (word_start > 0 .and. input_state%buffer(word_start:word_start) /= ' ')
3382 word_start = word_start - 1
3383 end do
3384
3385 ! word_start is now at space before word, or 0 if at beginning
3386 if (word_start < input_state%cursor_pos) then
3387 ! Save killed text
3388 input_state%kill_buffer = input_state%buffer(word_start+1:input_state%cursor_pos)
3389 input_state%kill_length = input_state%cursor_pos - word_start
3390
3391 ! Shift remaining text left
3392 do i = word_start + 1, input_state%length - input_state%cursor_pos + word_start
3393 if (input_state%cursor_pos + i - word_start <= input_state%length) then
3394 input_state%buffer(i:i) = input_state%buffer(input_state%cursor_pos + i - word_start: &
3395 input_state%cursor_pos + i - word_start)
3396 else
3397 input_state%buffer(i:i) = ' '
3398 end if
3399 end do
3400
3401 ! Update length and cursor position
3402 input_state%length = input_state%length - (input_state%cursor_pos - word_start)
3403 input_state%cursor_pos = word_start
3404 input_state%dirty = .true.
3405 else
3406 input_state%kill_length = 0
3407 end if
3408 end subroutine
3409
3410 subroutine handle_yank(input_state)
3411 type(input_state_t), intent(inout) :: input_state
3412 integer :: i, insert_len
3413
3414 if (input_state%kill_length == 0) return
3415
3416 insert_len = min(input_state%kill_length, MAX_LINE_LEN - input_state%length)
3417 if (insert_len == 0) return
3418
3419 ! Shift existing text right to make room
3420 do i = input_state%length, input_state%cursor_pos + 1, -1
3421 if (i + insert_len <= MAX_LINE_LEN) then
3422 input_state%buffer(i + insert_len:i + insert_len) = input_state%buffer(i:i)
3423 end if
3424 end do
3425
3426 ! Insert killed text at cursor position
3427 do i = 1, insert_len
3428 input_state%buffer(input_state%cursor_pos + i:input_state%cursor_pos + i) = &
3429 input_state%kill_buffer(i:i)
3430 end do
3431
3432 ! Update length and cursor position
3433 input_state%length = input_state%length + insert_len
3434 input_state%cursor_pos = input_state%cursor_pos + insert_len
3435 input_state%dirty = .true.
3436 end subroutine
3437
3438 subroutine handle_clear_screen(input_state, prompt)
3439 type(input_state_t), intent(inout) :: input_state
3440 character(len=*), intent(in) :: prompt
3441 character(len=:), allocatable :: highlighted
3442 integer :: i, term_rows, term_cols, available_space, suggestion_display_len
3443 logical :: success
3444
3445 ! Clear screen and move cursor to home position (0,0)
3446 write(output_unit, '(a)', advance='no') char(27) // '[2J' // char(27) // '[H'
3447
3448 ! Since we're now at home position, just redraw everything from scratch
3449 ! No need to calculate cursor movement - we know we're at top left
3450
3451 ! Draw prompt
3452 write(output_unit, '(a)', advance='no') prompt
3453
3454 ! Draw the current buffer with syntax highlighting
3455 if (input_state%length > 0) then
3456 highlighted = highlight_command_line(input_state%buffer(:input_state%length))
3457 write(output_unit, '(a)', advance='no') highlighted
3458 end if
3459
3460 ! Position cursor correctly
3461 if (input_state%cursor_pos < input_state%length) then
3462 ! Need to move cursor back from end of line
3463 do i = 1, input_state%length - input_state%cursor_pos
3464 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3465 end do
3466 end if
3467
3468 ! Handle autosuggestion if cursor is at end
3469 if (input_state%suggestion_length > 0 .and. input_state%cursor_pos == input_state%length) then
3470 ! Get terminal width for suggestion truncation
3471 success = get_terminal_size(term_rows, term_cols)
3472 if (.not. success .or. term_cols <= 0) then
3473 term_cols = 80
3474 end if
3475
3476 ! Calculate available space
3477 available_space = term_cols - mod(visual_length(prompt) + input_state%length, term_cols)
3478
3479 if (available_space > 2) then
3480 suggestion_display_len = min(input_state%suggestion_length, available_space - 1)
3481
3482 if (suggestion_display_len > 0) then
3483 ! Display suggestion in gray
3484 write(output_unit, '(a)', advance='no') char(27) // '[2m'
3485 write(output_unit, '(a)', advance='no') input_state%suggestion(:suggestion_display_len)
3486 write(output_unit, '(a)', advance='no') char(27) // '[0m'
3487
3488 ! Move cursor back to correct position after suggestion
3489 do i = 1, suggestion_display_len
3490 write(output_unit, '(a)', advance='no') ESC_CURSOR_LEFT
3491 end do
3492 end if
3493 end if
3494 end if
3495
3496 flush(output_unit)
3497 input_state%dirty = .false.
3498 end subroutine
3499
3500 ! Transpose characters (Ctrl+t) - swap char at cursor with previous char
3501 subroutine handle_transpose_chars(input_state)
3502 type(input_state_t), intent(inout) :: input_state
3503 character :: temp
3504
3505 ! Need at least 2 characters
3506 if (input_state%length < 2) return
3507
3508 ! If at end of line, transpose last two chars
3509 if (input_state%cursor_pos >= input_state%length) then
3510 if (input_state%length >= 2) then
3511 temp = input_state%buffer(input_state%length:input_state%length)
3512 input_state%buffer(input_state%length:input_state%length) = &
3513 input_state%buffer(input_state%length-1:input_state%length-1)
3514 input_state%buffer(input_state%length-1:input_state%length-1) = temp
3515 input_state%dirty = .true.
3516 end if
3517 ! If at beginning, do nothing
3518 else if (input_state%cursor_pos == 0) then
3519 return
3520 ! Normal case: swap char at cursor with previous char, move cursor forward
3521 else
3522 temp = input_state%buffer(input_state%cursor_pos+1:input_state%cursor_pos+1)
3523 input_state%buffer(input_state%cursor_pos+1:input_state%cursor_pos+1) = &
3524 input_state%buffer(input_state%cursor_pos:input_state%cursor_pos)
3525 input_state%buffer(input_state%cursor_pos:input_state%cursor_pos) = temp
3526 input_state%cursor_pos = input_state%cursor_pos + 1
3527 input_state%dirty = .true.
3528 end if
3529 end subroutine
3530
3531 ! Yank last argument from previous command (Alt+.)
3532 subroutine handle_yank_last_arg(input_state)
3533 type(input_state_t), intent(inout) :: input_state
3534 character(len=MAX_LINE_LEN) :: last_cmd, last_arg
3535 integer :: i, arg_start, arg_end
3536 logical :: in_arg
3537
3538 ! Get last command from history
3539 if (command_history%count == 0) return
3540
3541 last_cmd = command_history%lines(command_history%count)
3542
3543 ! Find last argument (last non-space word)
3544 arg_end = 0
3545 arg_start = 0
3546 in_arg = .false.
3547
3548 ! Scan backwards to find last argument
3549 do i = len_trim(last_cmd), 1, -1
3550 if (last_cmd(i:i) /= ' ' .and. last_cmd(i:i) /= char(9)) then
3551 if (.not. in_arg) then
3552 arg_end = i
3553 in_arg = .true.
3554 end if
3555 else if (in_arg) then
3556 arg_start = i + 1
3557 exit
3558 end if
3559 end do
3560
3561 ! If we found an arg but arg_start is still 0, it starts at position 1
3562 if (in_arg .and. arg_start == 0) arg_start = 1
3563
3564 if (arg_start > 0 .and. arg_end >= arg_start) then
3565 last_arg = last_cmd(arg_start:arg_end)
3566
3567 ! Insert the last argument at cursor position
3568 call insert_string_at_cursor(input_state, trim(last_arg))
3569 end if
3570 end subroutine
3571
3572 ! Delete word forward (Alt+d)
3573 subroutine handle_delete_word_forward(input_state)
3574 type(input_state_t), intent(inout) :: input_state
3575 integer :: word_end, i
3576
3577 if (input_state%cursor_pos >= input_state%length) return
3578
3579 word_end = input_state%cursor_pos + 1
3580
3581 ! Skip any leading whitespace
3582 do while (word_end <= input_state%length .and. &
3583 input_state%buffer(word_end:word_end) == ' ')
3584 word_end = word_end + 1
3585 end do
3586
3587 ! Find end of word (non-space characters)
3588 do while (word_end <= input_state%length .and. &
3589 input_state%buffer(word_end:word_end) /= ' ')
3590 word_end = word_end + 1
3591 end do
3592
3593 if (word_end > input_state%cursor_pos + 1) then
3594 ! Save deleted text to kill buffer
3595 input_state%kill_buffer = input_state%buffer(input_state%cursor_pos+1:word_end-1)
3596 input_state%kill_length = word_end - input_state%cursor_pos - 1
3597
3598 ! Shift remaining text left
3599 do i = input_state%cursor_pos + 1, input_state%length - (word_end - input_state%cursor_pos - 1)
3600 if (word_end + i - input_state%cursor_pos - 1 <= input_state%length) then
3601 input_state%buffer(i:i) = &
3602 input_state%buffer(word_end + i - input_state%cursor_pos - 1: &
3603 word_end + i - input_state%cursor_pos - 1)
3604 else
3605 input_state%buffer(i:i) = ' '
3606 end if
3607 end do
3608
3609 ! Update length
3610 input_state%length = input_state%length - (word_end - input_state%cursor_pos - 1)
3611 input_state%dirty = .true.
3612 end if
3613 end subroutine
3614
3615 ! Uppercase word (Alt+u) - convert from cursor to end of word to uppercase
3616 subroutine handle_uppercase_word(input_state)
3617 type(input_state_t), intent(inout) :: input_state
3618 integer :: pos, word_end
3619 character :: ch
3620
3621 if (input_state%cursor_pos >= input_state%length) return
3622
3623 pos = input_state%cursor_pos + 1
3624
3625 ! Skip any leading whitespace
3626 do while (pos <= input_state%length .and. &
3627 input_state%buffer(pos:pos) == ' ')
3628 pos = pos + 1
3629 end do
3630
3631 ! Uppercase characters until end of word
3632 do while (pos <= input_state%length .and. &
3633 input_state%buffer(pos:pos) /= ' ')
3634 ch = input_state%buffer(pos:pos)
3635 if (ch >= 'a' .and. ch <= 'z') then
3636 input_state%buffer(pos:pos) = char(ichar(ch) - 32)
3637 end if
3638 pos = pos + 1
3639 end do
3640
3641 ! Move cursor to end of word
3642 input_state%cursor_pos = pos - 1
3643 input_state%dirty = .true.
3644 end subroutine
3645
3646 ! Lowercase word (Alt+l) - convert from cursor to end of word to lowercase
3647 subroutine handle_lowercase_word(input_state)
3648 type(input_state_t), intent(inout) :: input_state
3649 integer :: pos
3650 character :: ch
3651
3652 if (input_state%cursor_pos >= input_state%length) return
3653
3654 pos = input_state%cursor_pos + 1
3655
3656 ! Skip any leading whitespace
3657 do while (pos <= input_state%length .and. &
3658 input_state%buffer(pos:pos) == ' ')
3659 pos = pos + 1
3660 end do
3661
3662 ! Lowercase characters until end of word
3663 do while (pos <= input_state%length .and. &
3664 input_state%buffer(pos:pos) /= ' ')
3665 ch = input_state%buffer(pos:pos)
3666 if (ch >= 'A' .and. ch <= 'Z') then
3667 input_state%buffer(pos:pos) = char(ichar(ch) + 32)
3668 end if
3669 pos = pos + 1
3670 end do
3671
3672 ! Move cursor to end of word
3673 input_state%cursor_pos = pos - 1
3674 input_state%dirty = .true.
3675 end subroutine
3676
3677 ! Capitalize word (Alt+c) - uppercase first char, lowercase rest
3678 subroutine handle_capitalize_word(input_state)
3679 type(input_state_t), intent(inout) :: input_state
3680 integer :: pos
3681 character :: ch
3682 logical :: first_char
3683
3684 if (input_state%cursor_pos >= input_state%length) return
3685
3686 pos = input_state%cursor_pos + 1
3687
3688 ! Skip any leading whitespace
3689 do while (pos <= input_state%length .and. &
3690 input_state%buffer(pos:pos) == ' ')
3691 pos = pos + 1
3692 end do
3693
3694 first_char = .true.
3695
3696 ! Capitalize first character, lowercase rest until end of word
3697 do while (pos <= input_state%length .and. &
3698 input_state%buffer(pos:pos) /= ' ')
3699 ch = input_state%buffer(pos:pos)
3700
3701 if (first_char) then
3702 ! Uppercase first character
3703 if (ch >= 'a' .and. ch <= 'z') then
3704 input_state%buffer(pos:pos) = char(ichar(ch) - 32)
3705 end if
3706 first_char = .false.
3707 else
3708 ! Lowercase remaining characters
3709 if (ch >= 'A' .and. ch <= 'Z') then
3710 input_state%buffer(pos:pos) = char(ichar(ch) + 32)
3711 end if
3712 end if
3713
3714 pos = pos + 1
3715 end do
3716
3717 ! Move cursor to end of word
3718 input_state%cursor_pos = pos - 1
3719 input_state%dirty = .true.
3720 end subroutine
3721
3722 ! Alt+Up: Replace line with "cd .." (Fish-style parent directory navigation)
3723 subroutine handle_alt_up(input_state)
3724 type(input_state_t), intent(inout) :: input_state
3725 character(len=5) :: cmd
3726
3727 cmd = 'cd ..'
3728
3729 ! Clear current buffer
3730 input_state%buffer = ''
3731
3732 ! Insert "cd .."
3733 input_state%buffer(1:5) = cmd
3734 input_state%length = 5
3735 input_state%cursor_pos = 5
3736
3737 ! Clear suggestion since we're replacing the line
3738 input_state%suggestion = ''
3739 input_state%suggestion_length = 0
3740
3741 input_state%dirty = .true.
3742 end subroutine
3743
3744 ! Alt+Left: Replace line with "prevd" (Fish-style previous directory)
3745 subroutine handle_alt_left(input_state)
3746 type(input_state_t), intent(inout) :: input_state
3747 character(len=5) :: cmd
3748
3749 cmd = 'prevd'
3750
3751 ! Clear current buffer
3752 input_state%buffer = ''
3753
3754 ! Insert "prevd"
3755 input_state%buffer(1:5) = cmd
3756 input_state%length = 5
3757 input_state%cursor_pos = 5
3758
3759 ! Clear suggestion since we're replacing the line
3760 input_state%suggestion = ''
3761 input_state%suggestion_length = 0
3762
3763 input_state%dirty = .true.
3764 end subroutine
3765
3766 ! Alt+Right: Replace line with "nextd" (Fish-style next directory)
3767 subroutine handle_alt_right(input_state)
3768 type(input_state_t), intent(inout) :: input_state
3769 character(len=5) :: cmd
3770
3771 cmd = 'nextd'
3772
3773 ! Clear current buffer
3774 input_state%buffer = ''
3775
3776 ! Insert "nextd"
3777 input_state%buffer(1:5) = cmd
3778 input_state%length = 5
3779 input_state%cursor_pos = 5
3780
3781 ! Clear suggestion since we're replacing the line
3782 input_state%suggestion = ''
3783 input_state%suggestion_length = 0
3784
3785 input_state%dirty = .true.
3786 end subroutine
3787
3788 ! Helper: Insert string at cursor position
3789 subroutine insert_string_at_cursor(input_state, str)
3790 type(input_state_t), intent(inout) :: input_state
3791 character(len=*), intent(in) :: str
3792 integer :: i, str_len, insert_len
3793
3794 str_len = len_trim(str)
3795 if (str_len == 0) return
3796
3797 insert_len = min(str_len, MAX_LINE_LEN - input_state%length)
3798 if (insert_len == 0) return
3799
3800 ! Shift existing text right to make room
3801 do i = input_state%length, input_state%cursor_pos + 1, -1
3802 if (i + insert_len <= MAX_LINE_LEN) then
3803 input_state%buffer(i + insert_len:i + insert_len) = input_state%buffer(i:i)
3804 end if
3805 end do
3806
3807 ! Insert string at cursor position
3808 do i = 1, insert_len
3809 input_state%buffer(input_state%cursor_pos + i:input_state%cursor_pos + i) = str(i:i)
3810 end do
3811
3812 ! Update length and cursor position
3813 input_state%length = input_state%length + insert_len
3814 input_state%cursor_pos = input_state%cursor_pos + insert_len
3815 input_state%dirty = .true.
3816 end subroutine
3817
3818 ! Cursor flash effect for visual feedback
3819 subroutine cursor_flash_effect()
3820 integer :: i, j
3821 integer, parameter :: FLASH_COUNT = 3
3822 integer, parameter :: DELAY_ITERATIONS = 50000
3823
3824 ! Flash cursor multiple times with visible delay
3825 do i = 1, FLASH_COUNT
3826 ! Hide cursor
3827 write(output_unit, '(a)', advance='no') ESC_HIDE_CURSOR
3828 flush(output_unit)
3829
3830 ! Small delay using busy-wait
3831 do j = 1, DELAY_ITERATIONS
3832 ! Busy wait
3833 end do
3834
3835 ! Show cursor
3836 write(output_unit, '(a)', advance='no') ESC_SHOW_CURSOR
3837 flush(output_unit)
3838
3839 ! Small delay using busy-wait
3840 do j = 1, DELAY_ITERATIONS
3841 ! Busy wait
3842 end do
3843 end do
3844 end subroutine
3845
3846 ! Reverse-i-search implementation
3847 subroutine handle_isearch(input_state, prompt, forward)
3848 type(input_state_t), intent(inout) :: input_state
3849 character(len=*), intent(in) :: prompt
3850 logical, intent(in) :: forward
3851
3852 ! Save current buffer if entering search for first time
3853 if (.not. input_state%in_search) then
3854 input_state%original_buffer = input_state%buffer(:input_state%length)
3855 input_state%in_search = .true.
3856 input_state%search_forward = forward
3857 input_state%search_string = ''
3858 input_state%search_length = 0
3859 input_state%search_match_index = 0
3860 else
3861 ! Ctrl+R/Ctrl+S pressed again - find next match
3862 ! Allow switching direction mid-search
3863 input_state%search_forward = forward
3864 call search_next_match(input_state)
3865 end if
3866
3867 ! Display search prompt
3868 call update_search_display(input_state, prompt)
3869 end subroutine
3870
3871 subroutine search_next_match(input_state)
3872 type(input_state_t), intent(inout) :: input_state
3873 integer :: i
3874 character(len=MAX_LINE_LEN) :: search_str
3875
3876 if (input_state%search_length == 0) return
3877
3878 search_str = input_state%search_string(:input_state%search_length)
3879
3880 if (input_state%search_forward) then
3881 ! Forward search - search from current match towards newer history
3882 do i = input_state%search_match_index + 1, command_history%count
3883 if (index(command_history%lines(i), trim(search_str)) > 0) then
3884 input_state%search_match_index = i
3885 input_state%buffer = command_history%lines(i)
3886 input_state%length = len_trim(command_history%lines(i))
3887 input_state%cursor_pos = input_state%length
3888 return
3889 end if
3890 end do
3891
3892 ! Wrap around to beginning if no match found
3893 if (input_state%search_match_index > 0) then
3894 do i = 1, input_state%search_match_index - 1
3895 if (index(command_history%lines(i), trim(search_str)) > 0) then
3896 input_state%search_match_index = i
3897 input_state%buffer = command_history%lines(i)
3898 input_state%length = len_trim(command_history%lines(i))
3899 input_state%cursor_pos = input_state%length
3900 return
3901 end if
3902 end do
3903 end if
3904 else
3905 ! Reverse search - search from current match towards older history
3906 do i = input_state%search_match_index - 1, 1, -1
3907 if (index(command_history%lines(i), trim(search_str)) > 0) then
3908 input_state%search_match_index = i
3909 input_state%buffer = command_history%lines(i)
3910 input_state%length = len_trim(command_history%lines(i))
3911 input_state%cursor_pos = input_state%length
3912 return
3913 end if
3914 end do
3915
3916 ! Wrap around to end if no match found
3917 if (input_state%search_match_index > 0) then
3918 do i = command_history%count, input_state%search_match_index + 1, -1
3919 if (index(command_history%lines(i), trim(search_str)) > 0) then
3920 input_state%search_match_index = i
3921 input_state%buffer = command_history%lines(i)
3922 input_state%length = len_trim(command_history%lines(i))
3923 input_state%cursor_pos = input_state%length
3924 return
3925 end if
3926 end do
3927 end if
3928 end if
3929 end subroutine
3930
3931 subroutine search_add_char(input_state, ch, prompt)
3932 type(input_state_t), intent(inout) :: input_state
3933 character, intent(in) :: ch
3934 character(len=*), intent(in) :: prompt
3935 integer :: i
3936 character(len=MAX_LINE_LEN) :: search_str
3937
3938 ! Add character to search string
3939 if (input_state%search_length < MAX_LINE_LEN) then
3940 input_state%search_length = input_state%search_length + 1
3941 input_state%search_string(input_state%search_length:input_state%search_length) = ch
3942
3943 ! Search through history in the appropriate direction
3944 search_str = input_state%search_string(:input_state%search_length)
3945
3946 if (input_state%search_forward) then
3947 ! Forward search - from beginning to end
3948 do i = 1, command_history%count
3949 if (index(command_history%lines(i), trim(search_str)) > 0) then
3950 input_state%search_match_index = i
3951 input_state%buffer = command_history%lines(i)
3952 input_state%length = len_trim(command_history%lines(i))
3953 input_state%cursor_pos = input_state%length
3954 exit
3955 end if
3956 end do
3957 else
3958 ! Reverse search - from end to beginning
3959 do i = command_history%count, 1, -1
3960 if (index(command_history%lines(i), trim(search_str)) > 0) then
3961 input_state%search_match_index = i
3962 input_state%buffer = command_history%lines(i)
3963 input_state%length = len_trim(command_history%lines(i))
3964 input_state%cursor_pos = input_state%length
3965 exit
3966 end if
3967 end do
3968 end if
3969
3970 call update_search_display(input_state, prompt)
3971 end if
3972 end subroutine
3973
3974 subroutine search_backspace(input_state, prompt)
3975 type(input_state_t), intent(inout) :: input_state
3976 character(len=*), intent(in) :: prompt
3977 integer :: i
3978 character(len=MAX_LINE_LEN) :: search_str
3979
3980 if (input_state%search_length > 0) then
3981 input_state%search_length = input_state%search_length - 1
3982
3983 if (input_state%search_length > 0) then
3984 ! Search again with shorter string
3985 search_str = input_state%search_string(:input_state%search_length)
3986
3987 if (input_state%search_forward) then
3988 ! Forward search
3989 do i = 1, command_history%count
3990 if (index(command_history%lines(i), trim(search_str)) > 0) then
3991 input_state%search_match_index = i
3992 input_state%buffer = command_history%lines(i)
3993 input_state%length = len_trim(command_history%lines(i))
3994 input_state%cursor_pos = input_state%length
3995 exit
3996 end if
3997 end do
3998 else
3999 ! Reverse search
4000 do i = command_history%count, 1, -1
4001 if (index(command_history%lines(i), trim(search_str)) > 0) then
4002 input_state%search_match_index = i
4003 input_state%buffer = command_history%lines(i)
4004 input_state%length = len_trim(command_history%lines(i))
4005 input_state%cursor_pos = input_state%length
4006 exit
4007 end if
4008 end do
4009 end if
4010 else
4011 ! Empty search - clear buffer
4012 input_state%buffer = ''
4013 input_state%length = 0
4014 input_state%cursor_pos = 0
4015 input_state%search_match_index = 0
4016 end if
4017
4018 call update_search_display(input_state, prompt)
4019 end if
4020 end subroutine
4021
4022 subroutine cancel_search(input_state)
4023 type(input_state_t), intent(inout) :: input_state
4024
4025 ! Restore original buffer
4026 input_state%buffer = input_state%original_buffer
4027 input_state%length = len_trim(input_state%original_buffer)
4028 input_state%cursor_pos = input_state%length
4029 input_state%in_search = .false.
4030 input_state%search_string = ''
4031 input_state%search_length = 0
4032 input_state%search_match_index = 0
4033 input_state%dirty = .true.
4034 end subroutine
4035
4036 subroutine accept_search(input_state, prompt)
4037 type(input_state_t), intent(inout) :: input_state
4038 character(len=*), intent(in) :: prompt
4039
4040 ! Keep the current buffer (matched command)
4041 input_state%in_search = .false.
4042 input_state%search_string = ''
4043 input_state%search_length = 0
4044 input_state%search_match_index = 0
4045
4046 ! Clear the search prompt and show normal prompt with result using proper redraw
4047 write(output_unit, '(a)', advance='no') char(13) // ESC_CLEAR_LINE
4048 flush(output_unit)
4049
4050 ! Use redraw_line to properly display with syntax highlighting and cursor positioning
4051 call redraw_line(prompt, input_state)
4052 end subroutine
4053
4054 subroutine update_search_display(input_state, prompt)
4055 type(input_state_t), intent(in) :: input_state
4056 character(len=*), intent(in) :: prompt
4057 character(len=512) :: search_prompt
4058 character(len=32) :: direction_str
4059
4060 ! Determine search direction string
4061 if (input_state%search_forward) then
4062 direction_str = '(i-search)'
4063 else
4064 direction_str = '(reverse-i-search)'
4065 end if
4066
4067 ! Build search prompt
4068 if (input_state%search_length > 0) then
4069 write(search_prompt, '(a,a,a,a)') trim(direction_str), '`', &
4070 input_state%search_string(:input_state%search_length), "': "
4071 else
4072 write(search_prompt, '(a,a)') trim(direction_str), '`'': '
4073 end if
4074
4075 ! Clear line and redraw
4076 write(output_unit, '(a)', advance='no') char(13) // ESC_CLEAR_LINE
4077 write(output_unit, '(a)', advance='no') trim(search_prompt)
4078 if (input_state%length > 0) then
4079 write(output_unit, '(a)', advance='no') input_state%buffer(:input_state%length)
4080 end if
4081 flush(output_unit)
4082 end subroutine
4083
4084 ! ============================================================================
4085 ! Advanced Vi Mode Features
4086 ! ============================================================================
4087
4088 ! Vi-style yank (copy)
4089 subroutine handle_vi_yank(input_state)
4090 type(input_state_t), intent(inout) :: input_state
4091
4092 ! Simplified: yank entire line (yy behavior)
4093 if (input_state%length > 0) then
4094 input_state%vi_yank_buffer = input_state%buffer(:input_state%length)
4095 input_state%vi_yank_length = input_state%length
4096 else
4097 input_state%vi_yank_buffer = ''
4098 input_state%vi_yank_length = 0
4099 end if
4100 end subroutine
4101
4102 ! Vi-style put (paste)
4103 subroutine handle_vi_put(input_state, before_cursor)
4104 type(input_state_t), intent(inout) :: input_state
4105 logical, intent(in) :: before_cursor
4106 integer :: i, insert_len, insert_pos
4107
4108 if (input_state%vi_yank_length == 0) return
4109
4110 insert_len = min(input_state%vi_yank_length, MAX_LINE_LEN - input_state%length)
4111 if (insert_len == 0) return
4112
4113 ! Determine insertion position
4114 if (before_cursor) then
4115 insert_pos = input_state%cursor_pos
4116 else
4117 ! After cursor
4118 insert_pos = min(input_state%cursor_pos + 1, input_state%length)
4119 end if
4120
4121 ! Shift existing text right to make room
4122 do i = input_state%length, insert_pos + 1, -1
4123 if (i + insert_len <= MAX_LINE_LEN) then
4124 input_state%buffer(i + insert_len:i + insert_len) = input_state%buffer(i:i)
4125 end if
4126 end do
4127
4128 ! Insert yanked text at insertion position
4129 do i = 1, insert_len
4130 input_state%buffer(insert_pos + i:insert_pos + i) = input_state%vi_yank_buffer(i:i)
4131 end do
4132
4133 ! Update length and cursor position
4134 input_state%length = input_state%length + insert_len
4135 input_state%cursor_pos = insert_pos + insert_len - 1
4136 input_state%dirty = .true.
4137 end subroutine
4138
4139 ! Set a vi mark
4140 subroutine handle_vi_mark_set(input_state, mark_char)
4141 type(input_state_t), intent(inout) :: input_state
4142 character, intent(in) :: mark_char
4143 integer :: mark_index
4144
4145 ! Convert character to mark index (a-z = 1-26)
4146 if (mark_char >= 'a' .and. mark_char <= 'z') then
4147 mark_index = iachar(mark_char) - iachar('a') + 1
4148 input_state%vi_marks(mark_index) = input_state%cursor_pos
4149 end if
4150
4151 ! Clear command buffer
4152 input_state%vi_command_buffer = ''
4153 input_state%vi_command_count = 0
4154 end subroutine
4155
4156 ! Jump to a vi mark
4157 subroutine handle_vi_mark_jump(input_state, mark_char)
4158 type(input_state_t), intent(inout) :: input_state
4159 character, intent(in) :: mark_char
4160 integer :: mark_index, mark_pos
4161
4162 ! Convert character to mark index (a-z = 1-26)
4163 if (mark_char >= 'a' .and. mark_char <= 'z') then
4164 mark_index = iachar(mark_char) - iachar('a') + 1
4165 mark_pos = input_state%vi_marks(mark_index)
4166
4167 ! Jump to mark if it's set (non-zero) and valid
4168 if (mark_pos > 0 .and. mark_pos <= input_state%length) then
4169 input_state%cursor_pos = mark_pos
4170 input_state%dirty = .true.
4171 end if
4172 end if
4173
4174 ! Clear command buffer
4175 input_state%vi_command_buffer = ''
4176 input_state%vi_command_count = 0
4177 end subroutine
4178
4179 ! Start vi-style search (/ or ?)
4180 subroutine handle_vi_search_start(input_state, forward)
4181 type(input_state_t), intent(inout) :: input_state
4182 logical, intent(in) :: forward
4183
4184 ! Enter vi search mode
4185 input_state%vi_in_vi_search = .true.
4186 input_state%vi_search_forward = forward
4187 input_state%vi_search_pattern = ''
4188 input_state%vi_search_length = 0
4189
4190 ! Visual feedback: show search prompt
4191 write(output_unit, '()') ! New line
4192 if (forward) then
4193 write(output_unit, '(a)', advance='no') '/'
4194 else
4195 write(output_unit, '(a)', advance='no') '?'
4196 end if
4197 flush(output_unit)
4198 end subroutine
4199
4200 ! Find next/previous search match in vi mode
4201 subroutine handle_vi_search_next(input_state, forward)
4202 type(input_state_t), intent(inout) :: input_state
4203 logical, intent(in) :: forward
4204 integer :: i, match_pos
4205 logical :: found
4206
4207 if (input_state%vi_search_length == 0) return
4208
4209 found = .false.
4210
4211 ! Determine search direction based on original direction and forward flag
4212 if (input_state%vi_search_forward .eqv. forward) then
4213 ! Search in same direction as original
4214 if (input_state%vi_search_forward) then
4215 ! Search forward from current position
4216 match_pos = index(input_state%buffer(input_state%cursor_pos+2:input_state%length), &
4217 input_state%vi_search_pattern(:input_state%vi_search_length))
4218 if (match_pos > 0) then
4219 input_state%cursor_pos = input_state%cursor_pos + 1 + match_pos
4220 found = .true.
4221 end if
4222 else
4223 ! Search backward from current position
4224 ! Simplified: search from beginning to current position
4225 do i = input_state%cursor_pos - 1, 1, -1
4226 match_pos = index(input_state%buffer(i:input_state%cursor_pos-1), &
4227 input_state%vi_search_pattern(:input_state%vi_search_length))
4228 if (match_pos > 0) then
4229 input_state%cursor_pos = i + match_pos - 1
4230 found = .true.
4231 exit
4232 end if
4233 end do
4234 end if
4235 else
4236 ! Search in opposite direction
4237 if (input_state%vi_search_forward) then
4238 ! Original was forward, now search backward
4239 do i = input_state%cursor_pos - 1, 1, -1
4240 match_pos = index(input_state%buffer(i:input_state%cursor_pos-1), &
4241 input_state%vi_search_pattern(:input_state%vi_search_length))
4242 if (match_pos > 0) then
4243 input_state%cursor_pos = i + match_pos - 1
4244 found = .true.
4245 exit
4246 end if
4247 end do
4248 else
4249 ! Original was backward, now search forward
4250 match_pos = index(input_state%buffer(input_state%cursor_pos+2:input_state%length), &
4251 input_state%vi_search_pattern(:input_state%vi_search_length))
4252 if (match_pos > 0) then
4253 input_state%cursor_pos = input_state%cursor_pos + 1 + match_pos
4254 found = .true.
4255 end if
4256 end if
4257 end if
4258
4259 if (found) then
4260 input_state%dirty = .true.
4261 end if
4262 end subroutine
4263
4264 ! ============================================================================
4265 ! Abbreviation Expansion (Fish-style)
4266 ! ============================================================================
4267
4268 ! Try to expand an abbreviation at cursor position (called when space is typed)
4269 subroutine try_expand_abbreviation_at_cursor(input_state)
4270 type(input_state_t), intent(inout) :: input_state
4271 character(len=MAX_LINE_LEN) :: word_before_cursor
4272 character(len=:), allocatable :: expanded_form
4273 integer :: word_start, word_end, i, expanded_len
4274
4275 ! Extract word before cursor
4276 word_end = input_state%cursor_pos
4277 word_start = word_end
4278
4279 ! Find start of word (go backwards until space or beginning)
4280 do while (word_start > 0)
4281 if (input_state%buffer(word_start:word_start) == ' ') then
4282 word_start = word_start + 1
4283 exit
4284 end if
4285 word_start = word_start - 1
4286 end do
4287
4288 if (word_start == 0) word_start = 1
4289
4290 ! Extract the word
4291 if (word_end > word_start) then
4292 word_before_cursor = input_state%buffer(word_start:word_end)
4293 else
4294 return ! No word to expand
4295 end if
4296
4297 ! Check if it's an abbreviation
4298 expanded_form = try_expand_abbreviation(trim(word_before_cursor))
4299 if (len(expanded_form) == 0) return ! Not an abbreviation
4300
4301 ! Replace the word with expanded form
4302 expanded_len = len(expanded_form)
4303
4304 ! First, remove the original word by shifting left
4305 do i = word_end + 1, input_state%length
4306 input_state%buffer(word_start + i - word_end - 1:word_start + i - word_end - 1) = &
4307 input_state%buffer(i:i)
4308 end do
4309 input_state%length = input_state%length - (word_end - word_start + 1)
4310 input_state%cursor_pos = word_start - 1
4311
4312 ! Then insert the expanded form
4313 ! Make room for expanded text
4314 do i = input_state%length, input_state%cursor_pos + 1, -1
4315 if (i + expanded_len <= MAX_LINE_LEN) then
4316 input_state%buffer(i + expanded_len:i + expanded_len) = input_state%buffer(i:i)
4317 end if
4318 end do
4319
4320 ! Insert expanded text
4321 do i = 1, expanded_len
4322 if (input_state%cursor_pos + i <= MAX_LINE_LEN) then
4323 input_state%buffer(input_state%cursor_pos + i:input_state%cursor_pos + i) = &
4324 expanded_form(i:i)
4325 end if
4326 end do
4327
4328 input_state%length = input_state%length + expanded_len
4329 input_state%cursor_pos = input_state%cursor_pos + expanded_len
4330 input_state%dirty = .true.
4331 end subroutine try_expand_abbreviation_at_cursor
4332
4333 ! ============================================================================
4334 ! Autosuggestion Support (Fish-style)
4335 ! ============================================================================
4336
4337 ! Update autosuggestion based on current input
4338 subroutine update_autosuggestion(input_state)
4339 type(input_state_t), intent(inout) :: input_state
4340 integer :: i, newline_pos, j
4341 character(len=MAX_LINE_LEN) :: current_input
4342 character(len=MAX_LINE_LEN) :: suggestion_candidate
4343
4344
4345 ! Clear suggestion if buffer is empty or in special modes
4346 if (input_state%length == 0 .or. input_state%in_search .or. input_state%in_history) then
4347 input_state%suggestion = ''
4348 input_state%suggestion_length = 0
4349 return
4350 end if
4351
4352 ! Get current input
4353 current_input = input_state%buffer(:input_state%length)
4354
4355 ! Search history backwards for matching command
4356 do i = command_history%count, 1, -1
4357 ! Check if history entry starts with current input
4358 if (len_trim(command_history%lines(i)) > input_state%length) then
4359 if (command_history%lines(i)(:input_state%length) == current_input(:input_state%length)) then
4360 ! Found a match! Store the rest as suggestion
4361 ! CRITICAL FIX: Stop at first newline to avoid multi-line suggestions (heredocs, etc.)
4362 suggestion_candidate = command_history%lines(i)(input_state%length+1:)
4363
4364 ! Find first newline character
4365 newline_pos = 0
4366 do j = 1, len_trim(suggestion_candidate)
4367 if (suggestion_candidate(j:j) == char(10) .or. suggestion_candidate(j:j) == char(13)) then
4368 newline_pos = j - 1
4369 exit
4370 end if
4371 end do
4372
4373 if (newline_pos >= 0 .and. newline_pos < len_trim(suggestion_candidate)) then
4374 ! Found newline - truncate before it (or clear if newline is first char)
4375 if (newline_pos > 0) then
4376 input_state%suggestion = suggestion_candidate(:newline_pos)
4377 input_state%suggestion_length = newline_pos
4378 else
4379 ! Newline is first character - no suggestion
4380 input_state%suggestion = ''
4381 input_state%suggestion_length = 0
4382 end if
4383 else
4384 ! No newline found, use full suggestion
4385 input_state%suggestion = suggestion_candidate
4386 input_state%suggestion_length = len_trim(suggestion_candidate)
4387 end if
4388 return
4389 end if
4390 end if
4391 end do
4392
4393 ! No match found
4394 input_state%suggestion = ''
4395 input_state%suggestion_length = 0
4396 end subroutine
4397
4398 ! Accept the current autosuggestion
4399 subroutine accept_autosuggestion(input_state)
4400 type(input_state_t), intent(inout) :: input_state
4401 integer :: i
4402
4403 if (input_state%suggestion_length == 0) return
4404
4405 ! Append suggestion to buffer
4406 do i = 1, input_state%suggestion_length
4407 if (input_state%length + i <= MAX_LINE_LEN) then
4408 input_state%buffer(input_state%length + i:input_state%length + i) = &
4409 input_state%suggestion(i:i)
4410 end if
4411 end do
4412
4413 input_state%length = input_state%length + input_state%suggestion_length
4414 input_state%cursor_pos = input_state%length
4415 input_state%suggestion = ''
4416 input_state%suggestion_length = 0
4417 input_state%dirty = .true.
4418 end subroutine
4419
4420 ! Accept one word from the autosuggestion (for partial acceptance)
4421 subroutine accept_autosuggestion_word(input_state)
4422 type(input_state_t), intent(inout) :: input_state
4423 integer :: i, word_end
4424
4425 if (input_state%suggestion_length == 0) return
4426
4427 ! Find the end of the first word in the suggestion
4428 word_end = 0
4429 do i = 1, input_state%suggestion_length
4430 if (input_state%suggestion(i:i) == ' ' .or. input_state%suggestion(i:i) == '/') then
4431 word_end = i
4432 exit
4433 end if
4434 end do
4435
4436 if (word_end == 0) then
4437 ! No space found, accept entire suggestion
4438 call accept_autosuggestion(input_state)
4439 return
4440 end if
4441
4442 ! Append first word to buffer
4443 do i = 1, word_end
4444 if (input_state%length + i <= MAX_LINE_LEN) then
4445 input_state%buffer(input_state%length + i:input_state%length + i) = &
4446 input_state%suggestion(i:i)
4447 end if
4448 end do
4449
4450 input_state%length = input_state%length + word_end
4451 input_state%cursor_pos = input_state%length
4452 input_state%dirty = .true.
4453
4454 ! Update suggestion to remove accepted part
4455 call update_autosuggestion(input_state)
4456 end subroutine
4457
4458 end module readline