File Coverage

lib/BATsh/CMD.pm
Criterion Covered Total %
statement 265 564 46.9
branch 102 282 36.1
condition 14 71 19.7
subroutine 22 37 59.4
pod 0 4 0.0
total 403 958 42.0


line stmt bran cond sub pod time code
1             package BATsh::CMD;
2             ######################################################################
3             #
4             # BATsh::CMD - Pure Perl cmd.exe interpreter
5             #
6             # Implements the cmd.exe command set in Perl.
7             # No external cmd.exe or shell required.
8             #
9             # Supported:
10             # ECHO, @ECHO OFF/ON
11             # SET VAR=value, SET /A expr
12             # IF string==string, IF EXIST, IF NOT, IF ERRORLEVEL, IF DEFINED
13             # IF (...) ELSE (...)
14             # FOR %%V IN (list) DO cmd
15             # FOR /L %%V IN (start,step,end) DO cmd
16             # FOR /F ... (limited)
17             # GOTO :label, :label
18             # CALL :label [args], CALL file.batsh
19             # SETLOCAL, ENDLOCAL
20             # CD / CHDIR
21             # DIR
22             # COPY, DEL / ERASE, MOVE, MKDIR / MD, RMDIR / RD, REN / RENAME
23             # TYPE
24             # PAUSE
25             # EXIT [/B] [code]
26             # CLS
27             # REM / :: (comments)
28             # TITLE
29             #
30             ######################################################################
31              
32 5     5   36 use strict;
  5         29  
  5         388  
33 5 50 33 5   157 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
34 5     5   25 use warnings; local $^W = 1;
  5         9  
  5         364  
35 5 50   5   167 BEGIN { pop @INC if $INC[-1] eq '.' }
36              
37 5     5   34 use File::Spec ();
  5         9  
  5         86  
38 5     5   2865 use File::Copy ();
  5         30548  
  5         154  
39 5     5   39 use File::Path ();
  5         10  
  5         152  
40 5     5   24 use Carp qw(croak);
  5         8  
  5         354  
41 5     5   29 use vars qw($VERSION);
  5         7  
  5         56512  
