File Coverage

lib/BATsh/SH.pm
Criterion Covered Total %
statement 1937 2476 78.2
branch 771 1314 58.6
condition 327 635 51.5
subroutine 98 105 93.3
pod 0 4 0.0
total 3133 4534 69.1


line stmt bran cond sub pod time code
1             package BATsh::SH;
2             ######################################################################
3             #
4             # BATsh::SH - Pure Perl sh/bash interpreter
5             #
6             # Copyright (c) 2026 INABA Hitoshi
7             #
8             # Implements sh/bash command set in Perl.
9             # No external sh or bash required.
10             #
11             # Supported:
12             # Variable assignment: VAR=value
13             # export VAR=value, export VAR, unset VAR
14             # echo, printf
15             # if/then/elif/else/fi
16             # for VAR in list; do ... done
17             # while condition; do ... done
18             # until condition; do ... done
19             # case $var in pat|pat) ... ;; *) ... ;; esac (|, globs, [classes], ;& ;;&)
20             # test / [ ... ] (file, string, integer comparisons)
21             # cd, pwd, exit, true, false, :
22             # trap 'cmd' SIG... / trap - SIG / trap '' SIG / trap [-p] (EXIT + %SIG bridge)
23             # read VAR (reads one line from STDIN)
24             # shift [N] (shift positional parameters left)
25             # local VAR=value (function-scoped variable)
26             # $(( arithmetic )) -- +,-,*,/,%, and $1..$9 inside
27             # $( command ) and `command` (command substitution, nested)
28             # name() { ... }, function name { ... } (function definitions)
29             # cmd1 | cmd2 [| cmd3 ...] (pipeline via tmpfile, 5.005_03)
30             # cmd1 && cmd2, cmd1 || cmd2, cmd1 ; cmd2 (compound commands)
31             # > >> < 2> 2>> 2>&1 1>&2 (I/O redirection)
32             # cmd << DELIM ... DELIM, <<-DELIM, <<'DELIM' (here-document)
33             # cmd & (background execution of an external command; SH mode)
34             # $VAR, ${VAR}, $1..$9, $@, $*, $#, $?, $$, $0, $!
35             # ${VAR:-def}, ${VAR:=def}, ${VAR:+alt}
36             # ${VAR%pat}, ${VAR%%pat} (shortest/longest suffix removal)
37             # ${VAR#pat}, ${VAR##pat} (shortest/longest prefix removal)
38             # ${VAR/pat/rep}, ${VAR//pat/rep} (first/all substitution)
39             # ${VAR^^}, ${VAR^}, ${VAR,,}, ${VAR,} (case conversion)
40             # ${VAR:N:L}, ${VAR:N} (substring)
41             # ${#VAR} (string length)
42             # arr=(a b c), arr+=(d e) (indexed array assignment / append)
43             # arr[i]=v, arr[i]+=v (indexed element assignment / append)
44             # declare -a arr, declare -A map, typeset ... (array declaration)
45             # map=([k1]=v1 [k2]=v2), map[k]=v (associative array assignment)
46             # ${arr[i]}, ${map[key]} (element access; $arr == ${arr[0]})
47             # ${arr[@]}, ${arr[*]} (all elements)
48             # ${#arr[@]}, ${#map[@]} (element count)
49             # ${#arr[i]} (length of one element)
50             # ${!arr[@]}, ${!map[@]} (indices / keys)
51             # unset arr, unset arr[i] (whole array / single element)
52             # source / . file
53             #
54             ######################################################################
55              
56 15     15   81 use strict;
  15         21  
  15         1050  
57 15 50 33 15   303 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
58 15     15   53 use warnings; local $^W = 1;
  15         22  
  15         879  
59 15 50   15   234 BEGIN { pop @INC if $INC[-1] eq '.' }
60              
61 15     15   54 use File::Spec ();
  15         16  
  15         234  
62 15     15   44 use Carp qw(croak);
  15         19  
  15         727  
63 15     15   53 use Fcntl qw(O_WRONLY O_CREAT O_EXCL);
  15         19  
  15         700  
64 15     15   53 use vars qw($VERSION);
  15         18  
  15         792  
65             $VERSION = '0.06';
66             $VERSION = $VERSION;
67              
68             # Bareword filehandle globs for SH pipeline (Perl 5.005_03 compatible)
69 15     15   55 use vars qw(*_SH_PIPE_SAVOUT *_SH_PIPE_SAVIN *_SH_PIPE_WFH *_SH_PIPE_RFH);
  15         20  
  15         770  
70              
71             # Bareword filehandle globs for SH I/O redirection (Perl 5.005_03 compatible)
72 15     15   52 use vars qw(*_SH_REDIR_SRC *_SH_REDIR_DST *_SH_REDIR_SAVOUT *_SH_REDIR_SAVERR *_SH_REDIR_SAVIN);
  15         21  
  15         717  
73              
74             # Bareword filehandle glob for here-document temp file (Perl 5.005_03 compatible)
75 15     15   80 use vars qw(*_HD_TMP);
  15         32  
  15         397  
76              
77             # Bareword filehandle globs for background-job PID temp file (Perl 5.005_03 compatible)
78 15     15   59 use vars qw(*_BG_TMP *_BG_PIDFH);
  15         21  
  15         437  
79              
80             # $! -- PID of the most recent background job (package-level so _expand and
81             # the test suite can read it). Empty string before any background job.
82 15     15   202 use vars qw($_LAST_BG_PID);
  15         62  
  15         526  
83             $_LAST_BG_PID = '';
84              
85             # Active nesting depth of command substitution _cmd_subst(). Each active
86             # level uses a distinct capture temp file so that a nested $( ... $( ... ) )
87             # does not truncate/unlink the file the outer level is still capturing into.
88             # (Sequential, non-overlapping substitutions safely reuse the same depth.)
89 15     15   46 use vars qw($_SUBST_DEPTH);
  15         36  
  15         407  
90             $_SUBST_DEPTH = 0;
91              
92             # Active nesting depth of pipeline execution _exec_sh_pipe(). A pipeline
93             # segment may itself contain a $(...) whose body is another pipeline; each
94             # active pipeline must use distinct stage temp files so the inner pipeline
95             # does not clobber/unlink the outer pipeline's stage file (which would leave
96             # the outer's final segment reading from the real STDIN and hanging).
97 15     15   53 use vars qw($_PIPE_DEPTH);
  15         19  
  15         516  
98             $_PIPE_DEPTH = 0;
99              
100             # SH function registry -- must be package-level for access from _expand and _exec_line
101 15     15   75 use vars qw(%_SH_FUNCTIONS);
  15         17  
  15         477  
102              
103             # SH array storage (v0.06). Indexed and associative arrays.
104             # %_SH_ARRAY : NAME (uppercased) => hashref { subscript => value }
105             # %_SH_ARRAY_TYPE : NAME (uppercased) => 'indexed' | 'assoc'
106             # Array names are case-insensitive (stored uppercase) to match the scalar
107             # store. Indexed arrays may be sparse; subscripts are integer strings.
108             # Associative arrays use arbitrary string subscripts. Element order for
109             # ${arr[@]} is ascending numeric index (indexed) or ascending key sort
110             # (assoc) -- the latter is chosen for deterministic output across Perl
111             # versions, since bash leaves associative-array order unspecified.
112 15     15   59 use vars qw(%_SH_ARRAY %_SH_ARRAY_TYPE);
  15         17  
  15         429  
113              
114             # SH trap registry (v0.06). Maps a normalized signal name (e.g. INT, TERM,
115             # EXIT) to the raw command string to run when that signal/event fires. An
116             # empty string means "ignore"; absence means "default". Real signals are
117             # bridged to Perl's %SIG (see _sh_set_os_sig); the EXIT pseudo-signal is run
118             # internally when the script exits (see fire_exit_trap / _cmd_exit).
119 15     15   43 use vars qw(%_SH_TRAP);
  15         17  
  15         357582  
120             # ----------------------------------------------------------------
121             my $LAST_STATUS = 0; # $?
122             my @FUNCTION_STACK = (); # for 'local' variable scoping
123              
124             # Signal: pending exit
125             my $_EXIT_CODE = undef; # undef = no exit pending
126             my $_BREAK = 0; # break out of loop
127             my $_CONTINUE = 0; # continue next iteration
128             my $_RETURN = 0; # return from function/source
129              
130             # Here-document state (Perl 5.005_03 compatible)
131             my $_HD_SEQ = 0; # per-process counter for unique temp names
132             my @_HD_TMPFILES = (); # tempfiles to remove on END (failsafe cleanup)
133              
134             # Background-job state (Perl 5.005_03 compatible)
135             my $_BG_SEQ = 0; # per-process counter for unique pidfile names
136             my @_BG_TMPFILES = (); # pidfiles to remove on END (failsafe cleanup)
137              
138             # ----------------------------------------------------------------
139             # Public: execute an array of SH lines
140             # Returns exit status (0 = success)
141             # ----------------------------------------------------------------
142             sub exec_block {
143 150     150 0 7370 my ($class, $lines_ref, %opts) = @_;
144 150         204 $_EXIT_CODE = undef;
145 150         184 $_BREAK = 0;
146 150         157 $_CONTINUE = 0;
147 150         159 $_RETURN = 0;
148              
149 150         413 my $status = _run_lines($class, $lines_ref, \%opts);
150 150 100       2002 return defined $_EXIT_CODE ? $_EXIT_CODE : $status;
151             }
152              
153             # ----------------------------------------------------------------
154             # Run an array of lines sequentially, handling multi-line blocks
155             # Returns last exit status
156             # ----------------------------------------------------------------
157             sub _run_lines {
158 330     330   538 my ($class, $lines_ref, $opts_ref) = @_;
159 330         390 my @lines = @{$lines_ref};
  330         612  
160 330         373 my $status = 0;
161 330         316 my $i = 0;
162              
163 330         556 while ($i <= $#lines) {
164 480 100       736 return $status if defined $_EXIT_CODE;
165 478 50 33     1300 return $status if $_BREAK || $_RETURN;
166              
167 478         621 my $line = $lines[$i];
168 478         443 $i++;
169             # Normalise
170 478         870 $line =~ s/\r?\n\z//;
171             # Skip empty and comment lines
172 478 100       1320 next if $line =~ /\A\s*\z/;
173 460 50       882 next if $line =~ /\A\s*#/;
174              
175             # Check for block-opening keywords
176 460         521 my $stripped = $line;
177 460         739 $stripped =~ s/\A\s+//;
178 460         602 my $first = '';
179 460         1201 ($first) = ($stripped =~ /\A(\S+)/);
180 460 50       940 $first = lc(defined($first) ? $first : '');
181              
182 460 100       712 if ($first eq 'if') {
183 7         35 ($status, $i) = _parse_if($class, \@lines, $i - 1, $opts_ref);
184 7         14 next;
185             }
186 453 100       691 if ($first eq 'for') {
187 14         73 ($status, $i) = _parse_for($class, \@lines, $i - 1, $opts_ref);
188 14         25 next;
189             }
190 439 100 100     1175 if ($first eq 'while' || $first eq 'until') {
191 6         27 ($status, $i) = _parse_while($class, \@lines, $i - 1, $opts_ref);
192 6         15 next;
193             }
194 433 100       691 if ($first eq 'case') {
195 18         50 ($status, $i) = _parse_case($class, \@lines, $i - 1, $opts_ref);
196 18         36 next;
197             }
198              
199             # Function definition: "name() {" or "function name {"
200 415 100       1353 if ($stripped =~ /\A(?:function\s+[A-Za-z_]|[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\))/) {
201 6         29 ($status, $i) = _parse_function($class, \@lines, $i - 1, $opts_ref);
202 6         12 next;
203             }
204              
205             # Here-document: cmd << [-] [QUOTE]DELIM[QUOTE]
206             # Detected on a simple command line; body is read from following
207             # lines up to a line equal to DELIM (after optional tab strip for <<-).
208 409         723 my @hd = _hd_detect($line);
209 409 100       597 if (@hd) {
210 13         46 my ($cmd_part, $dash, $delim, $quoted) = @hd;
211 13         21 my @body = ();
212 13         16 my $terminated = 0;
213 13         29 while ($i <= $#lines) {
214 29         52 my $bl = $lines[$i];
215 29         33 $i++;
216 29         42 $bl =~ s/\r?\n\z//;
217 29         36 my $probe = $bl;
218 29 100       48 $probe =~ s/\A\t+// if $dash; # <<- strips leading tabs
219 29 100       51 if ($probe eq $delim) { $terminated = 1; last }
  12         14  
  12         42  
220 17 100       38 $bl =~ s/\A\t+// if $dash; # also strip tabs from body
221 17         33 push @body, $bl;
222             }
223 13 100       31 if (!$terminated) {
224 1         28 warn "sh: unexpected EOF while looking for here-document delimiter \`$delim'\n";
225 1         9 $LAST_STATUS = 2;
226 1         7 $status = 2;
227 1         4 next;
228             }
229 12         61 $status = _hd_run($class, $cmd_part, \@body, $quoted, $opts_ref);
230 12         344 next;
231             }
232              
233 396         705 $status = _exec_line($class, $line, $opts_ref);
234 396 50       1405 $_CONTINUE = 0 if $_CONTINUE;
235             }
236 328         977 return $status;
237             }
238              
239             # ----------------------------------------------------------------
240             # Execute one SH line
241             # ----------------------------------------------------------------
242             sub _exec_line {
243 470     470   767 my ($class, $raw, $opts_ref) = @_;
244              
245 470         551 my $line = $raw;
246 470         916 $line =~ s/\A\s+//;
247 470 50       1103 return 0 if $line =~ /\A\s*\z/;
248 470 50       737 return 0 if $line =~ /\A\s*#/;
249              
250             # Shebang: treat as comment
251 470 50       797 return 0 if $line =~ /\A#!/;
252              
253             # ----------------------------------------------------------------
254             # Background execution: an unquoted trailing & (v1).
255             # Detected here, BEFORE _split_sh_compound, so that the bare & is
256             # never mistaken for && and so that an internal & (e.g. in 2>&1 or
257             # >&2) is left untouched. Only the single & at the very end of the
258             # line is consumed. Builtins / functions / control words / variable
259             # assignments run in the FOREGROUND (the & is ignored); only external
260             # commands are launched asynchronously.
261             # ----------------------------------------------------------------
262 470         833 my ($_is_bg, $_bg_line) = _split_trailing_bg($line);
263 470 100       796 if ($_is_bg) {
264 3         4 $line = $_bg_line;
265 3         4 my $probe = $line;
266 3         8 $probe =~ s/\A\s+//;
267 3         6 my $w0 = '';
268 3         9 ($w0) = ($probe =~ /\A(\S+)/);
269 3 50       5 $w0 = '' unless defined $w0;
270 3 100       6 if (_sh_word_is_foreground($w0)) {
271             # & ignored: run the stripped line in the foreground.
272 2         7 return _exec_line($class, $line, $opts_ref);
273             }
274 1         3 my $exp = _expand($class, $line);
275 1         2 $exp =~ s/\A\s+//;
276 1         8 $exp =~ s/\s+\z//;
277 1         4 return _bg_launch($class, $exp);
278             }
279              
280             # Detect && / || / ; compound commands BEFORE expansion.
281             # These must be split before _expand so that short-circuit logic works.
282 467         935 my @compound = _split_sh_compound($line);
283 467 100       739 if (@compound > 1) {
284 13         34 return _exec_sh_compound($class, \@compound, $opts_ref);
285             }
286              
287             # Detect pipeline BEFORE variable expansion to avoid expanding
288             # pipe-like characters inside command substitutions prematurely.
289             # _split_sh_pipe returns >1 segment only when bare | is present.
290 454         783 my @pipe_segs = _split_sh_pipe($line);
291 454 100       750 if (@pipe_segs > 1) {
292 17         72 return _exec_sh_pipe($class, \@pipe_segs, $opts_ref);
293             }
294              
295             # Array / associative-array operations (v0.06). Detected on the RAW line
296             # (before _expand) so that the "(a b c)" literal and the "[sub]" subscripts
297             # are not mangled by variable / command-substitution expansion.
298             {
299 437         978 my @h = _sh_try_array_op($class, $line, $opts_ref);
300 437 100       855 return $h[1] if @h;
301             }
302              
303             # trap (v0.06). Detected on the RAW line so that the handler command is
304             # captured literally and (re-)expanded only when the trap fires, matching
305             # shell semantics for e.g. trap 'rm $tmp' EXIT.
306             {
307 437         460 my $probe = $line;
  399         396  
308 399         622 $probe =~ s/\A\s+//;
309 399 100 66     1030 if ($probe =~ /\Atrap(\s.*|)\z/is && $probe !~ /\Atrap\s*=/) {
310 17         33 return _cmd_trap($class, $1, $opts_ref);
311             }
312             }
313              
314             # POSIX assignment prefix on the RAW line: `VAR=value command args`.
315             # Detected before expansion so that a value containing $(...) or quoted
316             # spaces is not mistaken for a trailing command. Pure assignments (no
317             # command following) fall through to the post-expansion handler below.
318             {
319 399         382 my ($pairs_ref, $remainder) = _sh_assign_prefix($line);
  382         392  
  382         701  
320 382 50 66     1051 if ($pairs_ref && defined $remainder && $remainder ne '') {
      66        
321 0         0 for my $p (@{$pairs_ref}) {
  0         0  
322 0         0 my ($var, $rawval) = @{$p};
  0         0  
323 0         0 my $val = _expand($class, $rawval);
324 0         0 $val =~ s/\A"(.*)"\z/$1/s;
325 0         0 $val =~ s/\A'(.*)'\z/$1/s;
326 0         0 BATsh::Env->set($var, $val);
327             }
328 0         0 return _exec_line($class, $remainder, $opts_ref);
329             }
330             }
331              
332             # Expand variables and command substitutions
333 382         843 $line = _expand($class, $line);
334              
335             # Strip trailing ;
336 382         564 $line =~ s/\s*;\s*\z//;
337              
338             # Detect I/O redirections: >, >>, <, 2>, 2>>, 2>&1
339             # Must be done after expansion so that variable-in-filename works.
340 382         906 my ($clean_line, $sh_redirs_ref) = _sh_strip_redirects($line);
341 382 50       440 if (@{$sh_redirs_ref}) {
  382         619  
342 0         0 return _sh_exec_with_redirs($class, $clean_line, $sh_redirs_ref, $opts_ref);
343             }
344 382         451 $line = $clean_line;
345              
346 382         619 my ($cmd, $rest) = _split_sh($line);
347 382 50 33     1305 return 0 unless defined $cmd && $cmd ne '';
348              
349 382         593 my $lc_cmd = lc($cmd);
350              
351             # Pure assignment: VAR=value (no spaces around =). Assignment prefixes
352             # of the form `VAR=value command` were already handled before expansion,
353             # so anything reaching here is a standalone assignment. Match the whole
354             # (expanded) line so values containing spaces are preserved in full.
355 382 100       896 if ($line =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
356 114         219 my ($var, $val) = ($1, $2); # capture before $1 is clobbered
357             # Strip outermost quotes from value
358 114         195 $val =~ s/\A"(.*)"\z/$1/s;
359 114         137 $val =~ s/\A'(.*)'\z/$1/s;
360 114         466 BATsh::Env->set($var, $val);
361 114         142 $LAST_STATUS = 0;
362 114         369 return 0;
363             }
364              
365 268 100       537 if ($lc_cmd eq 'export') { return _cmd_export($rest) }
  35         76  
366 233 100       335 if ($lc_cmd eq 'unset') { return _cmd_unset($rest) }
  4         9  
367 229 100       345 if ($lc_cmd eq 'echo') {
368             # Apply word-splitting and glob expansion to unquoted tokens
369 150 100       305 if ($rest =~ /[*?\[]/) {
370 7         15 my @words = _parse_args($rest);
371 7         28 $rest = join(' ', @words);
372             }
373 150         349 return _cmd_echo($rest);
374             }
375 79 50       125 if ($lc_cmd eq 'printf') { return _cmd_printf($rest) }
  0         0  
376 79 50       156 if ($lc_cmd eq 'cd') { return _cmd_cd($rest) }
  0         0  
377 79 50       153 if ($lc_cmd eq 'pwd') { print Cwd::cwd(), "\n"; return 0 }
  0         0  
  0         0  
378 79 100       147 if ($lc_cmd eq 'exit') { return _cmd_exit($rest) }
  2         4  
379 77 100       130 if ($lc_cmd eq 'true') { $LAST_STATUS = 0; return 0 }
  2         8  
  2         10  
380 75 100       119 if ($lc_cmd eq 'false') { $LAST_STATUS = 1; return 1 }
  4         5  
  4         14  
381 71 50       125 if ($lc_cmd eq ':') { $LAST_STATUS = 0; return 0 }
  0         0  
  0         0  
382 71 100       148 if ($lc_cmd eq 'read') { return _cmd_read($rest) }
  12         41  
383 59 100 66     259 if ($lc_cmd eq 'test' || $cmd eq '[') { return _cmd_test($rest) }
  26         55  
384 33 50 33     140 if ($lc_cmd eq 'source' || $cmd eq '.') { return _cmd_source($class, $rest, $opts_ref) }
  0         0  
385 33 0       67 if ($lc_cmd eq 'return') { $_RETURN = 1; $LAST_STATUS = ($rest =~ /\A\s*(\d+)/) ? int($1) : 0; return $LAST_STATUS }
  0 50       0  
  0         0  
  0         0  
386 33 50       62 if ($lc_cmd eq 'break') { $_BREAK = 1; return 0 }
  0         0  
  0         0  
387 33 50       73 if ($lc_cmd eq 'continue') { $_CONTINUE = 1; return 0 }
  0         0  
  0         0  
388 33 100       60 if ($lc_cmd eq 'shift') { return _cmd_shift() }
  2         19  
389 31 100       74 if ($lc_cmd eq 'local') { return _cmd_local($rest) }
  2         11  
390 29 50       47 if ($lc_cmd eq 'set') { return _cmd_set_sh($rest) }
  0         0  
391              
392             # Defined SH function
393 29 100       86 if (exists $_SH_FUNCTIONS{$cmd}) {
394 6         15 return _call_sh_function($class, $cmd, $rest, $opts_ref);
395             }
396              
397             # Unknown: try as external (runs via Perl system)
398 23         136 return _cmd_external($cmd, $rest);
399             }
400              
401             # ----------------------------------------------------------------
402             # Variable / arithmetic expansion
403             # ----------------------------------------------------------------
404             sub _expand {
405 537     537   928 my ($class, $str) = @_;
406 537 50       755 return '' unless defined $str;
407              
408             # Protect backslash escapes before any expansion. In POSIX shells the
409             # backslash inside double quotes keeps its special meaning only before
410             # $ ` " \ and newline, so "\$" is a literal dollar (NO expansion), "\`"
411             # is a literal backtick (NO command substitution) and "\\" is a literal
412             # backslash. Earlier releases performed the $-/`-substitutions globally
413             # with no escape awareness, so "\$_" expanded $_ to empty and left the
414             # stray backslash (giving e.g. perl -e "...uc(\)..." -> syntax error).
415             # We stash the escaped specials under NUL-delimited sentinels (a NUL can
416             # never occur in shell source) and restore them as literals at the end.
417             # Order matters: "\\" first so that "\\$" means backslash + expansion.
418 537         706 $str =~ s/\\\\/\x00BATSH_BS\x00/g; # \\ -> literal backslash
419 537         646 $str =~ s/\\\$/\x00BATSH_DL\x00/g; # \$ -> literal dollar (no expand)
420 537         547 $str =~ s/\\`/\x00BATSH_BT\x00/g; # \` -> literal backtick (no subst)
421              
422             # $(( arithmetic ))
423 537         851 $str =~ s/\$\(\(\s*(.*?)\s*\)\)/_eval_arith($1)/ge;
  45         90  
424              
425             # $( command ) substitution
426             # Use _extract_cmd_subst to correctly handle nested () and quoted ) chars.
427 537         1004 $str = _replace_cmd_subst($class, $str);
428              
429             # backtick command substitution: `cmd`
430 537         754 $str =~ s/`([^`]*)`/_cmd_subst($class, $1)/ge;
  1         4  
431              
432             # ---- Array expansions (v0.06) ----------------------------------
433             # These MUST precede the scalar ${#VAR} / ${VAR} rules below so that a
434             # subscripted reference is never mis-parsed as a plain variable.
435              
436             # ${#NAME[@]} / ${#NAME[*]} -- number of set elements
437 537         672 $str =~ s/\$\{#([A-Za-z_][A-Za-z0-9_]*)\[[\@*]\]\}/
438 9         18 _arr_count($1)
439             /ge;
440              
441             # ${#NAME[SUB]} -- length of one element
442 537         655 $str =~ s/\$\{#([A-Za-z_][A-Za-z0-9_]*)\[([^\]]*)\]\}/
443 2         2 do {
444 2         55 my $v = _arr_get_element($1, _arr_expand_sub($class, $2));
445 2 50       9 defined $v ? length($v) : 0
446             }
447             /ge;
448              
449             # ${!NAME[@]} / ${!NAME[*]} -- list of indices / keys
450 537         636 $str =~ s/\$\{!([A-Za-z_][A-Za-z0-9_]*)\[[\@*]\]\}/
451 5         11 join(' ', _arr_ordered_keys($1))
452             /ge;
453              
454             # ${NAME[@]} / ${NAME[*]} -- all elements (space-joined word-split model)
455 537         603 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\[[\@*]\]\}/
456 7         11 join(' ', _arr_values($1))
457             /ge;
458              
459             # ${NAME[SUB]} -- single element
460 537         634 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\[([^\]]*)\]\}/
461 15         16 do {
462 15         21 my $v = _arr_get_element($1, _arr_expand_sub($class, $2));
463 15 50       41 defined $v ? $v : ''
464             }
465             /ge;
466             # ----------------------------------------------------------------
467              
468             # ${#VAR} -- length of value
469 537         599 $str =~ s/\$\{#([A-Za-z_][A-Za-z0-9_]*)\}/
470 1 50       2 do { my $v = BATsh::Env->get($1); defined $v ? length($v) : 0 }
  1         3  
  1         4  
471             /ge;
472              
473             # ${VAR%%pattern} -- remove longest suffix (MUST be before single %)
474 537         623 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)%%([^}]*)\}/
475 1 50       1 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_suffix($v, $2, 1) }
  1         3  
  1         3  
  1         3  
