File Coverage

lib/BATsh.pm
Criterion Covered Total %
statement 177 249 71.0
branch 84 144 58.3
condition 14 29 48.2
subroutine 23 31 74.1
pod 0 12 0.0
total 298 465 64.0


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