File Coverage

lib/BATsh/SH.pm
Criterion Covered Total %
statement 369 609 60.5
branch 144 348 41.3
condition 25 79 31.6
subroutine 24 37 64.8
pod 0 3 0.0
total 562 1076 52.2


line stmt bran cond sub pod time code
1             package BATsh::SH;
2             ######################################################################
3             #
4             # BATsh::SH - Pure Perl sh/bash interpreter
5             #
6             # Implements sh/bash command set in Perl.
7             # No external sh or bash required.
8             #
9             # Supported:
10             # Variable assignment: VAR=value
11             # export VAR=value, export VAR, unset VAR
12             # echo, printf
13             # if/then/else/elif/fi
14             # for VAR in list; do ... done
15             # while condition; do ... done
16             # until condition; do ... done
17             # case $var in pattern) ... ;; esac
18             # test / [ ... ] (file tests, string, integer comparisons)
19             # cd, pwd, exit
20             # true, false, :
21             # read VAR
22             # $(( arithmetic ))
23             # $(...) command substitution (recursive BATsh execution)
24             # source / . file
25             # local VAR=value (inside function context)
26             #
27             ######################################################################
28              
29 5     5   38 use strict;
  5         10  
  5         486  
30 5 50 33 5   225 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
31 5     5   59 use warnings; local $^W = 1;
  5         44  
  5         528  
32 5 50   5   193 BEGIN { pop @INC if $INC[-1] eq '.' }
33              
34 5     5   31 use File::Spec ();
  5         9  
  5         133  
35 5     5   27 use Carp qw(croak);
  5         9  
  5         450  
36 5     5   31 use vars qw($VERSION);
  5         8  
  5         55355  
