File Coverage

lib/BATsh/SH.pm
Criterion Covered Total %
statement 912 1412 64.5
branch 345 760 45.3
condition 114 277 41.1
subroutine 47 55 85.4
pod 0 3 0.0
total 1418 2507 56.5


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 pattern) ... ;; esac
20             # test / [ ... ] (file, string, integer comparisons)
21             # cd, pwd, exit, true, false, :
22             # read VAR (reads one line from STDIN)
23             # shift [N] (shift positional parameters left)
24             # local VAR=value (function-scoped variable)
25             # $(( arithmetic )) -- +,-,*,/,%, and $1..$9 inside
26             # $( command ) and `command` (command substitution, nested)
27             # name() { ... }, function name { ... } (function definitions)
28             # cmd1 | cmd2 [| cmd3 ...] (pipeline via tmpfile, 5.005_03)
29             # cmd1 && cmd2, cmd1 || cmd2, cmd1 ; cmd2 (compound commands)
30             # > >> < 2> 2>> 2>&1 1>&2 (I/O redirection)
31             # $VAR, ${VAR}, $1..$9, $@, $*, $#, $?, $$, $0
32             # ${VAR:-def}, ${VAR:=def}, ${VAR:+alt}
33             # ${VAR%pat}, ${VAR%%pat} (shortest/longest suffix removal)
34             # ${VAR#pat}, ${VAR##pat} (shortest/longest prefix removal)
35             # ${VAR/pat/rep}, ${VAR//pat/rep} (first/all substitution)
36             # ${VAR^^}, ${VAR^}, ${VAR,,}, ${VAR,} (case conversion)
37             # ${VAR:N:L}, ${VAR:N} (substring)
38             # ${#VAR} (string length)
39             # source / . file
40             #
41             ######################################################################
42              
43 7     7   45 use strict;
  7         14  
  7         571  
44 7 50 33 7   1392 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
45 7     7   40 use warnings; local $^W = 1;
  7         12  
  7         745  
46 7 50   7   962 BEGIN { pop @INC if $INC[-1] eq '.' }
47              
48 7     7   33 use File::Spec ();
  7         11  
  7         152  
49 7     7   29 use Carp qw(croak);
  7         10  
  7         455  
50 7     7   33 use vars qw($VERSION);
  7         12  
  7         443  
51             $VERSION = '0.02';
52             $VERSION = $VERSION;
53              
54             # Bareword filehandle globs for SH pipeline (Perl 5.005_03 compatible)
55 7     7   33 use vars qw(*_SH_PIPE_SAVOUT *_SH_PIPE_SAVIN *_SH_PIPE_WFH *_SH_PIPE_RFH);
  7         11  
  7         341  
56              
57             # Bareword filehandle globs for SH I/O redirection (Perl 5.005_03 compatible)
58 7     7   1146 use vars qw(*_SH_REDIR_SRC *_SH_REDIR_DST *_SH_REDIR_SAVOUT *_SH_REDIR_SAVERR *_SH_REDIR_SAVIN);
  7         13  
  7         1990  
59              
60             # SH function registry -- must be package-level for access from _expand and _exec_line
61 7     7   33 use vars qw(%_SH_FUNCTIONS);
  7         11  
  7         118834  