476             /ge;
477              
478             # ${VAR%pattern} -- remove shortest suffix (single %, not %%)
479 537         637 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)%(?!%)([^}]*)\}/
480 1 50       2 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_suffix($v, $2, 0) }
  1         5  
  1         3  
  1         7  
481             /ge;
482              
483             # ${VAR##pattern} -- remove longest prefix (MUST be before single #)
484 537         581 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)##([^}]*)\}/
485 1 50       1 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_prefix($v, $2, 1) }
  1         4  
  1         3  
  1         11  
486             /ge;
487              
488             # ${VAR#pattern} -- remove shortest prefix (single #, not ##)
489 537         587 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)#(?!#)([^}]*)\}/
490 1 50       2 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_prefix($v, $2, 0) }
  1         4  
  1         3  
  1         4  
491             /ge;
492              
493             # ${VAR//pat/rep} -- replace all occurrences (MUST be before single /)
494 537         556 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\/\/([^\/}]*)\/([^}]*)\}/
495 1 50       2 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_replace($v, $2, $3, 1) }
  1         2  
  1         2  
  1         3  
496             /ge;
497              
498             # ${VAR/pat/rep} -- replace first occurrence (single /, not //)
499 537         503 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\/(?!\/)([^\/}]*)\/([^}]*)\}/
500 1 50       1 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_replace($v, $2, $3, 0) }
  1         3  
  1         8  
  1         3  
501             /ge;
502              
503             # ${VAR^^} -- uppercase all
504 537         553 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\^\^\}/
505 1 50       2 do { my $v = BATsh::Env->get($1); defined $v ? uc($v) : '' }
  1         2  
  1         3  
506             /ge;
507              
508             # ${VAR^} -- uppercase first char
509 537         555 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\^\}/
510 0 0       0 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; ucfirst($v) }
  0         0  
  0         0  
  0         0  
511             /ge;
512              
513             # ${VAR,,} -- lowercase all
514 537         590 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*),,\}/
515 1 50       1 do { my $v = BATsh::Env->get($1); defined $v ? lc($v) : '' }
  1         3  
  1         3  
516             /ge;
517              
518             # ${VAR,} -- lowercase first char
519 537         596 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*),\}/
520 0 0       0 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; lcfirst($v) }
  0         0  
  0         0  
  0         0  
521             /ge;
522              
523             # ${VAR:offset:length} and ${VAR:offset}
524 537         656 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):(-?\d+):(\d+)\}/
525 1         6 do {
526 1 50       4 my $v = BATsh::Env->get($1); $v = defined $v ? $v : '';
  1         3  
527 1         2 my $off = int($2); my $len = int($3);
  1         6  
528 1 50       3 $off = length($v) + $off if $off < 0;
529 1 50       20 $off = 0 if $off < 0;
530 1         3 substr($v, $off, $len)
531             }
532             /ge;
533 537         601 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):(-?\d+)\}/
534 0         0 do {
535 0 0       0 my $v = BATsh::Env->get($1); $v = defined $v ? $v : '';
  0         0  
536 0         0 my $off = int($2);
537 0 0       0 $off = length($v) + $off if $off < 0;
538 0 0       0 $off = 0 if $off < 0;
539 0         0 substr($v, $off)
540             }
541             /ge;
542              
543             # ${VAR:-default} ${VAR:=default} ${VAR:+alt}
544 537         595 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):-(.*?)\}/
545 2 50 33     3 do { my $v = BATsh::Env->get($1); (defined $v && $v ne '') ? $v : $2 }
  2         10  
  2         27  
546             /ge;
547 537         551 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):=(.*?)\}/
548 0         0 do {
549 0         0 my $v = BATsh::Env->get($1);
550 0 0 0     0 if (!defined $v || $v eq '') { BATsh::Env->set($1,$2); $v = $2 }
  0         0  
  0         0  
551             $v
552 0         0 }
553             /ge;
554 537         575 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):\+([^}]*)\}/
555 0 0 0     0 do { my $v = BATsh::Env->get($1); (defined $v && $v ne '') ? $2 : '' }
  0         0  
  0         0  
556             /ge;
557              
558             # ${VAR} -- plain expansion (array name yields element 0)
559 537         626 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
560 47         60 do {
561 47         85 my $n = $1;
562 47 50       55 if (_arr_exists($n)) {
563 0 0       0 my $v = _arr_get_element($n, 0); defined $v ? $v : ''
  0         0  
564             }
565             else {
566 47 50       99 my $v = BATsh::Env->get($n); defined $v ? $v : ''
  47         128  
567             }
568             }
569             /ge;
570              
571             # $? last status
572 537         619 $str =~ s/\$\?/$LAST_STATUS/g;
573              
574             # $$ PID
575 537         680 $str =~ s/\$\$/$$/g;
576              
577             # $! PID of the most recent background job (empty before any)
578 537         631 $str =~ s/\$\!/$_LAST_BG_PID/g;
579              
580              
581             # $0 script name
582 537 0       583 $str =~ s/\$0/do { my $v=BATsh::Env->get('%0'); defined $v ? $v : '' }/ge;
  0         0  
  0         0  
  0         0  
583              
584             # $1..$9 positional parameters
585 537         823 $str =~ s/\$([1-9])/
586 7         9 do {
587 7         15 my $n = $1;
588 7         26 my $v = BATsh::Env->get("%$n");
589 7 50 33     36 $v = BATsh::Env->get("BATSH_ARG$n") unless defined $v && $v ne '';
590 7 50       38 defined $v ? $v : ''
591             }
592             /ge;
593              
594             # $@ and $* all positional parameters
595 537 0       581 $str =~ s/\$\@/do { my $v=BATsh::Env->get('%*'); defined $v ? $v : '' }/ge;
  0         0  
  0         0  
  0         0  
