File Coverage

lib/BATsh/Env.pm
Criterion Covered Total %
statement 106 133 79.7
branch 43 74 58.1
condition 5 9 55.5
subroutine 22 25 88.0
pod 11 13 84.6
total 187 254 73.6


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 7     7   44 use strict;
  7         10  
  7         534  
21 7 50 33 7   188 BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } }
  0         0  
  0         0  
22 7     7   34 use warnings; local $^W = 1;
  7         13  
  7         534  
23 7 50   7   262 BEGIN { pop @INC if $INC[-1] eq '.' }
24              
25 7     7   38 use vars qw($VERSION);
  7         11  
  7         399  
26             $VERSION = '0.02';
27              
28 7     7   56 use File::Spec ();
  7         15  
  7         235  
29 7     7   16 BEGIN { eval { require Cwd } }
  7         294  
30             $VERSION = $VERSION;
31              
32             # Keys stored in UPPERCASE for case-insensitive lookup
33 7     7   39 use vars qw(%STORE);
  7         18  
  7         316  
34              
35             # Delayed expansion flag
36 7     7   39 use vars qw($DELAYED_EXPANSION);
  7         20  
  7         281  
37             $DELAYED_EXPANSION = 0;
38              
39             # SETLOCAL scope stack: each entry = { store => \%snap, delayed => $flag }
40 7     7   38 use vars qw(@SETLOCAL_STACK);
  7         35  
  7         12923  
41             @SETLOCAL_STACK = ();
42              
43             sub init {
44 88     88 1 22462 %STORE = ();
45 88         545 for my $k (keys %ENV) {
46 2200 50       4561 $STORE{uc($k)} = defined $ENV{$k} ? $ENV{$k} : '';
47             }
48 88         290 $DELAYED_EXPANSION = 0;
49             }
50              
51 734     734   2351 sub _key { return uc($_[0]) }
52              
53 259     259 1 575 sub get { my ($c,$n)=@_; return $STORE{_key($n)} }
  259         439  
54 472 50   472 1 920 sub set { my ($c,$n,$v)=@_; $STORE{_key($n)} = defined $v ? $v : '' }
  472         907  
55 2     2 1 19 sub unset { my ($c,$n)=@_; delete $STORE{_key($n)} }
  2         10  
56 1 50   1 1 15 sub exists_var { my ($c,$n)=@_; return exists $STORE{_key($n)} ? 1 : 0 }
  1         3  
57 5     5 1 1390 sub sync_to_env { %ENV = %STORE }
58 0     0 0 0 sub snapshot { my %s = %STORE; return \%s }
  0         0  
59 0     0 0 0 sub restore { my ($c,$s)=@_; %STORE = %{$s} }
  0         0  
  0         0  