37             $VERSION = '0.01';
38             $VERSION = $VERSION;
39              
40             require BATsh::Env;
41              
42             # ----------------------------------------------------------------
43             # State
44             # ----------------------------------------------------------------
45             my $LAST_STATUS = 0; # $?
46             my @FUNCTION_STACK = (); # for 'local' variable scoping
47              
48             # Signal: pending exit
49             my $_EXIT_CODE = undef; # undef = no exit pending
50             my $_BREAK = 0; # break out of loop
51             my $_CONTINUE = 0; # continue next iteration
52             my $_RETURN = 0; # return from function/source
53              
54             # ----------------------------------------------------------------
55             # Public: execute an array of SH lines
56             # Returns exit status (0 = success)
57             # ----------------------------------------------------------------
58             sub exec_block {
59 25     25 0 67 my ($class, $lines_ref, %opts) = @_;
60 25         33 $_EXIT_CODE = undef;
61 25         27 $_BREAK = 0;
62 25         27 $_CONTINUE = 0;
63 25         28 $_RETURN = 0;
64              
65 25         68 my $status = _run_lines($class, $lines_ref, \%opts);
66 25 50       118 return defined $_EXIT_CODE ? $_EXIT_CODE : $status;
67             }
68              
69             # ----------------------------------------------------------------
70             # Run an array of lines sequentially, handling multi-line blocks
71             # Returns last exit status
72             # ----------------------------------------------------------------
73             sub _run_lines {
74 65     65   92 my ($class, $lines_ref, $opts_ref) = @_;
75 65         62 my @lines = @{$lines_ref};
  65         100  
76 65         72 my $status = 0;
77 65         84 my $i = 0;
78              
79 65         95 while ($i <= $#lines) {
80 80 50       129 return $status if defined $_EXIT_CODE;
81 80 50 33     184 return $status if $_BREAK || $_RETURN;
82              
83 80         116 my $line = $lines[$i];
84 80         86 $i++;
85             # Normalise
86 80         114 $line =~ s/\r?\n\z//;
87             # Skip empty and comment lines
88 80 50       177 next if $line =~ /\A\s*\z/;
89 80 50       130 next if $line =~ /\A\s*#/;
90              
91             # Check for block-opening keywords
92 80         89 my $stripped = $line;
93 80         110 $stripped =~ s/\A\s+//;
94 80         89 my $first = '';
95 80         175 ($first) = ($stripped =~ /\A(\S+)/);
96 80 50       127 $first = lc(defined($first) ? $first : '');
97              
98 80 100       136 if ($first eq 'if') {
99 7         17 ($status, $i) = _parse_if($class, \@lines, $i - 1, $opts_ref);
100 7         15 next;
101             }
102 73 100       100 if ($first eq 'for') {
103 2         6 ($status, $i) = _parse_for($class, \@lines, $i - 1, $opts_ref);
104 2         4 next;
105             }
106 71 100 100     164 if ($first eq 'while' || $first eq 'until') {
107 2         7 ($status, $i) = _parse_while($class, \@lines, $i - 1, $opts_ref);
108 2         5 next;
109             }
110 69 100       93 if ($first eq 'case') {
111 1         6 ($status, $i) = _parse_case($class, \@lines, $i - 1, $opts_ref);
112 1         3 next;
113             }
114              
115 68         106 $status = _exec_line($class, $line, $opts_ref);
116 68 50       156 $_CONTINUE = 0 if $_CONTINUE;
117             }
118 65         109 return $status;
119             }
120              
121             # ----------------------------------------------------------------
122             # Execute one SH line
123             # ----------------------------------------------------------------
124             sub _exec_line {
125 68     68   101 my ($class, $raw, $opts_ref) = @_;
126              
127 68         73 my $line = $raw;
128 68         102 $line =~ s/\A\s+//;
129 68 50       134 return 0 if $line =~ /\A\s*\z/;
130 68 50       127 return 0 if $line =~ /\A\s*#/;
131              
132             # Shebang: treat as comment
133 68 50       123 return 0 if $line =~ /\A#!/;
134              
135             # Expand variables and command substitutions
136 68         96 $line = _expand($class, $line);
137              
138             # Strip trailing ;
139 68         113 $line =~ s/\s*;\s*\z//;
140              
141 68         108 my ($cmd, $rest) = _split_sh($line);
142 68 50 33     215 return 0 unless defined $cmd && $cmd ne '';
143              
144 68         84 my $lc_cmd = lc($cmd);
145              
146             # Simple assignment: VAR=value (no spaces around =)
147 68 100       138 if ($cmd =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
148 25         45 my ($var, $val) = ($1, $2); # capture before $1 is clobbered
149             # Strip outermost quotes from value
150 25         45 $val =~ s/\A"(.*)"\z/$1/s;
151 25         32 $val =~ s/\A'(.*)'\z/$1/s;
152 25         99 BATsh::Env->set($var, $val);
153 25         28 $LAST_STATUS = 0;
154 25         47 return 0;
155             }
156              
157 43 100       64 if ($lc_cmd eq 'export') { return _cmd_export($rest) }
  24         40  
158 19 100       33 if ($lc_cmd eq 'unset') { return _cmd_unset($rest) }
  1         3  
159 18 50       25 if ($lc_cmd eq 'echo') { return _cmd_echo($rest) }
  0         0  
160 18 50       28 if ($lc_cmd eq 'printf') { return _cmd_printf($rest) }
  0         0  
161 18 50       26 if ($lc_cmd eq 'cd') { return _cmd_cd($rest) }
  0         0  
162 18 50       26 if ($lc_cmd eq 'pwd') { print Cwd::cwd(), "\n"; return 0 }
  0         0  
  0         0  
163 18 50       24 if ($lc_cmd eq 'exit') { return _cmd_exit($rest) }
  0         0  
164 18 100       35 if ($lc_cmd eq 'true') { $LAST_STATUS = 0; return 0 }
  1         1  
  1         2  
165 17 100       28 if ($lc_cmd eq 'false') { $LAST_STATUS = 1; return 1 }
  1         2  
  1         2  
166 16 50       26 if ($lc_cmd eq ':') { $LAST_STATUS = 0; return 0 }
  0         0  
  0         0  
167 16 50       23 if ($lc_cmd eq 'read') { return _cmd_read($rest) }
  0         0  
168 16 50 33     46 if ($lc_cmd eq 'test' || $cmd eq '[') { return _cmd_test($rest) }
  16         37  
169 0 0 0     0 if ($lc_cmd eq 'source' || $cmd eq '.') { return _cmd_source($class, $rest, $opts_ref) }
  0         0  
170 0 0       0 if ($lc_cmd eq 'return') { $_RETURN = 1; $LAST_STATUS = ($rest =~ /\A\s*(\d+)/) ? int($1) : 0; return $LAST_STATUS }
  0 0       0  
  0         0  
  0         0  
171 0 0       0 if ($lc_cmd eq 'break') { $_BREAK = 1; return 0 }
  0         0  
  0         0  
172 0 0       0 if ($lc_cmd eq 'continue') { $_CONTINUE = 1; return 0 }
  0         0  
  0         0  
173 0 0       0 if ($lc_cmd eq 'shift') { return _cmd_shift() }
  0         0  
174 0 0       0 if ($lc_cmd eq 'local') { return _cmd_local($rest) }
  0         0  
175 0 0       0 if ($lc_cmd eq 'set') { return _cmd_set_sh($rest) }
  0         0  
176              
177             # Unknown: try as external (runs via Perl system)
178 0         0 return _cmd_external($cmd, $rest);
179             }
180              
181             # ----------------------------------------------------------------
182             # Variable / arithmetic expansion
183             # ----------------------------------------------------------------
184             sub _expand {
185 69     69   101 my ($class, $str) = @_;
186 69 50       95 return '' unless defined $str;
187              
188             # $(( arithmetic ))
189 69         143 $str =~ s/\$\(\(\s*(.*?)\s*\)\)/_eval_arith($1)/ge;
  9         16  
190              
191             # $( command ) substitution
192 69         85 $str =~ s/\$\(([^)]*)\)/_cmd_subst($class, $1)/ge;
  0         0  
193              
194             # ${VAR:-default} ${VAR:=default} ${VAR:+alt} ${VAR}
195 69         79 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):-(.*?)\}/
196 1 50 33     2 do { my $v = BATsh::Env->get($1); (defined $v && $v ne '') ? $v : $2 }
  1         4  
  1         5  
197             /ge;
198 69         91 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*):=(.*?)\}/
199 0         0 do {
200 0         0 my $v = BATsh::Env->get($1);
201 0 0 0     0 if (!defined $v || $v eq '') { BATsh::Env->set($1,$2); $v = $2 }
  0         0  
  0         0  
202             $v
203 0         0 }
204             /ge;
205 69         91 $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
206 12 50       11 do { my $v = BATsh::Env->get($1); defined $v ? $v : '' }
  12         35  
  12         30  
