package File::Codeowners; # ABSTRACT: Read and write CODEOWNERS files use v5.10.1; # defined-or use warnings; use strict; use Encode qw(encode); use Path::Tiny; use Scalar::Util qw(openhandle); use Text::Gitignore qw(build_gitignore_matcher); our $VERSION = '0.41'; # VERSION sub _croak { require Carp; Carp::croak(@_); } sub _usage { _croak("Usage: @_\n") } sub new { my $class = shift; my $self = bless {}, $class; } sub parse { my $self = shift; my $input = shift or _usage(q{$codeowners->parse($input)}); return $self->parse_from_array($input, @_) if @_; return $self->parse_from_array($input) if ref($input) eq 'ARRAY'; return $self->parse_from_string($input) if ref($input) eq 'SCALAR'; return $self->parse_from_fh($input) if openhandle($input); return $self->parse_from_filepath($input); } sub parse_from_filepath { my $self = shift; my $path = shift or _usage(q{$codeowners->parse_from_filepath($filepath)}); $self = bless({}, $self) if !ref($self); return $self->parse_from_fh(path($path)->openr_utf8); } sub parse_from_fh { my $self = shift; my $fh = shift or _usage(q{$codeowners->parse_from_fh($fh)}); $self = bless({}, $self) if !ref($self); my @lines; my $parse_unowned; my %unowned; my $current_project; while (my $line = <$fh>) { my $lineno = $. - 1; chomp $line; if ($line eq '### UNOWNED (File::Codeowners)') { $parse_unowned++; last; } elsif ($line =~ /^\h*#(.*)/) { my $comment = $1; if ($comment =~ /^\h*Project:\h*(.+?)\h*$/i) { $current_project = $1 || undef; } $lines[$lineno] = { comment => $comment, }; } elsif ($line =~ /^\h*$/) { # blank line } elsif ($line =~ /^\h*(.+?)(? $pattern, owners => \@owners, $current_project ? (project => $current_project) : (), }; } else { die "Parse error on line $.: $line\n"; } } if ($parse_unowned) { while (my $line = <$fh>) { chomp $line; if ($line =~ /# (.+)/) { my $filepath = $1; $unowned{$filepath}++; } } } $self->{lines} = \@lines; $self->{unowned} = \%unowned; return $self; } sub parse_from_array { my $self = shift; my $arr = shift or _usage(q{$codeowners->parse_from_array(\@lines)}); $self = bless({}, $self) if !ref($self); $arr = [$arr, @_] if @_; my $str = join("\n", @$arr); return $self->parse_from_string(\$str); } sub parse_from_string { my $self = shift; my $str = shift or _usage(q{$codeowners->parse_from_string(\$string)}); $self = bless({}, $self) if !ref($self); my $ref = ref($str) eq 'SCALAR' ? $str : \$str; open(my $fh, '<:encoding(UTF-8)', $ref) or die "open failed: $!"; return $self->parse_from_fh($fh); } sub write_to_filepath { my $self = shift; my $path = shift or _usage(q{$codeowners->write_to_filepath($filepath)}); path($path)->spew_utf8([map { "$_\n" } @{$self->write_to_array('')}]); } sub write_to_fh { my $self = shift; my $fh = shift or _usage(q{$codeowners->write_to_fh($fh)}); for my $line (@{$self->write_to_array}) { print $fh "$line\n"; } } sub write_to_string { my $self = shift; my $str = join("\n", @{$self->write_to_array}) . "\n"; return \$str; } sub write_to_array { my $self = shift; my $charset = shift // 'UTF-8'; my @format; for my $line (@{$self->_lines}) { if (my $comment = $line->{comment}) { push @format, "#$comment"; } elsif (my $pattern = $line->{pattern}) { my $owners = join(' ', @{$line->{owners}}); push @format, "$pattern $owners"; } else { push @format, ''; } } my @unowned = sort keys %{$self->_unowned}; if (@unowned) { push @format, '' if $format[-1]; push @format, '### UNOWNED (File::Codeowners)'; for my $unowned (@unowned) { push @format, "# $unowned"; } } if ($charset) { $_ = encode($charset, $_) for @format; } return \@format; } sub match { my $self = shift; my $filepath = shift or _usage(q{$codeowners->match($filepath)}); my $lines = $self->{match_lines} ||= [reverse grep { ($_ || {})->{pattern} } @{$self->_lines}]; for my $line (@$lines) { my $matcher = $line->{matcher} ||= build_gitignore_matcher([$line->{pattern}]); return { # deep copy pattern => $line->{pattern}, owners => [@{$line->{owners} || []}], $line->{project} ? (project => $line->{project}) : (), } if $matcher->($filepath); } return undef; ## no critic (Subroutines::ProhibitExplicitReturn) } sub owners { my $self = shift; my $pattern = shift; return $self->{owners} if !$pattern && $self->{owners}; my %owners; for my $line (@{$self->_lines}) { next if $pattern && $line->{pattern} && $pattern ne $line->{pattern}; $owners{$_}++ for (@{$line->{owners} || []}); } my $owners = [sort keys %owners]; $self->{owners} = $owners if !$pattern; return $owners; } sub patterns { my $self = shift; my $owner = shift; return $self->{patterns} if !$owner && $self->{patterns}; my %patterns; for my $line (@{$self->_lines}) { next if $owner && !grep { $_ eq $owner } @{$line->{owners} || []}; my $pattern = $line->{pattern}; $patterns{$pattern}++ if $pattern; } my $patterns = [sort keys %patterns]; $self->{patterns} = $patterns if !$owner; return $patterns; } sub update_owners { my $self = shift; my $pattern = shift; my $owners = shift; $pattern && $owners or _usage(q{$codeowners->update_owners($pattern => \@owners)}); $owners = [$owners] if ref($owners) ne 'ARRAY'; $self->_clear; for my $line (@{$self->_lines}) { next if !$line->{pattern}; next if $pattern ne $line->{pattern}; $line->{owners} = [@$owners]; } } sub append { my $self = shift; $self->_clear; push @{$self->_lines}, (@_ ? {@_} : undef); } sub prepend { my $self = shift; $self->_clear; unshift @{$self->_lines}, (@_ ? {@_} : undef); } sub unowned { my $self = shift; [sort keys %{$self->{unowned} || {}}]; } sub add_unowned { my $self = shift; $self->_unowned->{$_}++ for @_; } sub remove_unowned { my $self = shift; delete $self->_unowned->{$_} for @_; } sub is_unowned { my $self = shift; my $filepath = shift; $self->_unowned->{$filepath}; } sub clear_unowned { my $self = shift; $self->{unowned} = {}; } sub _lines { shift->{lines} ||= [] } sub _unowned { shift->{unowned} ||= {} } sub _clear { my $self = shift; delete $self->{match_lines}; delete $self->{owners}; delete $self->{patterns}; } 1; __END__ =pod =encoding UTF-8 =head1 NAME File::Codeowners - Read and write CODEOWNERS files =head1 VERSION version 0.41 =head1 METHODS =head2 new $codeowners = File::Codeowners->new; Construct a new L. =head2 parse $codeowners = File::Codeowners->parse('path/to/CODEOWNERS'); $codeowners = File::Codeowners->parse($filehandle); $codeowners = File::Codeowners->parse(\@lines); $codeowners = File::Codeowners->parse(\$string); Parse a F file. This is a shortcut for the C methods. =head2 parse_from_filepath $codeowners = File::Codeowners->parse_from_filepath('path/to/CODEOWNERS'); Parse a F file from the filesystem. =head2 parse_from_fh $codeowners = File::Codeowners->parse_from_fh($filehandle); Parse a F file from an open filehandle. =head2 parse_from_array $codeowners = File::Codeowners->parse_from_array(\@lines); Parse a F file stored as lines in an array. =head2 parse_from_string $codeowners = File::Codeowners->parse_from_string(\$string); $codeowners = File::Codeowners->parse_from_string($string); Parse a F file stored as a string. String should be UTF-8 encoded. =head2 write_to_filepath $codeowners->write_to_filepath($filepath); Write the contents of the file to the filesystem atomically. =head2 write_to_fh $codeowners->write_to_fh($fh); Format the file contents and write to a filehandle. =head2 write_to_string $scalarref = $codeowners->write_to_string; Format the file contents and return a reference to a formatted string. =head2 write_to_array $lines = $codeowners->write_to_array; Format the file contents as an arrayref of lines. =head2 match $owners = $codeowners->match($filepath); Match the given filepath against the available patterns and return just the owners for the matching pattern. Patterns are checked in the reverse order they were defined in the file. Returns C if no patterns match. =head2 owners $owners = $codeowners->owners; # get all defined owners $owners = $codeowners->owners($pattern); Get an arrayref of owners defined in the file. If a pattern argument is given, only owners for the given pattern are returned (or empty arrayref if the pattern does not exist). If no argument is given, simply returns all owners defined in the file. =head2 patterns $patterns = $codeowners->patterns; $patterns = $codeowners->patterns($owner); Get an arrayref of all patterns defined. =head2 update_owners $codeowners->update_owners($pattern => \@new_owners); Set a new set of owners for a given pattern. If for some reason the file has multiple such patterns, they will all be updated. Nothing happens if the file does not already have at least one such pattern. =head2 append $codeowners->append(comment => $str); $codeowners->append(pattern => $pattern, owners => \@owners); $codeowners->append(); # blank line Append a new line. =head2 prepend $codeowners->prepend(comment => $str); $codeowners->prepend(pattern => $pattern, owners => \@owners); $codeowners->prepend(); # blank line Prepend a new line. =head2 unowned $filepaths = $codeowners->unowned; Get the list of filepaths in the "unowned" section. This parser supports an "extension" to the F file format which lists unowned files at the end of the file. This list can be useful to have in order to figure out what files we know are unowned versus what files we don't know are unowned. =head2 add_unowned $codeowners->add_unowned($filepath, ...); Add one or more filepaths to the "unowned" list. This method does not check to make sure the filepath(s) actually do not match any patterns in the file, so you might want to call L first. See L for an explanation. =head2 remove_unowned $codeowners->remove_unowned($filepath, ...); Remove one or more filepaths from the "unowned" list. Silently ignores filepaths that are already not listed. See L for an explanation. =head2 clear_unowned $codeowners->clear_unowned; Remove all filepaths from the "unowned" list. See L for an explanation. =head1 BUGS Please report any bugs or feature requests on the bugtracker website L When submitting a bug or request, please include a test-file or a patch to an existing test-file that illustrates the bug or desired feature. =head1 AUTHOR Charles McGarvey =head1 COPYRIGHT AND LICENSE This software is copyright (c) 2019 by Charles McGarvey. This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself. =cut