62             # ----------------------------------------------------------------
63             my $LAST_STATUS = 0; # $?
64             my @FUNCTION_STACK = (); # for 'local' variable scoping
65              
66             # Signal: pending exit
67             my $_EXIT_CODE = undef; # undef = no exit pending
68             my $_BREAK = 0; # break out of loop
69             my $_CONTINUE = 0; # continue next iteration
70             my $_RETURN = 0; # return from function/source
71              
72             # ----------------------------------------------------------------
73             # Public: execute an array of SH lines
74             # Returns exit status (0 = success)
75             # ----------------------------------------------------------------
76             sub exec_block {
77 45     45 0 161 my ($class, $lines_ref, %opts) = @_;
78 45         63 $_EXIT_CODE = undef;
79 45         53 $_BREAK = 0;
80 45         58 $_CONTINUE = 0;
81 45         51 $_RETURN = 0;
82              
83 45         161 my $status = _run_lines($class, $lines_ref, \%opts);
84 45 50       442 return defined $_EXIT_CODE ? $_EXIT_CODE : $status;
85             }
86              
87             # ----------------------------------------------------------------
88             # Run an array of lines sequentially, handling multi-line blocks
89             # Returns last exit status
90             # ----------------------------------------------------------------
91             sub _run_lines {
92 92     92   150 my ($class, $lines_ref, $opts_ref) = @_;
93 92         112 my @lines = @{$lines_ref};
  92         187  
94 92         122 my $status = 0;
95 92         98 my $i = 0;
96              
97 92         178 while ($i <= $#lines) {
98 132 50       218 return $status if defined $_EXIT_CODE;
99 132 50 33     408 return $status if $_BREAK || $_RETURN;
100              
101 132         198 my $line = $lines[$i];
102 132         184 $i++;
103             # Normalise
104 132         219 $line =~ s/\r?\n\z//;
105             # Skip empty and comment lines
106 132 100       413 next if $line =~ /\A\s*\z/;
107 123 50       273 next if $line =~ /\A\s*#/;
108              
109             # Check for block-opening keywords
110 123         153 my $stripped = $line;
111 123         252 $stripped =~ s/\A\s+//;
112 123         168 my $first = '';
113 123         346 ($first) = ($stripped =~ /\A(\S+)/);
114 123 50       278 $first = lc(defined($first) ? $first : '');
115              
116 123 100       201 if ($first eq 'if') {
117 7         24 ($status, $i) = _parse_if($class, \@lines, $i - 1, $opts_ref);
118 7         15 next;
119             }
120 116 100       196 if ($first eq 'for') {
121 2         20 ($status, $i) = _parse_for($class, \@lines, $i - 1, $opts_ref);
122 2         8 next;
123             }
124 114 100 100     334 if ($first eq 'while' || $first eq 'until') {
125 2         8 ($status, $i) = _parse_while($class, \@lines, $i - 1, $opts_ref);
126 2         4 next;
127             }
128 112 100       176 if ($first eq 'case') {
129 1         5 ($status, $i) = _parse_case($class, \@lines, $i - 1, $opts_ref);
130 1         3 next;
131             }
132              
133             # Function definition: "name() {" or "function name {"
134 111 100       387 if ($stripped =~ /\A(?:function\s+[A-Za-z_]|[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\))/) {
135 6         42 ($status, $i) = _parse_function($class, \@lines, $i - 1, $opts_ref);
136 6         31 next;
137             }
138              
139 105         206 $status = _exec_line($class, $line, $opts_ref);
140 105 50       363 $_CONTINUE = 0 if $_CONTINUE;
141             }
142 92         230 return $status;
143             }
144              
145             # ----------------------------------------------------------------
146             # Execute one SH line
147             # ----------------------------------------------------------------
148             sub _exec_line {
149 121     121   210 my ($class, $raw, $opts_ref) = @_;
150              
151 121         175 my $line = $raw;
152 121         331 $line =~ s/\A\s+//;
153 121 50       350 return 0 if $line =~ /\A\s*\z/;
154 121 50       229 return 0 if $line =~ /\A\s*#/;
155              
156             # Shebang: treat as comment
157 121 50       226 return 0 if $line =~ /\A#!/;
158              
159             # Detect && / || / ; compound commands BEFORE expansion.
160             # These must be split before _expand so that short-circuit logic works.
161 121         280 my @compound = _split_sh_compound($line);
162 121 100       227 if (@compound > 1) {
163 5         13 return _exec_sh_compound($class, \@compound, $opts_ref);
164             }
165              
166             # Detect pipeline BEFORE variable expansion to avoid expanding
167             # pipe-like characters inside command substitutions prematurely.
168             # _split_sh_pipe returns >1 segment only when bare | is present.
169 116         231 my @pipe_segs = _split_sh_pipe($line);
170 116 100       204 if (@pipe_segs > 1) {
171 3         11 return _exec_sh_pipe($class, \@pipe_segs, $opts_ref);
172             }
173              
174             # Expand variables and command substitutions
175 113         304 $line = _expand($class, $line);
176              
177             # Strip trailing ;
178 113         195 $line =~ s/\s*;\s*\z//;
179              
180             # Detect I/O redirections: >, >>, <, 2>, 2>>, 2>&1
181             # Must be done after expansion so that variable-in-filename works.
182 113         249 my ($clean_line, $sh_redirs_ref) = _sh_strip_redirects($line);
183 113 50       183 if (@{$sh_redirs_ref}) {
  113         242  
184 0         0 return _sh_exec_with_redirs($class, $clean_line, $sh_redirs_ref, $opts_ref);
185             }
186 113         149 $line = $clean_line;
187              
188 113         237 my ($cmd, $rest) = _split_sh($line);
189 113 50 33     403 return 0 unless defined $cmd && $cmd ne '';
190              
191 113         192 my $lc_cmd = lc($cmd);
192              
193             # Simple assignment: VAR=value (no spaces around =)
194 113 100       309 if ($cmd =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
195 28         77 my ($var, $val) = ($1, $2); # capture before $1 is clobbered
196             # Strip outermost quotes from value
197 28         66 $val =~ s/\A"(.*)"\z/$1/s;
198 28         44 $val =~ s/\A'(.*)'\z/$1/s;
199 28         117 BATsh::Env->set($var, $val);
200 28         40 $LAST_STATUS = 0;
201 28         104 return 0;
202             }
203              
204 85 100       194 if ($lc_cmd eq 'export') { return _cmd_export($rest) }
  24         54  
205 61 100       103 if ($lc_cmd eq 'unset') { return _cmd_unset($rest) }
  1         3  
206 60 100       104 if ($lc_cmd eq 'echo') { return _cmd_echo($rest) }
  23         119  
207 37 50       80 if ($lc_cmd eq 'printf') { return _cmd_printf($rest) }
  0         0  
208 37 50       71 if ($lc_cmd eq 'cd') { return _cmd_cd($rest) }
  0         0  
209 37 50       64 if ($lc_cmd eq 'pwd') { print Cwd::cwd(), "\n"; return 0 }
  0         0  
  0         0  
210 37 50       71 if ($lc_cmd eq 'exit') { return _cmd_exit($rest) }
  0         0  
211 37 100       74 if ($lc_cmd eq 'true') { $LAST_STATUS = 0; return 0 }
  2         3  
  2         7  
212 35 100       83 if ($lc_cmd eq 'false') { $LAST_STATUS = 1; return 1 }
  4         7  
  4         14  
213 31 50       68 if ($lc_cmd eq ':') { $LAST_STATUS = 0; return 0 }
  0         0  
  0         0  
214 31 100       53 if ($lc_cmd eq 'read') { return _cmd_read($rest) }
  1         17  
215 30 100 66     157 if ($lc_cmd eq 'test' || $cmd eq '[') { return _cmd_test($rest) }
  16         32  
216 14 50 33     70 if ($lc_cmd eq 'source' || $cmd eq '.') { return _cmd_source($class, $rest, $opts_ref) }
  0         0  
217 14 0       35 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  
218 14 50       27 if ($lc_cmd eq 'break') { $_BREAK = 1; return 0 }
  0         0  
  0         0  
219 14 50       39 if ($lc_cmd eq 'continue') { $_CONTINUE = 1; return 0 }
  0         0  
  0         0  
220 14 100       26 if ($lc_cmd eq 'shift') { return _cmd_shift() }
  2         18  
221 12 100       29 if ($lc_cmd eq 'local') { return _cmd_local($rest) }
  2         25  
222 10 50       24 if ($lc_cmd eq 'set') { return _cmd_set_sh($rest) }
  0         0  
223              
224             # Defined SH function
225 10 100       43 if (exists $_SH_FUNCTIONS{$cmd}) {
226 6         23 return _call_sh_function($class, $cmd, $rest, $opts_ref);
227             }
228              
229             # Unknown: try as external (runs via Perl system)
230 4         16 return _cmd_external($cmd, $rest);
231             }
232              
233             # ----------------------------------------------------------------
234             # Variable / arithmetic expansion
235             # ----------------------------------------------------------------
236             sub _expand {
237 125     125   355 my ($class, $str) = @_;
238 125 50       233 return '' unless defined $str;
239              
240             # $(( arithmetic ))
241 125         362 $str =~ s/\$\(\(\s*(.*?)\s*\)\)/_eval_arith($1)/ge;
  12         43  
242              
243             # $( command ) substitution
244             # Use _extract_cmd_subst to correctly handle nested () and quoted ) chars.
245 125         270 $str = _replace_cmd_subst($class, $str);
246              
247             # backtick command substitution: `cmd`
248 125         310 $str =~ s/`([^`]*)`/_cmd_subst($class, $1)/ge;
  1         7  
249              
250             # ${#VAR} -- length of value
251 125         187 $str =~ s/\$\{#([A-Za-z_][A-Za-z0-9_]*)\}/
252 1 50       3 do { my $v = BATsh::Env->get($1); defined $v ? length($v) : 0 }
  1         7  
  1         7  
253             /ge;
254              
255             # ${VAR%%pattern} -- remove longest suffix (MUST be before single %)
256 125         199 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)%%([^}]*)\}/
257 1 50       3 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_suffix($v,$2,1) }
  1         7  
  1         13  
  1         14  
258             /ge;
259              
260             # ${VAR%pattern} -- remove shortest suffix (single %, not %%)
261 125         181 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)%(?!%)([^}]*)\}/
262 1 50       3 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_suffix($v,$2,0) }
  1         7  
  1         5  
  1         5  
263             /ge;
264              
265             # ${VAR##pattern} -- remove longest prefix (MUST be before single #)
266 125         148 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)##([^}]*)\}/
267 1 50       2 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_prefix($v,$2,1) }
  1         5  
  1         4  
  1         3  
268             /ge;
269              
270             # ${VAR#pattern} -- remove shortest prefix (single #, not ##)
271 125         178 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)#(?!#)([^}]*)\}/
272 1 50       20 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_remove_prefix($v,$2,0) }
  1         12  
  1         6  
  1         5  
273             /ge;
274              
275             # ${VAR//pat/rep} -- replace all occurrences (MUST be before single /)
276 125         182 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\/\/([^\/}]*)\/([^}]*)\}/
277 1 50       1 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_replace($v,$2,$3,1) }
  1         4  
  1         3  
  1         3  
278             /ge;
279              
280             # ${VAR/pat/rep} -- replace first occurrence (single /, not //)
281 125         206 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\/(?!\/)([^\/}]*)\/([^}]*)\}/
282 1 50       2 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; _sh_replace($v,$2,$3,0) }
  1         3  
  1         3  
  1         13  
283             /ge;
284              
285             # ${VAR^^} -- uppercase all
286 125         161 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\^\^\}/
287 1 50       2 do { my $v = BATsh::Env->get($1); defined $v ? uc($v) : '' }
  1         7  
  1         7  
288             /ge;
289              
290             # ${VAR^} -- uppercase first char
291 125         151 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\^\}/
292 0 0       0 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; ucfirst($v) }
  0         0  
  0         0  
  0         0  
293             /ge;
294              
295             # ${VAR,,} -- lowercase all
296 125         259 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*),,\}/
297 1 50       3 do { my $v = BATsh::Env->get($1); defined $v ? lc($v) : '' }
  1         7  
  1         8  
298             /ge;
299              
300             # ${VAR,} -- lowercase first char
301 125         164 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*),\}/
302 0 0       0 do { my $v = BATsh::Env->get($1); $v = defined $v ? $v : ''; lcfirst($v) }
  0         0  
  0         0  
  0         0  
303             /ge;
304              
305             # ${VAR:offset:length} and ${VAR:offset}
306 125         220 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):(-?\d+):(\d+)\}/
307 1         4 do {
308 1 50       7 my $v = BATsh::Env->get($1); $v = defined $v ? $v : '';
  1         5  
309 1         4 my $off = int($2); my $len = int($3);
  1         12  
310 1 50       4 $off = length($v) + $off if $off < 0;
311 1 50       3 $off = 0 if $off < 0;
312 1         6 substr($v, $off, $len)
313             }
314             /ge;
315 125         155 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):(-?\d+)\}/
316 0         0 do {
317 0 0       0 my $v = BATsh::Env->get($1); $v = defined $v ? $v : '';
  0         0  
318 0         0 my $off = int($2);
319 0 0       0 $off = length($v) + $off if $off < 0;
320 0 0       0 $off = 0 if $off < 0;
321 0         0 substr($v, $off)
322             }
323             /ge;
324              
325             # ${VAR:-default} ${VAR:=default} ${VAR:+alt}
326 125         171 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):-(.*?)\}/
327 2 50 33     4 do { my $v = BATsh::Env->get($1); (defined $v && $v ne '') ? $v : $2 }
  2         14  
  2         22  
328             /ge;
329 125         169 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):=(.*?)\}/
330 0         0 do {
331 0         0 my $v = BATsh::Env->get($1);
332 0 0 0     0 if (!defined $v || $v eq '') { BATsh::Env->set($1,$2); $v = $2 }
  0         0  
  0         0  
333             $v
334 0         0 }
335             /ge;
336 125         147 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):\+([^}]*)\}/
337 0 0 0     0 do { my $v = BATsh::Env->get($1); (defined $v && $v ne '') ? $2 : '' }
  0         0  
  0         0  
338             /ge;
339              
340             # ${VAR} -- plain expansion
341 125         198 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
342 13 50       13 do { my $v = BATsh::Env->get($1); defined $v ? $v : '' }
  13         35  
  13         49  
343             /ge;
344              
345             # $? last status
346 125         154 $str =~ s/\$\?/$LAST_STATUS/g;
347              
348             # $$ PID
349 125         200 $str =~ s/\$\$/$$/g;
350              
351              
352             # $0 script name
353 125 0       181 $str =~ s/\$0/do { my $v=BATsh::Env->get('%0'); defined $v ? $v : '' }/ge;
  0         0  
  0         0  
  0         0  
354              
355             # $1..$9 positional parameters
356 125         265 $str =~ s/\$([1-9])/
357 5         12 do {
358 5         14 my $n = $1;
359 5         30 my $v = BATsh::Env->get("%$n");
360 5 50 33     40 $v = BATsh::Env->get("BATSH_ARG$n") unless defined $v && $v ne '';
361 5 50       37 defined $v ? $v : ''
362             }
363             /ge;
364              
365             # $@ and $* all positional parameters
366 125 0       163 $str =~ s/\$\@/do { my $v=BATsh::Env->get('%*'); defined $v ? $v : '' }/ge;
  0         0  
  0         0  
  0         0  
367              
368             # $# number of positional parameters
369 125         169 $str =~ s/\$#/
370 0         0 do {
371 0         0 my $c = 0;
372 0         0 for my $nn (1..9) {
373 0         0 my $vv = BATsh::Env->get("%$nn");
374 0 0 0     0 $vv = BATsh::Env->get("BATSH_ARG$nn") unless defined $vv && $vv ne '';
375 0 0 0     0 last unless defined $vv && $vv ne '';
376 0         0 $c = $nn;
377             }
378             $c
379 0         0 }
380             /ge;
381              
382             # $VAR
383 125         355 $str =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/
384 32 50       41 do { my $v = BATsh::Env->get($1); defined $v ? $v : '' }
  32         122  
  32         112  
385             /ge;
386              
387 125         288 return $str;
388             }
389              
390             # ----------------------------------------------------------------
391             # Arithmetic evaluator
392             # ----------------------------------------------------------------
393             sub _eval_arith {
394 12     12   33 my ($expr) = @_;
395             # Expand $1..$9 positional params before further processing
396 12         35 $expr =~ s/\$([1-9])/_arith_pos($1)/ge;
  5         12  
397             # Expand $VAR names with numeric values
398 12         23 $expr =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/_arith_var($1)/ge;
  0         0  
399             # Replace bare VAR names with numeric values
400 12         34 $expr =~ s/([A-Za-z_][A-Za-z0-9_]*)/_arith_var($1)/ge;
  8         17  
401             # Safe eval: digits, operators, parens, spaces only
402 12 50       54 if ($expr =~ /\A[\d\s\+\-\*\/\%\(\)]+\z/) {
403 12         884 my $result = eval $expr;
404 12 50       77 return defined $result ? int($result) : 0;
405             }
406 0         0 return 0;
407             }
408              
409             sub _arith_pos {
410 5     5   11 my ($n) = @_;
411 5         19 my $v = BATsh::Env->get("%$n");
412 5 50 33     53 $v = BATsh::Env->get("BATSH_ARG$n") unless defined $v && $v ne '';
413 5 50 33     42 return (defined $v && $v =~ /\A-?\d+\z/) ? $v : 0;
414             }
415              
416             sub _arith_var {
417 8     8   15 my ($name) = @_;
418 8         29 my $v = BATsh::Env->get($name);
419 8 50 33     60 return (defined $v && $v =~ /\A-?\d+\z/) ? $v : 0;
420             }
421              
422             # ----------------------------------------------------------------
423             # Command substitution $( cmd )
424             # ----------------------------------------------------------------
425             # _replace_cmd_subst: replace all $(...) in $str with their output.
426             # Unlike a simple [^)]* regex, this function tracks nesting depth
427             # and quoted strings so that $(cmd | perl -e "...)" works correctly.
428             # ----------------------------------------------------------------
429             sub _replace_cmd_subst {
430 125     125   332 my ($class, $str) = @_;
431 125 50       229 return '' unless defined $str;
432              
433 125         190 my $result = '';
434 125         357 my @chars = split //, $str;
435 125         149 my $n = scalar @chars;
436 125         143 my $i = 0;
437              
438 125         193 while ($i < $n) {
439 1520         1684 my $ch = $chars[$i];
440              
441             # $( ... ) -- find matching close paren respecting nesting and quotes
442 1520 50 66     2399 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      66        
443 0         0 $i += 2; # skip $(
444 0         0 my $depth = 1;
445 0         0 my $body = '';
446 0         0 my $in_sq = 0;
447 0         0 my $in_dq = 0;
448              
449 0   0     0 while ($i < $n && $depth > 0) {
450 0         0 my $c = $chars[$i];
451              
452 0 0       0 if ($in_sq) {
453 0 0       0 if ($c eq "'") { $in_sq = 0 }
  0         0  
454 0         0 $body .= $c; $i++; next;
  0         0  
  0         0  
455             }
456 0 0 0     0 if ($c eq "'" && !$in_dq) {
457 0         0 $in_sq = 1; $body .= $c; $i++; next;
  0         0  
  0         0  
  0         0  
458             }
459 0 0 0     0 if ($c eq '"' && !$in_sq) {
460 0         0 $in_dq = !$in_dq; $body .= $c; $i++; next;
  0         0  
  0         0  
  0         0  
461             }
462 0 0       0 if ($in_dq) {
463 0 0       0 if ($c eq '\\') {
464 0         0 $body .= $c; $i++;
  0         0  
465 0 0       0 $body .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
466             }
467 0         0 $body .= $c; $i++; next;
  0         0  
  0         0  
468             }
469 0 0       0 if ($c eq '\\') {
470 0         0 $body .= $c; $i++;
  0         0  
471 0 0       0 $body .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
472             }
473 0 0       0 if ($c eq '(') { $depth++; $body .= $c; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
474 0 0       0 if ($c eq ')') {
475 0         0 $depth--;
476 0 0       0 if ($depth == 0) { $i++; last } # closing )
  0         0  
  0         0  
477 0         0 $body .= $c; $i++; next;
  0         0  
  0         0  
478             }
479 0         0 $body .= $c; $i++;
  0         0  
480             }
481              
482 0         0 $result .= _cmd_subst($class, $body);
483 0         0 next;
484             }
485              
486 1520         1552 $result .= $ch; $i++;
  1520         1975  
487             }
488              
489 125         353 return $result;
490             }
491              
492             # ----------------------------------------------------------------
493             sub _cmd_subst {
494 1     1   7 my ($class, $cmd_str) = @_;
495             # Capture stdout via temporary file (Perl 5.005_03 compatible).
496             # We use _run_lines so that all BATsh::SH builtins, functions,
497             # and pipelines work recursively inside $(...) and `...`.
498 1         43 my $tmpfile = File::Spec->catfile(
499             File::Spec->tmpdir(), "batsh_cap_$$.tmp");
500 1         6 local *_SUBST_SAVOUT;
501 1 50       42 open(_SUBST_SAVOUT, '>&STDOUT') or return '';
502 1         11 local *_SUBST_CAPFH;
503             open(_SUBST_CAPFH, "> $tmpfile")
504 1 50       273 or do { open(STDOUT, '>&_SUBST_SAVOUT'); return '' };
  0         0  
  0         0  
505             open(STDOUT, '>&_SUBST_CAPFH')
506 1 50       42 or do { close(_SUBST_CAPFH); open(STDOUT, '>&_SUBST_SAVOUT'); return '' };
  0         0  
  0         0  
  0         0  
507 1         7 close(_SUBST_CAPFH);
508 1         5 eval {
509             # Use _run_lines for full recursive BATsh::SH execution.
510             # $cmd_str may contain pipes, builtins, functions, etc.
511 1         5 my @sub_lines = split /\n/, $cmd_str;
512 1         5 _run_lines($class, \@sub_lines, {});
513             };
514 1         131 open(STDOUT, '>&_SUBST_SAVOUT');
515 1         8 close(_SUBST_SAVOUT);
516 1         5 my $output = '';
517 1         3 local *_SUBST_READFH;
518 1 50       46 if (open(_SUBST_READFH, "< $tmpfile")) {
519 1         6 local $/;
520 1         37 $output = <_SUBST_READFH>;
521 1         14 close(_SUBST_READFH);
522             }
523 1         128 unlink $tmpfile;
524 1 50       9 $output = '' unless defined $output;
525 1         18 $output =~ s/\n+\z//; # strip trailing newlines (like shell)
526 1         11 return $output;
527             }
528              
529             # ----------------------------------------------------------------
530             # export
531             # ----------------------------------------------------------------
532             sub _cmd_export {
533 24     24   31 my ($rest) = @_;
534 24         40 $rest =~ s/\A\s+//;
535             # export -p: print all
536 24 50       44 if ($rest =~ /\A-p\s*\z/) {
537 0         0 for my $k (sort keys %BATsh::Env::STORE) {
538 0         0 my $v = $BATsh::Env::STORE{$k};
539 0         0 $v =~ s/'/'\\''/g;
540 0         0 print "export $k='$v'\n";
541             }
542 0         0 return 0;
543             }
544             # export VAR=value or export VAR
545 24         47 for my $item (split /\s+/, $rest) {
546 24 100       85 if ($item =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
    50          
547 23         72 BATsh::Env->set($1, $2);
548             }
549             elsif ($item =~ /\A([A-Za-z_][A-Za-z0-9_]*)\z/) {
550             # export existing variable (already in store; no-op)
551             }
552             }
553 24         31 $LAST_STATUS = 0;
554 24         90 return 0;
555             }
556              
557             # ----------------------------------------------------------------
558             # unset
559             # ----------------------------------------------------------------
560             sub _cmd_unset {
561 1     1   1 my ($rest) = @_;
562 1         2 for my $var (split /\s+/, $rest) {
563 1         3 $var =~ s/\A\s+//; $var =~ s/\s+\z//;
  1         2  
564 1 50       5 BATsh::Env->unset($var) if $var ne '';
565             }
566 1         2 $LAST_STATUS = 0;
567 1         3 return 0;
568             }
569              
570             # ----------------------------------------------------------------
571             # echo
572             # ----------------------------------------------------------------
573             sub _cmd_echo {
574 23     23   39 my ($rest) = @_;
575 23         76 $rest =~ s/\A\s+//;
576 23         34 my $no_newline = 0;
577 23 50       50 if ($rest =~ s/\A-n\s*//) { $no_newline = 1 }
  0         0  
578             # -e: enable escape sequences
579 23         95 my $escape = 0;
580 23 50       44 if ($rest =~ s/\A-e\s*//) { $escape = 1 }
  0         0  
581 23 50       54 if ($escape) {
582 0         0 $rest =~ s/\\n/\n/g;
583 0         0 $rest =~ s/\\t/\t/g;
584 0         0 $rest =~ s/\\r/\r/g;
585 0         0 $rest =~ s/\\\\/\\/g;
586             }
587             # Strip surrounding quotes
588 23         50 $rest =~ s/\A"(.*)"\z/$1/s;
589 23         32 $rest =~ s/\A'(.*)'\z/$1/s;
590 23 50       47 if ($no_newline) { print $rest }
  0         0  
591 23         72 else { print "$rest\n" }
592 23         29 $LAST_STATUS = 0;
593 23         105 return 0;
594             }
595              
596             # ----------------------------------------------------------------
597             # printf
598             # ----------------------------------------------------------------
599             sub _cmd_printf {
600 0     0   0 my ($rest) = @_;
601 0         0 $rest =~ s/\A\s+//;
602             # Extract format string (first quoted arg or first word)
603 0         0 my ($fmt, @args);
604 0 0       0 if ($rest =~ s/\A"((?:[^"\\]|\\.)*)"\s*//) {
    0          
605 0         0 $fmt = $1;
606             }
607             elsif ($rest =~ s/\A'([^']*)'\s*//) {
608 0         0 $fmt = $1;
609             }
610             else {
611 0         0 ($fmt, $rest) = split /\s+/, $rest, 2;
612 0 0       0 $rest = '' unless defined $rest;
613             }
614 0         0 @args = split /\s+/, $rest;
615 0         0 $fmt =~ s/\\n/\n/g;
616 0         0 $fmt =~ s/\\t/\t/g;
617 0         0 eval { printf $fmt, @args };
  0         0  
618 0         0 $LAST_STATUS = 0;
619 0         0 return 0;
620             }
621              
622             # ----------------------------------------------------------------
623             # cd
624             # ----------------------------------------------------------------
625             sub _cmd_cd {
626 0     0   0 my ($rest) = @_;
627 0         0 $rest =~ s/\A\s+//;
628 0         0 $rest =~ s/\s+\z//;
629 0 0 0     0 if ($rest eq '' || $rest eq '~') {
630 0   0     0 $rest = $ENV{'HOME'} || BATsh::Env->get('HOME') || '.';
631             }
632 0 0       0 unless (chdir($rest)) {
633 0         0 print STDERR "cd: $rest: No such file or directory\n";
634 0         0 $LAST_STATUS = 1;
635 0         0 return 1;
636             }
637 0         0 BATsh::Env->set('PWD', Cwd::cwd());
638 0         0 $LAST_STATUS = 0;
639 0         0 return 0;
640             }
641              
642             # ----------------------------------------------------------------
643             # exit
644             # ----------------------------------------------------------------
645             sub _cmd_exit {
646 0     0   0 my ($rest) = @_;
647 0         0 $rest =~ s/\A\s+//;
648 0 0       0 my $code = ($rest =~ /\A(\d+)/) ? int($1) : 0;
649 0         0 $_EXIT_CODE = $code;
650 0         0 $LAST_STATUS = $code;
651 0         0 return $code;
652             }
653              
654             # ----------------------------------------------------------------
655             # read
656             # ----------------------------------------------------------------
657             sub _cmd_read {
658 1     1   4 my ($rest) = @_;
659 1         12 $rest =~ s/\A\s+//;
660 1         8 $rest =~ s/\s+\z//;
661 1         32 my $line = ;
662 1 50       11 $line = '' unless defined $line;
663 1         3 chomp $line;
664 1         8 my @vars = split /\s+/, $rest;
665 1 50       5 if (@vars == 1) {
    0          
666 1         56 BATsh::Env->set($vars[0], $line);
667             }
668             elsif (@vars > 1) {
669 0         0 my @words = split /\s+/, $line, scalar(@vars);
670 0         0 for my $i (0 .. $#vars) {
671 0 0       0 BATsh::Env->set($vars[$i], defined($words[$i]) ? $words[$i] : '');
672             }
673             }
674 1         4 $LAST_STATUS = 0;
675 1         7 return 0;
676             }
677              
678             # ----------------------------------------------------------------
679             # shift
680             # ----------------------------------------------------------------
681             sub _cmd_shift {
682 2     2   8 my ($rest) = @_;
683 2 50       7 $rest = '' unless defined $rest;
684 2         9 $rest =~ s/\A\s+//;
685              
686             # Optional /N offset (bash: shift N shifts N positions)
687 2         4 my $n_shift = 1;
688 2 0       10 if ($rest =~ /\A(\d+)\s*\z/) { $n_shift = int($1); $n_shift = 1 if $n_shift < 1 }
  0 50       0  
  0         0  
689              
690 2         11 for my $step (1 .. $n_shift) {
691             # Shift BATSH_ARG* (legacy)
692 2         12 for my $n (1 .. 8) {
693 16         36 my $next = BATsh::Env->get('BATSH_ARG' . ($n + 1));
694 16 50       43 BATsh::Env->set('BATSH_ARG' . $n, defined($next) ? $next : '');
695             }
696 2         7 BATsh::Env->set('BATSH_ARG9', '');
697              
698             # Shift %1..%9 (used by _expand $1..$9)
699 2         4 for my $n (1 .. 8) {
700 16         31 my $next = BATsh::Env->get('%' . ($n + 1));
701 16 50       35 BATsh::Env->set('%' . $n, defined($next) ? $next : '');
702             }
703 2         6 BATsh::Env->set('%9', '');
704              
705             # Rebuild %*
706 2         3 my @args;
707 2         6 for my $n (1 .. 9) {
708 6         16 my $v = BATsh::Env->get("%$n");
709 6 100 66     34 last unless defined $v && $v ne '';
710 4         6 push @args, $v;
711             }
712 2         7 BATsh::Env->set('%*', join(' ', @args));
713             }
714 2         3 $LAST_STATUS = 0;
715 2         9 return 0;
716             }
717              
718             # ----------------------------------------------------------------
719             # local
720             # ----------------------------------------------------------------
721             sub _cmd_local {
722 2     2   14 my ($rest) = @_;
723 2         16 $rest =~ s/\A\s+//;
724              
725 2         9 my ($var, $val);
726 2 100       26 if ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
    50          
727 1         5 ($var, $val) = ($1, $2);
728             # Strip surrounding quotes from value
729 1         8 $val =~ s/\A"(.*)"\z/$1/s;
730 1         9 $val =~ s/\A'(.*)'\z/$1/s;
731             }
732             elsif ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
733 1         3 $var = $1;
734 1         20 $val = BATsh::Env->get($var);
735 1 50       4 $val = '' unless defined $val;
736             }
737             else {
738 0         0 $LAST_STATUS = 0;
739 0         0 return 0;
740             }
741              
742             # Save old value in innermost function scope so it can be restored on return
743 2 50       9 if (@FUNCTION_STACK) {
744 2         10 my $frame = $FUNCTION_STACK[-1];
745             # Only save once per variable per frame (first local declaration wins)
746 2 50       14 unless (exists $frame->{$var}) {
747 2         15 my $old = BATsh::Env->get($var);
748 2 50       15 $frame->{$var} = defined $old ? $old : undef;
749             }
750             }
751 2         14 BATsh::Env->set($var, $val);
752 2         4 $LAST_STATUS = 0;
753 2         16 return 0;
754             }
755              
756             # ----------------------------------------------------------------
757             # set (sh set options -- minimal implementation)
758             # ----------------------------------------------------------------
759             sub _cmd_set_sh {
760 0     0   0 my ($rest) = @_;
761 0         0 $rest =~ s/\A\s+//;
762             # set -e, set +e, set -x, set +x: accepted silently
763 0         0 $LAST_STATUS = 0;
764 0         0 return 0;
765             }
766              
767             # ----------------------------------------------------------------
768             # source / .
769             # ----------------------------------------------------------------
770             sub _cmd_source {
771 0     0   0 my ($class, $rest, $opts_ref) = @_;
772 0         0 $rest =~ s/\A\s+//;
773 0         0 $rest =~ s/\s+\z//;
774 0 0       0 if (defined $opts_ref->{'_batsh'}) {
775 0         0 eval { $opts_ref->{'_batsh'}->source_file($rest) };
  0         0  
776 0 0       0 if ($@) { print STDERR "source: $rest: $@\n"; return 1 }
  0         0  
  0         0  
777             }
778 0         0 return 0;
779             }
780              
781             # ----------------------------------------------------------------
782             # test / [ ]
783             # ----------------------------------------------------------------
784             sub _cmd_test {
785 16     16   22 my ($rest) = @_;
786 16         26 $rest =~ s/\A\s+//;
787 16         70 $rest =~ s/\s*\]\s*\z//; # strip trailing ]
788 16         26 my $result = _eval_test($rest);
789 16 100       26 $LAST_STATUS = $result ? 0 : 1;
790 16         55 return $LAST_STATUS;
791             }
792              
793             sub _eval_test {
794 16     16   37 my ($expr) = @_;
795 16         25 $expr =~ s/\A\s+//;
796 16         31 $expr =~ s/\s+\z//;
797              
798             # Compound: -a (AND), -o (OR)
799 16 50       29 if ($expr =~ /^(.*)\s+-a\s+(.*)$/) {
800 0   0     0 return _eval_test($1) && _eval_test($2);
801             }
802 16 50       25 if ($expr =~ /^(.*)\s+-o\s+(.*)$/) {
803 0   0     0 return _eval_test($1) || _eval_test($2);
804             }
805             # Negation
806 16 50       37 if ($expr =~ /^!\s+(.*)$/) {
807 0         0 return !_eval_test($1);
808             }
809              
810             # File tests
811 16 100       26 if ($expr =~ /\A(-[a-z])\s+(.+)\z/) {
812 2         4 my ($op, $path) = ($1, $2);
813 2         5 $path =~ s/\A"//; $path =~ s/"\z//;
  2         18  
814 2 0       6 if ($op eq '-e') { return -e $path ? 1 : 0 }
  0 50       0  
815 2 0       4 if ($op eq '-f') { return -f $path ? 1 : 0 }
  0 50       0  
816 2 0       3 if ($op eq '-d') { return -d $path ? 1 : 0 }
  0 50       0  
817 2 0       4 if ($op eq '-r') { return -r $path ? 1 : 0 }
  0 50       0  
818 2 0       4 if ($op eq '-w') { return -w $path ? 1 : 0 }
  0 50       0  
819 2 0       3 if ($op eq '-x') { return -x $path ? 1 : 0 }
  0 50       0  
820 2 0       3 if ($op eq '-s') { return (-s $path) ? 1 : 0 }
  0 50       0  
821 2 50 33     3 if ($op eq '-z') { my $s = -s $path; return (!defined $s || $s == 0) ? 1 : 0 }
  1 100       10  
  1         5  
822 1 50       3 if ($op eq '-n') { return (length($path) > 0) ? 1 : 0 }
  1 50       4  
823 0 0       0 if ($op eq '-L') { return -l $path ? 1 : 0 }
  0 0       0  
824             }
825              
826             # String comparisons: = == != < >
827 14 50       52 if ($expr =~ /\A(.+?)\s+(=|==|!=|<|>)\s+(.+)\z/) {
828 0         0 my ($a, $op, $b) = ($1, $2, $3);
829 0         0 $a =~ s/\A"//; $a =~ s/"\z//;
  0         0  
830 0         0 $b =~ s/\A"//; $b =~ s/"\z//;
  0         0  
831 0 0 0     0 if ($op eq '=' || $op eq '==') { return ($a eq $b) ? 1 : 0 }
  0 0       0  
832 0 0       0 if ($op eq '!=') { return ($a ne $b) ? 1 : 0 }
  0 0       0  
833 0 0       0 if ($op eq '<') { return ($a lt $b) ? 1 : 0 }
  0 0       0  
834 0 0       0 if ($op eq '>') { return ($a gt $b) ? 1 : 0 }
  0 0       0  
835             }
836              
837             # Integer comparisons: -eq -ne -lt -le -gt -ge
838 14 50       52 if ($expr =~ /\A(.+?)\s+(-eq|-ne|-lt|-le|-gt|-ge)\s+(.+)\z/) {
839 14         47 my ($a, $op, $b) = ($1, $2, $3);
840 14         20 $a =~ s/\A"//; $a =~ s/"\z//;
  14         18  
841 14         19 $b =~ s/\A"//; $b =~ s/"\z//;
  14         18  
842 14 50       45 $a = int($a) if $a =~ /\A-?\d+\z/;
843 14 50       33 $b = int($b) if $b =~ /\A-?\d+\z/;
844 14 100       27 if ($op eq '-eq') { return ($a == $b) ? 1 : 0 }
  2 100       6  
845 12 0       23 if ($op eq '-ne') { return ($a != $b) ? 1 : 0 }
  0 50       0  
846 12 100       20 if ($op eq '-lt') { return ($a < $b) ? 1 : 0 }
  6 100       19  
847 6 0       9 if ($op eq '-le') { return ($a <= $b) ? 1 : 0 }
  0 50       0  
848 6 50       12 if ($op eq '-gt') { return ($a > $b) ? 1 : 0 }
  2 100       7  
849 4 100       7 if ($op eq '-ge') { return ($a >= $b) ? 1 : 0 }
  4 50       8  
850             }
851              
852             # -n string (non-empty)
853 0 0       0 if ($expr =~ /\A-n\s+(.+)\z/) {
854 0         0 my $s = $1; $s =~ s/\A"//; $s =~ s/"\z//;
  0         0  
  0         0  
855 0 0       0 return length($s) > 0 ? 1 : 0;
856             }
857             # -z string (empty)
858 0 0       0 if ($expr =~ /\A-z\s+(.+)\z/) {
859 0         0 my $s = $1; $s =~ s/\A"//; $s =~ s/"\z//;
  0         0  
  0         0  
860 0 0       0 return length($s) == 0 ? 1 : 0;
861             }
862              
863             # bare string: true if non-empty
864 0         0 $expr =~ s/\A"//; $expr =~ s/"\z//;
  0         0  
865 0 0 0     0 return (length($expr) > 0 && $expr ne '0') ? 1 : 0;
866             }
867              
868             # ----------------------------------------------------------------
869             # if/then/else/elif/fi parser
870             # ----------------------------------------------------------------
871             sub _parse_if {
872 7     7   15 my ($class, $lines_ref, $start, $opts_ref) = @_;
873 7         9 my @lines = @{$lines_ref};
  7         17  
874 7         9 my $i = $start;
875              
876             # Collect: if cond; then ... [elif cond; then ...] [else ...] fi
877             # Build a structure: [ ['cond_lines'], ['body_lines'] ] ...
878 7         10 my @branches = (); # [ [$cond_lines], [$body_lines] ]
879 7         11 my $else_body = undef;
880              
881             # First line: if cond; then
882 7         23 my $if_line = $lines[$i]; $i++;
  7         9  
883 7         10 $if_line =~ s/\r?\n\z//; $if_line =~ s/\A\s+//;
  7         13  
884              
885             # Extract condition (after 'if', before 'then' or ';')
886 7         10 my $cond_str = $if_line;
887 7         46 $cond_str =~ s/\Aif\s+//i;
888              
889             # 1-line form: if COND; then BODY [; BODY ...]; fi
890             # Detect by presence of "; then " and trailing "; fi" on the same line
891 7 50       19 if ($cond_str =~ /\A(.+?)\s*;\s*then\s+(.+?)\s*;\s*fi\s*\z/i) {
892 0         0 my ($cond_part, $body_part) = ($1, $2);
893 0         0 my $cond_status = _run_lines($class, [$cond_part], $opts_ref);
894 0 0       0 if ($cond_status == 0) {
895 0         0 _run_lines($class, [split /\s*;\s*/, $body_part], $opts_ref);
896             }
897 0         0 return ($cond_status, $i);
898             }
899              
900 7         57 $cond_str =~ s/\s*;\s*then\s*\z//i;
901 7         21 $cond_str =~ s/\s+then\s*\z//i;
902              
903 7         14 my @cond_lines = ($cond_str);
904 7         10 my @body_lines = ();
905 7         10 my $state = 'body'; # reading body of if
906              
907 7         16 while ($i <= $#lines) {
908 16         22 my $l = $lines[$i]; $i++;
  16         16  
909 16         21 $l =~ s/\r?\n\z//;
910 16         35 my $ls = $l; $ls =~ s/\A\s+//;
  16         34  
911 16 50       45 my $lc_first = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
912              
913 16 100       47 if ($lc_first eq 'fi') {
    100          
    100          
    50          
914 4         9 push @branches, [ [@cond_lines], [@body_lines] ];
915 4         8 last;
916             }
917             elsif ($lc_first eq 'elif') {
918 1         3 push @branches, [ [@cond_lines], [@body_lines] ];
919 1         2 $cond_str = $ls;
920 1         3 $cond_str =~ s/\Aelif\s+//i;
921 1         3 $cond_str =~ s/\s*;\s*then\s*\z//i;
922 1         3 $cond_str =~ s/\s+then\s*\z//i;
923 1         2 @cond_lines = ($cond_str);
924 1         3 @body_lines = ();
925             }
926             elsif ($lc_first eq 'else') {
927 3         23 push @branches, [ [@cond_lines], [@body_lines] ];
928 3         8 @body_lines = ();
929             # Read until fi
930 3         11 while ($i <= $#lines) {
931 6         9 my $el = $lines[$i]; $i++;
  6         10  
932 6         11 $el =~ s/\r?\n\z//;
933 6         9 my $els = $el; $els =~ s/\A\s+//;
  6         31  
934 6 50       27 if (lc(($els =~ /\A(\S+)/) ? $1 : '') eq 'fi') { last }
  3 100       7  
935 3         7 push @body_lines, $el;
936             }
937 3         23 $else_body = [@body_lines];
938 3         7 last;
939             }
940             elsif ($lc_first eq 'then') {
941             # 'then' on its own line: continue collecting body
942 0         0 next;
943             }
944             else {
945 8         19 push @body_lines, $l;
946             }
947             }
948              
949             # Evaluate branches
950 7         8 my $status = 0;
951 7         10 my $executed = 0;
952 7         19 for my $branch (@branches) {
953 8         9 my ($cond_ref, $body_ref) = @{$branch};
  8         16  
954 8         14 my $cond_status = _run_lines($class, $cond_ref, $opts_ref);
955 8 100       17 if ($cond_status == 0) {
956 6         13 $status = _run_lines($class, $body_ref, $opts_ref);
957 6         9 $executed = 1;
958 6         12 last;
959             }
960             }
961 7 100 66     21 if (!$executed && defined $else_body) {
962 1         3 $status = _run_lines($class, $else_body, $opts_ref);
963             }
964              
965 7         28 return ($status, $i);
966             }
967              
968             # ----------------------------------------------------------------
969             # for VAR in list; do ... done
970             # ----------------------------------------------------------------
971             sub _parse_for {
972 2     2   5 my ($class, $lines_ref, $start, $opts_ref) = @_;
973 2         3 my @lines = @{$lines_ref};
  2         5  
974 2         2 my $i = $start;
975              
976 2         4 my $for_line = $lines[$i]; $i++;
  2         9  
977 2         4 $for_line =~ s/\r?\n\z//; $for_line =~ s/\A\s+//;
  2         4  
978              
979             # for VAR in item1 item2 ...; do
980 2         5 my ($var, $list_str) = ('', '');
981 2 50       18 if ($for_line =~ /\Afor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*?)\s*(?:;\s*do)?\s*\z/i) {
982 2         6 ($var, $list_str) = ($1, $2);
983             }
984              
985             # Collect body until 'done'
986 2         3 my @body = ();
987 2         3 my $depth = 1;
988 2         17 while ($i <= $#lines) {
989 4         14 my $l = $lines[$i]; $i++;
  4         5  
990 4         5 $l =~ s/\r?\n\z//;
991 4         4 my $ls = $l; $ls =~ s/\A\s+//;
  4         8  
992 4 50       16 my $lc_f = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
993 4 50 33     16 if ($lc_f eq 'for' || $lc_f eq 'while' || $lc_f eq 'until') { $depth++ }
  0   33     0  
994 4 50       12 if ($lc_f eq 'done') { $depth--; last if $depth == 0 }
  2 100       3  
  2         10  
995 2 50 33     8 push @body, $l unless ($lc_f eq 'do' && $depth == 1);
996             }
997              
998             # Expand list items
999 2         6 my @items = split /\s+/, $list_str;
1000 2         2 my $status = 0;
1001 2         5 for my $val (@items) {
1002 6         15 BATsh::Env->set($var, $val);
1003 6         8 $_BREAK = 0; $_CONTINUE = 0;
  6         7  
1004 6         21 $status = _run_lines($class, \@body, $opts_ref);
1005 6 50 33     23 last if $_BREAK || defined $_EXIT_CODE;
1006             }
1007 2         5 $_BREAK = 0;
1008              
1009 2         27 return ($status, $i);
1010             }
1011              
1012             # ----------------------------------------------------------------
1013             # while/until condition; do ... done
1014             # ----------------------------------------------------------------
1015             sub _parse_while {
1016 2     2   6 my ($class, $lines_ref, $start, $opts_ref) = @_;
1017 2         3 my @lines = @{$lines_ref};
  2         42  
1018 2         5 my $i = $start;
1019              
1020 2         4 my $while_line = $lines[$i]; $i++;
  2         3  
1021 2         5 $while_line =~ s/\r?\n\z//; $while_line =~ s/\A\s+//;
  2         22  
1022              
1023 2 100       10 my $is_until = ($while_line =~ /\Auntil\s/i) ? 1 : 0;
1024              
1025             # Extract condition
1026 2         10 my $cond_str = $while_line;
1027 2         10 $cond_str =~ s/\A(?:while|until)\s+//i;
1028 2         13 $cond_str =~ s/\s*;\s*do\s*\z//i;
1029 2         7 $cond_str =~ s/\s+do\s*\z//i;
1030              
1031             # Collect body
1032 2         4 my @body = ();
1033 2         4 my $depth = 1;
1034 2         6 while ($i <= $#lines) {
1035 4         16 my $l = $lines[$i]; $i++;
  4         6  
1036 4         7 $l =~ s/\r?\n\z//;
1037 4         7 my $ls = $l; $ls =~ s/\A\s+//;
  4         8  
1038 4 50       15 my $lc_f = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
1039 4 50 33     21 if ($lc_f eq 'for' || $lc_f eq 'while' || $lc_f eq 'until') { $depth++ }
  0   33     0  
1040 4 50       9 if ($lc_f eq 'done') { $depth--; last if $depth == 0 }
  2 100       4  
  2         8  
1041 2 50 33     35 push @body, $l unless ($lc_f eq 'do' && $depth == 1);
1042             }
1043              
1044 2         3 my $status = 0;
1045 2         3 my $max_iter = 100_000; # safety guard
1046 2         6 while ($max_iter-- > 0) {
1047 10 50       20 last if defined $_EXIT_CODE;
1048 10         24 my $cond_status = _run_lines($class, [$cond_str], $opts_ref);
1049 10         18 my $cond_true = ($cond_status == 0);
1050 10 100 100     26 last if $is_until && $cond_true;
1051 9 100 100     36 last if !$is_until && !$cond_true;
1052 8         9 $_BREAK = 0; $_CONTINUE = 0;
  8         8  
1053 8         17 $status = _run_lines($class, \@body, $opts_ref);
1054 8 50       24 last if $_BREAK;
1055             }
1056 2         4 $_BREAK = 0;
1057              
1058 2         7 return ($status, $i);
1059             }
1060              
1061             # ----------------------------------------------------------------
1062             # case $var in pattern) ... ;; esac
1063             # ----------------------------------------------------------------
1064             sub _parse_case {
1065 1     1   3 my ($class, $lines_ref, $start, $opts_ref) = @_;
1066 1         1 my @lines = @{$lines_ref};
  1         4  
1067 1         1 my $i = $start;
1068              
1069 1         65 my $case_line = $lines[$i]; $i++;
  1         4  
1070 1         2 $case_line =~ s/\r?\n\z//; $case_line =~ s/\A\s+//;
  1         3  
1071              
1072             # case WORD in
1073 1         1 my $word = '';
1074 1 50       8 if ($case_line =~ /\Acase\s+(.*?)\s+in\s*\z/i) {
1075 1         3 $word = _expand(undef, $1);
1076             }
1077              
1078             # Read patterns and bodies until esac
1079 1         2 my $status = 0;
1080 1         2 my $matched = 0;
1081              
1082 1         3 while ($i <= $#lines) {
1083 4         6 my $pl = $lines[$i]; $i++;
  4         4  
1084 4         5 $pl =~ s/\r?\n\z//; $pl =~ s/\A\s+//;
  4         9  
1085 4 50       7 next if $pl =~ /\A\s*\z/;
1086 4 50       10 my $lc_f = lc( ($pl =~ /\A(\S+)/) ? $1 : '' );
1087 4 100       7 last if $lc_f eq 'esac';
1088 3 50       8 next if $pl =~ /\A\s*;;\s*\z/; # stray ;; between patterns
1089              
1090             # Case 1: pattern) body ;; -- all on one line
1091 3 50       15 if ($pl =~ /\A(.*?)\)\s*(.+?)\s*;;\s*\z/) {
1092 3         6 my ($pattern_str, $inline_body) = ($1, $2);
1093 3 100       4 if (!$matched) {
1094 2         4 for my $pat (split /\|/, $pattern_str) {
1095 2         3 $pat =~ s/\A\s+//; $pat =~ s/\s+\z//;
  2         4  
1096 2 100       5 if (_match_pattern($word, $pat)) {
1097 1         5 $status = _run_lines($class, [$inline_body], $opts_ref);
1098 1         2 $matched = 1;
1099 1         2 last;
1100             }
1101             }
1102             }
1103 3         5 next;
1104             }
1105              
1106             # Case 2: pattern) -- pattern only, body on next lines until ;;
1107 0 0       0 if ($pl =~ /\A(.*?)\)\s*\z/) {
1108 0         0 my $pattern_str = $1;
1109 0         0 my @body = ();
1110 0         0 while ($i <= $#lines) {
1111 0         0 my $bl = $lines[$i]; $i++;
  0         0  
1112 0         0 $bl =~ s/\r?\n\z//;
1113 0 0       0 last if $bl =~ /\A\s*;;\s*\z/;
1114 0 0       0 if ($bl =~ /\A(.+?)\s*;;\s*\z/) { push @body, $1; last }
  0         0  
  0         0  
1115 0         0 push @body, $bl;
1116             }
1117 0 0       0 if (!$matched) {
1118 0         0 for my $pat (split /\|/, $pattern_str) {
1119 0         0 $pat =~ s/\A\s+//; $pat =~ s/\s+\z//;
  0         0  
1120 0 0       0 if (_match_pattern($word, $pat)) {
1121 0         0 $status = _run_lines($class, \@body, $opts_ref);
1122 0         0 $matched = 1;
1123 0         0 last;
1124             }
1125             }
1126             }
1127             }
1128             }
1129              
1130 1         12 return ($status, $i);
1131             }
1132              
1133             # Shell glob pattern matching
1134             sub _match_pattern {
1135 2     2   3 my ($word, $pat) = @_;
1136 2 50       15 return 1 if $pat eq '*';
1137             # Convert shell glob to regex
1138 2         3 my $re = quotemeta($pat);
1139 2         3 $re =~ s/\\\*/.*/g;
1140 2         1 $re =~ s/\\\?/./g;
1141 2 100       36 return ($word =~ /\A$re\z/) ? 1 : 0;
1142             }
1143              
1144             # ----------------------------------------------------------------
1145             # External command
1146             # ----------------------------------------------------------------
1147             # ----------------------------------------------------------------
1148             # _split_sh_pipe: split a SH command line on bare | characters,
1149             # respecting single-quoted, double-quoted, and $(...) regions.
1150             # Returns a list of segment strings; length 1 means no pipe found.
1151             # ----------------------------------------------------------------
1152             # _split_sh_compound: split a SH line on bare && / || / ;
1153             # Returns list of { op => '', cmd => '...' } hashrefs.
1154             # Length 1 means no compound operator found.
1155             # Respects single-quotes, double-quotes, and $(...) nesting.
1156             # ----------------------------------------------------------------
1157             # _sh_strip_redirects: parse SH-style redirections from a command line.
1158             #
1159             # Recognized forms (processed right-to-left, last one wins per fd):
1160             # cmd > file stdout overwrite
1161             # cmd >> file stdout append
1162             # cmd < file stdin
1163             # cmd 2> file stderr overwrite
1164             # cmd 2>> file stderr append
1165             # cmd 2>&1 stderr to stdout (recorded as fd=2, file='&1')
1166             # cmd 1>&2 stdout to stderr (recorded as fd=1, file='&2')
1167             #
1168             # Returns ($clean_cmd, \@redirs) where each redir is [fd, append, file].
1169             # Parsing respects single-quotes, double-quotes, and backslash escapes.
1170             # ----------------------------------------------------------------
1171             sub _sh_strip_redirects {
1172 113     113   177 my ($line) = @_;
1173 113         342 my @chars = split //, $line;
1174 113         128 my $n = scalar @chars;
1175 113         128 my @found;
1176 113         156 my $clean = '';
1177 113         115 my $in_sq = 0;
1178 113         118 my $in_dq = 0;
1179 113         156 my $i = 0;
1180              
1181 113         178 while ($i < $n) {
1182 1371         1814 my $ch = $chars[$i];
1183              
1184             # Single-quote passthrough
1185 1371 50       1833 if ($in_sq) {
1186 0 0       0 if ($ch eq "'") { $in_sq = 0 }
  0         0  
1187 0         0 $clean .= $ch; $i++; next;
  0         0  
  0         0  
1188             }
1189 1371 50 33     1959 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $clean .= $ch; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
1190              
1191             # Double-quote toggle
1192 1371 100 66     1994 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $clean .= $ch; $i++; next }
  26         31  
  26         23  
  26         27  
  26         35  
1193              
1194             # Inside double-quotes: only escape matters
1195 1345 100       1664 if ($in_dq) {
1196 148 50       167 if ($ch eq '\\') {
1197 0         0 $clean .= $ch; $i++;
  0         0  
1198 0 0       0 $clean .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1199             }
1200 148         133 $clean .= $ch; $i++; next;
  148         125  
  148         169  
1201             }
1202              
1203             # Backslash escape outside quotes
1204 1197 50       1538 if ($ch eq '\\') {
1205 0         0 $clean .= $ch; $i++;
  0         0  
1206 0 0       0 $clean .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1207             }
1208              
1209             # 2>&1 or 2>>&1 or 1>&2
1210 1197 0 100     2290 if ($ch =~ /[012]/ && $i+2 < $n
    0 66        
      33        
      0        
1211             && $chars[$i+1] eq '>'
1212             && ($i+3 < $n ? $chars[$i+2] eq '>' : 0)
1213             && $chars[$i+3] eq '&') {
1214             # 2>>&1 form (rare but handle)
1215 0         0 my $fd = int($ch);
1216 0         0 my $j = $i + 4;
1217 0         0 my $tgt = '';
1218 0   0     0 while ($j < $n && $chars[$j] =~ /\S/) { $tgt .= $chars[$j]; $j++ }
  0         0  
  0         0  
1219 0         0 push @found, [$fd, 0, "&$tgt"];
1220 0         0 $i = $j; next;
  0         0  
1221             }
1222 1197 50 100     2058 if ($ch =~ /[012]/ && $i+2 < $n
      66        
      33        
1223             && $chars[$i+1] eq '>' && $chars[$i+2] eq '&') {
1224 0         0 my $fd = int($ch);
1225 0         0 my $j = $i + 3;
1226 0         0 my $tgt = '';
1227 0   0     0 while ($j < $n && $chars[$j] =~ /\S/) { $tgt .= $chars[$j]; $j++ }
  0         0  
  0         0  
1228 0         0 push @found, [$fd, 0, "&$tgt"];
1229 0         0 $i = $j; next;
  0         0  
1230             }
1231              
1232             # fd> or fd>> (fd is 0,1,2; or implicit 1 when just > or >>)
1233 1197         1227 my $redir_fd = undef;
1234 1197 50 100     3182 if ($ch =~ /[012]/ && $i+1 < $n && $chars[$i+1] eq '>') {
    50 66        
    50          
1235 0         0 $redir_fd = int($ch); $i++;
  0         0  
1236             }
1237             elsif ($ch eq '<') {
1238             # < file (stdin)
1239 0         0 my $j = $i + 1;
1240 0   0     0 $j++ while $j < $n && $chars[$j] eq ' ';
1241 0         0 my $file = '';
1242 0   0     0 while ($j < $n && $chars[$j] !~ /[\s<>]/) { $file .= $chars[$j]; $j++ }
  0         0  
  0         0  
1243 0 0       0 push @found, [0, 0, $file] if $file ne '';
1244 0         0 $i = $j; next;
  0         0  
1245             }
1246             elsif ($ch eq '>') {
1247 0         0 $redir_fd = 1;
1248             }
1249              
1250 1197 50       1621 if (defined $redir_fd) {
1251             # Check for >>
1252 0         0 my $append = 0;
1253 0 0 0     0 if ($i+1 < $n && $chars[$i+1] eq '>') { $append = 1; $i++ }
  0         0  
  0         0  
1254             # Skip spaces
1255 0         0 $i++;
1256 0   0     0 $i++ while $i < $n && $chars[$i] eq ' ';
1257 0         0 my $file = '';
1258             # Read filename (stop at space unless quoted)
1259 0   0     0 while ($i < $n && $chars[$i] !~ /[\s<>]/) {
1260 0         0 $file .= $chars[$i]; $i++;
  0         0  
1261             }
1262 0 0       0 push @found, [$redir_fd, $append, $file] if $file ne '';
1263 0         0 next;
1264             }
1265              
1266 1197         1204 $clean .= $ch; $i++;
  1197         1760  
1267             }
1268              
1269 113         337 $clean =~ s/\s+\z//;
1270 113         503 return ($clean, \@found);
1271             }
1272              
1273             # ----------------------------------------------------------------
1274             # _sh_exec_with_redirs: apply I/O redirections then execute a SH line.
1275             # Perl 5.005_03 compatible: fixed bareword FHs, 2-argument open.
1276             # Supports: > >> < 2> 2>> 2>&1 1>&2
1277             # ----------------------------------------------------------------
1278             sub _sh_exec_with_redirs {
1279 0     0   0 my ($class, $line, $redirs_ref, $opts_ref) = @_;
1280              
1281             # Collect per-fd: stdin, stdout, stderr
1282 0         0 my ($in_file, $out_file, $out_app, $err_file, $err_app);
1283 0         0 my $err_to_stdout = 0; # 2>&1
1284 0         0 my $out_to_stderr = 0; # 1>&2
1285              
1286 0         0 for my $r (@{$redirs_ref}) {
  0         0  
1287 0         0 my ($fd, $append, $file) = @{$r};
  0         0  
1288 0 0       0 if ($fd == 0) { $in_file = $file; }
  0 0       0  
1289             elsif ($fd == 1) {
1290 0 0       0 if ($file eq '&2') { $out_to_stderr = 1 }
  0         0  
1291 0         0 else { $out_file = $file; $out_app = $append }
  0         0  
1292             }
1293             else { # fd == 2
1294 0 0       0 if ($file eq '&1') { $err_to_stdout = 1 }
  0         0  
1295 0         0 else { $err_file = $file; $err_app = $append }
  0         0  
1296             }
1297             }
1298              
1299 0         0 my $ok = 1;
1300 0         0 my ($saved_in, $saved_out, $saved_err) = (0, 0, 0);
1301              
1302             # --- stdin ---
1303 0 0 0     0 if (defined $in_file && $ok) {
1304             open(_SH_REDIR_SRC, $in_file)
1305 0 0       0 or do { warn "sh: $in_file: $!\n"; $ok = 0 };
  0         0  
  0         0  
1306 0 0       0 if ($ok) {
1307 0 0       0 open(_SH_REDIR_SAVIN, '<&STDIN') or do { $ok = 0 };
  0         0  
1308             }
1309 0 0       0 if ($ok) {
1310 0 0       0 open(STDIN, '<&_SH_REDIR_SRC') or do { $ok = 0 };
  0         0  
1311 0         0 close(_SH_REDIR_SRC);
1312 0         0 $saved_in = 1;
1313             }
1314             }
1315              
1316             # --- stdout ---
1317 0 0 0     0 if (defined $out_file && $ok) {
    0 0        
1318 0 0       0 my $mode = $out_app ? '>>' : '>';
1319             open(_SH_REDIR_DST, "$mode$out_file")
1320 0 0       0 or do { warn "sh: $out_file: $!\n"; $ok = 0 };
  0         0  
  0         0  
1321 0 0       0 if ($ok) {
1322 0 0       0 open(_SH_REDIR_SAVOUT, '>&STDOUT') or do { $ok = 0 };
  0         0  
1323             }
1324 0 0       0 if ($ok) {
1325 0 0       0 open(STDOUT, '>&_SH_REDIR_DST') or do { $ok = 0 };
  0         0  
1326 0         0 close(_SH_REDIR_DST);
1327 0         0 $saved_out = 1;
1328             }
1329             }
1330             elsif ($out_to_stderr && $ok) {
1331 0 0       0 open(_SH_REDIR_SAVOUT, '>&STDOUT') or do { $ok = 0 };
  0         0  
1332 0 0       0 if ($ok) {
1333 0 0       0 open(STDOUT, '>&STDERR') or do { $ok = 0 };
  0         0  
1334 0         0 $saved_out = 1;
1335             }
1336             }
1337              
1338             # --- stderr ---
1339 0 0 0     0 if (defined $err_file && $ok) {
    0 0        
1340 0 0       0 my $mode = $err_app ? '>>' : '>';
1341             open(_SH_REDIR_DST, "$mode$err_file")
1342 0 0       0 or do { warn "sh: $err_file: $!\n"; $ok = 0 };
  0         0  
  0         0  
1343 0 0       0 if ($ok) {
1344 0 0       0 open(_SH_REDIR_SAVERR, '>&STDERR') or do { $ok = 0 };
  0         0  
1345             }
1346 0 0       0 if ($ok) {
1347 0 0       0 open(STDERR, '>&_SH_REDIR_DST') or do { $ok = 0 };
  0         0  
1348 0         0 close(_SH_REDIR_DST);
1349 0         0 $saved_err = 1;
1350             }
1351             }
1352             elsif ($err_to_stdout && $ok) {
1353             # Redirect stderr to the current STDOUT (which may itself be redirected)
1354 0 0       0 open(_SH_REDIR_SAVERR, '>&STDERR') or do { $ok = 0 };
  0         0  
1355 0 0       0 if ($ok) {
1356 0 0       0 open(STDERR, '>&STDOUT') or do { $ok = 0 };
  0         0  
1357 0         0 $saved_err = 1;
1358             }
1359             }
1360              
1361 0         0 my $rc = 0;
1362 0 0       0 if ($ok) {
1363 0         0 $rc = _exec_line($class, $line, $opts_ref);
1364             }
1365              
1366             # Restore in reverse order
1367 0 0       0 if ($saved_err) { open(STDERR, '>&_SH_REDIR_SAVERR'); close(_SH_REDIR_SAVERR) }
  0         0  
  0         0  
1368 0 0       0 if ($saved_out) { open(STDOUT, '>&_SH_REDIR_SAVOUT'); close(_SH_REDIR_SAVOUT) }
  0         0  
  0         0  
1369 0 0       0 if ($saved_in) { open(STDIN, '<&_SH_REDIR_SAVIN'); close(_SH_REDIR_SAVIN) }
  0         0  
  0         0  
1370              
1371 0         0 return $rc;
1372             }
1373              
1374             # ----------------------------------------------------------------
1375             sub _split_sh_compound {
1376 121     121   177 my ($line) = @_;
1377 121         135 my @parts;
1378 121         179 my $cur = '';
1379 121         125 my $in_sq = 0;
1380 121         135 my $in_dq = 0;
1381 121         125 my $depth = 0; # $( nesting
1382 121         496 my @chars = split //, $line;
1383 121         181 my $n = scalar @chars;
1384 121         154 my $i = 0;
1385              
1386 121         214 while ($i < $n) {
1387 1812         1917 my $ch = $chars[$i];
1388              
1389             # Single-quote region
1390 1812 50       2472 if ($in_sq) {
1391 0 0       0 if ($ch eq "'") { $in_sq = 0 }
  0         0  
1392 0         0 $cur .= $ch; $i++; next;
  0         0  
  0         0  
1393             }
1394 1812 50 33     3115 if ($ch eq "'" && !$in_dq) { $in_sq = 1; $cur .= $ch; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
1395              
1396             # Double-quote toggle
1397 1812 100 66     2931 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; $cur .= $ch; $i++; next }
  30         39  
  30         24  
  30         27  
  30         49  
1398              
1399             # $( nesting inside double-quotes
1400 1782 100       2324 if ($in_dq) {
1401 220 50 66     519 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') { $depth++ }
  0 50 66     0  
      66        
1402 0         0 elsif ($ch eq ')' && $depth > 0) { $depth-- }
1403 220         227 $cur .= $ch; $i++; next;
  220         192  
  220         296  
1404             }
1405              
1406             # Track $( nesting outside quotes
1407 1562 100 66     2484 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      100        
1408 11         19 $depth++; $cur .= $ch; $i++; next;
  11         10  
  11         14  
  11         39  
1409             }
1410 1551 100 100     2266 if ($ch eq ')' && $depth > 0) {
1411 11         11 $depth--; $cur .= $ch; $i++; next;
  11         13  
  11         13  
  11         17  
1412             }
1413              
1414             # Inside $(...) don't split on operators
1415 1540 100       2038 if ($depth > 0) { $cur .= $ch; $i++; next }
  106         129  
  106         125  
  106         162  
1416              
1417             # Backslash escape
1418 1434 50       1874 if ($ch eq '\\') {
1419 0         0 $cur .= $ch; $i++;
  0         0  
1420 0 0       0 $cur .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1421             }
1422              
1423             # && operator
1424 1434 50 66     2125 if ($ch eq '&' && $i+1 < $n && $chars[$i+1] eq '&') {
      66        
1425 3         13 push @parts, { op => '', cmd => $cur };
1426 3         11 push @parts, { op => '&&', cmd => '' };
1427 3         5 $cur = ''; $i += 2; next;
  3         4  
  3         7  
1428             }
1429              
1430             # || operator
1431 1431 100 66     2191 if ($ch eq '|' && $i+1 < $n && $chars[$i+1] eq '|') {
      100        
1432 2         8 push @parts, { op => '', cmd => $cur };
1433 2         5 push @parts, { op => '||', cmd => '' };
1434 2         3 $cur = ''; $i += 2; next;
  2         2  
  2         3  
1435             }
1436              
1437             # ; separator (not inside any quote or subst)
1438 1429 100       1861 if ($ch eq ';') {
1439 1         6 push @parts, { op => '', cmd => $cur };
1440 1         3 push @parts, { op => ';', cmd => '' };
1441 1         2 $cur = ''; $i++; next;
  1         2  
  1         4  
1442             }
1443              
1444 1428         1651 $cur .= $ch; $i++;
  1428         2084  
1445             }
1446 121         551 push @parts, { op => '', cmd => $cur };
1447              
1448             # If only one cmd part with no operators, return single element
1449 121         166 my $has_op = 0;
1450 121 100       227 for my $p (@parts) { $has_op = 1 if $p->{op} ne '' }
  133         1049  
1451 121 100       223 return @parts if $has_op;
1452 116         803 return ({ op => '', cmd => $line });
1453             }
1454              
1455             # ----------------------------------------------------------------
1456             # _exec_sh_compound: execute && / || / ; compound SH commands
1457             # ----------------------------------------------------------------
1458             sub _exec_sh_compound {
1459 5     5   9 my ($class, $parts, $opts_ref) = @_;
1460 5         6 my $pending_op = '';
1461 5         6 my $rc = 0;
1462              
1463 5         7 for my $part (@{$parts}) {
  5         9  
1464 17         21 my $op = $part->{op};
1465 17         20 my $cmd = $part->{cmd};
1466 17         31 $cmd =~ s/\A\s+//; $cmd =~ s/\s+\z//;
  17         34  
1467              
1468 17 100       23 if ($op eq '') {
1469             # Execute according to pending operator
1470 11 100       58 if ($pending_op eq '') {
    100          
    100          
    50          
1471 5 50       20 $rc = _exec_line($class, $cmd, $opts_ref) if $cmd =~ /\S/;
1472             }
1473             elsif ($pending_op eq '&&') {
1474 3 100 66     14 if ($LAST_STATUS == 0 && $cmd =~ /\S/) {
1475 1         4 $rc = _exec_line($class, $cmd, $opts_ref);
1476             }
1477             }
1478             elsif ($pending_op eq '||') {
1479 2 50 33     9 if ($LAST_STATUS != 0 && $cmd =~ /\S/) {
1480 2         6 $rc = _exec_line($class, $cmd, $opts_ref);
1481             }
1482             }
1483             elsif ($pending_op eq ';') {
1484 1 50       9 $rc = _exec_line($class, $cmd, $opts_ref) if $cmd =~ /\S/;
1485             }
1486 11         20 $pending_op = '';
1487             }
1488             else {
1489 6         8 $pending_op = $op;
1490             }
1491             }
1492 5         18 return $rc;
1493             }
1494              
1495             # ----------------------------------------------------------------
1496             sub _split_sh_pipe {
1497 116     116   174 my ($line) = @_;
1498 116         124 my @segs;
1499 116         154 my $cur = '';
1500 116         134 my $in_sq = 0; # inside single quotes
1501 116         127 my $in_dq = 0; # inside double quotes
1502 116         126 my $depth = 0; # $( nesting depth
1503 116         346 my @chars = split //, $line;
1504 116         128 my $n = scalar @chars;
1505 116         133 my $i = 0;
1506              
1507 116         193 while ($i < $n) {
1508 1724         1844 my $ch = $chars[$i];
1509              
1510             # Single-quote region: nothing special until closing '
1511 1724 50       2229 if ($in_sq) {
1512 0 0       0 if ($ch eq "'") { $in_sq = 0 }
  0         0  
1513 0         0 $cur .= $ch; $i++; next;
  0         0  
  0         0  
1514             }
1515              
1516             # Toggle double-quote
1517 1724 100 66     2522 if ($ch eq '"' && !$in_sq) {
1518 30         37 $in_dq = !$in_dq;
1519 30         28 $cur .= $ch; $i++; next;
  30         26  
  30         44  
1520             }
1521              
1522             # Inside double-quotes only $( nesting matters
1523 1694 100       2212 if ($in_dq) {
1524 220 50 66     316 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      66        
1525 0         0 $depth++; $cur .= $ch; $i++; next;
  0         0  
  0         0  
  0         0  
1526             }
1527 220 50 66     282 if ($ch eq ')' && $depth > 0) {
1528 0         0 $depth--; $cur .= $ch; $i++; next;
  0         0  
  0         0  
  0         0  
1529             }
1530             # backslash escape inside "
1531 220 50       247 if ($ch eq '\\') {
1532 0         0 $cur .= $ch; $i++;
  0         0  
1533 0 0       0 $cur .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1534             }
1535 220         203 $cur .= $ch; $i++; next;
  220         171  
  220         282  
1536             }
1537              
1538             # Enter single-quote
1539 1474 50       1985 if ($ch eq "'") { $in_sq = 1; $cur .= $ch; $i++; next }
  0         0  
  0         0  
  0         0  
  0         0  
1540              
1541             # $( command substitution: track nesting so | inside is not a pipe
1542 1474 100 66     3017 if ($ch eq '$' && $i+1 < $n && $chars[$i+1] eq '(') {
      100        
1543 11         16 $depth++; $cur .= $ch; $i++; next;
  11         85  
  11         16  
  11         17  
1544             }
1545 1463 50       1901 if ($ch eq '(' ) { $depth++ if $depth > 0; $cur .= $ch; $i++; next }
  22 100       32  
  22         25  
  22         26  
  22         31  
1546 1441 100       1885 if ($ch eq ')' ) {
1547 22 50       39 if ($depth > 0) { $depth-- }
  22         27  
1548 22         25 $cur .= $ch; $i++; next;
  22         23  
  22         40  
1549             }
1550              
1551             # Bare | outside any quote/subst => pipeline separator
1552 1419 100 66     2071 if ($ch eq '|' && $depth == 0) {
1553             # Peek: || is logical-or, not a pipe
1554 4 50 33     21 if ($i+1 < $n && $chars[$i+1] eq '|') {
1555 0         0 $cur .= '||'; $i += 2; next;
  0         0  
  0         0  
1556             }
1557 4         7 push @segs, $cur;
1558 4         19 $cur = '';
1559 4         5 $i++; next;
  4         9  
1560             }
1561              
1562             # Backslash escape (outside quotes)
1563 1415 50       2849 if ($ch eq '\\') {
1564 0         0 $cur .= $ch; $i++;
  0         0  
1565 0 0       0 $cur .= $chars[$i] if $i < $n; $i++; next;
  0         0  
  0         0  
1566             }
1567              
1568 1415         1457 $cur .= $ch; $i++;
  1415         2002  
1569             }
1570 116         196 push @segs, $cur;
1571 116         439 return @segs;
1572             }
1573              
1574             # ----------------------------------------------------------------
1575             # _exec_sh_pipe: run a SH pipeline via temporary files.
1576             # Each segment's stdout feeds the next segment's stdin.
1577             # Perl 5.005_03 compatible: bareword FHs, 2-arg open.
1578             # ----------------------------------------------------------------
1579             sub _exec_sh_pipe {
1580 3     3   69 my ($class, $segs_ref, $opts_ref) = @_;
1581 3         8 my @segs = @{$segs_ref};
  3         8  
1582 3         7 my $n_segs = scalar @segs;
1583 3         86 my $base = File::Spec->catfile(File::Spec->tmpdir(), "batsh_shp_$$");
1584 3         7 my $rc = 0;
1585 3         4 my $input_f = undef; # tmpfile that feeds this segment's STDIN
1586              
1587 3         15 for my $idx (0 .. $n_segs - 1) {
1588 7         13 my $seg = $segs[$idx];
1589 7         46 $seg =~ s/\A\s+//; $seg =~ s/\s+\z//;
  7         38  
1590 7 50       20 next unless $seg =~ /\S/;
1591              
1592 7 100       16 my $is_last = ($idx == $n_segs - 1) ? 1 : 0;
1593 7 100       18 my $output_f = $is_last ? undef : "${base}_${idx}.tmp";
1594              
1595             # --- redirect STDIN from previous segment's output ---
1596 7         9 my $saved_in = 0;
1597 7 100 66     89 if (defined $input_f && -f $input_f) {
1598             open(_SH_PIPE_RFH, $input_f)
1599 4 50       74 or do { warn "SH pipe: open $input_f: $!\n"; last };
  0         0  
  0         0  
1600             open(_SH_PIPE_SAVIN, '<&STDIN')
1601 4 50       47 or do { close(_SH_PIPE_RFH); last };
  0         0  
  0         0  
1602             open(STDIN, '<&_SH_PIPE_RFH')
1603 4 50       48 or do {
1604 0         0 close(_SH_PIPE_RFH);
1605 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
1606 0         0 last;
1607             };
1608 4         11 close(_SH_PIPE_RFH);
1609 4         7 $saved_in = 1;
1610             }
1611              
1612             # --- redirect STDOUT to next segment's input file ---
1613 7         8 my $saved_out = 0;
1614 7 100       9 if (defined $output_f) {
1615             open(_SH_PIPE_WFH, ">$output_f")
1616 4 50       587 or do {
1617 0 0       0 if ($saved_in) {
1618 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
1619             }
1620 0         0 warn "SH pipe: open $output_f: $!\n";
1621 0         0 last;
1622             };
1623             open(_SH_PIPE_SAVOUT, '>&STDOUT')
1624 4 50       55 or do {
1625 0         0 close(_SH_PIPE_WFH);
1626 0 0       0 if ($saved_in) {
1627 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
1628             }
1629 0         0 last;
1630             };
1631             open(STDOUT, '>&_SH_PIPE_WFH')
1632 4 50       54 or do {
1633 0         0 close(_SH_PIPE_WFH);
1634 0         0 open(STDOUT, '>&_SH_PIPE_SAVOUT'); close(_SH_PIPE_SAVOUT);
  0         0  
1635 0 0       0 if ($saved_in) {
1636 0         0 open(STDIN, '<&_SH_PIPE_SAVIN'); close(_SH_PIPE_SAVIN);
  0         0  
1637             }
1638 0         0 last;
1639             };
1640 4         12 close(_SH_PIPE_WFH);
1641 4         8 $saved_out = 1;
1642             }
1643              
1644             # --- execute the segment as a SH line ---
1645 7         32 $rc = _exec_line($class, $seg, $opts_ref);
1646              
1647             # --- restore STDOUT ---
1648 7 100       36 if ($saved_out) {
1649 4         243 open(STDOUT, '>&_SH_PIPE_SAVOUT');
1650 4         17 close(_SH_PIPE_SAVOUT);
1651             }
1652              
1653             # --- restore STDIN and remove input tmpfile ---
1654 7 100       26 if ($saved_in) {
1655 4         213 open(STDIN, '<&_SH_PIPE_SAVIN');
1656 4         36 close(_SH_PIPE_SAVIN);
1657 4         739 unlink $input_f;
1658             }
1659              
1660 7         67 $input_f = $output_f;
1661             }
1662              
1663 3 50 33     61 unlink $input_f if defined $input_f && -f $input_f;
1664 3         190 return $rc;
1665             }
1666              
1667             # ----------------------------------------------------------------
1668             # Pattern helpers for ${var%pat}, ${var#pat}, ${var/pat/rep}
1669             # Converts glob-style pattern to Perl regex (*, ?, [abc]).
1670             # ----------------------------------------------------------------
1671             sub _glob_to_re {
1672 6     6   10 my ($pat, $greedy) = @_;
1673 6         15 my $re = '';
1674 6         17 my @chars = split //, $pat;
1675 6         9 my $n = scalar @chars;
1676 6         6 my $i = 0;
1677 6         14 while ($i < $n) {
1678 12         15 my $c = $chars[$i];
1679 12 100       39 if ($c eq '*') {
    50          
    50          
1680 4 100       9 $re .= $greedy ? '.*' : '.*?';
1681             }
1682 0         0 elsif ($c eq '?') { $re .= '.' }
1683             elsif ($c eq '[') {
1684 0         0 my $cls = '[';
1685 0         0 $i++;
1686 0   0     0 while ($i < $n && $chars[$i] ne ']') {
1687 0         0 $cls .= $chars[$i]; $i++;
  0         0  
1688             }
1689 0         0 $cls .= ']';
1690 0         0 $re .= $cls;
1691             }
1692 8         15 else { $re .= quotemeta($c) }
1693 12         21 $i++;
1694             }
1695 6         14 return $re;
1696             }
1697              
1698             sub _sh_remove_suffix {
1699 2     2   10 my ($val, $pat, $greedy) = @_;
1700             # % (greedy=0, shortest suffix): keep longest prefix
1701             # => /\A(.*) PATTERN \z/s with greedy prefix => $1
1702             # %% (greedy=1, longest suffix): keep shortest prefix
1703             # => /\A(.*?)PATTERN \z/s with lazy prefix => $1
1704 2         7 my $re = _glob_to_re($pat, 1); # pattern itself is always greedy for suffix
1705 2 100       5 if ($greedy) {
1706             # longest suffix removed: lazy prefix
1707 1 50       71 return ($val =~ /\A(.*?)$re\z/s) ? $1 : $val;
1708             }
1709             else {
1710             # shortest suffix removed: greedy prefix
1711 1 50       61 return ($val =~ /\A(.*)$re\z/s) ? $1 : $val;
1712             }
1713             }
1714              
1715             sub _sh_remove_prefix {
1716 2     2   5 my ($val, $pat, $greedy) = @_;
1717             # # (greedy=0, shortest prefix): keep longest suffix
1718             # => /\A PATTERN(.*) \z/s with lazy pattern => $1
1719             # ## (greedy=1, longest prefix): keep shortest suffix
1720             # => /\A PATTERN(.*) \z/s with greedy pattern => $1
1721 2         6 my $re = _glob_to_re($pat, $greedy);
1722 2 50       82 return ($val =~ /\A$re(.*)\z/s) ? $1 : $val;
1723             }
1724              
1725             sub _sh_replace {
1726 2     2   6 my ($val, $pat, $rep, $global) = @_;
1727 2         4 my $re = _glob_to_re($pat, 1);
1728 2 100       4 if ($global) { $val =~ s/$re/$rep/g }
  1         11  
1729 1         26 else { $val =~ s/$re/$rep/ }
1730 2         6 return $val;
1731             }
1732              
1733             # ----------------------------------------------------------------
1734             # Shell function registry { name => \@body_lines }
1735             # ----------------------------------------------------------------
1736              
1737             # ----------------------------------------------------------------
1738             # _parse_function: parse "name() {" or "function name {" blocks
1739             # Returns ($status, $new_i).
1740             # ----------------------------------------------------------------
1741             sub _parse_function {
1742 6     6   15 my ($class, $lines_ref, $start, $opts_ref) = @_;
1743 6         15 my @lines = @{$lines_ref};
  6         25  
1744 6         11 my $line = $lines[$start];
1745 6         18 $line =~ s/\r?\n\z//;
1746 6         13 $line =~ s/\A\s+//;
1747              
1748 6         11 my $name = '';
1749 6 50       52 if ($line =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)\s*(?:\{.*)?\z/) {
    0          
1750 6         22 $name = $1;
1751             }
1752             elsif ($line =~ /\Afunction\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(\s*\))?\s*(?:\{.*)?\z/i) {
1753 0         0 $name = $1;
1754             }
1755             else {
1756 0         0 return (0, $start + 1);
1757             }
1758              
1759 6         8 my @body;
1760 6 50       21 my $depth = ($line =~ /\{/) ? 1 : 0;
1761 6         10 my $i = $start + 1;
1762              
1763             # Check if the function body is on the same line as the definition
1764             # e.g. "name() { cmd1; cmd2; }"
1765 6 100 66     37 if ($depth >= 1 && $line =~ /\{(.*)\}\s*\z/s) {
1766 1         4 my $inline = $1;
1767 1         5 $inline =~ s/\A\s+//; $inline =~ s/\s+\z//;
  1         8  
1768             # Split on ; to get individual commands
1769 1         5 for my $part (split /;/, $inline) {
1770 1         3 $part =~ s/\A\s+//; $part =~ s/\s+\z//;
  1         5  
1771 1 50       7 push @body, $part if $part =~ /\S/;
1772             }
1773 1         5 $_SH_FUNCTIONS{$name} = \@body;
1774 1         5 return (0, $i);
1775             }
1776              
1777 5 50       17 if ($depth == 0) {
1778 0         0 while ($i <= $#lines) {
1779 0         0 my $l = $lines[$i]; $l =~ s/\r?\n\z//; $l =~ s/\A\s+//;
  0         0  
  0         0  
1780 0         0 $i++;
1781 0 0       0 if ($l =~ /\{/) { $depth = 1; last }
  0         0  
  0         0  
1782             }
1783             }
1784              
1785 5         16 while ($i <= $#lines) {
1786 13         56 my $l = $lines[$i]; $l =~ s/\r?\n\z//;
  13         42  
1787 13         15 $i++;
1788 13         25 my $opens = () = ($l =~ /\{/g);
1789 13         47 my $closes = () = ($l =~ /\}/g);
1790 13         21 $depth += $opens - $closes;
1791 13 100       30 if ($depth <= 0) {
1792 5         10 my $before = $l;
1793 5         40 $before =~ s/\}\s*\z//;
1794 5 50       18 push @body, $before if $before =~ /\S/;
1795 5         12 last;
1796             }
1797 8         33 push @body, $l;
1798             }
1799              
1800 5         175 $_SH_FUNCTIONS{$name} = \@body;
1801 5         36 return (0, $i);
1802             }
1803              
1804             # ----------------------------------------------------------------
1805             # _call_sh_function: execute a registered SH function
1806             # ----------------------------------------------------------------
1807             sub _call_sh_function {
1808 6     6   20 my ($class, $name, $args_str, $opts_ref) = @_;
1809 6 50       16 return 1 unless exists $_SH_FUNCTIONS{$name};
1810              
1811 6         17 my @args = _parse_args($args_str);
1812              
1813 6         8 my @saved_arg;
1814 6         19 for my $n (1 .. 9) {
1815 54         144 push @saved_arg, BATsh::Env->get("BATSH_ARG$n");
1816 54 100       151 BATsh::Env->set("BATSH_ARG$n",
1817             defined($args[$n-1]) ? $args[$n-1] : '');
1818             }
1819 6         10 my @saved_pct;
1820 6         20 for my $n (1 .. 9) {
1821 54         150 push @saved_pct, BATsh::Env->get("%$n");
1822 54 100       171 BATsh::Env->set("%$n", defined($args[$n-1]) ? $args[$n-1] : '');
1823             }
1824 6         14 my $saved_star = BATsh::Env->get('%*');
1825 6         24 BATsh::Env->set('%*', join(' ', @args));
1826              
1827 6         13 push @FUNCTION_STACK, {};
1828 6         10 my $saved_ret = $_RETURN;
1829 6         7 $_RETURN = 0;
1830              
1831 6         35 my $rc = _run_lines($class, $_SH_FUNCTIONS{$name}, $opts_ref);
1832              
1833 6         12 $_RETURN = $saved_ret;
1834              
1835             # Restore local variables saved in this function's scope
1836 6 50       17 if (@FUNCTION_STACK) {
1837 6         12 my $frame = $FUNCTION_STACK[-1];
1838 6         20 for my $var (keys %{$frame}) {
  6         25  
1839 2         7 my $old = $frame->{$var};
1840 2 50       9 if (defined $old) { BATsh::Env->set($var, $old) }
  2         12  
1841 0         0 else { BATsh::Env->unset($var) }
1842             }
1843             }
1844 6         14 pop @FUNCTION_STACK;
1845              
1846 6         21 for my $n (1 .. 9) {
1847 54         163 my $v = $saved_arg[$n-1];
1848 54 50       162 BATsh::Env->set("BATSH_ARG$n", defined $v ? $v : '');
1849             }
1850 6         18 for my $n (1 .. 9) {
1851 54         65 my $v = $saved_pct[$n-1];
1852 54 100       152 BATsh::Env->set("%$n", defined $v ? $v : '');
1853             }
1854 6 50       71 BATsh::Env->set('%*', defined $saved_star ? $saved_star : '');
1855              
1856 6         10 $LAST_STATUS = $rc;
1857 6         40 return $rc;
1858             }
1859              
1860             # ----------------------------------------------------------------
1861             # _parse_args: split a string into arguments respecting quotes
1862             # ----------------------------------------------------------------
1863             sub _parse_args {
1864 6     6   15 my ($str) = @_;
1865 6 50       16 $str = '' unless defined $str;
1866 6         24 $str =~ s/\A\s+//; $str =~ s/\s+\z//;
  6         14  
1867 6 100       25 return () unless $str =~ /\S/;
1868 4         5 my @args;
1869 4         7 my $cur = '';
1870 4         7 my $in_sq = 0;
1871 4         6 my $in_dq = 0;
1872 4         19 for my $ch (split //, $str) {
1873 14 50       25 if ($in_sq) {
1874 0 0       0 if ($ch eq "'") { $in_sq = 0 } else { $cur .= $ch }
  0         0  
  0         0  
1875 0         0 next;
1876             }
1877 14 50 33     28 if ($ch eq "'" && !$in_dq) { $in_sq = 1; next }
  0         0  
  0         0  
1878 14 50 33     25 if ($ch eq '"' && !$in_sq) { $in_dq = !$in_dq; next }
  0         0  
  0         0  
1879 14 50 66     55 if ($ch =~ /\s/ && !$in_sq && !$in_dq) {
      66        
1880 1         4 push @args, $cur; $cur = '';
  1         2  
1881 1         3 next;
1882             }
1883 13         21 $cur .= $ch;
1884             }
1885 4 50 33     19 push @args, $cur if $cur ne '' || @args;
1886 4         14 return @args;
1887             }
1888              
1889             # ----------------------------------------------------------------
1890             # ----------------------------------------------------------------
1891             sub _cmd_external {
1892 4     4   11 my ($cmd, $rest) = @_;
1893 4 50       9 $rest = '' unless defined $rest;
1894 4         24 $rest =~ s/\A\s+//;
1895 4 50       18 my $full = $rest ne '' ? "$cmd $rest" : $cmd;
1896 4         54 BATsh::Env->sync_to_env();
1897 4         1650595 my $rc = system($full);
1898 4 100 50     181 $LAST_STATUS = ($rc == 0) ? 0 : (($rc >> 8) || 1);
1899 4         275 return $LAST_STATUS;
1900             }
1901              
1902             # ----------------------------------------------------------------
1903             # Split "cmd rest" honouring quoted strings
1904             # ----------------------------------------------------------------
1905             sub _split_sh {
1906 113     113   179 my ($line) = @_;
1907 113 50       428 if ($line =~ /\A(\S+)\s*(.*)\z/s) {
1908 113         472 return ($1, $2);
1909             }
1910 0           return ($line, '');
1911             }
1912              
1913             # ----------------------------------------------------------------
1914             # Accessors
1915             # ----------------------------------------------------------------
1916 0     0 0   sub last_status { return $LAST_STATUS }
1917 0     0 0   sub set_last_status { $LAST_STATUS = $_[1] }
1918              
1919             # Need Cwd
1920             BEGIN {
1921 7     7   37 eval { require Cwd };
  7         67  
1922 7 50       287 if ($@) {
1923 0         0 eval 'sub Cwd::cwd { return $ENV{PWD} || "." }';
1924             }
1925             }
1926              
1927             1;
1928              
1929             __END__