207             /ge;
208              
209             # $? last status
210 69         76 $str =~ s/\$\?/$LAST_STATUS/g;
211              
212             # $VAR
213 69         169 $str =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/
214 25 50       52 do { my $v = BATsh::Env->get($1); defined $v ? $v : '' }
  25         70  
  25         102  
215             /ge;
216              
217 69         130 return $str;
218             }
219              
220             # ----------------------------------------------------------------
221             # Arithmetic evaluator
222             # ----------------------------------------------------------------
223             sub _eval_arith {
224 9     9   21 my ($expr) = @_;
225             # Replace VAR names with numeric values
226 9         21 $expr =~ s/([A-Za-z_][A-Za-z0-9_]*)/_arith_var($1)/ge;
  8         10  
227             # Safe eval: digits, operators, parens only
228 9 50       24 if ($expr =~ /\A[\d\s\+\-\*\/\%\(\)]+\z/) {
229 9         520 my $result = eval $expr;
230 9 50       53 return defined $result ? int($result) : 0;
231             }
232 0         0 return 0;
233             }
234              
235             sub _arith_var {
236 8     8   15 my ($name) = @_;
237 8         17 my $v = BATsh::Env->get($name);
238 8 50 33     45 return (defined $v && $v =~ /\A-?\d+\z/) ? $v : 0;
239             }
240              
241             # ----------------------------------------------------------------
242             # Command substitution $( cmd )
243             # ----------------------------------------------------------------
244             sub _cmd_subst {
245 0     0   0 my ($class, $cmd_str) = @_;
246             # Capture stdout via temporary file (5.005_03 compatible)
247 0         0 my $tmpfile = File::Spec->catfile(
248             File::Spec->tmpdir(), "batsh_cap_$$.tmp");
249 0         0 local *OLD_STDOUT;
250 0 0       0 open(OLD_STDOUT, '>&STDOUT') or return '';
251 0         0 local *CAPFH;
252 0 0       0 open(CAPFH, "> $tmpfile") or do { open(STDOUT, '>&OLD_STDOUT'); return '' };
  0         0  
  0         0  
253 0         0 open(STDOUT, '>&CAPFH');
254 0         0 eval {
255 0         0 my @sub_lines = split /\n/, $cmd_str;
256 0         0 _run_lines($class, \@sub_lines, {});
257             };
258 0         0 open(STDOUT, '>&OLD_STDOUT');
259 0         0 close(CAPFH);
260 0         0 close(OLD_STDOUT);
261 0         0 my $output = '';
262 0 0       0 if (open(READFH, "< $tmpfile")) {
263 0         0 local $/;
264 0         0 $output = ;
265 0         0 close(READFH);
266             }
267 0         0 unlink $tmpfile;
268 0 0       0 $output = '' unless defined $output;
269 0         0 $output =~ s/\n+\z//; # strip trailing newlines (like shell)
270 0         0 return $output;
271             }
272              
273             # ----------------------------------------------------------------
274             # export
275             # ----------------------------------------------------------------
276             sub _cmd_export {
277 24     24   34 my ($rest) = @_;
278 24         37 $rest =~ s/\A\s+//;
279             # export -p: print all
280 24 50       38 if ($rest =~ /\A-p\s*\z/) {
281 0         0 for my $k (sort keys %BATsh::Env::STORE) {
282 0         0 my $v = $BATsh::Env::STORE{$k};
283 0         0 $v =~ s/'/'\\''/g;
284 0         0 print "export $k='$v'\n";
285             }
286 0         0 return 0;
287             }
288             # export VAR=value or export VAR
289 24         49 for my $item (split /\s+/, $rest) {
290 24 100       102 if ($item =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
    50          
291 23         70 BATsh::Env->set($1, $2);
292             }
293             elsif ($item =~ /\A([A-Za-z_][A-Za-z0-9_]*)\z/) {
294             # export existing variable (already in store; no-op)
295             }
296             }
297 24         27 $LAST_STATUS = 0;
298 24         53 return 0;
299             }
300              
301             # ----------------------------------------------------------------
302             # unset
303             # ----------------------------------------------------------------
304             sub _cmd_unset {
305 1     1   3 my ($rest) = @_;
306 1         2 for my $var (split /\s+/, $rest) {
307 1         2 $var =~ s/\A\s+//; $var =~ s/\s+\z//;
  1         17  
308 1 50       6 BATsh::Env->unset($var) if $var ne '';
309             }
310 1         1 $LAST_STATUS = 0;
311 1         2 return 0;
312             }
313              
314             # ----------------------------------------------------------------
315             # echo
316             # ----------------------------------------------------------------
317             sub _cmd_echo {
318 0     0   0 my ($rest) = @_;
319 0         0 $rest =~ s/\A\s+//;
320 0         0 my $no_newline = 0;
321 0 0       0 if ($rest =~ s/\A-n\s*//) { $no_newline = 1 }
  0         0  
322             # -e: enable escape sequences
323 0         0 my $escape = 0;
324 0 0       0 if ($rest =~ s/\A-e\s*//) { $escape = 1 }
  0         0  
325 0 0       0 if ($escape) {
326 0         0 $rest =~ s/\\n/\n/g;
327 0         0 $rest =~ s/\\t/\t/g;
328 0         0 $rest =~ s/\\r/\r/g;
329 0         0 $rest =~ s/\\\\/\\/g;
330             }
331             # Strip surrounding quotes
332 0         0 $rest =~ s/\A"(.*)"\z/$1/s;
333 0         0 $rest =~ s/\A'(.*)'\z/$1/s;
334 0 0       0 if ($no_newline) { print $rest }
  0         0  
335 0         0 else { print "$rest\n" }
336 0         0 $LAST_STATUS = 0;
337 0         0 return 0;
338             }
339              
340             # ----------------------------------------------------------------
341             # printf
342             # ----------------------------------------------------------------
343             sub _cmd_printf {
344 0     0   0 my ($rest) = @_;
345 0         0 $rest =~ s/\A\s+//;
346             # Extract format string (first quoted arg or first word)
347 0         0 my ($fmt, @args);
348 0 0       0 if ($rest =~ s/\A"((?:[^"\\]|\\.)*)"\s*//) {
    0          
349 0         0 $fmt = $1;
350             }
351             elsif ($rest =~ s/\A'([^']*)'\s*//) {
352 0         0 $fmt = $1;
353             }
354             else {
355 0         0 ($fmt, $rest) = split /\s+/, $rest, 2;
356 0 0       0 $rest = '' unless defined $rest;
357             }
358 0         0 @args = split /\s+/, $rest;
359 0         0 $fmt =~ s/\\n/\n/g;
360 0         0 $fmt =~ s/\\t/\t/g;
361 0         0 eval { printf $fmt, @args };
  0         0  