596              
597             # $# number of positional parameters
598 537         624 $str =~ s/\$#/
599 0         0 do {
600 0         0 my $c = 0;
601 0         0 for my $nn (1..9) {
602 0         0 my $vv = BATsh::Env->get("%$nn");
603 0 0 0     0 $vv = BATsh::Env->get("BATSH_ARG$nn") unless defined $vv && $vv ne '';
604 0 0 0     0 last unless defined $vv && $vv ne '';
605 0         0 $c = $nn;
606             }
607             $c
608 0         0 }
609             /ge;
610              
611             # $VAR (array name yields element 0)
612 537         880 $str =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/
613 105         109 do {
614 105         193 my $n = $1;
615 105 100       191 if (_arr_exists($n)) {
616 1 50       19 my $v = _arr_get_element($n, 0); defined $v ? $v : ''
  1         4  
617             }
618             else {
619 104 50       333 my $v = BATsh::Env->get($n); defined $v ? $v : ''
  104         335  
620             }
621             }
622             /ge;
623              
624             # Restore the escaped specials as literal characters (reverse order of
625             # protection is not required, the sentinels are disjoint).
626 537         623 $str =~ s/\x00BATSH_DL\x00/\$/g;
627 537         533 $str =~ s/\x00BATSH_BT\x00/`/g;
628 537         572 $str =~ s/\x00BATSH_BS\x00/\\/g;
629              
630 537         959 return $str;
631             }
632              
633             # ----------------------------------------------------------------
634             # Arithmetic evaluator
635             # ----------------------------------------------------------------
636             sub _eval_arith {
637 46     46   101 my ($expr) = @_;
638             # Expand $1..$9 positional params before further processing
639 46         64 $expr =~ s/\$([1-9])/_arith_pos($1)/ge;
  5         7  
640             # Expand $VAR names with numeric values
641 46         52 $expr =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/_arith_var($1)/ge;
  0         0  
642             # Replace bare VAR names with numeric values
643 46         106 $expr =~ s/([A-Za-z_][A-Za-z0-9_]*)/_arith_var($1)/ge;
  42         59  
644             # Safe eval: digits, operators, parens, spaces only
645 46 50       117 if ($expr =~ /\A[\d\s\+\-\*\/\%\(\)]+\z/) {
646 46         2752 my $result = eval $expr;
647 46 50       264 return defined $result ? int($result) : 0;
648             }
649 0         0 return 0;
650             }
651              
652             sub _arith_pos {
653 5     5   8 my ($n) = @_;
654 5         16 my $v = BATsh::Env->get("%$n");
655 5 50 33     18 $v = BATsh::Env->get("BATSH_ARG$n") unless defined $v && $v ne '';
656 5 50 33     26 return (defined $v && $v =~ /\A-?\d+\z/) ? $v : 0;
657             }
658              
659             sub _arith_var {
660 42     42   56 my ($name) = @_;
661 42         150 my $v = BATsh::Env->get($name);
662 42 50 33     264 return (defined $v && $v =~ /\A-?\d+\z/) ? $v : 0;
663             }
664              
665             # ----------------------------------------------------------------
666             # Command substitution $( cmd )
667             # ----------------------------------------------------------------
668             # _replace_cmd_subst: replace all $(...) in $str with their output.
669             # Unlike a simple [^)]* regex, this function tracks nesting depth
670             # and quoted strings so that $(cmd | perl -e "...)" works correctly.
671             # ----------------------------------------------------------------
672             sub _replace_cmd_subst {
673 537     537   768 my ($class, $str) = @_;
674 537 50       762 return '' unless defined $str;
675              
676 537         648 my $result = '';
677 537         1180 my @chars = split //, $str;
678 537         681 my $n = scalar @chars;
679 537         521 my $i = 0;
680              
681 537         769 while ($i < $n) {
682 4729         4310 my $ch = $chars[$i];
683              
684             # $( ... ) -- find matching close paren respecting nesting and quotes
685 4729 100 66     6564 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      100        
686 14         15 $i += 2; # skip $(
687 14         11 my $depth = 1;
688 14         16 my $body = '';
689 14         17 my $in_sq = 0;
690 14         13 my $in_dq = 0;
691              
692 14   33     44 while ($i < $n && $depth > 0) {
693 386         354 my $c = $chars[$i];
694              
695 386 50       454 if ($in_sq) {
696 0 0       0 if ($c eq "'") { $in_sq = 0 }
  0         0  
697 0         0 $body .= $c; $i++; next;
  0         0  
  0         0  
698             }
699 386 50 33     469 if ($c eq "'" && !$in_dq) {
700 0         0 $in_sq = 1; $body .= $c; $i++; next;
  0         0  
  0         0  
  0         0  
701             }
702 386 100 66     506 if ($c eq '"' && !$in_sq) {
703 22         38 $in_dq = !$in_dq; $body .= $c; $i++; next;
  22         22  
  22         19  
  22         38  
704             }
705 364 100       391 if ($in_dq) {
706 88 50       96 if ($c eq '\\') {
707 0         0 $body .= $c; $i++;
  0         0  
708 0 0       0 $body .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
709             }
710 88         83 $body .= $c; $i++; next;
  88         81  
  88         200  
711             }
712 276 50       300 if ($c eq '\\') {
713 0         0 $body .= $c; $i++;
  0         0  
714 0 0       0 $body .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
715             }
716 276 100       296 if ($c eq '(') { $depth++; $body .= $c; $i++; next }
  6         6  
  6         10  
  6         5  
  6         15  
717 270 100       274 if ($c eq ')') {
718 20         19 $depth--;
719 20 100       38 if ($depth == 0) { $i++; last } # closing )
  14         15  
  14         15  
720 6         7 $body .= $c; $i++; next;
  6         6  
  6         9  
721             }
722 250         212 $body .= $c; $i++;
  250         422  
723             }
724              
725 14         32 $result .= _cmd_subst($class, $body);
726 14         123 next;
727             }
728              
729 4715         4027 $result .= $ch; $i++;
  4715         5372  
730             }
731              
732 537         1719 return $result;
733             }
734              
735             # ----------------------------------------------------------------
736             sub _cmd_subst {
737 15     15   31 my ($class, $cmd_str) = @_;
738             # Capture stdout via temporary file (Perl 5.005_03 compatible).
739             # We use _run_lines so that all BATsh::SH builtins, functions,
740             # and pipelines work recursively inside $(...) and `...`.
741             # Tag the capture file with the active nesting depth so a nested
742             # $( ... $( ... ) ) gets a distinct file per level (the inner level
743             # must not truncate/unlink the file the outer level captures into).
744 15         32 local $_SUBST_DEPTH = $_SUBST_DEPTH + 1;
745 15         546 my $tmpfile = File::Spec->catfile(
746             File::Spec->tmpdir(),
747             'batsh_cap_' . $$ . '_' . $_SUBST_DEPTH . '.tmp');
748 15         55 local *_SUBST_SAVOUT;
749 15 50       309 open(_SUBST_SAVOUT, '>&STDOUT') or return '';
750 15         31 local *_SUBST_CAPFH;
751             open(_SUBST_CAPFH, "> $tmpfile")
752 15 50       1818 or do { open(STDOUT, '>&_SUBST_SAVOUT'); return '' };
  0         0  
  0         0  
753             open(STDOUT, '>&_SUBST_CAPFH')
754 15 50       281 or do { close(_SUBST_CAPFH); open(STDOUT, '>&_SUBST_SAVOUT'); return '' };
  0         0  
  0         0  
  0         0  
755 15         45 close(_SUBST_CAPFH);
756 15         28 eval {
757             # Use _run_lines for full recursive BATsh::SH execution.
758             # $cmd_str may contain pipes, builtins, functions, etc.
759 15         50 my @sub_lines = split /\n/, $cmd_str;
760 15         100 _run_lines($class, \@sub_lines, {});
761             };
762 15         692 open(STDOUT, '>&_SUBST_SAVOUT');
763 15         72 close(_SUBST_SAVOUT);
764 15         50 my $output = '';
765 15         166 local *_SUBST_READFH;
766 15 50       520 if (open(_SUBST_READFH, "< $tmpfile")) {
767 15         194 local $/;
768 15         936 $output = <_SUBST_READFH>;
769 15         181 close(_SUBST_READFH);
770             }
771 15         1041 unlink $tmpfile;
772 15 50       55 $output = '' unless defined $output;
773 15         204 $output =~ s/\n+\z//; # strip trailing newlines (like shell)
774 15         221 return $output;
775             }
776              
777             # ----------------------------------------------------------------
778             # export
779             # ----------------------------------------------------------------
780             sub _cmd_export {
781 35     35   47 my ($rest) = @_;
782 35         48 $rest =~ s/\A\s+//;
783             # export -p: print all
784 35 50       57 if ($rest =~ /\A-p\s*\z/) {
785 0         0 for my $k (sort keys %BATsh::Env::STORE) {
786 0         0 my $v = $BATsh::Env::STORE{$k};
787 0         0 $v =~ s/'/'\\''/g;
788 0         0 print "export $k='$v'\n";
789             }
790 0         0 return 0;
791             }
792             # export VAR=value or export VAR
793 35         66 for my $item (split /\s+/, $rest) {
794 35 100       91 if ($item =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
    50          
795 34         119 BATsh::Env->set($1, $2);
796             }
797             elsif ($item =~ /\A([A-Za-z_][A-Za-z0-9_]*)\z/) {
798             # export existing variable (already in store; no-op)
799             }
800             }
801 35         42 $LAST_STATUS = 0;
802 35         114 return 0;
803             }
804              
805             # ----------------------------------------------------------------
806             # unset
807             # ----------------------------------------------------------------
808             sub _cmd_unset {
809 4     4   7 my ($rest) = @_;
810 4         9 for my $var (split /\s+/, $rest) {
811 4         9 $var =~ s/\A\s+//; $var =~ s/\s+\z//;
  4         8  
812 4 50       17 next if $var eq '';
813             # unset NAME[SUB] -- remove a single array element
814 4 100       14 if ($var =~ /\A([A-Za-z_][A-Za-z0-9_]*)\[([^\]]*)\]\z/) {
815 2         5 my ($name, $sub) = ($1, $2);
816 2         5 my $k = _arr_name($name);
817 2 50       4 if (exists $_SH_ARRAY{$k}) {
818 2 50 33     9 if ((defined $_SH_ARRAY_TYPE{$k} && $_SH_ARRAY_TYPE{$k} eq 'assoc')) {
819 0         0 delete $_SH_ARRAY{$k}{$sub};
820             }
821             else {
822 2         6 delete $_SH_ARRAY{$k}{ _arr_index($sub) };
823             }
824             }
825 2         5 next;
826             }
827             # unset NAME -- remove a whole array (and any scalar of the same name)
828 2         6 my $k = _arr_name($var);
829 2 100       5 if (exists $_SH_ARRAY{$k}) {
830 1         3 delete $_SH_ARRAY{$k};
831 1         2 delete $_SH_ARRAY_TYPE{$k};
832             }
833 2         11 BATsh::Env->unset($var);
834             }
835 4         4 $LAST_STATUS = 0;
836 4         13 return 0;
837             }
838              
839             # ----------------------------------------------------------------
840             # echo
841             # ----------------------------------------------------------------
842             sub _cmd_echo {
843 150     150   205 my ($rest) = @_;
844 150         322 $rest =~ s/\A\s+//;
845 150         195 my $no_newline = 0;
846 150 50       239 if ($rest =~ s/\A-n\s*//) { $no_newline = 1 }
  0         0  
847             # -e: enable escape sequences
848 150         167 my $escape = 0;
849 150 50       233 if ($rest =~ s/\A-e\s*//) { $escape = 1 }
  0         0  
850 150 50       234 if ($escape) {
851 0         0 $rest =~ s/\\n/\n/g;
852 0         0 $rest =~ s/\\t/\t/g;
853 0         0 $rest =~ s/\\r/\r/g;
854 0         0 $rest =~ s/\\\\/\\/g;
855             }
856             # Remove shell quoting structurally so that quotes anywhere in the
857             # argument list are dropped (e.g. echo "${arr[@]}" tail), not only when
858             # the whole string is wrapped in one pair of quotes.
859 150         345 $rest = _arr_dequote($rest);
860 150 50       226 if ($no_newline) { print $rest }
  0         0  
861 150         969 else { print "$rest\n" }
862 150         188 $LAST_STATUS = 0;
863 150         616 return 0;
864             }
865              
866             # ----------------------------------------------------------------
867             # printf
868             # ----------------------------------------------------------------
869             sub _cmd_printf {
870 0     0   0 my ($rest) = @_;
871 0         0 $rest =~ s/\A\s+//;
872             # Extract format string (first quoted arg or first word)
873 0         0 my ($fmt, @args);
874 0 0       0 if ($rest =~ s/\A"((?:[^"\\]|\\.)*)"\s*//) {
    0          
875 0         0 $fmt = $1;
876             }
877             elsif ($rest =~ s/\A'([^']*)'\s*//) {
878 0         0 $fmt = $1;
879             }
880             else {
881 0         0 ($fmt, $rest) = split /\s+/, $rest, 2;
882 0 0       0 $rest = '' unless defined $rest;
883             }
884 0         0 @args = split /\s+/, $rest;
885 0         0 $fmt =~ s/\\n/\n/g;
886 0         0 $fmt =~ s/\\t/\t/g;
887 0         0 eval { printf $fmt, @args };
  0         0  
888 0         0 $LAST_STATUS = 0;
889 0         0 return 0;
890             }
891              
892             # ----------------------------------------------------------------
893             # cd
894             # ----------------------------------------------------------------
895             sub _cmd_cd {
896 0     0   0 my ($rest) = @_;
897 0         0 $rest =~ s/\A\s+//;
898 0         0 $rest =~ s/\s+\z//;
899 0 0 0     0 if ($rest eq '' || $rest eq '~') {
900 0   0     0 $rest = $ENV{'HOME'} || BATsh::Env->get('HOME') || '.';
901             }
902 0 0       0 unless (chdir($rest)) {
903 0         0 print STDERR "cd: $rest: No such file or directory\n";
904 0         0 $LAST_STATUS = 1;
905 0         0 return 1;
906             }
907 0         0 BATsh::Env->set('PWD', Cwd::cwd());
908 0         0 $LAST_STATUS = 0;
909 0         0 return 0;
910             }
911              
912             # ----------------------------------------------------------------
913             # exit
914             # ----------------------------------------------------------------
915             sub _cmd_exit {
916 2     2   3 my ($rest) = @_;
917 2         4 $rest =~ s/\A\s+//;
918 2 50       8 my $code = ($rest =~ /\A(\d+)/) ? int($1) : 0;
919             # Run the EXIT trap (once) before exiting, while no exit is yet pending so
920             # the trap body executes. Delete it first to avoid re-entry / double-fire.
921 2 50       4 if (exists $_SH_TRAP{'EXIT'}) {
922 2         4 my $cmd = delete $_SH_TRAP{'EXIT'};
923 2 50 33     19 if (defined $cmd && $cmd ne '') {
924 2         3 eval { _run_lines('BATsh::SH', [$cmd], {}) };
  2         13  
925             }
926             }
927 2         2 $_EXIT_CODE = $code;
928 2         3 $LAST_STATUS = $code;
929 2         5 return $code;
930             }
931              
932             # ----------------------------------------------------------------
933             # trap -- signal / event handling (v0.06)
934             #
935             # Supported forms:
936             # trap 'commands' SIGSPEC... register a handler
937             # trap - SIGSPEC... reset to the default action
938             # trap '' SIGSPEC... ignore the signal
939             # trap / trap -p list the current traps
940             #
941             # SIGSPEC may be a name (with or without a leading SIG), a number, or the
942             # EXIT pseudo-signal (also spelled 0). Real OS signals are bridged to
943             # Perl's %SIG; EXIT is run internally when the script ends or on `exit`.
944             # The handler command is stored unexpanded and (re-)expanded when it fires.
945             # ----------------------------------------------------------------
946             sub _cmd_trap {
947 17     17   56 my ($class, $rest, $opts_ref) = @_;
948 17         32 my ($mode, $cmd, $sigs) = _sh_parse_trap($rest);
949              
950 17 100       37 if ($mode eq 'list') {
951 1 50       2 my @names = @{$sigs} ? @{$sigs} : sort keys %_SH_TRAP;
  1         8  
  0         0  
952 1         2 for my $n (@names) {
953 1         3 my $sig = _sh_normalize_sig($n);
954 1 50       3 next unless exists $_SH_TRAP{$sig};
955 1         6 print "trap -- '" . $_SH_TRAP{$sig} . "' $sig\n";
956             }
957 1         2 $LAST_STATUS = 0;
958 1         4 return 0;
959             }
960              
961 16         16 for my $spec (@{$sigs}) {
  16         39  
962 17         24 my $sig = _sh_normalize_sig($spec);
963 17 50       20 next if $sig eq '';
964 17 100       34 if ($mode eq 'reset') {
    100          
965 2         4 delete $_SH_TRAP{$sig};
966 2         3 _sh_set_os_sig($sig, 'DEFAULT');
967             }
968             elsif ($mode eq 'ignore') {
969 1         3 $_SH_TRAP{$sig} = '';
970 1         3 _sh_set_os_sig($sig, 'IGNORE');
971             }
972             else { # set
973 14         28 $_SH_TRAP{$sig} = $cmd;
974 14         47 _sh_set_os_sig($sig, 'HANDLER');
975             }
976             }
977 16         19 $LAST_STATUS = 0;
978 16         55 return 0;
979             }
980              
981             # Parse the (raw) argument string of a trap command.
982             # Returns ($mode, $cmd, \@sigs) where $mode is 'list', 'reset', 'ignore'
983             # or 'set'. For 'set', $cmd is the (still unexpanded) handler command.
984             sub _sh_parse_trap {
985 17     17   21 my ($s) = @_;
986 17 50       27 $s = '' unless defined $s;
987 17         40 $s =~ s/\A\s+//; $s =~ s/\s+\z//;
  17         28  
988 17 100       32 return ('list', undef, []) if $s eq '';
989              
990 16 50       22 if ($s =~ /\A-p\b\s*(.*)\z/s) {
991 0         0 my @sigs = grep { length } split /\s+/, $1;
  0         0  
992 0         0 return ('list', undef, \@sigs);
993             }
994              
995 16         21 my ($action, $quoted, $rest);
996 16 100       50 if ($s =~ /\A'([^']*)'\s*(.*)\z/s) {
    50          
997 14         38 ($action, $quoted, $rest) = ($1, 1, $2);
998             }
999             elsif ($s =~ /\A"((?:[^"\\]|\\.)*)"\s*(.*)\z/s) {
1000 0         0 ($action, $quoted, $rest) = ($1, 1, $2);
1001             }
1002             else {
1003 2         3 ($action, $rest) = split /\s+/, $s, 2;
1004 2         3 $quoted = 0;
1005 2 50       4 $rest = '' unless defined $rest;
1006             }
1007 16         30 my @sigs = grep { length } split /\s+/, $rest;
  17         39  
1008              
1009 16 100 66     35 return ('reset', undef, \@sigs) if !$quoted && $action eq '-';
1010 14 100 66     39 return ('ignore', '', \@sigs) if $quoted && $action eq '';
1011 13         30 return ('set', $action, \@sigs);
1012             }
1013              
1014             # Normalize a signal spec to a bare name: strip a leading SIG, uppercase,
1015             # and map the common signal numbers to names.
1016             sub _sh_normalize_sig {
1017 18     18   23 my ($s) = @_;
1018 18 50       36 return '' unless defined $s;
1019 18         27 $s =~ s/\A\s+//; $s =~ s/\s+\z//;
  18         23  
1020 18 50       25 return '' if $s eq '';
1021 18         24 $s = uc($s);
1022 18         19 $s =~ s/\ASIG//;
1023 18 100       33 if ($s =~ /\A\d+\z/) {
1024 1         12 my %num = (0 => 'EXIT', 1 => 'HUP', 2 => 'INT', 3 => 'QUIT',
1025             6 => 'ABRT', 9 => 'KILL', 13 => 'PIPE', 14 => 'ALRM',
1026             15 => 'TERM');
1027 1 50       6 $s = exists $num{$s + 0} ? $num{$s + 0} : $s;
1028             }
1029 18         25 return $s;
1030             }
1031              
1032             # Bridge a trap to Perl's %SIG. Pseudo-signals (EXIT/ERR/DEBUG/RETURN) are
1033             # handled internally and never touch %SIG. Assignment is eval-guarded so an
1034             # unsupported signal name (e.g. on Windows) degrades quietly.
1035             sub _sh_set_os_sig {
1036 17     17   25 my ($sig, $what) = @_;
1037 17 50 66     65 return if $sig eq 'EXIT' || $sig eq 'ERR'
      66        
      33        
1038             || $sig eq 'DEBUG' || $sig eq 'RETURN';
1039             # Some signals (e.g. HUP/USR1/USR2) do not exist on every platform --
1040             # notably Windows -- where assigning to %SIG for them emits a harmless
1041             # "No such signal" warning. Suppress just that warning so a portable
1042             # script trapping such a signal stays quiet; all other warnings pass
1043             # through, and the assignment itself still succeeds (best effort).
1044             local $SIG{__WARN__} = sub {
1045 0 0   0   0 my $w = defined $_[0] ? $_[0] : '';
1046 0 0       0 warn $w unless $w =~ /No such signal/;
1047 10         50 };
1048 10         17 eval {
1049 10 100       17 if ($what eq 'DEFAULT') { $SIG{$sig} = 'DEFAULT' }
  1 100       9  
1050 1         9 elsif ($what eq 'IGNORE') { $SIG{$sig} = 'IGNORE' }
1051 8     2   69 else { $SIG{$sig} = sub { _sh_run_trap($sig) } }
  2         43  
1052             };
1053 10         56 return;
1054             }
1055              
1056             # Run the handler command registered for $sig (no-op if unset or 'ignore').
1057             sub _sh_run_trap {
1058 2     2   3 my ($sig) = @_;
1059 2         4 my $cmd = $_SH_TRAP{$sig};
1060 2 50 33     8 return unless defined $cmd && $cmd ne '';
1061 2         2 eval { _run_lines('BATsh::SH', [$cmd], {}) };
  2         6  
1062             }
1063              
1064             # Run the EXIT trap (if any) exactly once, then clear it. Called by the
1065             # top-level run paths in BATsh.pm when the whole script has finished.
1066             sub fire_exit_trap {
1067 166     166 0 438 my ($class) = @_;
1068 166 100       435 return unless exists $_SH_TRAP{'EXIT'};
1069 3         5 my $cmd = delete $_SH_TRAP{'EXIT'};
1070 3 50 33     10 return if !defined $cmd || $cmd eq '';
1071 3         3 $_EXIT_CODE = undef; # let the trap body run to completion
1072 3         4 eval { _run_lines('BATsh::SH', [$cmd], {}) };
  3         6  
1073 3         7 return;
1074             }
1075              
1076             # ----------------------------------------------------------------
1077             # read
1078             # ----------------------------------------------------------------
1079             sub _cmd_read {
1080 12     12   20 my ($rest) = @_;
1081 12 50       31 $rest = '' unless defined $rest;
1082 12         40 $rest =~ s/\A\s+//;
1083 12         23 $rest =~ s/\s+\z//;
1084              
1085             # Drop option flags such as -r (we always read a raw line); only the
1086             # bareword names that follow are treated as target variables.
1087 12 50       32 my @vars = grep { length && !/\A-/ } split /\s+/, $rest;
  12         103  
1088              
1089 12         281 my $line = ;
1090 12 100       56 if (!defined $line) {
1091             # End of input. POSIX read returns non-zero at EOF so that a
1092             # 'while read VAR; do ...; done < file' loop terminates instead
1093             # of spinning forever.
1094 2         15 for my $v (@vars) { BATsh::Env->set($v, '') }
  2         26  
1095 2         8 $LAST_STATUS = 1;
1096 2         14 return 1;
1097             }
1098 10         25 chomp $line;
1099              
1100 10 50       23 if (@vars == 1) {
    0          
1101 10         64 BATsh::Env->set($vars[0], $line);
1102             }
1103             elsif (@vars > 1) {
1104 0         0 my @words = split /\s+/, $line, scalar(@vars);
1105 0         0 for my $i (0 .. $#vars) {
1106 0 0       0 BATsh::Env->set($vars[$i], defined($words[$i]) ? $words[$i] : '');
1107             }
1108             }
1109 10         66 $LAST_STATUS = 0;
1110 10         57 return 0;
1111             }
1112              
1113             # ----------------------------------------------------------------
1114             # shift
1115             # ----------------------------------------------------------------
1116             sub _cmd_shift {
1117 2     2   9 my ($rest) = @_;
1118 2 50       13 $rest = '' unless defined $rest;
1119 2         4 $rest =~ s/\A\s+//;
1120              
1121             # Optional /N offset (bash: shift N shifts N positions)
1122 2         5 my $n_shift = 1;
1123 2 0       5 if ($rest =~ /\A(\d+)\s*\z/) { $n_shift = int($1); $n_shift = 1 if $n_shift < 1 }
  0 50       0  
  0         0  
1124              
1125 2         13 for my $step (1 .. $n_shift) {
1126             # Shift BATSH_ARG* (legacy)
1127 2         3 for my $n (1 .. 8) {
1128 16         34 my $next = BATsh::Env->get('BATSH_ARG' . ($n + 1));
1129 16 50       32 BATsh::Env->set('BATSH_ARG' . $n, defined($next) ? $next : '');
1130             }
1131 2         7 BATsh::Env->set('BATSH_ARG9', '');
1132              
1133             # Shift %1..%9 (used by _expand $1..$9)
1134 2         5 for my $n (1 .. 8) {
1135 16         31 my $next = BATsh::Env->get('%' . ($n + 1));
1136 16 50       62 BATsh::Env->set('%' . $n, defined($next) ? $next : '');
1137             }
1138 2         5 BATsh::Env->set('%9', '');
1139              
1140             # Rebuild %*
1141 2         2 my @args;
1142 2         5 for my $n (1 .. 9) {
1143 6         13 my $v = BATsh::Env->get("%$n");
1144 6 100 66     25 last unless defined $v && $v ne '';
1145 4         10 push @args, $v;
1146             }
1147 2         7 BATsh::Env->set('%*', join(' ', @args));
1148             }
1149 2         7 $LAST_STATUS = 0;
1150 2         8 return 0;
1151             }
1152              
1153             # ----------------------------------------------------------------
1154             # local
1155             # ----------------------------------------------------------------
1156             sub _cmd_local {
1157 2     2   5 my ($rest) = @_;
1158 2         10 $rest =~ s/\A\s+//;
1159              
1160 2         3 my ($var, $val);
1161 2 100       15 if ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
    50          
1162 1         7 ($var, $val) = ($1, $2);
1163             # Strip surrounding quotes from value
1164 1         3 $val =~ s/\A"(.*)"\z/$1/s;
1165 1         6 $val =~ s/\A'(.*)'\z/$1/s;
1166             }
1167             elsif ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
1168 1         10 $var = $1;
1169 1         9 $val = BATsh::Env->get($var);
1170 1 50       3 $val = '' unless defined $val;
1171             }
1172             else {
1173 0         0 $LAST_STATUS = 0;
1174 0         0 return 0;
1175             }
1176              
1177             # Save old value in innermost function scope so it can be restored on return
1178 2 50       5 if (@FUNCTION_STACK) {
1179 2         5 my $frame = $FUNCTION_STACK[-1];
1180             # Only save once per variable per frame (first local declaration wins)
1181 2 50       31 unless (exists $frame->{$var}) {
1182 2         9 my $old = BATsh::Env->get($var);
1183 2 50       8 $frame->{$var} = defined $old ? $old : undef;
1184             }
1185             }
1186 2         11 BATsh::Env->set($var, $val);
1187 2         3 $LAST_STATUS = 0;
1188 2         15 return 0;
1189             }
1190              
1191             # ----------------------------------------------------------------
1192             # set (sh set options -- minimal implementation)
1193             # ----------------------------------------------------------------
1194             sub _cmd_set_sh {
1195 0     0   0 my ($rest) = @_;
1196 0         0 $rest =~ s/\A\s+//;
1197             # set -e, set +e, set -x, set +x: accepted silently
1198 0         0 $LAST_STATUS = 0;
1199 0         0 return 0;
1200             }
1201              
1202             # ----------------------------------------------------------------
1203             # source / .
1204             # ----------------------------------------------------------------
1205             sub _cmd_source {
1206 0     0   0 my ($class, $rest, $opts_ref) = @_;
1207 0         0 $rest =~ s/\A\s+//;
1208 0         0 $rest =~ s/\s+\z//;
1209 0 0       0 if (defined $opts_ref->{'_batsh'}) {
1210 0         0 eval { $opts_ref->{'_batsh'}->source_file($rest) };
  0         0  
1211 0 0       0 if ($@) { print STDERR "source: $rest: $@\n"; return 1 }
  0         0  
  0         0  
1212             }
1213 0         0 return 0;
1214             }
1215              
1216             # ----------------------------------------------------------------
1217             # test / [ ]
1218             # ----------------------------------------------------------------
1219             sub _cmd_test {
1220 26     26   41 my ($rest) = @_;
1221 26         41 $rest =~ s/\A\s+//;
1222 26         100 $rest =~ s/\s*\]\s*\z//; # strip trailing ]
1223 26         40 my $result = _eval_test($rest);
1224 26 100       37 $LAST_STATUS = $result ? 0 : 1;
1225 26         84 return $LAST_STATUS;
1226             }
1227              
1228             sub _eval_test {
1229 26     26   38 my ($expr) = @_;
1230 26         31 $expr =~ s/\A\s+//;
1231 26         40 $expr =~ s/\s+\z//;
1232              
1233             # Compound: -a (AND), -o (OR)
1234 26 50       44 if ($expr =~ /^(.*)\s+-a\s+(.*)$/) {
1235 0   0     0 return _eval_test($1) && _eval_test($2);
1236             }
1237 26 50       35 if ($expr =~ /^(.*)\s+-o\s+(.*)$/) {
1238 0   0     0 return _eval_test($1) || _eval_test($2);
1239             }
1240             # Negation
1241 26 50       37 if ($expr =~ /^!\s+(.*)$/) {
1242 0         0 return !_eval_test($1);
1243             }
1244              
1245             # File tests
1246 26 100       43 if ($expr =~ /\A(-[a-z])\s+(.+)\z/) {
1247 2         5 my ($op, $path) = ($1, $2);
1248 2         5 $path =~ s/\A"//; $path =~ s/"\z//;
  2         4  
1249 2 0       4 if ($op eq '-e') { return -e $path ? 1 : 0 }
  0 50       0  
1250 2 0       4 if ($op eq '-f') { return -f $path ? 1 : 0 }
  0 50       0  
1251 2 0       4 if ($op eq '-d') { return -d $path ? 1 : 0 }
  0 50       0  
1252 2 0       3 if ($op eq '-r') { return -r $path ? 1 : 0 }
  0 50       0  
1253 2 0       4 if ($op eq '-w') { return -w $path ? 1 : 0 }
  0 50       0  
1254 2 0       16 if ($op eq '-x') { return -x $path ? 1 : 0 }
  0 50       0  
1255 2 0       4 if ($op eq '-s') { return (-s $path) ? 1 : 0 }
  0 50       0  
1256 2 50 33     4 if ($op eq '-z') { my $s = -s $path; return (!defined $s || $s == 0) ? 1 : 0 }
  1 100       13  
  1         5  
1257 1 50       2 if ($op eq '-n') { return (length($path) > 0) ? 1 : 0 }
  1 50       4  
1258 0 0       0 if ($op eq '-L') { return -l $path ? 1 : 0 }
  0 0       0  
1259             }
1260              
1261             # String comparisons: = == != < >
1262 24 50       86 if ($expr =~ /\A(.+?)\s+(=|==|!=|<|>)\s+(.+)\z/) {
1263 0         0 my ($a, $op, $b) = ($1, $2, $3);
1264 0         0 $a =~ s/\A"//; $a =~ s/"\z//;
  0         0  
1265 0         0 $b =~ s/\A"//; $b =~ s/"\z//;
  0         0  
1266 0 0 0     0 if ($op eq '=' || $op eq '==') { return ($a eq $b) ? 1 : 0 }
  0 0       0  
1267 0 0       0 if ($op eq '!=') { return ($a ne $b) ? 1 : 0 }
  0 0       0  
1268 0 0       0 if ($op eq '<') { return ($a lt $b) ? 1 : 0 }
  0 0       0  
1269 0 0       0 if ($op eq '>') { return ($a gt $b) ? 1 : 0 }
  0 0       0  
1270             }
1271              
1272             # Integer comparisons: -eq -ne -lt -le -gt -ge
1273 24 50       92 if ($expr =~ /\A(.+?)\s+(-eq|-ne|-lt|-le|-gt|-ge)\s+(.+)\z/) {
1274 24         73 my ($a, $op, $b) = ($1, $2, $3);
1275 24         25 $a =~ s/\A"//; $a =~ s/"\z//;
  24         33  
1276 24         26 $b =~ s/\A"//; $b =~ s/"\z//;
  24         29  
1277 24 50       69 $a = int($a) if $a =~ /\A-?\d+\z/;
1278 24 50       50 $b = int($b) if $b =~ /\A-?\d+\z/;
1279 24 100       37 if ($op eq '-eq') { return ($a == $b) ? 1 : 0 }
  2 100       6  
1280 22 0       37 if ($op eq '-ne') { return ($a != $b) ? 1 : 0 }
  0 50       0  
1281 22 100       32 if ($op eq '-lt') { return ($a < $b) ? 1 : 0 }
  13 100       27  
1282 9 0       16 if ($op eq '-le') { return ($a <= $b) ? 1 : 0 }
  0 50       0  
1283 9 50       16 if ($op eq '-gt') { return ($a > $b) ? 1 : 0 }
  2 100       8  
1284 7 100       17 if ($op eq '-ge') { return ($a >= $b) ? 1 : 0 }
  7 50       20  
1285             }
1286              
1287             # -n string (non-empty)
1288 0 0       0 if ($expr =~ /\A-n\s+(.+)\z/) {
1289 0         0 my $s = $1; $s =~ s/\A"//; $s =~ s/"\z//;
  0         0  
  0         0  
1290 0 0       0 return length($s) > 0 ? 1 : 0;
1291             }
1292             # -z string (empty)
1293 0 0       0 if ($expr =~ /\A-z\s+(.+)\z/) {
1294 0         0 my $s = $1; $s =~ s/\A"//; $s =~ s/"\z//;
  0         0  
  0         0  
1295 0 0       0 return length($s) == 0 ? 1 : 0;
1296             }
1297              
1298             # bare string: true if non-empty
1299 0         0 $expr =~ s/\A"//; $expr =~ s/"\z//;
  0         0  
1300 0 0 0     0 return (length($expr) > 0 && $expr ne '0') ? 1 : 0;
1301             }
1302              
1303             # ----------------------------------------------------------------
1304             # if/then/else/elif/fi parser
1305             # ----------------------------------------------------------------
1306             sub _parse_if {
1307 7     7   13 my ($class, $lines_ref, $start, $opts_ref) = @_;
1308 7         7 my @lines = @{$lines_ref};
  7         27  
1309 7         9 my $i = $start;
1310              
1311             # Collect: if cond; then ... [elif cond; then ...] [else ...] fi
1312             # Build a structure: [ ['cond_lines'], ['body_lines'] ] ...
1313 7         8 my @branches = (); # [ [$cond_lines], [$body_lines] ]
1314 7         9 my $else_body = undef;
1315              
1316             # First line: if cond; then
1317 7         9 my $if_line = $lines[$i]; $i++;
  7         6  
1318 7         9 $if_line =~ s/\r?\n\z//; $if_line =~ s/\A\s+//;
  7         16  
1319              
1320             # Extract condition (after 'if', before 'then' or ';')
1321 7         9 my $cond_str = $if_line;
1322 7         18 $cond_str =~ s/\Aif\s+//i;
1323              
1324             # 1-line form: if COND; then BODY [; BODY ...]; fi
1325             # Detect by presence of "; then " and trailing "; fi" on the same line
1326 7 50       14 if ($cond_str =~ /\A(.+?)\s*;\s*then\s+(.+?)\s*;\s*fi\s*\z/i) {
1327 0         0 my ($cond_part, $body_part) = ($1, $2);
1328 0         0 my $cond_status = _run_lines($class, [$cond_part], $opts_ref);
1329 0 0       0 if ($cond_status == 0) {
1330 0         0 _run_lines($class, [split /\s*;\s*/, $body_part], $opts_ref);
1331             }
1332 0         0 return ($cond_status, $i);
1333             }
1334              
1335 7         33 $cond_str =~ s/\s*;\s*then\s*\z//i;
1336 7         13 $cond_str =~ s/\s+then\s*\z//i;
1337              
1338 7         8 my @cond_lines = ($cond_str);
1339 7         10 my @body_lines = ();
1340 7         8 my $state = 'body'; # reading body of if
1341              
1342 7         14 while ($i <= $#lines) {
1343 16         20 my $l = $lines[$i]; $i++;
  16         16  
1344 16         17 $l =~ s/\r?\n\z//;
1345 16         19 my $ls = $l; $ls =~ s/\A\s+//;
  16         22  
1346 16 50       41 my $lc_first = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
1347              
1348 16 100       43 if ($lc_first eq 'fi') {
    100          
    100          
    50          
1349 4         20 push @branches, [ [@cond_lines], [@body_lines] ];
1350 4         8 last;
1351             }
1352             elsif ($lc_first eq 'elif') {
1353 1         3 push @branches, [ [@cond_lines], [@body_lines] ];
1354 1         3 $cond_str = $ls;
1355 1         15 $cond_str =~ s/\Aelif\s+//i;
1356 1         4 $cond_str =~ s/\s*;\s*then\s*\z//i;
1357 1         2 $cond_str =~ s/\s+then\s*\z//i;
1358 1         2 @cond_lines = ($cond_str);
1359 1         3 @body_lines = ();
1360             }
1361             elsif ($lc_first eq 'else') {
1362 3         9 push @branches, [ [@cond_lines], [@body_lines] ];
1363 3         10 @body_lines = ();
1364             # Read until fi
1365 3         7 while ($i <= $#lines) {
1366 6         9 my $el = $lines[$i]; $i++;
  6         6  
1367 6         8 $el =~ s/\r?\n\z//;
1368 6         5 my $els = $el; $els =~ s/\A\s+//;
  6         19  
1369 6 50       20 if (lc(($els =~ /\A(\S+)/) ? $1 : '') eq 'fi') { last }
  3 100       3  
1370 3         6 push @body_lines, $el;
1371             }
1372 3         5 $else_body = [@body_lines];
1373 3         7 last;
1374             }
1375             elsif ($lc_first eq 'then') {
1376             # 'then' on its own line: continue collecting body
1377 0         0 next;
1378             }
1379             else {
1380 8         15 push @body_lines, $l;
1381             }
1382             }
1383              
1384             # Evaluate branches
1385 7         7 my $status = 0;
1386 7         8 my $executed = 0;
1387 7         9 for my $branch (@branches) {
1388 8         10 my ($cond_ref, $body_ref) = @{$branch};
  8         10  
1389 8         15 my $cond_status = _run_lines($class, $cond_ref, $opts_ref);
1390 8 100       17 if ($cond_status == 0) {
1391 6         9 $status = _run_lines($class, $body_ref, $opts_ref);
1392 6         7 $executed = 1;
1393 6         11 last;
1394             }
1395             }
1396 7 100 66     16 if (!$executed && defined $else_body) {
1397 1         2 $status = _run_lines($class, $else_body, $opts_ref);
1398             }
1399              
1400 7         28 return ($status, $i);
1401             }
1402              
1403             # ----------------------------------------------------------------
1404             # for VAR in list; do ... done
1405             # ----------------------------------------------------------------
1406             sub _parse_for {
1407 14     14   42 my ($class, $lines_ref, $start, $opts_ref) = @_;
1408 14         19 my @lines = @{$lines_ref};
  14         31  
1409 14         16 my $i = $start;
1410              
1411 14         30 my $for_line = $lines[$i]; $i++;
  14         18  
1412 14         25 $for_line =~ s/\r?\n\z//; $for_line =~ s/\A\s+//;
  14         27  
1413              
1414             # for VAR in LIST [; do [BODY [; done]]]
1415             #
1416             # The header may stand alone (do/done on following lines) or carry an
1417             # inline "; do ... ; done" tail all on one physical line. The inline
1418             # form is detected first; ";do" is matched with \b after "do" so that a
1419             # "; done" terminator is never mistaken for the "do" keyword.
1420 14         25 my ($var, $list_str) = ('', '');
1421 14         33 my $inline_body; # defined when the header has an inline "do" tail
1422 14         18 my $inline_closed = 0; # true when that tail also held the "done"
1423 14 50       158 if ($for_line =~ /\Afor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*?)\s*;\s*do\b\s*(.*)\z/is) {
    0          
1424 14         19 my $tail;
1425 14         63 ($var, $list_str, $tail) = ($1, $2, $3);
1426 14         29 $tail =~ s/\s+\z//;
1427 14 100       72 if ($tail eq '') {
    50          
    0          
1428 3         6 $inline_body = undef; # "for ... ; do" -> body follows
1429             }
1430             elsif ($tail =~ /\A(.*);\s*done\b\s*\z/s) {
1431 11         20 $inline_body = $1; $inline_closed = 1; # fully inline; greedy = last done
  11         15  
1432             }
1433             elsif ($tail eq 'done') {
1434 0         0 $inline_body = ''; $inline_closed = 1; # empty inline body
  0         0  
1435             }
1436             else {
1437 0         0 $inline_body = $tail; # "for ... ; do BODY" (done later)
1438             }
1439             }
1440             elsif ($for_line =~ /\Afor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*?)\s*\z/i) {
1441 0         0 ($var, $list_str) = ($1, $2); # header only; do/done on later lines
1442             }
1443              
1444             # Collect body until 'done' (skipped when the tail already closed the loop)
1445 14         18 my @body = ();
1446 14 100       25 if (defined $inline_body) {
1447 11 50       39 push @body, $inline_body unless $inline_body eq '';
1448             }
1449 14 100       28 if (!$inline_closed) {
1450 3         5 my $depth = 1;
1451 3         8 while ($i <= $#lines) {
1452 6         11 my $l = $lines[$i]; $i++;
  6         5  
1453 6         7 $l =~ s/\r?\n\z//;
1454 6         16 my $ls = $l; $ls =~ s/\A\s+//;
  6         10  
1455 6 50       33 my $lc_f = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
1456 6 50 33     54 if ($lc_f eq 'for' || $lc_f eq 'while' || $lc_f eq 'until') { $depth++ }
  0   33     0  
1457 6 50       12 if ($lc_f eq 'done') { $depth--; last if $depth == 0 }
  3 100       4  
  3         11  
1458 3 50 33     18 push @body, $l unless ($lc_f eq 'do' && $depth == 1);
1459             }
1460             }
1461              
1462             # Expand list items. _expand_word_list resolves variables and command
1463             # substitutions, applies filename globbing to unquoted glob words, and
1464             # expands a whole-word ${arr[@]} / ${arr[*]} reference (quoted or not) to
1465             # one item per array element.
1466 14         44 my @items = _expand_word_list($class, $list_str);
1467 14         21 my $status = 0;
1468 14         20 for my $val (@items) {
1469 74         261 BATsh::Env->set($var, $val);
1470 74         86 $_BREAK = 0; $_CONTINUE = 0;
  74         81  
1471 74         152 $status = _run_lines($class, \@body, $opts_ref);
1472 74 50 33     267 last if $_BREAK || defined $_EXIT_CODE;
1473             }
1474 14         22 $_BREAK = 0;
1475              
1476 14         47 return ($status, $i);
1477             }
1478              
1479             # ----------------------------------------------------------------
1480             # while/until condition; do ... done
1481             # ----------------------------------------------------------------
1482             sub _parse_while {
1483 6     6   17 my ($class, $lines_ref, $start, $opts_ref) = @_;
1484 6         8 my @lines = @{$lines_ref};
  6         23  
1485 6         11 my $i = $start;
1486              
1487 6         10 my $while_line = $lines[$i]; $i++;
  6         7  
1488 6         17 $while_line =~ s/\r?\n\z//; $while_line =~ s/\A\s+//;
  6         12  
1489              
1490 6 100       29 my $is_until = ($while_line =~ /\Auntil\s/i) ? 1 : 0;
1491              
1492             # Extract condition, supporting an inline "COND; do BODY; done [REDIR]" form
1493             # as well as the multi-line header. As in _parse_for, ";do" is matched with
1494             # \b after "do" so that a "; done" terminator is never taken for "do".
1495 6         7 my $rest = $while_line;
1496 6         50 $rest =~ s/\A(?:while|until)\s+//i;
1497              
1498 6         7 my $cond_str;
1499 6         8 my @body = ();
1500 6         7 my $done_line = '';
1501 6         6 my $inline_body;
1502 6         7 my $inline_closed = 0;
1503              
1504 6 50       36 if ($rest =~ /\A(.*?)\s*;\s*do\b\s*(.*)\z/s) {
1505 6         5 my $tail;
1506 6         17 ($cond_str, $tail) = ($1, $2);
1507 6         16 $tail =~ s/\s+\z//;
1508 6 100       28 if ($tail eq '') {
    50          
    0          
1509 2         3 $inline_body = undef; # "while COND; do" -> body follows
1510             }
1511             elsif ($tail =~ /\A(.*);\s*done\b\s*(.*)\z/s) {
1512 4         9 $inline_body = $1; # greedy = last "; done"
1513 4         6 $inline_closed = 1;
1514 4         8 my $dr = $2; $dr =~ s/\A\s+//; $dr =~ s/\s+\z//;
  4         17  
  4         5  
1515 4 100       10 $done_line = ($dr ne '') ? "done $dr" : 'done';
1516             }
1517             elsif ($tail =~ /\Adone\b\s*(.*)\z/s) {
1518 0         0 $inline_body = ''; # empty inline body
1519 0         0 $inline_closed = 1;
1520 0         0 my $dr = $1; $dr =~ s/\A\s+//; $dr =~ s/\s+\z//;
  0         0  
  0         0  
1521 0 0       0 $done_line = ($dr ne '') ? "done $dr" : 'done';
1522             }
1523             else {
1524 0         0 $inline_body = $tail; # done on following lines
1525             }
1526             }
1527             else {
1528 0         0 $cond_str = $rest; # header only; do/done on later lines
1529 0         0 $cond_str =~ s/\s*;\s*do\s*\z//i;
1530 0         0 $cond_str =~ s/\s+do\s*\z//i;
1531             }
1532              
1533             # Collect body (skipped when the inline tail already closed the loop)
1534 6 100       13 if (defined $inline_body) {
1535 4 50       13 push @body, $inline_body unless $inline_body eq '';
1536             }
1537 6 100       12 if (!$inline_closed) {
1538 2         2 my $depth = 1;
1539 2         5 while ($i <= $#lines) {
1540 4         5 my $l = $lines[$i]; $i++;
  4         3  
1541 4         6 $l =~ s/\r?\n\z//;
1542 4         4 my $ls = $l; $ls =~ s/\A\s+//;
  4         8  
1543 4 50       9 my $lc_f = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
1544 4 50 33     16 if ($lc_f eq 'for' || $lc_f eq 'while' || $lc_f eq 'until') { $depth++ }
  0   33     0  
1545 4 50       6 if ($lc_f eq 'done') { $depth--; if ($depth == 0) { $done_line = $ls; last } }
  2 100       2  
  2         9  
  2         3  
  2         4  
1546 2 50 33     13 push @body, $l unless ($lc_f eq 'do' && $depth == 1);
1547             }
1548             }
1549              
1550             # Honor an input redirection on the `done' line, e.g.
1551             # while read LINE; do ...; done < FILE
1552             # by reopening STDIN from FILE for the duration of the loop so that the
1553             # loop's `read' built-in consumes the file. Perl 5.005_03 compatible:
1554             # 2-argument open and bareword filehandle duplication.
1555 6         9 my $saved_in = 0;
1556 6 50       9 if ($done_line ne '') {
1557 6         15 my $done_rest = $done_line;
1558 6         19 $done_rest =~ s/\Adone\b\s*//i;
1559 6 100       14 if ($done_rest ne '') {
1560 1         13 my ($dc, $rd) = _sh_strip_redirects(_expand($class, $done_rest));
1561 1         9 my $in_file;
1562 1         7 for my $r (@{$rd}) {
  1         4  
1563 1         2 my ($fd, $append, $file) = @{$r};
  1         5  
1564 1 50       11 $in_file = $file if $fd == 0;
1565             }
1566 1 50 33     13 if (defined $in_file && $in_file ne '') {
1567 1 50       68 if (open(_WH_REDIR_SRC, "< $in_file")) {
1568 1 50       35 if (open(_WH_REDIR_SAVIN, '<&STDIN')) {
1569 1 50       37 if (open(STDIN, '<&_WH_REDIR_SRC')) { $saved_in = 1 }
  1         7  
1570             }
1571 1         16 close(_WH_REDIR_SRC);
1572             }
1573             else {
1574 0         0 warn "sh: $in_file: $!\n";
1575             }
1576             }
1577             }
1578             }
1579              
1580 6         10 my $status = 0;
1581 6         15 my $max_iter = 100_000; # safety guard
1582 6         13 while ($max_iter-- > 0) {
1583 24 50       37 last if defined $_EXIT_CODE;
1584 24         55 my $cond_status = _run_lines($class, [$cond_str], $opts_ref);
1585 24         47 my $cond_true = ($cond_status == 0);
1586 24 100 100     59 last if $is_until && $cond_true;
1587 22 100 100     71 last if !$is_until && !$cond_true;
1588 18         30 $_BREAK = 0; $_CONTINUE = 0;
  18         21  
1589 18         34 $status = _run_lines($class, \@body, $opts_ref);
1590 18 50       51 last if $_BREAK;
1591             }
1592 6         19 $_BREAK = 0;
1593              
1594 6 100       14 if ($saved_in) {
1595 1         49 open(STDIN, '<&_WH_REDIR_SAVIN');
1596 1         7 close(_WH_REDIR_SAVIN);
1597             }
1598              
1599 6         26 return ($status, $i);
1600             }
1601              
1602             # ----------------------------------------------------------------
1603             # case $var in pattern) ... ;; esac
1604             # ----------------------------------------------------------------
1605             sub _parse_case {
1606 18     18   30 my ($class, $lines_ref, $start, $opts_ref) = @_;
1607 18         18 my @lines = @{$lines_ref};
  18         36  
1608 18         36 my $i = $start;
1609              
1610 18         21 my $case_line = $lines[$i]; $i++;
  18         17  
1611 18         24 $case_line =~ s/\r?\n\z//; $case_line =~ s/\A\s+//;
  18         20  
1612              
1613             # Header: case WORD in [inline clauses...]
1614             # The clauses (and even esac) may follow inline on the same physical line
1615             # -- e.g. "case $x in a) echo a ;; *) echo other ;; esac" -- or on the
1616             # following lines. $inline_rest captures anything after "in".
1617 18         17 my $word = '';
1618 18         20 my $inline_rest = '';
1619 18 50       97 if ($case_line =~ /\Acase\s+(.*?)\s+in\b\s*(.*)\z/is) {
1620 18         34 $word = _arr_dequote(_expand($class, $1));
1621 18         28 $inline_rest = $2;
1622             }
1623             else {
1624 0         0 return (0, $i); # malformed header: nothing to do
1625             }
1626              
1627             # Accumulate the clause region (everything between "in" and "esac"),
1628             # stopping as soon as the closing esac is seen. esac is only recognised
1629             # at a clause boundary (start of region, after a ;;/;&/;;& terminator, or
1630             # at the start of a line), so the word "esac" appearing inside a body
1631             # (e.g. echo esac) does not end the construct prematurely.
1632 18         18 my $region = '';
1633 18         20 my $found = 0;
1634             {
1635 18         18 my $pos = _case_top_esac_pos($inline_rest);
  18         24  
1636 18 100       32 if ($pos >= 0) { $region = substr($inline_rest, 0, $pos); $found = 1 }
  1         2  
  1         2  
1637 17         22 else { $region = $inline_rest }
1638             }
1639 18   66     50 while (!$found && $i <= $#lines) {
1640 61         61 my $l = $lines[$i]; $i++;
  61         57  
1641 61         60 $l =~ s/\r?\n\z//;
1642 61         66 my $pos = _case_top_esac_pos($l);
1643 61 100       75 if ($pos >= 0) {
1644 17         24 $region .= "\n" . substr($l, 0, $pos);
1645 17         14 $found = 1;
1646 17         21 last;
1647             }
1648 44         92 $region .= "\n" . $l;
1649             }
1650              
1651             # Split the region into clauses, then evaluate with fall-through support:
1652             # ;; normal: stop after the first matching clause
1653             # ;& fall through: run the NEXT clause's body unconditionally
1654             # ;;& continue: keep testing the remaining clauses against the word
1655 18         25 my @clauses = _case_split_clauses($region);
1656              
1657 18         18 my $status = 0;
1658 18         16 my $stop = 0; # a ;; was reached after a match -- stop entirely
1659 18         18 my $fall = 0; # previous clause ended in ;& -- run this body no matter what
1660 18         22 for my $cl (@clauses) {
1661 40 100       59 last if $stop;
1662 24         19 my ($ctext, $term) = @{$cl};
  24         38  
1663 24 50       68 next unless $ctext =~ /\S/;
1664              
1665 24         29 my ($pattern_str, $body_text) = _case_parse_clause($ctext);
1666 24 50       37 next unless defined $pattern_str; # not a pattern) clause -- skip
1667              
1668 24         20 my $run = 0;
1669 24 100       28 if ($fall) {
1670 2         3 $run = 1;
1671 2         3 $fall = 0;
1672             }
1673             else {
1674 22         33 for my $pat (_case_split_patterns($pattern_str)) {
1675 24         44 $pat =~ s/\A\s+//; $pat =~ s/\s+\z//;
  24         32  
1676 24 50       31 next if $pat eq '';
1677 24 100       32 if (_match_pattern($word, $pat)) { $run = 1; last }
  19         83  
  19         22  
1678             }
1679             }
1680              
1681 24 100       37 if ($run) {
1682 21         34 my @body = split /\n/, $body_text;
1683 21         59 $status = _run_lines($class, \@body, $opts_ref);
1684 21 50 33     92 last if $_BREAK || $_CONTINUE || $_RETURN || defined $_EXIT_CODE;
      33        
      33        
1685 21 100       40 if ($term eq ';;') { $stop = 1 }
  18 100       30  
1686 2         4 elsif ($term eq ';&') { $fall = 1 } # next body unconditionally
1687             # ';;&' : neither stop nor fall -- keep testing later clauses
1688             }
1689             }
1690              
1691 18         64 return ($status, $i);
1692             }
1693              
1694             # Locate a clause-boundary "esac" at the top level of $s (outside quotes).
1695             # Returns its character offset, or -1. An esac is only at a clause boundary
1696             # when the preceding non-blank character is a ';', '&', or newline, or it is
1697             # at the very start -- so "echo esac" inside a body does not match.
1698             sub _case_top_esac_pos {
1699 79     79   89 my ($s) = @_;
1700 79 100 66     236 return -1 unless defined $s && $s ne '';
1701 62         156 my @c = split //, $s;
1702 62         53 my $n = scalar @c;
1703 62         51 my $i = 0; my $sq = 0; my $dq = 0;
  62         55  
  62         55  
1704 62         67 while ($i < $n) {
1705 791         655 my $ch = $c[$i];
1706 791 0       829 if ($sq) { $sq = 0 if $ch eq "'"; $i++; next }
  0 50       0  
  0         0  
  0         0  
1707 791 50 33     887 if ($ch eq "'" && !$dq) { $sq = 1; $i++; next }
  0         0  
  0         0  
  0         0  
1708 791 100       800 if ($ch eq '"') { $dq = !$dq; $i++; next }
  8         11  
  8         10  
  8         11  
1709 783 100 100     1351 if (!$dq && lc(substr($s, $i, 4)) eq 'esac') {
1710 18 50       33 my $after = ($i + 4 < $n) ? substr($s, $i + 4, 1) : '';
1711 18 50 33     53 my $aok = ($i + 4 >= $n || $after =~ /\s/) ? 1 : 0;
1712 18         21 my $j = $i - 1;
1713 18   66     32 $j-- while $j >= 0 && ($c[$j] eq ' ' || $c[$j] eq "\t");
      66        
1714 18 50 33     66 my $bok = ($j < 0 || $c[$j] eq ';' || $c[$j] eq '&'
1715             || $c[$j] eq "\n") ? 1 : 0;
1716 18 50 33     71 return $i if $aok && $bok;
1717             }
1718 765         905 $i++;
1719             }
1720 44         96 return -1;
1721             }
1722              
1723             # Split a case region into clauses on the terminators ;;& / ;; / ;& at the
1724             # top level (outside quotes). Returns a list of [clause_text, terminator].
1725             sub _case_split_clauses {
1726 18     18   25 my ($s) = @_;
1727 18         16 my @out;
1728 18         19 my $cur = '';
1729 18         116 my @c = split //, $s;
1730 18         18 my $n = scalar @c;
1731 18         17 my $i = 0; my $sq = 0; my $dq = 0;
  18         16  
  18         20  
1732 18         23 while ($i < $n) {
1733 791         684 my $ch = $c[$i];
1734 791 0       825 if ($sq) { $cur .= $ch; $sq = 0 if $ch eq "'"; $i++; next }
  0 50       0  
  0         0  
  0         0  
  0         0  
1735 791 50 33     914 if ($ch eq "'" && !$dq) { $sq = 1; $cur .= $ch; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
1736 791 100       783 if ($ch eq '"') { $dq = !$dq; $cur .= $ch; $i++; next }
  8         8  
  8         8  
  8         6  
  8         50  
1737 783 100 66     1260 if (!$sq && !$dq) {
1738 746 100       888 if (substr($s, $i, 3) eq ';;&') {
1739 1         3 push @out, [$cur, ';;&']; $cur = ''; $i += 3; next;
  1         2  
  1         1  
  1         2  
1740             }
1741 745 100       855 if (substr($s, $i, 2) eq ';;') {
1742 39         90 push @out, [$cur, ';;']; $cur = ''; $i += 2; next;
  39         43  
  39         37  
  39         45  
1743             }
1744 706 100       797 if (substr($s, $i, 2) eq ';&') {
1745 2         5 push @out, [$cur, ';&']; $cur = ''; $i += 2; next;
  2         2  
  2         27  
  2         3  
1746             }
1747             }
1748 741         639 $cur .= $ch; $i++;
  741         783  
1749             }
1750 18 50       43 push @out, [$cur, ';;'] if $cur =~ /\S/;
1751 18         75 return @out;
1752             }
1753              
1754             # Parse a clause "pat1|pat2) body" into ($patterns, $body). A leading "("
1755             # is accepted (bash form). The "(" closing the pattern list is the first
1756             # top-level ")" outside quotes and outside a [...] class. Returns
1757             # (undef, undef) when no pattern ")" is present.
1758             sub _case_parse_clause {
1759 24     24   27 my ($s) = @_;
1760 24         64 $s =~ s/\A[\s\n]+//;
1761 24         31 $s =~ s/\A\(//; # optional leading (
1762 24         60 my @c = split //, $s;
1763 24         24 my $n = scalar @c;
1764 24         22 my $i = 0; my $sq = 0; my $dq = 0; my $cls = 0;
  24         18  
  24         22  
  24         20  
1765 24         20 my $found = -1;
1766 24         31 while ($i < $n) {
1767 88         81 my $ch = $c[$i];
1768 88 0       108 if ($sq) { $sq = 0 if $ch eq "'"; $i++; next }
  0 50       0  
  0         0  
  0         0  
1769 88 50 33     108 if ($ch eq "'" && !$dq) { $sq = 1; $i++; next }
  0         0  
  0         0  
  0         0  
1770 88 100       106 if ($ch eq '"') { $dq = !$dq; $i++; next }
  2         3  
  2         3  
  2         3  
1771 86 100 66     172 if (!$sq && !$dq) {
1772 85 100 66     200 if ($ch eq '[') { $cls = 1 }
  3 100       3  
    100          
1773 3         3 elsif ($ch eq ']') { $cls = 0 }
1774 24         22 elsif ($ch eq ')' && !$cls) { $found = $i; last }
  24         28  
1775             }
1776 62         91 $i++;
1777             }
1778 24 50       31 return (undef, undef) if $found < 0;
1779 24         38 my $patterns = substr($s, 0, $found);
1780 24 50       55 my $body = ($found + 1 <= length($s)) ? substr($s, $found + 1) : '';
1781 24         72 return ($patterns, $body);
1782             }
1783              
1784             # Split a pattern list on top-level "|" (outside quotes and [...] classes).
1785             sub _case_split_patterns {
1786 22     22   24 my ($s) = @_;
1787 22         18 my @out;
1788 22         24 my $cur = '';
1789 22         30 my @c = split //, $s;
1790 22         20 my $n = scalar @c;
1791 22         20 my $i = 0; my $sq = 0; my $dq = 0; my $cls = 0;
  22         23  
  22         17  
  22         19  
1792 22         29 while ($i < $n) {
1793 62         63 my $ch = $c[$i];
1794 62 0       76 if ($sq) { $cur .= $ch; $sq = 0 if $ch eq "'"; $i++; next }
  0 50       0  
  0         0  
  0         0  
  0         0  
1795 62 50 33     93 if ($ch eq "'" && !$dq) { $sq = 1; $cur .= $ch; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
1796 62 100       72 if ($ch eq '"') { $dq = !$dq; $cur .= $ch; $i++; next }
  2         3  
  2         2  
  2         2  
  2         3  
1797 60 100 66     112 if (!$sq && !$dq) {
1798 59 100 66     121 if ($ch eq '[') { $cls = 1 }
  3 100       3  
    100          
1799 3         4 elsif ($ch eq ']') { $cls = 0 }
1800 4         7 elsif ($ch eq '|' && !$cls) { push @out, $cur; $cur = ''; $i++; next }
  4         4  
  4         6  
  4         7  
1801             }
1802 56         51 $cur .= $ch; $i++;
  56         67  
1803             }
1804 22         28 push @out, $cur;
1805 22         40 return @out;
1806             }
1807              
1808             # Match a shell-glob case pattern against a word. Supports * ? and
1809             # character classes [abc] [a-z] [!abc]/[^abc], plus quoting and backslash
1810             # escapes (a quoted or escaped metacharacter is matched literally).
1811             sub _match_pattern {
1812 24     24   32 my ($word, $pat) = @_;
1813 24 50       38 $word = '' unless defined $word;
1814 24         32 my $re = _case_glob_to_re($pat);
1815 24 100       454 return ($word =~ /\A$re\z/) ? 1 : 0;
1816             }
1817              
1818             sub _case_glob_to_re {
1819 24     24   28 my ($pat) = @_;
1820 24 50       33 $pat = '' unless defined $pat;
1821 24         27 my $re = '';
1822 24         35 my @c = split //, $pat;
1823 24         19 my $n = scalar @c;
1824 24         25 my $i = 0;
1825 24         30 while ($i < $n) {
1826 41         59 my $ch = $c[$i];
1827 41 50       56 if ($ch eq "'") { # literal single-quoted run
1828 0         0 $i++;
1829 0   0     0 while ($i < $n && $c[$i] ne "'") { $re .= quotemeta($c[$i]); $i++ }
  0         0  
  0         0  
1830 0         0 $i++; next;
  0         0  
1831             }
1832 41 100       49 if ($ch eq '"') { # literal double-quoted run
1833 1         2 $i++;
1834 1   66     6 while ($i < $n && $c[$i] ne '"') {
1835 1 50 33     15 if ($c[$i] eq '\\' && $i + 1 < $n) {
1836 0         0 $i++; $re .= quotemeta($c[$i]); $i++; next;
  0         0  
  0         0  
  0         0  
1837             }
1838 1         2 $re .= quotemeta($c[$i]); $i++;
  1         3  
1839             }
1840 1         2 $i++; next;
  1         2  
1841             }
1842 40 50       44 if ($ch eq '\\') { # escaped literal
1843 0 0       0 $i++; $re .= quotemeta($c[$i]) if $i < $n; $i++; next;
  0         0  
  0         0  
  0         0  
1844             }
1845 40 100       45 if ($ch eq '*') { $re .= '.*'; $i++; next }
  4         6  
  4         3  
  4         6  
1846 36 50       43 if ($ch eq '?') { $re .= '.'; $i++; next }
  0         0  
  0         0  
  0         0  
1847 36 100       38 if ($ch eq '[') { # character class
1848 3         4 my $j = $i + 1;
1849 3         3 my $neg = 0;
1850 3 100 66     12 if ($j < $n && ($c[$j] eq '!' || $c[$j] eq '^')) { $neg = 1; $j++ }
  1   33     2  
  1         1  
1851 3         4 my $body = '';
1852 3 50 33     9 if ($j < $n && $c[$j] eq ']') { $body .= '\\]'; $j++ } # leading ] literal
  0         0  
  0         0  
1853 3   66     8 while ($j < $n && $c[$j] ne ']') {
1854 9         10 my $cc = $c[$j];
1855 9 50 33     13 if ($cc eq '\\' && $j + 1 < $n) {
1856 0         0 $body .= '\\' . $c[$j + 1]; $j += 2; next;
  0         0  
  0         0  
1857             }
1858 9 50 33     28 if ($cc eq '\\' || $cc eq '^' || $cc eq ']') { $body .= '\\' . $cc }
  0   33     0  
1859 9         10 else { $body .= $cc }
1860 9         16 $j++;
1861             }
1862 3 50 33     15 if ($j < $n && $c[$j] eq ']') {
1863 3 100       5 $re .= '[' . ($neg ? '^' : '') . $body . ']';
1864 3         3 $i = $j + 1; next;
  3         7  
1865             }
1866 0         0 $re .= '\\['; $i++; next; # unterminated [ : literal
  0         0  
  0         0  
1867             }
1868 33         41 $re .= quotemeta($ch);
1869 33         36 $i++;
1870             }
1871 24         43 return $re;
1872             }
1873              
1874             # ----------------------------------------------------------------
1875             # External command
1876             # ----------------------------------------------------------------
1877             # ----------------------------------------------------------------
1878             # _split_sh_pipe: split a SH command line on bare | characters,
1879             # respecting single-quoted, double-quoted, and $(...) regions.
1880             # Returns a list of segment strings; length 1 means no pipe found.
1881             # ----------------------------------------------------------------
1882             # _split_sh_compound: split a SH line on bare && / || / ;
1883             # Returns list of { op => '', cmd => '...' } hashrefs.
1884             # Length 1 means no compound operator found.
1885             # Respects single-quotes, double-quotes, and $(...) nesting.
1886             # ----------------------------------------------------------------
1887             # _sh_strip_redirects: parse SH-style redirections from a command line.
1888             #
1889             # Recognized forms (processed right-to-left, last one wins per fd):
1890             # cmd > file stdout overwrite
1891             # cmd >> file stdout append
1892             # cmd < file stdin
1893             # cmd 2> file stderr overwrite
1894             # cmd 2>> file stderr append
1895             # cmd 2>&1 stderr to stdout (recorded as fd=2, file='&1')
1896             # cmd 1>&2 stdout to stderr (recorded as fd=1, file='&2')
1897             #
1898             # Returns ($clean_cmd, \@redirs) where each redir is [fd, append, file].
1899             # Parsing respects single-quotes, double-quotes, and backslash escapes.
1900             # ----------------------------------------------------------------
1901             sub _sh_strip_redirects {
1902 383     383   541 my ($line) = @_;
1903 383         975 my @chars = split //, $line;
1904 383         421 my $n = scalar @chars;
1905 383         417 my @found;
1906 383         421 my $clean = '';
1907 383         374 my $in_sq = 0;
1908 383         402 my $in_dq = 0;
1909 383         408 my $i = 0;
1910              
1911 383         667 while ($i < $n) {
1912 4213         4118 my $ch = $chars[$i];
1913              
1914             # Single-quote passthrough
1915 4213 100       4872 if ($in_sq) {
1916 4 100       12 if ($ch eq "'") { $in_sq = 0 }
  1         3  
1917 4         5 $clean .= $ch; $i++; next;
  4         3  
  4         8  
1918             }
1919 4209 100 66     5253 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $clean .= $ch; $i++; next }
  1         4  
  1         1  
  1         2  
  1         3  
1920              
1921             # Double-quote toggle
1922 4208 100 66     5575 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $clean .= $ch; $i++; next }
  158         196  
  158         285  
  158         145  
  158         236  
1923              
1924             # Inside double-quotes: only escape matters
1925 4050 100       4482 if ($in_dq) {
1926 407 50       507 if ($ch eq '\\') {
1927 0         0 $clean .= $ch; $i++;
  0         0  
1928 0 0       0 $clean .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1929             }
1930 407         392 $clean .= $ch; $i++; next;
  407         354  
  407         506  
1931             }
1932              
1933             # Backslash escape outside quotes
1934 3643 50       4109 if ($ch eq '\\') {
1935 0         0 $clean .= $ch; $i++;
  0         0  
1936 0 0       0 $clean .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1937             }
1938              
1939             # 2>&1 or 2>>&1 or 1>&2
1940 3643 0 100     7059 if ($ch =~ /[012]/ && $i+2 < $n
    0 66        
      33        
      0        
1941             && $chars[$i+1] eq '>'
1942             && ($i+3 < $n ? $chars[$i+2] eq '>' : 0)
1943             && $chars[$i+3] eq '&') {
1944             # 2>>&1 form (rare but handle)
1945 0         0 my $fd = int($ch);
1946 0         0 my $j = $i + 4;
1947 0         0 my $tgt = '';
1948 0   0     0 while ($j < $n && $chars[$j] =~ /\S/) { $tgt .= $chars[$j]; $j++ }
  0         0  
  0         0  
1949 0         0 push @found, [$fd, 0, "&$tgt"];
1950 0         0 $i = $j; next;
  0         0  
1951             }
1952 3643 50 100     5529 if ($ch =~ /[012]/ && $i+2 < $n
      66        
      33        
1953             && $chars[$i+1] eq '>' && $chars[$i+2] eq '&') {
1954 0         0 my $fd = int($ch);
1955 0         0 my $j = $i + 3;
1956 0         0 my $tgt = '';
1957 0   0     0 while ($j < $n && $chars[$j] =~ /\S/) { $tgt .= $chars[$j]; $j++ }
  0         0  
  0         0  
1958 0         0 push @found, [$fd, 0, "&$tgt"];
1959 0         0 $i = $j; next;
  0         0  
1960             }
1961              
1962             # fd> or fd>> (fd is 0,1,2; or implicit 1 when just > or >>)
1963 3643         3221 my $redir_fd = undef;
1964 3643 50 100     8069 if ($ch =~ /[012]/ && $i+1 < $n && $chars[$i+1] eq '>') {
    100 66        
    50          
1965 0         0 $redir_fd = int($ch); $i++;
  0         0  
1966             }
1967             elsif ($ch eq '<') {
1968             # < file (stdin)
1969 1         13 my $j = $i + 1;
1970 1   66     22 $j++ while $j < $n && $chars[$j] eq ' ';
1971 1         13 my $file = '';
1972 1   66     41 while ($j < $n && $chars[$j] !~ /[\s<>]/) { $file .= $chars[$j]; $j++ }
  18         36  
  18         85  
1973 1 50       19 push @found, [0, 0, $file] if $file ne '';
1974 1         6 $i = $j; next;
  1         10  
1975             }
1976             elsif ($ch eq '>') {
1977 0         0 $redir_fd = 1;
1978             }
1979              
1980 3642 50       4206 if (defined $redir_fd) {
1981             # Check for >>
1982 0         0 my $append = 0;
1983 0 0 0     0 if ($i+1 < $n && $chars[$i+1] eq '>') { $append = 1; $i++ }
  0         0  
  0         0  
1984             # Skip spaces
1985 0         0 $i++;
1986 0   0     0 $i++ while $i < $n && $chars[$i] eq ' ';
1987 0         0 my $file = '';
1988             # Read filename (stop at space unless quoted)
1989 0   0     0 while ($i < $n && $chars[$i] !~ /[\s<>]/) {
1990 0         0 $file .= $chars[$i]; $i++;
  0         0  
1991             }
1992 0 0       0 push @found, [$redir_fd, $append, $file] if $file ne '';
1993 0         0 next;
1994             }
1995              
1996 3642         3379 $clean .= $ch; $i++;
  3642         4443  
1997             }
1998              
1999 383         1006 $clean =~ s/\s+\z//;
2000 383         1358 return ($clean, \@found);
2001             }
2002              
2003             # ----------------------------------------------------------------
2004             # _sh_exec_with_redirs: apply I/O redirections then execute a SH line.
2005             # Perl 5.005_03 compatible: fixed bareword FHs, 2-argument open.
2006             # Supports: > >> < 2> 2>> 2>&1 1>&2
2007             # ----------------------------------------------------------------
2008             sub _sh_exec_with_redirs {
2009 12     12   30 my ($class, $line, $redirs_ref, $opts_ref) = @_;
2010              
2011             # Collect per-fd: stdin, stdout, stderr
2012 12         29 my ($in_file, $out_file, $out_app, $err_file, $err_app);
2013 12         18 my $err_to_stdout = 0; # 2>&1
2014 12         17 my $out_to_stderr = 0; # 1>&2
2015              
2016 12         14 for my $r (@{$redirs_ref}) {
  12         48  
2017 12         15 my ($fd, $append, $file) = @{$r};
  12         23  
2018 12 50       37 if ($fd == 0) { $in_file = $file; }
  12 0       29  
2019             elsif ($fd == 1) {
2020 0 0       0 if ($file eq '&2') { $out_to_stderr = 1 }
  0         0  
2021 0         0 else { $out_file = $file; $out_app = $append }
  0         0  
2022             }
2023             else { # fd == 2
2024 0 0       0 if ($file eq '&1') { $err_to_stdout = 1 }
  0         0  
2025 0         0 else { $err_file = $file; $err_app = $append }
  0         0  
2026             }
2027             }
2028              
2029 12         17 my $ok = 1;
2030 12         26 my ($saved_in, $saved_out, $saved_err) = (0, 0, 0);
2031              
2032             # --- stdin ---
2033 12 50 33     89 if (defined $in_file && $ok) {
2034             open(_SH_REDIR_SRC, $in_file)
2035 12 50       366 or do { warn "sh: $in_file: $!\n"; $ok = 0 };
  0         0  
  0         0  
2036 12 50       36 if ($ok) {
2037 12 50       224 open(_SH_REDIR_SAVIN, '<&STDIN') or do { $ok = 0 };
  0         0  
2038             }
2039 12 50       27 if ($ok) {
2040 12 50       270 open(STDIN, '<&_SH_REDIR_SRC') or do { $ok = 0 };
  0         0  
2041 12         36 close(_SH_REDIR_SRC);
2042 12         24 $saved_in = 1;
2043             }
2044             }
2045              
2046             # --- stdout ---
2047 12 50 33     73 if (defined $out_file && $ok) {
    50 33        
2048 0 0       0 my $mode = $out_app ? '>>' : '>';
2049             open(_SH_REDIR_DST, "$mode$out_file")
2050 0 0       0 or do { warn "sh: $out_file: $!\n"; $ok = 0 };
  0         0  
  0         0  
2051 0 0       0 if ($ok) {
2052 0 0       0 open(_SH_REDIR_SAVOUT, '>&STDOUT') or do { $ok = 0 };
  0         0  
2053             }
2054 0 0       0 if ($ok) {
2055 0 0       0 open(STDOUT, '>&_SH_REDIR_DST') or do { $ok = 0 };
  0         0  
2056 0         0 close(_SH_REDIR_DST);
2057 0         0 $saved_out = 1;
2058             }
2059             }
2060             elsif ($out_to_stderr && $ok) {
2061 0 0       0 open(_SH_REDIR_SAVOUT, '>&STDOUT') or do { $ok = 0 };
  0         0  
2062 0 0       0 if ($ok) {
2063 0 0       0 open(STDOUT, '>&STDERR') or do { $ok = 0 };
  0         0  
2064 0         0 $saved_out = 1;
2065             }
2066             }
2067              
2068             # --- stderr ---
2069 12 50 33     61 if (defined $err_file && $ok) {
    50 33        
2070 0 0       0 my $mode = $err_app ? '>>' : '>';
2071             open(_SH_REDIR_DST, "$mode$err_file")
2072 0 0       0 or do { warn "sh: $err_file: $!\n"; $ok = 0 };
  0         0  
  0         0  
2073 0 0       0 if ($ok) {
2074 0 0       0 open(_SH_REDIR_SAVERR, '>&STDERR') or do { $ok = 0 };
  0         0  
2075             }
2076 0 0       0 if ($ok) {
2077 0 0       0 open(STDERR, '>&_SH_REDIR_DST') or do { $ok = 0 };
  0         0  
2078 0         0 close(_SH_REDIR_DST);
2079 0         0 $saved_err = 1;
2080             }
2081             }
2082             elsif ($err_to_stdout && $ok) {
2083             # Redirect stderr to the current STDOUT (which may itself be redirected)
2084 0 0       0 open(_SH_REDIR_SAVERR, '>&STDERR') or do { $ok = 0 };
  0         0  
2085 0 0       0 if ($ok) {
2086 0 0       0 open(STDERR, '>&STDOUT') or do { $ok = 0 };
  0         0  
2087 0         0 $saved_err = 1;
2088             }
2089             }
2090              
2091 12         16 my $rc = 0;
2092 12 50       21 if ($ok) {
2093 12         51 $rc = _exec_line($class, $line, $opts_ref);
2094             }
2095              
2096             # Restore in reverse order
2097 12 50       96 if ($saved_err) { open(STDERR, '>&_SH_REDIR_SAVERR'); close(_SH_REDIR_SAVERR) }
  0         0  
  0         0  
2098 12 50       50 if ($saved_out) { open(STDOUT, '>&_SH_REDIR_SAVOUT'); close(_SH_REDIR_SAVOUT) }
  0         0  
  0         0  
2099 12 50       37 if ($saved_in) { open(STDIN, '<&_SH_REDIR_SAVIN'); close(_SH_REDIR_SAVIN) }
  12         595  
  12         100  
2100              
2101 12         168 return $rc;
2102             }
2103              
2104             # ----------------------------------------------------------------
2105             sub _split_sh_compound {
2106 467     467   605 my ($line) = @_;
2107 467         498 my @parts;
2108 467         579 my $cur = '';
2109 467         463 my $in_sq = 0;
2110 467         508 my $in_dq = 0;
2111 467         420 my $depth = 0; # $( nesting
2112 467         1099 my @chars = split //, $line;
2113 467         492 my $n = scalar @chars;
2114 467         489 my $i = 0;
2115              
2116 467         664 while ($i < $n) {
2117 6911         6525 my $ch = $chars[$i];
2118              
2119             # Single-quote region
2120 6911 100       7848 if ($in_sq) {
2121 150 100       164 if ($ch eq "'") { $in_sq = 0 }
  15         17  
2122 150         126 $cur .= $ch; $i++; next;
  150         125  
  150         170  
2123             }
2124 6761 100 66     8709 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $cur .= $ch; $i++; next }
  15         23  
  15         14  
  15         14  
  15         20  
2125              
2126             # Double-quote toggle
2127 6746 100 66     8906 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $cur .= $ch; $i++; next }
  242         272  
  242         235  
  242         230  
  242         326  
2128              
2129             # $( nesting inside double-quotes
2130 6504 100       7352 if ($in_dq) {
2131 918 50 66     2244 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') { $depth++ }
  0 50 66     0  
      33        
2132 0         0 elsif ($ch eq ')' && $depth > 0) { $depth-- }
2133 918         936 $cur .= $ch; $i++; next;
  918         869  
  918         1090  
2134             }
2135              
2136             # Track $( nesting outside quotes
2137 5586 100 66     7641 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      100        
2138 73         68 $depth++; $cur .= $ch; $i++; next;
  73         69  
  73         64  
  73         124  
2139             }
2140 5513 100 100     7156 if ($ch eq ')' && $depth > 0) {
2141 73         71 $depth--; $cur .= $ch; $i++; next;
  73         68  
  73         64  
  73         94  
2142             }
2143              
2144             # Inside $(...) don't split on operators
2145 5440 100       6058 if ($depth > 0) { $cur .= $ch; $i++; next }
  786         707  
  786         647  
  786         917  
