File Coverage

lib/BATsh.pm
Criterion Covered Total %
statement 187 260 71.9
branch 90 152 59.2
condition 18 32 56.2
subroutine 25 33 75.7
pod 0 12 0.0
total 320 489 65.4


line stmt bran cond sub pod time code
1             package BATsh;
2             ######################################################################
3             #
4             # BATsh - Bilingual Shell for cmd.exe and bash in one script
5             #
6             # Version 0.02 -- Self-contained interpreter
7             #
8             # https://metacpan.org/dist/BATsh
9             #
10             # Copyright (c) 2026 INABA Hitoshi
11             #
12             # This version implements both cmd.exe and sh/bash command sets
13             # entirely in Perl. No external cmd.exe, bash, or sh is required.
14             #
15             ######################################################################
16              
17 7     7   1273818 use 5.00503;
  7         26  
18 7     7   36 use strict;
  7         10  
  7         520  
19 7 50 33 7   214 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
20 7     7   37 use warnings; local $^W = 1;
  7         11  
  7         629  
21 7 50   7   211 BEGIN { pop @INC if $INC[-1] eq '.' }
22              
23 7     7   42 use File::Spec ();
  7         10  
  7         190  
24 7     7   19 BEGIN { eval { require Cwd } }
  7         170  
25 7     7   49 use Carp qw(croak);
  7         9  
  7         567  
26 7     7   34 use vars qw($VERSION);
  7         10  
  7         28177  