60 47     47 1 103 sub delayed_expansion { return $DELAYED_EXPANSION }
61              
62             sub setlocal {
63 8     8 1 15 my ($opts) = @_;
64 8 50       29 $opts = '' unless defined $opts;
65 8         190 my %snap = %STORE;
66 8         40 push @SETLOCAL_STACK, { store => \%snap, delayed => $DELAYED_EXPANSION };
67 8 100       31 if ($opts =~ /ENABLEDELAYEDEXPANSION/i) { $DELAYED_EXPANSION = 1 }
  4 50       6  
68 0         0 elsif ($opts =~ /DISABLEDELAYEDEXPANSION/i) { $DELAYED_EXPANSION = 0 }
69             # ENABLEEXTENSIONS / DISABLEEXTENSIONS: accepted, not modelled
70             }
71              
72             sub endlocal {
73 8 50   8 1 23 unless (@SETLOCAL_STACK) {
74 0         0 warn "[BATsh] Warning: ENDLOCAL without matching SETLOCAL\n";
75 0         0 return;
76             }
77 8         11 my $f = pop @SETLOCAL_STACK;
78 8         11 %STORE = %{$f->{store}};
  8         180  
79 8         60 $DELAYED_EXPANSION = $f->{delayed};
80             }
81              
82             # ----------------------------------------------------------------
83             # expand_cmd: %VAR% expansion, then optional !VAR! delayed expansion
84             # ----------------------------------------------------------------
85             sub expand_cmd {
86 149     149 1 494 my ($class, $str) = @_;
87 149 50       253 return '' unless defined $str;
88              
89             # %~[modifiers][0-9]: batch parameter modifiers (e.g. %~dp0, %~nx1)
90             # Must be processed BEFORE %VAR% to avoid being mis-parsed.
91 149         254 $str =~ s/%~([fdpnxs]*)([0-9])/_expand_tilde_param($1, $2)/ge;
  12         40  
92              
93             # Batch positional parameters: %0..%9 and %* (single % prefix, no closing %)
94             # Must expand BEFORE %VAR% so that "%0 foo=%1" is not mis-parsed by
95             # the greedy %([^%]+)% pattern as a single named variable.
96 149         218 $str =~ s/%([0-9*])/
97 0 0       0 do { my $k = "%$1"; exists($STORE{$k}) ? $STORE{$k} : '' }
  0         0  
  0         0  
98             /ge;
99              
100             # %VAR% substitution (case-insensitive lookup via _key)
101 149         271 $str =~ s/%([^%\r\n]+)%/
102 28 50       35 do { my $k=uc($1); exists($STORE{$k}) ? $STORE{$k} : '' }
  28         75  
  28         159  
103             /ge;
104              
105             # %% -> literal %
106 149         205 $str =~ s/%%/%/g;
107              
108             # !VAR! delayed expansion (only when enabled)
109 149 100       235 if ($DELAYED_EXPANSION) {
110 19         34 $str =~ s/!([A-Za-z_][A-Za-z0-9_]*)!/
111 5 50       5 do { my $k=uc($1); exists($STORE{$k}) ? $STORE{$k} : '' }
  5         9  
  5         15  
112             /ge;
113             }
114              
115 149         352 return $str;
116             }
117              
118             # ----------------------------------------------------------------
119             # _expand_tilde_param: resolve %~[fdpnx]*N batch-parameter modifiers
120             #
121             # Modifier letters (combinable, same as cmd.exe):
122             # (none) strip surrounding double-quotes only
123             # f fully qualified path (absolute)
124             # d drive letter only (e.g. "C:" on Windows, "" on Unix)
125             # p path component only (directory, with trailing separator)
126             # n filename without extension
127             # x extension only (including leading dot, e.g. ".bat")
128             #
129             # The value is taken from %N in the Env store (%0..%9).
130             # Uses File::Spec (platform-aware) and a hand-rolled path splitter so
131             # that Windows-style paths work correctly on Windows and Unix-style
132             # paths work on Unix without requiring Win32-specific modules.
133             # ----------------------------------------------------------------
134             sub _expand_tilde_param {
135 12     12   50 my ($mods, $n) = @_;
136 12         20 my $key = "%$n";
137 12 100       48 my $val = exists($STORE{$key}) ? $STORE{$key} : '';
138              
139             # Always strip surrounding double-quotes first
140 12         49 $val =~ s/\A"//;
141 12         26 $val =~ s/"\z//;
142              
143             # With no recognised modifiers, just return the dequoted value
144 12 100       45 return $val unless $mods =~ /[fdpnx]/;
145              
146             # --- Normalise: extract drive letter first, then convert \ to / ---
147             # Extracting the drive before splitting avoids "C:" being treated as
148             # a path component and re-attached incorrectly.
149 9         13 my $drv = ''; # e.g. "C:" on Windows, "" on Unix
150 9         12 my $path = $val;
151 9         13 $path =~ s{\\}{/}g; # normalise separators
152 9 50       28 if ($path =~ s{\A([A-Za-z]:)}{}) { $drv = $1 }
  0         0  
153              
154             # --- resolve to absolute path when f/d/p requested ---
155 9 100       28 if ($mods =~ /[fdp]/) {
156 3 100 66     24 unless ($path =~ m{\A/} || $drv ne '') {
157             # relative Unix path: prepend cwd
158 2 50       10244 my $cwd = defined(&Cwd::cwd) ? Cwd::cwd() : '.';
159 2         32 $cwd =~ s{\\}{/}g;
160 2         22 $cwd =~ s{/+\z}{};
161 2         13 $path = "$cwd/$path";
162             }
163             # Ensure exactly one leading slash
164 3 50       24 $path = "/$path" unless $path =~ m{\A/};
165             # Collapse . and ..
166 3         16 my @segs;
167 3         55 for my $p (split m{/+}, $path) {
168 17 100 66     73 next if $p eq '' || $p eq '.';
169 14 0       23 if ($p eq '..') { pop @segs if @segs }
  0 50       0  
170 14         42 else { push @segs, $p }
171             }
172 3         20 $path = '/' . join('/', @segs);
173 3 50       17 $path = '/' if $path eq '/';
174             }
175              
176             # --- split path into directory and filename ---
177 9         21 my ($dirs, $file) = ('', '');
178 9 100       33 if ($path =~ m{\A(.*/)([^/]*)\z}) {
179 5         40 ($dirs, $file) = ($1, $2);
180             }
181             else {
182 4         6 $file = $path;
183             }
184              
185             # --- split filename into base and extension ---
186 9         24 my ($base, $ext) = ('', '');
187 9 50       42 if ($file =~ m{\A(.+)(\.[^.]+)\z}) {
188 9         21 ($base, $ext) = ($1, $2);
189             }
190             else {
191 0         0 $base = $file;
192             }
193              
194             # --- build result ---
195 9 100       26 if ($mods =~ /f/) {
196             # Full absolute path: drive + dirs + file
197             # dirs already ends with / when non-root
198 1         53 return $drv . $dirs . $file;
199             }
200              
201 8         10 my $result = '';
202 8 100       18 $result .= $drv if $mods =~ /d/;
203 8 100       27 $result .= $dirs if $mods =~ /p/;
204 8 100       17 $result .= $base if $mods =~ /n/;
205 8 100       24 $result .= $ext if $mods =~ /x/;
206 8         46 return $result;
207             }
208              
209             # ----------------------------------------------------------------
210             # expand_sh: $VAR and ${VAR} (SH mode)
211             # ----------------------------------------------------------------
212             sub expand_sh {
213 0     0 1   my ($class, $str) = @_;
214 0 0         return '' unless defined $str;
215 0           $str =~ s/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/
216 0 0         do { my $k=$1; defined($STORE{$k}) ? $STORE{$k} : defined($STORE{uc($k)}) ? $STORE{uc($k)} : '' }
  0 0          
  0            
217             /ge;
218 0           $str =~ s/\$([A-Za-z_][A-Za-z0-9_]*)/
219 0 0         do { my $k=$1; defined($STORE{$k}) ? $STORE{$k} : defined($STORE{uc($k)}) ? $STORE{uc($k)} : '' }
  0 0          
  0            
220             /ge;
221 0           return $str;
222             }
223              
224             1;
225              
226             __END__