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