362 0         0 $LAST_STATUS = 0;
363 0         0 return 0;
364             }
365              
366             # ----------------------------------------------------------------
367             # cd
368             # ----------------------------------------------------------------
369             sub _cmd_cd {
370 0     0   0 my ($rest) = @_;
371 0         0 $rest =~ s/\A\s+//;
372 0         0 $rest =~ s/\s+\z//;
373 0 0 0     0 if ($rest eq '' || $rest eq '~') {
374 0   0     0 $rest = $ENV{'HOME'} || BATsh::Env->get('HOME') || '.';
375             }
376 0 0       0 unless (chdir($rest)) {
377 0         0 print STDERR "cd: $rest: No such file or directory\n";
378 0         0 $LAST_STATUS = 1;
379 0         0 return 1;
380             }
381 0         0 BATsh::Env->set('PWD', Cwd::cwd());
382 0         0 $LAST_STATUS = 0;
383 0         0 return 0;
384             }
385              
386             # ----------------------------------------------------------------
387             # exit
388             # ----------------------------------------------------------------
389             sub _cmd_exit {
390 0     0   0 my ($rest) = @_;
391 0         0 $rest =~ s/\A\s+//;
392 0 0       0 my $code = ($rest =~ /\A(\d+)/) ? int($1) : 0;
393 0         0 $_EXIT_CODE = $code;
394 0         0 $LAST_STATUS = $code;
395 0         0 return $code;
396             }
397              
398             # ----------------------------------------------------------------
399             # read
400             # ----------------------------------------------------------------
401             sub _cmd_read {
402 0     0   0 my ($rest) = @_;
403 0         0 $rest =~ s/\A\s+//;
404 0         0 $rest =~ s/\s+\z//;
405 0         0 my $line = ;
406 0 0       0 $line = '' unless defined $line;
407 0         0 chomp $line;
408 0         0 my @vars = split /\s+/, $rest;
409 0 0       0 if (@vars == 1) {
    0          
410 0         0 BATsh::Env->set($vars[0], $line);
411             }
412             elsif (@vars > 1) {
413 0         0 my @words = split /\s+/, $line, scalar(@vars);
414 0         0 for my $i (0 .. $#vars) {
415 0 0       0 BATsh::Env->set($vars[$i], defined($words[$i]) ? $words[$i] : '');
416             }
417             }
418 0         0 $LAST_STATUS = 0;
419 0         0 return 0;
420             }
421              
422             # ----------------------------------------------------------------
423             # shift
424             # ----------------------------------------------------------------
425             sub _cmd_shift {
426             # Shift positional params $1..$9
427 0     0   0 for my $n (1 .. 8) {
428 0         0 my $next = BATsh::Env->get('BATSH_ARG' . ($n + 1));
429 0 0       0 BATsh::Env->set('BATSH_ARG' . $n, defined($next) ? $next : '');
430             }
431 0         0 BATsh::Env->set('BATSH_ARG9', '');
432 0         0 $LAST_STATUS = 0;
433 0         0 return 0;
434             }
435              
436             # ----------------------------------------------------------------
437             # local
438             # ----------------------------------------------------------------
439             sub _cmd_local {
440 0     0   0 my ($rest) = @_;
441 0         0 $rest =~ s/\A\s+//;
442 0 0       0 if ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/s) {
443 0         0 BATsh::Env->set($1, $2);
444             }
445 0         0 $LAST_STATUS = 0;
446 0         0 return 0;
447             }
448              
449             # ----------------------------------------------------------------
450             # set (sh set options -- minimal implementation)
451             # ----------------------------------------------------------------
452             sub _cmd_set_sh {
453 0     0   0 my ($rest) = @_;
454 0         0 $rest =~ s/\A\s+//;
455             # set -e, set +e, set -x, set +x: accepted silently
456 0         0 $LAST_STATUS = 0;
457 0         0 return 0;
458             }
459              
460             # ----------------------------------------------------------------
461             # source / .
462             # ----------------------------------------------------------------
463             sub _cmd_source {
464 0     0   0 my ($class, $rest, $opts_ref) = @_;
465 0         0 $rest =~ s/\A\s+//;
466 0         0 $rest =~ s/\s+\z//;
467 0 0       0 if (defined $opts_ref->{'_batsh'}) {
468 0         0 eval { $opts_ref->{'_batsh'}->source_file($rest) };
  0         0  
469 0 0       0 if ($@) { print STDERR "source: $rest: $@\n"; return 1 }
  0         0  
  0         0  
470             }
471 0         0 return 0;
472             }
473              
474             # ----------------------------------------------------------------
475             # test / [ ]
476             # ----------------------------------------------------------------
477             sub _cmd_test {
478 16     16   40 my ($rest) = @_;
479 16         26 $rest =~ s/\A\s+//;
480 16         63 $rest =~ s/\s*\]\s*\z//; # strip trailing ]
481 16         31 my $result = _eval_test($rest);
482 16 100       25 $LAST_STATUS = $result ? 0 : 1;
483 16         33 return $LAST_STATUS;
484             }
485              
486             sub _eval_test {
487 16     16   20 my ($expr) = @_;
488 16         22 $expr =~ s/\A\s+//;
489 16         31 $expr =~ s/\s+\z//;
490              
491             # Compound: -a (AND), -o (OR)
492 16 50       32 if ($expr =~ /^(.*)\s+-a\s+(.*)$/) {
493 0   0     0 return _eval_test($1) && _eval_test($2);
494             }
495 16 50       31 if ($expr =~ /^(.*)\s+-o\s+(.*)$/) {
496 0   0     0 return _eval_test($1) || _eval_test($2);
497             }
498             # Negation
499 16 50       23 if ($expr =~ /^!\s+(.*)$/) {
500 0         0 return !_eval_test($1);
501             }
502              
503             # File tests
504 16 100       29 if ($expr =~ /\A(-[a-z])\s+(.+)\z/) {
505 2         6 my ($op, $path) = ($1, $2);
506 2         5 $path =~ s/\A"//; $path =~ s/"\z//;
  2         6  
507 2 0       6 if ($op eq '-e') { return -e $path ? 1 : 0 }
  0 50       0  
508 2 0       5 if ($op eq '-f') { return -f $path ? 1 : 0 }
  0 50       0  
509 2 0       7 if ($op eq '-d') { return -d $path ? 1 : 0 }
  0 50       0  
510 2 0       4 if ($op eq '-r') { return -r $path ? 1 : 0 }
  0 50       0  
511 2 0       4 if ($op eq '-w') { return -w $path ? 1 : 0 }
  0 50       0  
512 2 0       6 if ($op eq '-x') { return -x $path ? 1 : 0 }
  0 50       0  
513 2 0       3 if ($op eq '-s') { return (-s $path) ? 1 : 0 }
  0 50       0  
514 2 50 33     18 if ($op eq '-z') { my $s = -s $path; return (!defined $s || $s == 0) ? 1 : 0 }
  1 100       9  
  1         5  
515 1 50       2 if ($op eq '-n') { return (length($path) > 0) ? 1 : 0 }
  1 50       4  
516 0 0       0 if ($op eq '-L') { return -l $path ? 1 : 0 }
  0 0       0  
517             }
518              
519             # String comparisons: = == != < >
520 14 50       55 if ($expr =~ /\A(.+?)\s+(=|==|!=|<|>)\s+(.+)\z/) {
521 0         0 my ($a, $op, $b) = ($1, $2, $3);
522 0         0 $a =~ s/\A"//; $a =~ s/"\z//;
  0         0  
523 0         0 $b =~ s/\A"//; $b =~ s/"\z//;
  0         0  
524 0 0 0     0 if ($op eq '=' || $op eq '==') { return ($a eq $b) ? 1 : 0 }
  0 0       0  
525 0 0       0 if ($op eq '!=') { return ($a ne $b) ? 1 : 0 }
  0 0       0  
526 0 0       0 if ($op eq '<') { return ($a lt $b) ? 1 : 0 }
  0 0       0  
527 0 0       0 if ($op eq '>') { return ($a gt $b) ? 1 : 0 }
  0 0       0  
528             }
529              
530             # Integer comparisons: -eq -ne -lt -le -gt -ge
531 14 50       49 if ($expr =~ /\A(.+?)\s+(-eq|-ne|-lt|-le|-gt|-ge)\s+(.+)\z/) {
532 14         40 my ($a, $op, $b) = ($1, $2, $3);
533 14         17 $a =~ s/\A"//; $a =~ s/"\z//;
  14         19  
534 14         28 $b =~ s/\A"//; $b =~ s/"\z//;
  14         17  
535 14 50       42 $a = int($a) if $a =~ /\A-?\d+\z/;
536 14 50       28 $b = int($b) if $b =~ /\A-?\d+\z/;
537 14 100       24 if ($op eq '-eq') { return ($a == $b) ? 1 : 0 }
  2 100       6  
538 12 0       20 if ($op eq '-ne') { return ($a != $b) ? 1 : 0 }
  0 50       0  
539 12 100       18 if ($op eq '-lt') { return ($a < $b) ? 1 : 0 }
  6 100       19  
540 6 0       10 if ($op eq '-le') { return ($a <= $b) ? 1 : 0 }
  0 50       0  
541 6 50       8 if ($op eq '-gt') { return ($a > $b) ? 1 : 0 }
  2 100       8  
542 4 100       7 if ($op eq '-ge') { return ($a >= $b) ? 1 : 0 }
  4 50       8  
543             }
544              
545             # -n string (non-empty)
546 0 0       0 if ($expr =~ /\A-n\s+(.+)\z/) {
547 0         0 my $s = $1; $s =~ s/\A"//; $s =~ s/"\z//;
  0         0  
  0         0  
548 0 0       0 return length($s) > 0 ? 1 : 0;
549             }
550             # -z string (empty)
551 0 0       0 if ($expr =~ /\A-z\s+(.+)\z/) {
552 0         0 my $s = $1; $s =~ s/\A"//; $s =~ s/"\z//;
  0         0  
  0         0  
553 0 0       0 return length($s) == 0 ? 1 : 0;
554             }
555              
556             # bare string: true if non-empty
557 0         0 $expr =~ s/\A"//; $expr =~ s/"\z//;
  0         0  
558 0 0 0     0 return (length($expr) > 0 && $expr ne '0') ? 1 : 0;
559             }
560              
561             # ----------------------------------------------------------------
562             # if/then/else/elif/fi parser
563             # ----------------------------------------------------------------
564             sub _parse_if {
565 7     7   12 my ($class, $lines_ref, $start, $opts_ref) = @_;
566 7         10 my @lines = @{$lines_ref};
  7         15  
567 7         12 my $i = $start;
568              
569             # Collect: if cond; then ... [elif cond; then ...] [else ...] fi
570             # Build a structure: [ ['cond_lines'], ['body_lines'] ] ...
571 7         6 my @branches = (); # [ [$cond_lines], [$body_lines] ]
572 7         10 my $else_body = undef;
573              
574             # First line: if cond; then
575 7         25 my $if_line = $lines[$i]; $i++;
  7         7  
576 7         11 $if_line =~ s/\r?\n\z//; $if_line =~ s/\A\s+//;
  7         13  
577              
578             # Extract condition (after 'if', before 'then' or ';')
579 7         7 my $cond_str = $if_line;
580 7         17 $cond_str =~ s/\Aif\s+//i;
581              
582             # 1-line form: if COND; then BODY [; BODY ...]; fi
583             # Detect by presence of "; then " and trailing "; fi" on the same line
584 7 50       16 if ($cond_str =~ /\A(.+?)\s*;\s*then\s+(.+?)\s*;\s*fi\s*\z/i) {
585 0         0 my ($cond_part, $body_part) = ($1, $2);
586 0         0 my $cond_status = _run_lines($class, [$cond_part], $opts_ref);
587 0 0       0 if ($cond_status == 0) {
588 0         0 _run_lines($class, [split /\s*;\s*/, $body_part], $opts_ref);
589             }
590 0         0 return ($cond_status, $i);
591             }
592              
593 7         34 $cond_str =~ s/\s*;\s*then\s*\z//i;
594 7         16 $cond_str =~ s/\s+then\s*\z//i;
595              
596 7         13 my @cond_lines = ($cond_str);
597 7         6 my @body_lines = ();
598 7         11 my $state = 'body'; # reading body of if
599              
600 7         14 while ($i <= $#lines) {
601 16         18 my $l = $lines[$i]; $i++;
  16         27  
602 16         19 $l =~ s/\r?\n\z//;
603 16         14 my $ls = $l; $ls =~ s/\A\s+//;
  16         23  
604 16 50       61 my $lc_first = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
605              
606 16 100       36 if ($lc_first eq 'fi') {
    100          
    100          
    50          
607 4         8 push @branches, [ [@cond_lines], [@body_lines] ];
608 4         7 last;
609             }
610             elsif ($lc_first eq 'elif') {
611 1         3 push @branches, [ [@cond_lines], [@body_lines] ];
612 1         1 $cond_str = $ls;
613 1         3 $cond_str =~ s/\Aelif\s+//i;
614 1         4 $cond_str =~ s/\s*;\s*then\s*\z//i;
615 1         2 $cond_str =~ s/\s+then\s*\z//i;
616 1         2 @cond_lines = ($cond_str);
617 1         2 @body_lines = ();
618             }
619             elsif ($lc_first eq 'else') {
620 3         15 push @branches, [ [@cond_lines], [@body_lines] ];
621 3         7 @body_lines = ();
622             # Read until fi
623 3         7 while ($i <= $#lines) {
624 6         10 my $el = $lines[$i]; $i++;
  6         6  
625 6         8 $el =~ s/\r?\n\z//;
626 6         8 my $els = $el; $els =~ s/\A\s+//;
  6         12  
627 6 50       32 if (lc(($els =~ /\A(\S+)/) ? $1 : '') eq 'fi') { last }
  3 100       5  
628 3         6 push @body_lines, $el;
629             }
630 3         6 $else_body = [@body_lines];
631 3         4 last;
632             }
633             elsif ($lc_first eq 'then') {
634             # 'then' on its own line: continue collecting body
635 0         0 next;
636             }
637             else {
638 8         16 push @body_lines, $l;
639             }
640             }
641              
642             # Evaluate branches
643 7         8 my $status = 0;
644 7         7 my $executed = 0;
645 7         9 for my $branch (@branches) {
646 8         9 my ($cond_ref, $body_ref) = @{$branch};
  8         12  
647 8         23 my $cond_status = _run_lines($class, $cond_ref, $opts_ref);
648 8 100       11 if ($cond_status == 0) {
649 6         10 $status = _run_lines($class, $body_ref, $opts_ref);
650 6         7 $executed = 1;
651 6         15 last;
652             }
653             }
654 7 100 66     23 if (!$executed && defined $else_body) {
655 1         5 $status = _run_lines($class, $else_body, $opts_ref);
656             }
657              
658 7         43 return ($status, $i);
659             }
660              
661             # ----------------------------------------------------------------
662             # for VAR in list; do ... done
663             # ----------------------------------------------------------------
664             sub _parse_for {
665 2     2   5 my ($class, $lines_ref, $start, $opts_ref) = @_;
666 2         3 my @lines = @{$lines_ref};
  2         6  
667 2         3 my $i = $start;
668              
669 2         3 my $for_line = $lines[$i]; $i++;
  2         3  
670 2         4 $for_line =~ s/\r?\n\z//; $for_line =~ s/\A\s+//;
  2         4  
671              
672             # for VAR in item1 item2 ...; do
673 2         5 my ($var, $list_str) = ('', '');
674 2 50       22 if ($for_line =~ /\Afor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+(.*?)\s*(?:;\s*do)?\s*\z/i) {
675 2         7 ($var, $list_str) = ($1, $2);
676             }
677              
678             # Collect body until 'done'
679 2         3 my @body = ();
680 2         3 my $depth = 1;
681 2         5 while ($i <= $#lines) {
682 4         6 my $l = $lines[$i]; $i++;
  4         5  
683 4         4 $l =~ s/\r?\n\z//;
684 4         5 my $ls = $l; $ls =~ s/\A\s+//;
  4         6  
685 4 50       12 my $lc_f = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
686 4 50 33     37 if ($lc_f eq 'for' || $lc_f eq 'while' || $lc_f eq 'until') { $depth++ }
  0   33     0  
687 4 50       12 if ($lc_f eq 'done') { $depth--; last if $depth == 0 }
  2 100       2  
  2         6  
688 2 50 33     8 push @body, $l unless ($lc_f eq 'do' && $depth == 1);
689             }
690              
691             # Expand list items
692 2         5 my @items = split /\s+/, $list_str;
693 2         6 my $status = 0;
694 2         4 for my $val (@items) {
695 6         12 BATsh::Env->set($var, $val);
696 6         22 $_BREAK = 0; $_CONTINUE = 0;
  6         7  
697 6         16 $status = _run_lines($class, \@body, $opts_ref);
698 6 50 33     18 last if $_BREAK || defined $_EXIT_CODE;
699             }
700 2         3 $_BREAK = 0;
701              
702 2         6 return ($status, $i);
703             }
704              
705             # ----------------------------------------------------------------
706             # while/until condition; do ... done
707             # ----------------------------------------------------------------
708             sub _parse_while {
709 2     2   5 my ($class, $lines_ref, $start, $opts_ref) = @_;
710 2         3 my @lines = @{$lines_ref};
  2         5  
711 2         2 my $i = $start;
712              
713 2         3 my $while_line = $lines[$i]; $i++;
  2         2  
714 2         3 $while_line =~ s/\r?\n\z//; $while_line =~ s/\A\s+//;
  2         4  
715              
716 2 100       15 my $is_until = ($while_line =~ /\Auntil\s/i) ? 1 : 0;
717              
718             # Extract condition
719 2         3 my $cond_str = $while_line;
720 2         8 $cond_str =~ s/\A(?:while|until)\s+//i;
721 2         9 $cond_str =~ s/\s*;\s*do\s*\z//i;
722 2         6 $cond_str =~ s/\s+do\s*\z//i;
723              
724             # Collect body
725 2         2 my @body = ();
726 2         7 my $depth = 1;
727 2         4 while ($i <= $#lines) {
728 4         4 my $l = $lines[$i]; $i++;
  4         5  
729 4         8 $l =~ s/\r?\n\z//;
730 4         4 my $ls = $l; $ls =~ s/\A\s+//;
  4         7  
731 4 50       10 my $lc_f = lc( ($ls =~ /\A(\S+)/) ? $1 : '' );
732 4 50 33     17 if ($lc_f eq 'for' || $lc_f eq 'while' || $lc_f eq 'until') { $depth++ }
  0   33     0  
733 4 50       5 if ($lc_f eq 'done') { $depth--; last if $depth == 0 }
  2 100       2  
  2         5  
734 2 50 33     19 push @body, $l unless ($lc_f eq 'do' && $depth == 1);
735             }
736              
737 2         2 my $status = 0;
738 2         2 my $max_iter = 100_000; # safety guard
739 2         4 while ($max_iter-- > 0) {
740 10 50       14 last if defined $_EXIT_CODE;
741 10         20 my $cond_status = _run_lines($class, [$cond_str], $opts_ref);
742 10         18 my $cond_true = ($cond_status == 0);
743 10 100 100     23 last if $is_until && $cond_true;
744 9 100 100     22 last if !$is_until && !$cond_true;
745 8         9 $_BREAK = 0; $_CONTINUE = 0;
  8         7  
746 8         14 $status = _run_lines($class, \@body, $opts_ref);
747 8 50       20 last if $_BREAK;
748             }
749 2         4 $_BREAK = 0;
750              
751 2         9 return ($status, $i);
752             }
753              
754             # ----------------------------------------------------------------
755             # case $var in pattern) ... ;; esac
756             # ----------------------------------------------------------------
757             sub _parse_case {
758 1     1   3 my ($class, $lines_ref, $start, $opts_ref) = @_;
759 1         2 my @lines = @{$lines_ref};
  1         4  
760 1         2 my $i = $start;
761              
762 1         2 my $case_line = $lines[$i]; $i++;
  1         1  
763 1         2 $case_line =~ s/\r?\n\z//; $case_line =~ s/\A\s+//;
  1         3  
764              
765             # case WORD in
766 1         2 my $word = '';
767 1 50       8 if ($case_line =~ /\Acase\s+(.*?)\s+in\s*\z/i) {
768 1         2 $word = _expand(undef, $1);
769             }
770              
771             # Read patterns and bodies until esac
772 1         2 my $status = 0;
773 1         1 my $matched = 0;
774              
775 1         4 while ($i <= $#lines) {
776 4         9 my $pl = $lines[$i]; $i++;
  4         6  
777 4         6 $pl =~ s/\r?\n\z//; $pl =~ s/\A\s+//;
  4         13  
778 4 50       14 next if $pl =~ /\A\s*\z/;
779 4 50       14 my $lc_f = lc( ($pl =~ /\A(\S+)/) ? $1 : '' );
780 4 100       9 last if $lc_f eq 'esac';
781 3 50       11 next if $pl =~ /\A\s*;;\s*\z/; # stray ;; between patterns
782              
783             # Case 1: pattern) body ;; -- all on one line
784 3 50       23 if ($pl =~ /\A(.*?)\)\s*(.+?)\s*;;\s*\z/) {
785 3         11 my ($pattern_str, $inline_body) = ($1, $2);
786 3 100       5 if (!$matched) {
787 2         6 for my $pat (split /\|/, $pattern_str) {
788 2         3 $pat =~ s/\A\s+//; $pat =~ s/\s+\z//;
  2         5  
789 2 100       4 if (_match_pattern($word, $pat)) {
790 1         5 $status = _run_lines($class, [$inline_body], $opts_ref);
791 1         3 $matched = 1;
792 1         2 last;
793             }
794             }
795             }
796 3         9 next;
797             }
798              
799             # Case 2: pattern) -- pattern only, body on next lines until ;;
800 0 0       0 if ($pl =~ /\A(.*?)\)\s*\z/) {
801 0         0 my $pattern_str = $1;
802 0         0 my @body = ();
803 0         0 while ($i <= $#lines) {
804 0         0 my $bl = $lines[$i]; $i++;
  0         0  
805 0         0 $bl =~ s/\r?\n\z//;
806 0 0       0 last if $bl =~ /\A\s*;;\s*\z/;
807 0 0       0 if ($bl =~ /\A(.+?)\s*;;\s*\z/) { push @body, $1; last }
  0         0  
  0         0  
808 0         0 push @body, $bl;
809             }
810 0 0       0 if (!$matched) {
811 0         0 for my $pat (split /\|/, $pattern_str) {
812 0         0 $pat =~ s/\A\s+//; $pat =~ s/\s+\z//;
  0         0  
813 0 0       0 if (_match_pattern($word, $pat)) {
814 0         0 $status = _run_lines($class, \@body, $opts_ref);
815 0         0 $matched = 1;
816 0         0 last;
817             }
818             }
819             }
820             }
821             }
822              
823 1         4 return ($status, $i);
824             }
825              
826             # Shell glob pattern matching
827             sub _match_pattern {
828 2     2   5 my ($word, $pat) = @_;
829 2 50       20 return 1 if $pat eq '*';
830             # Convert shell glob to regex
831 2         5 my $re = quotemeta($pat);
832 2         3 $re =~ s/\\\*/.*/g;
833 2         4 $re =~ s/\\\?/./g;
834 2 100       41 return ($word =~ /\A$re\z/) ? 1 : 0;
835             }
836              
837             # ----------------------------------------------------------------
838             # External command
839             # ----------------------------------------------------------------
840             sub _cmd_external {
841 0     0   0 my ($cmd, $rest) = @_;
842 0 0       0 $rest = '' unless defined $rest;
843 0         0 $rest =~ s/\A\s+//;
844 0 0       0 my $full = $rest ne '' ? "$cmd $rest" : $cmd;
845 0         0 BATsh::Env->sync_to_env();
846 0         0 my $rc = system($full);
847 0 0 0     0 $LAST_STATUS = ($rc == 0) ? 0 : (($rc >> 8) || 1);
848 0         0 return $LAST_STATUS;
849             }
850              
851             # ----------------------------------------------------------------
852             # Split "cmd rest" honouring quoted strings
853             # ----------------------------------------------------------------
854             sub _split_sh {
855 68     68   83 my ($line) = @_;
856 68 50       192 if ($line =~ /\A(\S+)\s*(.*)\z/s) {
857 68         204 return ($1, $2);
858             }
859 0           return ($line, '');
860             }
861              
862             # ----------------------------------------------------------------
863             # Accessors
864             # ----------------------------------------------------------------
865 0     0 0   sub last_status { return $LAST_STATUS }
866 0     0 0   sub set_last_status { $LAST_STATUS = $_[1] }
867              
868             # Need Cwd
869             BEGIN {
870 5     5   26 eval { require Cwd };
  5         43  
871 5 50       274 if ($@) {
872 0         0 eval 'sub Cwd::cwd { return $ENV{PWD} || "." }';
873             }
874             }
875              
876             1;
877              
878             __END__