package App::Codeowners::Util; # ABSTRACT: Grab bag of utility subs for Codeowners modules =head1 DESCRIPTION B except in L and related modules. =cut use warnings; use strict; use Encode qw(decode); use Exporter qw(import); use Path::Tiny; our @EXPORT_OK = qw( colorstrip find_codeowners_in_directory find_nearest_codeowners git_ls_files git_toplevel run_command run_git stringf stringify unbackslash zip ); our $VERSION = '9999.999'; # VERSION =func find_nearest_codeowners $filepath = find_nearest_codeowners($dirpath); Find the F file in the current working directory, or search in the parent directory recursively until a F file is found. Returns C if no F is found. =cut sub find_nearest_codeowners { my $path = path(shift || '.')->absolute; while (!$path->is_rootdir) { my $filepath = find_codeowners_in_directory($path); return $filepath if $filepath; $path = $path->parent; } } =func find_codeowners_in_directory $filepath = find_codeowners_in_directory($dirpath); Find the F file in a given directory. No recursive searching is done. Returns the first of (or undef if none found): =for :list * F * F * F<.bitbucket/CODEOWNERS> * F<.github/CODEOWNERS> * F<.gitlab/CODEOWNERS> =cut sub find_codeowners_in_directory { my $path = path(shift) or die; my @tries = ( [qw(CODEOWNERS)], [qw(docs CODEOWNERS)], [qw(.bitbucket CODEOWNERS)], [qw(.github CODEOWNERS)], [qw(.gitlab CODEOWNERS)], ); for my $parts (@tries) { my $try = $path->child(@$parts); return $try if $try->is_file; } } sub run_command { my $filter; $filter = pop if ref($_[-1]) eq 'CODE'; my ($child_in, $child_out); require IPC::Open2; my $pid = IPC::Open2::open2($child_out, $child_in, @_); close($child_in); binmode($child_out, ':encoding(UTF-8)'); my $proc = App::Codeowners::Util::Process->new( pid => $pid, fh => $child_out, filter => $filter, ); return wantarray ? ($proc, @{$proc->all}) : $proc; } sub run_git { return run_command('git', @_); } sub git_ls_files { my $dir = shift || '.'; return run_git('-C', $dir, 'ls-files', @_, \&_unescape_git_filepath); } # Depending on git's "core.quotepath" config, non-ASCII chars may be # escaped (identified by surrounding dquotes), so try to unescape. sub _unescape_git_filepath { return $_ if $_ !~ /^"(.+)"$/; return decode('UTF-8', unbackslash($1)); } sub git_toplevel { my $dir = shift || '.'; my ($proc, $path) = run_git('-C', $dir, qw{rev-parse --show-toplevel}); return if $proc->wait != 0 || !$path; return path($path); } sub colorstrip { my $str = shift || ''; $str =~ s/\e\[[\d;]*m//g; return $str; } sub stringify { my $item = shift; return ref($item) eq 'ARRAY' ? join(',', @$item) : $item; } # The zip code is from List::SomeUtils (thanks DROLSKY), copied just so as not # to bring in the extra dependency. sub zip (\@\@) { ## no critic (Subroutines::ProhibitSubroutinePrototypes) my $max = -1; $max < $#$_ && ( $max = $#$_ ) foreach @_; map { my $ix = $_; map $_->[$ix], @_; } 0 .. $max; } # The stringf code is from String::Format (thanks SREZIC), with changes: # - Use Unicode::GCString for better Unicode character padding, # - Strip ANSI color sequences, # - Prevent 'Negative repeat count does nothing' warnings sub _replace { my ($args, $orig, $alignment, $min_width, $max_width, $passme, $formchar) = @_; # For unknown escapes, return the orignial return $orig unless defined $args->{$formchar}; $alignment = '+' unless defined $alignment; my $replacement = $args->{$formchar}; if (ref $replacement eq 'CODE') { # $passme gets passed to subrefs. $passme ||= ""; $passme =~ tr/{}//d; $replacement = $replacement->($passme); } my $replength; if (eval { require Unicode::GCString }) { my $gcstring = Unicode::GCString->new(colorstrip($replacement)); $replength = $gcstring->columns; } else { $replength = length colorstrip($replacement); } $min_width ||= $replength; $max_width ||= $replength; # length of replacement is between min and max if (($replength > $min_width) && ($replength < $max_width)) { return $replacement; } # length of replacement is longer than max; truncate if ($replength > $max_width) { return substr($replacement, 0, $max_width); } my $padding = $min_width - $replength; $padding = 0 if $padding < 0; # length of replacement is less than min: pad if ($alignment eq '-') { # left align; pad in front return $replacement . ' ' x $padding; } # right align, pad at end return ' ' x $padding . $replacement; } my $regex = qr/ (% # leading '%' (-)? # left-align, rather than right (\d*)? # (optional) minimum field width (?:\.(\d*))? # (optional) maximum field width (\{.*?\})? # (optional) stuff inside (\S) # actual format character )/x; sub stringf { my $format = shift || return; my $args = UNIVERSAL::isa($_[0], 'HASH') ? shift : { @_ }; $args->{'n'} = "\n" unless exists $args->{'n'}; $args->{'t'} = "\t" unless exists $args->{'t'}; $args->{'%'} = "%" unless exists $args->{'%'}; $format =~ s/$regex/_replace($args, $1, $2, $3, $4, $5, $6)/ge; return $format; } # The unbacklash code is from String::Escape (thanks EVO), with changes: # - Handle \a, \b, \f and \v (thanks Berk Akinci) my %unbackslash; sub unbackslash { my $str = shift; # Earlier definitions are preferred to later ones, thus we output \n not \x0d %unbackslash = ( ( map { $_ => $_ } ( '\\', '"', '$', '@' ) ), ( 'r' => "\r", 'n' => "\n", 't' => "\t" ), ( map { 'x' . unpack('H2', chr($_)) => chr($_) } (0..255) ), ( map { sprintf('%03o', $_) => chr($_) } (0..255) ), ( 'a' => "\x07", 'b' => "\x08", 'f' => "\x0c", 'v' => "\x0b" ), ) if !%unbackslash; $str =~ s/ (\A|\G|[^\\]) \\ ( [0-7]{3} | x[\da-fA-F]{2} | . ) / $1 . $unbackslash{lc($2)} /gsxe; return $str; } { package App::Codeowners::Util::Process; sub new { my $class = shift; return bless {@_}, $class; } sub next { my $self = shift; my $line = readline($self->{fh}); if (defined $line) { chomp $line; if (my $filter = $self->{filter}) { local $_ = $line; $line = $filter->($line); } } $line; } sub all { my $self = shift; chomp(my @lines = readline($self->{fh})); if (my $filter = $self->{filter}) { $_ = $filter->($_) for @lines; } \@lines; } sub wait { my $self = shift; my $pid = $self->{pid} or return; if (my $fh = $self->{fh}) { close($fh); delete $self->{fh}; } waitpid($pid, 0); my $status = $?; delete $self->{pid}; return $status; } sub DESTROY { my ($self, $global_destruction) = @_; return if $global_destruction; $self->wait; } } 1;