File Coverage

blib/lib/App/Yath/Script.pm
Criterion Covered Total %
statement 198 209 94.7
branch 88 106 83.0
condition 36 46 78.2
subroutine 25 28 89.2
pod 8 19 42.1
total 355 408 87.0


line stmt bran cond sub pod time code
1             package App::Yath::Script;
2 13     13   205143 use strict;
  13         29  
  13         488  
3 13     13   61 use warnings;
  13         27  
  13         782  
4              
5 13     13   66 use Cwd qw/realpath/;
  13         21  
  13         848  
6 13     13   72 use Carp qw/confess/;
  13         33  
  13         793  
7 13     13   80 use File::Spec();
  13         76  
  13         489  
8              
9 13     13   7809 use Importer Importer => 'import';
  13         81975  
  13         94  
10              
11             our @EXPORT_OK = (
12             qw{
13             script
14             module
15              
16             do_exec
17              
18             clean_path
19             find_in_updir
20             find_rc_updir
21             mod2file
22             },
23             );
24              
25             our $VERSION = '2.000016';
26              
27             our ($SCRIPT, $MOD);
28              
29 0     0 1 0 sub script { $SCRIPT }
30 0     0 1 0 sub module { $MOD }
31              
32             sub do_begin {
33             # Check for an explicit version as the very first argument (V# or v#).
34             # Strip it from @ARGV before anything else sees it.
35 12     12 1 25 my $cli_version;
36 12 100 100     160 if (@ARGV && $ARGV[0] =~ /^[Vv](\d+)$/) {
37 4         18 $cli_version = int($1);
38 4         9 shift @ARGV;
39             }
40              
41 12         40 my $argv = [@ARGV];
42 12         50 my @caller = caller();
43              
44 12         34 my $exec = 0;
45              
46 12         52 $SCRIPT = clean_path($caller[1]);
47 12         202 $ENV{YATH_SCRIPT} = $SCRIPT;
48              
49 12         48 inject_includes();
50              
51 12         34 my $local_vers = install_local_lib();
52              
53 12 50       40 $exec = 1 if find_alt_script();
54              
55 12         43 my ($config, $user_config, $version) = find_rc_files($cli_version);
56 12   33     78 $version //= $local_vers;
57              
58             # Pre-parse the global section of the rc files for dev-libs flags
59             # so a user can put `-D` (etc) at the top of .yath.rc instead of on
60             # every CLI invocation. Only the global section is parsed -- command
61             # sections are reserved for the per-command parser since this layer
62             # has no idea which command is about to run. Run before the regular
63             # CLI -D pass so `T2_HARNESS_INCLUDES` carries everything across the
64             # re-exec triggered by either source.
65 12 50       59 $exec = 1 if parse_rc_dev_libs($config, $user_config);
66 12 50       37 $exec = 1 if parse_new_dev_libs();
67              
68 12 50       69 do_exec($argv) if $exec;
69              
70 12 50       73 $MOD = defined($version) ? load_yath_module($version) : load_latest_yath_module();
71              
72 12         106 $MOD->do_begin(
73             script => $SCRIPT,
74             argv => $argv,
75             config => $config,
76             user_config => $user_config,
77             );
78             }
79              
80 12     12 1 127 sub do_runtime { $MOD->do_runtime(@_) }
81              
82             sub do_exec {
83 0     0 1 0 my ($argv) = @_;
84 0         0 $ENV{T2_HARNESS_INCLUDES} = join ';' => @INC;
85 0         0 exec($^X, $SCRIPT, @$argv);
86             }
87              
88             sub find_alt_script {
89 12     12 0 25 my $script = './scripts/yath';
90 12 50       319 return 0 unless -f $script;
91 0 0       0 return 0 unless -x $script;
92              
93 0         0 $script = clean_path($script);
94              
95 0 0       0 return 0 if $script eq clean_path($SCRIPT);
96              
97 0         0 $SCRIPT = $script;
98              
99 0         0 return 1;
100             }
101              
102             sub parse_new_dev_libs {
103 15     15 0 2734 return _install_dev_libs(_collect_dev_libs(@ARGV));
104             }
105              
106             sub parse_rc_dev_libs {
107 20 100   20 0 4920 my @files = grep { defined && length } @_;
  33         173  
108 20 100       71 return 0 unless @files;
109              
110 17         55 my @args;
111 17         40 for my $file (@files) {
112 18 100       391 next unless -f $file;
113 17         57 push @args => _rc_global_tokens($file);
114             }
115              
116 17         71 return _install_dev_libs(_collect_dev_libs(@args));
117             }
118              
119             # Walk a list of argv-style tokens looking for -D / --dev-lib(s) flags.
120             # Returns the list of paths to add to @INC.
121             sub _collect_dev_libs {
122 48     48   3659 my @args = @_;
123              
124 48         86 my @add;
125 48         120 for my $arg (@args) {
126 55 100       201 last if $arg eq '::';
127 53 100       124 last if $arg eq '--';
128              
129 50 100       255 next unless $arg =~ m/^(?:-D|--dev-libs?)(?:=(.+))?$/;
130 19         52 my $val = $1;
131              
132 19 100 66     74 unless (defined $val && length $val) {
133 4         11 push @add => map { clean_path($_) } 'lib', 'blib/lib', 'blib/arch';
  12         27  
134 4         14 next;
135             }
136              
137 15         66 for my $path (split /,/, $val) {
138 19 100       44 if ($path =~ m/\*/) { push @add => glob($path) }
  1         62  
139 18         42 else { push @add => $path }
140             }
141             }
142              
143 48         221 return @add;
144             }
145              
146             # Dedup against @INC and prepend. Returns 1 if anything was added.
147             sub _install_dev_libs {
148 36     36   6618 my @add = @_;
149 36 100       213 return 0 unless @add;
150              
151 6         15 my %seen = map { ($_ => 1, clean_path($_) => 1) } @INC;
  6         18  
152 6   66     15 @add = grep { !($seen{$_} || $seen{clean_path($_)}) } @add;
  11         38  
153 6 100       20 return 0 unless @add;
154              
155 5         25 unshift @INC => @add;
156 5         40 return 1;
157             }
158              
159             # Tokenize the global section of an rc file into argv-style tokens.
160             # Stops at the first [section] marker. `--foo` and `--foo=bar` lines
161             # yield one token; `--foo bar` lines yield two, matching the format
162             # App::Yath2::ConfigFile uses for command sections.
163             sub _rc_global_tokens {
164 22     22   5529 my ($file) = @_;
165              
166 22         81 my @args;
167 22 50       738 open(my $fh, '<', $file) or return;
168 22         524 while (my $line = <$fh>) {
169 21         36 chomp $line;
170 21         59 $line =~ s/\s*[#;].*//;
171 21         43 $line =~ s/^\s+//;
172 21         37 $line =~ s/\s+$//;
173 21 100       50 next unless length $line;
174 17 100       42 last if $line =~ /^\[/;
175              
176 13 100       47 if ($line =~ /^(\S+)\s+(.+)$/) {
177 1         8 push @args => ($1, $2);
178             }
179             else {
180 12         57 push @args => $line;
181             }
182             }
183 22         263 close($fh);
184              
185 22         152 return @args;
186             }
187              
188             # Locate the project- and user-level rc files plus the version number
189             # they imply. Returns ($config, $user_config, $version), each of which
190             # may be undef. When $cli_version is defined the caller's explicit
191             # version always wins; only versioned rc files matching that version
192             # are looked up.
193             sub find_rc_files {
194 18     18 0 17670 my ($cli_version) = @_;
195              
196 18 100       82 if (defined $cli_version) {
197             # Explicit version on CLI: prefer matching versioned rc files,
198             # but fall back to plain .yath.rc / .yath.user.rc when no
199             # versioned match exists. Accept both .yath.v#.rc and
200             # .yath.V#.rc.
201 7   100     33 my $config = find_in_updir(".yath.v${cli_version}.rc")
      100        
202             // find_in_updir(".yath.V${cli_version}.rc")
203             // find_in_updir(".yath.rc");
204 7   66     21 my $user_config = find_in_updir(".yath.user.v${cli_version}.rc")
      100        
205             // find_in_updir(".yath.user.V${cli_version}.rc")
206             // find_in_updir(".yath.user.rc");
207 7         35 return ($config, $user_config, $cli_version);
208             }
209              
210 11         26 my ($config, $config_version) = find_rc_updir('.yath');
211 11         43 my ($user_config, $user_version) = find_rc_updir('.yath.user');
212              
213             # .yath.user(.v#).rc version takes precedence over .yath(.v#).rc.
214             # Either may be undef (plain unversioned rc); when both are undef
215             # the caller falls back to install_local_lib's version, then to
216             # load_yath_module's @INC scan.
217 11   66     79 my $version = $user_version // $config_version;
218              
219 11         65 return ($config, $user_config, $version);
220             }
221              
222             # Load and return the App::Yath::Script::V{X} module to delegate to.
223             # Dies if the requested module fails to load. V0 is reserved for
224             # script validation and emits a warning when explicitly requested.
225             sub load_yath_module {
226 14     14 0 4499 my ($version) = @_;
227              
228 14 100       448 warn "Warning: Version '0' is for validating the yath script only, it should not be used for any real testing.\n"
229             if $version == 0;
230              
231 14         71 my $mod = "App::Yath::Script::V${version}";
232 14         47 my $file = mod2file($mod);
233 14 100       26 eval { require $file; 1 } or die "Could not load $mod: $@";
  14         7543  
  13         86  
234 13         72 return $mod;
235             }
236              
237             # Scan @INC for installed App::Yath::Script::V#.pm modules and return
238             # the version numbers sorted highest-first. V0 is excluded since it is
239             # reserved for script validation and must never be auto-selected.
240             sub find_installed_versions {
241 4     4 0 2626 my %found;
242 4         10 for my $inc (@INC) {
243 4 50       12 next if ref $inc;
244 4         35 my $dir = File::Spec->catdir($inc, 'App', 'Yath', 'Script');
245 4 100       92 next unless -d $dir;
246 3 50       113 opendir(my $dh, $dir) or next;
247 3         68 for my $entry (readdir $dh) {
248 14 100       60 $found{$1} = 1 if $entry =~ /^V(\d+)\.pm$/;
249             }
250 3         53 closedir $dh;
251             }
252 4         9 delete $found{0};
253 4         19 return sort { $b <=> $a } keys %found;
  5         29  
254             }
255              
256             # Final fallback when no version was captured from CLI, rc files, or a
257             # local checkout. Tries each installed App::Yath::Script::V# module
258             # from highest to lowest until one loads. Dies with the collected load
259             # errors if none succeed (or if none are installed).
260             sub load_latest_yath_module {
261 3     3 0 7835 my @vers = find_installed_versions();
262              
263 3 100       15 die "No App::Yath (App::Yath::Script::V#) modules appear to be installed.\n"
264             unless @vers;
265              
266 2         4 my @err;
267 2         5 for my $v (@vers) {
268 3         7 my $mod = "App::Yath::Script::V${v}";
269 3         9 my $file = mod2file($mod);
270 3 100       4 return $mod if eval { require $file; 1 };
  3         1482  
  2         16  
271 1         4 push @err => $@;
272             }
273              
274 0         0 die join "\n" => (
275             "No Test2::Harness (App::Yath) versions could be loaded:",
276             @err,
277             );
278             }
279              
280             sub inject_includes {
281 14 100   14 0 7036 return unless $ENV{T2_HARNESS_INCLUDES};
282 1         8 @INC = split /;/, $ENV{T2_HARNESS_INCLUDES};
283             }
284              
285             # Scan ./lib/App/Yath/Script for V#.pm modules and return the highest
286             # version found, or undef when no such modules are present. Used by
287             # install_local_lib() to detect a working-copy checkout that ships its
288             # own versioned script module.
289             sub find_local_version {
290 21     21 0 3965 my $local_path = File::Spec->catdir(File::Spec->curdir, 'lib', 'App', 'Yath', 'Script');
291 21 100       731 return undef unless -d $local_path;
292 6 50       193 opendir(my $dh, $local_path) or return undef;
293              
294 6         10 my $vers;
295 6         115 for my $file (readdir($dh)) {
296 22 100       59 next unless $file =~ m/^V(\d+)\.pm$/;
297 9         17 my $n = int($1);
298 9 100 100     29 $vers = $n if !defined($vers) || $n > $vers;
299             }
300 6         47 closedir $dh;
301              
302 6         36 return $vers;
303             }
304              
305             # If the cwd contains ./lib/App/Yath/Script/V#.pm modules, ensure ./lib
306             # is at the front of @INC so they take precedence over any installed
307             # copy. Returns the highest local version found, or undef if no local
308             # modules exist. Idempotent: a re-exec that already has ./lib in @INC
309             # (via T2_HARNESS_INCLUDES) does not re-print or re-unshift.
310             sub install_local_lib {
311 15     15 0 3702 my $local_vers = find_local_version();
312 15 100       73 return undef unless defined $local_vers;
313              
314 2         19 my $lib_path = clean_path(File::Spec->catdir(File::Spec->curdir, 'lib'));
315 2 100       5 return $local_vers if grep { clean_path($_) eq $lib_path } @INC;
  3         6  
316              
317 1         4 print "Detected App::Yath::Script::V# modules in local ./lib, adding '$lib_path' to the front of \@INC.\n";
318 1         2 unshift @INC => $lib_path;
319              
320 1         4 return $local_vers;
321             }
322              
323             sub clean_path {
324 70     70 1 6197 my ( $path, $absolute ) = @_;
325              
326 70 100       782 confess "No path was provided to clean_path()" unless $path;
327              
328 68   100     271 $absolute //= 1;
329 68 100 66     3255 $path = realpath($path) // $path if $absolute;
330              
331 68         931 return File::Spec->rel2abs($path);
332             }
333              
334             sub find_rc_updir {
335 37     37 0 39689 my ($prefix) = @_;
336              
337 37         1147 my $versioned_pattern = qr/^\Q$prefix\E\.[Vv](\d+)\.rc$/;
338 37         107 my $plain_name = "$prefix.rc";
339              
340 37         59 my $abs = eval { realpath(File::Spec->rel2abs('.')) };
  37         1593  
341 37         76 my %seen;
342 37   66     270 while ($abs && !$seen{$abs}++) {
343             # Priority 1: plain name that is a symlink to a versioned file.
344 60         511 my $plain_path = File::Spec->catfile($abs, $plain_name);
345 60 100 66     1563 if (-l $plain_path && -f $plain_path) {
346 5   50     79 my $target = readlink($plain_path) // '';
347 5 50       120 if ((File::Spec->splitpath($target))[2] =~ $versioned_pattern) {
348 5         42 return ($plain_path, int($1));
349             }
350             }
351              
352             # Priority 2: explicitly versioned file -- highest version wins.
353 55 50       1742 if (opendir(my $dh, $abs)) {
354 55         110 my ($best_ver, $best_entry);
355 55         4521 for my $entry (readdir $dh) {
356 498 100       1602 next unless $entry =~ $versioned_pattern;
357 20         71 my $v = int($1);
358 20 100 100     78 if (!defined($best_ver) || $v > $best_ver) {
359 19         26 $best_ver = $v;
360 19         48 $best_entry = $entry;
361             }
362             }
363 55         708 closedir $dh;
364 55 100       150 if (defined $best_ver) {
365 17         344 return (File::Spec->catfile($abs, $best_entry), $best_ver);
366             }
367             }
368              
369             # Priority 3: plain unversioned file -- no version captured, the
370             # caller (find_rc_files / install_local_lib / load_yath_module)
371             # decides what version to use.
372 38 100       335 if (-f $plain_path) {
373 4         28 return ($plain_path, undef);
374             }
375              
376 34         70 $abs = eval { realpath(File::Spec->catdir($abs, '..')) };
  34         960  
377             }
378              
379 11         126 return;
380             }
381              
382             sub find_in_updir {
383 33     33 1 4364 my $path = shift;
384 33 100       2188 return clean_path($path) if -e $path;
385              
386 24         48 my %seen;
387 24         37 while(1) {
388 74         422 $path = File::Spec->catdir('..', $path);
389 74         135 my $check = eval { realpath(File::Spec->rel2abs($path)) };
  74         3119  
390 74 50       244 last unless $check;
391 74 100       234 last if $seen{$check}++;
392 50 50       327 return $check if -e $check;
393             }
394              
395 24         247 return;
396             }
397              
398             sub mod2file {
399 22     22 1 265510 my ($mod) = @_;
400 22 100       737 confess "No module name provided" unless $mod;
401 20         34 my $file = $mod;
402 20         132 $file =~ s{::}{/}g;
403 20         47 $file .= ".pm";
404 20         65 return $file;
405             }
406              
407             1;
408              
409             __END__