File Coverage

lib/BATsh/Env.pm
Criterion Covered Total %
statement 159 185 85.9
branch 79 118 66.9
condition 5 9 55.5
subroutine 25 28 89.2
pod 11 13 84.6
total 279 353 79.0


line stmt bran cond sub pod time code
1             package BATsh::Env;
2             # Copyright (c) 2026 INABA Hitoshi
3             ######################################################################
4             #
5             # BATsh::Env - Shared environment variable store
6             #
7             # v0.02 changes:
8             # - Variable names are case-insensitive (cmd.exe compatible).
9             # Stored internally in uppercase.
10             # - SETLOCAL ENABLEDELAYEDEXPANSION flag tracked per scope.
11             # - expand_cmd expands !VAR! when delayed expansion is active.
12             # - expand_cmd expands %0..%9 and %* positional parameters.
13             # - _expand_tilde_param: %~[fdpnx]*N batch-parameter tilde modifiers.
14             # Modifiers f(full path), d(drive), p(dir), n(basename), x(ext).
15             # Uses File::Spec and Cwd for cross-platform absolute path resolution.
16             # - init() guards undef %ENV values (Windows compatibility).
17             #
18             ######################################################################
19              
20 15     15   76 use strict;
  15         52  
  15         984  
21 15 50 33 15   334 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
22 15     15   56 use warnings; local $^W = 1;
  15         21  
  15         870  
23 15 50   15   437 BEGIN { pop @INC if $INC[-1] eq '.' }
24              
25 15     15   68 use vars qw($VERSION);
  15         21  
  15         643  
26             $VERSION = '0.06';
27              
28 15     15   60 use File::Spec ();
  15         17  
  15         365  
29 15     15   30 BEGIN { eval { require Cwd } }
  15         269  
30 15     15   53 BEGIN { eval { require POSIX } }
  15         6478  
31             $VERSION = $VERSION;
32              
33             # Keys stored in UPPERCASE for case-insensitive lookup
34 15     15   95689 use vars qw(%STORE);
  15         26  
  15         517  
35              
36             # Delayed expansion flag
37 15     15   49 use vars qw($DELAYED_EXPANSION);
  15         73  
  15         440  
38             $DELAYED_EXPANSION = 0;
39              
40             # SETLOCAL scope stack: each entry = { store => \%snap, delayed => $flag }
41 15     15   51 use vars qw(@SETLOCAL_STACK);
  15         19  
  15         30070  
42             @SETLOCAL_STACK = ();
43              
44             sub init {
45 189     189 1 89251 %STORE = ();
46 189         1177 for my $k (keys %ENV) {
47 4725 50       9023 $STORE{uc($k)} = defined $ENV{$k} ? $ENV{$k} : '';
48             }
49 189         593 $DELAYED_EXPANSION = 0;
50             }
51              
52 2020     2020   4330 sub _key { return uc($_[0]) }
53              
54 636     636 1 763 sub get { my ($c,$n)=@_; return $STORE{_key($n)} }
  636         759  
55 1347 50   1347 1 1895 sub set { my ($c,$n,$v)=@_; $STORE{_key($n)} = defined $v ? $v : '' }
  1347         1955  
56 36     36 1 60 sub unset { my ($c,$n)=@_; delete $STORE{_key($n)} }
  36         67  
57 1 50   1 1 3 sub exists_var { my ($c,$n)=@_; return exists $STORE{_key($n)} ? 1 : 0 }
  1         3  
58 25     25 1 3356 sub sync_to_env { %ENV = %STORE }
59 0     0 0 0 sub snapshot { my %s = %STORE; return { %s } }
  0         0  
60 0     0 0 0 sub restore { my ($c,$s)=@_; %STORE = %{$s} }
  0         0  
  0         0  