27             $VERSION = '0.02';
28             $VERSION = $VERSION;
29              
30             require BATsh::Env;
31             require BATsh::CMD;
32             require BATsh::SH;
33              
34             ###############################################################################
35             # Architecture
36             ###############################################################################
37             #
38             # BATsh is a self-contained bilingual shell interpreter.
39             #
40             # It splits a script into CMD sections and SH sections, then executes
41             # each section using its own pure-Perl interpreter:
42             #
43             # BATsh::CMD -- cmd.exe command set (SET, ECHO, IF, FOR, GOTO, ...)
44             # BATsh::SH -- sh/bash command set (echo, export, if/fi, for/done, ...)
45             # BATsh::Env -- shared variable store (bridge between both modes)
46             #
47             # MODE DETECTION: first non-empty, non-comment token of each section.
48             # CMD: token is [A-Z 0-9 _ - \ / : . @ %]+ with at least one A-Z
49             # SH: anything else
50             #
51             # SECTION BOUNDARY:
52             # CMD: parenthesis ( ) depth returns to 0
53             # SH: keyword depth (if/fi, for/done, ...) returns to 0
54             #
55             # ENV BRIDGE:
56             # BATsh::Env::STORE is the single variable table.
57             # CMD %VAR% and SH $VAR both read/write the same store.
58             #
59             ###############################################################################
60              
61             ###############################################################################
62             # Global state
63             ###############################################################################
64             my $_TMPCOUNT = 0;
65              
66             # Subroutine registry: { LABEL => \@lines }
67             my %_SUBROUTINES = ();
68              
69             ###############################################################################
70             # Constructor
71             ###############################################################################
72             sub new {
73 0     0 0 0 my ($class, %args) = @_;
74 0         0 BATsh::Env::init();
75 0   0     0 return bless { verbose => $args{verbose} || 0 }, $class;
76             }
77              
78             ###############################################################################
79             # Public run interface
80             ###############################################################################
81             sub run {
82 0     0 0 0 my ($class_or_self, $file, %args) = @_;
83 0 0       0 unless (-f $file) { croak "BATsh->run: file not found: $file" }
  0         0  
84 0         0 local *SRCFH;
85 0 0       0 open(SRCFH, $file) or croak "BATsh->run: cannot open $file: $!";
86 0         0 my @lines = ;
87 0         0 close(SRCFH);
88 0         0 _ensure_env_init();
89             # Set batch positional parameters: %0 = script path, %1..%9 = args, %* = all args
90 0 0       0 my @script_args = defined($args{args}) ? @{$args{args}} : ();
  0         0  
91 0         0 _set_batch_args($file, @script_args);
92 0         0 _process_lines(@lines);
93 0         0 return 1;
94             }
95              
96             sub run_string {
97 64     64 0 16704 my ($class_or_self, $source) = @_;
98 64 50       194 croak "BATsh->run_string: source required" unless defined $source;
99 64         257 my @lines = map { "$_\n" } split(/\n/, $source, -1);
  184         432  
100 64         180 _ensure_env_init();
101 64         164 _process_lines(@lines);
102 64         467 return 1;
103             }
104              
105             sub run_lines {
106 0     0 0 0 my ($class_or_self, @lines) = @_;
107 0         0 _ensure_env_init();
108 0         0 _process_lines(@lines);
109 0         0 return 1;
110             }
111              
112             sub _ensure_env_init {
113             # Init only once per process
114 64 50   64   152 BATsh::Env::init() unless %BATsh::Env::STORE;
115             }
116              
117             ###############################################################################
118             # _set_batch_args -- populate %0..%9 and %* in the Env store
119             # %0 = script path (as passed to run())
120             # %1 = first argument, ..., %9 = ninth argument
121             # %* = all arguments joined by single space (does not include %0)
122             ###############################################################################
123             sub _set_batch_args {
124 4     4   114 my ($script, @args) = @_;
125             # Normalise $0: resolve to absolute path using File::Spec
126 4 50       17 my $abs_script = defined $script ? $script : '';
127 4 100 66     106 if ($abs_script ne '' && !File::Spec->file_name_is_absolute($abs_script)) {
128 3 50       12546 my $cwd = defined(&Cwd::cwd) ? Cwd::cwd() : '.';
129 3         133 $abs_script = File::Spec->catfile($cwd, $abs_script);
130             }
131 4         120 BATsh::Env->set('%0', $abs_script);
132 4         25 for my $n (1 .. 9) {
133 36 100       107 BATsh::Env->set("%$n", defined($args[$n - 1]) ? $args[$n - 1] : '');
134             }
135 4         21 BATsh::Env->set('%*', join(' ', @args));
136             }
137              
138             ###############################################################################
139             # classify_token
140             ###############################################################################
141             sub classify_token {
142 193     193 0 391 my ($class_or_token, $token) = @_;
143 193 50       290 unless (defined $token) { $token = $class_or_token }
  193         215  
144 193 100 100     545 if ($token =~ /\A[A-Z0-9_\-\\\/\.:@%]+\z/ && $token =~ /[A-Z]/) {
145 62         174 return 'CMD';
146             }
147 131         330 return 'SH';
148             }
149              
150             ###############################################################################
151             # Line parser
152             # Returns ($mode, $stripped_line, $first_token)
153             ###############################################################################
154             sub _parse_line {
155 193     193   331 my ($line) = @_;
156 193         329 (my $s = $line) =~ s/\r?\n\z//;
157 193 100       496 return ('EMPTY', $s, '') if $s =~ /\A\s*\z/;
158 182 100       525 return ('COMMENT', $s, '') if $s =~ /\A\s*(?:::|\@?REM(?:\s|\z))/i;
159 179 100       289 return ('COMMENT', $s, '') if $s =~ /\A\s*#(?!!)/;
160 178         300 (my $t = $s) =~ s/\A\s+//;
161 178 50       545 my $first = ($t =~ /\A(\S+)/) ? $1 : '';
162 178         269 return (classify_token($first), $s, $first);
163             }
164              
165             ###############################################################################
166             # CMD section depth: count unquoted ( )
167             ###############################################################################
168             sub _cmd_paren_delta {
169 57     57   94 my ($line) = @_;
170 57         74 my ($delta, $in_q) = (0, 0);
171 57         209 for my $ch (split //, $line) {
172 1142 100       1532 if ($ch eq '"') { $in_q = !$in_q }
  18 100       17  
173             elsif (!$in_q) {
174 1112 100       1318 $delta++ if $ch eq '(';
175 1112 100       1383 $delta-- if $ch eq ')';
176             }
177             }
178 57         166 return $delta;
179             }
180              
181             ###############################################################################
182             # SH section depth
183             ###############################################################################
184             my %_SH_OPEN = map { $_ => 1 } qw(if for while until case function select);
185             my %_SH_CLOSE = map { $_ => 1 } qw(fi done esac);
186              
187             sub _sh_depth_delta {
188 129     129   237 my ($first) = @_;
189 129         221 my $l = lc($first);
190 129 100 66     408 return 1 if exists $_SH_OPEN{$l} || $first eq '{';
191 114 100 100     341 return -1 if exists $_SH_CLOSE{$l} || $first eq '}';
192 94         157 return 0;
193             }
194              
195             ###############################################################################
196             # Subroutine extraction
197             ###############################################################################
198             sub _extract_subroutines {
199 65     65   152 my (@lines) = @_;
200 65         87 my @out = (); my $in_sub = ''; my @sub_body = ();
  65         103  
  65         80  
201              
202             # Two-pass: first identify which :LABEL lines have a matching RET/RETURN.
203             # Only those are BATsh subroutines; pure GOTO labels stay in the stream.
204 65         102 my %is_sub_label = ();
205             {
206 65         92 my $cur = '';
  65         115  
207 65         122 for my $line (@lines) {
208 185         853 (my $s = $line) =~ s/\r?\n\z//;
209 185         307 $s =~ s/\A\s+//;
210 185 100 100     715 if ($s =~ /\A:([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
    100 66        
    50          
211 2         5 $cur = uc($1);
212             }
213             elsif ($cur ne '' && $s =~ /\A(?:RET|RETURN)\s*\z/i) {
214 1         2 $is_sub_label{$cur} = 1;
215 1         14 $cur = '';
216             }
217             elsif ($cur ne '' && $s =~ /\A:([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
218             # New label before RET: previous one is a GOTO label, not sub
219 0         0 $cur = uc($1);
220             }
221             }
222             }
223              
224 65         127 for my $line (@lines) {
225 185         548 (my $s = $line) =~ s/\r?\n\z//;
226 185         315 $s =~ s/\A\s+//;
227 185 100       300 if ($s =~ /\A:([A-Za-z_][A-Za-z0-9_]*)\s*\z/) {
228 2         6 my $lbl = uc($1);
229 2 100       6 if ($is_sub_label{$lbl}) {
230             # This is a BATsh subroutine definition
231 1 50       4 $_SUBROUTINES{$in_sub} = [@sub_body] if $in_sub ne '';
232 1         2 $in_sub = $lbl; @sub_body = ();
  1         4  
233 1         2 next; # remove label line from stream
234             }
235             else {
236             # This is a GOTO label: keep in stream for CMD interpreter
237 1 50       4 push @out, $line if $in_sub eq '';
238 1 50       2 push @sub_body, $line if $in_sub ne '';
239 1         3 next;
240             }
241             }
242 183 100       284 if ($in_sub ne '') {
243 2 100       9 if ($s =~ /\A(?:RET|RETURN)\s*\z/i) {
244 1         5 $_SUBROUTINES{$in_sub} = [@sub_body];
245 1         2 $in_sub = ''; @sub_body = ();
  1         3  
246 1         3 } else { push @sub_body, $line }
247 2         4 next;
248             }
249 181         264 push @out, $line;
250             }
251 65 50       116 $_SUBROUTINES{$in_sub} = [@sub_body] if $in_sub ne '';
252 65         235 return @out;
253             }
254              
255             ###############################################################################
256             # call_sub / source_file
257             ###############################################################################
258             sub call_sub {
259 1     1 0 23 my ($class_or_self, $label, @args) = @_;
260 1         2 $label = uc($label); $label =~ s/^://;
  1         6  
261             croak "BATsh->call_sub: undefined subroutine :$label"
262 1 50       4 unless exists $_SUBROUTINES{$label};
263 1         11 $BATsh::Env::STORE{'BATSH_ARGC'} = scalar @args;
264 1         3 for my $i (1 .. scalar @args) {
265 0         0 $BATsh::Env::STORE{"BATSH_ARG$i"} = $args[$i-1];
266             }
267 1         2 _process_lines(@{$_SUBROUTINES{$label}});
  1         10  
268 1         2 return 1;
269             }
270              
271             sub source_file {
272 0     0 0 0 my ($class_or_self, $file) = @_;
273 0 0       0 croak "BATsh->source_file: file not found: $file" unless -f $file;
274 0         0 local *SFHH;
275 0 0       0 open(SFHH, $file) or croak "BATsh->source_file: cannot open $file: $!";
276 0         0 my @src = ;
277 0         0 close(SFHH);
278 0         0 _process_lines(@src);
279 0         0 return 1;
280             }
281              
282             ###############################################################################
283             # SETLOCAL / ENDLOCAL (public API)
284             ###############################################################################
285 0     0 0 0 sub setlocal { BATsh::Env::setlocal() }
286 0     0 0 0 sub endlocal { BATsh::Env::endlocal() }
287              
288             ###############################################################################
289             # _exec_cmd_section -- run CMD lines through BATsh::CMD
290             ###############################################################################
291             sub _exec_cmd_section {
292 30     30   44 my (@lines) = @_;
293             # Handle BATsh-native directives before CMD interpreter
294 30         38 my @batch = ();
295 30         37 for my $line (@lines) {
296 53         83 (my $s = $line) =~ s/\r?\n\z//;
297 53         83 $s =~ s/\A\s+//;
298 53 100       123 if ($s =~ /\ASETLOCAL(?:\s+(.*))?\z/i) {
299 4 50       15 my $opts = defined $1 ? $1 : '';
300             # Pass the whole line through to CMD interpreter so it sees
301             # ENABLEDELAYEDEXPANSION etc; also update Env flags here.
302 4         49 push @batch, $line;
303 4         10 next;
304             }
305 49 100       108 if ($s =~ /\AENDLOCAL\s*\z/i) {
306             # Handled inside CMD.pm / _dispatch
307 4         8 push @batch, $line;
308 4         8 next;
309             }
310 45 100       77 if ($s =~ /\ACALL\s+:([A-Za-z_][A-Za-z0-9_]*)(.*)/i) {
311 1         3 my ($lbl, $rest) = (uc($1), $2);
312 1 50       3 _flush_cmd(\@batch) if @batch; @batch = ();
  1         1  
313 1         1 $rest =~ s/\A\s+//;
314 1         3 my @args = split /\s+/, $rest;
315 1         1 eval { call_sub('', $lbl, @args) };
  1         4  
316 1 50       4 warn $@ if $@;
317 1         2 next;
318             }
319 44 50       66 if ($s =~ /\ACALL\s+(\S+\.batsh)(.*)/i) {
320 0         0 my $bfile = $1;
321 0 0       0 _flush_cmd(\@batch) if @batch; @batch = ();
  0         0  
322 0         0 eval { source_file('', $bfile) };
  0         0  
323 0 0       0 warn $@ if $@;
324 0         0 next;
325             }
326 44         80 push @batch, $line;
327             }
328 30 100       72 _flush_cmd(\@batch) if @batch;
329             }
330              
331             sub _flush_cmd {
332 29     29   39 my ($lines_ref) = @_;
333 29 50       27 return unless @{$lines_ref};
  29         52  
334 29         87 BATsh::CMD::exec_block('BATsh::CMD', $lines_ref,
335             _batsh => __PACKAGE__,
336             _pushd_stack => [],
337             );
338             }
339              
340             ###############################################################################
341             # _exec_sh_section -- run SH lines through BATsh::SH
342             ###############################################################################
343             sub _exec_sh_section {
344 45     45   80 my (@lines) = @_;
345 45         67 my @batch = ();
346 45         76 for my $line (@lines) {
347 129         189 (my $s = $line) =~ s/\r?\n\z//;
348 129         238 $s =~ s/\A\s+//;
349 129 50       214 if ($s =~ /\A(?:source|\.)\s+(\S+\.batsh)/) {
350 0         0 my $bfile = $1;
351 0 0       0 _flush_sh(\@batch) if @batch; @batch = ();
  0         0  
352 0         0 eval { source_file('', $bfile) };
  0         0  
353 0 0       0 warn $@ if $@;
354 0         0 next;
355             }
356 129         201 push @batch, $line;
357             }
358 45 50       136 _flush_sh(\@batch) if @batch;
359             }
360              
361             sub _flush_sh {
362 45     45   83 my ($lines_ref) = @_;
363 45 50       50 return unless @{$lines_ref};
  45         93  
364 45         180 BATsh::SH::exec_block('BATsh::SH', $lines_ref,
365             _batsh => __PACKAGE__,
366             );
367             }
368              
369             ###############################################################################
370             # _process_lines -- main dispatcher
371             ###############################################################################
372             sub _process_lines {
373 65     65   149 my (@source) = @_;
374 65         193 @source = _extract_subroutines(@source);
375              
376 65         101 my @pending = (); my $cur_mode = ''; my $depth = 0;
  65         83  
  65         86  
377              
378 65         98 for my $raw (@source) {
379 182         287 chomp $raw;
380 182         290 my ($mode, $line, $first) = _parse_line($raw);
381              
382 182 100 66     628 if ($mode eq 'EMPTY' || $mode eq 'COMMENT') {
383 9 50       36 push @pending, $line if $cur_mode ne '';
384 9         24 next;
385             }
386              
387 173 100       303 if ($cur_mode eq '') {
    100          
388 65         70 $cur_mode = $mode; $depth = 0;
  65         74  
389 65         78 push @pending, $line;
390 65 100       165 $depth += ($mode eq 'CMD') ? _cmd_paren_delta($line) : _sh_depth_delta($first);
391             }
392             elsif ($mode eq $cur_mode) {
393 96         122 push @pending, $line;
394 96 100       159 $depth += ($mode eq 'CMD') ? _cmd_paren_delta($line) : _sh_depth_delta($first);
395 96 100       197 $depth = 0 if $depth < 0;
396             }
397             else {
398 12 100       19 if ($depth > 0) {
399 2         3 push @pending, $line;
400 2 50       19 $depth += ($cur_mode eq 'CMD') ? _cmd_paren_delta($line) : _sh_depth_delta($first);
401 2 50       5 $depth = 0 if $depth < 0;
402             }
403             else {
404 10 50       25 _flush_section($cur_mode, @pending) if @pending;
405 10         38 @pending = ($line); $cur_mode = $mode; $depth = 0;
  10         12  
  10         12  
406 10 100       24 $depth += ($mode eq 'CMD') ? _cmd_paren_delta($line) : _sh_depth_delta($first);
407             }
408             }
409             }
410 65 50       193 _flush_section($cur_mode, @pending) if @pending;
411             }
412              
413             sub _flush_section {
414 75     75   171 my ($mode, @lines) = @_;
415 75 50       147 return unless @lines;
416 75 100       124 if ($mode eq 'CMD') { _exec_cmd_section(@lines) }
  30         53  
417 45         105 else { _exec_sh_section(@lines) }
418             }
419              
420             ###############################################################################
421             # REPL
422             ###############################################################################
423             sub repl {
424 0     0 0 0 my ($class_or_self) = @_;
425 0         0 _ensure_env_init();
426 0         0 print "BATsh $VERSION - Self-contained Bilingual Shell\n";
427 0         0 print "Uppercase => CMD mode, lowercase => SH mode. EXIT/exit to quit.\n\n";
428              
429 0         0 my (@buf, $depth, $cur_mode) = ((), 0, '');
430 0         0 while (1) {
431 0 0       0 print $depth > 0 ? ' +> ' : 'BATsh> ';
432 0         0 my $line = ;
433 0 0       0 last unless defined $line;
434 0         0 chomp $line;
435 0 0       0 if ($line =~ /\A\s*(?:EXIT|exit)\s*\z/) { print "Bye.\n"; last }
  0         0  
  0         0  
436 0 0 0     0 next if $depth == 0 && $line =~ /\A\s*\z/;
437 0         0 push @buf, $line;
438 0         0 my (undef, undef, $first) = _parse_line($line);
439 0 0 0     0 $cur_mode = classify_token($first) if $depth == 0 && $cur_mode eq '';
440 0 0       0 $depth += ($cur_mode eq 'CMD') ? _cmd_paren_delta($line) : _sh_depth_delta($first);
441 0 0       0 $depth = 0 if $depth < 0;
442 0 0       0 if ($depth == 0) {
443 0         0 _flush_section($cur_mode, @buf);
444 0         0 @buf = (); $cur_mode = ''; $depth = 0;
  0         0  
  0         0  
445             }
446             }
447             }
448              
449             ###############################################################################
450             # Accessors
451             ###############################################################################
452 0     0 0 0 sub version { return $VERSION }
453 2     2 0 32 sub sh_available { return 1 } # always: built-in SH interpreter
454              
455             ###############################################################################
456             # Run as script
457             ###############################################################################
458             unless (caller) {
459             BATsh::Env::init();
460             if (@ARGV == 0) { BATsh->repl() }
461             elsif ($ARGV[0] eq '-e') { shift @ARGV; BATsh->run_string(join("\n", @ARGV)) }
462             else { BATsh->run($ARGV[0]) }
463             }
464              
465             1;
466              
467             __END__