2146              
2147             # Backslash escape
2148 4654 50       5207 if ($ch eq '\\') {
2149 0         0 $cur .= $ch; $i++;
  0         0  
2150 0 0       0 $cur .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
2151             }
2152              
2153             # && operator
2154 4654 50 66     5987 if ($ch eq '&' && $i+1 < $n && $chars[$i+1] eq '&') {
      66        
2155 3         14 push @parts, { op => '', cmd => $cur };
2156 3         9 push @parts, { op => '&&', cmd => '' };
2157 3         3 $cur = ''; $i += 2; next;
  3         3  
  3         5  
2158             }
2159              
2160             # || operator
2161 4651 100 66     6060 if ($ch eq '|' && $i+1 < $n && $chars[$i+1] eq '|') {
      100        
2162 2         8 push @parts, { op => '', cmd => $cur };
2163 2         4 push @parts, { op => '||', cmd => '' };
2164 2         2 $cur = ''; $i += 2; next;
  2         3  
  2         3  
2165             }
2166              
2167             # ; separator (not inside any quote or subst)
2168 4649 100       5279 if ($ch eq ';') {
2169 9         57 push @parts, { op => '', cmd => $cur };
2170 9         75 push @parts, { op => ';', cmd => '' };
2171 9         30 $cur = ''; $i++; next;
  9         14  
  9         16  
2172             }
2173              
2174 4640         4267 $cur .= $ch; $i++;
  4640         5460  
