Fortran · 177662 bytes Raw Blame History
1 ! ==============================================================================
2 ! Module: builtins (Extended with job control)
3 ! ==============================================================================
4 module builtins
5 use shell_types
6 use system_interface
7 use io_helpers
8 use job_control
9 use test_builtin
10 use readline
11 use shell_config
12 use aliases
13 use shell_options
14 use command_builtin, only: find_command_in_path, builtin_which, builtin_command, find_executable_in_path, &
15 cmd_builtin_type => builtin_type
16 use directory_builtin, only: builtin_pushd, builtin_popd, builtin_dirs
17 use performance
18 use parser
19 use coprocess
20 use substitution
21 use signal_handling
22 use getopts_builtin
23 use printf_builtin
24 use read_builtin
25 use iso_fortran_env, only: output_unit, error_unit
26 use completion
27 use iso_c_binding
28 use builtin_interface
29 #ifdef USE_MEMORY_POOL
30 use string_pool
31 use memory_dashboard
32 #endif
33 implicit none
34
35 ! Module constant for dashboard tracking
36 #ifdef USE_MEMORY_POOL
37 integer, parameter :: MOD_BUILTINS = 7 ! Module ID for dashboard
38 #endif
39
40 ! C interface for system() call
41 interface
42 function c_system(command) bind(C, name="system")
43 use iso_c_binding
44 character(kind=c_char), intent(in) :: command(*)
45 integer(c_int) :: c_system
46 end function c_system
47 end interface
48
49 contains
50
51 ! Initialize builtin interface by registering function pointers
52 subroutine init_builtins()
53 is_builtin_ptr => is_builtin_impl
54 execute_builtin_ptr => execute_builtin_impl
55 end subroutine init_builtins
56
57 function is_builtin_impl(cmd_name) result(is_built)
58 character(len=*), intent(in) :: cmd_name
59 logical :: is_built
60
61 is_built = (trim(cmd_name) == 'exit' .or. &
62 trim(cmd_name) == 'cd' .or. &
63 trim(cmd_name) == 'pwd' .or. &
64 trim(cmd_name) == 'pushd' .or. &
65 trim(cmd_name) == 'popd' .or. &
66 trim(cmd_name) == 'dirs' .or. &
67 trim(cmd_name) == 'prevd' .or. &
68 trim(cmd_name) == 'nextd' .or. &
69 trim(cmd_name) == 'dirh' .or. &
70 trim(cmd_name) == 'export' .or. &
71 trim(cmd_name) == 'echo' .or. &
72 trim(cmd_name) == 'jobs' .or. &
73 trim(cmd_name) == 'fg' .or. &
74 trim(cmd_name) == 'bg' .or. &
75 trim(cmd_name) == 'disown' .or. &
76 trim(cmd_name) == 'source' .or. &
77 trim(cmd_name) == '.' .or. &
78 trim(cmd_name) == ':' .or. &
79 trim(cmd_name) == 'history' .or. &
80 trim(cmd_name) == 'kill' .or. &
81 trim(cmd_name) == 'wait' .or. &
82 trim(cmd_name) == 'trap' .or. &
83 trim(cmd_name) == 'config' .or. &
84 trim(cmd_name) == 'alias' .or. &
85 trim(cmd_name) == 'unalias' .or. &
86 trim(cmd_name) == 'abbr' .or. &
87 trim(cmd_name) == 'help' .or. &
88 trim(cmd_name) == 'perf' .or. &
89 trim(cmd_name) == 'memory' .or. &
90 trim(cmd_name) == 'rawtest' .or. &
91 trim(cmd_name) == 'defun' .or. &
92 trim(cmd_name) == 'set' .or. &
93 trim(cmd_name) == 'shopt' .or. &
94 trim(cmd_name) == 'type' .or. &
95 trim(cmd_name) == 'which' .or. &
96 trim(cmd_name) == 'command' .or. &
97 trim(cmd_name) == 'unset' .or. &
98 trim(cmd_name) == 'readonly' .or. &
99 trim(cmd_name) == 'declare' .or. &
100 trim(cmd_name) == 'printenv' .or. &
101 trim(cmd_name) == 'local' .or. &
102 trim(cmd_name) == 'shift' .or. &
103 trim(cmd_name) == 'break' .or. &
104 trim(cmd_name) == 'continue' .or. &
105 trim(cmd_name) == 'return' .or. &
106 trim(cmd_name) == 'exec' .or. &
107 trim(cmd_name) == 'eval' .or. &
108 trim(cmd_name) == 'hash' .or. &
109 trim(cmd_name) == 'umask' .or. &
110 trim(cmd_name) == 'ulimit' .or. &
111 trim(cmd_name) == 'times' .or. &
112 trim(cmd_name) == 'let' .or. &
113 trim(cmd_name) == 'getopts' .or. &
114 trim(cmd_name) == 'printf' .or. &
115 trim(cmd_name) == 'read' .or. &
116 trim(cmd_name) == 'fc' .or. &
117 trim(cmd_name) == 'coproc' .or. &
118 trim(cmd_name) == 'complete' .or. &
119 trim(cmd_name) == 'compgen' .or. &
120 is_test_command(cmd_name))
121 end function
122
123 subroutine execute_builtin_impl(cmd, shell)
124 type(command_t), intent(in) :: cmd
125 type(shell_state_t), intent(inout) :: shell
126
127 select case(trim(cmd%tokens(1)))
128 case('exit')
129 call builtin_exit(cmd, shell)
130 case('cd')
131 call builtin_cd(cmd, shell)
132 case('pwd')
133 call builtin_pwd(cmd, shell)
134 case('pushd')
135 call builtin_pushd(cmd, shell)
136 case('popd')
137 call builtin_popd(cmd, shell)
138 case('dirs')
139 call builtin_dirs(cmd, shell)
140 case('prevd')
141 call builtin_prevd(cmd, shell)
142 case('nextd')
143 call builtin_nextd(cmd, shell)
144 case('dirh')
145 call builtin_dirh(cmd, shell)
146 case('export')
147 call builtin_export(cmd, shell)
148 case('echo')
149 call builtin_echo(cmd, shell)
150 case('jobs')
151 call builtin_jobs(cmd, shell)
152 case('fg')
153 call builtin_fg(cmd, shell)
154 case('bg')
155 call builtin_bg(cmd, shell)
156 case('disown')
157 call builtin_disown(cmd, shell)
158 case('source', '.')
159 call builtin_source(cmd, shell)
160 case(':')
161 ! Colon builtin - null command, always returns success
162 shell%last_exit_status = 0
163 case('history')
164 call builtin_history(cmd, shell)
165 case('kill')
166 call builtin_kill(cmd, shell)
167 case('wait')
168 call builtin_wait(cmd, shell)
169 case('trap')
170 call builtin_trap(cmd, shell)
171 case('config')
172 call builtin_config(cmd, shell)
173 case('command')
174 call builtin_command(cmd, shell)
175 case('alias')
176 call builtin_alias(cmd, shell)
177 case('unalias')
178 call builtin_unalias(cmd, shell)
179 case('abbr')
180 call builtin_abbr(cmd, shell)
181 case('help')
182 call builtin_help(cmd, shell)
183 case('perf')
184 call builtin_perf(cmd, shell)
185 case('memory')
186 call builtin_memory(cmd, shell)
187 case('rawtest')
188 call builtin_rawtest(cmd, shell)
189 case('defun')
190 call builtin_defun(cmd, shell)
191 case('test', '[', '[[')
192 call execute_test_command(cmd, shell)
193 case('set')
194 call builtin_set(cmd, shell)
195 case('shopt')
196 call builtin_shopt(cmd, shell)
197 case('type')
198 call cmd_builtin_type(cmd, shell)
199 case('which')
200 call builtin_which(cmd, shell)
201 case('unset')
202 call builtin_unset(cmd, shell)
203 case('readonly')
204 call builtin_readonly(cmd, shell)
205 case('declare')
206 call builtin_declare(cmd, shell)
207 case('printenv')
208 call builtin_printenv(cmd, shell)
209 case('local')
210 call builtin_local(cmd, shell)
211 case('shift')
212 call builtin_shift(cmd, shell)
213 case('break')
214 call builtin_break(cmd, shell)
215 case('continue')
216 call builtin_continue(cmd, shell)
217 case('return')
218 call builtin_return(cmd, shell)
219 case('exec')
220 call builtin_exec(cmd, shell)
221 case('eval')
222 call builtin_eval(cmd, shell)
223 case('hash')
224 call builtin_hash(cmd, shell)
225 case('umask')
226 call builtin_umask(cmd, shell)
227 case('ulimit')
228 call builtin_ulimit(cmd, shell)
229 case('times')
230 call builtin_times(cmd, shell)
231 case('let')
232 call builtin_let(cmd, shell)
233 case('getopts')
234 call builtin_getopts(cmd, shell)
235 case('printf')
236 call builtin_printf(cmd, shell)
237 case('read')
238 call builtin_read(cmd, shell)
239 case('fc')
240 call builtin_fc(cmd, shell)
241 case('coproc')
242 call builtin_coproc(cmd, shell)
243 case('complete')
244 call builtin_complete(cmd, shell)
245 case('compgen')
246 call builtin_compgen(cmd, shell)
247 case default
248 ! Should not reach here if is_builtin works correctly
249 shell%last_exit_status = 1
250 end select
251 end subroutine
252
253 subroutine builtin_exit(cmd, shell)
254 use signal_handling, only: get_trap_command, is_trap_inherited
255 use grammar_parser, only: parse_command_line
256 use ast_executor, only: execute_ast_node
257 use command_tree, only: command_node_t, destroy_command_node
258 type(command_t), intent(in) :: cmd
259 type(shell_state_t), intent(inout) :: shell
260 integer :: exit_code, iostat
261 character(len=4096) :: trap_command
262 type(command_node_t), pointer :: trap_ast
263 integer :: saved_exit_status, trap_exit_code
264
265 ! Execute EXIT trap before exiting (TRAP_EXIT = 0)
266 if (.not. shell%executing_trap .and. .not. shell%exit_trap_executed .and. &
267 .not. is_trap_inherited(shell, 0)) then
268 trap_command = get_trap_command(shell, 0)
269
270 if (len_trim(trap_command) > 0) then
271 shell%exit_trap_executed = .true.
272 saved_exit_status = shell%last_exit_status
273 shell%executing_trap = .true.
274
275 trap_ast => parse_command_line(trim(trap_command))
276 if (associated(trap_ast)) then
277 trap_exit_code = execute_ast_node(trap_ast, shell)
278 call destroy_command_node(trap_ast)
279 end if
280
281 shell%executing_trap = .false.
282 shell%last_exit_status = saved_exit_status
283 end if
284 end if
285
286 shell%running = .false.
287 if (cmd%num_tokens > 1) then
288 ! Parse the exit code from the argument
289 read(cmd%tokens(2), *, iostat=iostat) exit_code
290 if (iostat == 0) then
291 shell%last_exit_status = exit_code
292 else
293 ! Invalid exit code argument - treat as syntax error (exit 2)
294 shell%last_exit_status = 2
295 end if
296 end if
297 end subroutine
298
299 subroutine builtin_cd(cmd, shell)
300 type(command_t), intent(in) :: cmd
301 type(shell_state_t), intent(inout) :: shell
302 #ifdef USE_MEMORY_POOL
303 type(string_ref) :: target_dir_ref
304 character(len=:), allocatable :: temp_str
305 #else
306 character(len=:), allocatable :: target_dir
307 #endif
308 character(len=MAX_PATH_LEN) :: old_cwd
309 logical :: print_dir
310
311 print_dir = .false.
312
313 ! Save current directory for OLDPWD
314 old_cwd = shell%cwd
315
316 #ifdef USE_MEMORY_POOL
317 ! Get pooled buffer for target directory
318 target_dir_ref = pool_get_string(MAX_PATH_LEN)
319 call dashboard_track_allocation(MOD_BUILTINS, MAX_PATH_LEN, 4)
320 #endif
321
322 if (cmd%num_tokens == 1) then
323 ! cd with no arguments goes to HOME
324 #ifdef USE_MEMORY_POOL
325 temp_str = get_environment_var('HOME')
326 target_dir_ref%data = temp_str
327 if (allocated(temp_str)) deallocate(temp_str)
328 #else
329 target_dir = get_environment_var('HOME')
330 #endif
331 else if (trim(cmd%tokens(2)) == '-') then
332 ! cd - goes to OLDPWD and prints it
333 if (len_trim(shell%oldpwd) == 0) then
334 write(error_unit, '(a)') 'cd: OLDPWD not set'
335 shell%last_exit_status = 1
336 #ifdef USE_MEMORY_POOL
337 call pool_release_string(target_dir_ref)
338 call dashboard_track_deallocation(MOD_BUILTINS, MAX_PATH_LEN, 4)
339 #endif
340 return
341 end if
342 #ifdef USE_MEMORY_POOL
343 target_dir_ref%data = trim(shell%oldpwd)
344 #else
345 target_dir = trim(shell%oldpwd)
346 #endif
347 print_dir = .true.
348 else
349 ! Check if directory contains slash - if so, don't use CDPATH
350 if (index(cmd%tokens(2), '/') == 0) then
351 ! Try CDPATH directories
352 block
353 character(len=4096) :: cdpath, path_elem
354 character(len=MAX_PATH_LEN) :: test_path
355 integer :: start_pos, colon_pos
356 logical :: found
357
358 ! Check both shell variable and environment variable
359 cdpath = get_shell_variable(shell, 'CDPATH')
360 if (len_trim(cdpath) == 0) then
361 cdpath = get_environment_var('CDPATH')
362 end if
363 found = .false.
364
365 if (len_trim(cdpath) > 0) then
366 start_pos = 1
367 do while (start_pos <= len_trim(cdpath))
368 colon_pos = index(cdpath(start_pos:), ':')
369 if (colon_pos > 0) then
370 path_elem = cdpath(start_pos:start_pos+colon_pos-2)
371 start_pos = start_pos + colon_pos
372 else
373 path_elem = cdpath(start_pos:)
374 start_pos = len_trim(cdpath) + 1
375 end if
376
377 ! Construct test path
378 if (len_trim(path_elem) > 0) then
379 test_path = trim(path_elem) // '/' // trim(cmd%tokens(2))
380 else
381 test_path = trim(cmd%tokens(2))
382 end if
383
384 ! Test if this path exists and is a directory
385 if (test_is_directory(test_path)) then
386 #ifdef USE_MEMORY_POOL
387 target_dir_ref%data = trim(test_path)
388 #else
389 target_dir = trim(test_path)
390 #endif
391 found = .true.
392 print_dir = .true. ! Print directory when using CDPATH
393 exit
394 end if
395 end do
396 end if
397
398 if (.not. found) then
399 ! CDPATH didn't find it, use original argument
400 #ifdef USE_MEMORY_POOL
401 target_dir_ref%data = trim(cmd%tokens(2))
402 #else
403 target_dir = trim(cmd%tokens(2))
404 #endif
405 end if
406 end block
407 else
408 ! Contains slash - use as-is
409 #ifdef USE_MEMORY_POOL
410 target_dir_ref%data = trim(cmd%tokens(2))
411 #else
412 target_dir = trim(cmd%tokens(2))
413 #endif
414 end if
415 end if
416
417 #ifdef USE_MEMORY_POOL
418 if (change_directory(target_dir_ref%data)) then
419 #else
420 if (change_directory(target_dir)) then
421 #endif
422 ! Update OLDPWD before changing cwd
423 shell%oldpwd = old_cwd
424 ! POSIX: Use logical path (preserve symlinks) unless -P is specified
425 ! For absolute paths, use them as-is. For relative paths, resolve logically.
426 #ifdef USE_MEMORY_POOL
427 if (len(target_dir_ref%data) > 0 .and. target_dir_ref%data(1:1) == '/') then
428 ! Absolute path - use it directly (preserves symlinks like /tmp)
429 ! Strip trailing slashes (but keep root /)
430 shell%cwd = target_dir_ref%data
431 do while (len_trim(shell%cwd) > 1 .and. shell%cwd(len_trim(shell%cwd):len_trim(shell%cwd)) == '/')
432 shell%cwd(len_trim(shell%cwd):len_trim(shell%cwd)) = ' '
433 end do
434 #else
435 if (len(target_dir) > 0 .and. target_dir(1:1) == '/') then
436 ! Absolute path - use it directly (preserves symlinks like /tmp)
437 ! Strip trailing slashes (but keep root /)
438 shell%cwd = target_dir
439 do while (len_trim(shell%cwd) > 1 .and. shell%cwd(len_trim(shell%cwd):len_trim(shell%cwd)) == '/')
440 shell%cwd(len_trim(shell%cwd):len_trim(shell%cwd)) = ' '
441 end do
442 #endif
443 else
444 ! Relative path — resolve logically against $PWD (POSIX default -L behavior)
445 block
446 character(len=MAX_PATH_LEN) :: logical_path
447 integer :: lp_len, slash_pos
448 #ifdef USE_MEMORY_POOL
449 logical_path = trim(old_cwd) // '/' // trim(target_dir_ref%data)
450 #else
451 logical_path = trim(old_cwd) // '/' // trim(target_dir)
452 #endif
453 ! Normalize: resolve . and .. components
454 call normalize_path(logical_path)
455 lp_len = len_trim(logical_path)
456 if (lp_len > 0) then
457 shell%cwd = logical_path(1:lp_len)
458 else
459 shell%cwd = get_current_directory()
460 end if
461 end block
462 end if
463
464 ! Update PWD and OLDPWD environment variables
465 if (.not. set_environment_var('PWD', trim(shell%cwd))) then
466 ! Ignore error, not critical
467 end if
468 if (.not. set_environment_var('OLDPWD', trim(shell%oldpwd))) then
469 ! Ignore error, not critical
470 end if
471
472 ! Update terminal title after directory change
473 if (shell%is_interactive .and. shell%term_supports_color) then
474 call set_terminal_title(trim(shell%username) // '@' // trim(shell%hostname) // ': ' // trim(shell%cwd))
475 end if
476
477 ! Add OLD directory to history so prevd can go back to it (Fish-style prevd/nextd)
478 call add_to_dir_history(shell, old_cwd)
479
480 ! Add NEW directory to history so nextd can go forward to it
481 call add_to_dir_history(shell, shell%cwd)
482
483 ! Print new directory if cd - or CDPATH was used
484 if (print_dir) then
485 write(output_unit, '(a)') trim(shell%cwd)
486 flush(output_unit)
487 end if
488
489 shell%last_exit_status = 0
490 else
491 #ifdef USE_MEMORY_POOL
492 write(error_unit, '(a)') 'cd: cannot access ' // trim(target_dir_ref%data) // &
493 ': No such file or directory. Use "pwd" to see current location.'
494 #else
495 write(error_unit, '(a)') 'cd: cannot access ' // trim(target_dir) // &
496 ': No such file or directory. Use "pwd" to see current location.'
497 #endif
498 shell%last_exit_status = 1
499 end if
500
501 #ifdef USE_MEMORY_POOL
502 ! Release pooled buffer
503 call pool_release_string(target_dir_ref)
504 call dashboard_track_deallocation(MOD_BUILTINS, MAX_PATH_LEN, 4)
505 #endif
506 end subroutine
507
508 ! Resolve . and .. components in a path logically (no syscalls)
509 subroutine normalize_path(path)
510 character(len=*), intent(inout) :: path
511 character(len=256) :: components(64)
512 integer :: num_comp, i, start, plen, out_len
513 character(len=len(path)) :: result
514
515 plen = len_trim(path)
516 if (plen == 0) return
517
518 ! Split path on /
519 num_comp = 0
520 start = 1
521 ! Skip leading /
522 if (path(1:1) == '/') start = 2
523
524 do while (start <= plen)
525 i = index(path(start:plen), '/')
526 if (i == 0) then
527 i = plen - start + 1
528 else
529 i = i - 1
530 end if
531 if (i > 0) then
532 if (path(start:start+i-1) == '..') then
533 ! Pop last component
534 if (num_comp > 0) num_comp = num_comp - 1
535 else if (path(start:start+i-1) /= '.' .and. i > 0) then
536 ! Push component
537 num_comp = num_comp + 1
538 components(num_comp) = path(start:start+i-1)
539 end if
540 end if
541 start = start + i + 1
542 end do
543
544 ! Reconstruct
545 result = '/'
546 out_len = 1
547 do i = 1, num_comp
548 if (i > 1) then
549 result(out_len+1:out_len+1) = '/'
550 out_len = out_len + 1
551 end if
552 result(out_len+1:out_len+len_trim(components(i))) = trim(components(i))
553 out_len = out_len + len_trim(components(i))
554 end do
555
556 path = result(1:out_len)
557 end subroutine
558
559 subroutine builtin_pwd(cmd, shell)
560 type(command_t), intent(in) :: cmd
561 type(shell_state_t), intent(inout) :: shell
562
563 if (.false.) print *, cmd%num_tokens ! Silence unused warning
564
565 ! Use FD-aware I/O to respect redirections
566 call write_stdout(trim(shell%cwd))
567 shell%last_exit_status = 0
568 end subroutine
569
570 subroutine builtin_export(cmd, shell)
571 use variables, only: set_shell_variable, get_shell_variable
572 use system_interface, only: get_environ_entry
573 type(command_t), intent(in) :: cmd
574 type(shell_state_t), intent(inout) :: shell
575 integer :: eq_pos, i, j, arg_idx
576 character(len=MAX_TOKEN_LEN) :: var_name
577 character(len=:), allocatable :: var_value
578 logical :: print_mode, found, unexport_mode
579 character(len=:), allocatable :: env_entry
580
581 print_mode = .false.
582 unexport_mode = .false.
583 arg_idx = 2
584
585 ! Parse options
586 do while (arg_idx <= cmd%num_tokens)
587 if (trim(cmd%tokens(arg_idx)) == '-p') then
588 print_mode = .true.
589 arg_idx = arg_idx + 1
590 else if (trim(cmd%tokens(arg_idx)) == '-n') then
591 unexport_mode = .true.
592 arg_idx = arg_idx + 1
593 else
594 exit
595 end if
596 end do
597
598 if (cmd%num_tokens < 2 .or. (print_mode .and. arg_idx > cmd%num_tokens)) then
599 ! No arguments or -p with no args: print all exported variables
600 print_mode = .true.
601 end if
602
603 ! Handle export -n: unexport variables
604 if (unexport_mode) then
605 do i = arg_idx, cmd%num_tokens
606 var_name = trim(cmd%tokens(i))
607 do j = 1, shell%num_variables
608 if (trim(shell%variables(j)%name) == var_name) then
609 shell%variables(j)%exported = .false.
610 ! Remove from environment
611 call unset_environment_var(var_name)
612 exit
613 end if
614 end do
615 end do
616 shell%last_exit_status = 0
617 return
618 end if
619
620 if (print_mode) then
621 ! Print all environment variables in declare -x format (matches bash export -p)
622 i = 0
623 do
624 env_entry = get_environ_entry(i)
625 if (.not. allocated(env_entry) .or. len(env_entry) == 0) exit
626 ! Format: declare -x VAR="value"
627 block
628 integer :: eqp
629 eqp = index(env_entry, '=')
630 if (eqp > 0) then
631 write(output_unit, '(a)') 'declare -x ' // env_entry(:eqp) // '"' // &
632 trim(env_entry(eqp+1:)) // '"'
633 else
634 write(output_unit, '(a)') 'declare -x ' // trim(env_entry)
635 end if
636 end block
637 if (allocated(env_entry)) deallocate(env_entry)
638 i = i + 1
639 end do
640 shell%last_exit_status = 0
641 return
642 end if
643
644 ! Process each argument (arg_idx already points past parsed options)
645 do i = arg_idx, cmd%num_tokens
646 eq_pos = index(cmd%tokens(i), '=')
647
648 if (eq_pos > 0) then
649 ! VAR=value form - set and export
650 var_name = cmd%tokens(i)(:eq_pos-1)
651
652 ! Validate variable name: must start with letter or underscore
653 if (len_trim(var_name) == 0 .or. &
654 (.not. (var_name(1:1) >= 'a' .and. var_name(1:1) <= 'z') .and. &
655 .not. (var_name(1:1) >= 'A' .and. var_name(1:1) <= 'Z') .and. &
656 var_name(1:1) /= '_')) then
657 write(error_unit, '(a)') 'export: `' // trim(var_name) // "': not a valid identifier"
658 shell%last_exit_status = 1
659 cycle
660 end if
661
662 if (allocated(cmd%token_lengths) .and. i <= size(cmd%token_lengths) .and. &
663 cmd%token_lengths(i) > eq_pos) then
664 var_value = cmd%tokens(i)(eq_pos+1:cmd%token_lengths(i))
665 else
666 var_value = trim(cmd%tokens(i)(eq_pos+1:))
667 end if
668
669 ! Set as shell variable first
670 call set_shell_variable(shell, trim(var_name), var_value)
671
672 ! Mark as exported
673 do j = 1, shell%num_variables
674 if (trim(shell%variables(j)%name) == trim(var_name)) then
675 shell%variables(j)%exported = .true.
676 ! Also set in environment
677 if (.not. set_environment_var(trim(var_name), var_value)) then
678 write(error_unit, '(a)') 'export: failed to set environment variable'
679 shell%last_exit_status = 1
680 return
681 end if
682 exit
683 end if
684 end do
685 else
686 ! Just VAR - mark existing variable as exported
687 var_name = trim(cmd%tokens(i))
688
689 ! Validate variable name
690 if (len_trim(var_name) == 0 .or. &
691 (.not. (var_name(1:1) >= 'a' .and. var_name(1:1) <= 'z') .and. &
692 .not. (var_name(1:1) >= 'A' .and. var_name(1:1) <= 'Z') .and. &
693 var_name(1:1) /= '_')) then
694 write(error_unit, '(a)') 'export: `' // trim(var_name) // "': not a valid identifier"
695 shell%last_exit_status = 1
696 cycle
697 end if
698
699 found = .false.
700
701 do j = 1, shell%num_variables
702 if (trim(shell%variables(j)%name) == var_name) then
703 shell%variables(j)%exported = .true.
704 found = .true.
705 ! Export current value to environment
706 if (.not. set_environment_var(var_name, trim(shell%variables(j)%value))) then
707 write(error_unit, '(a)') 'export: failed to set environment variable'
708 shell%last_exit_status = 1
709 return
710 end if
711 exit
712 end if
713 end do
714
715 if (.not. found) then
716 ! Check special built-in variables stored in dedicated fields
717 select case (trim(var_name))
718 case ('PS1')
719 found = .true.
720 if (.not. set_environment_var(var_name, trim(shell%ps1))) then
721 write(error_unit, '(a)') 'export: failed to set environment variable'
722 end if
723 case ('PS2')
724 found = .true.
725 if (.not. set_environment_var(var_name, trim(shell%ps2))) then
726 write(error_unit, '(a)') 'export: failed to set environment variable'
727 end if
728 case default
729 ! Variable doesn't exist, create it with empty value and export
730 call set_shell_variable(shell, var_name, '')
731 do j = 1, shell%num_variables
732 if (trim(shell%variables(j)%name) == var_name) then
733 shell%variables(j)%exported = .true.
734 if (.not. set_environment_var(var_name, '')) then
735 write(error_unit, '(a)') 'export: failed to set environment variable'
736 shell%last_exit_status = 1
737 return
738 end if
739 exit
740 end if
741 end do
742 end select
743 end if
744 end if
745 end do
746
747 shell%last_exit_status = 0
748 end subroutine
749
750 subroutine builtin_echo(cmd, shell)
751 use io_helpers, only: write_stdout_checked, write_stdout_nonl_checked
752 type(command_t), intent(in) :: cmd
753 type(shell_state_t), intent(inout) :: shell
754 integer :: i, j, len_token, start_token
755 logical :: first, suppress_newline, write_ok, had_error, interpret_escapes, stop_output
756 character(len=:), allocatable :: processed
757 character(len=MAX_TOKEN_LEN) :: token
758
759 had_error = .false.
760
761 ! POSIX echo implementation - interprets backslash escape sequences
762 if (.not. allocated(cmd%tokens) .or. cmd%num_tokens < 1) then
763 call write_stdout_checked('', write_ok)
764 if (.not. write_ok) then
765 call write_stderr('fortsh: echo: write error: Bad file descriptor')
766 shell%last_exit_status = 1
767 else
768 shell%last_exit_status = 0
769 end if
770 return
771 end if
772
773 first = .true.
774 suppress_newline = .false.
775 stop_output = .false.
776 interpret_escapes = .false. ! Bash default: do NOT interpret escapes (use -e to enable)
777 start_token = 2
778
779 ! Parse options (must be first arguments)
780 do i = 2, cmd%num_tokens
781 token = cmd%tokens(i)
782 if (token(1:1) /= '-' .or. len_trim(token) < 2) exit
783
784 ! Check for valid option characters
785 if (trim(token) == '-n') then
786 suppress_newline = .true.
787 start_token = i + 1
788 else if (trim(token) == '-e') then
789 interpret_escapes = .true.
790 start_token = i + 1
791 else if (trim(token) == '-E') then
792 interpret_escapes = .false.
793 start_token = i + 1
794 else if (trim(token) == '-ne' .or. trim(token) == '-en') then
795 suppress_newline = .true.
796 interpret_escapes = .true.
797 start_token = i + 1
798 else if (trim(token) == '-nE' .or. trim(token) == '-En') then
799 suppress_newline = .true.
800 interpret_escapes = .false.
801 start_token = i + 1
802 else
803 ! Not a recognized option, treat as regular argument
804 exit
805 end if
806 end do
807
808 ! Fast path: when not interpreting escapes, build output in a single buffer
809 ! and write once to avoid per-token syscall overhead
810 if (.not. interpret_escapes) then
811 block
812 character(len=:), allocatable :: out_buf
813 integer :: out_pos, out_cap, tlen
814
815 ! Estimate total output size
816 out_cap = 0
817 do i = start_token, cmd%num_tokens
818 if (allocated(cmd%token_lengths) .and. i <= size(cmd%token_lengths) .and. &
819 cmd%token_lengths(i) > 0) then
820 out_cap = out_cap + cmd%token_lengths(i) + 1
821 else
822 out_cap = out_cap + len_trim(cmd%tokens(i)) + 1
823 end if
824 end do
825 if (.not. suppress_newline) out_cap = out_cap + 1
826
827 allocate(character(len=max(out_cap, 1)) :: out_buf)
828 out_pos = 1
829 first = .true.
830
831 do i = start_token, cmd%num_tokens
832 ! Skip empty unquoted tokens
833 if (len_trim(cmd%tokens(i)) == 0) then
834 if (allocated(cmd%token_quoted)) then
835 if (.not. (i <= size(cmd%token_quoted) .and. cmd%token_quoted(i))) cycle
836 else
837 cycle
838 end if
839 end if
840
841 if (.not. first) then
842 out_buf(out_pos:out_pos) = ' '
843 out_pos = out_pos + 1
844 end if
845
846 if (allocated(cmd%token_lengths) .and. i <= size(cmd%token_lengths) .and. &
847 cmd%token_lengths(i) > 0) then
848 tlen = cmd%token_lengths(i)
849 else
850 tlen = len_trim(cmd%tokens(i))
851 end if
852
853 if (tlen > 0) then
854 out_buf(out_pos:out_pos+tlen-1) = cmd%tokens(i)(1:tlen)
855 out_pos = out_pos + tlen
856 end if
857 first = .false.
858 end do
859
860 if (.not. suppress_newline) then
861 out_buf(out_pos:out_pos) = new_line('a')
862 out_pos = out_pos + 1
863 end if
864
865 if (out_pos > 1) then
866 call write_stdout_nonl_checked(out_buf(1:out_pos-1), write_ok)
867 if (.not. write_ok) had_error = .true.
868 else if (.not. suppress_newline) then
869 call write_stdout_checked('', write_ok)
870 if (.not. write_ok) had_error = .true.
871 end if
872 end block
873 else
874 ! Escape-interpreting path (less common)
875 do i = start_token, cmd%num_tokens
876 if (len_trim(cmd%tokens(i)) == 0) then
877 if (allocated(cmd%token_quoted)) then
878 if (i <= size(cmd%token_quoted) .and. cmd%token_quoted(i)) then
879 ! Token was quoted - keep it as empty argument
880 else
881 cycle
882 end if
883 else
884 cycle
885 end if
886 end if
887
888 if (.not. first) then
889 call write_stdout_nonl_checked(' ', write_ok)
890 if (.not. write_ok) had_error = .true.
891 end if
892
893 token = cmd%tokens(i)
894 if (allocated(cmd%token_lengths) .and. i <= size(cmd%token_lengths) .and. &
895 cmd%token_lengths(i) > 0) then
896 len_token = cmd%token_lengths(i)
897 else
898 len_token = len_trim(token)
899 end if
900
901 processed = ''
902 j = 1
903
904 do while (j <= len_token)
905 if (token(j:j) == '\' .and. j < len_token) then
906 j = j + 1
907 select case (token(j:j))
908 case ('a')
909 processed = processed // achar(7)
910 case ('b')
911 processed = processed // achar(8)
912 case ('c')
913 suppress_newline = .true.
914 stop_output = .true.
915 exit
916 case ('f')
917 processed = processed // achar(12)
918 case ('n')
919 processed = processed // new_line('a')
920 case ('r')
921 processed = processed // achar(13)
922 case ('t')
923 processed = processed // achar(9)
924 case ('v')
925 processed = processed // achar(11)
926 case ('\')
927 processed = processed // '\'
928 case ('0')
929 block
930 integer :: oval, nd
931 oval = 0
932 nd = 0
933 do while (nd < 3 .and. j + nd + 1 <= len_token)
934 if (token(j+nd+1:j+nd+1) >= '0' .and. token(j+nd+1:j+nd+1) <= '7') then
935 oval = oval * 8 + ichar(token(j+nd+1:j+nd+1)) - ichar('0')
936 nd = nd + 1
937 else
938 exit
939 end if
940 end do
941 if (oval >= 0 .and. oval <= 127) then
942 processed = processed // achar(oval)
943 else
944 processed = processed // achar(mod(oval, 256))
945 end if
946 j = j + nd
947 end block
948 case ('x')
949 block
950 integer :: hval, hd, hc
951 hval = 0
952 hd = 0
953 do while (hd < 2 .and. j + hd + 1 <= len_token)
954 hc = ichar(token(j+hd+1:j+hd+1))
955 if (hc >= ichar('0') .and. hc <= ichar('9')) then
956 hval = hval * 16 + hc - ichar('0')
957 hd = hd + 1
958 else if (hc >= ichar('a') .and. hc <= ichar('f')) then
959 hval = hval * 16 + hc - ichar('a') + 10
960 hd = hd + 1
961 else if (hc >= ichar('A') .and. hc <= ichar('F')) then
962 hval = hval * 16 + hc - ichar('A') + 10
963 hd = hd + 1
964 else
965 exit
966 end if
967 end do
968 if (hd > 0) then
969 processed = processed // achar(mod(hval, 256))
970 j = j + hd
971 else
972 processed = processed // '\x'
973 end if
974 end block
975 case default
976 processed = processed // '\' // token(j:j)
977 end select
978 j = j + 1
979 else
980 processed = processed // token(j:j)
981 j = j + 1
982 end if
983 end do
984
985 call write_stdout_nonl_checked(processed, write_ok)
986 if (.not. write_ok) had_error = .true.
987 first = .false.
988
989 if (stop_output) exit
990 end do
991
992 if (.not. suppress_newline) then
993 call write_stdout_checked('', write_ok)
994 if (.not. write_ok) had_error = .true.
995 end if
996 end if
997
998 if (had_error) then
999 call write_stderr('fortsh: echo: write error: Bad file descriptor')
1000 shell%last_exit_status = 1
1001 else
1002 shell%last_exit_status = 0
1003 end if
1004 end subroutine
1005
1006 subroutine builtin_jobs(cmd, shell)
1007 type(command_t), intent(in) :: cmd
1008 type(shell_state_t), intent(inout) :: shell
1009 logical :: show_pids
1010
1011 ! Check for -p flag to show PIDs
1012 show_pids = .false.
1013 if (cmd%num_tokens > 1 .and. trim(cmd%tokens(2)) == '-p') then
1014 show_pids = .true.
1015 end if
1016
1017 call update_job_status(shell)
1018 call list_jobs(shell, show_pids)
1019 shell%last_exit_status = 0
1020 end subroutine
1021
1022 ! Parse job specification and return job_id
1023 ! Supports: %n, %%, %+, %-, %?string
1024 ! Returns 0 if no match found
1025 function parse_job_spec(shell, spec) result(job_id)
1026 type(shell_state_t), intent(in) :: shell
1027 character(len=*), intent(in) :: spec
1028 integer :: job_id
1029 character(len=256) :: search_str
1030 integer :: iostat, i
1031
1032 job_id = 0
1033
1034 if (len_trim(spec) == 0) then
1035 ! Empty spec - use current job
1036 job_id = shell%current_job_id
1037 return
1038 end if
1039
1040 ! Remove leading % if present
1041 if (spec(1:1) == '%') then
1042 if (len_trim(spec) == 1) then
1043 ! Just "%" - current job
1044 job_id = shell%current_job_id
1045 return
1046 end if
1047
1048 select case (spec(2:2))
1049 case ('+')
1050 ! %+ - current job
1051 job_id = shell%current_job_id
1052 case ('-')
1053 ! %- - previous job
1054 job_id = shell%previous_job_id
1055 case ('%')
1056 ! %% - current job
1057 job_id = shell%current_job_id
1058 case ('?')
1059 ! %?string - search for string in command
1060 search_str = trim(spec(3:))
1061 do i = 1, MAX_JOBS
1062 if (shell%jobs(i)%job_id > 0) then
1063 if (index(shell%jobs(i)%command_line, trim(search_str)) > 0) then
1064 job_id = shell%jobs(i)%job_id
1065 return
1066 end if
1067 end if
1068 end do
1069 case default
1070 ! %n - job number
1071 read(spec(2:), *, iostat=iostat) job_id
1072 if (iostat /= 0) then
1073 job_id = 0
1074 end if
1075 end select
1076 else
1077 ! No % prefix - try to parse as number
1078 read(spec, *, iostat=iostat) job_id
1079 if (iostat /= 0) then
1080 job_id = 0
1081 end if
1082 end if
1083 end function parse_job_spec
1084
1085 subroutine builtin_fg(cmd, shell)
1086 type(command_t), intent(in) :: cmd
1087 type(shell_state_t), intent(inout) :: shell
1088 integer :: job_id, i
1089
1090 if (cmd%num_tokens < 2) then
1091 ! Use current job, or fall back to most recent stopped job
1092 job_id = shell%current_job_id
1093 if (job_id == 0) then
1094 do i = MAX_JOBS, 1, -1
1095 if (shell%jobs(i)%job_id > 0 .and. shell%jobs(i)%state == JOB_STOPPED) then
1096 job_id = shell%jobs(i)%job_id
1097 exit
1098 end if
1099 end do
1100 end if
1101
1102 if (job_id == 0) then
1103 write(error_unit, '(a)') 'fg: no current job'
1104 shell%last_exit_status = 1
1105 return
1106 end if
1107 else
1108 ! Parse job spec (%n, %%, %+, %-, %?string)
1109 job_id = parse_job_spec(shell, cmd%tokens(2))
1110
1111 if (job_id == 0) then
1112 write(error_unit, '(a)') 'fg: no such job'
1113 shell%last_exit_status = 1
1114 return
1115 end if
1116 end if
1117
1118 call resume_job_fg(shell, job_id)
1119 end subroutine
1120
1121 subroutine builtin_bg(cmd, shell)
1122 type(command_t), intent(in) :: cmd
1123 type(shell_state_t), intent(inout) :: shell
1124 integer :: job_id, i
1125
1126 if (cmd%num_tokens < 2) then
1127 ! Use current job, or fall back to most recent stopped job
1128 job_id = shell%current_job_id
1129 if (job_id == 0) then
1130 do i = MAX_JOBS, 1, -1
1131 if (shell%jobs(i)%job_id > 0 .and. &
1132 shell%jobs(i)%state == JOB_STOPPED) then
1133 job_id = shell%jobs(i)%job_id
1134 exit
1135 end if
1136 end do
1137 end if
1138
1139 if (job_id == 0) then
1140 write(error_unit, '(a)') 'bg: no current job'
1141 shell%last_exit_status = 1
1142 return
1143 end if
1144 else
1145 ! Parse job spec (%n, %%, %+, %-, %?string)
1146 job_id = parse_job_spec(shell, cmd%tokens(2))
1147
1148 if (job_id == 0) then
1149 write(error_unit, '(a)') 'bg: no such job'
1150 shell%last_exit_status = 1
1151 return
1152 end if
1153 end if
1154
1155 call resume_job_bg(shell, job_id)
1156 end subroutine
1157
1158 subroutine builtin_disown(cmd, shell)
1159 use job_control, only: remove_job
1160 type(command_t), intent(in) :: cmd
1161 type(shell_state_t), intent(inout) :: shell
1162 integer :: job_id, i
1163
1164 if (cmd%num_tokens < 2) then
1165 ! Disown current job
1166 job_id = shell%current_job_id
1167 if (job_id == 0) then
1168 ! Find most recent job
1169 do i = MAX_JOBS, 1, -1
1170 if (shell%jobs(i)%job_id > 0) then
1171 job_id = shell%jobs(i)%job_id
1172 exit
1173 end if
1174 end do
1175 end if
1176 else
1177 ! Parse job spec
1178 job_id = parse_job_spec(shell, cmd%tokens(2))
1179 end if
1180
1181 if (job_id == 0) then
1182 write(error_unit, '(a)') 'disown: no current job'
1183 shell%last_exit_status = 1
1184 return
1185 end if
1186
1187 call remove_job(shell, job_id)
1188 shell%last_exit_status = 0
1189 end subroutine
1190
1191 subroutine builtin_source(cmd, shell)
1192 use variables, only: get_shell_variable
1193 type(command_t), intent(in) :: cmd
1194 type(shell_state_t), intent(inout) :: shell
1195
1196 character(len=:), allocatable :: filename, path_var, dir, candidate
1197 character(len=:), allocatable :: path_str
1198 logical :: file_exists, found_in_path
1199 integer :: i, path_start, path_end, path_len
1200
1201 ! Check if filename provided
1202 if (cmd%num_tokens < 2) then
1203 write(error_unit, '(a)') 'source: usage: source filename [arguments...]'
1204 shell%last_exit_status = 1
1205 return
1206 end if
1207
1208 filename = trim(cmd%tokens(2))
1209
1210 ! POSIX: If filename doesn't contain '/', search PATH
1211 if (index(filename, '/') == 0) then
1212 ! Get PATH variable
1213 path_str = get_shell_variable(shell, 'PATH')
1214 if (allocated(path_str)) then
1215 path_var = path_str
1216 else
1217 path_var = ''
1218 end if
1219 path_len = len_trim(path_var)
1220
1221 found_in_path = .false.
1222 path_start = 1
1223
1224 ! Search each directory in PATH
1225 do while (path_start <= path_len .and. .not. found_in_path)
1226 ! Find next colon or end of string
1227 path_end = index(path_var(path_start:), ':')
1228 if (path_end == 0) then
1229 path_end = path_len + 1
1230 else
1231 path_end = path_start + path_end - 1
1232 end if
1233
1234 ! Extract directory
1235 if (path_end > path_start) then
1236 dir = trim(path_var(path_start:path_end-1))
1237 ! Build candidate path
1238 if (len_trim(dir) > 0) then
1239 candidate = trim(dir) // '/' // trim(filename)
1240 else
1241 ! Empty PATH component means current directory
1242 candidate = trim(filename)
1243 end if
1244
1245 ! Check if candidate exists
1246 inquire(file=candidate, exist=file_exists)
1247 if (file_exists) then
1248 filename = candidate
1249 found_in_path = .true.
1250 end if
1251 end if
1252
1253 ! Move to next PATH component
1254 path_start = path_end + 1
1255 end do
1256
1257 ! If not found in PATH, try current directory as fallback
1258 if (.not. found_in_path) then
1259 inquire(file=filename, exist=file_exists)
1260 if (.not. file_exists) then
1261 write(error_unit, '(a)') 'source: ' // trim(cmd%tokens(2)) // ': No such file or directory'
1262 shell%last_exit_status = 1
1263 return
1264 end if
1265 end if
1266 else
1267 ! Contains '/' - use as-is, no PATH search
1268 inquire(file=filename, exist=file_exists)
1269 if (.not. file_exists) then
1270 write(error_unit, '(a)') 'source: ' // trim(filename) // ': No such file or directory'
1271 shell%last_exit_status = 1
1272 return
1273 end if
1274 end if
1275
1276 ! Set positional parameters from remaining arguments
1277 ! Save $0 (script name)
1278 shell%shell_name = trim(filename)
1279
1280 ! Set $1, $2, ... from arguments
1281 shell%num_positional = 0
1282 if (cmd%num_tokens > 2) then
1283 ! Allocate positional_params if not already allocated
1284 if (.not. allocated(shell%positional_params)) then
1285 allocate(shell%positional_params(50)) ! Default size
1286 block
1287 integer :: k
1288 do k = 1, 50
1289 shell%positional_params(k)%str = ''
1290 end do
1291 end block
1292 end if
1293
1294 do i = 3, cmd%num_tokens
1295 shell%num_positional = shell%num_positional + 1
1296 if (shell%num_positional <= size(shell%positional_params)) then
1297 shell%positional_params(shell%num_positional)%str = trim(cmd%tokens(i))
1298 end if
1299 end do
1300 end if
1301
1302 ! Mark the shell to source this file on next main loop iteration
1303 ! This avoids circular dependency issues
1304 shell%source_file = filename
1305 shell%should_source = .true.
1306 ! Don't set exit status here - will be set by the sourced file execution
1307 end subroutine
1308
1309 subroutine builtin_history(cmd, shell)
1310 type(command_t), intent(in) :: cmd
1311 type(shell_state_t), intent(inout) :: shell
1312 integer :: i, n, offset, iostat, history_start_index
1313 character(len=256) :: arg
1314
1315 ! Handle history command options
1316 if (cmd%num_tokens > 1) then
1317 arg = trim(cmd%tokens(2))
1318
1319 select case(arg)
1320 case('-c', '--clear')
1321 ! Clear history (silent like bash)
1322 call clear_history()
1323 shell%last_exit_status = 0
1324 return
1325
1326 case('-d')
1327 ! Delete history entry at offset
1328 if (cmd%num_tokens < 3) then
1329 write(error_unit, '(a)') 'history: -d requires an argument'
1330 shell%last_exit_status = 1
1331 return
1332 end if
1333
1334 read(cmd%tokens(3), *, iostat=iostat) offset
1335 if (iostat /= 0 .or. offset < 1) then
1336 write(error_unit, '(a)') 'history: -d: invalid offset'
1337 shell%last_exit_status = 1
1338 return
1339 end if
1340
1341 call delete_history_entry(offset)
1342 shell%last_exit_status = 0
1343 return
1344
1345 case('-a')
1346 ! Append new history lines to history file
1347 if (len_trim(shell%histfile) == 0) then
1348 write(error_unit, '(a)') 'history: HISTFILE not set'
1349 shell%last_exit_status = 1
1350 return
1351 end if
1352
1353 ! We'll append all history for simplicity (could track last saved index)
1354 call save_history_to_file(trim(shell%histfile), shell%histfilesize)
1355 shell%last_exit_status = 0
1356 return
1357
1358 case('-r')
1359 ! Read history file and append to current history
1360 if (len_trim(shell%histfile) == 0) then
1361 write(error_unit, '(a)') 'history: HISTFILE not set'
1362 shell%last_exit_status = 1
1363 return
1364 end if
1365
1366 call load_history_from_file(trim(shell%histfile), shell%histsize)
1367 shell%last_exit_status = 0
1368 return
1369
1370 case('-w')
1371 ! Write current history to history file
1372 if (len_trim(shell%histfile) == 0) then
1373 write(error_unit, '(a)') 'history: HISTFILE not set'
1374 shell%last_exit_status = 1
1375 return
1376 end if
1377
1378 call save_history_to_file(trim(shell%histfile), shell%histfilesize)
1379 shell%last_exit_status = 0
1380 return
1381
1382 case default
1383 ! Try to parse as number (show last n commands)
1384 read(arg, *, iostat=iostat) n
1385 if (iostat /= 0) then
1386 write(error_unit, '(a)') 'history: unknown option: ' // trim(arg)
1387 shell%last_exit_status = 1
1388 return
1389 end if
1390
1391 ! Show last n commands
1392 history_start_index = max(1, get_history_count() - n + 1)
1393 do i = history_start_index, get_history_count()
1394 write(output_unit, '(i4,2x,a)') i, trim(command_history%lines(i))
1395 end do
1396 shell%last_exit_status = 0
1397 return
1398 end select
1399 else
1400 ! Show all history
1401 call show_history()
1402 shell%last_exit_status = 0
1403 end if
1404 end subroutine
1405
1406 subroutine builtin_kill(cmd, shell)
1407 type(command_t), intent(in) :: cmd
1408 type(shell_state_t), intent(inout) :: shell
1409 integer :: signal_num, target_pid, iostat, ret
1410 integer :: i, arg_start
1411 logical :: found_signal
1412
1413 signal_num = 15 ! Default: SIGTERM
1414 arg_start = 2
1415 found_signal = .false.
1416
1417 if (cmd%num_tokens < 2) then
1418 write(error_unit, '(a)') 'kill: usage: kill [-signal] pid...'
1419 shell%last_exit_status = 1
1420 return
1421 end if
1422
1423 ! Check if first argument is a signal specifier or -l flag
1424 if (cmd%tokens(2)(1:1) == '-') then
1425 if (len_trim(cmd%tokens(2)) > 1) then
1426 ! Check for -s flag (signal name as next arg)
1427 if (trim(cmd%tokens(2)) == '-s') then
1428 if (cmd%num_tokens < 3) then
1429 write(error_unit, '(a)') &
1430 'kill: -s requires an argument'
1431 shell%last_exit_status = 1
1432 return
1433 end if
1434 ! Parse signal name from next argument
1435 block
1436 character(len=256) :: sig_name
1437 sig_name = trim(cmd%tokens(3))
1438 select case(sig_name)
1439 case('TERM', 'term', 'SIGTERM')
1440 signal_num = 15
1441 case('KILL', 'kill', 'SIGKILL')
1442 signal_num = 9
1443 case('INT', 'int', 'SIGINT')
1444 signal_num = 2
1445 case('STOP', 'stop', 'SIGSTOP')
1446 #ifdef __APPLE__
1447 signal_num = 18
1448 #else
1449 signal_num = 20
1450 #endif
1451 case('CONT', 'cont', 'SIGCONT')
1452 #ifdef __APPLE__
1453 signal_num = 19
1454 #else
1455 signal_num = 18
1456 #endif
1457 case('HUP', 'hup', 'SIGHUP')
1458 signal_num = 1
1459 case('QUIT', 'quit', 'SIGQUIT')
1460 signal_num = 3
1461 case('USR1', 'usr1', 'SIGUSR1')
1462 signal_num = 10
1463 case('USR2', 'usr2', 'SIGUSR2')
1464 signal_num = 12
1465 case default
1466 read(sig_name, *, iostat=iostat) signal_num
1467 if (iostat /= 0) then
1468 write(error_unit, '(a)') &
1469 'kill: invalid signal: ' // &
1470 trim(sig_name)
1471 shell%last_exit_status = 1
1472 return
1473 end if
1474 end select
1475 end block
1476 found_signal = .true.
1477 arg_start = 4
1478 ! Check for -l flag (list signals)
1479 else if (trim(cmd%tokens(2)) == '-l') then
1480 ! Check if there's a signal number argument
1481 if (cmd%num_tokens >= 3) then
1482 ! kill -l <num> - translate signal number to name
1483 read(cmd%tokens(3), *, iostat=iostat) signal_num
1484 if (iostat == 0) then
1485 select case(signal_num)
1486 case(1); write(output_unit, '(a)') 'HUP'
1487 case(2); write(output_unit, '(a)') 'INT'
1488 case(3); write(output_unit, '(a)') 'QUIT'
1489 case(4); write(output_unit, '(a)') 'ILL'
1490 case(5); write(output_unit, '(a)') 'TRAP'
1491 case(6); write(output_unit, '(a)') 'ABRT'
1492 case(7); write(output_unit, '(a)') 'BUS'
1493 case(8); write(output_unit, '(a)') 'FPE'
1494 case(9); write(output_unit, '(a)') 'KILL'
1495 case(10); write(output_unit, '(a)') 'USR1'
1496 case(11); write(output_unit, '(a)') 'SEGV'
1497 case(12); write(output_unit, '(a)') 'USR2'
1498 case(13); write(output_unit, '(a)') 'PIPE'
1499 case(14); write(output_unit, '(a)') 'ALRM'
1500 case(15); write(output_unit, '(a)') 'TERM'
1501 case(16); write(output_unit, '(a)') 'STKFLT'
1502 case(17); write(output_unit, '(a)') 'CHLD'
1503 case(18); write(output_unit, '(a)') 'CONT'
1504 case(19); write(output_unit, '(a)') 'STOP'
1505 case(20); write(output_unit, '(a)') 'TSTP'
1506 case(21); write(output_unit, '(a)') 'TTIN'
1507 case(22); write(output_unit, '(a)') 'TTOU'
1508 case default
1509 write(error_unit, '(a,i0)') 'kill: invalid signal number: ', signal_num
1510 shell%last_exit_status = 1
1511 return
1512 end select
1513 shell%last_exit_status = 0
1514 return
1515 end if
1516 end if
1517 ! No argument or invalid - list all signals
1518 write(output_unit, '(a)') 'Available signals:'
1519 write(output_unit, '(a)') ' 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL'
1520 write(output_unit, '(a)') ' 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE'
1521 write(output_unit, '(a)') ' 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2'
1522 write(output_unit, '(a)') ' 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT'
1523 write(output_unit, '(a)') ' 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP'
1524 write(output_unit, '(a)') ' 21) SIGTTIN 22) SIGTTOU'
1525 shell%last_exit_status = 0
1526 return
1527 end if
1528
1529 if (.not. found_signal) then
1530 read(cmd%tokens(2)(2:), *, iostat=iostat) signal_num
1531 if (iostat /= 0) then
1532 ! Try named signals
1533 select case(trim(cmd%tokens(2)(2:)))
1534 case('HUP', 'hup', 'SIGHUP')
1535 signal_num = 1
1536 case('INT', 'int', 'SIGINT')
1537 signal_num = 2
1538 case('QUIT', 'quit', 'SIGQUIT')
1539 signal_num = 3
1540 case('ILL', 'ill', 'SIGILL')
1541 signal_num = 4
1542 case('TRAP', 'trap', 'SIGTRAP')
1543 signal_num = 5
1544 case('ABRT', 'abrt', 'SIGABRT')
1545 signal_num = 6
1546 case('BUS', 'bus', 'SIGBUS')
1547 signal_num = 7
1548 case('FPE', 'fpe', 'SIGFPE')
1549 signal_num = 8
1550 case('KILL', 'kill', 'SIGKILL')
1551 signal_num = 9
1552 case('USR1', 'usr1', 'SIGUSR1')
1553 signal_num = 10
1554 case('SEGV', 'segv', 'SIGSEGV')
1555 signal_num = 11
1556 case('USR2', 'usr2', 'SIGUSR2')
1557 signal_num = 12
1558 case('PIPE', 'pipe', 'SIGPIPE')
1559 signal_num = 13
1560 case('ALRM', 'alrm', 'SIGALRM')
1561 signal_num = 14
1562 case('TERM', 'term', 'SIGTERM')
1563 signal_num = 15
1564 case('STKFLT', 'stkflt', 'SIGSTKFLT')
1565 signal_num = 16
1566 case('CHLD', 'chld', 'SIGCHLD')
1567 #ifdef __APPLE__
1568 signal_num = 20
1569 #else
1570 signal_num = 17
1571 #endif
1572 case('CONT', 'cont', 'SIGCONT')
1573 #ifdef __APPLE__
1574 signal_num = 19
1575 #else
1576 signal_num = 18
1577 #endif
1578 case('STOP', 'stop', 'SIGSTOP')
1579 #ifdef __APPLE__
1580 signal_num = 18 ! SIGTSTP on macOS
1581 #else
1582 signal_num = 20 ! SIGTSTP on Linux
1583 #endif
1584 case('TSTP', 'tstp', 'SIGTSTP')
1585 #ifdef __APPLE__
1586 signal_num = 18 ! SIGTSTP on macOS
1587 #else
1588 signal_num = 20 ! SIGTSTP on Linux
1589 #endif
1590 case('TTIN', 'ttin', 'SIGTTIN')
1591 signal_num = 21
1592 case('TTOU', 'ttou', 'SIGTTOU')
1593 signal_num = 22
1594 case default
1595 write(error_unit, '(a)') 'kill: invalid signal specification'
1596 shell%last_exit_status = 1
1597 return
1598 end select
1599 end if
1600 found_signal = .true.
1601 arg_start = 3
1602 end if ! .not. found_signal
1603 end if
1604 end if
1605
1606 if (cmd%num_tokens < arg_start) then
1607 write(error_unit, '(a)') 'kill: usage: kill [-signal] pid...'
1608 shell%last_exit_status = 1
1609 return
1610 end if
1611
1612 ! Kill each specified process
1613 do i = arg_start, cmd%num_tokens
1614 ! Handle job syntax (%n)
1615 if (cmd%tokens(i)(1:1) == '%') then
1616 read(cmd%tokens(i)(2:), *, iostat=iostat) target_pid
1617 if (iostat == 0) then
1618 ! Find job by job_id and get its pgid
1619 target_pid = find_job_pgid(shell, target_pid)
1620 if (target_pid <= 0) then
1621 write(error_unit, '(a)') 'kill: no such job'
1622 shell%last_exit_status = 1
1623 cycle
1624 end if
1625 ! In non-interactive mode, processes may not have
1626 ! their own group; kill PID directly if so
1627 if (shell%is_interactive) then
1628 target_pid = -target_pid ! Kill process group
1629 end if
1630 else
1631 write(error_unit, '(a)') 'kill: invalid job specification'
1632 shell%last_exit_status = 1
1633 cycle
1634 end if
1635 else
1636 read(cmd%tokens(i), *, iostat=iostat) target_pid
1637 if (iostat /= 0) then
1638 write(error_unit, '(a)') 'kill: invalid pid'
1639 shell%last_exit_status = 1
1640 cycle
1641 end if
1642 end if
1643
1644 ret = c_kill(int(target_pid, c_pid_t), int(signal_num, c_int))
1645 if (ret /= 0) then
1646 write(error_unit, '(a,i15)') 'kill: failed to kill process ', target_pid
1647 shell%last_exit_status = 1
1648 else
1649 ! Update job state when sending SIGCONT to a job
1650 #ifdef __APPLE__
1651 if (signal_num == 19 .and. cmd%tokens(i)(1:1) == '%') then
1652 #else
1653 if (signal_num == 18 .and. cmd%tokens(i)(1:1) == '%') then
1654 #endif
1655 block
1656 integer :: jid, ji
1657 read(cmd%tokens(i)(2:), *, iostat=iostat) jid
1658 if (iostat == 0) then
1659 do ji = 1, MAX_JOBS
1660 if (shell%jobs(ji)%job_id == jid) then
1661 shell%jobs(ji)%state = JOB_RUNNING
1662 exit
1663 end if
1664 end do
1665 end if
1666 end block
1667 end if
1668 end if
1669 end do
1670
1671 if (shell%last_exit_status /= 1) then
1672 shell%last_exit_status = 0
1673 end if
1674 end subroutine
1675
1676
1677 subroutine builtin_wait(cmd, shell)
1678 type(command_t), intent(in) :: cmd
1679 type(shell_state_t), intent(inout) :: shell
1680 integer :: target_pid, iostat, ret
1681 integer(c_int), target :: wait_status
1682 integer :: i
1683
1684 if (cmd%num_tokens == 1) then
1685 ! Wait for all background jobs
1686 block
1687 integer :: done_ids(MAX_JOBS), num_done, di
1688 num_done = 0
1689 do i = 1, MAX_JOBS
1690 if (shell%jobs(i)%job_id > 0 .and. &
1691 shell%jobs(i)%state == JOB_RUNNING) then
1692 ret = c_waitpid(shell%jobs(i)%pgid, &
1693 c_loc(wait_status), 0)
1694 if (WIFEXITED(wait_status) .or. &
1695 WIFSIGNALED(wait_status)) then
1696 num_done = num_done + 1
1697 done_ids(num_done) = shell%jobs(i)%job_id
1698 end if
1699 end if
1700 end do
1701 do di = 1, num_done
1702 call remove_job(shell, done_ids(di))
1703 end do
1704 end block
1705 shell%last_exit_status = 0
1706 else
1707 ! Wait for specific job or PID
1708 do i = 2, cmd%num_tokens
1709 if (cmd%tokens(i)(1:1) == '%') then
1710 ! Job syntax
1711 read(cmd%tokens(i)(2:), *, iostat=iostat) target_pid
1712 if (iostat == 0) then
1713 target_pid = find_job_pgid(shell, target_pid)
1714 else
1715 write(error_unit, '(a)') 'wait: invalid job specification'
1716 shell%last_exit_status = 1
1717 cycle
1718 end if
1719 else
1720 read(cmd%tokens(i), *, iostat=iostat) target_pid
1721 if (iostat /= 0) then
1722 write(error_unit, '(a)') 'wait: invalid pid'
1723 shell%last_exit_status = 1
1724 cycle
1725 end if
1726 end if
1727
1728 if (target_pid > 0) then
1729 ret = c_waitpid(int(target_pid, c_pid_t), c_loc(wait_status), 0)
1730 if (ret > 0) then
1731 if (WIFEXITED(wait_status)) then
1732 shell%last_exit_status = WEXITSTATUS(wait_status)
1733 else if (WIFSIGNALED(wait_status)) then
1734 shell%last_exit_status = 128 + WTERMSIG(wait_status)
1735 else
1736 shell%last_exit_status = 1
1737 end if
1738 else
1739 ! PID is not a child of this shell (or doesn't exist)
1740 write(error_unit, '(a,i0,a)') 'wait: pid ', target_pid, ' not found'
1741 shell%last_exit_status = 127
1742 end if
1743 end if
1744 end do
1745 end if
1746 end subroutine
1747
1748 subroutine builtin_trap(cmd, shell)
1749 type(command_t), intent(in) :: cmd
1750 type(shell_state_t), intent(inout) :: shell
1751 character(len=:), allocatable :: action
1752 character(len=256) :: signal_spec
1753 integer :: i, j, k, signum
1754 logical :: list_mode, remove_mode
1755
1756 list_mode = .false.
1757 remove_mode = .false.
1758
1759 ! trap (no arguments) - list all traps
1760 if (cmd%num_tokens == 1) then
1761 call list_traps(shell)
1762 shell%last_exit_status = 0
1763 return
1764 end if
1765
1766 ! trap -l (list signals)
1767 if (cmd%num_tokens == 2 .and. trim(cmd%tokens(2)) == '-l') then
1768 call write_stdout('Available signals:')
1769 call write_stdout(' 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL')
1770 call write_stdout(' 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE')
1771 call write_stdout(' 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2')
1772 call write_stdout(' 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT')
1773 call write_stdout(' 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP')
1774 call write_stdout(' 21) SIGTTIN 22) SIGTTOU')
1775 call write_stdout('')
1776 call write_stdout('Special signals:')
1777 call write_stdout(' 0) EXIT DEBUG ERR RETURN')
1778 shell%last_exit_status = 0
1779 return
1780 end if
1781
1782 ! trap -p [signal...] (print traps)
1783 if (trim(cmd%tokens(2)) == '-p') then
1784 if (cmd%num_tokens == 2) then
1785 ! Print all traps
1786 call list_traps(shell)
1787 else
1788 ! Print specific traps
1789 do j = 3, cmd%num_tokens
1790 signum = signal_name_to_number(trim(cmd%tokens(j)))
1791 if (signum == -999) then
1792 write(error_unit, '(a)') 'trap: invalid signal: ' // trim(cmd%tokens(j))
1793 shell%last_exit_status = 1
1794 return
1795 end if
1796 ! Print trap for this signal if it exists
1797 ! Use num_traps instead of size(traps) so that subshells can clear traps
1798 do k = 1, shell%num_traps
1799 if (shell%traps(k)%signal == signum .and. shell%traps(k)%active) then
1800 write(output_unit, '(a)') 'trap -- ' // "'" // &
1801 trim(shell%traps(k)%command) // "' " // &
1802 trim(signal_number_to_name(signum))
1803 flush(output_unit)
1804 exit
1805 end if
1806 end do
1807 end do
1808 end if
1809 shell%last_exit_status = 0
1810 return
1811 end if
1812
1813 ! trap action signal [signal...]
1814 if (cmd%num_tokens < 3) then
1815 write(error_unit, '(a)') 'trap: usage: trap [-lp] [action signal_spec ...]'
1816 shell%last_exit_status = 1
1817 return
1818 end if
1819
1820 ! Get action
1821 action = trim(cmd%tokens(2))
1822
1823 ! Strip quotes from action if present
1824 if (len_trim(action) >= 2) then
1825 if (action(1:1) == '"' .and. action(len_trim(action):len_trim(action)) == '"') then
1826 action = action(2:len_trim(action)-1)
1827 else if (action(1:1) == "'" .and. action(len_trim(action):len_trim(action)) == "'") then
1828 action = action(2:len_trim(action)-1)
1829 end if
1830 end if
1831
1832 ! Check for removal syntax: trap - signal
1833 ! Note: trap "" signal (empty action) means ignore the signal, not remove the trap
1834 if (trim(action) == '-') then
1835 remove_mode = .true.
1836 end if
1837
1838 ! Process each signal
1839 do i = 3, cmd%num_tokens
1840 signal_spec = trim(cmd%tokens(i))
1841
1842 ! Convert signal name/number to signal number
1843 signum = signal_name_to_number(signal_spec)
1844
1845 if (signum == -999) then
1846 write(error_unit, '(a)') 'trap: invalid signal specification: ' // trim(signal_spec)
1847 shell%last_exit_status = 1
1848 cycle
1849 end if
1850
1851 ! Check if signal is trappable
1852 if (.not. is_trappable_signal(signum) .and. signum > 0) then
1853 write(error_unit, '(a)') 'trap: ' // trim(signal_spec) // ': cannot trap signal'
1854 shell%last_exit_status = 1
1855 cycle
1856 end if
1857
1858 if (remove_mode) then
1859 ! Remove trap
1860 call remove_signal_trap(shell, signum)
1861 else
1862 ! Set trap
1863 call set_signal_trap(shell, signum, action)
1864 end if
1865 end do
1866
1867 shell%last_exit_status = 0
1868 end subroutine
1869
1870 subroutine builtin_config(cmd, shell)
1871 type(command_t), intent(in) :: cmd
1872 type(shell_state_t), intent(inout) :: shell
1873
1874 if (cmd%num_tokens == 1) then
1875 ! Show current config
1876 call show_config()
1877 else
1878 select case(trim(cmd%tokens(2)))
1879 case('show')
1880 call show_config()
1881 case('create')
1882 call create_default_config()
1883 case('reload')
1884 call load_config_file(shell)
1885 case default
1886 write(error_unit, '(a)') 'config: usage: config [show|create|reload]'
1887 shell%last_exit_status = 1
1888 return
1889 end select
1890 end if
1891
1892 shell%last_exit_status = 0
1893 end subroutine
1894
1895 subroutine builtin_alias(cmd, shell)
1896 type(command_t), intent(in) :: cmd
1897 type(shell_state_t), intent(inout) :: shell
1898 integer :: eq_pos, i
1899 character(len=256) :: alias_name, alias_command
1900 character(len=:), allocatable :: full_arg
1901
1902 if (cmd%num_tokens == 1) then
1903 ! Show all aliases
1904 call show_aliases(shell)
1905 else if (cmd%num_tokens == 2 .and. trim(cmd%tokens(2)) == '-p') then
1906 ! POSIX: -p prints all aliases in reusable format (same as no args)
1907 call show_aliases(shell)
1908 else if (cmd%num_tokens >= 2) then
1909 ! Reconstruct the full argument from all tokens
1910 full_arg = trim(cmd%tokens(2))
1911 do i = 3, cmd%num_tokens
1912 full_arg = trim(full_arg) // ' ' // trim(cmd%tokens(i))
1913 end do
1914
1915 ! Check for alias=command format
1916 eq_pos = index(full_arg, '=')
1917 if (eq_pos > 0) then
1918 alias_name = full_arg(:eq_pos-1)
1919 alias_command = full_arg(eq_pos+1:)
1920
1921 ! Remove quotes or quote sentinels if present
1922 ! Lexer uses char(2)/char(3) for single-quote boundaries, char(1) for double-quote
1923 if (len_trim(alias_command) >= 2) then
1924 ! Check for single-quote sentinels (char(2) start, char(3) end)
1925 if (alias_command(1:1) == char(2) .and. &
1926 alias_command(len_trim(alias_command):len_trim(alias_command)) == char(3)) then
1927 alias_command = alias_command(2:len_trim(alias_command)-1)
1928 ! Check for actual quote characters (in case they weren't converted)
1929 else if (alias_command(1:1) == '"' .and. alias_command(len_trim(alias_command):len_trim(alias_command)) == '"') then
1930 alias_command = alias_command(2:len_trim(alias_command)-1)
1931 else if (alias_command(1:1) == "'" .and. alias_command(len_trim(alias_command):len_trim(alias_command)) == "'") then
1932 alias_command = alias_command(2:len_trim(alias_command)-1)
1933 end if
1934 end if
1935
1936 call set_alias(shell, trim(alias_name), trim(alias_command))
1937 else if (cmd%num_tokens == 2) then
1938 ! Show specific alias (only if single argument without =)
1939 alias_name = cmd%tokens(2)
1940 alias_command = get_alias(shell, trim(alias_name))
1941 if (len_trim(alias_command) > 0) then
1942 write(output_unit, '(a)') 'alias ' // trim(alias_name) // &
1943 '=' // "'" // trim(alias_command) // "'"
1944 else
1945 call write_stderr('alias: ' // trim(alias_name) // ': not found')
1946 shell%last_exit_status = 1
1947 return
1948 end if
1949 else
1950 call write_stderr('alias: usage: alias [name[=value]...]')
1951 shell%last_exit_status = 1
1952 return
1953 end if
1954 end if
1955
1956 shell%last_exit_status = 0
1957 end subroutine
1958
1959 subroutine builtin_unalias(cmd, shell)
1960 type(command_t), intent(in) :: cmd
1961 type(shell_state_t), intent(inout) :: shell
1962 integer :: i
1963 logical :: found, any_not_found
1964
1965 if (cmd%num_tokens < 2) then
1966 call write_stderr('unalias: usage: unalias name...')
1967 shell%last_exit_status = 1
1968 return
1969 end if
1970
1971 ! Check for -a flag (remove all aliases)
1972 if (trim(cmd%tokens(2)) == '-a') then
1973 call clear_all_aliases(shell)
1974 shell%last_exit_status = 0
1975 return
1976 end if
1977
1978 any_not_found = .false.
1979
1980 ! Remove each specified alias
1981 do i = 2, cmd%num_tokens
1982 found = unset_alias(shell, trim(cmd%tokens(i)))
1983 if (.not. found) any_not_found = .true.
1984 end do
1985
1986 if (any_not_found) then
1987 shell%last_exit_status = 1
1988 else
1989 shell%last_exit_status = 0
1990 end if
1991 end subroutine
1992
1993 subroutine builtin_abbr(cmd, shell)
1994 use abbreviations
1995 type(command_t), intent(in) :: cmd
1996 type(shell_state_t), intent(inout) :: shell
1997 integer :: eq_pos
1998 character(len=256) :: short_form, expanded_form
1999 character(len=64) :: abbr_short
2000 character(len=256) :: abbr_expanded
2001
2002 if (cmd%num_tokens == 1) then
2003 ! Show all abbreviations
2004 call show_abbreviations()
2005 else if (cmd%num_tokens >= 2) then
2006 ! Check for --erase flag
2007 if (trim(cmd%tokens(2)) == '--erase' .or. trim(cmd%tokens(2)) == '-e') then
2008 if (cmd%num_tokens >= 3) then
2009 abbr_short = trim(cmd%tokens(3))
2010 call unset_abbreviation(abbr_short)
2011 else
2012 write(error_unit, '(a)') 'abbr: --erase requires an abbreviation name'
2013 shell%last_exit_status = 1
2014 return
2015 end if
2016 else if (trim(cmd%tokens(2)) == '--show' .or. trim(cmd%tokens(2)) == '-s') then
2017 ! Show abbreviations (same as no args)
2018 call show_abbreviations()
2019 else
2020 ! Check for short=expanded format
2021 eq_pos = index(cmd%tokens(2), '=')
2022 if (eq_pos > 0) then
2023 short_form = cmd%tokens(2)(:eq_pos-1)
2024 expanded_form = cmd%tokens(2)(eq_pos+1:)
2025
2026 ! Remove quotes if present
2027 if (expanded_form(1:1) == '"' .and. expanded_form(len_trim(expanded_form):len_trim(expanded_form)) == '"') then
2028 expanded_form = expanded_form(2:len_trim(expanded_form)-1)
2029 else if (expanded_form(1:1) == "'" .and. expanded_form(len_trim(expanded_form):len_trim(expanded_form)) == "'") then
2030 expanded_form = expanded_form(2:len_trim(expanded_form)-1)
2031 end if
2032
2033 abbr_short = trim(short_form)
2034 abbr_expanded = trim(expanded_form)
2035 call set_abbreviation(abbr_short, abbr_expanded)
2036 else
2037 ! Show specific abbreviation
2038 abbr_short = trim(cmd%tokens(2))
2039 abbr_expanded = get_abbreviation(abbr_short)
2040 if (len_trim(abbr_expanded) > 0) then
2041 write(output_unit, '(a)') trim(abbr_short) // &
2042 ' = ' // trim(abbr_expanded)
2043 else
2044 write(error_unit, '(a)') 'abbr: ' // trim(abbr_short) // ': not found'
2045 shell%last_exit_status = 1
2046 return
2047 end if
2048 end if
2049 end if
2050 end if
2051
2052 shell%last_exit_status = 0
2053 end subroutine
2054
2055 subroutine builtin_help(cmd, shell)
2056 use builtin_help_texts, only: print_builtin_help
2057 type(command_t), intent(in) :: cmd
2058 type(shell_state_t), intent(inout) :: shell
2059 logical :: found
2060
2061 ! Per-builtin help: help <name>
2062 if (cmd%num_tokens > 1) then
2063 found = print_builtin_help(trim(cmd%tokens(2)))
2064 if (.not. found) then
2065 write(error_unit, '(a)') 'help: no help topics match `' // &
2066 trim(cmd%tokens(2)) // "'."
2067 shell%last_exit_status = 1
2068 else
2069 shell%last_exit_status = 0
2070 end if
2071 return
2072 end if
2073
2074 write(output_unit, '(a)') 'Fortran Shell (fortsh) - Built-in Commands:'
2075 write(output_unit, '(a)') '========================================'
2076 write(output_unit, '(a)') ''
2077 write(output_unit, '(a)') 'Navigation & Directories:'
2078 write(output_unit, '(a)') ' cd [dir] Change directory (cd - for previous, cd ~ for home)'
2079 write(output_unit, '(a)') ' pwd Print working directory'
2080 write(output_unit, '(a)') ' pushd [dir] Push directory onto stack'
2081 write(output_unit, '(a)') ' popd Pop directory from stack'
2082 write(output_unit, '(a)') ' dirs [-clpv] Display directory stack'
2083 write(output_unit, '(a)') ' prevd/nextd Navigate directory stack'
2084 write(output_unit, '(a)') ' dirh Show directory history'
2085 write(output_unit, '(a)') ''
2086 write(output_unit, '(a)') 'Variables & Environment:'
2087 write(output_unit, '(a)') ' export VAR=val Set/export environment variable'
2088 write(output_unit, '(a)') ' unset name Remove variable or function'
2089 write(output_unit, '(a)') ' readonly VAR Mark variable as read-only'
2090 write(output_unit, '(a)') ' declare [-x] Declare variables with attributes'
2091 write(output_unit, '(a)') ' local VAR=val Declare function-local variable'
2092 write(output_unit, '(a)') ' printenv [VAR] Print environment variables'
2093 write(output_unit, '(a)') ' set [opts] Set shell options (-e, -u, -x, -o pipefail)'
2094 write(output_unit, '(a)') ' shopt [opt] Toggle shell options'
2095 write(output_unit, '(a)') ''
2096 write(output_unit, '(a)') 'I/O & Formatting:'
2097 write(output_unit, '(a)') ' echo [args] Display text'
2098 write(output_unit, '(a)') ' printf fmt args Formatted output'
2099 write(output_unit, '(a)') ' read [-p] var Read input into variable'
2100 write(output_unit, '(a)') ''
2101 write(output_unit, '(a)') 'Job Control:'
2102 write(output_unit, '(a)') ' jobs List active jobs'
2103 write(output_unit, '(a)') ' fg [%n] Bring job to foreground'
2104 write(output_unit, '(a)') ' bg [%n] Send job to background'
2105 write(output_unit, '(a)') ' kill [-sig] pid Send signal to process'
2106 write(output_unit, '(a)') ' wait [pid] Wait for process to complete'
2107 write(output_unit, '(a)') ' coproc cmd Start coprocess with bidirectional I/O'
2108 write(output_unit, '(a)') ''
2109 write(output_unit, '(a)') 'Shell Features:'
2110 write(output_unit, '(a)') ' source/. file Execute commands from file'
2111 write(output_unit, '(a)') ' eval [args] Evaluate arguments as shell command'
2112 write(output_unit, '(a)') ' exec [cmd] Replace shell with command'
2113 write(output_unit, '(a)') ' command [-v] cmd Run command bypassing functions'
2114 write(output_unit, '(a)') ' type/which name Identify command type'
2115 write(output_unit, '(a)') ' hash [-r] Manage command hash table'
2116 write(output_unit, '(a)') ' trap [cmd] sig Set signal handlers'
2117 write(output_unit, '(a)') ' history Show command history'
2118 write(output_unit, '(a)') ' fc Fix/edit previous commands'
2119 write(output_unit, '(a)') ' alias [n=cmd] Create/show command aliases'
2120 write(output_unit, '(a)') ' unalias name Remove alias'
2121 write(output_unit, '(a)') ' abbr [n=cmd] Manage abbreviations'
2122 write(output_unit, '(a)') ' config [cmd] Manage shell configuration'
2123 write(output_unit, '(a)') ''
2124 write(output_unit, '(a)') 'Scripting & Control Flow:'
2125 write(output_unit, '(a)') ' test / [ ] / [[ ]] Evaluate conditions'
2126 write(output_unit, '(a)') ' if/then/elif/else/fi Conditional execution'
2127 write(output_unit, '(a)') ' for/while/until Loop constructs'
2128 write(output_unit, '(a)') ' case/esac Pattern matching'
2129 write(output_unit, '(a)') ' break/continue Loop control'
2130 write(output_unit, '(a)') ' return [n] Return from function'
2131 write(output_unit, '(a)') ' shift [n] Shift positional parameters'
2132 write(output_unit, '(a)') ' getopts str var Parse positional parameters'
2133 write(output_unit, '(a)') ' let expr Arithmetic evaluation'
2134 write(output_unit, '(a)') ' : (colon) Null command (always succeeds)'
2135 write(output_unit, '(a)') ''
2136 write(output_unit, '(a)') 'System:'
2137 write(output_unit, '(a)') ' umask [mode] Get/set file creation mask'
2138 write(output_unit, '(a)') ' ulimit [-a] Get/set resource limits'
2139 write(output_unit, '(a)') ' times Display process times'
2140 write(output_unit, '(a)') ''
2141 write(output_unit, '(a)') 'Completion:'
2142 write(output_unit, '(a)') ' complete Define programmable completions'
2143 write(output_unit, '(a)') ' compgen Generate completion matches'
2144 write(output_unit, '(a)') ''
2145 write(output_unit, '(a)') 'Other:'
2146 write(output_unit, '(a)') ' perf [on|off] Performance monitoring'
2147 write(output_unit, '(a)') ' memory [cmd] Memory pool management'
2148 write(output_unit, '(a)') ' help Show this help message'
2149 write(output_unit, '(a)') ' exit [code] Exit shell'
2150 write(output_unit, '(a)') ''
2151 write(output_unit, '(a)') 'Interactive Keybindings:'
2152 write(output_unit, '(a)') ' Up/Down Navigate command history'
2153 write(output_unit, '(a)') ' Ctrl+A/E Move to beginning/end of line'
2154 write(output_unit, '(a)') ' Ctrl+W/K/U Kill word/to-end/line'
2155 write(output_unit, '(a)') ' Ctrl+Y Yank (paste) killed text'
2156 write(output_unit, '(a)') ' Ctrl+R Reverse history search'
2157 write(output_unit, '(a)') ' Ctrl+L Clear screen'
2158 write(output_unit, '(a)') ' Tab Smart completion with menu'
2159 write(output_unit, '(a)') ' Ctrl+F fzf file browser'
2160 write(output_unit, '(a)') ' Alt+j fzf directory jump'
2161 write(output_unit, '(a)') ' Alt+g fzf git browser'
2162
2163 shell%last_exit_status = 0
2164 end subroutine
2165
2166 subroutine builtin_perf(cmd, shell)
2167 type(command_t), intent(in) :: cmd
2168 type(shell_state_t), intent(inout) :: shell
2169
2170 if (cmd%num_tokens > 1) then
2171 select case(trim(cmd%tokens(2)))
2172 case('on')
2173 call set_performance_monitoring(.true.)
2174 write(output_unit, '(a)') 'Performance monitoring enabled'
2175 case('off')
2176 call set_performance_monitoring(.false.)
2177 write(output_unit, '(a)') 'Performance monitoring disabled'
2178 case('stats', 'status')
2179 call print_performance_stats()
2180 case('reset')
2181 total_commands = 0
2182 total_parse_time = 0
2183 total_exec_time = 0
2184 total_glob_time = 0
2185 write(output_unit, '(a)') 'Performance counters reset'
2186 case default
2187 write(error_unit, '(a)') 'perf: Usage: perf [on|off|stats|reset]'
2188 shell%last_exit_status = 1
2189 return
2190 end select
2191 else
2192 ! Show current status
2193 if (perf_monitoring_enabled) then
2194 write(output_unit, '(a)') 'Performance monitoring: ENABLED'
2195 else
2196 write(output_unit, '(a)') 'Performance monitoring: DISABLED'
2197 end if
2198 write(output_unit, '(a,i15)') 'Commands processed: ', total_commands
2199 write(output_unit, '(a,i15,a)') 'Memory usage: ', get_memory_usage(), ' KB'
2200 end if
2201
2202 shell%last_exit_status = 0
2203 end subroutine
2204
2205 subroutine builtin_memory(cmd, shell)
2206 type(command_t), intent(in) :: cmd
2207 type(shell_state_t), intent(inout) :: shell
2208
2209 if (cmd%num_tokens > 1) then
2210 select case(trim(cmd%tokens(2)))
2211 case('optimize')
2212 call optimize_memory_pools()
2213 write(output_unit, '(a)') 'Memory pools optimized'
2214 case('stats')
2215 call print_pool_stats()
2216 case('auto')
2217 call auto_optimize_memory()
2218 write(output_unit, '(a)') 'Auto memory optimization triggered'
2219 case default
2220 write(error_unit, '(a)') 'memory: Usage: memory [optimize|stats|auto]'
2221 shell%last_exit_status = 1
2222 return
2223 end select
2224 else
2225 ! Show memory status
2226 write(output_unit, '(a)') 'Memory Usage Summary:'
2227 write(output_unit, '(a)') '===================='
2228 write(output_unit, '(a,i15)') 'Current allocations: ', current_allocations
2229 write(output_unit, '(a,i15)') 'Peak allocations: ', peak_allocations
2230 write(output_unit, '(a,i15,a)') 'Current memory: ', current_memory_used, ' bytes'
2231 write(output_unit, '(a,i15,a)') 'Peak memory: ', peak_memory_used, ' bytes'
2232
2233 if (needs_memory_optimization()) then
2234 write(output_unit, '(a)') ''
2235 write(output_unit, '(a)') 'Tip: Memory optimization recommended. Run "memory optimize"'
2236 end if
2237 end if
2238
2239 shell%last_exit_status = 0
2240 end subroutine
2241
2242 subroutine builtin_rawtest(cmd, shell)
2243 type(command_t), intent(in) :: cmd
2244 type(shell_state_t), intent(inout) :: shell
2245 type(termios_t) :: original_termios
2246 character :: ch
2247 logical :: success
2248 integer :: char_code
2249
2250 if (.false.) print *, cmd%num_tokens ! Silence unused warning
2251
2252 write(output_unit, '(a)') 'Raw mode test - press keys to see codes, q to quit:'
2253 write(output_unit, '(a)') 'Entering raw mode...'
2254
2255 ! Enable raw mode
2256 success = enable_raw_mode(original_termios)
2257 if (.not. success) then
2258 write(error_unit, '(a)') 'rawtest: Failed to enable raw mode'
2259 shell%last_exit_status = 1
2260 return
2261 end if
2262
2263 ! Read characters until 'q' is pressed
2264 do
2265 success = read_single_char(ch)
2266 if (.not. success) exit
2267
2268 char_code = iachar(ch)
2269
2270 ! Exit on 'q'
2271 if (ch == 'q' .or. ch == 'Q') exit
2272
2273 ! Handle special characters
2274 if (char_code == 27) then
2275 ! Escape sequence - try to read more
2276 write(output_unit, '(a)', advance='no') 'ESC '
2277 success = read_single_char(ch)
2278 if (success) then
2279 write(output_unit, '(a,i15)', advance='no') '[', iachar(ch)
2280 if (ch == '[') then
2281 success = read_single_char(ch)
2282 if (success) then
2283 write(output_unit, '(a,i15,a)', advance='no') '[', iachar(ch), '] = '
2284 select case(ch)
2285 case('A')
2286 write(output_unit, '(a)') 'UP ARROW'
2287 case('B')
2288 write(output_unit, '(a)') 'DOWN ARROW'
2289 case('C')
2290 write(output_unit, '(a)') 'RIGHT ARROW'
2291 case('D')
2292 write(output_unit, '(a)') 'LEFT ARROW'
2293 case default
2294 write(output_unit, '(a)') 'UNKNOWN ESCAPE'
2295 end select
2296 end if
2297 else
2298 write(output_unit, '(a)') '] = ALT+key'
2299 end if
2300 end if
2301 else if (char_code < 32) then
2302 ! Control character
2303 write(output_unit, '(a,i15,a)') 'CTRL+', char_code, ' (^', char(char_code + 64), ')'
2304 else if (char_code == 127) then
2305 write(output_unit, '(a)') 'BACKSPACE/DELETE (127)'
2306 else
2307 ! Regular character
2308 write(output_unit, '(a,a,a,i15,a)') 'Regular: ''', ch, ''' (', char_code, ')'
2309 end if
2310 end do
2311
2312 ! Restore terminal
2313 success = restore_terminal(original_termios)
2314 if (.not. success) then
2315 write(error_unit, '(a)') 'rawtest: Warning - failed to restore terminal'
2316 end if
2317
2318 write(output_unit, '(a)') ''
2319 write(output_unit, '(a)') 'Raw mode test completed.'
2320 shell%last_exit_status = 0
2321 end subroutine
2322
2323 subroutine builtin_defun(cmd, shell)
2324 type(command_t), intent(in) :: cmd
2325 type(shell_state_t), intent(inout) :: shell
2326
2327 character(len=:), allocatable :: function_body
2328 character(len=256) :: func_name
2329 integer :: i
2330
2331 if (cmd%num_tokens < 3) then
2332 write(error_unit, '(a)') 'defun: usage: defun function_name "command1; command2"'
2333 shell%last_exit_status = 1
2334 return
2335 end if
2336
2337 func_name = trim(cmd%tokens(2))
2338
2339 ! Reconstruct the function body from all remaining tokens
2340 ! This handles cases where the parser split the quoted string
2341 function_body = trim(cmd%tokens(3))
2342 do i = 4, cmd%num_tokens
2343 function_body = trim(function_body) // ' ' // trim(cmd%tokens(i))
2344 end do
2345
2346 ! Strip quotes from function body
2347 if (len(function_body) >= 2) then
2348 if (function_body(1:1) == '"' .or. function_body(1:1) == "'") then
2349 ! Check if last character is also a quote
2350 if (function_body(len(function_body):len(function_body)) == '"' .or. &
2351 function_body(len(function_body):len(function_body)) == "'") then
2352 ! Remove first and last character (quotes)
2353 function_body = function_body(2:len(function_body)-1)
2354 end if
2355 end if
2356 end if
2357
2358 call add_function(shell, func_name, [function_body], 1)
2359 shell%last_exit_status = 0
2360 end subroutine
2361
2362 ! Coprocess built-in command: coproc [NAME] command [args]
2363 subroutine builtin_coproc(cmd, shell)
2364 use coprocess, only: start_coprocess, coprocs
2365 use variables, only: set_array_element, set_shell_variable
2366 type(command_t), intent(in) :: cmd
2367 type(shell_state_t), intent(inout) :: shell
2368
2369 character(len=256) :: coproc_name, command_str
2370 integer :: coproc_id, i, cmd_start_idx
2371 character(len=16) :: fd_str
2372
2373 ! Default name
2374 coproc_name = 'COPROC'
2375
2376 ! Parse arguments: coproc [NAME] command [args]
2377 if (cmd%num_tokens < 2) then
2378 write(error_unit, '(a)') 'coproc: usage: coproc [NAME] command [args]'
2379 shell%last_exit_status = 1
2380 return
2381 end if
2382
2383 ! Check if first argument is a name (uppercase letters)
2384 if (cmd%num_tokens >= 3 .and. is_valid_coproc_name(cmd%tokens(2))) then
2385 coproc_name = trim(cmd%tokens(2))
2386 cmd_start_idx = 3
2387 else
2388 cmd_start_idx = 2
2389 end if
2390
2391 ! Build command string from remaining tokens
2392 command_str = ''
2393 do i = cmd_start_idx, cmd%num_tokens
2394 if (i > cmd_start_idx) then
2395 command_str = trim(command_str) // ' ' // trim(cmd%tokens(i))
2396 else
2397 command_str = trim(cmd%tokens(i))
2398 end if
2399 end do
2400
2401 ! Start the coprocess
2402 coproc_id = start_coprocess(trim(command_str), trim(coproc_name), shell%is_interactive)
2403
2404 if (coproc_id < 0) then
2405 write(error_unit, '(a)') 'coproc: failed to start coprocess'
2406 shell%last_exit_status = 1
2407 return
2408 end if
2409
2410 ! Set NAME_PID variable (e.g., COPROC_PID)
2411 block
2412 character(len=16) :: pid_str
2413 write(pid_str, '(I0)') coprocs(coproc_id)%pid
2414 call set_shell_variable(shell, &
2415 trim(coproc_name) // '_PID', trim(pid_str))
2416 end block
2417
2418 ! Create array variables: NAME[0] = read_fd, NAME[1] = write_fd
2419 write(fd_str, '(I0)') coprocs(coproc_id)%read_fd
2420 call set_array_element(shell, trim(coproc_name), 1, trim(fd_str)) ! Bash index 0 = Fortran index 1
2421 write(fd_str, '(I0)') coprocs(coproc_id)%write_fd
2422 call set_array_element(shell, trim(coproc_name), 2, trim(fd_str)) ! Bash index 1 = Fortran index 2
2423
2424 shell%last_exit_status = 0
2425 end subroutine
2426
2427 ! Helper: Check if name is valid (uppercase letters/digits/underscore)
2428 function is_valid_coproc_name(name) result(is_valid)
2429 character(len=*), intent(in) :: name
2430 logical :: is_valid
2431 integer :: i
2432 character :: c
2433
2434 is_valid = .false.
2435 if (len_trim(name) == 0) return
2436
2437 ! Name must start with letter or underscore
2438 c = name(1:1)
2439 if (.not. ((c >= 'A' .and. c <= 'Z') .or. c == '_')) return
2440
2441 ! Rest can be letters, digits, or underscore
2442 do i = 2, len_trim(name)
2443 c = name(i:i)
2444 if (.not. ((c >= 'A' .and. c <= 'Z') .or. (c >= '0' .and. c <= '9') .or. c == '_')) return
2445 end do
2446
2447 is_valid = .true.
2448 end function
2449
2450 subroutine builtin_timeout(cmd, shell)
2451 type(command_t), intent(in) :: cmd
2452 type(shell_state_t), intent(inout) :: shell
2453
2454 integer :: timeout_seconds, i
2455 character(len=:), allocatable :: command
2456
2457 if (cmd%num_tokens < 3) then
2458 write(error_unit, '(a)') 'timeout: usage: timeout DURATION COMMAND...'
2459 shell%last_exit_status = 1
2460 return
2461 end if
2462
2463 read(cmd%tokens(2), *, iostat=i) timeout_seconds
2464 if (i /= 0 .or. timeout_seconds <= 0) then
2465 write(error_unit, '(a)') 'timeout: invalid duration'
2466 shell%last_exit_status = 1
2467 return
2468 end if
2469
2470 ! Reconstruct command from remaining tokens
2471 command = ''
2472 do i = 3, cmd%num_tokens
2473 if (i > 3) command = trim(command) // ' '
2474 command = trim(command) // trim(cmd%tokens(i))
2475 end do
2476
2477 ! Execute command with timeout - placeholder
2478 shell%last_exit_status = 0
2479 end subroutine
2480
2481 ! =============================================================================
2482 ! POSIX Required Built-ins (Phase 10: Critical POSIX Compliance)
2483 ! =============================================================================
2484
2485 subroutine builtin_type(cmd, shell)
2486 type(command_t), intent(in) :: cmd
2487 type(shell_state_t), intent(inout) :: shell
2488
2489 character(len=256) :: command_name
2490 character(len=:), allocatable :: full_path
2491 integer :: i
2492 logical :: any_not_found
2493
2494 if (cmd%num_tokens < 2) then
2495 write(error_unit, '(a)') 'type: usage: type name [name ...]'
2496 shell%last_exit_status = 1
2497 return
2498 end if
2499
2500 any_not_found = .false.
2501
2502 do i = 2, cmd%num_tokens
2503 command_name = trim(cmd%tokens(i))
2504
2505 if (is_builtin(command_name)) then
2506 write(output_unit, '(a)') trim(command_name) // ' is a shell builtin'
2507 else if (is_alias(shell, command_name)) then
2508 write(output_unit, '(a)') trim(command_name) // ' is aliased to `' // &
2509 trim(get_alias(shell, command_name)) // "'"
2510 else if (is_function(shell, command_name)) then
2511 write(output_unit, '(a)') trim(command_name) // ' is a function'
2512 else
2513 ! Try to find in PATH
2514 allocate(character(len=MAX_PATH_LEN) :: full_path)
2515 if (find_executable_in_path(shell, command_name, full_path)) then
2516 write(output_unit, '(a)') trim(command_name) // ' is ' // trim(full_path)
2517 else
2518 write(error_unit, '(a)') trim(command_name) // ': not found'
2519 any_not_found = .true.
2520 end if
2521 end if
2522 end do
2523
2524 if (any_not_found) then
2525 shell%last_exit_status = 1
2526 else
2527 shell%last_exit_status = 0
2528 end if
2529 end subroutine
2530
2531 subroutine builtin_unset(cmd, shell)
2532 use ast_executor, only: unset_ast_function
2533 type(command_t), intent(in) :: cmd
2534 type(shell_state_t), intent(inout) :: shell
2535
2536 logical :: unset_functions
2537 character(len=256) :: var_name
2538 integer :: i, j, start_idx
2539
2540 unset_functions = .false.
2541
2542 if (cmd%num_tokens < 2) then
2543 write(error_unit, '(a)') 'unset: usage: unset [-f] name [name ...]'
2544 shell%last_exit_status = 1
2545 return
2546 end if
2547
2548 start_idx = 2
2549 if (trim(cmd%tokens(2)) == '-f') then
2550 unset_functions = .true.
2551 start_idx = 3
2552 if (cmd%num_tokens < 3) then
2553 write(error_unit, '(a)') 'unset: usage: unset [-f] name [name ...]'
2554 shell%last_exit_status = 1
2555 return
2556 end if
2557 end if
2558
2559 do i = start_idx, cmd%num_tokens
2560 var_name = trim(cmd%tokens(i))
2561
2562 if (unset_functions) then
2563 ! Unset function from both old and new function storage
2564
2565 ! Clear from old executor's function storage
2566 do j = 1, shell%num_functions
2567 if (trim(shell%functions(j)%name) == var_name) then
2568 shell%functions(j)%name = ''
2569 shell%functions(j)%body_lines = 0
2570 if (allocated(shell%functions(j)%body)) deallocate(shell%functions(j)%body)
2571 exit
2572 end if
2573 end do
2574
2575 ! Clear from AST executor's function cache
2576 call unset_ast_function(var_name)
2577 else
2578 ! Check for array element syntax: arr[index]
2579 block
2580 integer :: bracket_pos, bracket_end, arr_idx, iostat_v
2581 character(len=256) :: arr_name, idx_str
2582 bracket_pos = index(var_name, '[')
2583 if (bracket_pos > 0) then
2584 bracket_end = index(var_name, ']')
2585 if (bracket_end > bracket_pos) then
2586 arr_name = var_name(:bracket_pos-1)
2587 idx_str = var_name(bracket_pos+1:bracket_end-1)
2588 ! Check for associative array first
2589 if (is_associative_array(shell, trim(arr_name))) then
2590 call unset_assoc_array_key(shell, &
2591 trim(arr_name), trim(idx_str))
2592 else
2593 read(idx_str, *, iostat=iostat_v) arr_idx
2594 if (iostat_v == 0) then
2595 arr_idx = arr_idx + 1 ! 0→1 based
2596 call set_array_element(shell, &
2597 trim(arr_name), arr_idx, '')
2598 end if
2599 end if
2600 cycle
2601 end if
2602 end if
2603 end block
2604
2605 ! Unset variable - check local scope first
2606 block
2607 logical :: found_local
2608 integer :: lv_depth, lv_i
2609 found_local = .false.
2610 if (shell%function_depth > 0) then
2611 lv_depth = shell%function_depth
2612 if (lv_depth <= size(shell%local_var_counts)) then
2613 do lv_i = 1, shell%local_var_counts(lv_depth)
2614 if (trim(shell%local_vars(lv_depth, lv_i)%name) &
2615 == trim(var_name)) then
2616 if (shell%local_vars(lv_depth, lv_i)%readonly) then
2617 write(error_unit, '(a)') 'unset: ' // &
2618 trim(var_name) // &
2619 ': cannot unset readonly variable'
2620 shell%last_exit_status = 1
2621 return
2622 end if
2623 ! Mark local variable as unset (value_len=-1 sentinel)
2624 ! This keeps it shadowing the global but treated as unset
2625 shell%local_vars(lv_depth, lv_i)%value = ''
2626 shell%local_vars(lv_depth, lv_i)%value_len = -1
2627 ! Special handling: restore default IFS when unset
2628 if (trim(var_name) == 'IFS') then
2629 shell%ifs = ' ' // char(9) // char(10)
2630 shell%ifs_len = -1
2631 end if
2632 found_local = .true.
2633 exit
2634 end if
2635 end do
2636 end if
2637 end if
2638 if (.not. found_local) then
2639 do j = 1, shell%num_variables
2640 if (trim(shell%variables(j)%name) == var_name) then
2641 ! Check if variable is readonly
2642 if (shell%variables(j)%readonly) then
2643 write(error_unit, '(a)') 'unset: ' // &
2644 trim(var_name) // &
2645 ': cannot unset readonly variable'
2646 shell%last_exit_status = 1
2647 return
2648 end if
2649 ! Unset from environment if exported
2650 if (shell%variables(j)%exported) then
2651 call unset_environment_var(var_name)
2652 end if
2653 shell%variables(j)%name = ''
2654 shell%variables(j)%value = ''
2655 shell%variables(j)%is_array = .false.
2656 shell%variables(j)%is_assoc_array = .false.
2657 shell%variables(j)%readonly = .false.
2658 shell%variables(j)%exported = .false.
2659 shell%variables(j)%array_size = 0
2660 shell%variables(j)%assoc_size = 0
2661 ! Special handling: restore default IFS when unset
2662 if (trim(var_name) == 'IFS') then
2663 shell%ifs = ' ' // char(9) // char(10)
2664 shell%ifs_len = -1
2665 end if
2666 exit
2667 end if
2668 end do
2669 end if
2670 end block
2671 end if
2672 end do
2673
2674 shell%last_exit_status = 0
2675 end subroutine
2676
2677 subroutine builtin_readonly(cmd, shell)
2678 use variables, only: set_shell_variable
2679 type(command_t), intent(in) :: cmd
2680 type(shell_state_t), intent(inout) :: shell
2681 integer :: eq_pos, i, j, arg_idx
2682 character(len=MAX_TOKEN_LEN) :: var_name, var_value
2683 logical :: print_mode, found
2684
2685 print_mode = .false.
2686 arg_idx = 2
2687
2688 ! Parse -p flag
2689 if (cmd%num_tokens >= 2 .and. trim(cmd%tokens(2)) == '-p') then
2690 print_mode = .true.
2691 arg_idx = 3
2692 end if
2693
2694 if (cmd%num_tokens < 2) then
2695 ! No arguments: print all readonly variables
2696 print_mode = .true.
2697 end if
2698
2699 if (print_mode) then
2700 ! Print all readonly variables (including special readonly params)
2701 ! Match bash behavior: include PPID, UID, EUID, and shell options
2702 block
2703 use system_interface, only: c_getuid, c_geteuid
2704 character(len=20) :: ppid_str, uid_str, euid_str
2705 character(len=256) :: shellopts
2706
2707 ! PPID - parent process ID
2708 write(ppid_str, '(i0)') shell%parent_pid
2709 write(output_unit, '(a)') 'readonly PPID=' // trim(ppid_str)
2710
2711 ! UID - real user ID
2712 write(uid_str, '(i0)') c_getuid()
2713 write(output_unit, '(a)') 'readonly UID=' // trim(uid_str)
2714
2715 ! EUID - effective user ID
2716 write(euid_str, '(i0)') c_geteuid()
2717 write(output_unit, '(a)') 'readonly EUID=' // trim(euid_str)
2718
2719 ! SHELLOPTS - shell option settings (bash compatibility)
2720 shellopts = ''
2721 if (shell%option_braceexpand) shellopts = trim(shellopts) // ':braceexpand'
2722 if (shell%option_hashall) shellopts = trim(shellopts) // ':hashall'
2723 shellopts = trim(shellopts) // ':interactive-comments' ! Always on
2724 if (len_trim(shellopts) > 0 .and. shellopts(1:1) == ':') shellopts = shellopts(2:)
2725 write(output_unit, '(a)') 'readonly SHELLOPTS="' // trim(shellopts) // '"'
2726
2727 ! FORTSH_VERSION - shell version
2728 write(output_unit, '(a)') 'readonly FORTSH_VERSION="0.1.0"'
2729
2730 ! HOSTNAME - system hostname (bash compatibility)
2731 write(output_unit, '(a)') 'readonly HOSTNAME="' // trim(shell%hostname) // '"'
2732 end block
2733 ! Print user-defined readonly variables in declare -r format
2734 do i = 1, shell%num_variables
2735 if (shell%variables(i)%readonly .and. len_trim(shell%variables(i)%name) > 0) then
2736 write(output_unit, '(a)') 'declare -r ' // trim(shell%variables(i)%name) // '="' // &
2737 trim(shell%variables(i)%value) // '"'
2738 end if
2739 end do
2740 shell%last_exit_status = 0
2741 return
2742 end if
2743
2744 ! Process each argument
2745 do arg_idx = 2, cmd%num_tokens
2746 eq_pos = index(cmd%tokens(arg_idx), '=')
2747
2748 if (eq_pos > 0) then
2749 ! VAR=value form - set and mark readonly
2750 var_name = cmd%tokens(arg_idx)(:eq_pos-1)
2751 var_value = cmd%tokens(arg_idx)(eq_pos+1:)
2752
2753 ! Check if variable already exists and is readonly
2754 found = .false.
2755 do j = 1, shell%num_variables
2756 if (trim(shell%variables(j)%name) == trim(var_name)) then
2757 if (shell%variables(j)%readonly) then
2758 write(error_unit, '(a)') trim(var_name) // ': readonly variable'
2759 shell%last_exit_status = 1
2760 return
2761 end if
2762 found = .true.
2763 exit
2764 end if
2765 end do
2766
2767 ! Set the variable
2768 call set_shell_variable(shell, trim(var_name), trim(var_value))
2769
2770 ! Mark as readonly
2771 do j = 1, shell%num_variables
2772 if (trim(shell%variables(j)%name) == trim(var_name)) then
2773 shell%variables(j)%readonly = .true.
2774 exit
2775 end if
2776 end do
2777 else
2778 ! Just VAR - mark existing variable as readonly
2779 var_name = trim(cmd%tokens(arg_idx))
2780 found = .false.
2781
2782 do j = 1, shell%num_variables
2783 if (trim(shell%variables(j)%name) == var_name) then
2784 if (shell%variables(j)%readonly) then
2785 write(error_unit, '(a)') trim(var_name) // ': readonly variable'
2786 shell%last_exit_status = 1
2787 return
2788 end if
2789 shell%variables(j)%readonly = .true.
2790 found = .true.
2791 exit
2792 end if
2793 end do
2794
2795 if (.not. found) then
2796 ! Variable doesn't exist, create it with empty value and mark readonly
2797 call set_shell_variable(shell, var_name, '')
2798 do j = 1, shell%num_variables
2799 if (trim(shell%variables(j)%name) == var_name) then
2800 shell%variables(j)%readonly = .true.
2801 exit
2802 end if
2803 end do
2804 end if
2805 end if
2806 end do
2807
2808 shell%last_exit_status = 0
2809 end subroutine
2810
2811 subroutine builtin_local(cmd, shell)
2812 type(command_t), intent(in) :: cmd
2813 type(shell_state_t), intent(inout) :: shell
2814 integer :: i, eq_pos, depth, var_index, fi, start_arg
2815 character(len=256) :: var_name
2816 character(len=:), allocatable :: var_value
2817 logical :: integer_flag, readonly_flag, array_flag
2818 character(len=MAX_TOKEN_LEN) :: flag_str
2819
2820 ! Check if we're inside a function
2821 if (shell%function_depth == 0) then
2822 write(error_unit, '(a)') 'local: can only be used in a function'
2823 shell%last_exit_status = 1
2824 return
2825 end if
2826
2827 ! Check depth is within bounds
2828 depth = shell%function_depth
2829 if (depth > size(shell%local_var_counts)) then
2830 write(error_unit, '(a)') 'local: function nesting too deep'
2831 shell%last_exit_status = 1
2832 return
2833 end if
2834
2835 ! Parse flags
2836 integer_flag = .false.
2837 readonly_flag = .false.
2838 array_flag = .false.
2839 start_arg = 2
2840 do while (start_arg <= cmd%num_tokens)
2841 if (cmd%tokens(start_arg)(1:1) == '-' .and. len_trim(cmd%tokens(start_arg)) >= 2 .and. &
2842 index(cmd%tokens(start_arg), '=') == 0) then
2843 flag_str = trim(cmd%tokens(start_arg))
2844 do fi = 2, len_trim(flag_str)
2845 select case (flag_str(fi:fi))
2846 case ('i'); integer_flag = .true.
2847 case ('r'); readonly_flag = .true.
2848 case ('a'); array_flag = .true.
2849 case default
2850 ! Ignore unknown flags
2851 end select
2852 end do
2853 start_arg = start_arg + 1
2854 else
2855 exit
2856 end if
2857 end do
2858
2859 ! Process each variable assignment
2860 do i = start_arg, cmd%num_tokens
2861 eq_pos = index(cmd%tokens(i), '=')
2862
2863 if (eq_pos > 0) then
2864 ! Variable assignment: local var=value
2865 var_name = cmd%tokens(i)(:eq_pos-1)
2866 var_value = cmd%tokens(i)(eq_pos+1:)
2867
2868 ! Handle array initialization: local -a arr=(a b c)
2869 if (array_flag .and. len_trim(var_value) > 0 .and. &
2870 var_value(1:1) == '(') then
2871 block
2872 use variables, only: set_array_variable
2873 character(len=256) :: arr_elems(100)
2874 integer :: ne, k, es
2875 character(len=:), allocatable :: content
2876 content = trim(var_value)
2877 if (content(len(content):len(content)) == ')') then
2878 content = content(2:len(content)-1)
2879 else
2880 content = content(2:)
2881 end if
2882 ne = 0
2883 es = 1
2884 do k = 1, len_trim(content)
2885 if (content(k:k) == ' ') then
2886 if (k > es) then
2887 ne = ne + 1
2888 arr_elems(ne) = content(es:k-1)
2889 end if
2890 es = k + 1
2891 end if
2892 end do
2893 if (es <= len_trim(content)) then
2894 ne = ne + 1
2895 arr_elems(ne) = content(es:len_trim(content))
2896 end if
2897 call set_array_variable(shell, trim(var_name), &
2898 arr_elems, ne)
2899 ! Track array in local_vars for cleanup on function return
2900 var_index = shell%local_var_counts(depth) + 1
2901 if (var_index <= size(shell%local_vars, 2)) then
2902 shell%local_var_counts(depth) = var_index
2903 shell%local_vars(depth, var_index)%name = trim(var_name)
2904 shell%local_vars(depth, var_index)%value = ''
2905 shell%local_vars(depth, var_index)%value_len = 0
2906 shell%local_vars(depth, var_index)%is_array = .true.
2907 end if
2908 end block
2909 cycle
2910 end if
2911
2912 ! Evaluate arithmetic if integer flag is set
2913 if (integer_flag .and. len_trim(var_value) > 0) then
2914 block
2915 use expansion, only: arithmetic_expansion_shell
2916 character(len=:), allocatable :: arith_expr, arith_result
2917 arith_expr = '$((' // trim(var_value) // '))'
2918 arith_result = trim(arithmetic_expansion_shell( &
2919 trim(arith_expr), shell))
2920 var_value = arith_result
2921 end block
2922 end if
2923
2924 ! Find or create local variable slot
2925 var_index = shell%local_var_counts(depth) + 1
2926 if (var_index > size(shell%local_vars, 2)) then
2927 write(error_unit, '(a)') 'local: too many local variables'
2928 shell%last_exit_status = 1
2929 return
2930 end if
2931
2932 ! Store local variable
2933 shell%local_vars(depth, var_index)%name = var_name
2934 shell%local_vars(depth, var_index)%value = var_value
2935 shell%local_vars(depth, var_index)%readonly = readonly_flag
2936 shell%local_vars(depth, var_index)%exported = .false.
2937 shell%local_vars(depth, var_index)%is_integer = integer_flag
2938 shell%local_var_counts(depth) = var_index
2939
2940 ! Special handling: IFS needs shell state update for word splitting
2941 if (trim(var_name) == 'IFS') then
2942 shell%ifs = trim(var_value)
2943 shell%ifs_len = len_trim(var_value)
2944 end if
2945 else
2946 ! Just declare local: local var (unset or empty)
2947 var_name = trim(cmd%tokens(i))
2948
2949 var_index = shell%local_var_counts(depth) + 1
2950 if (var_index > size(shell%local_vars, 2)) then
2951 write(error_unit, '(a)') 'local: too many local variables'
2952 shell%last_exit_status = 1
2953 return
2954 end if
2955
2956 shell%local_vars(depth, var_index)%name = var_name
2957 shell%local_vars(depth, var_index)%value = ''
2958 shell%local_vars(depth, var_index)%readonly = .false.
2959 shell%local_vars(depth, var_index)%exported = .false.
2960 shell%local_var_counts(depth) = var_index
2961 end if
2962 end do
2963
2964 shell%last_exit_status = 0
2965 end subroutine
2966
2967 subroutine builtin_shift(cmd, shell)
2968 type(command_t), intent(in) :: cmd
2969 type(shell_state_t), intent(inout) :: shell
2970 integer :: shift_count, iostat
2971
2972 shift_count = 1 ! Default shift by 1
2973
2974 if (cmd%num_tokens > 1) then
2975 ! Parse shift count from argument
2976 read(cmd%tokens(2), *, iostat=iostat) shift_count
2977 if (iostat /= 0) then
2978 write(error_unit, '(a)') 'shift: numeric argument required'
2979 shell%last_exit_status = 1
2980 return
2981 end if
2982 end if
2983
2984 if (shift_count < 0) then
2985 write(error_unit, '(a)') 'shift: shift count out of range'
2986 shell%last_exit_status = 1
2987 return
2988 end if
2989
2990 if (shift_count > shell%num_positional) then
2991 write(error_unit, '(a)') 'shift: shift count out of range'
2992 shell%last_exit_status = 1
2993 return
2994 end if
2995
2996 call shift_positional_params(shell, shift_count)
2997 shell%last_exit_status = 0
2998 end subroutine
2999
3000 subroutine builtin_break(cmd, shell)
3001 type(command_t), intent(in) :: cmd
3002 type(shell_state_t), intent(inout) :: shell
3003 integer :: break_count, i, iostat
3004 logical :: invalid_count
3005
3006 ! Default to breaking 1 level
3007 break_count = 1
3008 invalid_count = .false.
3009
3010 ! Parse optional numeric argument
3011 if (cmd%num_tokens > 1) then
3012 read(cmd%tokens(2), *, iostat=iostat) break_count
3013 if (iostat /= 0) then
3014 write(error_unit, '(a)') 'break: invalid number'
3015 shell%last_exit_status = 1
3016 return
3017 end if
3018 if (break_count < 1) then
3019 ! POSIX: behavior for n < 1 is unspecified, silently treat as 1
3020 invalid_count = .true.
3021 break_count = 1
3022 end if
3023 end if
3024
3025 ! Find the nearest loop and set break flag
3026 do i = shell%control_depth, 1, -1
3027 if (shell%control_stack(i)%block_type == BLOCK_FOR .or. &
3028 shell%control_stack(i)%block_type == BLOCK_WHILE .or. &
3029 shell%control_stack(i)%block_type == BLOCK_UNTIL .or. &
3030 shell%control_stack(i)%block_type == BLOCK_FOR_ARITH) then
3031 ! POSIX: if break is already requested, don't change exit status
3032 ! This preserves the status from the first break command (e.g., "break 0 || break")
3033 if (.not. shell%control_stack(i)%break_requested) then
3034 shell%control_stack(i)%break_requested = .true.
3035 shell%control_stack(i)%break_level = break_count
3036 ! POSIX: invalid count still breaks loop, but with exit status 1
3037 if (invalid_count) then
3038 shell%last_exit_status = 1
3039 else
3040 shell%last_exit_status = 0
3041 end if
3042 end if
3043 return
3044 end if
3045 end do
3046
3047 ! No loop found - POSIX says behavior is unspecified
3048 ! For maximum compatibility, silently return success (like POSIX sh)
3049 shell%last_exit_status = 0
3050 end subroutine
3051
3052 subroutine builtin_continue(cmd, shell)
3053 type(command_t), intent(in) :: cmd
3054 type(shell_state_t), intent(inout) :: shell
3055 integer :: continue_count, i, iostat
3056 logical :: invalid_count
3057
3058 ! Default to continuing 1 level
3059 continue_count = 1
3060 invalid_count = .false.
3061
3062 ! Parse optional numeric argument
3063 if (cmd%num_tokens > 1) then
3064 read(cmd%tokens(2), *, iostat=iostat) continue_count
3065 if (iostat /= 0) then
3066 write(error_unit, '(a)') 'continue: invalid number'
3067 shell%last_exit_status = 1
3068 return
3069 end if
3070 if (continue_count < 1) then
3071 ! POSIX: behavior for n < 1 is unspecified, silently treat as 1
3072 invalid_count = .true.
3073 continue_count = 1
3074 end if
3075 end if
3076
3077 ! Find the nearest loop and set continue flag
3078 do i = shell%control_depth, 1, -1
3079 if (shell%control_stack(i)%block_type == BLOCK_FOR .or. &
3080 shell%control_stack(i)%block_type == BLOCK_WHILE .or. &
3081 shell%control_stack(i)%block_type == BLOCK_UNTIL .or. &
3082 shell%control_stack(i)%block_type == BLOCK_FOR_ARITH) then
3083 ! POSIX: if continue is already requested, don't change exit status
3084 ! This preserves the status from the first continue command (e.g., "continue 0 || continue")
3085 if (.not. shell%control_stack(i)%continue_requested) then
3086 shell%control_stack(i)%continue_requested = .true.
3087 shell%control_stack(i)%continue_level = continue_count
3088 ! POSIX: invalid count still continues loop, but with exit status 1
3089 if (invalid_count) then
3090 shell%last_exit_status = 1
3091 else
3092 shell%last_exit_status = 0
3093 end if
3094 end if
3095 return
3096 end if
3097 end do
3098
3099 ! No loop found - POSIX says behavior is unspecified
3100 ! For maximum compatibility, silently return success (like POSIX sh)
3101 shell%last_exit_status = 0
3102 end subroutine
3103
3104 subroutine builtin_return(cmd, shell)
3105 type(command_t), intent(in) :: cmd
3106 type(shell_state_t), intent(inout) :: shell
3107 integer :: return_code, iostat
3108
3109 ! POSIX: return outside function/sourced script should fail
3110 ! Return silently with exit status 2 (like bash)
3111 if (shell%function_depth == 0 .and. shell%source_depth == 0) then
3112 shell%last_exit_status = 2
3113 return
3114 end if
3115
3116 ! Default to last command's exit status
3117 return_code = shell%last_exit_status
3118
3119 ! Parse optional return value argument
3120 if (cmd%num_tokens > 1) then
3121 read(cmd%tokens(2), *, iostat=iostat) return_code
3122 if (iostat /= 0) then
3123 write(error_unit, '(a)') 'return: numeric argument required'
3124 shell%last_exit_status = 2
3125 return
3126 end if
3127 end if
3128
3129 ! Set the return value and flag to exit function
3130 shell%function_return_value = return_code
3131 shell%last_exit_status = return_code
3132 shell%function_return_pending = .true.
3133 end subroutine
3134
3135 subroutine builtin_exec(cmd, shell)
3136 use command_builtin, only: find_command_full_path
3137 use fd_redirection, only: apply_single_redirection
3138 use parser, only: expand_variables
3139 use system_interface, only: file_exists, file_is_executable
3140 type(command_t), intent(in) :: cmd
3141 type(shell_state_t), intent(inout) :: shell
3142
3143 character(len=256), target :: c_prog_name
3144 character(len=256), target, allocatable :: c_args(:)
3145 type(c_ptr), allocatable, target :: argv(:)
3146 integer :: i, ret
3147 character(len=MAX_PATH_LEN) :: prog_path
3148 logical :: redir_success
3149 type(redirection_t) :: expanded_redir
3150 character(len=:), allocatable :: expanded_filename
3151
3152 ! exec without arguments but with redirections applies them to the current shell
3153 if (cmd%num_tokens < 2) then
3154 if (cmd%num_redirections > 0) then
3155 ! Apply redirections to the current shell process (permanent=.true. for exec)
3156 do i = 1, cmd%num_redirections
3157 ! Make a copy of the redirection and expand the filename
3158 expanded_redir = cmd%redirections(i)
3159 if (allocated(cmd%redirections(i)%filename)) then
3160 call expand_variables(trim(cmd%redirections(i)%filename), expanded_filename, shell)
3161 if (allocated(expanded_filename)) then
3162 expanded_redir%filename = expanded_filename
3163 end if
3164 end if
3165 call apply_single_redirection(expanded_redir, redir_success, shell%option_noclobber, permanent=.true.)
3166 if (.not. redir_success) then
3167 shell%last_exit_status = 1
3168 return
3169 end if
3170 end do
3171 shell%last_exit_status = 0
3172 return
3173 else
3174 ! No command and no redirections - just return success
3175 shell%last_exit_status = 0
3176 return
3177 end if
3178 end if
3179
3180 ! Get the command name
3181 c_prog_name = trim(cmd%tokens(2)) // c_null_char
3182
3183 ! Find full path for the command (if it's not an absolute/relative path)
3184 if (index(cmd%tokens(2), '/') == 0) then
3185 prog_path = find_command_full_path(trim(cmd%tokens(2)))
3186 if (len_trim(prog_path) == 0) then
3187 write(error_unit, '(a)') 'exec: ' // trim(cmd%tokens(2)) // ': command not found'
3188 shell%last_exit_status = 127
3189 return
3190 end if
3191 c_prog_name = trim(prog_path) // c_null_char
3192 else
3193 ! Absolute or relative path - check if it exists
3194 if (.not. file_exists(trim(cmd%tokens(2)))) then
3195 write(error_unit, '(a)') 'exec: ' // trim(cmd%tokens(2)) // ': No such file or directory'
3196 shell%last_exit_status = 127
3197 return
3198 end if
3199 ! Check if it's executable
3200 if (.not. file_is_executable(trim(cmd%tokens(2)))) then
3201 write(error_unit, '(a)') 'exec: ' // trim(cmd%tokens(2)) // ': Permission denied'
3202 shell%last_exit_status = 126
3203 return
3204 end if
3205 end if
3206
3207 ! Build argv array for execvp (NULL-terminated array of C string pointers)
3208 ! argv[0] is the program name, argv[1..n-1] are arguments, argv[n] is NULL
3209 allocate(c_args(cmd%num_tokens - 1))
3210 allocate(argv(cmd%num_tokens))
3211
3212 ! First argument is program name
3213 c_args(1) = trim(cmd%tokens(2)) // c_null_char
3214 argv(1) = c_loc(c_args(1))
3215
3216 ! Copy remaining arguments
3217 do i = 3, cmd%num_tokens
3218 c_args(i - 1) = trim(cmd%tokens(i)) // c_null_char
3219 argv(i - 1) = c_loc(c_args(i - 1))
3220 end do
3221
3222 ! NULL-terminate the argv array
3223 argv(cmd%num_tokens) = c_null_ptr
3224
3225 ! Apply any redirections before exec
3226 if (cmd%num_redirections > 0) then
3227 do i = 1, cmd%num_redirections
3228 call apply_single_redirection(cmd%redirections(i), redir_success, shell%option_noclobber)
3229 if (.not. redir_success) then
3230 shell%last_exit_status = 1
3231 return
3232 end if
3233 end do
3234 end if
3235
3236 ! Replace the current process with the new command
3237 ! If execvp succeeds, this function never returns
3238 ret = c_execvp(c_loc(c_prog_name), c_loc(argv))
3239
3240 ! If we reach here, execvp failed
3241 ! Clean up allocations
3242 deallocate(c_args)
3243 deallocate(argv)
3244
3245 ! Report error
3246 write(error_unit, '(a)') 'exec: ' // trim(cmd%tokens(2)) // ': cannot execute'
3247 shell%last_exit_status = 126
3248 end subroutine
3249
3250 subroutine builtin_eval(cmd, shell)
3251 use eval_builtin, only: execute_eval
3252 type(command_t), intent(in) :: cmd
3253 type(shell_state_t), intent(inout) :: shell
3254
3255 ! Delegate to the eval_builtin module to avoid circular dependency
3256 call execute_eval(cmd, shell)
3257 end subroutine
3258
3259 subroutine builtin_hash(cmd, shell)
3260 use command_builtin, only: find_command_full_path
3261 type(command_t), intent(in) :: cmd
3262 type(shell_state_t), intent(inout) :: shell
3263 character(len=256) :: cmd_name, pathname
3264 integer :: i, j
3265 logical :: remove_mode, list_mode, path_mode, delete_mode
3266 character(len=MAX_PATH_LEN) :: found_path
3267
3268 remove_mode = .false.
3269 list_mode = .false.
3270 path_mode = .false.
3271 delete_mode = .false.
3272
3273 ! Parse options
3274 if (cmd%num_tokens > 1) then
3275 if (trim(cmd%tokens(2)) == '-r') then
3276 ! Clear hash table
3277 shell%num_hashed_commands = 0
3278 do i = 1, size(shell%command_hash)
3279 shell%command_hash(i)%command_name = ''
3280 shell%command_hash(i)%full_path = ''
3281 shell%command_hash(i)%hits = 0
3282 end do
3283 shell%last_exit_status = 0
3284 return
3285 else if (trim(cmd%tokens(2)) == '-l') then
3286 list_mode = .true.
3287 else if (trim(cmd%tokens(2)) == '-d') then
3288 delete_mode = .true.
3289 if (cmd%num_tokens < 3) then
3290 write(error_unit, '(a)') 'hash: -d requires an argument'
3291 shell%last_exit_status = 1
3292 return
3293 end if
3294 else if (trim(cmd%tokens(2)) == '-t') then
3295 ! Print cached path for specified commands
3296 if (cmd%num_tokens < 3) then
3297 write(error_unit, '(a)') 'hash: -t requires an argument'
3298 shell%last_exit_status = 1
3299 return
3300 end if
3301 shell%last_exit_status = 0
3302 do i = 3, cmd%num_tokens
3303 block
3304 logical :: found_in_hash
3305 found_in_hash = .false.
3306 do j = 1, shell%num_hashed_commands
3307 if (trim(shell%command_hash(j)%command_name) == trim(cmd%tokens(i))) then
3308 write(output_unit, '(a)') trim(shell%command_hash(j)%full_path)
3309 found_in_hash = .true.
3310 exit
3311 end if
3312 end do
3313 if (.not. found_in_hash) then
3314 write(error_unit, '(a,a,a)') 'hash: ', trim(cmd%tokens(i)), ': not found'
3315 shell%last_exit_status = 1
3316 end if
3317 end block
3318 end do
3319 flush(output_unit)
3320 return
3321 else if (trim(cmd%tokens(2)) == '-p') then
3322 path_mode = .true.
3323 if (cmd%num_tokens < 4) then
3324 write(error_unit, '(a)') 'hash: usage: hash -p pathname name'
3325 shell%last_exit_status = 1
3326 return
3327 end if
3328 end if
3329 end if
3330
3331 ! hash with no arguments - display hash table
3332 if (cmd%num_tokens == 1) then
3333 if (shell%num_hashed_commands == 0) then
3334 write(output_unit, '(a)') 'hash: hash table empty'
3335 flush(output_unit)
3336 shell%last_exit_status = 0
3337 return
3338 end if
3339 write(output_unit, '(a,a1,a)') 'hits', achar(9), 'command'
3340 do i = 1, shell%num_hashed_commands
3341 if (len_trim(shell%command_hash(i)%command_name) > 0) then
3342 write(output_unit, '(i4,a1,a)') shell%command_hash(i)%hits, achar(9), &
3343 trim(shell%command_hash(i)%full_path)
3344 end if
3345 end do
3346 shell%last_exit_status = 0
3347 return
3348 end if
3349
3350 ! hash -l - list format
3351 if (list_mode) then
3352 do i = 1, shell%num_hashed_commands
3353 if (len_trim(shell%command_hash(i)%command_name) > 0) then
3354 write(output_unit, '(a)') 'builtin hash -p ' // &
3355 trim(shell%command_hash(i)%full_path) // ' ' // &
3356 trim(shell%command_hash(i)%command_name)
3357 end if
3358 end do
3359 shell%last_exit_status = 0
3360 return
3361 end if
3362
3363 ! hash -d name - delete specific command
3364 if (delete_mode) then
3365 cmd_name = trim(cmd%tokens(3))
3366 do i = 1, shell%num_hashed_commands
3367 if (trim(shell%command_hash(i)%command_name) == cmd_name) then
3368 ! Remove this entry by shifting others down
3369 do j = i, shell%num_hashed_commands - 1
3370 shell%command_hash(j) = shell%command_hash(j + 1)
3371 end do
3372 shell%command_hash(shell%num_hashed_commands)%command_name = ''
3373 shell%command_hash(shell%num_hashed_commands)%full_path = ''
3374 shell%command_hash(shell%num_hashed_commands)%hits = 0
3375 shell%num_hashed_commands = shell%num_hashed_commands - 1
3376 shell%last_exit_status = 0
3377 return
3378 end if
3379 end do
3380 ! Silently fail (POSIX compatible behavior)
3381 shell%last_exit_status = 1
3382 return
3383 end if
3384
3385 ! hash -p pathname name - add with explicit path
3386 if (path_mode) then
3387 pathname = trim(cmd%tokens(3))
3388 cmd_name = trim(cmd%tokens(4))
3389
3390 ! Check if command already exists
3391 do i = 1, shell%num_hashed_commands
3392 if (trim(shell%command_hash(i)%command_name) == cmd_name) then
3393 shell%command_hash(i)%full_path = pathname
3394 shell%last_exit_status = 0
3395 return
3396 end if
3397 end do
3398
3399 ! Add new entry
3400 if (shell%num_hashed_commands < size(shell%command_hash)) then
3401 shell%num_hashed_commands = shell%num_hashed_commands + 1
3402 shell%command_hash(shell%num_hashed_commands)%command_name = cmd_name
3403 shell%command_hash(shell%num_hashed_commands)%full_path = pathname
3404 shell%command_hash(shell%num_hashed_commands)%hits = 0
3405 shell%last_exit_status = 0
3406 else
3407 write(error_unit, '(a)') 'hash: hash table full'
3408 shell%last_exit_status = 1
3409 end if
3410 return
3411 end if
3412
3413 ! hash name [name...] - add commands to hash table
3414 do i = 2, cmd%num_tokens
3415 cmd_name = trim(cmd%tokens(i))
3416
3417 ! Search PATH for command
3418 found_path = find_command_full_path(cmd_name)
3419 if (len_trim(found_path) == 0) then
3420 write(error_unit, '(a)') 'hash: ' // trim(cmd_name) // ': not found'
3421 shell%last_exit_status = 1
3422 cycle
3423 end if
3424
3425 ! Check if command already exists
3426 do j = 1, shell%num_hashed_commands
3427 if (trim(shell%command_hash(j)%command_name) == cmd_name) then
3428 shell%command_hash(j)%full_path = found_path
3429 shell%last_exit_status = 0
3430 goto 100 ! Skip to next command
3431 end if
3432 end do
3433
3434 ! Add new entry
3435 if (shell%num_hashed_commands < size(shell%command_hash)) then
3436 shell%num_hashed_commands = shell%num_hashed_commands + 1
3437 shell%command_hash(shell%num_hashed_commands)%command_name = cmd_name
3438 shell%command_hash(shell%num_hashed_commands)%full_path = found_path
3439 shell%command_hash(shell%num_hashed_commands)%hits = 0
3440 else
3441 write(error_unit, '(a)') 'hash: hash table full'
3442 shell%last_exit_status = 1
3443 end if
3444
3445 100 continue
3446 end do
3447
3448 ! Don't reset exit status if an error occurred during processing
3449 ! (e.g., command not found)
3450 end subroutine
3451
3452 subroutine builtin_umask(cmd, shell)
3453 type(command_t), intent(in) :: cmd
3454 type(shell_state_t), intent(inout) :: shell
3455 integer(c_int) :: current_mask, new_mask, temp_mask
3456 integer :: new_mask_int, iostat
3457 logical :: symbolic_mode, print_mode
3458 character(len=16) :: mask_str
3459
3460 symbolic_mode = .false.
3461 print_mode = .false.
3462
3463 ! Parse options
3464 if (cmd%num_tokens > 1) then
3465 if (trim(cmd%tokens(2)) == '-S') then
3466 symbolic_mode = .true.
3467 else if (trim(cmd%tokens(2)) == '-p') then
3468 print_mode = .true.
3469 else if (cmd%tokens(2)(1:1) == '-') then
3470 write(error_unit, '(a)') 'umask: invalid option: ' // trim(cmd%tokens(2))
3471 shell%last_exit_status = 1
3472 return
3473 end if
3474 end if
3475
3476 ! Get current umask (set to 0 temporarily, then restore)
3477 current_mask = c_umask(0_c_int) ! Save the current mask
3478 temp_mask = c_umask(current_mask) ! Restore it
3479
3480 ! If no value specified, display current mask
3481 if (cmd%num_tokens == 1 .or. symbolic_mode .or. print_mode) then
3482 if (symbolic_mode) then
3483 ! Display in symbolic form: u=rwx,g=rx,o=rx
3484 call print_umask_symbolic(current_mask)
3485 else if (print_mode) then
3486 ! Display in a form that can be reused as input
3487 write(mask_str, '(o4.4)') current_mask
3488 write(output_unit, '(a)') 'umask ' // trim(adjustl(mask_str))
3489 else
3490 ! Display in octal (default)
3491 write(mask_str, '(o4.4)') current_mask
3492 write(output_unit, '(a)') trim(adjustl(mask_str))
3493 end if
3494 shell%last_exit_status = 0
3495 return
3496 end if
3497
3498 ! Set new mask
3499 ! Determine starting index for mask value (skip -S or -p if present)
3500 if (trim(cmd%tokens(2)) == '-S' .or. trim(cmd%tokens(2)) == '-p') then
3501 if (cmd%num_tokens < 3) then
3502 write(error_unit, '(a)') 'umask: usage: umask [-p] [-S] [mode]'
3503 shell%last_exit_status = 1
3504 return
3505 end if
3506 mask_str = trim(cmd%tokens(3))
3507 else
3508 mask_str = trim(cmd%tokens(2))
3509 end if
3510
3511 ! Parse octal mask value
3512 read(mask_str, '(o10)', iostat=iostat) new_mask_int
3513 if (iostat /= 0) then
3514 write(error_unit, '(a)') 'umask: invalid mode: ' // trim(mask_str)
3515 shell%last_exit_status = 1
3516 return
3517 end if
3518
3519 ! Validate mask (should be 0-0777)
3520 if (new_mask_int < 0 .or. new_mask_int > int(o'0777')) then
3521 write(error_unit, '(a)') 'umask: octal number out of range'
3522 shell%last_exit_status = 1
3523 return
3524 end if
3525
3526 ! Set the new mask
3527 new_mask = int(new_mask_int, c_int)
3528 temp_mask = c_umask(new_mask)
3529
3530 shell%last_exit_status = 0
3531 end subroutine
3532
3533 subroutine print_umask_symbolic(mask)
3534 integer(c_int), intent(in) :: mask
3535 character(len=32) :: perm_str
3536 integer :: u_perm, g_perm, o_perm
3537
3538 ! Extract permissions for user, group, and others
3539 ! umask inverts permissions, so we need to flip bits
3540 u_perm = iand(ishft(not(mask), -6), 7) ! User permissions
3541 g_perm = iand(ishft(not(mask), -3), 7) ! Group permissions
3542 o_perm = iand(not(mask), 7) ! Other permissions
3543
3544 ! Build symbolic string
3545 perm_str = 'u='
3546 if (iand(u_perm, 4) /= 0) perm_str = trim(perm_str) // 'r'
3547 if (iand(u_perm, 2) /= 0) perm_str = trim(perm_str) // 'w'
3548 if (iand(u_perm, 1) /= 0) perm_str = trim(perm_str) // 'x'
3549
3550 perm_str = trim(perm_str) // ',g='
3551 if (iand(g_perm, 4) /= 0) perm_str = trim(perm_str) // 'r'
3552 if (iand(g_perm, 2) /= 0) perm_str = trim(perm_str) // 'w'
3553 if (iand(g_perm, 1) /= 0) perm_str = trim(perm_str) // 'x'
3554
3555 perm_str = trim(perm_str) // ',o='
3556 if (iand(o_perm, 4) /= 0) perm_str = trim(perm_str) // 'r'
3557 if (iand(o_perm, 2) /= 0) perm_str = trim(perm_str) // 'w'
3558 if (iand(o_perm, 1) /= 0) perm_str = trim(perm_str) // 'x'
3559
3560 write(output_unit, '(a)') trim(perm_str)
3561 end subroutine
3562
3563 subroutine builtin_ulimit(cmd, shell)
3564 type(command_t), intent(in) :: cmd
3565 type(shell_state_t), intent(inout) :: shell
3566
3567 type(rlimit_t) :: rlim
3568 integer :: i, ret, resource
3569 character(len=256) :: arg, value_str
3570 logical :: show_all, set_hard, set_soft, setting_value
3571 integer(c_long) :: new_limit
3572 character(len=20) :: limit_name
3573
3574 ! Default: query soft limit for file size
3575 resource = RLIMIT_FSIZE
3576 show_all = .false.
3577 set_hard = .false.
3578 set_soft = .true. ! Default to soft limit
3579 setting_value = .false.
3580 limit_name = 'file size'
3581
3582 ! Parse options
3583 i = 2
3584 do while (i <= cmd%num_tokens)
3585 arg = trim(cmd%tokens(i))
3586
3587 if (arg == '-a') then
3588 show_all = .true.
3589 i = i + 1
3590 else if (arg == '-H') then
3591 set_hard = .true.
3592 set_soft = .false.
3593 i = i + 1
3594 else if (arg == '-S') then
3595 set_soft = .true.
3596 set_hard = .false.
3597 i = i + 1
3598 else if (arg == '-c') then
3599 resource = RLIMIT_CORE
3600 limit_name = 'core file size'
3601 i = i + 1
3602 else if (arg == '-d') then
3603 resource = RLIMIT_DATA
3604 limit_name = 'data seg size'
3605 i = i + 1
3606 else if (arg == '-f') then
3607 resource = RLIMIT_FSIZE
3608 limit_name = 'file size'
3609 i = i + 1
3610 else if (arg == '-l') then
3611 resource = RLIMIT_MEMLOCK
3612 limit_name = 'max locked memory'
3613 i = i + 1
3614 else if (arg == '-m') then
3615 resource = RLIMIT_RSS
3616 limit_name = 'max memory size'
3617 i = i + 1
3618 else if (arg == '-n') then
3619 resource = RLIMIT_NOFILE
3620 limit_name = 'open files'
3621 i = i + 1
3622 else if (arg == '-s') then
3623 resource = RLIMIT_STACK
3624 limit_name = 'stack size'
3625 i = i + 1
3626 else if (arg == '-t') then
3627 resource = RLIMIT_CPU
3628 limit_name = 'cpu time'
3629 i = i + 1
3630 else if (arg == '-u') then
3631 resource = RLIMIT_NPROC
3632 limit_name = 'max user processes'
3633 i = i + 1
3634 else if (arg == '-v') then
3635 resource = RLIMIT_AS
3636 limit_name = 'virtual memory'
3637 i = i + 1
3638 else if (len_trim(arg) == 3 .and. arg(1:1) == '-' .and. &
3639 (arg(2:2) == 'S' .or. arg(2:2) == 'H')) then
3640 ! Combined flags like -Sn, -Hn, -Ss, -Hs, etc.
3641 if (arg(2:2) == 'S') then
3642 set_soft = .true.
3643 set_hard = .false.
3644 else
3645 set_hard = .true.
3646 set_soft = .false.
3647 end if
3648 select case (arg(3:3))
3649 case ('c'); resource = RLIMIT_CORE; limit_name = 'core file size'
3650 case ('d'); resource = RLIMIT_DATA; limit_name = 'data seg size'
3651 case ('f'); resource = RLIMIT_FSIZE; limit_name = 'file size'
3652 case ('l'); resource = RLIMIT_MEMLOCK; limit_name = 'max locked memory'
3653 case ('m'); resource = RLIMIT_RSS; limit_name = 'max memory size'
3654 case ('n'); resource = RLIMIT_NOFILE; limit_name = 'open files'
3655 case ('s'); resource = RLIMIT_STACK; limit_name = 'stack size'
3656 case ('t'); resource = RLIMIT_CPU; limit_name = 'cpu time'
3657 case ('u'); resource = RLIMIT_NPROC; limit_name = 'max user processes'
3658 case ('v'); resource = RLIMIT_AS; limit_name = 'virtual memory'
3659 case default
3660 write(error_unit, '(a)') 'ulimit: invalid option: ' // trim(arg)
3661 shell%last_exit_status = 1
3662 return
3663 end select
3664 i = i + 1
3665 else if (arg(1:1) == '-') then
3666 ! Unknown flag
3667 write(error_unit, '(a)') 'ulimit: invalid option: ' // trim(arg)
3668 shell%last_exit_status = 2
3669 return
3670 else
3671 ! This is the value to set
3672 value_str = arg
3673 setting_value = .true.
3674 exit
3675 end if
3676 end do
3677
3678 ! Display all limits if -a was specified
3679 if (show_all) then
3680 call display_all_limits(shell)
3681 return
3682 end if
3683
3684 ! Get current limit
3685 ret = c_getrlimit(resource, rlim)
3686 if (ret /= 0) then
3687 write(error_unit, '(a)') 'ulimit: failed to get resource limit'
3688 shell%last_exit_status = 1
3689 return
3690 end if
3691
3692 ! If setting a new value
3693 if (setting_value) then
3694 ! Parse the new limit value
3695 if (trim(value_str) == 'unlimited') then
3696 new_limit = RLIM_INFINITY
3697 else
3698 read(value_str, *, iostat=ret) new_limit
3699 if (ret /= 0) then
3700 write(error_unit, '(a)') 'ulimit: invalid number: ' // trim(value_str)
3701 shell%last_exit_status = 1
3702 return
3703 end if
3704
3705 ! Convert based on resource type (some are in KB)
3706 if (resource == RLIMIT_FSIZE .or. resource == RLIMIT_CORE .or. &
3707 resource == RLIMIT_DATA .or. resource == RLIMIT_STACK .or. &
3708 resource == RLIMIT_RSS .or. resource == RLIMIT_MEMLOCK .or. &
3709 resource == RLIMIT_AS) then
3710 new_limit = new_limit * 1024 ! Convert KB to bytes
3711 end if
3712 end if
3713
3714 ! Set the new limit
3715 if (set_hard) then
3716 rlim%rlim_max = new_limit
3717 else
3718 rlim%rlim_cur = new_limit
3719 end if
3720
3721 ret = c_setrlimit(resource, rlim)
3722 if (ret /= 0) then
3723 write(error_unit, '(a)') 'ulimit: failed to set resource limit'
3724 shell%last_exit_status = 1
3725 return
3726 end if
3727 else
3728 ! Display current limit
3729 if (set_hard) then
3730 call display_limit(rlim%rlim_max, resource)
3731 else
3732 call display_limit(rlim%rlim_cur, resource)
3733 end if
3734 end if
3735
3736 shell%last_exit_status = 0
3737
3738 contains
3739
3740 subroutine display_limit(limit_value, res)
3741 integer(c_long), intent(in) :: limit_value
3742 integer(c_int), intent(in) :: res
3743 integer(c_long) :: display_value
3744
3745 ! Treat RLIM_INFINITY, negative values, and very large values as "unlimited".
3746 ! macOS returns large finite values (e.g. 2^63-1) instead of RLIM_INFINITY
3747 ! for some resources. Threshold: > 2^40 (~1 trillion) is effectively unlimited.
3748 if (limit_value == RLIM_INFINITY .or. limit_value < 0 .or. &
3749 limit_value > 1099511627776_c_long) then
3750 write(output_unit, '(a)') 'unlimited'
3751 else
3752 ! Convert bytes to KB for display
3753 if (res == RLIMIT_FSIZE .or. res == RLIMIT_CORE .or. &
3754 res == RLIMIT_DATA .or. res == RLIMIT_STACK .or. &
3755 res == RLIMIT_RSS .or. res == RLIMIT_MEMLOCK .or. &
3756 res == RLIMIT_AS) then
3757 display_value = limit_value / 1024
3758 else
3759 display_value = limit_value
3760 end if
3761 write(output_unit, '(i0)') display_value
3762 end if
3763 end subroutine
3764
3765 subroutine display_all_limits(sh)
3766 type(shell_state_t), intent(inout) :: sh
3767
3768 write(output_unit, '(a)') 'core file size (blocks, -c) ' // get_limit_str(RLIMIT_CORE)
3769 write(output_unit, '(a)') 'data seg size (kbytes, -d) ' // get_limit_str(RLIMIT_DATA)
3770 write(output_unit, '(a)') 'file size (blocks, -f) ' // get_limit_str(RLIMIT_FSIZE)
3771 write(output_unit, '(a)') 'max locked memory (kbytes, -l) ' // get_limit_str(RLIMIT_MEMLOCK)
3772 write(output_unit, '(a)') 'max memory size (kbytes, -m) ' // get_limit_str(RLIMIT_RSS)
3773 write(output_unit, '(a)') 'open files (-n) ' // get_limit_str(RLIMIT_NOFILE)
3774 write(output_unit, '(a)') 'stack size (kbytes, -s) ' // get_limit_str(RLIMIT_STACK)
3775 write(output_unit, '(a)') 'cpu time (seconds,-t) ' // get_limit_str(RLIMIT_CPU)
3776 write(output_unit, '(a)') 'max user processes (-u) ' // get_limit_str(RLIMIT_NPROC)
3777 write(output_unit, '(a)') 'virtual memory (kbytes, -v) ' // get_limit_str(RLIMIT_AS)
3778
3779 sh%last_exit_status = 0
3780 end subroutine
3781
3782 function get_limit_str(res) result(str)
3783 integer(c_int), intent(in) :: res
3784 character(len=20) :: str
3785 type(rlimit_t) :: r
3786 integer :: res_ret
3787 integer(c_long) :: val
3788
3789 res_ret = c_getrlimit(res, r)
3790 if (res_ret /= 0) then
3791 str = 'error'
3792 return
3793 end if
3794
3795 if (r%rlim_cur == RLIM_INFINITY .or. r%rlim_cur < 0 .or. &
3796 r%rlim_cur > 1099511627776_c_long) then
3797 str = 'unlimited'
3798 else
3799 ! Convert to appropriate units
3800 if (res == RLIMIT_FSIZE .or. res == RLIMIT_CORE .or. &
3801 res == RLIMIT_DATA .or. res == RLIMIT_STACK .or. &
3802 res == RLIMIT_RSS .or. res == RLIMIT_MEMLOCK .or. &
3803 res == RLIMIT_AS) then
3804 val = r%rlim_cur / 1024
3805 else
3806 val = r%rlim_cur
3807 end if
3808 write(str, '(i20)') val
3809 str = adjustl(str)
3810 end if
3811 end function
3812
3813 end subroutine
3814
3815 subroutine builtin_times(cmd, shell)
3816 type(command_t), intent(in) :: cmd
3817 type(shell_state_t), intent(inout) :: shell
3818 type(rusage_t) :: self_usage, children_usage
3819 integer :: ret
3820 real :: self_user_sec, self_sys_sec, children_user_sec, children_sys_sec
3821 integer :: self_user_min, self_sys_min, children_user_min, children_sys_min
3822
3823 if (.false.) print *, cmd%num_tokens ! Silence unused warning
3824
3825 ! Get resource usage for the shell itself
3826 ret = c_getrusage(RUSAGE_SELF, self_usage)
3827 if (ret /= 0) then
3828 write(error_unit, '(a)') 'times: failed to get resource usage'
3829 shell%last_exit_status = 1
3830 return
3831 end if
3832
3833 ! Get resource usage for all children
3834 ret = c_getrusage(RUSAGE_CHILDREN, children_usage)
3835 if (ret /= 0) then
3836 write(error_unit, '(a)') 'times: failed to get child resource usage'
3837 shell%last_exit_status = 1
3838 return
3839 end if
3840
3841 ! Convert timeval structures to seconds (tv_sec + tv_usec/1000000)
3842 self_user_sec = real(self_usage%ru_utime%tv_sec) + real(self_usage%ru_utime%tv_usec) / 1000000.0
3843 self_sys_sec = real(self_usage%ru_stime%tv_sec) + real(self_usage%ru_stime%tv_usec) / 1000000.0
3844 children_user_sec = real(children_usage%ru_utime%tv_sec) + real(children_usage%ru_utime%tv_usec) / 1000000.0
3845 children_sys_sec = real(children_usage%ru_stime%tv_sec) + real(children_usage%ru_stime%tv_usec) / 1000000.0
3846
3847 ! Extract minutes and seconds
3848 self_user_min = int(self_user_sec / 60.0)
3849 self_user_sec = self_user_sec - (self_user_min * 60.0)
3850 self_sys_min = int(self_sys_sec / 60.0)
3851 self_sys_sec = self_sys_sec - (self_sys_min * 60.0)
3852 children_user_min = int(children_user_sec / 60.0)
3853 children_user_sec = children_user_sec - (children_user_min * 60.0)
3854 children_sys_min = int(children_sys_sec / 60.0)
3855 children_sys_sec = children_sys_sec - (children_sys_min * 60.0)
3856
3857 ! Print in bash format: user_time system_time (one line for shell, one for children)
3858 write(output_unit, '(i0,a,f5.3,a,1x,i0,a,f5.3,a)') &
3859 self_user_min, 'm', self_user_sec, 's', &
3860 self_sys_min, 'm', self_sys_sec, 's'
3861 write(output_unit, '(i0,a,f5.3,a,1x,i0,a,f5.3,a)') &
3862 children_user_min, 'm', children_user_sec, 's', &
3863 children_sys_min, 'm', children_sys_sec, 's'
3864
3865 shell%last_exit_status = 0
3866 end subroutine
3867
3868 subroutine builtin_let(cmd, shell)
3869 use expansion, only: arithmetic_expansion_shell
3870 type(command_t), intent(in) :: cmd
3871 type(shell_state_t), intent(inout) :: shell
3872 integer :: i, iostat
3873 character(len=:), allocatable :: expr, arith_expr, result_str
3874 integer(kind=8) :: result_val
3875
3876 ! Default to success
3877 shell%last_exit_status = 0
3878
3879 ! Process each argument as an arithmetic expression
3880 do i = 2, cmd%num_tokens
3881 ! Build arithmetic expression - remove quotes if present
3882 expr = trim(cmd%tokens(i))
3883 if (len_trim(expr) > 0) then
3884 if (expr(1:1) == '"' .and. expr(len_trim(expr):len_trim(expr)) == '"') then
3885 expr = expr(2:len_trim(expr)-1)
3886 else if (expr(1:1) == "'" .and. expr(len_trim(expr):len_trim(expr)) == "'") then
3887 expr = expr(2:len_trim(expr)-1)
3888 end if
3889 end if
3890
3891 ! Evaluate as $((expression))
3892 arith_expr = '$((' // trim(expr) // '))'
3893 result_str = arithmetic_expansion_shell(trim(arith_expr), shell)
3894
3895 ! Convert to integer to check result
3896 read(result_str, *, iostat=iostat) result_val
3897 if (iostat /= 0) result_val = 0
3898
3899 ! Set exit status based on last expression result
3900 ! Exit status 0 if non-zero, 1 if zero
3901 if (result_val /= 0) then
3902 shell%last_exit_status = 0
3903 else
3904 shell%last_exit_status = 1
3905 end if
3906 end do
3907 end subroutine
3908
3909 subroutine builtin_declare(cmd, shell)
3910 use variables, only: set_shell_variable, declare_associative_array
3911 type(command_t), intent(in) :: cmd
3912 type(shell_state_t), intent(inout) :: shell
3913 integer :: eq_pos, i, j, arg_idx
3914 character(len=256) :: var_name
3915 character(len=:), allocatable :: var_value
3916 logical :: readonly_flag, export_flag, print_mode, print_funcs
3917 logical :: array_flag, assoc_array_flag, found, integer_flag, global_flag
3918 character(len=MAX_TOKEN_LEN) :: flag_str
3919 integer :: fi
3920
3921 readonly_flag = .false.
3922 export_flag = .false.
3923 print_mode = .false.
3924 print_funcs = .false.
3925 array_flag = .false.
3926 assoc_array_flag = .false.
3927 integer_flag = .false.
3928 global_flag = .false.
3929
3930 if (cmd%num_tokens < 2) then
3931 ! No arguments: print all variables
3932 print_mode = .true.
3933 end if
3934
3935 ! Parse options (supports combined flags like -ix, -ri, -rxi)
3936 arg_idx = 2
3937 do while (arg_idx <= cmd%num_tokens)
3938 if (cmd%tokens(arg_idx)(1:1) == '-' .and. len_trim(cmd%tokens(arg_idx)) >= 2 .and. &
3939 cmd%tokens(arg_idx)(2:2) /= '-') then
3940 flag_str = trim(cmd%tokens(arg_idx))
3941 do fi = 2, len_trim(flag_str)
3942 select case (flag_str(fi:fi))
3943 case ('r')
3944 readonly_flag = .true.
3945 case ('x')
3946 export_flag = .true.
3947 case ('p')
3948 print_mode = .true.
3949 case ('f')
3950 print_funcs = .true.
3951 print_mode = .true.
3952 case ('a')
3953 array_flag = .true.
3954 case ('A')
3955 assoc_array_flag = .true.
3956 case ('i')
3957 integer_flag = .true.
3958 case ('g')
3959 global_flag = .true.
3960 case default
3961 write(error_unit, '(a)') 'declare: invalid option: ' // trim(cmd%tokens(arg_idx))
3962 shell%last_exit_status = 1
3963 return
3964 end select
3965 end do
3966 arg_idx = arg_idx + 1
3967 else if (trim(cmd%tokens(arg_idx)) == '--') then
3968 arg_idx = arg_idx + 1
3969 exit
3970 else
3971 exit
3972 end if
3973 end do
3974
3975 if (print_mode) then
3976 ! Print functions if -f flag is set
3977 if (print_funcs) then
3978 do i = 1, shell%num_functions
3979 if (len_trim(shell%functions(i)%name) > 0 .and. shell%functions(i)%body_lines > 0) then
3980 write(output_unit, '(a)') trim(shell%functions(i)%name) // ' ()'
3981 write(output_unit, '(a)') '{'
3982 if (allocated(shell%functions(i)%body)) then
3983 do j = 1, shell%functions(i)%body_lines
3984 if (allocated(shell%functions(i)%body(j)%str)) then
3985 write(output_unit, '(a)') ' ' // trim(shell%functions(i)%body(j)%str)
3986 end if
3987 end do
3988 end if
3989 write(output_unit, '(a)') '}'
3990 end if
3991 end do
3992 shell%last_exit_status = 0
3993 return
3994 end if
3995
3996 ! Print variables with declare syntax
3997 if (arg_idx <= cmd%num_tokens) then
3998 ! Print specific named variables
3999 do j = arg_idx, cmd%num_tokens
4000 var_name = trim(cmd%tokens(j))
4001 found = .false.
4002 do i = 1, shell%num_variables
4003 if (trim(shell%variables(i)%name) == var_name) then
4004 block
4005 character(len=16) :: flags
4006 flags = '-'
4007 if (shell%variables(i)%is_integer) flags = trim(flags) // 'i'
4008 if (shell%variables(i)%readonly) flags = trim(flags) // 'r'
4009 if (shell%variables(i)%exported) flags = trim(flags) // 'x'
4010 if (flags == '-') flags = '--'
4011 write(output_unit, '(a)') 'declare ' // trim(flags) // ' ' // &
4012 trim(shell%variables(i)%name) // '="' // trim(shell%variables(i)%value) // '"'
4013 end block
4014 found = .true.
4015 exit
4016 end if
4017 end do
4018 if (.not. found) then
4019 write(error_unit, '(a)') 'declare: ' // trim(var_name) // ': not found'
4020 shell%last_exit_status = 1
4021 end if
4022 end do
4023 else
4024 ! Print all variables
4025 do i = 1, shell%num_variables
4026 if (len_trim(shell%variables(i)%name) > 0) then
4027 block
4028 character(len=16) :: flags
4029 flags = '-'
4030 if (shell%variables(i)%is_integer) flags = trim(flags) // 'i'
4031 if (shell%variables(i)%readonly) flags = trim(flags) // 'r'
4032 if (shell%variables(i)%exported) flags = trim(flags) // 'x'
4033 if (flags == '-') flags = '--'
4034 write(output_unit, '(a)') 'declare ' // trim(flags) // ' ' // &
4035 trim(shell%variables(i)%name) // '="' // trim(shell%variables(i)%value) // '"'
4036 end block
4037 end if
4038 end do
4039 end if
4040 shell%last_exit_status = 0
4041 return
4042 end if
4043
4044 ! Process variable assignments
4045 do while (arg_idx <= cmd%num_tokens)
4046 eq_pos = index(cmd%tokens(arg_idx), '=')
4047
4048 if (eq_pos > 0) then
4049 ! VAR=value form
4050 var_name = cmd%tokens(arg_idx)(:eq_pos-1)
4051 var_value = cmd%tokens(arg_idx)(eq_pos+1:)
4052
4053 ! Check if variable already exists and is readonly
4054 found = .false.
4055 do j = 1, shell%num_variables
4056 if (trim(shell%variables(j)%name) == trim(var_name)) then
4057 if (shell%variables(j)%readonly .and. .not. readonly_flag) then
4058 write(error_unit, '(a)') trim(var_name) // ': readonly variable'
4059 shell%last_exit_status = 1
4060 return
4061 end if
4062 found = .true.
4063 exit
4064 end if
4065 end do
4066
4067 ! Handle array initialization: declare -a arr=(a b c)
4068 if (array_flag .and. len_trim(var_value) > 0 .and. &
4069 var_value(1:1) == '(') then
4070 block
4071 use variables, only: set_array_variable
4072 character(len=256) :: arr_elems(100)
4073 integer :: num_elems, k, elem_start
4074 character(len=:), allocatable :: content
4075 ! Strip parentheses
4076 content = trim(var_value)
4077 if (content(len(content):len(content)) == ')') then
4078 content = content(2:len(content)-1)
4079 else
4080 content = content(2:)
4081 end if
4082 ! Split on spaces
4083 num_elems = 0
4084 elem_start = 1
4085 do k = 1, len_trim(content)
4086 if (content(k:k) == ' ') then
4087 if (k > elem_start) then
4088 num_elems = num_elems + 1
4089 arr_elems(num_elems) = content(elem_start:k-1)
4090 end if
4091 elem_start = k + 1
4092 end if
4093 end do
4094 if (elem_start <= len_trim(content)) then
4095 num_elems = num_elems + 1
4096 arr_elems(num_elems) = &
4097 content(elem_start:len_trim(content))
4098 end if
4099 call set_array_variable(shell, trim(var_name), &
4100 arr_elems, num_elems)
4101 end block
4102 arg_idx = arg_idx + 1
4103 cycle
4104 end if
4105
4106 ! Evaluate arithmetic if integer flag is set
4107 if (integer_flag .and. len_trim(var_value) > 0) then
4108 block
4109 use expansion, only: arithmetic_expansion_shell
4110 character(len=:), allocatable :: arith_expr, arith_result
4111 arith_expr = '$((' // trim(var_value) // '))'
4112 arith_result = trim(arithmetic_expansion_shell( &
4113 trim(arith_expr), shell))
4114 var_value = arith_result
4115 end block
4116 end if
4117
4118 ! Set the variable — inside functions without -g, use local scope
4119 if (shell%function_depth > 0 .and. .not. global_flag) then
4120 block
4121 integer :: lv_depth, lv_idx
4122 lv_depth = shell%function_depth
4123 if (lv_depth <= size(shell%local_var_counts)) then
4124 ! Check if already exists locally
4125 lv_idx = 0
4126 do j = 1, shell%local_var_counts(lv_depth)
4127 if (trim(shell%local_vars(lv_depth, j)%name) == trim(var_name)) then
4128 lv_idx = j
4129 exit
4130 end if
4131 end do
4132 if (lv_idx == 0) then
4133 lv_idx = shell%local_var_counts(lv_depth) + 1
4134 if (lv_idx <= size(shell%local_vars, 2)) then
4135 shell%local_var_counts(lv_depth) = lv_idx
4136 end if
4137 end if
4138 if (lv_idx <= size(shell%local_vars, 2)) then
4139 shell%local_vars(lv_depth, lv_idx)%name = var_name
4140 shell%local_vars(lv_depth, lv_idx)%value = trim(var_value)
4141 shell%local_vars(lv_depth, lv_idx)%readonly = readonly_flag
4142 shell%local_vars(lv_depth, lv_idx)%exported = export_flag
4143 shell%local_vars(lv_depth, lv_idx)%is_integer = integer_flag
4144 end if
4145 end if
4146 end block
4147 if (export_flag) then
4148 if (.not. set_environment_var(trim(var_name), trim(var_value))) then
4149 write(error_unit, '(a)') 'declare: failed to export variable'
4150 shell%last_exit_status = 1
4151 return
4152 end if
4153 end if
4154 else
4155 call set_shell_variable(shell, trim(var_name), &
4156 trim(var_value))
4157
4158 ! Apply attributes
4159 do j = 1, shell%num_variables
4160 if (trim(shell%variables(j)%name) == trim(var_name)) then
4161 if (readonly_flag) shell%variables(j)%readonly = .true.
4162 if (integer_flag) shell%variables(j)%is_integer = .true.
4163 if (export_flag) then
4164 shell%variables(j)%exported = .true.
4165 if (.not. set_environment_var(trim(var_name), trim(var_value))) then
4166 write(error_unit, '(a)') 'declare: failed to export variable'
4167 shell%last_exit_status = 1
4168 return
4169 end if
4170 end if
4171 exit
4172 end if
4173 end do
4174 end if
4175 else
4176 ! Just VAR - declare variable or apply attributes
4177 var_name = trim(cmd%tokens(arg_idx))
4178 found = .false.
4179
4180 ! Handle array declarations
4181 if (assoc_array_flag) then
4182 ! declare -A arrayname
4183 call declare_associative_array(shell, var_name)
4184 arg_idx = arg_idx + 1
4185 cycle
4186 else if (array_flag) then
4187 ! declare -a arrayname
4188 ! Create an empty indexed array
4189 call set_shell_variable(shell, var_name, '')
4190 do j = 1, shell%num_variables
4191 if (trim(shell%variables(j)%name) == var_name) then
4192 shell%variables(j)%is_array = .true.
4193 exit
4194 end if
4195 end do
4196 arg_idx = arg_idx + 1
4197 cycle
4198 end if
4199
4200 do j = 1, shell%num_variables
4201 if (trim(shell%variables(j)%name) == var_name) then
4202 if (readonly_flag) shell%variables(j)%readonly = .true.
4203 if (integer_flag) shell%variables(j)%is_integer = .true.
4204 if (export_flag) then
4205 shell%variables(j)%exported = .true.
4206 if (.not. set_environment_var(var_name, &
4207 trim(shell%variables(j)%value))) then
4208 write(error_unit, '(a)') &
4209 'declare: failed to export variable'
4210 shell%last_exit_status = 1
4211 return
4212 end if
4213 end if
4214 found = .true.
4215 exit
4216 end if
4217 end do
4218
4219 if (.not. found) then
4220 ! Variable doesn't exist, create it with empty value
4221 call set_shell_variable(shell, var_name, '')
4222 do j = 1, shell%num_variables
4223 if (trim(shell%variables(j)%name) == var_name) then
4224 if (readonly_flag) &
4225 shell%variables(j)%readonly = .true.
4226 if (integer_flag) &
4227 shell%variables(j)%is_integer = .true.
4228 if (export_flag) then
4229 shell%variables(j)%exported = .true.
4230 if (.not. set_environment_var(var_name, &
4231 '')) then
4232 write(error_unit, '(a)') &
4233 'declare: failed to export variable'
4234 shell%last_exit_status = 1
4235 return
4236 end if
4237 end if
4238 exit
4239 end if
4240 end do
4241 end if
4242 end if
4243
4244 arg_idx = arg_idx + 1
4245 end do
4246
4247 shell%last_exit_status = 0
4248 end subroutine
4249
4250 subroutine builtin_printenv(cmd, shell)
4251 use system_interface, only: get_environ_entry
4252 type(command_t), intent(in) :: cmd
4253 type(shell_state_t), intent(inout) :: shell
4254 integer :: i
4255 #ifdef USE_MEMORY_POOL
4256 type(string_ref) :: env_value_ref
4257 character(len=:), allocatable :: temp_str
4258 #else
4259 character(len=:), allocatable :: env_value
4260 #endif
4261 character(len=:), allocatable :: env_entry
4262
4263 if (cmd%num_tokens < 2) then
4264 ! No arguments: print all environment variables
4265 i = 0
4266 do
4267 env_entry = get_environ_entry(i)
4268 if (.not. allocated(env_entry) .or. len(env_entry) == 0) exit
4269 write(output_unit, '(a)') trim(env_entry)
4270 if (allocated(env_entry)) deallocate(env_entry)
4271 i = i + 1
4272 end do
4273 shell%last_exit_status = 0
4274 else
4275 ! Print specific environment variable(s)
4276 #ifdef USE_MEMORY_POOL
4277 env_value_ref = pool_get_string(1024)
4278 call dashboard_track_allocation(MOD_BUILTINS, 1024, 3)
4279 #endif
4280 shell%last_exit_status = 0
4281 do i = 2, cmd%num_tokens
4282 #ifdef USE_MEMORY_POOL
4283 temp_str = get_environment_var(trim(cmd%tokens(i)))
4284 if (allocated(temp_str) .and. len(temp_str) > 0) then
4285 env_value_ref%data = temp_str
4286 write(output_unit, '(a)') trim(env_value_ref%data)
4287 else
4288 shell%last_exit_status = 1
4289 end if
4290 if (allocated(temp_str)) deallocate(temp_str)
4291 #else
4292 env_value = get_environment_var(trim(cmd%tokens(i)))
4293 if (allocated(env_value) .and. len(env_value) > 0) then
4294 write(output_unit, '(a)') env_value
4295 else
4296 shell%last_exit_status = 1
4297 end if
4298 #endif
4299 end do
4300 #ifdef USE_MEMORY_POOL
4301 call pool_release_string(env_value_ref)
4302 call dashboard_track_deallocation(MOD_BUILTINS, 1024, 3)
4303 #endif
4304 end if
4305 end subroutine
4306
4307 subroutine builtin_fc(cmd, shell)
4308 type(command_t), intent(in) :: cmd
4309 type(shell_state_t), intent(inout) :: shell
4310 logical :: list_mode, no_line_numbers, reverse_order, subst_mode
4311 character(len=:), allocatable :: editor, old_str, new_str
4312 character(len=:), allocatable :: line, tmpfile, edit_cmd
4313 character(len=40) :: fmt_buf
4314 integer :: first, last, i, arg_idx, iostat, tmp_unit
4315 integer :: eq_pos, history_count
4316 logical :: found
4317
4318 ! Pre-allocate line for intent(out) calls and Fortran read
4319 allocate(character(len=4096) :: line)
4320
4321 ! Initialize flags
4322 list_mode = .false.
4323 no_line_numbers = .false.
4324 reverse_order = .false.
4325 subst_mode = .false.
4326 editor = ''
4327 first = -1
4328 last = -1
4329 arg_idx = 2
4330
4331 ! Get history count
4332 history_count = get_history_count()
4333
4334 ! Parse options
4335 do while (arg_idx <= cmd%num_tokens)
4336 if (cmd%tokens(arg_idx)(1:1) == '-') then
4337 select case(trim(cmd%tokens(arg_idx)))
4338 case('-l')
4339 list_mode = .true.
4340 arg_idx = arg_idx + 1
4341 case('-n')
4342 no_line_numbers = .true.
4343 arg_idx = arg_idx + 1
4344 case('-r')
4345 reverse_order = .true.
4346 arg_idx = arg_idx + 1
4347 case('-e')
4348 ! Next argument is editor name
4349 if (arg_idx + 1 > cmd%num_tokens) then
4350 write(error_unit, '(a)') 'fc: -e requires an argument'
4351 shell%last_exit_status = 1
4352 return
4353 end if
4354 editor = trim(cmd%tokens(arg_idx + 1))
4355 arg_idx = arg_idx + 2
4356 case('-s')
4357 subst_mode = .true.
4358 arg_idx = arg_idx + 1
4359 case default
4360 write(error_unit, '(a)') 'fc: invalid option: ' // trim(cmd%tokens(arg_idx))
4361 shell%last_exit_status = 1
4362 return
4363 end select
4364 else
4365 exit ! Done with options
4366 end if
4367 end do
4368
4369 ! Parse range arguments [first] [last] (skip for -s mode)
4370 if (.not. subst_mode .and. arg_idx <= cmd%num_tokens) then
4371 ! Parse first
4372 if (cmd%tokens(arg_idx)(1:1) == '-') then
4373 ! Negative offset from end
4374 read(cmd%tokens(arg_idx), *, iostat=iostat) first
4375 if (iostat == 0) first = history_count + first + 1
4376 else
4377 ! Try to parse as number
4378 read(cmd%tokens(arg_idx), *, iostat=iostat) first
4379 if (iostat /= 0) then
4380 ! Not a number, search for command starting with this string
4381 first = find_history_by_prefix(trim(cmd%tokens(arg_idx)))
4382 if (first < 0) then
4383 write(error_unit, '(a)') 'fc: ' // trim(cmd%tokens(arg_idx)) // ': event not found'
4384 shell%last_exit_status = 1
4385 return
4386 end if
4387 end if
4388 end if
4389 arg_idx = arg_idx + 1
4390 end if
4391
4392 if (.not. subst_mode .and. arg_idx <= cmd%num_tokens) then
4393 ! Parse last
4394 if (cmd%tokens(arg_idx)(1:1) == '-') then
4395 read(cmd%tokens(arg_idx), *, iostat=iostat) last
4396 if (iostat == 0) last = history_count + last + 1
4397 else
4398 read(cmd%tokens(arg_idx), *, iostat=iostat) last
4399 if (iostat /= 0) then
4400 last = find_history_by_prefix(trim(cmd%tokens(arg_idx)))
4401 if (last < 0) then
4402 write(error_unit, '(a)') 'fc: ' // trim(cmd%tokens(arg_idx)) // ': event not found'
4403 shell%last_exit_status = 1
4404 return
4405 end if
4406 end if
4407 end if
4408 end if
4409
4410 ! Handle empty history - list mode succeeds silently, other modes fail
4411 if (history_count == 0) then
4412 if (list_mode) then
4413 shell%last_exit_status = 0
4414 else
4415 write(error_unit, '(a)') 'fc: no commands in history'
4416 shell%last_exit_status = 1
4417 end if
4418 return
4419 end if
4420
4421 ! Set defaults if not specified
4422 if (first < 0) then
4423 if (list_mode) then
4424 first = max(1, history_count - 15) ! Show last 16 commands by default
4425 else if (subst_mode) then
4426 first = max(1, history_count - 1) ! Get command before fc itself
4427 else
4428 first = history_count ! Edit last command
4429 end if
4430 end if
4431
4432 if (last < 0) then
4433 if (list_mode) then
4434 last = history_count
4435 else
4436 last = first ! Edit single command
4437 end if
4438 end if
4439
4440 ! Validate range
4441 if (first < 1) first = 1
4442 if (last > history_count) last = history_count
4443 if (first > last .and. .not. reverse_order) then
4444 ! Swap if needed
4445 i = first
4446 first = last
4447 last = i
4448 end if
4449
4450 ! Handle -s (substitution mode)
4451 if (subst_mode) then
4452 ! fc -s [old=new] [command]
4453 ! Parse old=new substitution
4454 old_str = ''
4455 new_str = ''
4456
4457 if (arg_idx <= cmd%num_tokens) then
4458 eq_pos = index(cmd%tokens(arg_idx), '=')
4459 if (eq_pos > 0) then
4460 old_str = cmd%tokens(arg_idx)(:eq_pos-1)
4461 new_str = cmd%tokens(arg_idx)(eq_pos+1:)
4462 arg_idx = arg_idx + 1
4463 end if
4464 end if
4465
4466 ! Get the command to re-execute
4467 call get_history_line(first, line, found)
4468 if (.not. found) then
4469 write(error_unit, '(a)') 'fc: history entry not found'
4470 shell%last_exit_status = 1
4471 return
4472 end if
4473
4474 ! Perform substitution if requested
4475 if (len_trim(old_str) > 0) then
4476 i = index(line, trim(old_str))
4477 if (i > 0) then
4478 line = line(:i-1) // trim(new_str) // line(i+len_trim(old_str):)
4479 else
4480 write(error_unit, '(a)') 'fc: substitution failed'
4481 shell%last_exit_status = 1
4482 return
4483 end if
4484 end if
4485
4486 ! Print the command being executed
4487 write(output_unit, '(a)') trim(line)
4488
4489 ! Execute using c_system
4490 shell%last_exit_status = c_system(trim(line) // c_null_char)
4491
4492 return
4493 end if
4494
4495 ! Handle -l (list mode)
4496 if (list_mode) then
4497 if (reverse_order) then
4498 do i = last, first, -1
4499 call get_history_line(i, line, found)
4500 if (found) then
4501 if (no_line_numbers) then
4502 write(output_unit, '(a)') trim(line)
4503 else
4504 write(output_unit, '(i5,2x,a)') i, trim(line)
4505 end if
4506 end if
4507 end do
4508 else
4509 do i = first, last
4510 call get_history_line(i, line, found)
4511 if (found) then
4512 if (no_line_numbers) then
4513 write(output_unit, '(a)') trim(line)
4514 else
4515 write(output_unit, '(i5,2x,a)') i, trim(line)
4516 end if
4517 end if
4518 end do
4519 end if
4520 shell%last_exit_status = 0
4521 return
4522 end if
4523
4524 ! Handle edit mode (default)
4525 ! Determine editor to use
4526 if (len_trim(editor) == 0) then
4527 editor = get_environment_var('FCEDIT')
4528 if (len_trim(editor) == 0) then
4529 editor = get_environment_var('EDITOR')
4530 if (len_trim(editor) == 0) then
4531 editor = 'vi' ! Default to vi
4532 end if
4533 end if
4534 end if
4535
4536 ! Create temporary file with commands to edit
4537 write(fmt_buf, '(a,i15)') '/tmp/fortsh_fc_', c_getpid()
4538 tmpfile = trim(adjustl(fmt_buf))
4539
4540 open(newunit=tmp_unit, file=trim(tmpfile), status='replace', action='write', iostat=iostat)
4541 if (iostat /= 0) then
4542 write(error_unit, '(a)') 'fc: failed to create temporary file'
4543 shell%last_exit_status = 1
4544 return
4545 end if
4546
4547 ! Write commands to temp file
4548 do i = first, last
4549 call get_history_line(i, line, found)
4550 if (found) then
4551 write(tmp_unit, '(a)') trim(line)
4552 end if
4553 end do
4554 close(tmp_unit)
4555
4556 ! Launch editor
4557 edit_cmd = trim(editor) // ' ' // trim(tmpfile)
4558 i = c_system(trim(edit_cmd) // c_null_char)
4559
4560 ! Read back edited commands and execute them
4561 open(newunit=tmp_unit, file=trim(tmpfile), status='old', action='read', iostat=iostat)
4562 if (iostat /= 0) then
4563 write(error_unit, '(a)') 'fc: failed to read edited file'
4564 shell%last_exit_status = 1
4565 return
4566 end if
4567
4568 do
4569 read(tmp_unit, '(a)', iostat=iostat) line
4570 if (iostat /= 0) exit
4571
4572 if (len_trim(line) == 0 .or. line(1:1) == '#') cycle
4573
4574 ! Execute the line using c_system
4575 shell%last_exit_status = c_system(trim(line) // c_null_char)
4576 end do
4577
4578 close(tmp_unit)
4579
4580 ! Clean up temporary file
4581 call unlink_file(trim(tmpfile))
4582
4583 shell%last_exit_status = 0
4584 end subroutine
4585
4586 function find_history_by_prefix(prefix) result(hist_index)
4587 character(len=*), intent(in) :: prefix
4588 integer :: hist_index
4589 character(len=:), allocatable :: line
4590 logical :: found
4591 integer :: i, count, pos
4592
4593 count = get_history_count()
4594 allocate(character(len=4096) :: line)
4595
4596 ! Search backwards from most recent
4597 do i = count, 1, -1
4598 call get_history_line(i, line, found)
4599 if (found) then
4600 pos = index(line, trim(prefix))
4601 if (pos == 1) then
4602 hist_index = i
4603 return
4604 end if
4605 end if
4606 end do
4607
4608 hist_index = -1 ! Not found
4609 end function
4610
4611 subroutine unlink_file(filepath)
4612 character(len=*), intent(in) :: filepath
4613 integer :: iostat
4614
4615 ! Use Fortran intrinsic to delete file
4616 open(newunit=iostat, file=trim(filepath), status='old')
4617 if (iostat >= 0) then
4618 close(iostat, status='delete')
4619 end if
4620 end subroutine
4621
4622 ! ===========================================================================
4623 ! PROGRAMMABLE COMPLETION BUILTINS
4624 ! ===========================================================================
4625
4626 subroutine builtin_complete(cmd, shell)
4627 type(command_t), intent(in) :: cmd
4628 type(shell_state_t), intent(inout) :: shell
4629 integer :: i
4630 character(len=256) :: arg
4631 type(completion_spec_t) :: spec
4632 logical :: remove_flag, list_flag, print_flag
4633 character(len=256) :: word_list_arg, function_arg, action_arg
4634 character(len=256) :: option_arg, prefix_arg, suffix_arg, filter_arg
4635 character(len=256) :: command_names(50)
4636 integer :: num_commands
4637
4638 ! Initialize spec
4639 spec%is_active = .false.
4640 spec%command = ''
4641 spec%word_list_count = 0
4642 spec%function_name = ''
4643 spec%filter_pattern = ''
4644 spec%prefix = ''
4645 spec%suffix = ''
4646 spec%use_default = .false.
4647 spec%use_dirnames = .false.
4648 spec%use_filenames = .false.
4649 spec%nospace = .false.
4650 spec%plusdirs = .false.
4651 spec%nosort = .false.
4652 spec%builtin_alias = .false.
4653 spec%builtin_command = .false.
4654 spec%builtin_directory = .false.
4655 spec%builtin_file = .false.
4656 spec%builtin_function = .false.
4657 spec%builtin_hostname = .false.
4658 spec%builtin_variable = .false.
4659 spec%builtin_user = .false.
4660 spec%builtin_group = .false.
4661 spec%builtin_service = .false.
4662 spec%builtin_export = .false.
4663 spec%builtin_keyword = .false.
4664 spec%builtin_builtin = .false.
4665
4666 remove_flag = .false.
4667 list_flag = .false.
4668 print_flag = .false.
4669 word_list_arg = ''
4670 function_arg = ''
4671 action_arg = ''
4672 option_arg = ''
4673 prefix_arg = ''
4674 suffix_arg = ''
4675 filter_arg = ''
4676 num_commands = 0
4677
4678 ! Parse arguments
4679 i = 2
4680 do while (i <= cmd%num_tokens)
4681 arg = trim(cmd%tokens(i))
4682
4683 if (arg == '-r') then
4684 ! Remove completion spec
4685 remove_flag = .true.
4686 i = i + 1
4687 else if (arg == '-p' .or. arg == '-l') then
4688 ! List/print completion specs
4689 list_flag = .true.
4690 i = i + 1
4691 else if (arg == '-W') then
4692 ! Word list
4693 if (i + 1 <= cmd%num_tokens) then
4694 i = i + 1
4695 word_list_arg = trim(cmd%tokens(i))
4696 i = i + 1
4697 else
4698 write(error_unit, '(a)') 'complete: -W requires an argument'
4699 shell%last_exit_status = 1
4700 return
4701 end if
4702 else if (arg == '-F') then
4703 ! Function name
4704 if (i + 1 <= cmd%num_tokens) then
4705 i = i + 1
4706 function_arg = trim(cmd%tokens(i))
4707 i = i + 1
4708 else
4709 write(error_unit, '(a)') 'complete: -F requires an argument'
4710 shell%last_exit_status = 1
4711 return
4712 end if
4713 else if (arg == '-A') then
4714 ! Built-in action
4715 if (i + 1 <= cmd%num_tokens) then
4716 i = i + 1
4717 action_arg = trim(cmd%tokens(i))
4718 i = i + 1
4719 else
4720 write(error_unit, '(a)') 'complete: -A requires an argument'
4721 shell%last_exit_status = 1
4722 return
4723 end if
4724 else if (arg == '-o') then
4725 ! Option
4726 if (i + 1 <= cmd%num_tokens) then
4727 i = i + 1
4728 option_arg = trim(cmd%tokens(i))
4729 i = i + 1
4730 else
4731 write(error_unit, '(a)') 'complete: -o requires an argument'
4732 shell%last_exit_status = 1
4733 return
4734 end if
4735 else if (arg == '-P') then
4736 ! Prefix
4737 if (i + 1 <= cmd%num_tokens) then
4738 i = i + 1
4739 prefix_arg = trim(cmd%tokens(i))
4740 i = i + 1
4741 else
4742 write(error_unit, '(a)') 'complete: -P requires an argument'
4743 shell%last_exit_status = 1
4744 return
4745 end if
4746 else if (arg == '-S') then
4747 ! Suffix
4748 if (i + 1 <= cmd%num_tokens) then
4749 i = i + 1
4750 suffix_arg = trim(cmd%tokens(i))
4751 i = i + 1
4752 else
4753 write(error_unit, '(a)') 'complete: -S requires an argument'
4754 shell%last_exit_status = 1
4755 return
4756 end if
4757 else if (arg == '-X') then
4758 ! Filter pattern
4759 if (i + 1 <= cmd%num_tokens) then
4760 i = i + 1
4761 filter_arg = trim(cmd%tokens(i))
4762 i = i + 1
4763 else
4764 write(error_unit, '(a)') 'complete: -X requires an argument'
4765 shell%last_exit_status = 1
4766 return
4767 end if
4768 else if (arg(1:1) /= '-') then
4769 ! Command name
4770 num_commands = num_commands + 1
4771 if (num_commands <= 50) then
4772 command_names(num_commands) = trim(arg)
4773 end if
4774 i = i + 1
4775 else
4776 write(error_unit, '(a)') 'complete: invalid option: ' // trim(arg)
4777 shell%last_exit_status = 2
4778 return
4779 end if
4780 end do
4781
4782 ! Handle list flag
4783 if (list_flag) then
4784 block
4785 logical :: has_specs
4786 call list_completion_specs(has_specs)
4787 if (has_specs) then
4788 shell%last_exit_status = 0
4789 else
4790 shell%last_exit_status = 1
4791 end if
4792 end block
4793 return
4794 end if
4795
4796 ! Handle remove flag
4797 if (remove_flag) then
4798 if (num_commands == 0) then
4799 ! Remove all specs
4800 call clear_completion_specs()
4801 else
4802 ! Remove specific specs
4803 do i = 1, num_commands
4804 if (.not. remove_completion_spec(trim(command_names(i)))) then
4805 shell%last_exit_status = 1
4806 end if
4807 end do
4808 end if
4809 shell%last_exit_status = 0
4810 return
4811 end if
4812
4813 ! Build completion spec
4814 if (len_trim(word_list_arg) > 0) then
4815 call parse_word_list(word_list_arg, spec)
4816 end if
4817
4818 if (len_trim(function_arg) > 0) then
4819 spec%function_name = function_arg
4820 end if
4821
4822 if (len_trim(action_arg) > 0) then
4823 select case(trim(action_arg))
4824 case('alias')
4825 spec%builtin_alias = .true.
4826 case('command')
4827 spec%builtin_command = .true.
4828 case('directory')
4829 spec%builtin_directory = .true.
4830 case('file')
4831 spec%builtin_file = .true.
4832 case('function')
4833 spec%builtin_function = .true.
4834 case('hostname')
4835 spec%builtin_hostname = .true.
4836 case('variable')
4837 spec%builtin_variable = .true.
4838 case('user')
4839 spec%builtin_user = .true.
4840 case('group')
4841 spec%builtin_group = .true.
4842 case('service')
4843 spec%builtin_service = .true.
4844 case('export')
4845 spec%builtin_export = .true.
4846 case('keyword')
4847 spec%builtin_keyword = .true.
4848 case('builtin')
4849 spec%builtin_builtin = .true.
4850 case default
4851 write(error_unit, '(a)') 'complete: invalid action: ' // trim(action_arg)
4852 shell%last_exit_status = 1
4853 return
4854 end select
4855 end if
4856
4857 if (len_trim(option_arg) > 0) then
4858 select case(trim(option_arg))
4859 case('default')
4860 spec%use_default = .true.
4861 case('dirnames')
4862 spec%use_dirnames = .true.
4863 case('filenames')
4864 spec%use_filenames = .true.
4865 case('nospace')
4866 spec%nospace = .true.
4867 case('plusdirs')
4868 spec%plusdirs = .true.
4869 case('nosort')
4870 spec%nosort = .true.
4871 case default
4872 write(error_unit, '(a)') 'complete: invalid option: ' // trim(option_arg)
4873 shell%last_exit_status = 1
4874 return
4875 end select
4876 end if
4877
4878 if (len_trim(prefix_arg) > 0) then
4879 spec%prefix = prefix_arg
4880 end if
4881
4882 if (len_trim(suffix_arg) > 0) then
4883 spec%suffix = suffix_arg
4884 end if
4885
4886 if (len_trim(filter_arg) > 0) then
4887 spec%filter_pattern = filter_arg
4888 end if
4889
4890 ! Register spec for each command
4891 if (num_commands == 0) then
4892 write(error_unit, '(a)') 'complete: no command names specified'
4893 shell%last_exit_status = 1
4894 return
4895 end if
4896
4897 do i = 1, num_commands
4898 spec%command = trim(command_names(i))
4899 if (.not. register_completion_spec(spec)) then
4900 write(error_unit, '(a)') 'complete: failed to register spec for ' // trim(command_names(i))
4901 shell%last_exit_status = 1
4902 return
4903 end if
4904 end do
4905
4906 shell%last_exit_status = 0
4907 end subroutine builtin_complete
4908
4909 subroutine builtin_compgen(cmd, shell)
4910 type(command_t), intent(in) :: cmd
4911 type(shell_state_t), intent(inout) :: shell
4912 character(len=256) :: word_list_arg, prefix_arg
4913 integer :: i
4914 character(len=256) :: arg
4915 type(completion_spec_t) :: spec
4916 character(len=256) :: completions(MAX_COMPLETIONS)
4917 integer :: completion_count
4918
4919 ! compgen is used for testing completion specs
4920 ! Syntax: compgen -W "word1 word2 word3" [prefix]
4921
4922 word_list_arg = ''
4923 prefix_arg = ''
4924
4925 ! Parse arguments
4926 i = 2
4927 do while (i <= cmd%num_tokens)
4928 arg = trim(cmd%tokens(i))
4929
4930 if (arg == '-W') then
4931 ! Word list
4932 if (i + 1 <= cmd%num_tokens) then
4933 i = i + 1
4934 word_list_arg = trim(cmd%tokens(i))
4935 i = i + 1
4936 else
4937 write(error_unit, '(a)') 'compgen: -W requires an argument'
4938 shell%last_exit_status = 1
4939 return
4940 end if
4941 else if (arg(1:1) /= '-') then
4942 ! Prefix to match
4943 prefix_arg = trim(arg)
4944 i = i + 1
4945 else
4946 write(error_unit, '(a)') 'compgen: invalid option: ' // trim(arg)
4947 shell%last_exit_status = 2
4948 return
4949 end if
4950 end do
4951
4952 ! Build a temporary spec for testing
4953 spec%is_active = .true.
4954 spec%word_list_count = 0
4955 spec%function_name = ''
4956 spec%filter_pattern = ''
4957 spec%prefix = ''
4958 spec%suffix = ''
4959 spec%use_default = .false.
4960 spec%use_dirnames = .false.
4961 spec%use_filenames = .false.
4962 spec%nospace = .false.
4963 spec%plusdirs = .false.
4964 spec%nosort = .true. ! compgen preserves input order (no sorting)
4965
4966 if (len_trim(word_list_arg) > 0) then
4967 call parse_word_list(word_list_arg, spec)
4968 end if
4969
4970 ! Generate completions
4971 call generate_word_list_completions(spec, prefix_arg, completions, completion_count)
4972
4973 ! Print completions (one per line)
4974 do i = 1, completion_count
4975 write(output_unit, '(a)') trim(completions(i))
4976 end do
4977
4978 if (completion_count > 0) then
4979 shell%last_exit_status = 0
4980 else
4981 shell%last_exit_status = 1
4982 end if
4983 end subroutine builtin_compgen
4984
4985 ! ===========================================================================
4986 ! Directory History Functions (Fish-style prevd/nextd)
4987 ! ===========================================================================
4988
4989 ! Add directory to history
4990 subroutine add_to_dir_history(shell, dir)
4991 type(shell_state_t), intent(inout) :: shell
4992 character(len=*), intent(in) :: dir
4993 integer :: i
4994
4995 ! Don't add if it's the same as the last entry (avoid consecutive duplicates)
4996 if (shell%dir_history_size > 0) then
4997 if (trim(shell%dir_history(shell%dir_history_size)) == trim(dir)) then
4998 ! Duplicate of last entry, just update index to point here
4999 shell%dir_history_index = shell%dir_history_size
5000 return
5001 end if
5002 end if
5003
5004 ! If we're browsing history (not at the end), truncate everything after current position
5005 ! This implements browser-style history: go back, then cd somewhere = discard forward history
5006 if (shell%dir_history_index > 0 .and. shell%dir_history_index < shell%dir_history_size) then
5007 shell%dir_history_size = shell%dir_history_index
5008 end if
5009
5010 ! Add new directory
5011 if (shell%dir_history_size < 50) then
5012 shell%dir_history_size = shell%dir_history_size + 1
5013 else
5014 ! Shift history left (circular buffer)
5015 do i = 1, 49
5016 shell%dir_history(i) = shell%dir_history(i + 1)
5017 end do
5018 end if
5019
5020 shell%dir_history(shell%dir_history_size) = trim(dir)
5021 ! Set index to point at the newly added directory (current position)
5022 shell%dir_history_index = shell%dir_history_size
5023 end subroutine add_to_dir_history
5024
5025 ! prevd builtin - go to previous directory in history
5026 subroutine builtin_prevd(cmd, shell)
5027 type(command_t), intent(in) :: cmd
5028 type(shell_state_t), intent(inout) :: shell
5029
5030 if (.false.) print *, cmd%num_tokens ! Silence unused warning
5031
5032 ! Check if we can go back (must be at index > 1)
5033 if (shell%dir_history_index <= 1) then
5034 write(error_unit, '(a)') 'prevd: no previous directory'
5035 shell%last_exit_status = 1
5036 return
5037 end if
5038
5039 ! Move back in history
5040 shell%dir_history_index = shell%dir_history_index - 1
5041
5042 if (change_directory(trim(shell%dir_history(shell%dir_history_index)))) then
5043 shell%oldpwd = shell%cwd
5044 shell%cwd = get_current_directory()
5045 write(output_unit, '(a)') trim(shell%cwd)
5046 shell%last_exit_status = 0
5047 else
5048 write(error_unit, '(a)') 'prevd: cannot access directory'
5049 shell%dir_history_index = shell%dir_history_index + 1
5050 shell%last_exit_status = 1
5051 end if
5052 end subroutine builtin_prevd
5053
5054 ! nextd builtin - go to next directory in history
5055 subroutine builtin_nextd(cmd, shell)
5056 type(command_t), intent(in) :: cmd
5057 type(shell_state_t), intent(inout) :: shell
5058
5059 if (.false.) print *, cmd%num_tokens ! Silence unused warning
5060
5061 ! Check if we can go forward (must be at index < size)
5062 if (shell%dir_history_index >= shell%dir_history_size) then
5063 write(error_unit, '(a)') 'nextd: no next directory'
5064 shell%last_exit_status = 1
5065 return
5066 end if
5067
5068 ! Move forward in history
5069 shell%dir_history_index = shell%dir_history_index + 1
5070
5071 if (change_directory(trim(shell%dir_history(shell%dir_history_index)))) then
5072 shell%oldpwd = shell%cwd
5073 shell%cwd = get_current_directory()
5074 write(output_unit, '(a)') trim(shell%cwd)
5075 shell%last_exit_status = 0
5076 else
5077 write(error_unit, '(a)') 'nextd: cannot access directory'
5078 shell%dir_history_index = shell%dir_history_index - 1
5079 shell%last_exit_status = 1
5080 end if
5081 end subroutine builtin_nextd
5082
5083 ! dirh builtin - show directory history
5084 subroutine builtin_dirh(cmd, shell)
5085 type(command_t), intent(in) :: cmd
5086 type(shell_state_t), intent(inout) :: shell
5087 integer :: i
5088
5089 if (.false.) print *, cmd%num_tokens ! Silence unused warning
5090
5091 if (shell%dir_history_size == 0) then
5092 write(output_unit, '(a)') 'Directory history is empty'
5093 shell%last_exit_status = 0
5094 return
5095 end if
5096
5097 write(output_unit, '(a)') 'Directory history:'
5098 do i = 1, shell%dir_history_size
5099 if (i == shell%dir_history_index) then
5100 ! Highlight current position
5101 write(output_unit, '(i3,a,a)') i, ' * ', trim(shell%dir_history(i))
5102 else
5103 write(output_unit, '(i3,a,a)') i, ' ', trim(shell%dir_history(i))
5104 end if
5105 end do
5106
5107 shell%last_exit_status = 0
5108 end subroutine builtin_dirh
5109
5110 ! Execute EXIT trap inline (to avoid circular dependency with executor module)
5111 subroutine execute_exit_trap_inline(shell)
5112 type(shell_state_t), intent(inout) :: shell
5113 character(len=:), allocatable :: trap_cmd
5114 integer :: saved_status
5115 type(pipeline_t) :: trap_pipeline
5116 integer :: i
5117
5118 ! Save trap command and clear
5119 trap_cmd = shell%pending_trap_command
5120 shell%pending_trap_command = ''
5121 shell%pending_trap_signal = 0
5122
5123 ! Save exit status (traps don't affect $?)
5124 saved_status = shell%last_exit_status
5125
5126 ! Set flag to prevent recursive traps
5127 shell%executing_trap = .true.
5128
5129 ! Parse trap command
5130 call parse_pipeline(trim(trap_cmd), trap_pipeline)
5131
5132 ! Execute it in current shell context (inline execution using c_system)
5133 ! We use c_system instead of execute_pipeline to avoid circular dependency
5134 if (len_trim(trap_cmd) > 0) then
5135 i = c_system(trim(trap_cmd) // c_null_char)
5136 end if
5137
5138 ! Clean up pipeline allocations
5139 if (allocated(trap_pipeline%commands)) then
5140 do i = 1, trap_pipeline%num_commands
5141 if (allocated(trap_pipeline%commands(i)%tokens)) deallocate(trap_pipeline%commands(i)%tokens)
5142 if (allocated(trap_pipeline%commands(i)%input_file)) deallocate(trap_pipeline%commands(i)%input_file)
5143 if (allocated(trap_pipeline%commands(i)%output_file)) deallocate(trap_pipeline%commands(i)%output_file)
5144 if (allocated(trap_pipeline%commands(i)%error_file)) deallocate(trap_pipeline%commands(i)%error_file)
5145 if (allocated(trap_pipeline%commands(i)%heredoc_delimiter)) deallocate(trap_pipeline%commands(i)%heredoc_delimiter)
5146 if (allocated(trap_pipeline%commands(i)%heredoc_content)) deallocate(trap_pipeline%commands(i)%heredoc_content)
5147 if (allocated(trap_pipeline%commands(i)%here_string)) deallocate(trap_pipeline%commands(i)%here_string)
5148 end do
5149 deallocate(trap_pipeline%commands)
5150 end if
5151
5152 ! Clear flag
5153 shell%executing_trap = .false.
5154
5155 ! Restore exit status (traps don't affect $?)
5156 shell%last_exit_status = saved_status
5157 end subroutine execute_exit_trap_inline
5158
5159
5160 end module builtins
5161