42             $VERSION = '0.01';
43             $VERSION = $VERSION;
44              
45             require BATsh::Env;
46              
47             # ----------------------------------------------------------------
48             # State
49             # ----------------------------------------------------------------
50             my $ECHO_ON = 1; # @ECHO OFF sets to 0
51             my $ERRORLEVEL = 0; # last command exit code
52              
53             # For GOTO support: set by exec_block, read by exec_line
54             my $_GOTO_LABEL = ''; # '' means no pending GOTO
55              
56             # ----------------------------------------------------------------
57             # Public: execute an array of CMD lines
58             # Returns exit code (0 = success)
59             # ----------------------------------------------------------------
60             sub exec_block {
61 26     26 0 106 my ($class, $lines_ref, %opts) = @_;
62 26         40 my @lines = @{$lines_ref};
  26         60  
63              
64             # Build label index: label name (uppercase) -> line index
65 26         48 my %labels = ();
66 26         76 for my $i (0 .. $#lines) {
67 40         67 my $l = $lines[$i];
68 40         96 $l =~ s/\r?\n\z//;
69 40         97 $l =~ s/\A\s+//;
70 40 100       115 if ($l =~ /\A:([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
71 1         10 $labels{uc($1)} = $i;
72             }
73             }
74              
75 26         42 my $i = 0;
76 26         66 while ($i <= $#lines) {
77 34         53 my $raw = $lines[$i];
78 34         69 $i++;
79 34         52 $raw =~ s/\r?\n\z//;
80              
81 34         109 my $rc = _exec_line($class, $raw, \@lines, \%labels, \$i, \%opts);
82              
83             # GOTO: jump to label
84 34 100       107 if ($_GOTO_LABEL ne '') {
85 1         3 my $lbl = $_GOTO_LABEL;
86 1         2 $_GOTO_LABEL = '';
87 1 50       4 if (exists $labels{$lbl}) {
    0          
88 1         3 $i = $labels{$lbl} + 1;
89             }
90             elsif ($lbl eq 'EOF') {
91 0         0 last; # GOTO :EOF exits the block
92             }
93             else {
94 0         0 _warn("GOTO: label :$lbl not found");
95             }
96 1         4 next;
97             }
98              
99             # EXIT /B or EXIT with code
100 33 50 33     165 if (defined $rc && $rc eq '__EXIT__') {
101 0         0 return $ERRORLEVEL;
102             }
103             }
104 26         155 return $ERRORLEVEL;
105             }
106              
107             # ----------------------------------------------------------------
108             # Execute one logical line (may recurse for IF bodies, FOR bodies)
109             # ----------------------------------------------------------------
110             sub _exec_line {
111 51     51   128 my ($class, $raw, $lines_ref, $labels_ref, $i_ref, $opts_ref, $pre_expanded) = @_;
112 51 100       112 $pre_expanded = 0 unless defined $pre_expanded;
113              
114 51         72 my $line = $raw;
115 51         109 $line =~ s/\A\s+//;
116              
117 51         74 my $suppress_echo = 0;
118 51 100       115 if ($line =~ s/\A\@//) { $suppress_echo = 1; }
  1         3  
119              
120 51 50       143 return 0 if $line =~ /\A\s*\z/;
121 51 50       137 return 0 if $line =~ /\A::/;
122 51 50       114 return 0 if $line =~ /\AREM(?:\s|\z)/i;
123 51 50       102 return 0 if $line =~ /\A:[A-Za-z_]/;
124 51 50       107 return 0 if $line =~ /\A\s*\)\s*(?:ELSE\s*.*)??\s*\z/i;
125 51 50       99 return 0 if $line =~ /\A#/; # SH-style comment inside CMD block
126              
127 51 100       103 if (!$pre_expanded) {
128             # For FOR lines: protect %%V AND defer DO-part expansion to the loop
129 39 100       94 if ($line =~ /\AFOR\s/i) {
130             # Only expand the IN(list) portion; protect the DO part
131 3 50       26 if ($line =~ /\A(FOR\s+(?:\/[A-Za-z]\s+)?%%[A-Za-z]\s+(?:\/[A-Za-z]\s+)?IN\s*\([^)]*\)\s+DO\s+)(.*)\z/i) {
132 3         12 my ($for_head, $do_part) = ($1, $2);
133             # Protect %%V in head
134 3         16 $for_head =~ s/%%([A-Za-z])/"\x00FOR_$1\x00"/ge;
  3         18  
135 3         34 $for_head = BATsh::Env->expand_cmd($for_head);
136 3         50 $for_head =~ s/\x00FOR_([A-Za-z])\x00/%%$1/g;
137             # Protect ALL %VAR% in do_part with placeholder so loop re-expands them
138 3         12 $do_part =~ s/%%([A-Za-z])/"\x00FOR_$1\x00"/ge;
  1         5  
139 3         15 $do_part =~ s/%([^%\r\n]+)%/"\x00PCT_$1\x00"/ge;
  3         11  
140 3         10 $line = $for_head . $do_part;
141             }
142             else {
143 0         0 $line =~ s/%%([A-Za-z])/"\x00FOR_$1\x00"/ge;
  0         0  
144 0         0 $line = BATsh::Env->expand_cmd($line);
145 0         0 $line =~ s/\x00FOR_([A-Za-z])\x00/%%$1/g;
146             }
147             }
148             else {
149 36         66 $line =~ s/%%([A-Za-z])/"\x00FOR_$1\x00"/ge;
  0         0  
150 36         179 $line = BATsh::Env->expand_cmd($line);
151 36         89 $line =~ s/\x00FOR_([A-Za-z])\x00/%%$1/g;
152             }
153             }
154              
155 51         113 return _dispatch($class, $line, $lines_ref, $labels_ref, $i_ref, $opts_ref);
156             }
157              
158             # ----------------------------------------------------------------
159             # Command dispatcher
160             # ----------------------------------------------------------------
161             sub _dispatch {
162 51     51   115 my ($class, $line, $lines_ref, $labels_ref, $i_ref, $opts_ref) = @_;
163              
164             # Tokenize: first word is the command
165 51         110 my ($cmd, $rest) = _split_cmd($line);
166 51 50 33     223 return 0 unless defined $cmd && $cmd ne '';
167              
168 51         99 my $CMD = uc($cmd);
169              
170 51 100       103 if ($CMD eq 'ECHO') {
171 3         31 return _cmd_echo($rest);
172             }
173 48 50       123 if ($CMD eq '@ECHO') {
174             # already stripped @ above; should not reach here
175 0         0 return _cmd_echo($rest);
176             }
177 48 100       94 if ($CMD eq 'SET') {
178 38         77 return _cmd_set($rest);
179             }
180 10 100       27 if ($CMD eq 'IF') {
181 6         18 return _cmd_if($class, $line, $lines_ref, $labels_ref, $i_ref, $opts_ref);
182             }
183 4 100       29 if ($CMD eq 'FOR') {
184 3         11 return _cmd_for($class, $line, $lines_ref, $labels_ref, $i_ref, $opts_ref);
185             }
186 1 50       22 if ($CMD eq 'GOTO') {
187 1         5 $rest =~ s/\A\s+//;
188 1         3 $rest =~ s/\s+\z//;
189 1         5 $rest =~ s/\A://;
190 1         4 $_GOTO_LABEL = uc($rest);
191 1         4 return 0;
192             }
193 0 0       0 if ($CMD eq 'CALL') {
194 0         0 return _cmd_call($class, $rest, $opts_ref);
195             }
196 0 0       0 if ($CMD eq 'SETLOCAL') {
197 0         0 BATsh::Env::setlocal();
198 0         0 return 0;
199             }
200 0 0       0 if ($CMD eq 'ENDLOCAL') {
201 0         0 BATsh::Env::endlocal();
202 0         0 return 0;
203             }
204 0 0 0     0 if ($CMD eq 'CD' || $CMD eq 'CHDIR') {
205 0         0 return _cmd_cd($rest);
206             }
207 0 0       0 if ($CMD eq 'DIR') {
208 0         0 return _cmd_dir($rest);
209             }
210 0 0       0 if ($CMD eq 'COPY') {
211 0         0 return _cmd_copy($rest);
212             }
213 0 0 0     0 if ($CMD eq 'DEL' || $CMD eq 'ERASE') {
214 0         0 return _cmd_del($rest);
215             }
216 0 0       0 if ($CMD eq 'MOVE') {
217 0         0 return _cmd_move($rest);
218             }
219 0 0 0     0 if ($CMD eq 'MKDIR' || $CMD eq 'MD') {
220 0         0 return _cmd_mkdir($rest);
221             }
222 0 0 0     0 if ($CMD eq 'RMDIR' || $CMD eq 'RD') {
223 0         0 return _cmd_rmdir($rest);
224             }
225 0 0 0     0 if ($CMD eq 'REN' || $CMD eq 'RENAME') {
226 0         0 return _cmd_rename($rest);
227             }
228 0 0       0 if ($CMD eq 'TYPE') {
229 0         0 return _cmd_type($rest);
230             }
231 0 0       0 if ($CMD eq 'PAUSE') {
232 0         0 print "Press any key to continue . . . ";
233 0         0 my $ch = '';
234 0         0 eval { local $| = 1; require POSIX; POSIX::tcgetattr(0); read(STDIN, $ch, 1) };
  0         0  
  0         0  
  0         0  
  0         0  
235 0         0 print "\n";
236 0         0 return 0;
237             }
238 0 0       0 if ($CMD eq 'EXIT') {
239 0         0 $rest =~ s/\A\s+//;
240 0 0       0 my $is_b = ($rest =~ s{/B\s*}{}i) ? 1 : 0;
241 0         0 $rest =~ s/\A\s+//;
242 0 0       0 $ERRORLEVEL = ($rest =~ /\A\d+\z/) ? int($rest) : 0;
243 0         0 return '__EXIT__';
244             }
245 0 0       0 if ($CMD eq 'CLS') {
246 0         0 print "\033[2J\033[H"; # ANSI clear screen
247 0         0 return 0;
248             }
249 0 0       0 if ($CMD eq 'TITLE') {
250             # Set terminal title (best-effort)
251 0         0 print "\033]0;$rest\007";
252 0         0 return 0;
253             }
254 0 0       0 if ($CMD eq 'VER') {
255 0         0 print "BATsh [Version $BATsh::VERSION]\n";
256 0         0 return 0;
257             }
258 0 0       0 if ($CMD eq 'PUSHD') {
259 0         0 $rest =~ s/\A\s+//; $rest =~ s/\s+\z//;
  0         0  
260 0         0 push @{$opts_ref->{'_pushd_stack'}}, Cwd::cwd();
  0         0  
261 0         0 return _cmd_cd($rest);
262             }
263 0 0       0 if ($CMD eq 'POPD') {
264 0 0 0     0 if (defined $opts_ref->{'_pushd_stack'} && @{$opts_ref->{'_pushd_stack'}}) {
  0         0  
265 0         0 chdir(pop @{$opts_ref->{'_pushd_stack'}});
  0         0  
266             }
267 0         0 return 0;
268             }
269              
270             # Unknown command: try as external executable
271 0         0 return _cmd_external($cmd, $rest);
272             }
273              
274             # ----------------------------------------------------------------
275             # ECHO
276             # ----------------------------------------------------------------
277             sub _cmd_echo {
278 3     3   8 my ($rest) = @_;
279 3         9 $rest =~ s/\A\s+//;
280              
281             # @ECHO OFF / ON
282 3 100       14 if ($rest =~ /\AOFF\s*\z/i) { $ECHO_ON = 0; return 0; }
  1         3  
  1         4  
283 2 50       7 if ($rest =~ /\AON\s*\z/i) { $ECHO_ON = 1; return 0; }
  0         0  
  0         0  
284              
285             # ECHO. (empty line)
286 2 50       7 if ($rest =~ /\A\.\s*\z/) { print "\n"; return 0; }
  0         0  
  0         0  
287              
288             # ECHO with no args: show state
289 2 50       9 if ($rest =~ /\A\s*\z/) {
290 0 0       0 print "ECHO is " . ($ECHO_ON ? "on" : "off") . "\n";
291 0         0 return 0;
292             }
293              
294 2         7 print "$rest\n";
295 2         5 $ERRORLEVEL = 0;
296 2         8 return 0;
297             }
298              
299             # ----------------------------------------------------------------
300             # SET
301             # ----------------------------------------------------------------
302             sub _cmd_set {
303 38     38   66 my ($rest) = @_;
304 38         88 $rest =~ s/\A\s+//;
305              
306             # SET /A arithmetic
307 38 100       104 if ($rest =~ s/\A\/A\s*//i) {
308             # Check for VAR=expr form
309 10 50       44 if ($rest =~ /\A\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)\z/) {
310 10         28 my ($var, $expr) = ($1, $2);
311 10         36 my $result = _eval_arith($expr);
312 10         32 BATsh::Env->set($var, $result);
313             }
314             else {
315 0         0 my $result = _eval_arith($rest);
316 0         0 print "$result\n";
317             }
318 10         12 $ERRORLEVEL = 0;
319 10         37 return 0;
320             }
321              
322             # SET with no args: display all variables
323 28 50       76 if ($rest =~ /\A\s*\z/) {
324 0         0 for my $k (sort keys %BATsh::Env::STORE) {
325 0         0 print "$k=$BATsh::Env::STORE{$k}\n";
326             }
327 0         0 return 0;
328             }
329              
330             # SET VAR=value
331 28 50       128 if ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)/) {
332 28         108 BATsh::Env->set($1, $2);
333 28         43 $ERRORLEVEL = 0;
334 28         103 return 0;
335             }
336              
337             # SET VAR (display matching)
338 0 0       0 if ($rest =~ /\A([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
339 0         0 my $prefix = $1;
340 0         0 for my $k (sort keys %BATsh::Env::STORE) {
341 0 0       0 if (index(uc($k), uc($prefix)) == 0) {
342 0         0 print "$k=$BATsh::Env::STORE{$k}\n";
343             }
344             }
345 0         0 return 0;
346             }
347              
348 0         0 return 0;
349             }
350              
351             # ----------------------------------------------------------------
352             # Arithmetic evaluator for SET /A
353             # Supports: + - * / %% () and variable references
354             # ----------------------------------------------------------------
355             sub _eval_arith {
356 10     10   20 my ($expr) = @_;
357             # Replace variable names with their numeric values
358 10         22 $expr =~ s/([A-Za-z_][A-Za-z0-9_]*)/
359 0 0 0     0 do { my $v = BATsh::Env->get($1); defined $v && $v =~ m|^\d+$| ? $v : 0 }
  0         0  
  0         0  
360             /ge;
361             # %% in cmd.exe is modulo
362 10         13 $expr =~ s/%%/%/g;
363             # Evaluate safely: only allow digits, operators, parens, whitespace
364 10 50       37 if ($expr =~ /\A[\d\s\+\-\*\/\%\(\)]+\z/) {
365 10         604 my $result = eval $expr;
366 10 50       48 return defined $result ? int($result) : 0;
367             }
368 0         0 return 0;
369             }
370              
371             # ----------------------------------------------------------------
372             # IF
373             # ----------------------------------------------------------------
374             sub _cmd_if {
375 6     6   14 my ($class, $line, $lines_ref, $labels_ref, $i_ref, $opts_ref) = @_;
376              
377             # Strip "IF" from front
378 6         33 (my $rest = $line) =~ s/\AIF\s+//i;
379              
380 6         13 my $negate = 0;
381 6 100       20 if ($rest =~ s/\ANOT\s+//i) { $negate = 1; }
  1         3  
382              
383 6         10 my $condition = 0;
384              
385             # IF ERRORLEVEL n
386 6 50       66 if ($rest =~ s/\AERRORLEVEL\s+(\d+)\s*//i) {
    100          
    100          
    50          
    0          
387 0 0       0 $condition = ($ERRORLEVEL >= int($1)) ? 1 : 0;
388             }
389             # IF EXIST path
390             elsif ($rest =~ s/\AEXIST\s+(\S+)\s*//i) {
391 1 50       40 $condition = (-e $1) ? 1 : 0;
392             }
393             # IF DEFINED var
394             elsif ($rest =~ s/\ADEFINED\s+([A-Za-z_][A-Za-z0-9_]*)\s*//i) {
395 1 50       7 $condition = BATsh::Env->exists_var($1) ? 1 : 0;
396             }
397             # IF "str1"=="str2" or IF str1==str2
398             elsif ($rest =~ s/\A("?[^"]*"?)\s*==\s*("?[^"]*"?)\s*//) {
399 4         16 my ($a, $b) = ($1, $2);
400 4         12 $a =~ s/\A"//; $a =~ s/"\z//;
  4         14  
401 4         12 $b =~ s/\A"//; $b =~ s/"\z//;
  4         12  
402 4 100       14 $condition = ($a eq $b) ? 1 : 0;
403             }
404             # IF /I "str1"=="str2" (case-insensitive)
405             elsif ($rest =~ s/\A\/I\s+("?[^"]*"?)\s*==\s*("?[^"]*"?)\s*//i) {
406 0         0 my ($a, $b) = ($1, $2);
407 0         0 $a =~ s/\A"//; $a =~ s/"\z//;
  0         0  
408 0         0 $b =~ s/\A"//; $b =~ s/"\z//;
  0         0  
409 0 0       0 $condition = (lc($a) eq lc($b)) ? 1 : 0;
410             }
411              
412 6 100       17 $condition = !$condition if $negate;
413              
414             # Parse the THEN body (may be parenthesised block)
415 6         15 my ($then_body, $else_body) = _parse_if_bodies($rest, $lines_ref, $i_ref);
416              
417 6 100       41 if ($condition) {
    100          
418 4         13 return _exec_body($class, $then_body, $lines_ref, $labels_ref, $i_ref, $opts_ref);
419             }
420             elsif (defined $else_body) {
421 1         4 return _exec_body($class, $else_body, $lines_ref, $labels_ref, $i_ref, $opts_ref);
422             }
423 1         5 return 0;
424             }
425              
426             # Parse IF then/else bodies from the rest of the line and possibly
427             # subsequent lines (parenthesised multi-line blocks).
428             sub _parse_if_bodies {
429 6     6   16 my ($rest, $lines_ref, $i_ref) = @_;
430              
431 6         10 my ($then_body, $else_body);
432 6         16 $rest =~ s/\A\s+//;
433              
434 6 100       18 if ($rest =~ s/\A\(//) {
435             # Multi-line parenthesised THEN block; _read_paren_block handles ) ELSE (
436 1         4 $then_body = _read_paren_block($rest, $lines_ref, $i_ref, \$else_body);
437             }
438             else {
439             # Single-line: IF cond cmd [ELSE cmd]
440 5 50       27 if ($rest =~ s/\s+ELSE\s+(.+)\z//i) { $else_body = $1 }
  0         0  
441 5         11 $then_body = $rest;
442             }
443              
444 6         22 return ($then_body, $else_body);
445             }
446              
447             # Read lines until closing ) for a parenthesised block.
448             # Returns the body content. On exit, $$i_ref points past the closing line.
449             # If the closing line is ") ELSE (" we also return the else body.
450             sub _read_paren_block {
451 2     2   7 my ($first_content, $lines_ref, $i_ref, $else_ref) = @_;
452 2         4 my @body = ();
453 2 50 33     13 push @body, $first_content if defined $first_content && $first_content =~ /\S/;
454 2         5 my $depth = 1;
455              
456 2         5 while ($$i_ref <= $#{$lines_ref}) {
  4         18  
457 4         9 my $l = $lines_ref->[$$i_ref];
458 4         9 $$i_ref++;
459 4         9 $l =~ s/\r?\n\z//;
460 4         6 my $ls = $l; $ls =~ s/\A\s+//;
  4         15  
461              
462             # Check for ) ELSE ( or ) ELSE cmd pattern before counting parens
463 4 100 66     60 if ($depth == 1 && $ls =~ /\A\)\s*ELSE\s*\(\s*\z/i) {
464             # Collect else body
465 1 50       14 if (defined $else_ref) {
466 1         7 $$else_ref = _read_paren_block('', $lines_ref, $i_ref);
467             }
468 1         3 last;
469             }
470 3 50 33     15 if ($depth == 1 && $ls =~ /\A\)\s*ELSE\s+(.+)\z/i) {
471 0 0       0 if (defined $else_ref) { $$else_ref = $1 }
  0         0  
472 0         0 last;
473             }
474              
475             # Count parens
476 3         6 my $delta = 0; my $in_q = 0;
  3         5  
477 3         15 for my $ch (split //, $l) {
478 43 50       103 if ($ch eq '"') { $in_q = !$in_q }
  0 50       0  
479             elsif (!$in_q) {
480 43 50       81 $delta++ if $ch eq '(';
481 43 100       91 $delta-- if $ch eq ')';
482             }
483             }
484 3         10 $depth += $delta;
485              
486 3 100       7 if ($depth <= 0) {
487             # Plain closing )
488 1         5 $l =~ s/\)\s*\z//;
489 1 50       5 push @body, $l if $l =~ /\S/;
490 1         3 last;
491             }
492 2         6 push @body, $l;
493             }
494              
495 2         8 return join("\n", @body);
496             }
497              
498             # Execute a body (string of lines or single command)
499             sub _exec_body {
500 5     5   12 my ($class, $body, $lines_ref, $labels_ref, $i_ref, $opts_ref, $expanded) = @_;
501 5 50 33     35 return 0 unless defined $body && $body =~ /\S/;
502 5 50       12 $expanded = 0 unless defined $expanded;
503             # Use a private lines array and i_ref so that nested _read_paren_block
504             # calls inside the body do not consume lines from the parent block.
505 5         17 my @sub_lines = split /\n/, $body;
506 5         10 my $sub_i = 0;
507 5         11 my %sub_labels = ();
508 5         16 for my $j (0 .. $#sub_lines) {
509 5         11 my $ls = $sub_lines[$j]; $ls =~ s/\r?\n\z//; $ls =~ s/\A\s+//;
  5         11  
  5         11  
510 5 50       17 if ($ls =~ /\A:([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
511 0         0 $sub_labels{uc($1)} = $j;
512             }
513             }
514 5         14 while ($sub_i <= $#sub_lines) {
515 5         9 my $sl = $sub_lines[$sub_i];
516 5         8 $sub_i++;
517 5         21 my $rc = _exec_line($class, $sl, \@sub_lines, \%sub_labels, \$sub_i, $opts_ref, $expanded);
518 5 50 33     29 return $rc if defined $rc && $rc eq '__EXIT__';
519 5 50       22 if ($_GOTO_LABEL ne '') {
520 0         0 my $lbl = $_GOTO_LABEL;
521 0         0 $_GOTO_LABEL = '';
522 0 0       0 if (exists $sub_labels{$lbl}) {
523 0         0 $sub_i = $sub_labels{$lbl} + 1;
524             } else {
525             # propagate GOTO to parent
526 0         0 $_GOTO_LABEL = $lbl;
527 0         0 return 0;
528             }
529             }
530             }
531 5         26 return 0;
532             }
533              
534             # ----------------------------------------------------------------
535             # FOR
536             # ----------------------------------------------------------------
537             sub _cmd_for {
538 3     3   8 my ($class, $line, $lines_ref, $labels_ref, $i_ref, $opts_ref) = @_;
539              
540             # FOR %%V IN (list) DO command
541 3 100       21 if ($line =~ /\AFOR\s+(?:%%|\x00FOR_)([A-Za-z])(?:\x00)?\s+IN\s*\(([^)]*)\)\s+DO\s+(.*)/i) {
542 1         6 my ($var, $list_str, $do_part) = ($1, $2, $3);
543 1         6 my @items = split /[\s,]+/, $list_str;
544             # Expand wildcards
545 1         3 my @expanded = ();
546 1         3 for my $item (@items) {
547 3         6 $item =~ s/\A\s+//; $item =~ s/\s+\z//;
  3         7  
548 3 50       7 next if $item eq '';
549 3 50       9 if ($item =~ /[*?]/) {
550 0         0 my @glob = glob($item);
551 0 0       0 push @expanded, @glob ? @glob : ($item);
552             }
553             else {
554 3         8 push @expanded, $item;
555             }
556             }
557             # Detect paren block and read it ONCE before the loop
558 1         3 my $for_in_paren_body = undef;
559             {
560             # probe: check if do_part is just "(" after stripping placeholders
561 1         2 my $probe = $do_part;
  1         2  
562 1         7 $probe =~ s/\x00FOR_[A-Za-z]\x00//g;
563 1         19 $probe =~ s/\x00PCT_[^\x00]+\x00//g;
564 1         4 $probe =~ s/%%[A-Za-z]//g;
565 1 50       11 if ($probe =~ /\A\s*\(\s*\z/) {
566 0         0 $for_in_paren_body = _read_paren_block('', $lines_ref, $i_ref);
567             }
568             }
569 1         3 for my $val (@expanded) {
570 3         14 BATsh::Env->set("%%$var", $val);
571 3 50       18 if (defined $for_in_paren_body) {
572             # Paren block: substitute loop var + PCT placeholders, then per-line expand
573 0         0 my @body_lines = split /\n/, $for_in_paren_body;
574 0         0 for my $bl (@body_lines) {
575 0         0 $bl =~ s/%%$var/$val/g;
576 0         0 $bl =~ s/\x00FOR_$var\x00/$val/g;
577 0         0 $bl =~ s/\x00PCT_([^\x00]+)\x00/%$1%/g;
578             }
579 0         0 _exec_body($class, join("\n", @body_lines),
580             $lines_ref, $labels_ref, $i_ref, $opts_ref, 0);
581             }
582             else {
583 3         5 my $do_line = $do_part;
584 3         29 $do_line =~ s/%%$var/$val/g;
585 3         25 $do_line =~ s/\x00FOR_([A-Za-z])\x00/%%$1/g;
586 3         18 $do_line =~ s/\x00PCT_([^\x00]+)\x00/%$1%/g;
587 3         11 $do_line = BATsh::Env->expand_cmd($do_line);
588 3         11 _exec_line($class, $do_line, $lines_ref, $labels_ref, $i_ref, $opts_ref, 1);
589             }
590 3 50       11 last if $_GOTO_LABEL ne '';
591             }
592 1         7 return 0;
593             }
594              
595             # FOR /L %%V IN (start,step,end) DO command
596 2 50       36 if ($line =~ /\AFOR\s+\/L\s+(?:%%|\x00FOR_)([A-Za-z])(?:\x00)?\s+IN\s*\(([^)]*)\)\s+DO\s+(.*)/i) {
597 2         12 my ($var, $range, $do_part) = ($1, $2, $3);
598 2         8 my ($start, $step, $end) = split /,/, $range;
599 2 50       11 $start = defined $start ? int($start) : 0;
600 2 50       5 $step = defined $step ? int($step) : 1;
601 2 50       6 $end = defined $end ? int($end) : 0;
602 2 50       5 $step = 1 if $step == 0;
603             # If do_part is a paren block, read it once before looping
604 2         4 my $paren_body_l = undef;
605             {
606 2         3 my $probe = $do_part;
  2         4  
607 2         44 $probe =~ s/%%$var/0/g;
608 2         8 $probe =~ s/\x00FOR_([A-Za-z])\x00/%%$1/g;
609 2         16 $probe =~ s/\x00PCT_([^\x00]+)\x00/%$1%/g;
610             # No expand_cmd needed just to check if it's a (
611 2 50       9 if ($probe =~ /\A\s*\(\s*\z/) {
612 0         0 $paren_body_l = _read_paren_block('', $lines_ref, $i_ref);
613             }
614             }
615 2         4 my $i = $start;
616 2   66     12 while (($step > 0 && $i <= $end) || ($step < 0 && $i >= $end)) {
      33        
      66        
617 9         41 BATsh::Env->set("%%$var", $i);
618 9 50       20 if (defined $paren_body_l) {
619 0         0 my @body_lines = split /\n/, $paren_body_l;
620 0         0 for my $bl (@body_lines) {
621 0         0 $bl =~ s/%%$var/$i/g;
622 0         0 $bl =~ s/\x00FOR_$var\x00/$i/g; # placeholder form
623 0         0 $bl =~ s/\x00PCT_([^\x00]+)\x00/%$1%/g;
624             }
625 0         0 _exec_body($class, join("\n", @body_lines),
626             $lines_ref, $labels_ref, $i_ref, $opts_ref, 0);
627             }
628             else {
629 9         13 my $do_line = $do_part;
630 9         73 $do_line =~ s/%%$var/$i/g;
631 9         19 $do_line =~ s/\x00FOR_([A-Za-z])\x00/%%$1/g;
632 9         70 $do_line =~ s/\x00PCT_([^\x00]+)\x00/%$1%/g;
633 9         26 $do_line = BATsh::Env->expand_cmd($do_line);
634 9         26 _exec_line($class, $do_line, $lines_ref, $labels_ref, $i_ref, $opts_ref, 1);
635             }
636 9 50       28 last if $_GOTO_LABEL ne '';
637 9         44 $i += $step;
638             }
639 2         9 return 0;
640             }
641              
642 0         0 _warn("FOR: unsupported syntax: $line");
643 0         0 return 1;
644             }
645              
646             # ----------------------------------------------------------------
647             # CALL
648             # ----------------------------------------------------------------
649             sub _cmd_call {
650 0     0   0 my ($class, $rest, $opts_ref) = @_;
651 0         0 $rest =~ s/\A\s+//;
652              
653             # CALL :label [args]
654 0 0       0 if ($rest =~ /\A:([A-Za-z_][A-Za-z0-9_]*)(.*)/i) {
655 0         0 my ($lbl, $argstr) = (uc($1), $2);
656 0         0 $argstr =~ s/\A\s+//;
657 0         0 my @args = split /\s+/, $argstr;
658             # Store args as %1 %2 ...
659 0         0 for my $n (1 .. 9) {
660 0 0       0 BATsh::Env->set("%$n", defined($args[$n-1]) ? $args[$n-1] : '');
661             }
662             # Delegate to BATsh (sub-routine call)
663 0 0       0 if (defined $opts_ref->{'_batsh'}) {
664 0         0 $opts_ref->{'_batsh'}->call_sub($lbl);
665             }
666 0         0 return 0;
667             }
668              
669             # CALL file.batsh
670 0 0       0 if ($rest =~ /(\S+\.batsh)(.*)/i) {
671 0         0 my $file = $1;
672 0 0       0 if (defined $opts_ref->{'_batsh'}) {
673 0         0 $opts_ref->{'_batsh'}->source_file($file);
674             }
675 0         0 return 0;
676             }
677              
678             # CALL external command
679 0         0 return _cmd_external($rest, '');
680             }
681              
682             # ----------------------------------------------------------------
683             # CD / CHDIR
684             # ----------------------------------------------------------------
685             sub _cmd_cd {
686 0     0   0 my ($rest) = @_;
687 0         0 $rest =~ s/\A\s+//;
688 0         0 $rest =~ s/\s+\z//;
689 0 0 0     0 if ($rest eq '' || $rest eq '/D') {
690             # Print current directory
691 0         0 print Cwd::cwd(), "\n";
692 0         0 return 0;
693             }
694 0         0 $rest =~ s/\A\/D\s*//i;
695 0 0       0 unless (chdir($rest)) {
696 0         0 print "The system cannot find the path specified.\n";
697 0         0 $ERRORLEVEL = 1;
698 0         0 return 1;
699             }
700 0         0 BATsh::Env->set('CD', Cwd::cwd());
701 0         0 $ERRORLEVEL = 0;
702 0         0 return 0;
703             }
704              
705             # ----------------------------------------------------------------
706             # DIR
707             # ----------------------------------------------------------------
708             sub _cmd_dir {
709 0     0   0 my ($rest) = @_;
710 0         0 $rest =~ s/\A\s+//;
711 0         0 $rest =~ s/\s+\z//;
712 0 0       0 my $target = $rest eq '' ? '.' : $rest;
713             # Strip switches
714 0         0 $target =~ s/\s*\/[A-Za-z:]+//g;
715 0         0 $target =~ s/\s+\z//;
716 0 0       0 $target = '.' if $target eq '';
717 0 0       0 unless (-e $target) {
718 0         0 print "File Not Found\n";
719 0         0 $ERRORLEVEL = 1;
720 0         0 return 1;
721             }
722 0         0 local *DH;
723 0 0       0 if (-d $target) {
724 0 0       0 opendir(DH, $target) or do { print "Access denied.\n"; return 1 };
  0         0  
  0         0  
725 0         0 my @entries = sort readdir(DH);
726 0         0 closedir(DH);
727 0         0 print " Directory of $target\n\n";
728 0         0 for my $e (@entries) {
729 0 0 0     0 next if $e eq '.' || $e eq '..';
730 0         0 my $full = "$target/$e";
731 0 0       0 if (-d $full) {
732 0         0 printf "%-40s \n", $e;
733             }
734             else {
735 0         0 my $size = -s $full;
736 0         0 printf "%-40s %12d\n", $e, $size;
737             }
738             }
739             }
740             else {
741 0         0 my $size = -s $target;
742 0         0 printf "%-40s %12d\n", $target, $size;
743             }
744 0         0 $ERRORLEVEL = 0;
745 0         0 return 0;
746             }
747              
748             # ----------------------------------------------------------------
749             # COPY
750             # ----------------------------------------------------------------
751             sub _cmd_copy {
752 0     0   0 my ($rest) = @_;
753 0         0 $rest =~ s/\A\s+//;
754 0         0 $rest =~ s/\s*\/[YN]\s*//gi;
755 0         0 my ($src, $dst) = split /\s+/, $rest, 2;
756 0 0 0     0 unless (defined $src && defined $dst) {
757 0         0 print "The syntax of the command is incorrect.\n";
758 0         0 return 1;
759             }
760 0 0       0 unless (File::Copy::copy($src, $dst)) {
761 0         0 print "The system cannot find the file specified.\n";
762 0         0 $ERRORLEVEL = 1;
763 0         0 return 1;
764             }
765 0         0 print " 1 file(s) copied.\n";
766 0         0 $ERRORLEVEL = 0;
767 0         0 return 0;
768             }
769              
770             # ----------------------------------------------------------------
771             # DEL / ERASE
772             # ----------------------------------------------------------------
773             sub _cmd_del {
774 0     0   0 my ($rest) = @_;
775 0         0 $rest =~ s/\A\s+//;
776 0         0 $rest =~ s/\s*\/[A-Za-z:]+//g;
777 0         0 $rest =~ s/\s+\z//;
778 0         0 my @files = glob($rest);
779 0 0       0 @files = ($rest) unless @files;
780 0         0 my $count = 0;
781 0         0 for my $f (@files) {
782 0 0       0 if (unlink($f)) { $count++ }
  0         0  
783 0         0 else { print "Could not find $f\n" }
784             }
785 0         0 $ERRORLEVEL = 0;
786 0         0 return 0;
787             }
788              
789             # ----------------------------------------------------------------
790             # MOVE
791             # ----------------------------------------------------------------
792             sub _cmd_move {
793 0     0   0 my ($rest) = @_;
794 0         0 $rest =~ s/\A\s+//;
795 0         0 $rest =~ s/\s*\/[YN]\s*//gi;
796 0         0 my ($src, $dst) = split /\s+/, $rest, 2;
797 0 0 0     0 unless (defined $src && defined $dst) {
798 0         0 print "The syntax of the command is incorrect.\n";
799 0         0 return 1;
800             }
801 0 0       0 unless (File::Copy::move($src, $dst)) {
802 0         0 print "The system cannot find the file specified.\n";
803 0         0 $ERRORLEVEL = 1;
804 0         0 return 1;
805             }
806 0         0 print " 1 file(s) moved.\n";
807 0         0 $ERRORLEVEL = 0;
808 0         0 return 0;
809             }
810              
811             # ----------------------------------------------------------------
812             # MKDIR / MD
813             # ----------------------------------------------------------------
814             sub _cmd_mkdir {
815 0     0   0 my ($rest) = @_;
816 0         0 $rest =~ s/\A\s+//;
817 0         0 $rest =~ s/\s+\z//;
818 0 0       0 if (-d $rest) {
819 0         0 print "A subdirectory or file $rest already exists.\n";
820 0         0 $ERRORLEVEL = 1;
821 0         0 return 1;
822             }
823 0         0 File::Path::mkpath($rest);
824 0         0 $ERRORLEVEL = 0;
825 0         0 return 0;
826             }
827              
828             # ----------------------------------------------------------------
829             # RMDIR / RD
830             # ----------------------------------------------------------------
831             sub _cmd_rmdir {
832 0     0   0 my ($rest) = @_;
833 0         0 $rest =~ s/\A\s+//;
834 0 0       0 my $recurse = ($rest =~ s/\s*\/S\s*//i) ? 1 : 0;
835 0         0 $rest =~ s/\s*\/Q\s*//i;
836 0         0 $rest =~ s/\s+\z//;
837 0 0       0 if ($recurse) {
838 0         0 File::Path::rmtree($rest);
839             }
840             else {
841 0 0       0 unless (rmdir($rest)) {
842 0         0 print "The directory is not empty.\n";
843 0         0 $ERRORLEVEL = 1;
844 0         0 return 1;
845             }
846             }
847 0         0 $ERRORLEVEL = 0;
848 0         0 return 0;
849             }
850              
851             # ----------------------------------------------------------------
852             # REN / RENAME
853             # ----------------------------------------------------------------
854             sub _cmd_rename {
855 0     0   0 my ($rest) = @_;
856 0         0 $rest =~ s/\A\s+//;
857 0         0 my ($src, $dst) = split /\s+/, $rest, 2;
858 0 0 0     0 unless (defined $src && defined $dst) {
859 0         0 print "The syntax of the command is incorrect.\n";
860 0         0 return 1;
861             }
862 0 0       0 unless (rename($src, $dst)) {
863 0         0 print "Could not rename $src to $dst: $!\n";
864 0         0 $ERRORLEVEL = 1;
865 0         0 return 1;
866             }
867 0         0 $ERRORLEVEL = 0;
868 0         0 return 0;
869             }
870              
871             # ----------------------------------------------------------------
872             # TYPE
873             # ----------------------------------------------------------------
874             sub _cmd_type {
875 0     0   0 my ($rest) = @_;
876 0         0 $rest =~ s/\A\s+//;
877 0         0 $rest =~ s/\s+\z//;
878 0         0 local *TFH;
879 0 0       0 unless (open(TFH, $rest)) {
880 0         0 print "The system cannot find the file specified.\n";
881 0         0 $ERRORLEVEL = 1;
882 0         0 return 1;
883             }
884 0         0 while () { print }
  0         0  
885 0         0 close(TFH);
886 0         0 $ERRORLEVEL = 0;
887 0         0 return 0;
888             }
889              
890             # ----------------------------------------------------------------
891             # External command (run as child process via Perl system())
892             # ----------------------------------------------------------------
893             sub _cmd_external {
894 0     0   0 my ($cmd, $rest) = @_;
895 0 0       0 $rest = '' unless defined $rest;
896 0         0 $rest =~ s/\A\s+//;
897 0 0       0 my $full = $rest ne '' ? "$cmd $rest" : $cmd;
898 0         0 BATsh::Env->sync_to_env();
899 0         0 my $rc = system($full);
900 0 0 0     0 $ERRORLEVEL = ($rc == 0) ? 0 : (($rc >> 8) || 1);
901 0         0 return $ERRORLEVEL;
902             }
903              
904             # ----------------------------------------------------------------
905             # Split "COMMAND rest" from a line
906             # ----------------------------------------------------------------
907             sub _split_cmd {
908 51     51   108 my ($line) = @_;
909 51 50       244 if ($line =~ /\A(\S+)\s*(.*)\z/s) {
910 51         251 return ($1, $2);
911             }
912 0           return ($line, '');
913             }
914              
915             # ----------------------------------------------------------------
916             # Warn helper
917             # ----------------------------------------------------------------
918             sub _warn {
919 0     0     my ($msg) = @_;
920 0           print STDERR "[BATsh::CMD] $msg\n";
921             }
922              
923             # ----------------------------------------------------------------
924             # Accessors for state (used by tests and BATsh.pm)
925             # ----------------------------------------------------------------
926 0     0 0   sub errorlevel { return $ERRORLEVEL }
927 0     0 0   sub set_errorlevel { $ERRORLEVEL = $_[1] }
928 0     0 0   sub echo_on { return $ECHO_ON }
929              
930             # Need Cwd for cd/dir
931             BEGIN {
932 5     5   24 eval { require Cwd };
  5         46  
933 5 50       290 if ($@) {
934             # Cwd not available (very old Perl): provide minimal fallback
935 0         0 eval 'sub Cwd::cwd { return $ENV{CD} || "." }';
936             }
937             }
938              
939             1;
940              
941             __END__