2175             }
2176 467         1893 push @parts, { op => '', cmd => $cur };
2177              
2178             # If only one cmd part with no operators, return single element
2179 467         513 my $has_op = 0;
2180 467 100       626 for my $p (@parts) { $has_op = 1 if $p->{op} ne '' }
  495         1011  
2181 467 100       649 return @parts if $has_op;
2182 454         2231 return ({ op => '', cmd => $line });
2183             }
2184              
2185             # ----------------------------------------------------------------
2186             # _exec_sh_compound: execute && / || / ; compound SH commands
2187             # ----------------------------------------------------------------
2188             sub _exec_sh_compound {
2189 13     13   28 my ($class, $parts, $opts_ref) = @_;
2190 13         22 my $pending_op = '';
2191 13         15 my $rc = 0;
2192              
2193 13         19 for my $part (@{$parts}) {
  13         25  
2194 41         57 my $op = $part->{op};
2195 41         45 my $cmd = $part->{cmd};
2196 41         69 $cmd =~ s/\A\s+//; $cmd =~ s/\s+\z//;
  41         76  
2197              
2198 41 100       65 if ($op eq '') {
2199             # Execute according to pending operator
2200 27 100       80 if ($pending_op eq '') {
    100          
    100          
    50          
2201 13 50       67 $rc = _exec_line($class, $cmd, $opts_ref) if $cmd =~ /\S/;
2202             }
2203             elsif ($pending_op eq '&&') {
2204 3 100 66     11 if ($LAST_STATUS == 0 && $cmd =~ /\S/) {
2205 1         3 $rc = _exec_line($class, $cmd, $opts_ref);
2206             }
2207             }
2208             elsif ($pending_op eq '||') {
2209 2 50 33     8 if ($LAST_STATUS != 0 && $cmd =~ /\S/) {
2210 2         5 $rc = _exec_line($class, $cmd, $opts_ref);
2211             }
2212             }
2213             elsif ($pending_op eq ';') {
2214 9 50       40 $rc = _exec_line($class, $cmd, $opts_ref) if $cmd =~ /\S/;
2215             }
2216 27         60 $pending_op = '';
2217             }
2218             else {
2219 14         19 $pending_op = $op;
2220             }
2221             }
2222 13         160 return $rc;
2223             }
2224              
2225             # ----------------------------------------------------------------
2226             sub _split_sh_pipe {
2227 454     454   562 my ($line) = @_;
2228 454         499 my @segs;
2229 454         519 my $cur = '';
2230 454         469 my $in_sq = 0; # inside single quotes
2231 454         432 my $in_dq = 0; # inside double quotes
2232 454         439 my $depth = 0; # $( nesting depth
2233 454         1173 my @chars = split //, $line;
2234 454         477 my $n = scalar @chars;
2235 454         468 my $i = 0;
2236              
2237 454         661 while ($i < $n) {
2238 6496         6063 my $ch = $chars[$i];
2239              
2240             # Single-quote region: nothing special until closing '
2241 6496 100       7268 if ($in_sq) {
2242 150 100       169 if ($ch eq "'") { $in_sq = 0 }
  15         57  
2243 150         137 $cur .= $ch; $i++; next;
  150         115  
  150         188  
2244             }
2245              
2246             # Toggle double-quote
2247 6346 100 66     8116 if ($ch eq '"' && !$in_sq) {
2248 220         263 $in_dq = !$in_dq;
2249 220         245 $cur .= $ch; $i++; next;
  220         218  
  220         298  
2250             }
2251              
2252             # Inside double-quotes only $( nesting matters
2253 6126 100       6942 if ($in_dq) {
2254 836 50 66     1244 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      66        
2255 0         0 $depth++; $cur .= $ch; $i++; next;
  0         0  
  0         0  
  0         0  
2256             }
2257 836 50 33     1138 if ($ch eq ')' && $depth > 0) {
2258 0         0 $depth--; $cur .= $ch; $i++; next;
  0         0  
  0         0  
  0         0  
2259             }
2260             # backslash escape inside "
2261 836 50       980 if ($ch eq '\\') {
2262 0         0 $cur .= $ch; $i++;
  0         0  
2263 0 0       0 $cur .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
2264             }
2265 836         737 $cur .= $ch; $i++; next;
  836         715  
  836         1010  
2266             }
2267              
2268             # Enter single-quote
2269 5290 100       5969 if ($ch eq "'") { $in_sq = 1; $cur .= $ch; $i++; next }
  15         16  
  15         19  
  15         14  
  15         18  
2270              
2271             # $( command substitution: consume BOTH characters and bump the
2272             # nesting depth exactly ONCE here, so the standalone-'(' handler
2273             # below does not double-count the '(' of a $( and leave depth
2274             # stuck at 1 after the matching ')'. (That stale depth made a
2275             # bare '|' following a nested $(...) fail to split as a pipe.)
2276 5275 100 66     7015 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      100        
2277 66         64 $depth++; $cur .= '$('; $i += 2; next;
  66         101  
  66         64  
  66         95  
2278             }
2279 5209 100       5927 if ($ch eq '(' ) { $depth++ if $depth > 0; $cur .= $ch; $i++; next }
  69 100       87  
  69         64  
  69         61  
  69         103  