61 56     56 1 105 sub delayed_expansion { return $DELAYED_EXPANSION }
62              
63             sub setlocal {
64 8     8 1 12 my ($opts) = @_;
65 8 50       14 $opts = '' unless defined $opts;
66 8         148 my %snap = %STORE;
67 8         162 push @SETLOCAL_STACK, { store => { %snap }, delayed => $DELAYED_EXPANSION };
68 8 100       51 if ($opts =~ /ENABLEDELAYEDEXPANSION/i) { $DELAYED_EXPANSION = 1 }
  4 50       16  
69 0         0 elsif ($opts =~ /DISABLEDELAYEDEXPANSION/i) { $DELAYED_EXPANSION = 0 }
70             # ENABLEEXTENSIONS / DISABLEEXTENSIONS: accepted, not modelled
71             }
72              
73             sub endlocal {
74 8 50   8 1 13 unless (@SETLOCAL_STACK) {
75 0         0 warn "[BATsh] Warning: ENDLOCAL without matching SETLOCAL\n";
76 0         0 return;
77             }
78 8         18 my $f = pop @SETLOCAL_STACK;
79 8         10 %STORE = %{$f->{store}};
  8         137  
80 8         51 $DELAYED_EXPANSION = $f->{delayed};
81             }
82              
83             # ----------------------------------------------------------------
84             # expand_cmd: %VAR% expansion, then optional !VAR! delayed expansion
85             # ----------------------------------------------------------------
86             sub expand_cmd {
87 364     364 1 664 my ($class, $str) = @_;
88 364 50       600 return '' unless defined $str;
89              
90             # %~[modifiers][0-9]: batch parameter modifiers (e.g. %~dp0, %~nx1)
91             # Must be processed BEFORE %VAR% to avoid being mis-parsed.
92 364         479 $str =~ s/%~([fdpnxs]*)([0-9])/_expand_tilde_param($1, $2)/ge;
  16         37  
93              
94             # Batch positional parameters: %0..%9 and %* (single % prefix, no closing %)
95             # Must expand BEFORE %VAR% so that "%0 foo=%1" is not mis-parsed by
96             # the greedy %([^%]+)% pattern as a single named variable.
97 364         626 $str =~ s/%([0-9*])/
98 61 50       61 do { my $k = "%$1"; exists($STORE{$k}) ? $STORE{$k} : '' }
  61         116  
  61         198  
99             /ge;
100              
101             # %VAR:~n,m% substring and %VAR:str1=str2% substitution
102             # Must be processed BEFORE plain %VAR% expansion.
103 364         478 $str =~ s/%([A-Za-z_][A-Za-z0-9_]*):([^%
104 14         28 ]+)%/_expand_var_modifier($1,$2)/ge;
105              
106             # %VAR% substitution: dynamic pseudo-variables first, then STORE lookup
107 364         556 $str =~ s/%([^%\r\n]+)%/_expand_named_var($1)/ge;
  79         179  
108              
109             # %% -> literal %
110 364         445 $str =~ s/%%/%/g;
111              
112             # !VAR! delayed expansion (only when enabled)
113 364 100       537 if ($DELAYED_EXPANSION) {
114 19         35 $str =~ s/!([A-Za-z_][A-Za-z0-9_]*)!/
115 5 50       5 do { my $k=uc($1); exists($STORE{$k}) ? $STORE{$k} : '' }
  5         8  
  5         23  
116             /ge;
117             }
118              
119 364         809 return $str;
120             }
121              
122             # ----------------------------------------------------------------
123             # _expand_tilde_param: resolve %~[fdpnx]*N batch-parameter modifiers
124             #
125             # Modifier letters (combinable, same as cmd.exe):
126             # (none) strip surrounding double-quotes only
127             # f fully qualified path (absolute)
128             # d drive letter only (e.g. "C:" on Windows, "" on Unix)
129             # p path component only (directory, with trailing separator)
130             # n filename without extension
131             # x extension only (including leading dot, e.g. ".bat")
132             #
133             # The value is taken from %N in the Env store (%0..%9).
134             # ----------------------------------------------------------------
135             # _expand_named_var: resolve %VARNAME% with dynamic pseudo-variable support
136             #
137             # Dynamic pseudo-variables (read-only, computed at expansion time):
138             # %RANDOM% pseudo-random integer 0-32767 (cmd.exe range)
139             # %DATE% current date YYYY-MM-DD
140             # %TIME% current time HH:MM:SS.cc
141             # %CD% current working directory
142             # %CMDCMDLINE% empty string (not meaningful in a pure-Perl interpreter)
143             # %ERRORLEVEL% current ERRORLEVEL from BATsh::CMD (via hook function)
144             #
145             # All other names: looked up in %STORE (case-insensitive, as cmd.exe).
146             # ----------------------------------------------------------------
147             sub _expand_named_var {
148 79     79   160 my ($name) = @_;
149 79         105 my $upper = uc($name);
150 79 100       134 if ($upper eq 'RANDOM') {
151 1         59 return int(rand(32768));
152             }
153 78 100       130 if ($upper eq 'DATE') {
154 1         36 my @t = localtime(time());
155 1         11 return sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
156             }
157 77 100       107 if ($upper eq 'TIME') {
158 1         32 my @t = localtime(time());
159 1         9 return sprintf('%02d:%02d:%02d.%02d', $t[2], $t[1], $t[0], 0);
160             }
161 76 100       108 if ($upper eq 'CD') {
162 1 50       4172 return defined(&Cwd::cwd) ? Cwd::cwd() : '.';
163             }
164 75 50       122 if ($upper eq 'CMDCMDLINE') {
165 0         0 return '';
166             }
167 75 100       97 if ($upper eq 'ERRORLEVEL') {
168             return defined(&BATsh::CMD::_get_errorlevel)
169             ? BATsh::CMD::_get_errorlevel()
170 1 0       8 : (exists($STORE{ERRORLEVEL}) ? $STORE{ERRORLEVEL} : '0');
    50          
171             }
172 74 50       271 return exists($STORE{$upper}) ? $STORE{$upper} : '';
173             }
174              
175             # ----------------------------------------------------------------
176             # _expand_var_modifier: %VAR:~n,m% substring / %VAR:str1=str2% substitution
177             #
178             # Substring form (cmd.exe compatible):
179             # %VAR:~n% characters from offset n to end
180             # %VAR:~n,m% m characters starting at offset n (negative = from end)
181             #
182             # Substitution form:
183             # %VAR:str1=str2% replace first occurrence of str1 with str2
184             # %VAR:*str1=str2% replace from start up-to-and-including first str1
185             # with str2 (cmd.exe *-prefix behaviour)
186             # ----------------------------------------------------------------
187             sub _expand_var_modifier {
188 14     14   47 my ($varname, $modifier) = @_;
189 14         14 my $val = do {
190 14         22 my $k = uc($varname);
191 14 50       31 exists($STORE{$k}) ? $STORE{$k} : ''
192             };
193              
194             # Substring: ~n or ~n,m
195 14 100       41 if ($modifier =~ /\A~(-?\d+)(?:,(-?\d+))?\z/) {
196 8         15 my ($n, $m) = ($1, $2);
197 8         10 my $len = length($val);
198 8 100       15 my $start = ($n < 0) ? $len + $n : $n;
199 8 50       12 $start = 0 if $start < 0;
200 8 100       14 return '' if $start >= $len;
201 7 100       12 if (!defined $m) {
202 2         8 return substr($val, $start);
203             }
204 5         3 my $end;
205 5 100       8 if ($m < 0) {
206 1         3 $end = $len + $m;
207             }
208             else {
209 4         4 $end = $start + $m;
210             }
211 5 50       7 $end = $len if $end > $len;
212 5 100       9 return '' if $end <= $start;
213 4         15 return substr($val, $start, $end - $start);
214             }
215              
216             # Substitution: str1=str2 or *str1=str2
217 6 50       16 if ($modifier =~ /\A(\*?)([^=]*)=(.*)\z/) {
218 6         15 my ($star, $str1, $str2) = ($1, $2, $3);
219 6 100       12 if ($star eq '*') {
220 1         3 my $pos = index(lc($val), lc($str1));
221 1 50       3 if ($pos >= 0) {
222 1         5 return $str2 . substr($val, $pos + length($str1));
223             }
224 0         0 return $val;
225             }
226             else {
227 5         7 my $lval = lc($val);
228 5         6 my $lstr1 = lc($str1);
229 5         8 my $pos = index($lval, $lstr1);
230 5 100       19 if ($pos >= 0) {
231 4         19 return substr($val, 0, $pos) . $str2
232             . substr($val, $pos + length($str1));
233             }
234 1         4 return $val;
235             }
236             }
237              
238             # Unrecognised modifier: return as-is
239 0         0 return '%' . $varname . ':' . $modifier . '%';
240             }
241              
242             # Uses File::Spec (platform-aware) and a hand-rolled path splitter so
243             # that Windows-style paths work correctly on Windows and Unix-style
244             # paths work on Unix without requiring Win32-specific modules.
245             # ----------------------------------------------------------------
246             sub _expand_tilde_param {
247 16     16   46 my ($mods, $n) = @_;
248 16         21 my $key = "%$n";
249 16 100       40 my $val = exists($STORE{$key}) ? $STORE{$key} : '';
250              
251             # Always strip surrounding double-quotes first
252 16         32 $val =~ s/\A"//;
253 16         23 $val =~ s/"\z//;
254              
255             # With no recognised modifiers, just return the dequoted value
256 16 100       38 return $val unless $mods =~ /[fdpnx]/;
257              
258             # --- Normalise: extract drive letter first, then convert \ to / ---
259             # Extracting the drive before splitting avoids "C:" being treated as
260             # a path component and re-attached incorrectly.
261 13         24 my $drv = ''; # e.g. "C:" on Windows, "" on Unix
262 13         17 my $path = $val;
263 13         21 $path =~ s{\\}{/}g; # normalise separators
264 13 100       27 if ($path =~ s{\A([A-Za-z]:)}{}) { $drv = $1 }
  4         6  
265              
266             # --- resolve to absolute path when f/d/p requested ---
267 13 100       25 if ($mods =~ /[fdp]/) {
268 4 100 66     26 unless ($path =~ m{\A/} || $drv ne '') {
269             # relative Unix path: prepend cwd
270 2 50       7880 my $cwd = defined(&Cwd::cwd) ? Cwd::cwd() : '.';
271 2         41 $cwd =~ s{\\}{/}g;
272 2         21 $cwd =~ s{/+\z}{};
273 2         10 $path = "$cwd/$path";
274             }
275             # Ensure exactly one leading slash
276 4 50       30 $path = "/$path" unless $path =~ m{\A/};
277             # Collapse . and ..
278 4         8 my @segs;
279 4         46 for my $p (split m{/+}, $path) {
280 21 100 66     64 next if $p eq '' || $p eq '.';
281 17 0       27 if ($p eq '..') { pop @segs if @segs }
  0 50       0  
282 17         52 else { push @segs, $p }
283             }
284 4         18 $path = '/' . join('/', @segs);
285 4 50       12 $path = '/' if $path eq '/';
286             }
287              
288             # --- split path into directory and filename ---
289 13         29 my ($dirs, $file) = ('', '');
290 13 100       36 if ($path =~ m{\A(.*/)([^/]*)\z}) {
291 9         37 ($dirs, $file) = ($1, $2);
292             }
293             else {
294 4         4 $file = $path;
295             }
296              
297             # --- split filename into base and extension ---
298 13         21 my ($base, $ext) = ('', '');
299 13 50       40 if ($file =~ m{\A(.+)(\.[^.]+)\z}) {
300 13         19 ($base, $ext) = ($1, $2);
301             }
302             else {
303 0         0 $base = $file;
304             }
305              
306             # --- build result ---
307 13 100       26 if ($mods =~ /f/) {
308             # Full absolute path: drive + dirs + file
309             # dirs already ends with / when non-root
310 1         27 return $drv . $dirs . $file;
311             }
312              
313 12         17 my $result = '';
314 12 100       21 $result .= $drv if $mods =~ /d/;
315 12 100       23 $result .= $dirs if $mods =~ /p/;
316 12 100       25 $result .= $base if $mods =~ /n/;
317 12 100       34 $result .= $ext if $mods =~ /x/;
318 12         48 return $result;
319             }
320              
321             # ----------------------------------------------------------------
322             # expand_sh: $VAR and ${VAR} (SH mode)
323             # ----------------------------------------------------------------
324             sub expand_sh {
325 0     0 1   my ($class, $str) = @_;
326 0 0         return '' unless defined $str;
327 0           $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
328 0 0         do { my $k=$1; defined($STORE{$k}) ? $STORE{$k} : defined($STORE{uc($k)}) ? $STORE{uc($k)} : '' }
  0 0          
  0            
329             /ge;
330 0           $str =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/
331 0 0         do { my $k=$1; defined($STORE{$k}) ? $STORE{$k} : defined($STORE{uc($k)}) ? $STORE{uc($k)} : '' }
  0 0          
  0            
332             /ge;
333 0           return $str;
334             }
335              
336             1;
337              
338             __END__