1package Test2::Harness::Util;
2use strict;
3use warnings;
4
5use Carp qw/confess/;
6use Cwd qw/realpath/;
7use Test2::Util qw/try_sig_mask do_rename/;
8use Fcntl qw/LOCK_EX LOCK_UN SEEK_SET :mode/;
9use File::Spec;
10
11our $VERSION = '1.000082';
12
13use Importer Importer => 'import';
14
15our @EXPORT_OK = qw{
16    find_libraries
17    clean_path
18
19    parse_exit
20    mod2file
21    file2mod
22    fqmod
23
24    maybe_open_file
25    maybe_read_file
26    open_file
27    read_file
28    write_file
29    write_file_atomic
30    lock_file
31    unlock_file
32
33    hub_truth
34
35    apply_encoding
36
37    process_includes
38
39    chmod_tmp
40};
41
42sub chmod_tmp {
43    my $file = shift;
44
45    my $mode = S_ISVTX | S_IRWXU | S_IRWXG | S_IRWXO;
46
47    chmod($mode, $file);
48}
49
50sub process_includes {
51    my %params = @_;
52
53    my @start = @{delete $params{list} // []};
54
55    my @list;
56    my %seen = ('.' => 1);
57
58    if (my $ch_dir = delete $params{ch_dir}) {
59        for my $path (@start) {
60            # '.' is special.
61            $seen{'.'}++ and next if $path eq '.';
62
63            if (File::Spec->file_name_is_absolute($path)) {
64                push @list => $path;
65            }
66            else {
67                push @list => File::Spec->catdir($ch_dir, $path);
68            }
69        }
70    }
71    else {
72        @list = @start;
73    }
74
75    push @list => @INC if delete $params{include_current};
76
77    @list = map { $_ eq '.' ? $_ : clean_path($_) || $_ } @list if delete $params{clean};
78
79    @list = grep { !$seen{$_}++ } @list;
80
81    # If we ask for dot, or saw it during our processing, add it to the end.
82    push @list => '.' if delete($params{include_dot}) || $seen{'.'} > 1;
83
84    confess "Invalid parameters: " . join(', ' => sort keys %params) if keys %params;
85
86    return @list;
87}
88
89sub apply_encoding {
90    my ($fh, $enc) = @_;
91    return unless $enc;
92
93    # https://rt.perl.org/Public/Bug/Display.html?id=31923
94    # If utf8 is requested we use ':utf8' instead of ':encoding(utf8)' in
95    # order to avoid the thread segfault.
96    return binmode($fh, ":utf8") if $enc =~ m/^utf-?8$/i;
97    binmode($fh, ":encoding($enc)");
98}
99
100sub parse_exit {
101    my ($exit) = @_;
102
103    my $sig = $exit & 127;
104    my $dmp = $exit & 128;
105
106    return {
107        sig => $sig,
108        err => ($exit >> 8),
109        dmp => $dmp,
110        all => $exit,
111    };
112}
113
114sub fqmod {
115    my ($prefix, $input) = @_;
116    return $1 if $input =~ m/^\+(.*)$/;
117    return "$prefix\::$input";
118}
119
120sub hub_truth {
121    my ($f) = @_;
122
123    return $f->{hubs}->[0] if $f->{hubs} && @{$f->{hubs}};
124    return $f->{trace} if $f->{trace};
125    return {};
126}
127
128sub maybe_read_file {
129    my ($file) = @_;
130    return undef unless -f $file;
131    return read_file($file);
132}
133
134sub read_file {
135    my ($file, @args) = @_;
136
137    my $fh = open_file($file, '<', @args);
138    local $/;
139    my $out = <$fh>;
140    close_file($fh, $file);
141
142    return $out;
143}
144
145sub write_file {
146    my ($file, @content) = @_;
147
148    my $fh = open_file($file, '>');
149    print $fh @content;
150    close_file($fh, $file);
151
152    return @content;
153};
154
155my %COMPRESSION = (
156    bz2 => {module => 'IO::Uncompress::Bunzip2', errors => \$IO::Uncompress::Bunzip2::Bunzip2Error},
157    gz  => {module => 'IO::Uncompress::Gunzip',  errors => \$IO::Uncompress::Gunzip::GunzipError},
158);
159sub open_file {
160    my ($file, $mode, %opts) = @_;
161    $mode ||= '<';
162
163    unless ($opts{no_decompress}) {
164        if (my $ext = $opts{ext}) {
165            $opts{compression} //= $COMPRESSION{$ext} or die "Unknown compression: $ext";
166        }
167
168        if ($file =~ m/\.(gz|bz2)$/i) {
169            my $ext = lc($1);
170            $opts{compression} //= $COMPRESSION{$ext} or die "Unknown compression: $ext";
171        }
172
173        if ($mode eq '<' && $opts{compression}) {
174            my $spec = $opts{compression};
175            my $mod  = $spec->{module};
176            require(mod2file($mod));
177
178            my $fh = $mod->new($file) or die "Could not open file '$file' ($mode): ${$spec->{errors}}";
179            return $fh;
180        }
181    }
182
183    open(my $fh, $mode, $file) or confess "Could not open file '$file' ($mode): $!";
184    return $fh;
185}
186
187sub maybe_open_file {
188    my ($file, $mode) = @_;
189    return undef unless -f $file;
190    return open_file($file, $mode);
191}
192
193sub close_file {
194    my ($fh, $name) = @_;
195    return if close($fh);
196    confess "Could not close file: $!" unless $name;
197    confess "Could not close file '$name': $!";
198}
199
200sub write_file_atomic {
201    my ($file, @content) = @_;
202
203    my $pend = "$file.pend";
204
205    my ($ok, $err) = try_sig_mask {
206        write_file($pend, @content);
207        my ($ren_ok, $ren_err) = do_rename($pend, $file);
208        die "$pend -> $file: $ren_err" unless $ren_ok;
209    };
210
211    die $err unless $ok;
212
213    return @content;
214}
215
216sub lock_file {
217    my ($file, $mode) = @_;
218
219    my $fh;
220    if (ref $file) {
221        $fh = $file;
222    }
223    else {
224        open($fh, $mode // '>>', $file) or die "Could not open file '$file': $!";
225    }
226
227    for (1 .. 21) {
228        flock($fh, LOCK_EX) and last;
229        die "Could not lock file (try $_): $!" if $_ >= 20;
230        next if $!{EINTR} || $!{ERESTART};
231        die "Could not lock file: $!";
232    }
233
234    return $fh;
235}
236
237sub unlock_file {
238    my ($fh) = @_;
239    for (1 .. 21) {
240        flock($fh, LOCK_UN) and last;
241        die "Could not unlock file (try $_): $!" if $_ >= 20;
242        next if $!{EINTR} || $!{ERESTART};
243        die "Could not unlock file: $!";
244    }
245
246    return $fh;
247}
248
249sub clean_path {
250    my ( $path, $absolute ) = @_;
251
252    $absolute //= 1;
253    $path = realpath($path) // $path if $absolute;
254
255    return File::Spec->rel2abs($path);
256}
257
258sub mod2file {
259    my ($mod) = @_;
260    confess "No module name provided" unless $mod;
261    my $file = $mod;
262    $file =~ s{::}{/}g;
263    $file .= ".pm";
264    return $file;
265}
266
267sub file2mod {
268    my $file = shift;
269    my $mod  = $file;
270    $mod =~ s{/}{::}g;
271    $mod =~ s/\..*$//;
272    return $mod;
273}
274
275
276sub find_libraries {
277    my ($search, @paths) = @_;
278    my @parts = grep $_, split /::(\*)?/, $search;
279
280    @paths = @INC unless @paths;
281
282    @paths = map { File::Spec->canonpath($_) } @paths;
283
284    my %prefixes = map {$_ => 1} @paths;
285
286    my @found;
287    my @bases = ([map { [$_ => length($_)] } @paths]);
288    while (my $set = shift @bases) {
289        my $new_base = [];
290        my $part      = shift @parts;
291
292        for my $base (@$set) {
293            my ($dir, $prefix) = @$base;
294            if ($part ne '*') {
295                my $path = File::Spec->catdir($dir, $part);
296                if (@parts) {
297                    push @$new_base => [$path, $prefix] if -d $path;
298                }
299                elsif (-f "$path.pm") {
300                    push @found => ["$path.pm", $prefix];
301                }
302
303                next;
304            }
305
306            opendir(my $dh, $dir) or next;
307            for my $item (readdir($dh)) {
308                next if $item =~ m/^\./;
309                my $path = File::Spec->catdir($dir, $item);
310                if (@parts) {
311                    # Sometimes @INC dirs are nested in eachother.
312                    next if $prefixes{$path};
313
314                    push @$new_base => [$path, $prefix] if -d $path;
315                    next;
316                }
317
318                next unless -f $path && $path =~ m/\.pm$/;
319                push @found => [$path, $prefix];
320            }
321        }
322
323        push @bases => $new_base if @$new_base;
324    }
325
326    my %out;
327    for my $found (@found) {
328        my ($path, $prefix) = @$found;
329
330        my @file_parts = File::Spec->splitdir(substr($path, $prefix));
331        shift @file_parts if $file_parts[0] eq '';
332
333        my $file = join '/' => @file_parts;
334        $file_parts[-1] = substr($file_parts[-1], 0, -3);
335        my $module = join '::' => @file_parts;
336
337        $out{$module} //= $file;
338    }
339
340    return \%out;
341}
342
3431;
344
345__END__
346
347
348=pod
349
350=encoding UTF-8
351
352=head1 NAME
353
354Test2::Harness::Util - General utiliy functions.
355
356=head1 DESCRIPTION
357
358=head1 METHODS
359
360=head2 MISC
361
362=over 4
363
364=item apply_encoding($fh, $enc)
365
366Apply the specified encoding to the filehandle.
367
368B<Justification>:
369L<PERLBUG 31923|https://rt.perl.org/Public/Bug/Display.html?id=31923>
370If utf8 is requested we use ':utf8' instead of ':encoding(utf8)' in
371order to avoid the thread segfault.
372
373This is a reusable implementation of this:
374
375    sub apply_encoding {
376        my ($fh, $enc) = @_;
377        return unless $enc;
378        return binmode($fh, ":utf8") if $enc =~ m/^utf-?8$/i;
379        binmode($fh, ":encoding($enc)");
380    }
381
382=item $clean = clean_path($path)
383
384Take a file path and clean it up to a minimal absolute path if possible. Always
385returns a path, but if it cannot be cleaned up it is unchanged.
386
387=item $hashref = find_libraries($search)
388
389=item $hashref = find_libraries($search, @paths)
390
391C<@INC> is used if no C<@paths> are provided.
392
393C<$search> should be a module name with C<*> wildcards replacing sections.
394
395    find_libraries('Foo::*::Baz')
396    find_libraries('*::Bar::Baz')
397    find_libraries('Foo::Bar::*')
398
399These all look for modules matching the search, this is a good way to find
400plugins, or similar patterns.
401
402The result is a hashref of C<< { $module => $path } >>. If a module exists in
403more than 1 search path the first is used.
404
405=item $mod = fqmod($prefix, $mod)
406
407This will automatically add C<$prefix> to C<$mod> with C<'::'> to join them. If
408C<$mod> starts with the C<'+'> character the character will be removed and the
409result returned without prepending C<$prefix>.
410
411=item hub_truth
412
413This is an internal implementation detail, do not use it.
414
415=item $hashref = parse_exit($?)
416
417This parses the exit value as typically stored in C<$?>.
418
419Resulting hash:
420
421    {
422        sig => ($? & 127), # Signal value if the exit was caused by a signal
423        err => ($? >> 8),  # Actual exit code, if any.
424        dmp => ($? & 128), # Was there a core dump?
425        all => $?,         # Original exit value, unchanged
426    }
427
428
429=item @list = process_includes(%PARAMS)
430
431This method will build up a list of include dirs fit for C<@INC>. The returned
432list should contain only unique values, in proper order.
433
434Params:
435
436=over 4
437
438=item list => \@START
439
440Paths to start the new list.
441
442Optional.
443
444=item ch_dir => $path
445
446Prefix to prepend to all paths in the C<list> param. No effect without an
447initial list.
448
449=item include_current => $bool
450
451This will add all paths from C<@INC> to the output, after the initial list.
452Note that '.', if in C<@INC> will be moved to the end of the final output.
453
454=item clean => $bool
455
456If included all paths except C<'.'> will be cleaned using C<clean_path()>.
457
458=item include_dot => $bool
459
460If true C<'.'> will be appended to the end of the output.
461
462B<Note> even if this is set to false C<'.'> may still be included if it was in
463the initial list, or if it was in C<@INC> and C<@INC> was included using the
464C<include_current> parameter.
465
466=back
467
468=back
469
470=head2 FOR DEALING WITH MODULE <-> FILE CONVERSION
471
472These convert between module names like C<Foo::Bar> and filenames like
473C<Foo/Bar.pm>.
474
475=over 4
476
477=item $file = mod2file($mod)
478
479=item $mod = file2mod($file)
480
481=back
482
483=head2 FOR READING/WRITING FILES
484
485=over 4
486
487=item $fh = open_file($path, $mode)
488
489=item $fh = open_file($path)
490
491If no mode is provided C<< '<' >> is assumed.
492
493This will open the file at C<$path> and return a filehandle.
494
495An exception will be thrown if the file cannot be opened.
496
497B<NOTE:> This will automatically use L<IO::Uncompress::Bunzip2> or
498L<IO::Uncompress::Gunzip> to uncompress the file if it has a .bz2 or .gz
499extension.
500
501=item $text = read_file($file)
502
503This will open the file at C<$path> and return all its contents.
504
505An exception will be thrown if the file cannot be opened.
506
507B<NOTE:> This will automatically use L<IO::Uncompress::Bunzip2> or
508L<IO::Uncompress::Gunzip> to uncompress the file if it has a .bz2 or .gz
509extension.
510
511=item $fh = maybe_open_file($path)
512
513=item $fh = maybe_open_file($path, $mode)
514
515If no mode is provided C<< '<' >> is assumed.
516
517This will open the file at C<$path> and return a filehandle.
518
519C<undef> is returned if the file cannot be opened.
520
521B<NOTE:> This will automatically use L<IO::Uncompress::Bunzip2> or
522L<IO::Uncompress::Gunzip> to uncompress the file if it has a .bz2 or .gz
523extension.
524
525=item $text = maybe_read_file($path)
526
527This will open the file at C<$path> and return all its contents.
528
529This will return C<undef> if the file cannot be opened.
530
531B<NOTE:> This will automatically use L<IO::Uncompress::Bunzip2> or
532L<IO::Uncompress::Gunzip> to uncompress the file if it has a .bz2 or .gz
533extension.
534
535=item @content = write_file($path, @content)
536
537Write content to the specified file. This will open the file with mode
538C<< '>' >>, write the content, then close the file.
539
540An exception will be thrown if any part fails.
541
542=item @content = write_file_atomic($path, @content)
543
544This will open a temporary file, write the content, close the file, then rename
545the file to the desired C<$path>. This is essentially an atomic write in that
546C<$file> will not exist until all content is written, preventing other
547processes from doing a partial read while C<@content> is being written.
548
549=back
550
551=head1 SOURCE
552
553The source code repository for Test2-Harness can be found at
554F<http://github.com/Test-More/Test2-Harness/>.
555
556=head1 MAINTAINERS
557
558=over 4
559
560=item Chad Granum E<lt>exodist@cpan.orgE<gt>
561
562=back
563
564=head1 AUTHORS
565
566=over 4
567
568=item Chad Granum E<lt>exodist@cpan.orgE<gt>
569
570=back
571
572=head1 COPYRIGHT
573
574Copyright 2020 Chad Granum E<lt>exodist7@gmail.comE<gt>.
575
576This program is free software; you can redistribute it and/or
577modify it under the same terms as Perl itself.
578
579See F<http://dev.perl.org/licenses/>
580
581=cut
582