2280 5140 100       5687 if ($ch eq ')' ) {
2281 135 100       178 if ($depth > 0) { $depth-- }
  110         113  
2282 135         128 $cur .= $ch; $i++; next;
  135         106  
  135         176  
2283             }
2284              
2285             # Bare | outside any quote/subst => pipeline separator
2286 5005 100 100     6713 if ($ch eq '|' && $depth == 0) {
2287             # Peek: || is logical-or, not a pipe
2288 18 50 33     133 if ($i+1 < $n && $chars[$i+1] eq '|') {
2289 0         0 $cur .= '||'; $i += 2; next;
  0         0  
  0         0  
2290             }
2291 18         52 push @segs, $cur;
2292 18         27 $cur = '';
2293 18         21 $i++; next;
  18         35  
2294             }
2295              
2296             # Backslash escape (outside quotes)
2297 4987 50       5478 if ($ch eq '\\') {
2298 0         0 $cur .= $ch; $i++;
  0         0  
2299 0 0       0 $cur .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
2300             }
2301              
2302 4987         4442 $cur .= $ch; $i++;
  4987         5723  
2303             }
2304 454         767 push @segs, $cur;
2305 454         1266 return @segs;
2306             }
2307              
2308             # ----------------------------------------------------------------
2309             # _exec_sh_pipe: run a SH pipeline via temporary files.
2310             # Each segment's stdout feeds the next segment's stdin.
2311             # Perl 5.005_03 compatible: bareword FHs, 2-arg open.
2312             # ----------------------------------------------------------------
2313             sub _exec_sh_pipe {
2314 17     17   38 my ($class, $segs_ref, $opts_ref) = @_;
2315 17         27 my @segs = @{$segs_ref};
  17         72  
2316 17         29 my $n_segs = scalar @segs;
2317             # Tag the stage files with the active pipeline-nesting depth so a nested
2318             # pipeline (reached when a segment contains a $(...) that is itself a
2319             # pipeline) gets distinct stage files and does not clobber this one.
2320 17         62 local $_PIPE_DEPTH = $_PIPE_DEPTH + 1;
2321 17         652 my $base = File::Spec->catfile(File::Spec->tmpdir(),
2322             'batsh_shp_' . $$ . '_' . $_PIPE_DEPTH);
2323             # Localize the dup/stage filehandle globs so that a nested pipeline
2324             # (a segment whose $(...) body is itself a pipeline) does not overwrite
2325             # this pipeline's saved STDOUT/STDIN handles. Perl 5.005_03 compatible.
2326 17         120 local (*_SH_PIPE_RFH, *_SH_PIPE_WFH, *_SH_PIPE_SAVIN, *_SH_PIPE_SAVOUT);
2327 17         25 my $rc = 0;
2328 17         37 my $input_f = undef; # tmpfile that feeds this segment's STDIN
2329              
2330 17         65 for my $idx (0 .. $n_segs - 1) {
2331 35         67 my $seg = $segs[$idx];
2332 35         216 $seg =~ s/\A\s+//; $seg =~ s/\s+\z//;
  35         157  
2333 35 50       124 next unless $seg =~ /\S/;
2334              
2335 35 100       105 my $is_last = ($idx == $n_segs - 1) ? 1 : 0;
2336 35 100       129 my $output_f = $is_last ? undef : "${base}_${idx}.tmp";
2337              
2338             # --- redirect STDIN from previous segment's output ---
2339 35         49 my $saved_in = 0;
2340 35 100 66     536 if (defined $input_f && -f $input_f) {
2341             open(_SH_PIPE_RFH, $input_f)
2342 18 50       526 or do { warn "SH pipe: open $input_f: $!\n"; last };
  0         0  
  0         0  
2343             open(_SH_PIPE_SAVIN, '<&STDIN')
2344 18 50       298 or do { close(_SH_PIPE_RFH); last };
  0         0  
  0         0  
2345             open(STDIN, '<&_SH_PIPE_RFH')
2346 18 50       302 or do {
2347 0         0 close(_SH_PIPE_RFH);
2348 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
2349 0         0 last;
2350             };
2351 18         57 close(_SH_PIPE_RFH);
2352 18         42 $saved_in = 1;
2353             }
2354              
2355             # --- redirect STDOUT to next segment's input file ---
2356 35         67 my $saved_out = 0;
2357 35 100       76 if (defined $output_f) {
2358             open(_SH_PIPE_WFH, ">$output_f")
2359 18 50       4439 or do {
2360 0 0       0 if ($saved_in) {
2361 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
2362             }
2363 0         0 warn "SH pipe: open $output_f: $!\n";
2364 0         0 last;
2365             };
2366             open(_SH_PIPE_SAVOUT, '>&STDOUT')
2367 18 50       308 or do {
2368 0         0 close(_SH_PIPE_WFH);
2369 0 0       0 if ($saved_in) {
2370 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
2371             }
2372 0         0 last;
2373             };
2374             open(STDOUT, '>&_SH_PIPE_WFH')
2375 18 50       340 or do {
2376 0         0 close(_SH_PIPE_WFH);
2377 0         0 open(STDOUT, '>&_SH_PIPE_SAVOUT'); close(_SH_PIPE_SAVOUT);
  0         0  
2378 0 0       0 if ($saved_in) {
2379 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
2380             }
2381 0         0 last;
2382             };
2383 18         66 close(_SH_PIPE_WFH);
2384 18         38 $saved_out = 1;
2385             }
2386              
2387             # --- execute the segment as a SH line ---
2388 35         168 $rc = _exec_line($class, $seg, $opts_ref);
2389              
2390             # --- restore STDOUT ---
2391 35 100       226 if ($saved_out) {
2392 18         1291 open(STDOUT, '>&_SH_PIPE_SAVOUT');
2393 18         93 close(_SH_PIPE_SAVOUT);
2394             }
2395              
2396             # --- restore STDIN and remove input tmpfile ---
2397 35 100       164 if ($saved_in) {
2398 18         1404 open(STDIN, '<&_SH_PIPE_SAVIN');
2399 18         219 close(_SH_PIPE_SAVIN);
2400 18         2831 unlink $input_f;
2401             }
2402              
2403 35         446 $input_f = $output_f;
2404             }
2405              
2406 17 50 33     199 unlink $input_f if defined $input_f && -f $input_f;
2407 17         2641 return $rc;
2408             }
2409              
2410             # ----------------------------------------------------------------
2411             # Pattern helpers for ${var%pat}, ${var#pat}, ${var/pat/rep}
2412             # Converts glob-style pattern to Perl regex (*, ?, [abc]).
2413             # ----------------------------------------------------------------
2414             # ----------------------------------------------------------------
2415             # _glob_expand: expand a single word that contains unquoted glob
2416             # metacharacters (* ? [...]) into a sorted list of matching pathnames.
2417             # Returns the original word unchanged if no matches are found (POSIX
2418             # "nullglob off" behaviour, which is the shell default).
2419             # Only words that were NOT wrapped in quotes are eligible; the caller
2420             # is responsible for passing only unquoted words.
2421             # ----------------------------------------------------------------
2422             sub _glob_expand {
2423 5     5   16 my ($word) = @_;
2424             # Fast path: no metacharacters
2425 5 50       33 return ($word) unless $word =~ /[*?\[]/;
2426 5         1150 my @matches = glob($word);
2427 5 100       89 return @matches ? @matches : ($word);
2428             }
2429              
2430             # ----------------------------------------------------------------
2431             # _glob_expand_args: apply filename globbing to each unquoted word in
2432             # an already-split argument list. Words that were originally quoted
2433             # (single or double) must already have their quotes stripped by the
2434             # caller; we cannot distinguish them at this stage, so we re-check
2435             # for glob metacharacters and call _glob_expand only when present.
2436             # ----------------------------------------------------------------
2437             sub _glob_expand_args {
2438 0     0   0 my (@words) = @_;
2439 0         0 my @result;
2440 0         0 for my $w (@words) {
2441 0         0 push @result, _glob_expand($w);
2442             }
2443 0         0 return @result;
2444             }
2445              
2446             sub _glob_to_re {
2447 6     6   14 my ($pat, $greedy) = @_;
2448 6         6 my $re = '';
2449 6         8 my @chars = split //, $pat;
2450 6         6 my $n = scalar @chars;
2451 6         7 my $i = 0;
2452 6         8 while ($i < $n) {
2453 12         13 my $c = $chars[$i];
2454 12 100       22 if ($c eq '*') {
    50          
    50          
2455 4 100       6 $re .= $greedy ? '.*' : '.*?';
2456             }
2457 0         0 elsif ($c eq '?') { $re .= '.' }
2458             elsif ($c eq '[') {
2459 0         0 my $cls = '[';
2460 0         0 $i++;
2461 0   0     0 while ($i < $n && $chars[$i] ne ']') {
2462 0         0 $cls .= $chars[$i]; $i++;
  0         0  
2463             }
2464 0         0 $cls .= ']';
2465 0         0 $re .= $cls;
2466             }
2467 8         10 else { $re .= quotemeta($c) }
2468 12         14 $i++;
2469             }
2470 6         11 return $re;
2471             }
2472              
2473             sub _sh_remove_suffix {
2474 2     2   3 my ($val, $pat, $greedy) = @_;
2475             # % (greedy=0, shortest suffix): keep longest prefix
2476             # => /\A(.*) PATTERN \z/s with greedy prefix => $1
2477             # %% (greedy=1, longest suffix): keep shortest prefix
2478             # => /\A(.*?)PATTERN \z/s with lazy prefix => $1
2479 2         5 my $re = _glob_to_re($pat, 1); # pattern itself is always greedy for suffix
2480 2 100       11 if ($greedy) {
2481             # longest suffix removed: lazy prefix
2482 1 50       25 return ($val =~ /\A(.*?)$re\z/s) ? $1 : $val;
2483             }
2484             else {
2485             # shortest suffix removed: greedy prefix
2486 1 50       39 return ($val =~ /\A(.*)$re\z/s) ? $1 : $val;
2487             }
2488             }
2489              
2490             sub _sh_remove_prefix {
2491 2     2   4 my ($val, $pat, $greedy) = @_;
2492             # # (greedy=0, shortest prefix): keep longest suffix
2493             # => /\A PATTERN(.*) \z/s with lazy pattern => $1
2494             # ## (greedy=1, longest prefix): keep shortest suffix
2495             # => /\A PATTERN(.*) \z/s with greedy pattern => $1
2496 2         3 my $re = _glob_to_re($pat, $greedy);
2497 2 50       47 return ($val =~ /\A$re(.*)\z/s) ? $1 : $val;
2498             }
2499              
2500             sub _sh_replace {
2501 2     2   10 my ($val, $pat, $rep, $global) = @_;
2502 2         4 my $re = _glob_to_re($pat, 1);
2503 2 100       5 if ($global) { $val =~ s/$re/$rep/g }
  1         12  
2504 1         16 else { $val =~ s/$re/$rep/ }
2505 2         6 return $val;
2506             }
2507              
2508             # ----------------------------------------------------------------
2509             # Shell function registry { name => \@body_lines }
2510             # ----------------------------------------------------------------
2511              
2512             # ----------------------------------------------------------------
2513             # _parse_function: parse "name() {" or "function name {" blocks
2514             # Returns ($status, $new_i).
2515             # ----------------------------------------------------------------
2516             sub _parse_function {
2517 6     6   10 my ($class, $lines_ref, $start, $opts_ref) = @_;
2518 6         9 my @lines = @{$lines_ref};
  6         16  
2519 6         11 my $line = $lines[$start];
2520 6         14 $line =~ s/\r?\n\z//;
2521 6         11 $line =~ s/\A\s+//;
2522              
2523 6         8 my $name = '';
2524 6 50       27 if ($line =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)\s*(?:\{.*)?\z/) {
    0          
2525 6         13 $name = $1;
2526             }
2527             elsif ($line =~ /\Afunction\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(\s*\))?\s*(?:\{.*)?\z/i) {
2528 0         0 $name = $1;
2529             }
2530             else {
2531 0         0 return (0, $start + 1);
2532             }
2533              
2534 6         7 my @body;
2535 6 50       21 my $depth = ($line =~ /\{/) ? 1 : 0;
2536 6         12 my $i = $start + 1;
2537              
2538             # Check if the function body is on the same line as the definition
2539             # e.g. "name() { cmd1; cmd2; }"
2540 6 100 66     31 if ($depth >= 1 && $line =~ /\{(.*)\}\s*\z/s) {
2541 1         2 my $inline = $1;
2542 1         3 $inline =~ s/\A\s+//; $inline =~ s/\s+\z//;
  1         4  
2543             # Split on ; to get individual commands
2544 1         3 for my $part (split /;/, $inline) {
2545 1         3 $part =~ s/\A\s+//; $part =~ s/\s+\z//;
  1         3  
2546 1 50       5 push @body, $part if $part =~ /\S/;
2547             }
2548 1         3 $_SH_FUNCTIONS{$name} = [ @body ];
2549 1         3 return (0, $i);
2550             }
2551              
2552 5 50       29 if ($depth == 0) {
2553 0         0 while ($i <= $#lines) {
2554 0         0 my $l = $lines[$i]; $l =~ s/\r?\n\z//; $l =~ s/\A\s+//;
  0         0  
  0         0  
2555 0         0 $i++;
2556 0 0       0 if ($l =~ /\{/) { $depth = 1; last }
  0         0  
  0         0  
2557             }
2558             }
2559              
2560 5         12 while ($i <= $#lines) {
2561 13         18 my $l = $lines[$i]; $l =~ s/\r?\n\z//;
  13         40  
2562 13         17 $i++;
2563 13         18 my $opens = () = ($l =~ /\{/g);
2564 13         24 my $closes = () = ($l =~ /\}/g);
2565 13         17 $depth += $opens - $closes;
2566 13 100       18 if ($depth <= 0) {
2567 5         6 my $before = $l;
2568 5         14 $before =~ s/\}\s*\z//;
2569 5 50       10 push @body, $before if $before =~ /\S/;
2570 5         7 last;
2571             }
2572 8         15 push @body, $l;
2573             }
2574              
2575 5         23 $_SH_FUNCTIONS{$name} = [ @body ];
2576 5         15 return (0, $i);
2577             }
2578              
2579             # ----------------------------------------------------------------
2580             # _call_sh_function: execute a registered SH function
2581             # ----------------------------------------------------------------
2582             sub _call_sh_function {
2583 6     6   11 my ($class, $name, $args_str, $opts_ref) = @_;
2584 6 50       14 return 1 unless exists $_SH_FUNCTIONS{$name};
2585              
2586 6         48 my @args = _parse_args($args_str);
2587              
2588 6         14 my @saved_arg;
2589 6         16 for my $n (1 .. 9) {
2590 54         101 push @saved_arg, BATsh::Env->get("BATSH_ARG$n");
2591 54 100       99 BATsh::Env->set("BATSH_ARG$n",
2592             defined($args[$n-1]) ? $args[$n-1] : '');
2593             }
2594 6         7 my @saved_pct;
2595 6         8 for my $n (1 .. 9) {
2596 54         75 push @saved_pct, BATsh::Env->get("%$n");
2597 54 100       90 BATsh::Env->set("%$n", defined($args[$n-1]) ? $args[$n-1] : '');
2598             }
2599 6         9 my $saved_star = BATsh::Env->get('%*');
2600 6         20 BATsh::Env->set('%*', join(' ', @args));
2601              
2602 6         12 push @FUNCTION_STACK, {};
2603 6         7 my $saved_ret = $_RETURN;
2604 6         8 $_RETURN = 0;
2605              
2606 6         23 my $rc = _run_lines($class, $_SH_FUNCTIONS{$name}, $opts_ref);
2607              
2608 6         7 $_RETURN = $saved_ret;
2609              
2610             # Restore local variables saved in this function's scope
2611 6 50       9 if (@FUNCTION_STACK) {
2612 6         9 my $frame = $FUNCTION_STACK[-1];
2613 6         5 for my $var (keys %{$frame}) {
  6         20  
2614 2         4 my $old = $frame->{$var};
2615 2 50       5 if (defined $old) { BATsh::Env->set($var, $old) }
  2         10  
2616 0         0 else { BATsh::Env->unset($var) }
2617             }
2618             }
2619 6         9 pop @FUNCTION_STACK;
2620              
2621 6         14 for my $n (1 .. 9) {
2622 54         52 my $v = $saved_arg[$n-1];
2623 54 50       88 BATsh::Env->set("BATSH_ARG$n", defined $v ? $v : '');
2624             }
2625 6         15 for my $n (1 .. 9) {
2626 54         47 my $v = $saved_pct[$n-1];
2627 54 100       88 BATsh::Env->set("%$n", defined $v ? $v : '');
2628             }
2629 6 50       15 BATsh::Env->set('%*', defined $saved_star ? $saved_star : '');
2630              
2631 6         6 $LAST_STATUS = $rc;
2632 6         23 return $rc;
2633             }
2634              
2635             # ----------------------------------------------------------------
2636             # _parse_args: split a string into arguments respecting quotes
2637             # ----------------------------------------------------------------
2638             sub _parse_args {
2639 13     13   21 my ($str) = @_;
2640 13 50       18 $str = '' unless defined $str;
2641 13         28 $str =~ s/\A\s+//; $str =~ s/\s+\z//;
  13         21  
2642 13 100       34 return () unless $str =~ /\S/;
2643 11         13 my @args;
2644             my @quoted; # parallel array: 1 if word was quoted, 0 if bare
2645 11         15 my $cur = '';
2646 11         12 my $word_quoted = 0;
2647 11         18 my $in_sq = 0;
2648 11         12 my $in_dq = 0;
2649 11         34 for my $ch (split //, $str) {
2650 89 100       109 if ($in_sq) {
2651 4 100       5 if ($ch eq "'") { $in_sq = 0 } else { $cur .= $ch; $word_quoted = 1 }
  1         1  
  3         4  
  3         3  
2652 4         4 next;
2653             }
2654 85 100 66     117 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $word_quoted = 1; next }
  1         3  
  1         2  
  1         1  
2655 84 100 66     112 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $word_quoted = 1; next }
  6         6  
  6         6  
  6         6  
2656 78 100 66     132 if ($ch =~ /\s/ && !$in_sq && !$in_dq) {
      100        
2657 1         3 push @args, $cur;
2658 1         1 push @quoted, $word_quoted;
2659 1         2 $cur = ''; $word_quoted = 0;
  1         1  
2660 1         2 next;
2661             }
2662 77         82 $cur .= $ch;
2663             }
2664 11 50 33     39 push @args, $cur if $cur ne '' || @args;
2665 11 50 33     25 push @quoted, $word_quoted if $cur ne '' || @quoted;
2666             # Apply filename globbing to unquoted words containing metacharacters
2667 11         11 my @result;
2668 11         30 for my $i (0 .. $#args) {
2669 12 100 100     56 if (!$quoted[$i] && $args[$i] =~ /[*?\[]/) {
2670 3         13 push @result, _glob_expand($args[$i]);
2671             }
2672             else {
2673 9         14 push @result, $args[$i];
2674             }
2675             }
2676 11         35 return @result;
2677             }
2678              
2679             # ----------------------------------------------------------------
2680             # ----------------------------------------------------------------
2681             sub _cmd_external {
2682 23     23   80 my ($cmd, $rest) = @_;
2683 23 50       59 $rest = '' unless defined $rest;
2684 23         84 $rest =~ s/\A\s+//;
2685 23 50       101 my $full = $rest ne '' ? "$cmd $rest" : $cmd;
2686 23         453 BATsh::Env->sync_to_env();
2687 23         10021523 my $rc = system($full);
2688 23 50 0     1069 $LAST_STATUS = ($rc == 0) ? 0 : (($rc >> 8) || 1);
2689 23         2576 return $LAST_STATUS;
2690             }
2691              
2692             # ----------------------------------------------------------------
2693             # Background execution helpers (v1)
2694             # ----------------------------------------------------------------
2695             # _split_trailing_bg: detect an unquoted single & at the very end of a
2696             # line. Returns (1, $line_without_amp) when present, else (0, $line).
2697             # Rules:
2698             # * only the last non-space character may be the background &
2699             # * it must not be part of && (i.e. preceding char must not be &)
2700             # * it must not be an fd-duplication >& (preceding char must not be >)
2701             # * it must be outside single/double quotes
2702             # * the remaining command must be non-empty
2703             sub _split_trailing_bg {
2704 477     477   1119 my ($line) = @_;
2705 477 50       748 return (0, $line) unless defined $line;
2706              
2707             # Find the index of the last character, ignoring trailing whitespace.
2708 477         527 my $rtrim = $line;
2709 477         1183 $rtrim =~ s/\s+\z//;
2710 477 50       721 return (0, $line) if $rtrim eq '';
2711              
2712 477         1554 my @chars = split //, $rtrim;
2713 477         622 my $last = $#chars;
2714 477 100       1782 return (0, $line) unless $chars[$last] eq '&';
2715              
2716             # && is a compound operator, not background.
2717 8 50 66     40 return (0, $line) if $last >= 1 && $chars[$last-1] eq '&';
2718             # >& is fd duplication, not background.
2719 8 50 66     51 return (0, $line) if $last >= 1 && $chars[$last-1] eq '>';
2720              
2721             # Verify the trailing & is outside quotes and not backslash-escaped by
2722             # scanning up to it. $esc_last becomes true if the char at $last is
2723             # escaped by an immediately preceding (unquoted) backslash.
2724 8         13 my $in_sq = 0;
2725 8         6 my $in_dq = 0;
2726 8         9 my $esc_last = 0;
2727 8         7 my $i = 0;
2728 8         17 while ($i < $last) {
2729 87         86 my $ch = $chars[$i];
2730 87 50       87 if ($in_sq) {
2731 0 0       0 $in_sq = 0 if $ch eq "'";
2732 0         0 $i++; next;
  0         0  
2733             }
2734 87 100 66     104 if ($ch eq '\\' && !$in_sq) { # backslash escapes next char
2735 1 50       11 $esc_last = 1 if $i + 1 == $last;
2736 1         6 $i += 2; next;
  1         4  
2737             }
2738 86 50 33     100 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $i++; next }
  0         0  
  0         0  
  0         0  
2739 86 100 66     108 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $i++; next }
  2         3  
  2         1  
  2         4  
2740 84         94 $i++;
2741             }
2742 8 50 33     25 return (0, $line) if $in_sq || $in_dq; # & is inside a quote
2743 8 100       18 return (0, $line) if $esc_last; # & is backslash-escaped
2744              
2745             # Strip the trailing & (and surrounding whitespace before it).
2746 7         31 my $stripped = join('', @chars[0 .. $last-1]);
2747 7         24 $stripped =~ s/\s+\z//;
2748 7 100       14 return (0, $line) if $stripped eq ''; # nothing to run
2749              
2750 6         21 return (1, $stripped);
2751             }
2752              
2753             # _sh_word_is_foreground: true when the first word of a backgrounded line
2754             # is a BATsh builtin, defined SH function, control keyword, or a variable
2755             # assignment. Such commands run in the foreground and the trailing &
2756             # is ignored (documented limitation: only external commands background).
2757             sub _sh_word_is_foreground {
2758 6     6   28 my ($w) = @_;
2759 6 50 33     17 return 0 unless defined $w && $w ne '';
2760              
2761             # VAR=value assignment
2762 6 100       19 return 1 if $w =~ /\A[A-Za-z_][A-Za-z0-9_]*=/;
2763              
2764             # test bracket and no-op
2765 4 50 33     18 return 1 if $w eq '[' || $w eq ':' || $w eq '.';
      33        
2766              
2767 4         5 my $lc = lc($w);
2768              
2769 4         52 my %builtin = (
2770             export => 1, unset => 1, echo => 1, printf => 1, cd => 1,
2771             pwd => 1, exit => 1, 'true' => 1, 'false' => 1, read => 1,
2772             test => 1, source => 1, 'return' => 1, 'break' => 1,
2773             'continue' => 1, shift => 1, local => 1, set => 1,
2774             );
2775 4 100       15 return 1 if $builtin{$lc};
2776              
2777             # Control keywords (defensive; these are normally handled in _run_lines)
2778 2         29 my %kw = (
2779             'if' => 1, then => 1, 'else' => 1, elif => 1, fi => 1,
2780             'for' => 1, 'while' => 1, until => 1, 'do' => 1, done => 1,
2781             case => 1, esac => 1, function => 1, in => 1,
2782             );
2783 2 50       4 return 1 if $kw{$lc};
2784              
2785             # Defined SH function (case-sensitive, as in _exec_line dispatch)
2786 2 50       8 return 1 if exists $_SH_FUNCTIONS{$w};
2787              
2788 2         8 return 0;
2789             }
2790              
2791             # _bg_tempfile: create a unique, empty temp file (O_CREAT|O_EXCL to avoid
2792             # symlink races) for capturing a background job's PID on Unix-like systems.
2793             # Returns the path, or undef on failure.
2794             sub _bg_tempfile {
2795 1   50 1   13 my $dir = $ENV{'TMPDIR'} || $ENV{'TEMP'} || $ENV{'TMP'} || '';
2796 1 50 33     22 $dir = '/tmp' if $dir eq '' && -d '/tmp';
2797 1 50       2 $dir = '.' if $dir eq '';
2798 1         3 $dir =~ s{[\\/]+\z}{};
2799 1 50 33     28 $dir = '.' if !(-d $dir && -w $dir);
2800              
2801 1         8 my $attempt = 0;
2802 1         5 while ($attempt < 1000) {
2803 1         1 $_BG_SEQ++;
2804 1         2 $attempt++;
2805 1         14 my $path = $dir . '/' . 'batsh_bg_' . $$ . '_' . $_BG_SEQ;
2806 1 50       176 if (sysopen(_BG_TMP, $path, O_WRONLY | O_CREAT | O_EXCL, 0600)) {
2807 1         12 close(_BG_TMP);
2808 1         2 push @_BG_TMPFILES, $path;
2809 1         5 return $path;
2810             }
2811             # EEXIST or transient error: retry with next sequence number
2812             }
2813 0         0 warn "sh: cannot create background pidfile in $dir: $!\n";
2814 0         0 return undef;
2815             }
2816              
2817             # _bg_launch: start $cmdline asynchronously.
2818             # Win32 : system(1, STRING) spawns via the command shell (P_NOWAIT)
2819             # and returns the PID directly.
2820             # Unix-like : delegate to /bin/sh so the job is backgrounded without a
2821             # Perl fork; the shell's $! (the job PID) is written to a
2822             # temp file and read back into BATsh's own $!.
2823             # On a successful launch $? (LAST_STATUS) is 0; the exit code of the
2824             # background job itself is not awaited (sh semantics).
2825             sub _bg_launch {
2826 1     1   3 my ($class, $cmdline) = @_;
2827 1 50       2 $cmdline = '' unless defined $cmdline;
2828 1 50       7 return 0 if $cmdline =~ /\A\s*\z/;
2829 1         11 BATsh::Env->sync_to_env();
2830              
2831 1 50       9 if ($^O =~ /MSWin32/i) {
2832 0         0 my $pid = system(1, $cmdline);
2833 0 0 0     0 if (defined $pid && $pid > 0) {
2834 0         0 $_LAST_BG_PID = $pid;
2835 0         0 $LAST_STATUS = 0;
2836             }
2837             else {
2838 0         0 warn "sh: failed to start background process\n";
2839 0         0 $LAST_STATUS = 1;
2840             }
2841 0         0 return $LAST_STATUS;
2842             }
2843              
2844             # Unix-like
2845 1         3 my $pidfile = _bg_tempfile();
2846 1         1 my $rc;
2847 1 50       4 if (defined $pidfile) {
2848             # Group the command so that the whole list (pipelines, &&, ...) is
2849             # backgrounded as a unit, then echo the job PID ($!) to the file.
2850 1         4413 $rc = system("{ $cmdline ; } & echo \$! > '$pidfile'");
2851 1 50       96 if (open(_BG_PIDFH, "< $pidfile")) {
2852 1         29 local $/;
2853 1         34 my $buf = <_BG_PIDFH>;
2854 1         13 close(_BG_PIDFH);
2855 1 50       24 $buf = '' unless defined $buf;
2856 1         7 my $pid = '';
2857 1         16 ($pid) = ($buf =~ /(\d+)/);
2858 1 50 33     26 $_LAST_BG_PID = $pid if defined $pid && $pid ne '';
2859             }
2860 1         136 unlink $pidfile;
2861 1         16 @_BG_TMPFILES = grep { $_ ne $pidfile } @_BG_TMPFILES;
  1         15  
2862             }
2863             else {
2864 0         0 $rc = system("{ $cmdline ; } &");
2865             }
2866 1 50 33     23 $LAST_STATUS = (defined $rc && $rc != -1) ? 0 : 1;
2867 1         29 return $LAST_STATUS;
2868             }
2869              
2870              
2871             # ----------------------------------------------------------------
2872             # Split "cmd rest" honouring quoted strings
2873             # ----------------------------------------------------------------
2874             sub _split_sh {
2875 382     382   481 my ($line) = @_;
2876 382 50       1161 if ($line =~ /\A(\S+)\s*(.*)\z/s) {
2877 382         1575 return ($1, $2);
2878             }
2879 0         0 return ($line, '');
2880             }
2881              
2882             # ----------------------------------------------------------------
2883             # _sh_assign_prefix: detect POSIX assignment prefixes on a RAW (un-expanded)
2884             # command line, e.g. `IFS= read -r LINE` or `LC_ALL=C sort file`.
2885             #
2886             # Parses one or more leading VAR=VALUE words, where VALUE is read with
2887             # quote / $(...) / backtick awareness so that an assignment whose value
2888             # merely *contains* spaces (e.g. UPPER=$(echo "a b")) is NOT mistaken for
2889             # a prefix followed by a command.
2890             #
2891             # Returns (\@pairs, $remainder):
2892             # - @pairs is a list of [VAR, RAW_VALUE] (value still un-expanded)
2893             # - $remainder is the rest of the line (the command to run), '' if none
2894             # Returns () when the line does not begin with an assignment.
2895             # ----------------------------------------------------------------
2896             sub _sh_assign_prefix {
2897 382     382   490 my ($line) = @_;
2898 382         1001 my @chars = split //, $line;
2899 382         480 my $n = scalar @chars;
2900 382         405 my $i = 0;
2901 382         425 my @pairs = ();
2902              
2903 382         347 while (1) {
2904             # Skip leading spaces between successive assignments.
2905 382   33     1543 $i++ while $i < $n && ($chars[$i] eq ' ' || $chars[$i] eq "\t");
      33        
2906 382 50       600 last if $i >= $n;
2907              
2908             # Match a variable name followed by '='.
2909 382         409 my $j = $i;
2910 382         435 my $name = '';
2911 382 100       1012 if ($chars[$j] =~ /[A-Za-z_]/) {
2912 356         431 $name .= $chars[$j]; $j++;
  356         324  
2913 356   100     1173 while ($j < $n && $chars[$j] =~ /[A-Za-z0-9_]/) { $name .= $chars[$j]; $j++ }
  838         816  
  838         2079  
2914             }
2915 382 100 100     1608 last unless length($name) && $j < $n && $chars[$j] eq '=';
      100        
2916 114         127 $j++; # consume '='
2917              
2918             # Read the value with quote / $() / backtick awareness.
2919 114         147 my $val = '';
2920 114         145 my $in_sq = 0;
2921 114         113 my $in_dq = 0;
2922 114         126 my $depth = 0; # $( ) nesting
2923 114         135 my $in_bt = 0; # backticks
2924 114         154 while ($j < $n) {
2925 845         842 my $c = $chars[$j];
2926 845 0       928 if ($in_sq) { $val .= $c; $in_sq = 0 if $c eq "'"; $j++; next }
  0 50       0  
  0         0  
  0         0  
  0         0  
2927 845 0 33     1045 if ($c eq "'" && !$in_dq && !$in_bt) { $in_sq = 1; $val .= $c; $j++; next }
  0   33     0  
  0         0  
  0         0  
  0         0  
2928 845 100 66     1059 if ($c eq '"' && !$in_bt) { $in_dq = !$in_dq; $val .= $c; $j++; next }
  62         69  
  62         60  
  62         52  
  62         75  
2929 783 100 100     1400 if (!$in_dq && $c eq '`') { $in_bt = !$in_bt; $val .= $c; $j++; next }
  2         5  
  2         4  
  2         3  
  2         5  
2930 781 50 100     2085 if (!$in_dq && !$in_bt && $c eq '$' && $j + 1 < $n && $chars[$j+1] eq '(') {
      100        
      66        
      66        
2931 42         49 $depth++; $val .= '$('; $j += 2; next;
  42         57  
  42         40  
  42         64  
2932             }
2933 739 100 100     1970 if (!$in_dq && !$in_bt && $depth > 0 && $c eq ')') {
      100        
      100        
2934 42         95 $depth--; $val .= ')'; $j++; next;
  42         45  
  42         37  
  42         58  
2935             }
2936 697 50 100     2241 if (!$in_sq && !$in_dq && !$in_bt && $depth == 0
      100        
      33        
      66        
2937             && ($c eq ' ' || $c eq "\t")) {
2938 0         0 last; # end of this value word
2939             }
2940 697         681 $val .= $c; $j++;
  697         847  
2941             }
2942              
2943 114         299 push @pairs, [$name, $val];
2944 114         148 $i = $j;
2945              
2946             # Peek: is there a following non-space token?
2947 114         132 my $k = $i;
2948 114   0     157 $k++ while $k < $n && ($chars[$k] eq ' ' || $chars[$k] eq "\t");
      33        
2949 114 50       216 last if $k >= $n; # nothing follows: trailing pure assignment(s)
2950              
2951             # Is the next token another assignment? If so, loop; otherwise the
2952             # remainder is the command.
2953 0         0 my $m = $k;
2954 0         0 my $nm2 = '';
2955 0 0       0 if ($chars[$m] =~ /[A-Za-z_]/) {
2956 0         0 $nm2 .= $chars[$m]; $m++;
  0         0  
2957 0   0     0 while ($m < $n && $chars[$m] =~ /[A-Za-z0-9_]/) { $nm2 .= $chars[$m]; $m++ }
  0         0  
  0         0  
2958             }
2959 0 0 0     0 if (length($nm2) && $m < $n && $chars[$m] eq '=') {
      0        
2960 0         0 $i = $k; # next assignment; continue the loop
2961 0         0 next;
2962             }
2963              
2964             # Remainder is a command.
2965 0         0 my $remainder = join('', @chars[$k .. $n-1]);
2966 0         0 return (\@pairs, $remainder);
2967             }
2968              
2969             # No command remainder: either not an assignment at all, or pure
2970             # assignment(s) which the normal post-expansion path handles.
2971 382 100       1043 return () unless @pairs;
2972 114         326 return (\@pairs, '');
2973             }
2974              
2975             # ----------------------------------------------------------------
2976             # Here-document support (Perl 5.005_03 compatible)
2977             # ----------------------------------------------------------------
2978             # _hd_detect: scan a command line for an *unquoted* << operator.
2979             # Returns () if none found, otherwise:
2980             # ($cmd_part, $dash, $delim, $quoted)
2981             # where $cmd_part is the command with the "<< DELIM" token removed
2982             # (text after the delimiter, e.g. trailing redirections, is preserved),
2983             # $dash is 1 for <<- (strip leading tabs), $delim is the delimiter word,
2984             # and $quoted is 1 when the delimiter was quoted (suppresses expansion).
2985             sub _hd_detect {
2986 632     632   865 my ($line) = @_;
2987 632         3127 my @chars = split //, $line;
2988 632         858 my $n = scalar @chars;
2989 632         735 my $in_sq = 0;
2990 632         640 my $in_dq = 0;
2991 632         683 my $i = 0;
2992              
2993 632         952 while ($i < $n) {
2994 10159         9574 my $ch = $chars[$i];
2995              
2996 10159 100       11493 if ($in_sq) {
2997 300 100       323 $in_sq = 0 if $ch eq "'";
2998 300         221 $i++; next;
  300         336  
2999             }
3000 9859 100 66     12676 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $i++; next }
  30         38  
  30         33  
  30         43  
3001 9829 100 66     12703 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $i++; next }
  270         323  
  270         271  
  270         424  
