Fortran · 113766 bytes Raw Blame History
1 ! ==============================================================================
2 ! Module: executor (Extended with job control)
3 ! ==============================================================================
4 module executor
5 use shell_types
6 use system_interface
7 use builtin_interface
8 use parser
9 use job_control
10 use pipeline_helpers, only: expand_tokens, expand_command_globs, process_command_escapes
11 use variables, only: var_set_shell_variable => set_shell_variable, set_array_variable, set_array_element, &
12 get_shell_variable
13 use control_flow
14 use error_handling
15 use performance
16 use aliases, only: expand_alias, is_alias, get_alias
17 use shell_options
18 use signal_handling, only: execute_trap, TRAP_DEBUG, TRAP_ERR
19 use better_errors
20 use completion, only: register_completion_executor, completion_func_executor_t
21 use iso_fortran_env, only: error_unit, input_unit
22 use iso_c_binding
23 implicit none
24
25 public :: init_completion_executor
26
27 contains
28
29 subroutine execute_pipeline(pipeline, shell, original_input)
30 type(pipeline_t), intent(inout) :: pipeline
31 type(shell_state_t), intent(inout) :: shell
32 character(len=*), intent(in) :: original_input
33
34 integer :: i
35 logical :: should_continue
36
37 should_continue = .true.
38 i = 1
39
40 do while (i <= pipeline%num_commands .and. should_continue)
41 select case(pipeline%commands(i)%separator)
42 case(SEP_PIPE)
43 call execute_pipe_chain(pipeline, i, shell, original_input)
44 call check_errexit(shell, shell%last_exit_status)
45 ! Check if shell should exit (e.g., due to ${VAR?error})
46 if (.not. shell%running) exit
47 do while (i <= pipeline%num_commands)
48 if (pipeline%commands(i)%separator /= SEP_PIPE) exit
49 i = i + 1
50 end do
51 ! Skip the last command in the pipeline (it has non-PIPE separator)
52 i = i + 1
53
54 case(SEP_SEMICOLON, SEP_NONE)
55 call execute_single(pipeline%commands(i), shell, original_input)
56 ! NOTE: Sourcing is now handled at the AST level (in execute_ast) or by the caller
57 ! We don't process sourced files inline here to avoid double-processing
58 call check_errexit(shell, shell%last_exit_status)
59 ! Check if shell should exit (e.g., due to ${VAR?error})
60 if (.not. shell%running) exit
61 i = i + 1
62
63 case(SEP_AND)
64 call execute_single(pipeline%commands(i), shell, original_input)
65 should_continue = (shell%last_exit_status == 0)
66 ! POSIX: errexit should be ignored in AND-OR lists
67 ! Check if shell should exit (e.g., due to ${VAR?error})
68 if (.not. shell%running) exit
69 i = i + 1
70
71 case(SEP_OR)
72 call execute_single(pipeline%commands(i), shell, original_input)
73 should_continue = (shell%last_exit_status /= 0)
74 ! POSIX: errexit should be ignored in AND-OR lists
75 ! Check if shell should exit (e.g., due to ${VAR?error})
76 if (.not. shell%running) exit
77 i = i + 1
78 end select
79 end do
80 end subroutine
81
82 ! DEPRECATED: Legacy pipeline execution path, only used by FORTSH_USE_OLD_PARSER=1.
83 ! The AST executor (ast_executor.f90::execute_pipeline_node) is the primary pipeline
84 ! implementation with full feature parity. This subroutine will be removed when the
85 ! legacy parser path is retired.
86 subroutine execute_pipe_chain(pipeline, start_idx, shell, original_input)
87 type(pipeline_t), intent(inout) :: pipeline
88 integer, intent(in) :: start_idx
89 type(shell_state_t), intent(inout) :: shell
90 character(len=*), intent(in) :: original_input
91
92 integer :: i, j, pipe_count, end_idx
93 integer(c_int), allocatable :: pipefd(:,:)
94 integer(c_pid_t), allocatable :: pids(:)
95 integer(c_pid_t) :: pgid
96 integer :: ret, job_id
97 logical :: foreground
98 type(c_funptr) :: old_handler
99 type(pipeline_t) :: group_pipeline
100 integer :: k
101 character(len=2048) :: reconstructed_cmd
102
103 ! Count pipes in chain
104 pipe_count = 0
105 end_idx = start_idx
106 do i = start_idx, pipeline%num_commands - 1
107 if (pipeline%commands(i)%separator == SEP_PIPE) then
108 pipe_count = pipe_count + 1
109 end_idx = i + 1
110 else
111 exit
112 end if
113 end do
114
115 if (pipe_count == 0) then
116 call execute_single(pipeline%commands(start_idx), shell, original_input)
117 return
118 end if
119
120 foreground = .not. pipeline%commands(end_idx)%background
121
122 allocate(pipefd(2, pipe_count))
123 allocate(pids(pipe_count + 1))
124
125 ! Create all pipes
126 do i = 1, pipe_count
127 if (.not. create_pipe(pipefd(1,i), pipefd(2,i))) then
128 write(error_unit, '(a)') 'Error: Failed to create pipe'
129 shell%last_exit_status = 1
130 return
131 end if
132 end do
133
134 pgid = 0
135
136 ! Trace all pipeline commands BEFORE forking (so order is deterministic)
137 if (shell%option_xtrace) then
138 do i = start_idx, end_idx
139 if (pipeline%commands(i)%num_tokens > 0) then
140 call reconstruct_command_from_tokens(pipeline%commands(i), reconstructed_cmd)
141 call trace_command(shell, trim(reconstructed_cmd))
142 end if
143 end do
144 end if
145
146 ! Flush all output before forking to prevent buffer duplication
147 flush(output_unit)
148 flush(error_unit)
149
150 ! Fork all processes
151 do i = start_idx, end_idx
152 pids(i - start_idx + 1) = c_fork()
153
154 if (pids(i - start_idx + 1) == 0) then
155 ! Child process
156
157 ! Set process group
158 if (pgid == 0) pgid = c_getpid()
159 ret = c_setpgid(0, pgid)
160
161 ! Reset signal handlers to default
162 old_handler = c_signal(SIGINT, c_null_funptr)
163 old_handler = c_signal(SIGPIPE, c_null_funptr)
164 old_handler = c_signal(SIGTSTP, c_null_funptr)
165 old_handler = c_signal(SIGTTIN, c_null_funptr)
166 old_handler = c_signal(SIGTTOU, c_null_funptr)
167
168 ! Set up pipes
169 if (i > start_idx) then
170 ret = c_dup2(pipefd(1, i - start_idx), STDIN_FD)
171 end if
172
173 if (i < end_idx) then
174 ret = c_dup2(pipefd(2, i - start_idx + 1), STDOUT_FD)
175 end if
176
177 ! Close all pipe FDs
178 do j = 1, pipe_count
179 ret = c_close(pipefd(1, j))
180 ret = c_close(pipefd(2, j))
181 end do
182
183 ! Handle here document
184 call handle_heredoc(pipeline%commands(i), shell)
185
186 ! Handle command groups { cmd1; cmd2; }
187 if (pipeline%commands(i)%is_command_group .and. allocated(pipeline%commands(i)%group_content)) then
188 ! Parse the group content as a pipeline and execute it
189 call parse_pipeline(pipeline%commands(i)%group_content, group_pipeline)
190 if (group_pipeline%num_commands > 0) then
191 call execute_pipeline(group_pipeline, shell, pipeline%commands(i)%group_content)
192 ! Clean up
193 do k = 1, group_pipeline%num_commands
194 if (allocated(group_pipeline%commands(k)%tokens)) deallocate(group_pipeline%commands(k)%tokens)
195 if (allocated(group_pipeline%commands(k)%input_file)) deallocate(group_pipeline%commands(k)%input_file)
196 if (allocated(group_pipeline%commands(k)%output_file)) deallocate(group_pipeline%commands(k)%output_file)
197 if (allocated(group_pipeline%commands(k)%error_file)) deallocate(group_pipeline%commands(k)%error_file)
198 if (allocated(group_pipeline%commands(k)%heredoc_delimiter)) deallocate(group_pipeline%commands(k)%heredoc_delimiter)
199 if (allocated(group_pipeline%commands(k)%heredoc_content)) deallocate(group_pipeline%commands(k)%heredoc_content)
200 if (allocated(group_pipeline%commands(k)%here_string)) deallocate(group_pipeline%commands(k)%here_string)
201 if (allocated(group_pipeline%commands(k)%group_content)) deallocate(group_pipeline%commands(k)%group_content)
202 end do
203 if (allocated(group_pipeline%commands)) deallocate(group_pipeline%commands)
204 end if
205 call c_exit(int(shell%last_exit_status, c_int))
206 end if
207
208 ! Handle subshells ( cmd1; cmd2 )
209 if (pipeline%commands(i)%is_subshell .and. allocated(pipeline%commands(i)%subshell_content)) then
210 ! Parse the subshell content as a pipeline and execute it
211 call parse_pipeline(pipeline%commands(i)%subshell_content, group_pipeline)
212 if (group_pipeline%num_commands > 0) then
213 call execute_pipeline(group_pipeline, shell, pipeline%commands(i)%subshell_content)
214 ! Clean up
215 do k = 1, group_pipeline%num_commands
216 if (allocated(group_pipeline%commands(k)%tokens)) deallocate(group_pipeline%commands(k)%tokens)
217 if (allocated(group_pipeline%commands(k)%input_file)) deallocate(group_pipeline%commands(k)%input_file)
218 if (allocated(group_pipeline%commands(k)%output_file)) deallocate(group_pipeline%commands(k)%output_file)
219 if (allocated(group_pipeline%commands(k)%error_file)) deallocate(group_pipeline%commands(k)%error_file)
220 if (allocated(group_pipeline%commands(k)%heredoc_delimiter)) deallocate(group_pipeline%commands(k)%heredoc_delimiter)
221 if (allocated(group_pipeline%commands(k)%heredoc_content)) deallocate(group_pipeline%commands(k)%heredoc_content)
222 if (allocated(group_pipeline%commands(k)%here_string)) deallocate(group_pipeline%commands(k)%here_string)
223 if (allocated(group_pipeline%commands(k)%group_content)) deallocate(group_pipeline%commands(k)%group_content)
224 if (allocated(group_pipeline%commands(k)%subshell_content)) deallocate(group_pipeline%commands(k)%subshell_content)
225 end do
226 if (allocated(group_pipeline%commands)) deallocate(group_pipeline%commands)
227 end if
228 call c_exit(int(shell%last_exit_status, c_int))
229 end if
230
231 ! Check if we have tokens to process
232 if (pipeline%commands(i)%num_tokens == 0) then
233 ! No tokens (shouldn't happen after command group handling)
234 write(error_unit, '(a)') 'Error: command has no tokens'
235 call c_exit(1)
236 end if
237
238 ! Expand variables and execute (unless pre-expanded in pipeline)
239 if (.not. pipeline%commands(i)%skip_expansion) then
240 call expand_tokens(pipeline%commands(i), shell)
241 end if
242
243 ! Expand glob patterns
244 call expand_command_globs(pipeline%commands(i), shell)
245
246 ! Process backslash escapes AFTER glob expansion
247 call process_command_escapes(pipeline%commands(i))
248
249 ! Handle eval builtin directly (to avoid circular dependency)
250 ! Removed special handling for eval - it's now a regular builtin
251 if (is_builtin(pipeline%commands(i)%tokens(1))) then
252 ! Builtins in pipes need redirections applied
253 call setup_redirections(pipeline%commands(i), shell)
254 call execute_builtin(pipeline%commands(i), shell)
255 call c_exit(int(shell%last_exit_status, c_int))
256 else
257 call setup_redirections(pipeline%commands(i), shell)
258 call exec_child(pipeline%commands(i)%tokens, pipeline%commands(i)%num_tokens)
259 call c_exit(127)
260 end if
261 else if (pids(i - start_idx + 1) > 0) then
262 ! Parent: set process group
263 if (pgid == 0) pgid = pids(1)
264 ret = c_setpgid(pids(i - start_idx + 1), pgid)
265 end if
266 end do
267
268 ! Parent: close all pipes
269 do i = 1, pipe_count
270 ret = c_close(pipefd(1, i))
271 ret = c_close(pipefd(2, i))
272 end do
273
274 ! Add job to job list
275 if (.not. foreground) then
276 job_id = add_job(shell, pgid, original_input, .false.)
277 ! Only print job notification in interactive mode
278 if (shell%is_interactive) then
279 write(output_unit, '(a,i0,a,i0)') '[', job_id, '] ', pgid
280 end if
281 shell%last_pid = pgid
282 ! Set $! to last process in background pipeline
283 shell%last_bg_pid = pids(pipe_count + 1)
284 else if (shell%is_interactive) then
285 ! Give terminal to job
286 ret = c_tcsetpgrp(shell%shell_terminal, pgid)
287 end if
288
289 ! Wait for all children (if foreground)
290 if (foreground) then
291 call wait_for_pipeline(shell, pids, pipe_count + 1)
292
293 ! Take back terminal
294 if (shell%is_interactive) then
295 ret = c_tcsetpgrp(shell%shell_terminal, shell%shell_pgid)
296 end if
297 end if
298
299 deallocate(pipefd)
300 deallocate(pids)
301 end subroutine
302
303 ! Wait for pipeline processes with POSIX-compliant exit status handling
304 subroutine wait_for_pipeline(shell, pids, num_processes)
305 type(shell_state_t), intent(inout) :: shell
306 integer(c_pid_t), intent(in) :: pids(:)
307 integer, intent(in) :: num_processes
308
309 integer(c_int), target :: status
310 integer :: i, ret
311 integer, allocatable :: exit_statuses(:)
312
313 allocate(exit_statuses(num_processes))
314
315 ! Wait for all processes and collect their exit statuses
316 do i = 1, num_processes
317 ret = c_waitpid(pids(i), c_loc(status), WUNTRACED)
318 if (ret > 0) then
319 if (WIFEXITED(status)) then
320 exit_statuses(i) = WEXITSTATUS(status)
321 else if (WIFSIGNALED(status)) then
322 exit_statuses(i) = 128 + WTERMSIG(status)
323 else
324 exit_statuses(i) = 1
325 end if
326 else
327 exit_statuses(i) = 1
328 end if
329 end do
330
331 ! Set exit status according to POSIX/bash rules
332 if (shell%option_pipefail) then
333 ! pipefail: return rightmost non-zero exit status (bash-correct semantics)
334 shell%last_exit_status = 0
335 do i = num_processes, 1, -1
336 if (exit_statuses(i) /= 0) then
337 shell%last_exit_status = exit_statuses(i)
338 exit
339 end if
340 end do
341 else
342 ! Normal: return exit status of last (rightmost) command
343 shell%last_exit_status = exit_statuses(num_processes)
344 end if
345
346 deallocate(exit_statuses)
347 end subroutine
348
349 recursive subroutine execute_single(cmd, shell, original_input)
350 use control_flow, only: capture_loop_command, is_control_flow_keyword
351 type(command_t), intent(inout) :: cmd
352 type(shell_state_t), intent(inout) :: shell
353 character(len=*), intent(in) :: original_input
354 logical :: should_execute, trap_executed, negate_exit_status
355 integer(int64) :: exec_start_time
356 integer :: i
357 character(len=256) :: reconstructed_cmd
358 character(len=MAX_TOKEN_LEN), allocatable :: temp_tokens(:)
359 type(pipeline_t) :: pipeline
360
361 ! Start performance timing
362 call start_timer('execute_single', exec_start_time)
363
364 ! Handle subshells ( cmd1; cmd2 )
365 if (cmd%is_subshell .and. allocated(cmd%subshell_content)) then
366 ! Fork a subshell and execute commands in it
367 call execute_subshell(cmd%subshell_content, shell, original_input)
368 ! End performance timing
369 call end_timer('execute_single', exec_start_time, total_exec_time)
370 return
371 end if
372
373 ! Handle command groups { cmd1; cmd2; }
374 if (cmd%is_command_group .and. allocated(cmd%group_content)) then
375 ! Parse the group content as a pipeline and execute it
376 call parse_pipeline(cmd%group_content, pipeline)
377 if (pipeline%num_commands > 0) then
378 call execute_pipeline(pipeline, shell, cmd%group_content)
379 ! Clean up
380 do i = 1, pipeline%num_commands
381 if (allocated(pipeline%commands(i)%tokens)) deallocate(pipeline%commands(i)%tokens)
382 if (allocated(pipeline%commands(i)%input_file)) deallocate(pipeline%commands(i)%input_file)
383 if (allocated(pipeline%commands(i)%output_file)) deallocate(pipeline%commands(i)%output_file)
384 if (allocated(pipeline%commands(i)%error_file)) deallocate(pipeline%commands(i)%error_file)
385 if (allocated(pipeline%commands(i)%heredoc_delimiter)) deallocate(pipeline%commands(i)%heredoc_delimiter)
386 if (allocated(pipeline%commands(i)%heredoc_content)) deallocate(pipeline%commands(i)%heredoc_content)
387 if (allocated(pipeline%commands(i)%here_string)) deallocate(pipeline%commands(i)%here_string)
388 if (allocated(pipeline%commands(i)%group_content)) deallocate(pipeline%commands(i)%group_content)
389 end do
390 if (allocated(pipeline%commands)) deallocate(pipeline%commands)
391 end if
392 ! End performance timing
393 call end_timer('execute_single', exec_start_time, total_exec_time)
394 return
395 end if
396
397 if (cmd%num_tokens == 0) return
398
399 ! Check for empty command (e.g., from empty command substitution result)
400 if (len_trim(cmd%tokens(1)) == 0) then
401 ! Empty command - nothing to execute
402 ! Note: exit status from command substitution is preserved
403 return
404 end if
405
406 ! Handle negation operator (!)
407 negate_exit_status = .false.
408
409 ! Check if first token is the negation operator
410 if (trim(cmd%tokens(1)) == '!') then
411 negate_exit_status = .true.
412
413 ! Remove the ! from tokens and shift everything left
414 if (cmd%num_tokens > 1) then
415 allocate(temp_tokens(cmd%num_tokens - 1))
416 do i = 2, cmd%num_tokens
417 temp_tokens(i - 1) = cmd%tokens(i)
418 end do
419
420 ! Replace cmd%tokens with shifted tokens
421 deallocate(cmd%tokens)
422 allocate(character(len=MAX_TOKEN_LEN) :: cmd%tokens(cmd%num_tokens - 1))
423 cmd%tokens = temp_tokens
424 cmd%num_tokens = cmd%num_tokens - 1
425 deallocate(temp_tokens)
426 else
427 ! Just "!" with no command - that's an error
428 write(error_unit, '(a)') '!: command not found'
429 shell%last_exit_status = 127
430 return
431 end if
432 end if
433
434 ! Capture command if we're inside a loop body (before executing control flow)
435 if (shell%control_depth > 0) then
436 if (shell%control_stack(shell%control_depth)%capturing_loop_body) then
437 if (allocated(cmd%tokens) .and. cmd%num_tokens > 0) then
438 ! Reconstruct the command from tokens (don't use original_input which may contain the whole line)
439 call reconstruct_command_from_tokens(cmd, reconstructed_cmd)
440
441 ! Track nested depth for proper 'done' matching
442 if (trim(cmd%tokens(1)) == 'for' .or. trim(cmd%tokens(1)) == 'while' .or. &
443 (len_trim(cmd%tokens(1)) >= 5 .and. cmd%tokens(1)(1:5) == 'for((')) then
444 ! Starting a nested loop - increment nesting depth
445 shell%control_stack(shell%control_depth)%capture_nesting_depth = &
446 shell%control_stack(shell%control_depth)%capture_nesting_depth + 1
447 call capture_loop_command(shell, trim(reconstructed_cmd))
448 return
449 else if (trim(cmd%tokens(1)) == 'do') then
450 ! 'do' for nested loop - just capture it
451 if (shell%control_stack(shell%control_depth)%capture_nesting_depth > 0) then
452 call capture_loop_command(shell, trim(reconstructed_cmd))
453 return
454 end if
455 ! If nesting depth is 0, this 'do' is an error - let it be processed
456 else if (trim(cmd%tokens(1)) == 'done') then
457 ! Check nesting depth
458 if (shell%control_stack(shell%control_depth)%capture_nesting_depth > 0) then
459 ! This 'done' ends a nested loop
460 shell%control_stack(shell%control_depth)%capture_nesting_depth = &
461 shell%control_stack(shell%control_depth)%capture_nesting_depth - 1
462 call capture_loop_command(shell, trim(reconstructed_cmd))
463 return
464 else
465 ! This 'done' ends the current capturing loop - process normally
466 end if
467 else
468 ! Everything else gets captured
469 call capture_loop_command(shell, trim(reconstructed_cmd))
470 return
471 end if
472 end if
473 end if
474 end if
475
476 ! Check for control flow keywords and apply control flow state
477 if (allocated(cmd%tokens) .and. cmd%num_tokens > 0) then
478 if (is_control_flow_keyword(cmd%tokens(1))) then
479 ! Special handling for single-line if statements: if condition; then command; fi
480 ! Check BEFORE processing control flow, because process_control_flow will set should_execute=false for "then"/"else"
481 if (trim(cmd%tokens(1)) == 'then' .and. cmd%num_tokens > 1) then
482 call process_control_flow(cmd, shell, should_execute)
483 call execute_inline_then_commands(cmd, shell)
484 return
485 end if
486
487 ! Special handling for single-line else: else echo command
488 if (trim(cmd%tokens(1)) == 'else' .and. cmd%num_tokens > 1) then
489 call process_control_flow(cmd, shell, should_execute)
490 call execute_inline_then_commands(cmd, shell) ! Reuse same function since logic is identical
491 return
492 end if
493
494 ! Special handling for single-line elif: elif condition; then echo command
495 if (trim(cmd%tokens(1)) == 'elif') then
496 call process_control_flow(cmd, shell, should_execute)
497 ! If the elif condition was true and we should execute, wait for the "then" keyword
498 ! The inline command will be handled when we see "then"
499 return
500 end if
501
502 call process_control_flow(cmd, shell, should_execute)
503
504 ! If we just processed a 'done' keyword, execute the loop body immediately
505 ! This ensures loops execute inline, not deferred until after the pipeline
506 if (trim(cmd%tokens(1)) == 'done') then
507 call replay_loop_if_needed(shell)
508 end if
509
510 if (.not. should_execute) return
511 else
512 ! For regular commands, check if we should execute based on control flow state
513 call process_control_flow(cmd, shell, should_execute)
514 if (.not. should_execute) return
515 end if
516 end if
517
518 ! Handle case pattern: skip first token if flag is set
519 if (shell%case_pattern_skip_first_token .and. cmd%num_tokens > 1) then
520 ! Shift tokens left to skip the pattern token
521 allocate(temp_tokens(cmd%num_tokens - 1))
522 do i = 2, cmd%num_tokens
523 temp_tokens(i - 1) = cmd%tokens(i)
524 end do
525 deallocate(cmd%tokens)
526 allocate(character(len=MAX_TOKEN_LEN) :: cmd%tokens(cmd%num_tokens - 1))
527 cmd%tokens = temp_tokens
528 cmd%num_tokens = cmd%num_tokens - 1
529 deallocate(temp_tokens)
530 ! Reset the flag
531 shell%case_pattern_skip_first_token = .false.
532 end if
533
534 ! Handle here document input
535 ! Only read from stdin if content wasn't already extracted from input string
536 if (allocated(cmd%heredoc_delimiter) .and. .not. allocated(cmd%heredoc_content)) then
537 call read_heredoc(cmd%heredoc_delimiter, cmd%heredoc_content, shell, cmd%heredoc_strip_tabs)
538 end if
539
540 ! Check if this is a function definition BEFORE expanding variables
541 ! (so $1, $2, etc. remain literal in the function body)
542 if (is_function_definition_command(cmd, shell)) then
543 ! Function was registered, don't execute anything
544 shell%last_exit_status = 0
545 return
546 end if
547
548 ! Expand variables in all tokens (except for defun and assignments)
549 ! Skip expansion for assignments because execute_assignment needs to handle
550 ! quote-aware expansion properly (e.g., CMD="echo \$X" should store "echo $X")
551 ! Also skip if already pre-expanded in pipeline
552 if (.not. cmd%skip_expansion .and. &
553 trim(cmd%tokens(1)) /= 'defun' .and. &
554 .not. (index(cmd%tokens(1), '=') > 0 .and. index(cmd%tokens(1), '=') > 1)) then
555 ! Initialize token metadata if not set (for old parser path)
556 call init_token_metadata(cmd)
557 call expand_tokens(cmd, shell)
558 end if
559
560 ! All tokens removed by expansion (e.g., unquoted $(exit 5) expands to nothing)
561 ! Preserve exit status from command substitution and return
562 if (cmd%num_tokens == 0) then
563 call end_timer('execute_single', exec_start_time, total_exec_time)
564 return
565 end if
566
567 ! Check if parameter expansion error occurred (${VAR?error})
568 if (shell%fatal_expansion_error) then
569 ! NOTE: Don't reset flag here - let it propagate to subshell handler
570 ! The subshell code uses this flag to convert exit code 127 to 1 (bash behavior)
571 ! End performance timing
572 call end_timer('execute_single', exec_start_time, total_exec_time)
573 ! POSIX: In non-interactive shells, exit the shell entirely
574 if (.not. shell%is_interactive) then
575 shell%running = .false.
576 end if
577 return ! Abort command execution
578 end if
579
580 ! Check if arithmetic expansion error occurred
581 if (shell%arithmetic_error) then
582 shell%arithmetic_error = .false. ! Reset flag
583 ! End performance timing
584 call end_timer('execute_single', exec_start_time, total_exec_time)
585 return ! Abort command execution
586 end if
587
588 ! Check for empty command after expansion (e.g., $(exit 0) returns empty)
589 if (cmd%num_tokens > 0 .and. len_trim(cmd%tokens(1)) == 0) then
590 ! Empty command after expansion - nothing to execute
591 ! Note: exit status from command substitution is preserved
592 call end_timer('execute_single', exec_start_time, total_exec_time)
593 return
594 end if
595
596 ! Check for variable assignment (after expansion so ${...} is processed)
597 ! DISABLED: Now that we skip expand_tokens for assignments (line 503-506),
598 ! all assignments should go through execute_assignment which properly handles
599 ! backslash escapes in double-quoted strings.
600 ! if (cmd%num_tokens == 1 .and. is_assignment(cmd%tokens(1))) then
601 ! call handle_assignment(shell, cmd%tokens(1))
602 ! return
603 ! end if
604
605 ! Expand glob patterns (except for defun)
606 if (trim(cmd%tokens(1)) /= 'defun') then
607 call expand_command_globs(cmd, shell)
608 end if
609
610 ! Process backslash escapes AFTER glob expansion
611 if (trim(cmd%tokens(1)) /= 'defun') then
612 call process_command_escapes(cmd)
613 end if
614
615 ! === DEBUGGING & TRACING HOOKS ===
616 ! Execute DEBUG trap if set (before command execution)
617 trap_executed = execute_trap(shell, TRAP_DEBUG)
618
619 ! If a trap command was queued, execute it now
620 ! Check executing_trap to prevent recursion
621 ! Don't execute EXIT trap here - it should only execute when shell is exiting
622 if (len_trim(shell%pending_trap_command) > 0 .and. .not. shell%executing_trap .and. shell%pending_trap_signal /= 0) then
623 call execute_pending_trap(shell)
624 end if
625
626 ! Trace command if xtrace is enabled (set -x)
627 if (shell%option_xtrace .and. cmd%num_tokens > 0) then
628 ! Reconstruct command for tracing
629 call reconstruct_command_from_tokens(cmd, reconstructed_cmd)
630 call trace_command(shell, trim(reconstructed_cmd))
631 end if
632 ! === END DEBUGGING & TRACING HOOKS ===
633
634 ! Check for variable assignment: var=value or arr=(...)
635 ! Only recognize as assignment if name before = is valid (no $, `, etc.)
636 ! POSIX: Words containing $ before = are not assignments, they're commands
637 if (index(cmd%tokens(1), '=') > 0 .and. index(cmd%tokens(1), '=') > 1 .and. &
638 index(cmd%tokens(1)(1:index(cmd%tokens(1), '=')-1), '$') == 0) then
639 call execute_assignment(cmd, shell)
640 ! Check for ((expression)) arithmetic evaluation command
641 else if (len_trim(cmd%tokens(1)) >= 4 .and. &
642 cmd%tokens(1)(1:2) == '((' .and. &
643 cmd%tokens(1)(len_trim(cmd%tokens(1))-1:len_trim(cmd%tokens(1))) == '))') then
644 call execute_arithmetic_command(cmd, shell)
645 ! Check if it's a user-defined function (unless bypass_functions is set)
646 else if (.not. shell%bypass_functions .and. is_function(shell, cmd%tokens(1))) then
647 call execute_function(cmd, shell)
648 ! Eval is now handled as a regular builtin (no special case needed)
649 ! Check for cd-less navigation: if single token is a directory, treat as 'cd'
650 else if (cmd%num_tokens == 1 .and. file_is_directory(trim(cmd%tokens(1)))) then
651 ! Create synthetic cd command by properly reallocating tokens array
652 block
653 character(len=:), allocatable :: dir_path
654 integer :: token_len
655 ! Save the directory path
656 dir_path = trim(cmd%tokens(1))
657 token_len = len(cmd%tokens)
658 ! Deallocate old tokens and allocate new array with size 2
659 deallocate(cmd%tokens)
660 allocate(character(len=token_len) :: cmd%tokens(2))
661 cmd%tokens(1) = 'cd'
662 cmd%tokens(2) = dir_path
663 cmd%num_tokens = 2
664 end block
665 call execute_builtin_with_redirects(cmd, shell)
666 ! Check for alias expansion (unless bypass_aliases is set by 'command' builtin)
667 ! POSIX: Aliases are only expanded in interactive shells
668 else if (shell%is_interactive .and. .not. shell%bypass_aliases .and. is_alias(shell, cmd%tokens(1))) then
669 block
670 character(len=:), allocatable :: alias_command, expanded_command
671 character(len=4096) :: rest_of_command
672 integer :: j
673 type(pipeline_t) :: alias_pipeline
674
675 ! Get the alias command
676 alias_command = get_alias(shell, cmd%tokens(1))
677
678 ! Build the rest of the command (arguments after the aliased command)
679 rest_of_command = ''
680 do j = 2, cmd%num_tokens
681 if (j > 2) rest_of_command = trim(rest_of_command) // ' '
682 rest_of_command = trim(rest_of_command) // trim(cmd%tokens(j))
683 end do
684
685 ! Combine alias expansion with rest of arguments
686 if (len_trim(rest_of_command) > 0) then
687 expanded_command = trim(alias_command) // ' ' // trim(rest_of_command)
688 else
689 expanded_command = trim(alias_command)
690 end if
691
692 ! Parse the expanded command
693 call parse_pipeline(expanded_command, alias_pipeline)
694
695 if (alias_pipeline%num_commands > 0) then
696 ! Execute the expanded command
697 if (alias_pipeline%num_commands == 1) then
698 ! Single command - execute it directly with current redirections
699 ! Copy over any redirections from the original command
700 if (allocated(cmd%input_file)) then
701 alias_pipeline%commands(1)%input_file = cmd%input_file
702 end if
703 if (allocated(cmd%output_file)) then
704 alias_pipeline%commands(1)%output_file = cmd%output_file
705 alias_pipeline%commands(1)%append_output = cmd%append_output
706 alias_pipeline%commands(1)%force_clobber = cmd%force_clobber
707 end if
708 if (allocated(cmd%error_file)) then
709 alias_pipeline%commands(1)%error_file = cmd%error_file
710 alias_pipeline%commands(1)%append_error = cmd%append_error
711 end if
712 ! Copy other redirection flags
713 alias_pipeline%commands(1)%redirect_stderr_to_stdout = cmd%redirect_stderr_to_stdout
714 alias_pipeline%commands(1)%redirect_stdout_to_stderr = cmd%redirect_stdout_to_stderr
715 alias_pipeline%commands(1)%redirect_both_to_file = cmd%redirect_both_to_file
716 if (allocated(cmd%here_string)) then
717 alias_pipeline%commands(1)%here_string = cmd%here_string
718 end if
719 if (allocated(cmd%heredoc_delimiter)) then
720 alias_pipeline%commands(1)%heredoc_delimiter = cmd%heredoc_delimiter
721 alias_pipeline%commands(1)%heredoc_content = cmd%heredoc_content
722 alias_pipeline%commands(1)%heredoc_quoted = cmd%heredoc_quoted
723 end if
724
725 ! Execute the single command recursively
726 call execute_single(alias_pipeline%commands(1), shell, expanded_command)
727 else
728 ! Multiple commands - execute as pipeline
729 call execute_pipeline(alias_pipeline, shell, expanded_command)
730 end if
731
732 ! Clean up
733 do j = 1, alias_pipeline%num_commands
734 if (allocated(alias_pipeline%commands(j)%tokens)) deallocate(alias_pipeline%commands(j)%tokens)
735 if (allocated(alias_pipeline%commands(j)%input_file)) deallocate(alias_pipeline%commands(j)%input_file)
736 if (allocated(alias_pipeline%commands(j)%output_file)) deallocate(alias_pipeline%commands(j)%output_file)
737 if (allocated(alias_pipeline%commands(j)%error_file)) deallocate(alias_pipeline%commands(j)%error_file)
738 if (allocated(alias_pipeline%commands(j)%heredoc_delimiter)) deallocate(alias_pipeline%commands(j)%heredoc_delimiter)
739 if (allocated(alias_pipeline%commands(j)%heredoc_content)) deallocate(alias_pipeline%commands(j)%heredoc_content)
740 if (allocated(alias_pipeline%commands(j)%here_string)) deallocate(alias_pipeline%commands(j)%here_string)
741 if (allocated(alias_pipeline%commands(j)%group_content)) deallocate(alias_pipeline%commands(j)%group_content)
742 end do
743 if (allocated(alias_pipeline%commands)) deallocate(alias_pipeline%commands)
744 end if
745 end block
746 else if (is_builtin(cmd%tokens(1))) then
747 call execute_builtin_with_redirects(cmd, shell)
748 else
749 ! Check for command_not_found_handle before executing external
750 if (.not. shell%bypass_functions .and. &
751 is_function(shell, 'command_not_found_handle') .and. &
752 index(trim(cmd%tokens(1)), '/') == 0) then
753 block
754 logical :: cmd_found
755 integer :: ii
756 character(len=4096) :: path_var, candidate
757 character(len=:), allocatable :: path_dir
758 integer :: spos, cpos
759
760 ! Inline PATH search to avoid command_builtin dependency
761 cmd_found = .false.
762 path_var = get_shell_variable(shell, 'PATH')
763 if (len_trim(path_var) == 0) path_var = '/usr/bin:/bin'
764 spos = 1
765 do while (spos <= len_trim(path_var) .and. .not. cmd_found)
766 cpos = index(path_var(spos:), ':')
767 if (cpos == 0) then
768 path_dir = path_var(spos:len_trim(path_var))
769 spos = len_trim(path_var) + 1
770 else
771 path_dir = path_var(spos:spos + cpos - 2)
772 spos = spos + cpos
773 end if
774 if (len_trim(path_dir) == 0) path_dir = '.'
775 write(candidate, '(a,a,a)') trim(path_dir), '/', trim(cmd%tokens(1))
776 cmd_found = file_exists(trim(candidate))
777 end do
778
779 if (.not. cmd_found) then
780 ! Build handler call string and dispatch via AST pipeline
781 ! (command_not_found_handle is an AST-cached function from eval)
782 block
783 use trap_dispatch, only: eval_trap_string
784 character(len=4096) :: handler_str
785 integer :: handler_exit
786
787 handler_str = 'command_not_found_handle'
788 do ii = 1, cmd%num_tokens
789 handler_str = trim(handler_str) // ' ' // trim(cmd%tokens(ii))
790 end do
791 call eval_trap_string(trim(handler_str), shell, handler_exit)
792 end block
793 else
794 call execute_external(cmd, shell, original_input)
795 end if
796 end block
797 else
798 call execute_external(cmd, shell, original_input)
799 end if
800 end if
801
802 ! === ERROR TRAP HOOK ===
803 ! Execute ERR trap if command failed (after command execution)
804 ! POSIX: ERR trap suppressed in same contexts as errexit:
805 ! - || / && lists, if/while/until conditions, negation (!)
806 if (shell%last_exit_status /= 0 .and. &
807 .not. shell%evaluating_condition .and. &
808 .not. shell%in_and_or_list .and. &
809 .not. shell%in_negation) then
810 trap_executed = execute_trap(shell, TRAP_ERR, shell%last_exit_status)
811
812 ! If a trap command was queued, execute it now
813 ! Check executing_trap to prevent recursion
814 ! Don't execute EXIT trap here - it should only execute when shell is exiting
815 if (len_trim(shell%pending_trap_command) > 0 .and. .not. shell%executing_trap .and. shell%pending_trap_signal /= 0) then
816 call execute_pending_trap(shell)
817 end if
818 end if
819 ! === END ERROR TRAP HOOK ===
820
821 ! Handle exit status negation
822 if (negate_exit_status) then
823 if (shell%last_exit_status == 0) then
824 shell%last_exit_status = 1
825 else
826 shell%last_exit_status = 0
827 end if
828 end if
829
830 ! End performance timing
831 call end_timer('execute_single', exec_start_time, total_exec_time)
832 end subroutine
833
834 ! Execute builtin with redirection support
835 subroutine execute_builtin_with_redirects(cmd, shell)
836 type(command_t), intent(in) :: cmd
837 type(shell_state_t), intent(inout) :: shell
838
839 integer :: saved_stdout, saved_stdin, saved_stderr
840 integer :: fd, ret, flags, i
841 integer, target :: pipefd(2)
842 integer(c_size_t) :: bytes_written
843 character(len=256), target :: c_filename
844 character(kind=c_char), target :: c_content(MAX_HEREDOC_LEN)
845 character(len=:), allocatable :: content_to_write, expanded_content
846 logical :: has_redirects, has_heredoc
847 ! Prefix assignment handling
848 character(len=MAX_TOKEN_LEN), allocatable :: saved_var_names(:), saved_var_values(:)
849 integer :: num_saved_vars, eq_pos, j
850 character(len=MAX_TOKEN_LEN) :: var_name, var_value
851 logical, allocatable :: var_was_set(:)
852
853 ! Apply prefix assignments to shell variables (save old values first)
854 num_saved_vars = 0
855 if (allocated(cmd%prefix_assignments)) then
856 allocate(saved_var_names(cmd%num_prefix_assignments))
857 allocate(saved_var_values(cmd%num_prefix_assignments))
858 allocate(var_was_set(cmd%num_prefix_assignments))
859 do j = 1, cmd%num_prefix_assignments
860 eq_pos = index(cmd%prefix_assignments(j), '=')
861 if (eq_pos > 1) then
862 num_saved_vars = num_saved_vars + 1
863 var_name = cmd%prefix_assignments(j)(:eq_pos-1)
864 var_value = cmd%prefix_assignments(j)(eq_pos+1:)
865 saved_var_names(num_saved_vars) = trim(var_name)
866 ! Save old value (empty string if not set)
867 saved_var_values(num_saved_vars) = get_shell_variable(shell, trim(var_name))
868 var_was_set(num_saved_vars) = (len_trim(saved_var_values(num_saved_vars)) > 0)
869 ! Set new value
870 call var_set_shell_variable(shell, trim(var_name), trim(var_value))
871 end if
872 end do
873 end if ! allocated(prefix_assignments)
874
875 ! Check if we have any redirections
876 has_redirects = allocated(cmd%output_file) .or. allocated(cmd%input_file) .or. &
877 allocated(cmd%error_file) .or. cmd%redirect_stderr_to_stdout .or. &
878 cmd%redirect_stdout_to_stderr
879 has_heredoc = allocated(cmd%heredoc_content) .or. allocated(cmd%here_string)
880
881 if (.not. has_redirects .and. .not. has_heredoc) then
882 ! No redirections, just execute the builtin normally
883 call execute_builtin(cmd, shell)
884 ! Restore prefix assignment variables
885 do j = 1, num_saved_vars
886 if (var_was_set(j)) then
887 call var_set_shell_variable(shell, trim(saved_var_names(j)), trim(saved_var_values(j)))
888 else
889 ! Variable wasn't set before, we should unset it
890 ! For now, just set to empty (proper unset would need more work)
891 call var_set_shell_variable(shell, trim(saved_var_names(j)), '')
892 end if
893 end do
894 return
895 end if
896
897 ! Save current file descriptors
898 saved_stdout = c_dup(STDOUT_FD)
899 saved_stdin = c_dup(STDIN_FD)
900 saved_stderr = c_dup(STDERR_FD)
901
902 ! Handle heredoc/here-string input (redirects stdin)
903 if (has_heredoc) then
904 ! Prepare content to write
905 if (allocated(cmd%here_string)) then
906 content_to_write = cmd%here_string // char(10) ! Add newline
907 else if (allocated(cmd%heredoc_content)) then
908 ! Check if we should expand variables (unquoted delimiter)
909 if (.not. cmd%heredoc_quoted) then
910 ! Expand variables in heredoc content
911 call expand_variables(cmd%heredoc_content, expanded_content, shell)
912 if (allocated(expanded_content)) then
913 content_to_write = expanded_content
914 else
915 content_to_write = cmd%heredoc_content
916 end if
917 else
918 ! Quoted delimiter - use content as-is
919 content_to_write = cmd%heredoc_content
920 end if
921 end if
922
923 ! Create pipe and redirect stdin
924 if (allocated(content_to_write)) then
925 ret = c_pipe(c_loc(pipefd))
926 if (ret == 0) then
927 ! Convert content to C string
928 do i = 1, min(len(content_to_write), MAX_HEREDOC_LEN-1)
929 c_content(i) = content_to_write(i:i)
930 end do
931 c_content(min(len(content_to_write), MAX_HEREDOC_LEN-1)+1) = c_null_char
932
933 ! Write content to pipe
934 bytes_written = c_write(pipefd(2), c_loc(c_content), &
935 int(min(len(content_to_write), MAX_HEREDOC_LEN-1), c_size_t))
936
937 ! Close write end and redirect stdin to read end
938 ret = c_close(pipefd(2))
939 ret = c_dup2(pipefd(1), STDIN_FD)
940 ret = c_close(pipefd(1))
941 end if
942 end if
943 end if
944
945 ! Handle input redirection (file)
946 if (allocated(cmd%input_file)) then
947 c_filename = trim(cmd%input_file)//c_null_char
948 fd = c_open(c_loc(c_filename), O_RDONLY, 0)
949 if (fd >= 0) then
950 ret = c_dup2(fd, STDIN_FD)
951 ret = c_close(fd)
952 else
953 write(error_unit, '(3a)') 'fortsh: cannot open input file: ', trim(cmd%input_file)
954 shell%last_exit_status = 1
955 ! Restore prefix assignment variables before returning
956 do j = 1, num_saved_vars
957 if (var_was_set(j)) then
958 call var_set_shell_variable(shell, trim(saved_var_names(j)), trim(saved_var_values(j)))
959 else
960 call var_set_shell_variable(shell, trim(saved_var_names(j)), '')
961 end if
962 end do
963 return
964 end if
965 end if
966
967 ! Handle output redirection
968 if (allocated(cmd%output_file)) then
969 ! Check noclobber protection
970 if (shell%option_noclobber .and. .not. cmd%force_clobber .and. .not. cmd%append_output) then
971 ! Check if file exists
972 if (file_exists(trim(cmd%output_file))) then
973 write(error_unit, '(3a)') 'fortsh: ', trim(cmd%output_file), ': cannot overwrite existing file'
974 shell%last_exit_status = 1
975 ! Restore stdin before returning
976 ret = c_dup2(saved_stdin, STDIN_FD)
977 ret = c_close(saved_stdin)
978 ! Restore prefix assignment variables before returning
979 do j = 1, num_saved_vars
980 if (var_was_set(j)) then
981 call var_set_shell_variable(shell, trim(saved_var_names(j)), trim(saved_var_values(j)))
982 else
983 call var_set_shell_variable(shell, trim(saved_var_names(j)), '')
984 end if
985 end do
986 return
987 end if
988 end if
989
990 if (cmd%append_output) then
991 flags = ior(ior(O_WRONLY, O_CREAT), O_APPEND)
992 else
993 flags = ior(ior(O_WRONLY, O_CREAT), O_TRUNC)
994 end if
995
996 c_filename = trim(cmd%output_file)//c_null_char
997 fd = c_open(c_loc(c_filename), flags, int(o'644', c_int))
998 if (fd >= 0) then
999 ret = c_dup2(fd, STDOUT_FD)
1000 ret = c_close(fd)
1001 else
1002 write(error_unit, '(3a)') 'fortsh: cannot open output file: ', trim(cmd%output_file)
1003 shell%last_exit_status = 1
1004 ! Restore stdin before returning
1005 ret = c_dup2(saved_stdin, STDIN_FD)
1006 ret = c_close(saved_stdin)
1007 ! Restore prefix assignment variables before returning
1008 do j = 1, num_saved_vars
1009 if (var_was_set(j)) then
1010 call var_set_shell_variable(shell, trim(saved_var_names(j)), trim(saved_var_values(j)))
1011 else
1012 call var_set_shell_variable(shell, trim(saved_var_names(j)), '')
1013 end if
1014 end do
1015 return
1016 end if
1017 end if
1018
1019 ! Handle error redirection
1020 if (allocated(cmd%error_file)) then
1021 if (cmd%append_error) then
1022 flags = ior(ior(O_WRONLY, O_CREAT), O_APPEND)
1023 else
1024 flags = ior(ior(O_WRONLY, O_CREAT), O_TRUNC)
1025 end if
1026
1027 c_filename = trim(cmd%error_file)//c_null_char
1028 fd = c_open(c_loc(c_filename), flags, int(o'644', c_int))
1029 if (fd >= 0) then
1030 ret = c_dup2(fd, STDERR_FD)
1031 ret = c_close(fd)
1032 else
1033 write(error_unit, '(3a)') 'fortsh: cannot open error file: ', trim(cmd%error_file)
1034 shell%last_exit_status = 1
1035 ! Restore fds before returning
1036 ret = c_dup2(saved_stdin, STDIN_FD)
1037 ret = c_dup2(saved_stdout, STDOUT_FD)
1038 ret = c_close(saved_stdin)
1039 ret = c_close(saved_stdout)
1040 ! Restore prefix assignment variables before returning
1041 do j = 1, num_saved_vars
1042 if (var_was_set(j)) then
1043 call var_set_shell_variable(shell, trim(saved_var_names(j)), trim(saved_var_values(j)))
1044 else
1045 call var_set_shell_variable(shell, trim(saved_var_names(j)), '')
1046 end if
1047 end do
1048 return
1049 end if
1050 end if
1051
1052 ! Handle advanced redirections
1053 if (cmd%redirect_stderr_to_stdout) then
1054 ret = c_dup2(STDOUT_FD, STDERR_FD)
1055 end if
1056
1057 if (cmd%redirect_stdout_to_stderr) then
1058 ret = c_dup2(STDERR_FD, STDOUT_FD)
1059 end if
1060
1061 ! Execute the builtin
1062 call execute_builtin(cmd, shell)
1063
1064 ! Restore original file descriptors
1065 ret = c_dup2(saved_stdout, STDOUT_FD)
1066 ret = c_dup2(saved_stdin, STDIN_FD)
1067 ret = c_dup2(saved_stderr, STDERR_FD)
1068 ret = c_close(saved_stdout)
1069 ret = c_close(saved_stdin)
1070 ret = c_close(saved_stderr)
1071
1072 ! Restore prefix assignment variables
1073 do j = 1, num_saved_vars
1074 if (var_was_set(j)) then
1075 call var_set_shell_variable(shell, trim(saved_var_names(j)), trim(saved_var_values(j)))
1076 else
1077 ! Variable wasn't set before, set to empty
1078 call var_set_shell_variable(shell, trim(saved_var_names(j)), '')
1079 end if
1080 end do
1081 end subroutine
1082
1083 ! Execute ((expression)) arithmetic evaluation command
1084 ! Sets exit status to 0 if expression is non-zero, 1 if zero
1085 subroutine execute_arithmetic_command(cmd, shell)
1086 use expansion, only: arithmetic_expansion_shell
1087 type(command_t), intent(in) :: cmd
1088 type(shell_state_t), intent(inout) :: shell
1089 character(len=:), allocatable :: expr, result_str
1090 character(len=:), allocatable :: arith_expr
1091 integer(kind=8) :: result_val
1092 integer :: iostat
1093
1094 ! Build full expression from all tokens
1095 expr = trim(cmd%tokens(1))
1096
1097 ! Convert ((expr)) to $((expr)) for arithmetic_expansion_shell
1098 arith_expr = '$' // trim(expr)
1099
1100 ! Evaluate arithmetic expression
1101 result_str = arithmetic_expansion_shell(trim(arith_expr), shell)
1102
1103 ! Check if there was an error (empty result indicates error)
1104 if (len_trim(result_str) == 0) then
1105 ! There was an arithmetic error, exit status already set by arithmetic_expansion_shell
1106 ! Just return without changing it
1107 return
1108 end if
1109
1110 ! Convert result to integer
1111 read(result_str, *, iostat=iostat) result_val
1112 if (iostat /= 0) result_val = 0
1113
1114 ! Set exit status: 0 if non-zero, 1 if zero
1115 if (result_val /= 0) then
1116 shell%last_exit_status = 0
1117 else
1118 shell%last_exit_status = 1
1119 end if
1120 end subroutine
1121
1122 ! Execute variable assignment: var=value, arr=(a b c), or arr[0]=value
1123 subroutine execute_assignment(cmd, shell)
1124 type(command_t), intent(in) :: cmd
1125 type(shell_state_t), intent(inout) :: shell
1126 character(len=MAX_TOKEN_LEN) :: var_name, var_value, token
1127 ! Heap-allocated to avoid static storage in recursive context
1128 character(len=MAX_TOKEN_LEN), allocatable :: array_elements(:)
1129 character(len=100) :: index_str
1130 character(len=:), allocatable :: expanded_value
1131 integer :: eq_pos, paren_start, paren_end, num_elements, bracket_pos
1132 integer :: bracket_end, array_index, read_status, actual_value_len, i, token_len
1133 logical :: is_indexed_assignment
1134 character(len=1) :: quote_char_temp
1135
1136 allocate(array_elements(30))
1137
1138 ! For quoted tokens, preserve whitespace by not trimming
1139 ! For unquoted tokens, trim is safe
1140 ! NOTE: Check allocation first to avoid accessing unallocated arrays
1141 if (allocated(cmd%token_quoted) .and. allocated(cmd%token_lengths)) then
1142 if (size(cmd%token_quoted) >= 1 .and. cmd%token_quoted(1)) then
1143 ! Quoted token - preserve whitespace, track actual length
1144 ! Use token_lengths array if available, otherwise fall back to len()
1145 if (size(cmd%token_lengths) >= 1) then
1146 token_len = cmd%token_lengths(1)
1147 else
1148 token_len = len(cmd%tokens(1))
1149 end if
1150 token = cmd%tokens(1)
1151 else
1152 ! Token arrays allocated but this token not quoted - use standard processing
1153 token = cmd%tokens(1)
1154 token_len = len_trim(token)
1155 end if
1156 else
1157 ! Old parser: token may contain quotes with whitespace inside
1158 ! We need to find the actual token end, not use len_trim which strips whitespace
1159 token = cmd%tokens(1)
1160
1161 ! Find the = sign to check if value is quoted
1162 eq_pos = index(token, '=')
1163 if (eq_pos > 0 .and. eq_pos < len(token)) then
1164 ! Check if value starts with a quote
1165 if (token(eq_pos+1:eq_pos+1) == '"' .or. token(eq_pos+1:eq_pos+1) == "'") then
1166 quote_char_temp = token(eq_pos+1:eq_pos+1)
1167 ! Find the closing quote to determine actual length
1168 do i = eq_pos + 2, len(token)
1169 if (token(i:i) == quote_char_temp) then
1170 token_len = i
1171 exit
1172 end if
1173 end do
1174 ! If no closing quote found, fall back to len_trim
1175 if (i > len(token)) then
1176 token_len = len_trim(token)
1177 end if
1178 else
1179 ! Unquoted value - trim is safe
1180 token_len = len_trim(token)
1181 end if
1182 else
1183 token_len = len_trim(token)
1184 end if
1185 end if
1186 eq_pos = index(token, '=')
1187 if (eq_pos == 0) return
1188
1189 ! Check for array index assignment: arr[index]=value
1190 bracket_pos = index(token, '[')
1191 is_indexed_assignment = (bracket_pos > 0 .and. bracket_pos < eq_pos)
1192
1193 if (is_indexed_assignment) then
1194 ! arr[index]=value
1195 var_name = token(:bracket_pos-1)
1196 bracket_end = index(token(bracket_pos:token_len), ']')
1197 if (bracket_end > 0) then
1198 bracket_end = bracket_pos + bracket_end - 1
1199 index_str = token(bracket_pos+1:bracket_end-1)
1200 var_value = token(eq_pos+1:token_len)
1201
1202 ! Strip quotes and lexer sentinel chars from array subscript
1203 block
1204 use variables, only: strip_quotes
1205 character(len=100) :: clean_key
1206 integer :: ci, co
1207 call strip_quotes(index_str)
1208 ! Remove sentinel chars (char(1), char(2), char(3))
1209 co = 0
1210 clean_key = ''
1211 do ci = 1, len_trim(index_str)
1212 if (ichar(index_str(ci:ci)) > 3) then
1213 co = co + 1
1214 clean_key(co:co) = index_str(ci:ci)
1215 end if
1216 end do
1217 index_str = clean_key
1218 end block
1219
1220 ! Expand variables/command substitutions in value
1221 if (index(var_value, '$') > 0 .or. index(var_value, '~') > 0) then
1222 block
1223 use parser, only: expand_variables
1224 call expand_variables(var_value, expanded_value, shell)
1225 if (allocated(expanded_value)) then
1226 var_value = expanded_value
1227 end if
1228 end block
1229 end if
1230
1231 ! Check associative array first (numeric keys are valid)
1232 block
1233 use variables, only: is_associative_array, set_assoc_array_value
1234 if (is_associative_array(shell, trim(var_name))) then
1235 call set_assoc_array_value(shell, trim(var_name), &
1236 trim(index_str), trim(var_value))
1237 shell%last_exit_status = 0
1238 else
1239 ! Parse as numeric index (0-indexed → 1-indexed)
1240 read(index_str, *, iostat=read_status) array_index
1241 if (read_status == 0) then
1242 array_index = array_index + 1
1243 call set_array_element(shell, trim(var_name), &
1244 array_index, trim(var_value))
1245 shell%last_exit_status = 0
1246 else
1247 write(error_unit, '(a)') &
1248 'Error: invalid array index'
1249 shell%last_exit_status = 1
1250 end if
1251 end if
1252 end block
1253 else
1254 write(error_unit, '(a)') 'Error: unclosed bracket in array assignment'
1255 shell%last_exit_status = 1
1256 end if
1257 return
1258 end if
1259
1260 ! Get variable name (before =)
1261 var_name = token(:eq_pos-1)
1262
1263 ! Check for += append syntax: arr+=(...)
1264 block
1265 logical :: is_append
1266 is_append = .false.
1267 if (eq_pos >= 2 .and. token(eq_pos-1:eq_pos-1) == '+') then
1268 is_append = .true.
1269 var_name = token(:eq_pos-2)
1270 end if
1271
1272 ! Check if it's an array literal: arr=(...) or arr+=(...)
1273 paren_start = eq_pos + 1
1274 if (paren_start <= token_len .and. token(paren_start:paren_start) == '(') then
1275 ! Array literal
1276 paren_end = index(token(paren_start+1:token_len), ')')
1277 if (paren_end > 0) then
1278 paren_end = paren_start + paren_end
1279 ! Extract elements between parentheses
1280 var_value = token(paren_start+1:paren_end-1)
1281
1282 ! Expand command substitutions and variables
1283 if (index(trim(var_value), '$') > 0 .or. &
1284 index(trim(var_value), '`') > 0) then
1285 block
1286 use parser, only: expand_variables
1287 character(len=:), allocatable :: arr_exp
1288 call expand_variables(trim(var_value), &
1289 arr_exp, shell)
1290 if (allocated(arr_exp)) then
1291 var_value = arr_exp
1292 end if
1293 end block
1294 end if
1295
1296 ! Split by spaces to get array elements
1297 num_elements = 0
1298 call split_array_elements(var_value, array_elements, num_elements)
1299
1300 if (is_append) then
1301 ! Append to existing array
1302 block
1303 integer :: existing_size, k
1304 existing_size = get_array_size(shell, trim(var_name))
1305 do k = 1, num_elements
1306 call set_array_element(shell, trim(var_name), &
1307 existing_size + k, trim(array_elements(k)))
1308 end do
1309 end block
1310 else
1311 ! Set as array variable
1312 call set_array_variable(shell, trim(var_name), &
1313 array_elements, num_elements)
1314 end if
1315 shell%last_exit_status = 0
1316 else
1317 write(error_unit, '(a)') 'Error: unclosed array literal'
1318 shell%last_exit_status = 1
1319 end if
1320 else
1321 ! Simple assignment: var=value
1322 ! Use token_len to avoid including padding for quoted tokens
1323 var_value = token(eq_pos+1:token_len)
1324
1325 ! Expand variables in the value (including parameter expansions like ${var##pattern})
1326 ! IMPORTANT: Call expand_variables BEFORE stripping quotes, so it can apply
1327 ! correct backslash escape handling for double-quoted strings
1328 ! expand_variables will strip outer quotes automatically
1329 if (index(var_value, '$') > 0 .or. index(var_value, '~') > 0) then
1330 call expand_variables(var_value, expanded_value, shell)
1331
1332 ! Check if arithmetic expansion error occurred
1333 if (shell%arithmetic_error) then
1334 shell%arithmetic_error = .false. ! Reset flag
1335 shell%last_exit_status = 127 ! POSIX sh returns 127 for arithmetic errors
1336 return ! Abort assignment
1337 end if
1338
1339 ! POSIX: Exit status of assignment with command substitution is from last substitution
1340 ! Don't overwrite the exit status that was set by execute_command_and_capture
1341
1342 if (allocated(expanded_value)) then
1343 ! For expanded values, use the allocated length
1344 call var_set_shell_variable(shell, trim(var_name), expanded_value, len(expanded_value))
1345 else
1346 call var_set_shell_variable(shell, trim(var_name), '', 0)
1347 end if
1348 else
1349 ! No variable expansion needed
1350 ! Calculate actual length from token positions (NOT len_trim, to preserve whitespace)
1351 actual_value_len = token_len - eq_pos
1352
1353 ! Strip outer quotes if present (old parser keeps quotes in tokens)
1354 if (actual_value_len >= 2) then
1355 if ((var_value(1:1) == '"' .and. &
1356 var_value(actual_value_len:actual_value_len) == '"') &
1357 .or. (var_value(1:1) == "'" .and. &
1358 var_value(actual_value_len:actual_value_len) == "'")) &
1359 then
1360 ! Remove quotes and adjust length
1361 var_value = var_value(2:actual_value_len-1)
1362 actual_value_len = actual_value_len - 2
1363 end if
1364 end if
1365
1366 ! Check for integer attribute: evaluate as arithmetic
1367 block
1368 logical :: is_int_var
1369 is_int_var = .false.
1370 do i = 1, shell%num_variables
1371 if (trim(shell%variables(i)%name) == &
1372 trim(var_name)) then
1373 is_int_var = shell%variables(i)%is_integer
1374 exit
1375 end if
1376 end do
1377 if (is_int_var .and. actual_value_len > 0) then
1378 block
1379 use expansion, only: arithmetic_expansion_shell
1380 character(len=:), allocatable :: arith_expr, arith_result
1381 arith_expr = '$((' // &
1382 var_value(:actual_value_len) // '))'
1383 arith_result = &
1384 arithmetic_expansion_shell( &
1385 trim(arith_expr), shell)
1386 var_value = arith_result
1387 actual_value_len = len_trim(var_value)
1388 end block
1389 end if
1390 end block
1391
1392 call var_set_shell_variable(shell, trim(var_name), &
1393 var_value, actual_value_len)
1394 ! Set exit status to 0 for simple assignments without expansions
1395 ! But don't overwrite error status from readonly violation
1396 if (shell%last_exit_status /= 127) then
1397 shell%last_exit_status = 0
1398 end if
1399 end if
1400
1401 ! If allexport is enabled (set -a), automatically export the variable
1402 if (shell%option_allexport) then
1403 do i = 1, shell%num_variables
1404 if (trim(shell%variables(i)%name) == trim(var_name)) then
1405 shell%variables(i)%exported = .true.
1406 ! Also set in environment
1407 if (.not. set_environment_var(trim(var_name), trim(shell%variables(i)%value))) then
1408 ! Silently ignore export errors (POSIX behavior)
1409 end if
1410 exit
1411 end if
1412 end do
1413 end if
1414
1415 ! POSIX: Exit status of assignment is from last command substitution
1416 ! Only set to 0 if no expansion was performed (i.e., no command substitution)
1417 ! Don't overwrite exit status when there was a command substitution
1418 end if
1419 end block
1420 end subroutine
1421
1422 ! Split array elements by spaces (respecting quotes)
1423 subroutine split_array_elements(input, elements, count)
1424 character(len=*), intent(in) :: input
1425 character(len=MAX_TOKEN_LEN), intent(out) :: elements(:)
1426 integer, intent(out) :: count
1427 integer :: i, start, len_input
1428 logical :: in_quotes
1429 character :: quote_char
1430
1431 count = 0
1432 i = 1
1433 len_input = len_trim(input)
1434
1435 do while (i <= len_input)
1436 ! Skip leading spaces
1437 do while (i <= len_input .and. input(i:i) == ' ')
1438 i = i + 1
1439 end do
1440 if (i > len_input) exit
1441
1442 ! Start of element
1443 count = count + 1
1444 if (count > size(elements)) exit
1445 start = i
1446 in_quotes = .false.
1447 quote_char = ' '
1448
1449 ! Find end of element
1450 do while (i <= len_input)
1451 if (.not. in_quotes .and. (input(i:i) == '"' .or. input(i:i) == "'")) then
1452 in_quotes = .true.
1453 quote_char = input(i:i)
1454 else if (in_quotes .and. input(i:i) == quote_char) then
1455 in_quotes = .false.
1456 else if (.not. in_quotes .and. input(i:i) == ' ') then
1457 exit
1458 end if
1459 i = i + 1
1460 end do
1461
1462 ! Extract element (and remove quotes if present)
1463 elements(count) = input(start:i-1)
1464 if (len_trim(elements(count)) >= 2) then
1465 if ((elements(count)(1:1) == '"' .and. elements(count)(len_trim(elements(count)):len_trim(elements(count))) == '"') .or. &
1466 (elements(count)(1:1) == "'" .and. elements(count)(len_trim(elements(count)):len_trim(elements(count))) == "'")) then
1467 elements(count) = elements(count)(2:len_trim(elements(count))-1)
1468 end if
1469 end if
1470 end do
1471 end subroutine
1472
1473 subroutine execute_external(cmd, shell, original_input)
1474 type(command_t), intent(in) :: cmd
1475 type(shell_state_t), intent(inout) :: shell
1476 character(len=*), intent(in) :: original_input
1477
1478 integer(c_pid_t) :: pid, pgid, ret
1479 integer(c_int), target :: wait_status
1480 integer(c_int) :: pgid_ret
1481 integer :: job_id
1482 logical :: foreground
1483 type(c_funptr) :: old_handler
1484
1485 foreground = .not. cmd%background
1486
1487 ! Check for empty command before forking (e.g., from empty command substitution)
1488 ! This preserves the exit status from the command substitution
1489 if (cmd%num_tokens < 1 .or. len_trim(cmd%tokens(1)) == 0) then
1490 ! Empty command - nothing to execute, preserve current exit status
1491 return
1492 end if
1493
1494 ! CRITICAL: Re-ensure SIGCHLD is SIG_DFL before forking
1495 ! Something in interactive mode might be resetting it
1496 pid = c_fork()
1497
1498 if (pid < 0) then
1499 write(error_unit, '(a)') 'Error: fork failed'
1500 shell%last_exit_status = 1
1501 else if (pid == 0) then
1502 ! Child process
1503 if (.not. shell%in_pipeline_child) then
1504 ! Only set up own process group when NOT in a pipeline child.
1505 ! Pipeline children inherit their process group from the AST
1506 ! pipeline executor which manages groups at the pipeline level.
1507 pgid = c_getpid()
1508 ret = c_setpgid(0, pgid)
1509 end if
1510
1511 ! Reset signal handlers to default
1512 old_handler = c_signal(SIGINT, c_null_funptr)
1513 old_handler = c_signal(SIGPIPE, c_null_funptr)
1514 old_handler = c_signal(SIGTSTP, c_null_funptr)
1515 old_handler = c_signal(SIGTTIN, c_null_funptr)
1516 old_handler = c_signal(SIGTTOU, c_null_funptr)
1517
1518 ! Handle here document
1519 call handle_heredoc(cmd, shell)
1520
1521 ! Apply prefix assignments to environment (VAR=value command)
1522 call apply_prefix_assignments(cmd)
1523
1524 ! Set up redirections
1525 call setup_redirections(cmd, shell)
1526
1527 ! Execute
1528 call exec_child(cmd%tokens, cmd%num_tokens)
1529 ! Error message is printed by exec_child if command not found
1530 call c_exit(127)
1531 else
1532 ! Parent process
1533 shell%last_pid = pid
1534
1535 ! Auto-populate hash table (hashall)
1536 if (shell%option_hashall .and. &
1537 index(trim(cmd%tokens(1)), '/') == 0) then
1538 call cache_command_path(shell, trim(cmd%tokens(1)))
1539 end if
1540
1541 if (.not. shell%in_pipeline_child) then
1542 ! Only manage process groups and terminal when NOT in a pipeline
1543 ! child. The AST pipeline executor handles these at pipeline level.
1544 pgid = pid
1545 ret = c_setpgid(pid, pgid)
1546
1547 if (foreground) then
1548 ! Give terminal to child
1549 if (shell%is_interactive) then
1550 pgid_ret = c_tcsetpgrp(shell%shell_terminal, pgid)
1551 end if
1552
1553 ! Wait for child
1554 ret = c_waitpid(pid, c_loc(wait_status), WUNTRACED)
1555
1556 ! Take back terminal
1557 if (shell%is_interactive) then
1558 pgid_ret = c_tcsetpgrp(shell%shell_terminal, shell%shell_pgid)
1559 end if
1560
1561 if (ret == pid) then
1562 if (WIFEXITED(wait_status)) then
1563 shell%last_exit_status = WEXITSTATUS(wait_status)
1564 else if (WIFSIGNALED(wait_status)) then
1565 ! Process was killed by a signal - exit status is 128 + signal_number
1566 shell%last_exit_status = 128 + WTERMSIG(wait_status)
1567 else if (WIFSTOPPED(wait_status)) then
1568 job_id = add_job(shell, pgid, original_input, .true.)
1569 ! Set state to stopped (add_job defaults to RUNNING)
1570 block
1571 integer :: ji
1572 do ji = 1, MAX_JOBS
1573 if (shell%jobs(ji)%job_id == job_id) then
1574 shell%jobs(ji)%state = JOB_STOPPED
1575 exit
1576 end if
1577 end do
1578 end block
1579 write(output_unit, '(a)') 'Stopped'
1580 end if
1581 end if
1582 else
1583 ! Background job
1584 job_id = add_job(shell, pgid, original_input, .false.)
1585 ! Only print job notification in interactive mode
1586 if (shell%is_interactive) then
1587 write(output_unit, '(a,i0,a,i0)') '[', job_id, '] ', pid
1588 end if
1589 ! Set $! to the background job PID
1590 shell%last_bg_pid = pid
1591 end if
1592 else
1593 ! In pipeline child: just wait for the grandchild, no terminal/pgroup mgmt
1594 ret = c_waitpid(pid, c_loc(wait_status), int(0, c_int))
1595 if (ret == pid) then
1596 if (WIFEXITED(wait_status)) then
1597 shell%last_exit_status = WEXITSTATUS(wait_status)
1598 else if (WIFSIGNALED(wait_status)) then
1599 shell%last_exit_status = 128 + WTERMSIG(wait_status)
1600 end if
1601 end if
1602 end if
1603 end if
1604 end subroutine
1605
1606 subroutine handle_heredoc(cmd, shell)
1607 type(command_t), intent(in) :: cmd
1608 type(shell_state_t), intent(inout) :: shell
1609 integer, target :: pipefd(2)
1610 integer :: ret
1611 integer(c_size_t) :: bytes_written
1612 character(kind=c_char), target :: c_content(MAX_HEREDOC_LEN)
1613 integer :: i
1614 character(len=:), allocatable :: content_to_write
1615 character(len=:), allocatable :: expanded_content
1616
1617 ! Handle here-string (<<<)
1618 if (allocated(cmd%here_string)) then
1619 content_to_write = cmd%here_string // char(10) ! Add newline
1620 else if (allocated(cmd%heredoc_content)) then
1621 ! Check if we should expand variables (unquoted delimiter)
1622 if (.not. cmd%heredoc_quoted) then
1623 ! Expand variables in heredoc content
1624 call expand_variables(cmd%heredoc_content, expanded_content, shell)
1625 if (allocated(expanded_content)) then
1626 content_to_write = expanded_content
1627 else
1628 content_to_write = cmd%heredoc_content
1629 end if
1630 else
1631 ! Quoted delimiter - use content as-is
1632 content_to_write = cmd%heredoc_content
1633 end if
1634 else
1635 return
1636 end if
1637
1638 ! Create pipe for input
1639 ret = c_pipe(c_loc(pipefd))
1640 if (ret == 0) then
1641 ! Convert content to C string
1642 do i = 1, min(len(content_to_write), MAX_HEREDOC_LEN-1)
1643 c_content(i) = content_to_write(i:i)
1644 end do
1645 c_content(min(len(content_to_write), MAX_HEREDOC_LEN-1)+1) = c_null_char
1646
1647 ! Write content to pipe
1648 bytes_written = c_write(pipefd(2), c_loc(c_content), &
1649 int(min(len(content_to_write), MAX_HEREDOC_LEN-1), c_size_t))
1650
1651 ! Close write end and redirect stdin to read end
1652 ret = c_close(pipefd(2))
1653 ret = c_dup2(pipefd(1), STDIN_FD)
1654 ret = c_close(pipefd(1))
1655 end if
1656 end subroutine
1657
1658 subroutine setup_redirections(cmd, shell)
1659 type(command_t), intent(in) :: cmd
1660 type(shell_state_t), intent(in) :: shell
1661 integer :: fd, ret
1662 integer :: flags
1663 character(len=256), target :: c_filename
1664
1665 ! Handle input redirection
1666 if (allocated(cmd%input_file)) then
1667 c_filename = trim(cmd%input_file)//c_null_char
1668 fd = c_open(c_loc(c_filename), O_RDONLY, 0)
1669 if (fd >= 0) then
1670 ret = c_dup2(fd, STDIN_FD)
1671 ret = c_close(fd)
1672 else
1673 write(error_unit, '(3a)') 'Cannot open input file: ', trim(cmd%input_file)
1674 call c_exit(1)
1675 end if
1676 end if
1677
1678 ! Handle output redirection
1679 if (allocated(cmd%output_file)) then
1680 ! Check noclobber protection
1681 if (shell%option_noclobber .and. .not. cmd%force_clobber .and. .not. cmd%append_output) then
1682 ! Check if file exists
1683 if (file_exists(trim(cmd%output_file))) then
1684 write(error_unit, '(3a)') 'fortsh: ', trim(cmd%output_file), ': cannot overwrite existing file'
1685 call c_exit(1)
1686 end if
1687 end if
1688
1689 if (cmd%append_output) then
1690 flags = ior(ior(O_WRONLY, O_CREAT), O_APPEND)
1691 else
1692 flags = ior(ior(O_WRONLY, O_CREAT), O_TRUNC)
1693 end if
1694
1695 c_filename = trim(cmd%output_file)//c_null_char
1696 fd = c_open(c_loc(c_filename), flags, int(o'644', c_int))
1697 if (fd >= 0) then
1698 ret = c_dup2(fd, STDOUT_FD)
1699 ret = c_close(fd)
1700 else
1701 write(error_unit, '(3a)') 'Cannot open output file: ', trim(cmd%output_file)
1702 call c_exit(1)
1703 end if
1704 end if
1705
1706 ! Handle error redirection
1707 if (allocated(cmd%error_file)) then
1708 if (cmd%append_error) then
1709 flags = ior(ior(O_WRONLY, O_CREAT), O_APPEND)
1710 else
1711 flags = ior(ior(O_WRONLY, O_CREAT), O_TRUNC)
1712 end if
1713
1714 c_filename = trim(cmd%error_file)//c_null_char
1715 fd = c_open(c_loc(c_filename), flags, int(o'644', c_int))
1716 if (fd >= 0) then
1717 ret = c_dup2(fd, STDERR_FD)
1718 ret = c_close(fd)
1719 else
1720 write(error_unit, '(3a)') 'Cannot open error file: ', trim(cmd%error_file)
1721 call c_exit(1)
1722 end if
1723 end if
1724
1725 ! Handle advanced redirections
1726 if (cmd%redirect_stderr_to_stdout) then
1727 ret = c_dup2(STDOUT_FD, STDERR_FD)
1728 end if
1729
1730 if (cmd%redirect_stdout_to_stderr) then
1731 ret = c_dup2(STDERR_FD, STDOUT_FD)
1732 end if
1733
1734 ! Handle FD duplications from redirections array
1735 call apply_fd_redirections(cmd)
1736 end subroutine
1737
1738 ! Apply file descriptor redirections (including variable FD duplications)
1739 subroutine apply_fd_redirections(cmd)
1740 use expansion, only: get_environment_var
1741 type(command_t), intent(in) :: cmd
1742 character(len=:), allocatable :: expanded_value
1743 character(len=256) :: target_fd_str, var_name
1744 integer :: i, target_fd, ret, iostat, bracket_pos
1745
1746 do i = 1, cmd%num_redirections
1747 select case(cmd%redirections(i)%type)
1748 case(REDIR_DUP_OUT) ! >&n
1749 if (allocated(cmd%redirections(i)%target_fd_expr)) then
1750 ! Variable FD expression - need to expand
1751 target_fd_str = cmd%redirections(i)%target_fd_expr
1752
1753 ! Extract variable name from ${var} or ${var[index]} pattern
1754 if (len_trim(target_fd_str) > 3) then
1755 if (target_fd_str(1:2) == '${' .and. &
1756 target_fd_str(len_trim(target_fd_str):len_trim(target_fd_str)) == '}') then
1757 var_name = target_fd_str(3:len_trim(target_fd_str)-1)
1758
1759 ! Check for array syntax [index]
1760 bracket_pos = index(var_name, '[')
1761 if (bracket_pos > 0) then
1762 ! For COPROC[1] style, try environment variable with underscore
1763 ! e.g., COPROC_1 for COPROC[1]
1764 var_name = var_name(:bracket_pos-1) // '_' // &
1765 var_name(bracket_pos+1:index(var_name,']')-1)
1766 end if
1767
1768 ! Get value from environment
1769 expanded_value = get_environment_var(trim(var_name))
1770 if (allocated(expanded_value)) then
1771 read(expanded_value, *, iostat=iostat) target_fd
1772 if (iostat == 0 .and. target_fd >= 0) then
1773 ret = c_dup2(target_fd, cmd%redirections(i)%fd)
1774 end if
1775 end if
1776 end if
1777 end if
1778 else if (cmd%redirections(i)%target_fd >= 0) then
1779 ! Literal FD
1780 ret = c_dup2(cmd%redirections(i)%target_fd, cmd%redirections(i)%fd)
1781 end if
1782
1783 case(REDIR_DUP_IN) ! <&n
1784 if (allocated(cmd%redirections(i)%target_fd_expr)) then
1785 ! Variable FD expression - need to expand
1786 target_fd_str = cmd%redirections(i)%target_fd_expr
1787
1788 ! Extract variable name from ${var} pattern
1789 if (len_trim(target_fd_str) > 3) then
1790 if (target_fd_str(1:2) == '${' .and. &
1791 target_fd_str(len_trim(target_fd_str):len_trim(target_fd_str)) == '}') then
1792 var_name = target_fd_str(3:len_trim(target_fd_str)-1)
1793
1794 ! Check for array syntax [index]
1795 bracket_pos = index(var_name, '[')
1796 if (bracket_pos > 0) then
1797 ! For COPROC[0] style, try environment variable with underscore
1798 var_name = var_name(:bracket_pos-1) // '_' // &
1799 var_name(bracket_pos+1:index(var_name,']')-1)
1800 end if
1801
1802 ! Get value from environment
1803 expanded_value = get_environment_var(trim(var_name))
1804 if (allocated(expanded_value)) then
1805 read(expanded_value, *, iostat=iostat) target_fd
1806 if (iostat == 0 .and. target_fd >= 0) then
1807 ret = c_dup2(target_fd, cmd%redirections(i)%fd)
1808 end if
1809 end if
1810 end if
1811 end if
1812 else if (cmd%redirections(i)%target_fd >= 0) then
1813 ! Literal FD
1814 ret = c_dup2(cmd%redirections(i)%target_fd, cmd%redirections(i)%fd)
1815 end if
1816
1817 case(REDIR_FD_OUT, REDIR_FD_APPEND) ! n> file or n>> file
1818 if (allocated(cmd%redirections(i)%filename)) then
1819 block
1820 character(len=256), target :: c_filename
1821 integer :: fd, flags
1822
1823 c_filename = trim(cmd%redirections(i)%filename) // c_null_char
1824
1825 if (cmd%redirections(i)%type == REDIR_FD_APPEND) then
1826 flags = ior(ior(O_WRONLY, O_CREAT), O_APPEND)
1827 else
1828 flags = ior(ior(O_WRONLY, O_CREAT), O_TRUNC)
1829 end if
1830
1831 fd = c_open(c_loc(c_filename), flags, int(o'644', c_int))
1832 if (fd >= 0) then
1833 ret = c_dup2(fd, cmd%redirections(i)%fd)
1834 ret = c_close(fd)
1835 end if
1836 end block
1837 end if
1838
1839 case(REDIR_CLOSE) ! n>&-
1840 ret = c_close(cmd%redirections(i)%fd)
1841
1842 end select
1843 end do
1844 end subroutine
1845
1846 subroutine exec_child(tokens, num_tokens)
1847 use system_interface, only: file_exists, file_is_executable
1848 use iso_fortran_env, only: error_unit
1849 character(len=*), intent(in) :: tokens(:)
1850 integer, intent(in) :: num_tokens
1851
1852 type(c_ptr), target :: argv(num_tokens + 1)
1853 integer :: c_tok_len
1854 character(kind=c_char), allocatable, target :: c_tokens(:,:)
1855 integer :: i, j, k
1856 integer :: ret
1857 logical :: is_path_command
1858
1859
1860 ! Allocate C token buffer based on actual token character length
1861 c_tok_len = len(tokens(1)) + 1
1862 allocate(c_tokens(c_tok_len, num_tokens))
1863
1864 ! Convert tokens to C strings
1865 do i = 1, num_tokens
1866 ! Use len_trim, but if it's 0 and token starts with whitespace, use 1
1867 ! This preserves whitespace-only arguments like " "
1868 j = len_trim(tokens(i))
1869 if (j == 0 .and. len(tokens(i)) > 0) then
1870 if (tokens(i)(1:1) == ' ' .or. tokens(i)(1:1) == char(9)) then
1871 j = 1 ! Keep at least one whitespace character
1872 end if
1873 end if
1874 do k = 1, j
1875 c_tokens(k, i) = tokens(i)(k:k)
1876 end do
1877 c_tokens(j + 1, i) = c_null_char
1878 argv(i) = c_loc(c_tokens(1, i))
1879 end do
1880 argv(num_tokens + 1) = c_null_ptr
1881
1882 ! Check if command is a path (contains /) before calling exec
1883 ! This allows us to distinguish between "file not found" (127) and "permission denied" (126)
1884 is_path_command = (index(trim(tokens(1)), '/') > 0)
1885
1886 if (is_path_command) then
1887 ! For path-based commands, check if file exists and is executable
1888 if (file_exists(trim(tokens(1)))) then
1889 if (.not. file_is_executable(trim(tokens(1)))) then
1890 ! File exists but is not executable -> exit 126
1891 write(error_unit, '(a)') 'fortsh: ' // trim(tokens(1)) // ': Permission denied'
1892 call c_exit(126)
1893 end if
1894 end if
1895 ! If file doesn't exist, execvp will fail with ENOENT (exit 127 below)
1896 end if
1897
1898 ! Execute the command
1899 ret = c_execvp(argv(1), c_loc(argv))
1900
1901 ! If we reach here, exec failed
1902 call show_command_not_found_error(trim(tokens(1)))
1903 end subroutine
1904
1905 subroutine execute_function(cmd, shell)
1906 type(command_t), intent(in) :: cmd
1907 type(shell_state_t), intent(inout) :: shell
1908
1909 type(string_t), allocatable :: function_body(:)
1910 type(string_t), allocatable :: saved_positional_params(:)
1911 integer :: saved_num_positional
1912 integer :: i
1913 type(pipeline_t) :: pipeline
1914 character(len=:), allocatable :: expanded_line
1915 logical :: function_returned
1916
1917 ! Save current positional parameters (caller's $1, $2, etc.)
1918 if (allocated(shell%positional_params)) then
1919 allocate(saved_positional_params(size(shell%positional_params)))
1920 do i = 1, size(shell%positional_params)
1921 saved_positional_params(i)%str = shell%positional_params(i)%str
1922 end do
1923 end if
1924 saved_num_positional = shell%num_positional
1925
1926 ! Enter function scope
1927 shell%function_depth = shell%function_depth + 1
1928
1929 ! Initialize local variable count for this function scope
1930 if (shell%function_depth <= size(shell%local_var_counts)) then
1931 shell%local_var_counts(shell%function_depth) = 0
1932 end if
1933
1934 ! Set positional parameters from function arguments
1935 ! cmd%tokens(1) is function name, cmd%tokens(2:) are arguments
1936 shell%num_positional = cmd%num_tokens - 1
1937 do i = 1, shell%num_positional
1938 shell%positional_params(i)%str = cmd%tokens(i + 1)
1939 end do
1940
1941 ! Get function body
1942 function_body = get_function_body(shell, cmd%tokens(1))
1943
1944 function_returned = .false.
1945
1946 if (allocated(function_body)) then
1947 ! For defun-style functions (body has no $), append call args
1948 if (size(function_body) == 1 .and. cmd%num_tokens > 1 .and. &
1949 allocated(function_body(1)%str) .and. &
1950 index(function_body(1)%str, '$') == 0) then
1951 block
1952 integer :: j
1953 do j = 2, cmd%num_tokens
1954 function_body(1)%str = trim(function_body(1)%str) // &
1955 ' ' // trim(cmd%tokens(j))
1956 end do
1957 end block
1958 end if
1959
1960 ! Execute each line of the function
1961 do i = 1, size(function_body)
1962 if (allocated(function_body(i)%str) .and. len_trim(function_body(i)%str) > 0) then
1963 ! Expand aliases
1964 call expand_alias(shell, trim(function_body(i)%str), expanded_line)
1965
1966 ! Parse and execute
1967 call parse_pipeline(expanded_line, pipeline)
1968 if (pipeline%num_commands > 0) then
1969 call execute_pipeline(pipeline, shell, expanded_line)
1970
1971 ! NOTE: Loop replay now happens inline when 'done' is processed (see line ~470)
1972 ! Deferred replay is no longer needed for normal loop execution
1973 ! This fallback might still be needed for edge cases, so leaving it commented:
1974 ! if (shell%control_depth == 0 .or. .not. shell%control_stack(shell%control_depth)%capturing_loop_body) then
1975 ! call replay_loop_if_needed(shell)
1976 ! end if
1977 end if
1978
1979 ! Clean up
1980 if (allocated(pipeline%commands)) then
1981 deallocate(pipeline%commands)
1982 end if
1983
1984 ! Check if function returned early (via return builtin)
1985 ! We'll use a special flag in shell state for this
1986 if (shell%function_return_pending) then
1987 shell%function_return_pending = .false.
1988 function_returned = .true.
1989 exit
1990 end if
1991
1992 ! Exit early if shell stopped
1993 if (.not. shell%running) exit
1994 end if
1995 end do
1996 end if
1997
1998 ! Clean up local variables for this function scope
1999 if (shell%function_depth > 0 .and. shell%function_depth <= size(shell%local_var_counts)) then
2000 shell%local_var_counts(shell%function_depth) = 0
2001 end if
2002
2003 ! Exit function scope
2004 shell%function_depth = shell%function_depth - 1
2005
2006 ! Restore caller's positional parameters
2007 if (allocated(saved_positional_params)) then
2008 do i = 1, size(saved_positional_params)
2009 shell%positional_params(i)%str = saved_positional_params(i)%str
2010 end do
2011 deallocate(saved_positional_params)
2012 end if
2013 shell%num_positional = saved_num_positional
2014 end subroutine
2015
2016 ! ===========================================================================
2017 ! COMPLETION FUNCTION EXECUTOR
2018 ! Called by completion module to execute -F completion functions
2019 ! ===========================================================================
2020 subroutine execute_completion_function(shell, func_name, command, word, prev_word)
2021 use variables, only: set_shell_variable, get_function_body, is_function
2022 type(shell_state_t), intent(inout) :: shell
2023 character(len=*), intent(in) :: func_name
2024 character(len=*), intent(in) :: command
2025 character(len=*), intent(in) :: word
2026 character(len=*), intent(in) :: prev_word
2027
2028 type(command_t) :: cmd
2029
2030 ! Check if function exists
2031 if (.not. is_function(shell, func_name)) then
2032 return
2033 end if
2034
2035 ! Set up COMP_* variables for the completion function
2036 ! COMP_LINE - the full command line (simplified)
2037 call set_shell_variable(shell, 'COMP_LINE', trim(command) // ' ' // trim(word))
2038 ! COMP_POINT - cursor position
2039 call set_shell_variable(shell, 'COMP_POINT', '0')
2040 ! COMP_CWORD - index of word containing cursor (0-based)
2041 call set_shell_variable(shell, 'COMP_CWORD', '1')
2042
2043 ! Build command structure to execute the function
2044 ! The function receives: command word prev_word
2045 allocate(character(len=256) :: cmd%tokens(4))
2046 cmd%num_tokens = 4
2047 cmd%tokens(1) = trim(func_name)
2048 cmd%tokens(2) = trim(command)
2049 cmd%tokens(3) = trim(word)
2050 cmd%tokens(4) = trim(prev_word)
2051
2052 ! Execute the function
2053 call execute_function(cmd, shell)
2054
2055 ! Clean up
2056 deallocate(cmd%tokens)
2057 end subroutine execute_completion_function
2058
2059 ! Execute eval builtin (moved here to avoid circular dependency with builtins module)
2060 subroutine execute_eval_builtin(cmd, shell)
2061 type(command_t), intent(in) :: cmd
2062 type(shell_state_t), intent(inout) :: shell
2063
2064 character(len=4096) :: eval_command
2065 integer :: i
2066 type(pipeline_t) :: pipeline
2067
2068 ! If no arguments, just return success
2069 if (cmd%num_tokens < 2) then
2070 shell%last_exit_status = 0
2071 return
2072 end if
2073
2074 ! Concatenate all arguments into a single command string
2075 eval_command = trim(cmd%tokens(2))
2076 do i = 3, cmd%num_tokens
2077 eval_command = trim(eval_command) // ' ' // trim(cmd%tokens(i))
2078 end do
2079
2080 ! Parse the concatenated string as a pipeline
2081 call parse_pipeline(trim(eval_command), pipeline)
2082
2083 ! Execute the parsed pipeline in the current shell context
2084 if (pipeline%num_commands > 0) then
2085 call execute_pipeline(pipeline, shell, trim(eval_command))
2086
2087 ! Clean up pipeline allocations
2088 do i = 1, pipeline%num_commands
2089 if (allocated(pipeline%commands(i)%tokens)) deallocate(pipeline%commands(i)%tokens)
2090 if (allocated(pipeline%commands(i)%input_file)) deallocate(pipeline%commands(i)%input_file)
2091 if (allocated(pipeline%commands(i)%output_file)) deallocate(pipeline%commands(i)%output_file)
2092 if (allocated(pipeline%commands(i)%error_file)) deallocate(pipeline%commands(i)%error_file)
2093 if (allocated(pipeline%commands(i)%heredoc_delimiter)) deallocate(pipeline%commands(i)%heredoc_delimiter)
2094 if (allocated(pipeline%commands(i)%heredoc_content)) deallocate(pipeline%commands(i)%heredoc_content)
2095 if (allocated(pipeline%commands(i)%here_string)) deallocate(pipeline%commands(i)%here_string)
2096 end do
2097
2098 if (allocated(pipeline%commands)) deallocate(pipeline%commands)
2099 else
2100 ! Empty command - success
2101 shell%last_exit_status = 0
2102 end if
2103 end subroutine
2104
2105 ! Replay loop body if needed
2106 subroutine replay_loop_if_needed(shell)
2107 use parser, only: parse_pipeline
2108 use control_flow, only: process_control_flow
2109 type(shell_state_t), intent(inout) :: shell
2110 type(pipeline_t) :: pipeline, done_pipeline
2111 type(command_t) :: done_cmd
2112 integer :: i, iteration_count, loop_depth
2113 logical :: should_execute
2114 character(len=4) :: done_str
2115
2116 ! Check if we should replay
2117 if (shell%control_depth == 0) return
2118 if (shell%control_stack(shell%control_depth)%loop_body_count == 0) return
2119
2120 ! Save the loop's control depth - this won't change even if nested control structures are executed
2121 loop_depth = shell%control_depth
2122 iteration_count = 0
2123 done_str = 'done'
2124
2125 ! Keep replaying until loop condition is false
2126 ! Use loop_depth instead of shell%control_depth since depth can change during loop body execution
2127 do while (shell%control_depth >= loop_depth .and. shell%control_stack(loop_depth)%loop_body_count > 0)
2128 iteration_count = iteration_count + 1
2129 if (iteration_count > 1000) then
2130 write(error_unit, '(a)') 'Loop limit reached (1000 iterations)'
2131 exit
2132 end if
2133
2134 ! Temporarily stop capturing to avoid re-capturing during replay
2135 shell%control_stack(loop_depth)%capturing_loop_body = .false.
2136
2137 do i = 1, shell%control_stack(loop_depth)%loop_body_count
2138 ! Check if break was requested
2139 if (shell%control_stack(loop_depth)%break_requested) then
2140 if (shell%control_stack(loop_depth)%break_level > 1) then
2141 ! Multi-level break: propagate to parent loop
2142 if (loop_depth > 1) then
2143 shell%control_stack(loop_depth - 1)%break_requested = .true.
2144 shell%control_stack(loop_depth - 1)%break_level = &
2145 shell%control_stack(loop_depth)%break_level - 1
2146 end if
2147 end if
2148 ! Clear the break flag for this level
2149 shell%control_stack(loop_depth)%break_requested = .false.
2150 shell%control_stack(loop_depth)%break_level = 0
2151 ! Exit the loop immediately
2152 shell%control_stack(loop_depth)%loop_body_count = 0 ! Signal loop end
2153 exit
2154 end if
2155
2156 ! Check if continue was requested
2157 if (shell%control_stack(loop_depth)%continue_requested) then
2158 if (shell%control_stack(loop_depth)%continue_level > 1) then
2159 ! Multi-level continue: propagate to parent loop
2160 if (loop_depth > 1) then
2161 shell%control_stack(loop_depth - 1)%continue_requested = .true.
2162 shell%control_stack(loop_depth - 1)%continue_level = &
2163 shell%control_stack(loop_depth)%continue_level - 1
2164 end if
2165 end if
2166 ! Clear the continue flag for this level
2167 shell%control_stack(loop_depth)%continue_requested = .false.
2168 shell%control_stack(loop_depth)%continue_level = 0
2169 ! Skip the rest of the iteration
2170 exit
2171 end if
2172
2173 call parse_pipeline(shell%control_stack(loop_depth)%loop_body(i)%str, pipeline)
2174 if (pipeline%num_commands > 0) then
2175 call execute_pipeline(pipeline, shell, shell%control_stack(loop_depth)%loop_body(i)%str)
2176 end if
2177 end do
2178
2179 ! Re-enable capturing for next iteration
2180 shell%control_stack(loop_depth)%capturing_loop_body = .true.
2181
2182 ! Check if loop_body_count is 0 (break was called)
2183 if (shell%control_stack(loop_depth)%loop_body_count == 0) then
2184 ! Break was called, exit the loop
2185 exit
2186 end if
2187
2188 ! Simulate 'done' to check loop condition and update state
2189 call parse_pipeline(done_str, done_pipeline)
2190 if (done_pipeline%num_commands > 0) then
2191 done_cmd = done_pipeline%commands(1)
2192 call process_control_flow(done_cmd, shell, should_execute)
2193 ! If control_depth decreased below loop_depth, loop ended
2194 if (shell%control_depth < loop_depth) then
2195 exit
2196 end if
2197 ! Also exit if loop_body_count became 0 (break was called)
2198 if (shell%control_stack(loop_depth)%loop_body_count == 0) then
2199 exit
2200 end if
2201 else
2202 exit ! Couldn't parse done, exit
2203 end if
2204 end do
2205 end subroutine
2206
2207 ! Reconstruct command line from command tokens
2208 subroutine reconstruct_command_from_tokens(cmd, result)
2209 type(command_t), intent(in) :: cmd
2210 character(len=*), intent(out) :: result
2211 integer :: i
2212
2213 result = ''
2214 if (.not. allocated(cmd%tokens) .or. cmd%num_tokens == 0) return
2215
2216 ! Join tokens with spaces
2217 do i = 1, cmd%num_tokens
2218 if (i == 1) then
2219 result = trim(cmd%tokens(i))
2220 else
2221 result = trim(result) // ' ' // trim(cmd%tokens(i))
2222 end if
2223 end do
2224 end subroutine
2225
2226 ! Strip surrounding quotes (single or double) from a string
2227 ! Preserves trailing spaces within quotes
2228 subroutine strip_quotes_local(str)
2229 character(len=*), intent(inout) :: str
2230 integer :: i, j, len_str, closing_quote_pos
2231 character(len=len(str)) :: temp
2232 character(len=1) :: quote_char
2233 logical :: is_double_quote
2234
2235 len_str = len_trim(str)
2236 if (len_str < 2) return
2237
2238 ! Check if string starts with a quote
2239 if (str(1:1) /= "'" .and. str(1:1) /= '"') return
2240
2241 quote_char = str(1:1)
2242 is_double_quote = (quote_char == '"')
2243
2244 ! Search for matching closing quote (search backwards from end)
2245 closing_quote_pos = 0
2246 do i = len_str, 2, -1
2247 if (str(i:i) == quote_char) then
2248 closing_quote_pos = i
2249 exit
2250 end if
2251 end do
2252
2253 ! If we found a matching closing quote, extract the content (preserving all characters including trailing spaces)
2254 if (closing_quote_pos > 1) then
2255 ! Save the original string first
2256 temp = str
2257 ! Clear the output string
2258 str = repeat(' ', len(str))
2259
2260 ! If double quotes, process escape sequences while copying
2261 if (is_double_quote) then
2262 i = 2
2263 j = 1
2264 do while (i < closing_quote_pos)
2265 if (temp(i:i) == '\' .and. i+1 < closing_quote_pos) then
2266 ! Check if this backslash escapes a special character
2267 if (temp(i+1:i+1) == '"' .or. temp(i+1:i+1) == '\' .or. &
2268 temp(i+1:i+1) == '$' .or. temp(i+1:i+1) == '`') then
2269 ! Skip backslash, keep the escaped character
2270 i = i + 1
2271 str(j:j) = temp(i:i)
2272 i = i + 1
2273 j = j + 1
2274 else
2275 ! Backslash doesn't escape anything special - keep both
2276 str(j:j) = temp(i:i)
2277 i = i + 1
2278 j = j + 1
2279 end if
2280 else
2281 ! Regular character
2282 str(j:j) = temp(i:i)
2283 i = i + 1
2284 j = j + 1
2285 end if
2286 end do
2287 else
2288 ! Single quotes - copy literally without escape processing
2289 do i = 2, closing_quote_pos - 1
2290 str(i-1:i-1) = temp(i:i)
2291 end do
2292 end if
2293 end if
2294 end subroutine
2295
2296 ! Execute a pending trap command (set by signal_handling module)
2297 subroutine execute_pending_trap(shell)
2298 use trap_dispatch, only: eval_trap_string
2299 type(shell_state_t), intent(inout) :: shell
2300 integer :: saved_status, exit_code
2301 logical :: saved_bypass
2302
2303 ! Save the trap command and signal before clearing
2304 character(len=:), allocatable :: trap_cmd
2305 trap_cmd = trim(shell%pending_trap_command)
2306
2307 ! Save current exit status (traps don't affect $?)
2308 saved_status = shell%last_exit_status
2309
2310 ! Save and clear bypass_functions — trap handlers should see all functions
2311 ! even when fired inside 'command' builtin context
2312 saved_bypass = shell%bypass_functions
2313 shell%bypass_functions = .false.
2314
2315 ! Clear the pending trap
2316 shell%pending_trap_command = ''
2317 shell%pending_trap_signal = 0
2318
2319 ! Set flag to prevent recursive trap execution
2320 shell%executing_trap = .true.
2321
2322 ! Execute via trap_dispatch (registered by ast_executor at startup)
2323 call eval_trap_string(trim(trap_cmd), shell, exit_code)
2324
2325 ! Clear flag to allow future trap execution
2326 shell%executing_trap = .false.
2327
2328 ! Restore bypass_functions and exit status
2329 shell%bypass_functions = saved_bypass
2330 shell%last_exit_status = saved_status
2331 end subroutine
2332
2333 ! Execute inline commands after "then" in single-line if statements
2334 ! Example: if [ 1 -eq 1 ]; then echo "test"; fi
2335 ! After parsing, the "then echo 'test'" becomes a single command with tokens ["then", "echo", "test"]
2336 subroutine execute_inline_then_commands(cmd, shell)
2337 type(command_t), intent(in) :: cmd
2338 type(shell_state_t), intent(inout) :: shell
2339 character(len=:), allocatable :: remainder_cmd
2340 type(pipeline_t) :: inline_pipeline
2341 integer :: i
2342
2343 ! Only execute if we're in a truthy if block
2344 ! The control flow state has already been updated by process_control_flow
2345 if (shell%control_depth == 0) return
2346 if (.not. shell%control_stack(shell%control_depth)%should_execute) return
2347
2348 ! Build command string from tokens 2 onwards
2349 remainder_cmd = ''
2350 do i = 2, cmd%num_tokens
2351 if (len_trim(remainder_cmd) > 0) then
2352 remainder_cmd = trim(remainder_cmd) // ' ' // trim(cmd%tokens(i))
2353 else
2354 remainder_cmd = trim(cmd%tokens(i))
2355 end if
2356 end do
2357
2358 ! Parse and execute the inline commands
2359 if (len_trim(remainder_cmd) > 0) then
2360 call parse_pipeline(trim(remainder_cmd), inline_pipeline)
2361 if (inline_pipeline%num_commands > 0) then
2362 call execute_pipeline(inline_pipeline, shell, trim(remainder_cmd))
2363
2364 ! Clean up pipeline allocations
2365 do i = 1, inline_pipeline%num_commands
2366 if (allocated(inline_pipeline%commands(i)%tokens)) deallocate(inline_pipeline%commands(i)%tokens)
2367 if (allocated(inline_pipeline%commands(i)%input_file)) deallocate(inline_pipeline%commands(i)%input_file)
2368 if (allocated(inline_pipeline%commands(i)%output_file)) deallocate(inline_pipeline%commands(i)%output_file)
2369 if (allocated(inline_pipeline%commands(i)%error_file)) deallocate(inline_pipeline%commands(i)%error_file)
2370 if (allocated(inline_pipeline%commands(i)%heredoc_delimiter)) deallocate(inline_pipeline%commands(i)%heredoc_delimiter)
2371 if (allocated(inline_pipeline%commands(i)%heredoc_content)) deallocate(inline_pipeline%commands(i)%heredoc_content)
2372 if (allocated(inline_pipeline%commands(i)%here_string)) deallocate(inline_pipeline%commands(i)%here_string)
2373 end do
2374
2375 if (allocated(inline_pipeline%commands)) deallocate(inline_pipeline%commands)
2376 end if
2377 end if
2378 end subroutine
2379
2380 ! Initialize control_flow's evaluate_condition procedure pointer
2381 ! This breaks the circular dependency by setting the pointer at runtime
2382 subroutine init_control_flow_callbacks()
2383 use control_flow
2384 evaluate_condition => evaluate_condition_impl
2385 end subroutine
2386
2387 ! Evaluate a condition for control flow (if/while statements)
2388 subroutine evaluate_condition_impl(condition_cmd, shell, result)
2389 use test_builtin, only: execute_test_command
2390 use parser, only: parse_pipeline
2391 character(len=*), intent(in) :: condition_cmd
2392 type(shell_state_t), intent(inout) :: shell
2393 logical, intent(out) :: result
2394
2395 type(pipeline_t) :: pipeline
2396 integer :: saved_depth, i
2397
2398 ! POSIX: Suppress errexit during condition evaluation (if/while/until test expressions)
2399 shell%evaluating_condition = .true.
2400
2401 ! Save control depth and temporarily reset to 0 so condition executes unconditionally
2402 ! This prevents should_execute_command() from blocking condition evaluation
2403 saved_depth = shell%control_depth
2404 shell%control_depth = 0
2405
2406 ! Check if it's a test command (starts with [ or test)
2407 if (index(trim(condition_cmd), '[') == 1 .or. &
2408 index(trim(condition_cmd), 'test ') == 1) then
2409 call execute_test_condition_impl(condition_cmd, shell, result)
2410 else
2411 ! For any other command, parse and execute it to get exit status
2412 call parse_pipeline(trim(condition_cmd), pipeline)
2413
2414 if (pipeline%num_commands > 0) then
2415 call execute_pipeline(pipeline, shell, trim(condition_cmd))
2416
2417 ! Clean up allocations
2418 do i = 1, pipeline%num_commands
2419 if (allocated(pipeline%commands(i)%tokens)) deallocate(pipeline%commands(i)%tokens)
2420 if (allocated(pipeline%commands(i)%input_file)) deallocate(pipeline%commands(i)%input_file)
2421 if (allocated(pipeline%commands(i)%output_file)) deallocate(pipeline%commands(i)%output_file)
2422 if (allocated(pipeline%commands(i)%error_file)) deallocate(pipeline%commands(i)%error_file)
2423 if (allocated(pipeline%commands(i)%heredoc_delimiter)) deallocate(pipeline%commands(i)%heredoc_delimiter)
2424 if (allocated(pipeline%commands(i)%heredoc_content)) deallocate(pipeline%commands(i)%heredoc_content)
2425 if (allocated(pipeline%commands(i)%here_string)) deallocate(pipeline%commands(i)%here_string)
2426 end do
2427
2428 if (allocated(pipeline%commands)) deallocate(pipeline%commands)
2429
2430 result = (shell%last_exit_status == 0)
2431 else
2432 result = .false.
2433 end if
2434 end if
2435
2436 ! Restore control depth
2437 shell%control_depth = saved_depth
2438
2439 ! Re-enable errexit checking
2440 shell%evaluating_condition = .false.
2441 end subroutine
2442
2443 ! Simple tokenization by spaces
2444 subroutine tokenize_line(input, tokens, num_tokens)
2445 character(len=*), intent(in) :: input
2446 character(len=256), intent(out) :: tokens(:)
2447 integer, intent(out) :: num_tokens
2448 integer :: i, start_pos
2449
2450 num_tokens = 0
2451 i = 1
2452
2453 do while (i <= len_trim(input))
2454 ! Skip spaces
2455 do while (i <= len_trim(input) .and. input(i:i) == ' ')
2456 i = i + 1
2457 end do
2458
2459 if (i > len_trim(input)) exit
2460
2461 ! Start of token
2462 start_pos = i
2463 do while (i <= len_trim(input) .and. input(i:i) /= ' ')
2464 i = i + 1
2465 end do
2466
2467 ! Store token
2468 num_tokens = num_tokens + 1
2469 if (num_tokens <= size(tokens)) then
2470 tokens(num_tokens) = input(start_pos:i-1)
2471 end if
2472 end do
2473 end subroutine
2474
2475 ! Helper for evaluating test conditions
2476 subroutine execute_test_condition_impl(condition_cmd, shell, result)
2477 use test_builtin, only: execute_test_command
2478 use control_flow, only: simple_variable_expand
2479 character(len=*), intent(in) :: condition_cmd
2480 type(shell_state_t), intent(inout) :: shell
2481 logical, intent(out) :: result
2482
2483 type(command_t) :: cmd
2484 character(len=256) :: tokens(50), expanded_token
2485 character(len=:), allocatable :: expanded_result
2486 integer :: num_tokens, i
2487
2488 ! Tokenize the condition command
2489 num_tokens = 0
2490 call tokenize_line(trim(condition_cmd), tokens, num_tokens)
2491
2492 ! Allocate and set command tokens with variable expansion
2493 if (allocated(cmd%tokens)) deallocate(cmd%tokens)
2494 allocate(character(len=256) :: cmd%tokens(num_tokens))
2495 cmd%num_tokens = num_tokens
2496 do i = 1, num_tokens
2497 expanded_token = tokens(i)
2498
2499 ! Expand variables in the token (e.g., $count becomes the value of count)
2500 if (index(expanded_token, '$') > 0) then
2501 call simple_variable_expand(expanded_token, expanded_result, shell)
2502 if (allocated(expanded_result)) then
2503 cmd%tokens(i) = expanded_result
2504 else
2505 cmd%tokens(i) = expanded_token
2506 end if
2507 else
2508 cmd%tokens(i) = expanded_token
2509 end if
2510 end do
2511
2512 ! Execute the test command
2513 call execute_test_command(cmd, shell)
2514
2515 ! Clean up
2516 if (allocated(cmd%tokens)) deallocate(cmd%tokens)
2517
2518 result = (shell%last_exit_status == 0)
2519 end subroutine
2520
2521 ! Check if command is a function definition and register it
2522 function is_function_definition_command(cmd, shell) result(is_func_def)
2523 use variables, only: add_function
2524 type(command_t), intent(in) :: cmd
2525 type(shell_state_t), intent(inout) :: shell
2526 logical :: is_func_def
2527
2528 character(len=256) :: func_name
2529 character(len=2048) :: reconstructed
2530 character(len=:), allocatable :: func_body_line
2531 integer :: body_count, brace_start, brace_end, paren_pos
2532
2533 is_func_def = .false.
2534
2535 ! Check if we have enough tokens
2536 if (cmd%num_tokens < 1) return
2537
2538 ! IMPORTANT: Don't treat quoted strings as function definitions
2539 ! If the first token is quoted, it cannot be a function definition
2540 if (allocated(cmd%token_quoted) .and. size(cmd%token_quoted) >= 1) then
2541 if (cmd%token_quoted(1)) return
2542 end if
2543
2544 ! Reconstruct the full command to analyze it
2545 call reconstruct_command_from_tokens(cmd, reconstructed)
2546
2547 ! Check for pattern: name() { body }
2548 ! Look for () followed by {
2549 paren_pos = index(reconstructed, '()')
2550 if (paren_pos == 0) return
2551
2552 ! Extract function name (everything before ())
2553 func_name = adjustl(reconstructed(1:paren_pos-1))
2554 if (len_trim(func_name) == 0) return
2555
2556 ! IMPORTANT: Function names cannot contain spaces
2557 ! This prevents "echo 'a() { }'" from being treated as a function definition
2558 ! (it would reconstruct to "echo a() { }" with func_name="echo a")
2559 if (index(func_name, ' ') > 0) return
2560
2561 ! Check if there's a { after the ()
2562 brace_start = index(reconstructed(paren_pos:), '{')
2563 if (brace_start == 0) return
2564 brace_start = paren_pos + brace_start - 1
2565
2566 ! Find matching }
2567 brace_end = index(reconstructed(brace_start:), '}')
2568 if (brace_end == 0) return
2569 brace_end = brace_start + brace_end - 1
2570
2571 ! Extract function body (between { and })
2572 if (brace_end > brace_start + 1) then
2573 body_count = 1
2574 func_body_line = trim(adjustl(reconstructed(brace_start+1:brace_end-1)))
2575 else
2576 body_count = 0
2577 func_body_line = ''
2578 end if
2579
2580 ! Register the function
2581 call add_function(shell, trim(func_name), [func_body_line], body_count)
2582
2583 is_func_def = .true.
2584 end function
2585
2586 ! Execute subshell ( cmd1; cmd2 )
2587 ! Forks a child process and executes commands in isolated environment
2588 subroutine execute_subshell(content, shell, original_input)
2589 character(len=*), intent(in) :: content
2590 type(shell_state_t), intent(inout) :: shell
2591 character(len=*), intent(in) :: original_input
2592
2593 integer(c_pid_t) :: pid
2594 integer(c_int), target :: wait_status
2595 integer :: ret
2596 type(pipeline_t) :: subshell_pipeline
2597
2598 if (.false.) print *, original_input ! Silence unused warning
2599
2600 pid = c_fork()
2601
2602 if (pid < 0) then
2603 write(error_unit, '(a)') 'Error: fork failed for subshell'
2604 shell%last_exit_status = 1
2605 else if (pid == 0) then
2606 ! Child process (subshell)
2607 ! Parse and execute the subshell content
2608 call parse_pipeline(content, subshell_pipeline)
2609 if (subshell_pipeline%num_commands > 0) then
2610 call execute_pipeline(subshell_pipeline, shell, content)
2611 end if
2612 ! Exit with the last command's exit status
2613 call c_exit(int(shell%last_exit_status, c_int))
2614 else
2615 ! Parent process - wait for subshell
2616 shell%last_pid = pid
2617 ret = c_waitpid(pid, c_loc(wait_status), 0)
2618
2619 if (WIFEXITED(wait_status)) then
2620 shell%last_exit_status = WEXITSTATUS(wait_status)
2621 else if (WIFSIGNALED(wait_status)) then
2622 shell%last_exit_status = 128 + WTERMSIG(wait_status)
2623 else
2624 shell%last_exit_status = 1
2625 end if
2626 end if
2627 end subroutine
2628
2629 ! Apply prefix assignments to environment (VAR=value command)
2630 ! Called in child process before exec to set environment variables
2631 ! scoped to the command execution
2632 subroutine apply_prefix_assignments(cmd)
2633 type(command_t), intent(in) :: cmd
2634 integer :: i, eq_pos, ret
2635 character(len=MAX_TOKEN_LEN) :: var_name, var_value
2636 character(len=MAX_TOKEN_LEN), target :: c_var_name, c_var_value
2637
2638 ! Iterate through all prefix assignments
2639 if (.not. allocated(cmd%prefix_assignments)) return
2640 do i = 1, cmd%num_prefix_assignments
2641 ! Find the '=' separator
2642 eq_pos = index(cmd%prefix_assignments(i), '=')
2643
2644 if (eq_pos > 1) then
2645 ! Extract variable name and value
2646 var_name = cmd%prefix_assignments(i)(:eq_pos-1)
2647 var_value = cmd%prefix_assignments(i)(eq_pos+1:)
2648
2649 ! Convert to C strings with null terminator
2650 c_var_name = trim(var_name)//c_null_char
2651 c_var_value = trim(var_value)//c_null_char
2652
2653 ! Set environment variable (overwrite=1 to replace existing values)
2654 ret = c_setenv(c_loc(c_var_name), c_loc(c_var_value), 1_c_int)
2655 ! Note: We ignore the return value here since we're in a child process
2656 ! and any errors will be reflected in the command's execution
2657 end if
2658 end do
2659 end subroutine
2660
2661 ! Process sourced files inline (for dot command in non-interactive mode)
2662 subroutine process_source_inline(shell)
2663 use variables, only: set_shell_variable
2664 type(shell_state_t), intent(inout) :: shell
2665 character(len=16384) :: input_line
2666 integer :: file_unit, iostat, i
2667 type(pipeline_t) :: pipeline
2668 character(len=:), allocatable :: expanded_line
2669
2670 ! Reset the source flag first
2671 shell%should_source = .false.
2672
2673 ! Open file for reading
2674 open(newunit=file_unit, file=trim(shell%source_file), status='old', action='read', iostat=iostat)
2675 if (iostat /= 0) then
2676 write(error_unit, '(a)') 'source: failed to open ' // trim(shell%source_file)
2677 shell%last_exit_status = 1
2678 return
2679 end if
2680
2681 ! Execute each line in the file
2682 do
2683 read(file_unit, '(a)', iostat=iostat) input_line
2684 if (iostat /= 0) exit ! End of file or error
2685
2686 ! Skip empty lines and comments
2687 if (len_trim(input_line) == 0 .or. input_line(1:1) == '#') cycle
2688
2689 ! Expand aliases (simple version - just use the line as-is)
2690 expanded_line = trim(input_line)
2691
2692 ! Parse and execute pipeline
2693 call parse_pipeline(expanded_line, pipeline)
2694
2695 if (pipeline%num_commands > 0) then
2696 call execute_pipeline(pipeline, shell, expanded_line)
2697
2698 ! Clean up pipeline
2699 if (allocated(pipeline%commands)) then
2700 do i = 1, pipeline%num_commands
2701 if (allocated(pipeline%commands(i)%tokens)) deallocate(pipeline%commands(i)%tokens)
2702 if (allocated(pipeline%commands(i)%input_file)) deallocate(pipeline%commands(i)%input_file)
2703 if (allocated(pipeline%commands(i)%output_file)) deallocate(pipeline%commands(i)%output_file)
2704 if (allocated(pipeline%commands(i)%error_file)) deallocate(pipeline%commands(i)%error_file)
2705 if (allocated(pipeline%commands(i)%heredoc_delimiter)) deallocate(pipeline%commands(i)%heredoc_delimiter)
2706 if (allocated(pipeline%commands(i)%heredoc_content)) deallocate(pipeline%commands(i)%heredoc_content)
2707 if (allocated(pipeline%commands(i)%here_string)) deallocate(pipeline%commands(i)%here_string)
2708 end do
2709 deallocate(pipeline%commands)
2710 end if
2711 end if
2712
2713 ! Stop execution if exit command was encountered
2714 if (.not. shell%running) exit
2715 end do
2716
2717 close(file_unit)
2718 shell%source_file = ''
2719 end subroutine process_source_inline
2720
2721 ! ===========================================================================
2722 ! COMPLETION EXECUTOR INITIALIZATION
2723 ! Registers the executor's completion function handler with the completion module
2724 ! ===========================================================================
2725 subroutine init_completion_executor()
2726 ! Register our execute_completion_function as the callback
2727 call register_completion_executor(execute_completion_function)
2728 end subroutine init_completion_executor
2729
2730 ! Initialize token metadata arrays if not already set
2731 ! This is needed for commands parsed by the old parser path which doesn't
2732 ! populate these arrays. Inspects tokens to determine quote type and length.
2733 subroutine init_token_metadata(cmd)
2734 type(command_t), intent(inout) :: cmd
2735 integer :: i, token_len
2736 character(len=1) :: first_char, last_char
2737
2738 if (cmd%num_tokens == 0) return
2739
2740 ! Initialize token_quoted if not allocated
2741 if (.not. allocated(cmd%token_quoted)) then
2742 allocate(cmd%token_quoted(cmd%num_tokens))
2743 cmd%token_quoted = .false.
2744 end if
2745
2746 ! Initialize token_escaped if not allocated
2747 if (.not. allocated(cmd%token_escaped)) then
2748 allocate(cmd%token_escaped(cmd%num_tokens))
2749 cmd%token_escaped = .false.
2750 end if
2751
2752 ! Initialize token_quote_type if not allocated
2753 if (.not. allocated(cmd%token_quote_type)) then
2754 allocate(cmd%token_quote_type(cmd%num_tokens))
2755 cmd%token_quote_type = QUOTE_NONE
2756 end if
2757
2758 ! Initialize token_lengths if not allocated
2759 if (.not. allocated(cmd%token_lengths)) then
2760 allocate(cmd%token_lengths(cmd%num_tokens))
2761 cmd%token_lengths = 0
2762 end if
2763
2764 ! Inspect each token to determine quote type and length
2765 do i = 1, cmd%num_tokens
2766 token_len = len_trim(cmd%tokens(i))
2767
2768 ! If quote info isn't set, try to detect from token content
2769 if (cmd%token_quote_type(i) == QUOTE_NONE .and. token_len >= 2) then
2770 first_char = cmd%tokens(i)(1:1)
2771 last_char = cmd%tokens(i)(token_len:token_len)
2772
2773 ! Check for single-quoted token (may have sentinel markers char(2)/char(3))
2774 ! IMPORTANT: Don't treat as syntactic quotes if token was escaped (e.g., \'a\')
2775 if (first_char == "'" .and. last_char == "'" .and. .not. cmd%token_escaped(i)) then
2776 cmd%token_quoted(i) = .true.
2777 cmd%token_quote_type(i) = QUOTE_SINGLE
2778 ! For single quotes, preserve trailing whitespace by not using len_trim
2779 ! Find actual content length between quotes
2780 cmd%token_lengths(i) = token_len
2781 else if (first_char == char(2)) then
2782 ! Single-quote sentinel marker - this token was single-quoted
2783 cmd%token_quoted(i) = .true.
2784 cmd%token_quote_type(i) = QUOTE_SINGLE
2785 cmd%token_lengths(i) = token_len
2786 ! Check for double-quoted token
2787 ! IMPORTANT: Don't treat as syntactic quotes if token was escaped (e.g., \"a\")
2788 else if (first_char == '"' .and. last_char == '"' .and. .not. cmd%token_escaped(i)) then
2789 cmd%token_quoted(i) = .true.
2790 cmd%token_quote_type(i) = QUOTE_DOUBLE
2791 ! For double-quoted, find actual length including trailing whitespace
2792 ! The content is between the quotes: token(2:token_len-1)
2793 ! We need to preserve the full quoted token including any trailing space inside
2794 cmd%token_lengths(i) = token_len
2795 else if (first_char == char(1)) then
2796 ! Double-quote sentinel marker
2797 cmd%token_quoted(i) = .true.
2798 cmd%token_quote_type(i) = QUOTE_DOUBLE
2799 cmd%token_lengths(i) = token_len
2800 else
2801 ! Unquoted token
2802 cmd%token_lengths(i) = token_len
2803 end if
2804 else if (cmd%token_lengths(i) == 0) then
2805 cmd%token_lengths(i) = token_len
2806 end if
2807 end do
2808 end subroutine init_token_metadata
2809
2810 subroutine cache_command_path(shell, cmd_name)
2811 type(shell_state_t), intent(inout) :: shell
2812 character(len=*), intent(in) :: cmd_name
2813 character(len=MAX_PATH_LEN) :: full_path
2814 integer :: j
2815
2816 ! Inline PATH search to avoid circular dependency with command_builtin
2817 block
2818 character(len=:), allocatable :: path_alloc
2819 character(len=4096) :: path_var
2820 character(len=:), allocatable :: path_comp, candidate
2821 character(len=MAX_PATH_LEN) :: candidate_buf
2822 integer :: spos, epos, cpos
2823 character(kind=c_char), target :: c_path(1025)
2824 integer :: ci, acc_status
2825 interface
2826 function cache_access(pathname, mode) bind(C, name="access")
2827 import :: c_char, c_int
2828 character(kind=c_char), intent(in) :: pathname(*)
2829 integer(c_int), value :: mode
2830 integer(c_int) :: cache_access
2831 end function
2832 end interface
2833
2834 full_path = ''
2835 if (index(cmd_name, '/') > 0) return
2836
2837 path_alloc = get_environment_var('PATH')
2838 if (allocated(path_alloc) .and. len_trim(path_alloc) > 0) then
2839 path_var = path_alloc
2840 else
2841 path_var = '/usr/bin:/bin'
2842 end if
2843
2844 spos = 1
2845 do while (spos <= len_trim(path_var))
2846 cpos = index(path_var(spos:), ':')
2847 if (cpos == 0) then
2848 epos = len_trim(path_var)
2849 else
2850 epos = spos + cpos - 2
2851 end if
2852 path_comp = path_var(spos:epos)
2853 if (len_trim(path_comp) == 0) path_comp = '.'
2854
2855 write(candidate_buf, '(a,a,a)') trim(path_comp), '/', trim(cmd_name)
2856 candidate = trim(candidate_buf)
2857
2858 ! Check executable via C access()
2859 do ci = 1, len_trim(candidate)
2860 c_path(ci) = candidate(ci:ci)
2861 end do
2862 c_path(len_trim(candidate) + 1) = c_null_char
2863 acc_status = cache_access(c_path, int(1, c_int)) ! X_OK = 1
2864 if (acc_status == 0) then
2865 full_path = trim(candidate)
2866 exit
2867 end if
2868
2869 if (cpos == 0) exit
2870 spos = spos + cpos
2871 end do
2872 end block
2873 if (len_trim(full_path) == 0) return
2874
2875 ! Check if already in hash table — update hits
2876 do j = 1, shell%num_hashed_commands
2877 if (trim(shell%command_hash(j)%command_name) == &
2878 cmd_name) then
2879 shell%command_hash(j)%hits = &
2880 shell%command_hash(j)%hits + 1
2881 return
2882 end if
2883 end do
2884
2885 ! Add new entry
2886 if (shell%num_hashed_commands < &
2887 size(shell%command_hash)) then
2888 shell%num_hashed_commands = &
2889 shell%num_hashed_commands + 1
2890 shell%command_hash(shell%num_hashed_commands) &
2891 %command_name = cmd_name
2892 shell%command_hash(shell%num_hashed_commands) &
2893 %full_path = full_path
2894 shell%command_hash(shell%num_hashed_commands) &
2895 %hits = 1
2896 end if
2897 end subroutine cache_command_path
2898
2899 end module executor