3002 9559 50       10913 if ($ch eq '\\') { $i += 2; next }
  0         0  
  0         0  
3003              
3004             # Unquoted << (but not <<<, which is a here-string: not supported)
3005 9559 100 100     18581 if (!$in_dq && $ch eq '<' && $i+1 < $n && $chars[$i+1] eq '<'
      66        
      100        
      33        
      66        
3006             && !($i+2 < $n && $chars[$i+2] eq '<')) {
3007 26         175 my $cmd_part = join('', @chars[0 .. $i-1]);
3008 26         63 my $j = $i + 2;
3009 26         39 my $dash = 0;
3010 26 100 66     116 if ($j < $n && $chars[$j] eq '-') { $dash = 1; $j++ }
  2         3  
  2         2  
3011 26   33     146 $j++ while $j < $n && ($chars[$j] eq ' ' || $chars[$j] eq "\t");
      33        
3012              
3013 26         42 my $quoted = 0;
3014 26         44 my $q = '';
3015 26 100 66     157 if ($j < $n && ($chars[$j] eq "'" || $chars[$j] eq '"')) {
      33        
3016 2         3 $quoted = 1; $q = $chars[$j]; $j++;
  2         3  
  2         3  
3017             }
3018 26         40 my $delim = '';
3019 26 100       165 if ($quoted) {
3020 2   66     18 while ($j < $n && $chars[$j] ne $q) { $delim .= $chars[$j]; $j++ }
  6         7  
  6         10  
3021 2 50       4 $j++ if $j < $n; # skip closing quote
3022             }
3023             else {
3024 24   66     183 while ($j < $n && $chars[$j] =~ /\w/) { $delim .= $chars[$j]; $j++ }
  72         88  
  72         263  
3025             }
3026 26 50       67 return () if $delim eq ''; # malformed; treat as ordinary line
3027              
3028 26         86 my $rest = join('', @chars[$j .. $n-1]);
3029 26         40 $cmd_part .= $rest; # preserve trailing tokens
3030 26         144 return ($cmd_part, $dash, $delim, $quoted);
3031             }
3032              
3033 9533         11075 $i++;
3034             }
3035 606         1574 return ();
3036             }
3037              
3038             # _hd_tempfile: write $body to a uniquely-named temp file using
3039             # sysopen(... O_CREAT|O_EXCL ...) to avoid symlink races.
3040             # Returns the path, or undef on failure.
3041             sub _hd_tempfile {
3042 12     12   24 my ($body) = @_;
3043              
3044 12   50     135 my $dir = $ENV{'TMPDIR'} || $ENV{'TEMP'} || $ENV{'TMP'} || '';
3045 12 50 33     384 $dir = '/tmp' if $dir eq '' && -d '/tmp';
3046 12 50       36 $dir = '.' if $dir eq '';
3047 12         57 $dir =~ s{[\\/]+\z}{};
3048 12 50 33     213 $dir = '.' if !(-d $dir && -w $dir);
3049              
3050 12         27 my $attempt = 0;
3051 12         40 while ($attempt < 1000) {
3052 12         37 $_HD_SEQ++;
3053 12         14 $attempt++;
3054 12         119 my $path = $dir . '/' . 'batsh_hd_' . $$ . '_' . $_HD_SEQ;
3055 12 50       2316 if (sysopen(_HD_TMP, $path, O_WRONLY | O_CREAT | O_EXCL, 0600)) {
3056 12         62 binmode(_HD_TMP);
3057 12         135 print _HD_TMP $body;
3058 12         599 close(_HD_TMP);
3059 12         44 push @_HD_TMPFILES, $path;
3060 12         57 return $path;
3061             }
3062             # EEXIST or transient error: retry with next sequence number
3063             }
3064 0         0 warn "sh: cannot create here-document temp file in $dir: $!\n";
3065 0         0 return undef;
3066             }
3067              
3068             # _hd_run: materialise the here-document body and run the command with
3069             # its STDIN connected to the body, reusing the existing redirect path.
3070             sub _hd_run {
3071 12     12   48 my ($class, $cmd_part, $body_ref, $quoted, $opts_ref) = @_;
3072              
3073 12         20 my @body = @{$body_ref};
  12         51  
3074 12 100       33 if (!$quoted) {
3075 11         34 for my $b (@body) { $b = _expand($class, $b) }
  15         55  
3076             }
3077 12         43 my $text = '';
3078 12         22 for my $b (@body) { $text .= $b . "\n" }
  16         28  
3079              
3080 12         68 my $tmp = _hd_tempfile($text);
3081 12 50       50 if (!defined $tmp) { $LAST_STATUS = 2; return 2 }
  0         0  
  0         0  
3082              
3083 12         39 my @redir = ( [0, 0, $tmp] ); # fd=0 (stdin), append=0, source=tmp
3084 12         63 my $rc = _sh_exec_with_redirs($class, $cmd_part, \@redir, $opts_ref);
3085              
3086 12         1454 unlink $tmp;
3087 12         134 @_HD_TMPFILES = grep { $_ ne $tmp } @_HD_TMPFILES;
  12         100  
3088 12         193 return $rc;
3089             }
3090              
3091             # Failsafe: remove any here-document temp files left behind on abnormal exit.
3092 15 0   15   76 END { for my $f (@_HD_TMPFILES) { unlink $f if defined $f } }
  0         0  
3093              
3094             # Failsafe: remove any background-job pidfiles left behind on abnormal exit.
3095 15 0   15   425176 END { for my $f (@_BG_TMPFILES) { unlink $f if defined $f } }
  0         0  
3096              
3097              
3098             # ----------------------------------------------------------------
3099             # Array / associative-array support (v0.06)
3100             # ----------------------------------------------------------------
3101             # Array names are case-insensitive, matching the scalar store: the key
3102             # stored in %_SH_ARRAY is always the uppercased name.
3103 276     276   617 sub _arr_name { return uc($_[0]) }
3104              
3105             sub _arr_exists {
3106 155     155   207 my ($name) = @_;
3107 155 100       260 return exists $_SH_ARRAY{ _arr_name($name) } ? 1 : 0;
3108             }
3109              
3110             sub _arr_is_assoc {
3111 25     25   31 my ($name) = @_;
3112 25         28 my $k = _arr_name($name);
3113 25 100 100     67 return (exists $_SH_ARRAY_TYPE{$k} && $_SH_ARRAY_TYPE{$k} eq 'assoc') ? 1 : 0;
3114             }
3115              
3116             # Evaluate an indexed-array subscript as an integer. Plain integers are
3117             # taken verbatim; anything else is run through the arithmetic evaluator so
3118             # that subscripts such as "1+1" or a bare variable name work like bash.
3119             sub _arr_index {
3120 15     15   17 my ($s) = @_;
3121 15 50       23 $s = '' unless defined $s;
3122 15         19 $s =~ s/\A\s+//; $s =~ s/\s+\z//;
  15         21  
3123 15 50       17 return 0 if $s eq '';
3124 15 100       52 return int($s) if $s =~ /\A-?\d+\z/;
3125 1         3 my $v = _eval_arith($s);
3126 1 50       4 return ($v =~ /\A-?\d+\z/) ? int($v) : 0;
3127             }
3128              
3129             # Resolve $VAR / ${VAR} inside a subscript (no command substitution, no
3130             # arithmetic -- those are applied later for indexed subscripts).
3131             sub _arr_expand_sub {
3132 25     25   43 my ($class, $s) = @_;
3133 25 50       39 return '' unless defined $s;
3134 25         40 $s =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
3135 0 0       0 do { my $v = BATsh::Env->get($1); defined $v ? $v : '' }
  0         0  
  0         0  
3136             /ge;
3137 25         31 $s =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/
3138 3 50       4 do { my $v = BATsh::Env->get($1); defined $v ? $v : '' }
  3         9  
  3         9  
3139             /ge;
3140 25         36 return $s;
3141             }
3142              
3143             # Element keys in display order: ascending numeric (indexed) or sorted
3144             # string (assoc).
3145             sub _arr_ordered_keys {
3146 16     16   21 my ($name) = @_;
3147 16         20 my $k = _arr_name($name);
3148 16 50       23 return () unless exists $_SH_ARRAY{$k};
3149 16         17 my $h = $_SH_ARRAY{$k};
3150 16 100 66     67 if ((defined $_SH_ARRAY_TYPE{$k} && $_SH_ARRAY_TYPE{$k} eq 'assoc')) {
3151 2         3 return sort keys %{$h};
  2         15  
3152             }
3153 14         13 return sort { $a <=> $b } keys %{$h};
  30         75  
  14         54  
3154             }
3155              
3156             sub _arr_values {
3157 9     9   15 my ($name) = @_;
3158 9         12 my $k = _arr_name($name);
3159 9 50       15 return () unless exists $_SH_ARRAY{$k};
3160 9         11 my $h = $_SH_ARRAY{$k};
3161 9         11 return map { $h->{$_} } _arr_ordered_keys($name);
  24         54  
3162             }
3163              
3164             sub _arr_count {
3165 9     9   20 my ($name) = @_;
3166 9         13 my $k = _arr_name($name);
3167 9 100       18 return 0 unless exists $_SH_ARRAY{$k};
3168 8         8 return scalar keys %{$_SH_ARRAY{$k}};
  8         32  
3169             }
3170              
3171             # Fetch a single element. $sub is already $VAR-expanded by the caller.
3172             # Returns undef when the element is unset.
3173             sub _arr_get_element {
3174 18     18   25 my ($name, $sub) = @_;
3175 18         21 my $k = _arr_name($name);
3176 18 50       26 return undef unless exists $_SH_ARRAY{$k};
3177 18         20 my $h = $_SH_ARRAY{$k};
3178 18 100 66     60 if ((defined $_SH_ARRAY_TYPE{$k} && $_SH_ARRAY_TYPE{$k} eq 'assoc')) {
3179 9 50       23 return exists $h->{$sub} ? $h->{$sub} : undef;
3180             }
3181 9         11 my $idx = _arr_index($sub);
3182 9 100       13 if ($idx < 0) {
3183             # Negative subscript: count back over the ordered set of set indices.
3184 1         3 my @keys = _arr_ordered_keys($name);
3185 1 50       2 return undef unless @keys;
3186 1         2 my $kk = $keys[$idx];
3187 1 50       4 return defined $kk ? $h->{$kk} : undef;
3188             }
3189 8 50       19 return exists $h->{$idx} ? $h->{$idx} : undef;
3190             }
3191              
3192             # Remove shell quoting from a value: drop unescaped quote characters while
3193             # honouring single- vs double-quote regions. Handles whole-token quotes
3194             # ("a b") and partial quotes (foo"a b"bar) alike.
3195             sub _arr_dequote {
3196 267     267   352 my ($v) = @_;
3197 267 50       352 return '' unless defined $v;
3198 267         334 my $out = '';
3199 267         289 my $in_sq = 0;
3200 267         266 my $in_dq = 0;
3201 267         787 for my $c (split //, $v) {
3202 1912 0       2145 if ($in_sq) { if ($c eq "'") { $in_sq = 0 } else { $out .= $c } next }
  0 50       0  
  0         0  
  0         0  
  0         0  
3203 1912 50 33     2428 if ($c eq "'" && !$in_dq) { $in_sq = 1; next }
  0         0  
  0         0  
3204 1912 100 66     2421 if ($c eq '"' && !$in_sq) { $in_dq = !$in_dq; next }
  50         51  
  50         52  
3205 1862         1894 $out .= $c;
3206             }
3207 267         547 return $out;
3208             }
3209              
3210             # Split a string on unquoted whitespace, KEEPING the quote characters in
3211             # each returned token (so the caller can tell whether a token was quoted).
3212             sub _arr_split_words {
3213 39     39   49 my ($s) = @_;
3214 39 50       96 $s = '' unless defined $s;
3215 39         37 my @words;
3216 39         48 my $cur = '';
3217 39         38 my $have = 0;
3218 39         46 my $in_sq = 0;
3219 39         31 my $in_dq = 0;
3220 39         81 my @chars = split //, $s;
3221 39         40 my $n = scalar @chars;
3222 39         38 my $i = 0;
3223 39         56 while ($i < $n) {
3224 255         228 my $c = $chars[$i];
3225 255 0       302 if ($in_sq) { $cur .= $c; $in_sq = 0 if $c eq "'"; $have = 1; $i++; next }
  0 50       0  
  0         0  
  0         0  
  0         0  
  0         0  
3226 255 50 33     303 if ($c eq "'" && !$in_dq) { $in_sq = 1; $cur .= $c; $have = 1; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
  0         0  
3227 255 100 66     350 if ($c eq '"' && !$in_sq) { $in_dq = !$in_dq; $cur .= $c; $have = 1; $i++; next }
  10         10  
  10         9  
  10         66  
  10         9  
  10         12  
3228 245 100 66     674 if (!$in_sq && !$in_dq && $c =~ /\s/) {
      100        
3229 54 50       74 if ($have) { push @words, $cur; $cur = ''; $have = 0 }
  54         68  
  54         52  
  54         49  
3230 54         40 $i++; next;
  54         70  
3231             }
3232 191         168 $cur .= $c; $have = 1; $i++;
  191         179  
  191         205  
3233             }
3234 39 50       63 push @words, $cur if $have;
3235 39         108 return @words;
3236             }
3237              
3238             # Parse the body of a (...) array literal into a list of [subscript, value]
3239             # pairs. $subscript is undef for positional elements and a string for
3240             # explicit [sub]=value elements.
3241             sub _arr_parse_elements {
3242 25     25   33 my ($class, $body) = @_;
3243 25         26 my @raw = _arr_split_words($body);
3244 25         25 my @out;
3245 25         29 for my $tok (@raw) {
3246 67         64 my ($sub, $vpart);
3247 67 100       98 if ($tok =~ /\A\[(.*?)\]=(.*)\z/s) {
3248 9         18 ($sub, $vpart) = ($1, $2);
3249             }
3250             else {
3251 58         75 ($sub, $vpart) = (undef, $tok);
3252             }
3253 67 100       82 my $raw_has_quote = ($vpart =~ /['"]/) ? 1 : 0;
3254 67         79 my $exp = _expand($class, $vpart);
3255 67 100 66     151 if (defined $sub) {
    50          
3256 9         15 $sub = _arr_dequote(_expand($class, $sub));
3257 9         13 push @out, [$sub, _arr_dequote($exp)];
3258             }
3259             elsif (!$raw_has_quote && $exp =~ /\s/) {
3260             # Unquoted expansion is subject to word splitting.
3261 0         0 for my $w (split /\s+/, $exp) {
3262 0 0       0 next if $w eq '';
3263 0         0 push @out, [undef, $w];
3264             }
3265             }
3266             else {
3267 58         68 push @out, [undef, _arr_dequote($exp)];
3268             }
3269             }
3270 25         45 return @out;
3271             }
3272              
3273             # arr=( ... ) / arr+=( ... ) whole-array assignment or append.
3274             sub _arr_assign_literal {
3275 25     25   41 my ($class, $name, $body, $append) = @_;
3276 25         37 my $k = _arr_name($name);
3277 25         40 my $assoc = _arr_is_assoc($name); # honour a prior 'declare -A'
3278              
3279 25 100       33 if (!$append) {
3280 23         43 $_SH_ARRAY{$k} = {};
3281 23 100       44 $_SH_ARRAY_TYPE{$k} = $assoc ? 'assoc' : 'indexed';
3282             }
3283             else {
3284 2 50       5 $_SH_ARRAY{$k} = {} unless exists $_SH_ARRAY{$k};
3285             $_SH_ARRAY_TYPE{$k} = ($assoc ? 'assoc' : 'indexed')
3286 2 0       5 unless exists $_SH_ARRAY_TYPE{$k};
    50          
3287             }
3288 25         124 BATsh::Env->unset($name); # a name is array OR scalar, not both
3289              
3290 25         37 my $is_assoc = ($_SH_ARRAY_TYPE{$k} eq 'assoc');
3291 25         39 my @elems = _arr_parse_elements($class, $body);
3292              
3293 25 100       39 if ($is_assoc) {
3294 3         6 for my $e (@elems) {
3295 7         8 my ($sub, $val) = @{$e};
  7         12  
3296 7 50       10 $sub = '' unless defined $sub;
3297 7         15 $_SH_ARRAY{$k}{$sub} = $val;
3298             }
3299             }
3300             else {
3301 22         22 my $next = 0;
3302 22         19 for my $ix (keys %{$_SH_ARRAY{$k}}) {
  22         49  
3303 4 100 66     33 $next = $ix + 1 if $ix =~ /\A-?\d+\z/ && $ix + 1 > $next;
3304             }
3305 22         25 for my $e (@elems) {
3306 60         49 my ($sub, $val) = @{$e};
  60         105  
3307 60 100 66     84 if (defined $sub && $sub ne '') {
3308 2         3 my $ix = _arr_index($sub);
3309 2         5 $_SH_ARRAY{$k}{$ix} = $val;
3310 2         21 $next = $ix + 1;
3311             }
3312             else {
3313 58         90 $_SH_ARRAY{$k}{$next} = $val;
3314 58         61 $next++;
3315             }
3316             }
3317             }
3318 25         22 $LAST_STATUS = 0;
3319 25         96 return 0;
3320             }
3321              
3322             # arr[sub]=value / arr[sub]+=value single-element assignment or append.
3323             sub _arr_assign_element {
3324 8     8   15 my ($class, $name, $sub, $rawval, $append) = @_;
3325 8         14 my $val = _expand($class, $rawval);
3326 8         12 $val =~ s/\A"(.*)"\z/$1/s;
3327 8         9 $val =~ s/\A'(.*)'\z/$1/s;
3328 8         11 my $k = _arr_name($name);
3329 8         16 $sub = _arr_expand_sub($class, $sub);
3330 8 50       15 if (!exists $_SH_ARRAY{$k}) {
3331 0         0 $_SH_ARRAY{$k} = {};
3332 0         0 $_SH_ARRAY_TYPE{$k} = 'indexed';
3333             }
3334 8         34 BATsh::Env->unset($name);
3335 8 100 66     40 my $key = (defined $_SH_ARRAY_TYPE{$k} && $_SH_ARRAY_TYPE{$k} eq 'assoc')
3336             ? $sub : _arr_index($sub);
3337 8 100       11 if ($append) {
3338 1 50       5 my $old = exists $_SH_ARRAY{$k}{$key} ? $_SH_ARRAY{$k}{$key} : '';
3339 1         2 $_SH_ARRAY{$k}{$key} = $old . $val;
3340             }
3341             else {
3342 7         17 $_SH_ARRAY{$k}{$key} = $val;
3343             }
3344 8         9 $LAST_STATUS = 0;
3345 8         14 return 0;
3346             }
3347              
3348             # declare / typeset [-aA] NAME[=(...)] ... array (and scalar) declaration.
3349             sub _cmd_declare {
3350 7     7   10 my ($class, $rest) = @_;
3351 7 50       10 $rest = '' unless defined $rest;
3352 7         12 $rest =~ s/\A\s+//;
3353              
3354 7         6 my $type; # 'assoc' | 'indexed' | undef
3355 7         22 while ($rest =~ s/\A(-[A-Za-z]+)\s+//) {
3356 7         10 my $flag = $1;
3357 7 50       11 if ($flag =~ /A/) { $type = 'assoc' }
  7 0       14  
3358 0 0       0 elsif ($flag =~ /a/) { $type = 'indexed' unless defined $type }
3359             }
3360              
3361 7         9 while ($rest ne '') {
3362 7         9 $rest =~ s/\A\s+//;
3363 7 50       11 last if $rest eq '';
3364 7 100       27 if ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)\+?=\((.*)\)\s*(.*)\z/s) {
    50          
    50          
3365 2         5 my ($name, $body, $tail) = ($1, $2, $3);
3366 2         6 my $k = _arr_name($name);
3367 2 50       6 $_SH_ARRAY_TYPE{$k} = $type if defined $type;
3368 2         5 _arr_assign_literal($class, $name, $body, 0);
3369 2         5 $rest = $tail;
3370             }
3371             elsif ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(\S*)\s*(.*)\z/s) {
3372 0         0 my ($name, $val, $tail) = ($1, $2, $3);
3373 0 0       0 if (defined $type) {
3374             # Typed array declared with a scalar initialiser: seed [0].
3375 0         0 my $k = _arr_name($name);
3376 0         0 $_SH_ARRAY_TYPE{$k} = $type;
3377 0         0 _arr_assign_element($class, $name, '0', $val, 0);
3378             }
3379             else {
3380 0         0 $val = _expand($class, $val);
3381 0         0 $val =~ s/\A"(.*)"\z/$1/s;
3382 0         0 $val =~ s/\A'(.*)'\z/$1/s;
3383 0         0 BATsh::Env->set($name, $val);
3384             }
3385 0         0 $rest = $tail;
3386             }
3387             elsif ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*(.*)\z/s) {
3388 5         7 my ($name, $tail) = ($1, $2);
3389 5         9 my $k = _arr_name($name);
3390 5 50       15 $_SH_ARRAY{$k} = {} unless exists $_SH_ARRAY{$k};
3391 5 50       9 $_SH_ARRAY_TYPE{$k} = (defined $type ? $type : 'indexed');
3392 5         16 $rest = $tail;
3393             }
3394             else {
3395 0         0 last;
3396             }
3397             }
3398 7         9 $LAST_STATUS = 0;
3399 7         36 return 0;
3400             }
3401              
3402             # _sh_try_array_op: detect and perform an array operation on the RAW line.
3403             # Returns (1, $status) when it handled the line, or () otherwise.
3404             sub _sh_try_array_op {
3405 437     437   735 my ($class, $line, $opts_ref) = @_;
3406 437         455 my $s = $line;
3407 437         976 $s =~ s/\A\s+//; $s =~ s/\s+\z//;
  437         885  
3408 437         548 $s =~ s/\s*;\s*\z//; # tolerate one trailing ';'
3409              
3410             # declare / typeset / local with array semantics
3411 437 100       1130 if ($s =~ /\A(declare|typeset|local)\b\s*(.*)\z/is) {
3412 9         36 my ($kw, $args) = ($1, $2);
3413 9         19 my $is_local = (lc($kw) eq 'local');
3414 9 100       38 my $has_arr_flag = ($args =~ /\A-[A-Za-z]*[aA]/) ? 1 : 0;
3415 9 100       21 my $has_arr_init = ($args =~ /=\(/) ? 1 : 0;
3416 9 50 66     42 if (!$is_local || $has_arr_flag || $has_arr_init) {
      66        
3417 7         13 return (1, _cmd_declare($class, $args));
3418             }
3419 2         13 return (); # plain 'local x=1' -> handled by _cmd_local
3420             }
3421              
3422             # NAME=( ... ) or NAME+=( ... ) -- whole-line array literal
3423 428 100       807 if ($s =~ /\A([A-Za-z_][A-Za-z0-9_]*)(\+?)=\((.*)\)\z/s) {
3424 23         110 my ($name, $plus, $body) = ($1, $2, $3);
3425 23 100       52 return (1, _arr_assign_literal($class, $name, $body,
3426             ($plus eq '+') ? 1 : 0));
3427             }
3428              
3429             # NAME[SUB]=VALUE or NAME[SUB]+=VALUE -- single-element assignment
3430 405 100       656 if ($s =~ /\A([A-Za-z_][A-Za-z0-9_]*)\[([^\]]*)\](\+?)=(.*)\z/s) {
3431 8         35 my ($name, $sub, $plus, $val) = ($1, $2, $3, $4);
3432 8 100       23 return (1, _arr_assign_element($class, $name, $sub, $val,
3433             ($plus eq '+') ? 1 : 0));
3434             }
3435              
3436 397         577 return ();
3437             }
3438              
3439             # _expand_word_list: turn a for-loop list string into a list of items.
3440             # Resolves variables / command substitution, applies filename globbing to
3441             # unquoted glob words, and expands a whole-word ${arr[@]} / ${arr[*]}
3442             # reference (quoted or not) to one item per array element.
3443             sub _expand_word_list {
3444 14     14   27 my ($class, $list_str) = @_;
3445 14         35 my @raw = _arr_split_words($list_str);
3446 14         18 my @items;
3447 14         25 for my $tok (@raw) {
3448             # Whole-word ${arr[@]} / ${arr[*]} -> one item per element value.
3449 26 100 66     67 if ($tok =~ /\A"?\$\{([A-Za-z_][A-Za-z0-9_]*)\[[\@*]\]\}"?\z/
3450             && _arr_exists($1)) {
3451 2         4 push @items, _arr_values($1);
3452 2         4 next;
3453             }
3454             # Whole-word ${!arr[@]} / ${!arr[*]} -> one item per index / key.
3455 24 100 66     51 if ($tok =~ /\A"?\$\{!([A-Za-z_][A-Za-z0-9_]*)\[[\@*]\]\}"?\z/
3456             && _arr_exists($1)) {
3457 1         3 push @items, _arr_ordered_keys($1);
3458 1         2 next;
3459             }
3460 23 50       34 my $raw_has_quote = ($tok =~ /['"]/) ? 1 : 0;
3461 23         36 my $exp = _arr_dequote(_expand($class, $tok));
3462 23 50       38 if (!$raw_has_quote) {
3463 23 100       64 if ($exp =~ /[*?\[]/) {
    50          
    50          
3464 2         20 push @items, _glob_expand($exp);
3465             }
3466             elsif ($exp =~ /\s/) {
3467 0         0 push @items, grep { $_ ne '' } split /\s+/, $exp;
  0         0  
3468             }
3469             elsif ($exp ne '') {
3470 21         28 push @items, $exp;
3471             }
3472             # an empty unquoted word expands to nothing
3473             }
3474             else {
3475 0         0 push @items, $exp;
3476             }
3477             }
3478 14         46 return @items;
3479             }
3480              
3481             # ----------------------------------------------------------------
3482             # Accessors
3483             # ----------------------------------------------------------------
3484 2     2 0 195 sub last_status { return $LAST_STATUS }
3485 0     0 0   sub set_last_status { $LAST_STATUS = $_[1] }
3486              
3487             # Need Cwd
3488             BEGIN {
3489 15     15   83 eval { require Cwd };
  15         115  
3490 15 50       638 if ($@) {
3491 0         0 eval 'sub Cwd::cwd { return $ENV{PWD} || "." }';
3492             }
3493             }
3494              
3495             1;
3496              
3497             __END__