]> Dogcows Code - chaz/git-codeowners/blob - lib/File/Codeowners.pm
ee50d99f14356e5bfd94cea9297ce200cdeaa412
[chaz/git-codeowners] / lib / File / Codeowners.pm
1 package File::Codeowners;
2 # ABSTRACT: Read and write CODEOWNERS files
3
4 use v5.10.1; # defined-or
5 use warnings;
6 use strict;
7
8 use Encode qw(encode);
9 use Path::Tiny 0.089;
10 use Scalar::Util qw(openhandle);
11 use Text::Gitignore qw(build_gitignore_matcher);
12
13 our $VERSION = '0.48'; # VERSION
14
15 sub _croak { require Carp; Carp::croak(@_); }
16 sub _usage { _croak("Usage: @_\n") }
17
18
19 sub new {
20 my $class = shift;
21 my $self = bless {}, $class;
22 }
23
24
25 sub parse {
26 my $self = shift;
27 my $input = shift or _usage(q{$codeowners->parse($input)});
28
29 return $self->parse_from_array($input, @_) if @_;
30 return $self->parse_from_array($input) if ref($input) eq 'ARRAY';
31 return $self->parse_from_string($input) if ref($input) eq 'SCALAR';
32 return $self->parse_from_fh($input) if openhandle($input);
33 return $self->parse_from_filepath($input);
34 }
35
36
37 sub parse_from_filepath {
38 my $self = shift;
39 my $path = shift or _usage(q{$codeowners->parse_from_filepath($filepath)});
40
41 $self = bless({}, $self) if !ref($self);
42
43 return $self->parse_from_fh(path($path)->openr_utf8);
44 }
45
46
47 sub parse_from_fh {
48 my $self = shift;
49 my $fh = shift or _usage(q{$codeowners->parse_from_fh($fh)});
50
51 $self = bless({}, $self) if !ref($self);
52
53 my @lines;
54
55 my $parse_unowned;
56 my %unowned;
57 my $current_project;
58
59 while (my $line = <$fh>) {
60 my $lineno = $. - 1;
61 chomp $line;
62 if ($line eq '### UNOWNED (File::Codeowners)') {
63 $parse_unowned++;
64 last;
65 }
66 elsif ($line =~ /^\h*#(.*)/) {
67 my $comment = $1;
68 my $project;
69 if ($comment =~ /^\h*Project:\h*(.+?)\h*$/i) {
70 $project = $current_project = $1 || undef;
71 }
72 $lines[$lineno] = {
73 comment => $comment,
74 $project ? (project => $project) : (),
75 };
76 }
77 elsif ($line =~ /^\h*$/) {
78 # blank line
79 }
80 elsif ($line =~ /^\h*(.+?)(?<!\\)\h+(.+)/) {
81 my $pattern = $1;
82 my @owners = $2 =~ /( (?:\@+"[^"]*") | (?:\H+) )/gx;
83 $lines[$lineno] = {
84 pattern => $pattern,
85 owners => \@owners,
86 $current_project ? (project => $current_project) : (),
87 };
88 }
89 else {
90 die "Parse error on line $.: $line\n";
91 }
92 }
93
94 if ($parse_unowned) {
95 while (my $line = <$fh>) {
96 chomp $line;
97 if ($line =~ /# (.+)/) {
98 my $filepath = $1;
99 $unowned{$filepath}++;
100 }
101 }
102 }
103
104 $self->{lines} = \@lines;
105 $self->{unowned} = \%unowned;
106
107 return $self;
108 }
109
110
111 sub parse_from_array {
112 my $self = shift;
113 my $arr = shift or _usage(q{$codeowners->parse_from_array(\@lines)});
114
115 $self = bless({}, $self) if !ref($self);
116
117 $arr = [$arr, @_] if @_;
118 my $str = join("\n", @$arr);
119 return $self->parse_from_string(\$str);
120 }
121
122
123 sub parse_from_string {
124 my $self = shift;
125 my $str = shift or _usage(q{$codeowners->parse_from_string(\$string)});
126
127 $self = bless({}, $self) if !ref($self);
128
129 my $ref = ref($str) eq 'SCALAR' ? $str : \$str;
130 open(my $fh, '<:encoding(UTF-8)', $ref) or die "open failed: $!";
131
132 return $self->parse_from_fh($fh);
133 }
134
135
136 sub write_to_filepath {
137 my $self = shift;
138 my $path = shift or _usage(q{$codeowners->write_to_filepath($filepath)});
139
140 path($path)->spew_utf8([map { "$_\n" } @{$self->write_to_array('')}]);
141 }
142
143
144 sub write_to_fh {
145 my $self = shift;
146 my $fh = shift or _usage(q{$codeowners->write_to_fh($fh)});
147
148 for my $line (@{$self->write_to_array}) {
149 print $fh "$line\n";
150 }
151 }
152
153
154 sub write_to_string {
155 my $self = shift;
156
157 my $str = join("\n", @{$self->write_to_array}) . "\n";
158 return \$str;
159 }
160
161
162 sub write_to_array {
163 my $self = shift;
164 my $charset = shift // 'UTF-8';
165
166 my @format;
167
168 for my $line (@{$self->_lines}) {
169 if (my $comment = $line->{comment}) {
170 push @format, "#$comment";
171 }
172 elsif (my $pattern = $line->{pattern}) {
173 my $owners = join(' ', @{$line->{owners}});
174 push @format, "$pattern $owners";
175 }
176 else {
177 push @format, '';
178 }
179 }
180
181 my @unowned = sort keys %{$self->_unowned};
182 if (@unowned) {
183 push @format, '' if $format[-1];
184 push @format, '### UNOWNED (File::Codeowners)';
185 for my $unowned (@unowned) {
186 push @format, "# $unowned";
187 }
188 }
189
190 if ($charset) {
191 $_ = encode($charset, $_) for @format;
192 }
193 return \@format;
194 }
195
196
197 sub match {
198 my $self = shift;
199 my $filepath = shift or _usage(q{$codeowners->match($filepath)});
200
201 my $lines = $self->{match_lines} ||= [reverse grep { ($_ || {})->{pattern} } @{$self->_lines}];
202
203 for my $line (@$lines) {
204 my $matcher = $line->{matcher} ||= build_gitignore_matcher([$line->{pattern}]);
205 return { # deep copy
206 pattern => $line->{pattern},
207 owners => [@{$line->{owners} || []}],
208 $line->{project} ? (project => $line->{project}) : (),
209 } if $matcher->($filepath);
210 }
211
212 return undef; ## no critic (Subroutines::ProhibitExplicitReturn)
213 }
214
215
216 sub owners {
217 my $self = shift;
218 my $pattern = shift;
219
220 return $self->{owners} if !$pattern && $self->{owners};
221
222 my %owners;
223 for my $line (@{$self->_lines}) {
224 next if $pattern && $line->{pattern} && $pattern ne $line->{pattern};
225 $owners{$_}++ for (@{$line->{owners} || []});
226 }
227
228 my $owners = [sort keys %owners];
229 $self->{owners} = $owners if !$pattern;
230
231 return $owners;
232 }
233
234
235 sub patterns {
236 my $self = shift;
237 my $owner = shift;
238
239 return $self->{patterns} if !$owner && $self->{patterns};
240
241 my %patterns;
242 for my $line (@{$self->_lines}) {
243 next if $owner && !grep { $_ eq $owner } @{$line->{owners} || []};
244 my $pattern = $line->{pattern};
245 $patterns{$pattern}++ if $pattern;
246 }
247
248 my $patterns = [sort keys %patterns];
249 $self->{patterns} = $patterns if !$owner;
250
251 return $patterns;
252 }
253
254
255 sub projects {
256 my $self = shift;
257
258 return $self->{projects} if $self->{projects};
259
260 my %projects;
261 for my $line (@{$self->_lines}) {
262 my $project = $line->{project};
263 $projects{$project}++ if $project;
264 }
265
266 my $projects = [sort keys %projects];
267 $self->{projects} = $projects;
268
269 return $projects;
270 }
271
272
273 sub update_owners {
274 my $self = shift;
275 my $pattern = shift;
276 my $owners = shift;
277 $pattern && $owners or _usage(q{$codeowners->update_owners($pattern => \@owners)});
278
279 $owners = [$owners] if ref($owners) ne 'ARRAY';
280
281 $self->_clear;
282
283 my $count = 0;
284
285 for my $line (@{$self->_lines}) {
286 next if !$line->{pattern};
287 next if $pattern ne $line->{pattern};
288 $line->{owners} = [@$owners];
289 ++$count;
290 }
291
292 return $count;
293 }
294
295
296 sub update_owners_by_project {
297 my $self = shift;
298 my $project = shift;
299 my $owners = shift;
300 $project && $owners or _usage(q{$codeowners->update_owners_by_project($project => \@owners)});
301
302 $owners = [$owners] if ref($owners) ne 'ARRAY';
303
304 $self->_clear;
305
306 my $count = 0;
307
308 for my $line (@{$self->_lines}) {
309 next if !$line->{project} || !$line->{owners};
310 next if $project ne $line->{project};
311 $line->{owners} = [@$owners];
312 ++$count;
313 }
314
315 return $count;
316 }
317
318
319 sub rename_project {
320 my $self = shift;
321 my $old_project = shift;
322 my $new_project = shift;
323 $old_project && $new_project or _usage(q{$codeowners->rename_project($project => $new_project)});
324
325 $self->_clear;
326
327 my $count = 0;
328
329 for my $line (@{$self->_lines}) {
330 next if !exists $line->{project} || $old_project ne $line->{project};
331 $line->{project} = $new_project;
332 $line->{comment} = " Project: $new_project" if exists $line->{comment};
333 ++$count;
334 }
335
336 return $count;
337 }
338
339
340 sub append {
341 my $self = shift;
342 $self->_clear;
343 push @{$self->_lines}, (@_ ? {@_} : undef);
344 }
345
346
347 sub prepend {
348 my $self = shift;
349 $self->_clear;
350 unshift @{$self->_lines}, (@_ ? {@_} : undef);
351 }
352
353
354 sub unowned {
355 my $self = shift;
356 [sort keys %{$self->{unowned} || {}}];
357 }
358
359
360 sub add_unowned {
361 my $self = shift;
362 $self->_unowned->{$_}++ for @_;
363 }
364
365
366 sub remove_unowned {
367 my $self = shift;
368 delete $self->_unowned->{$_} for @_;
369 }
370
371 sub is_unowned {
372 my $self = shift;
373 my $filepath = shift;
374 $self->_unowned->{$filepath};
375 }
376
377
378 sub clear_unowned {
379 my $self = shift;
380 $self->{unowned} = {};
381 }
382
383 sub _lines { shift->{lines} ||= [] }
384 sub _unowned { shift->{unowned} ||= {} }
385
386 sub _clear {
387 my $self = shift;
388 delete $self->{match_lines};
389 delete $self->{owners};
390 delete $self->{patterns};
391 delete $self->{projects};
392 }
393
394 1;
395
396 __END__
397
398 =pod
399
400 =encoding UTF-8
401
402 =head1 NAME
403
404 File::Codeowners - Read and write CODEOWNERS files
405
406 =head1 VERSION
407
408 version 0.48
409
410 =head1 METHODS
411
412 =head2 new
413
414 $codeowners = File::Codeowners->new;
415
416 Construct a new L<File::Codeowners>.
417
418 =head2 parse
419
420 $codeowners = File::Codeowners->parse('path/to/CODEOWNERS');
421 $codeowners = File::Codeowners->parse($filehandle);
422 $codeowners = File::Codeowners->parse(\@lines);
423 $codeowners = File::Codeowners->parse(\$string);
424
425 Parse a F<CODEOWNERS> file.
426
427 This is a shortcut for the C<parse_from_*> methods.
428
429 =head2 parse_from_filepath
430
431 $codeowners = File::Codeowners->parse_from_filepath('path/to/CODEOWNERS');
432
433 Parse a F<CODEOWNERS> file from the filesystem.
434
435 =head2 parse_from_fh
436
437 $codeowners = File::Codeowners->parse_from_fh($filehandle);
438
439 Parse a F<CODEOWNERS> file from an open filehandle.
440
441 =head2 parse_from_array
442
443 $codeowners = File::Codeowners->parse_from_array(\@lines);
444
445 Parse a F<CODEOWNERS> file stored as lines in an array.
446
447 =head2 parse_from_string
448
449 $codeowners = File::Codeowners->parse_from_string(\$string);
450 $codeowners = File::Codeowners->parse_from_string($string);
451
452 Parse a F<CODEOWNERS> file stored as a string. String should be UTF-8 encoded.
453
454 =head2 write_to_filepath
455
456 $codeowners->write_to_filepath($filepath);
457
458 Write the contents of the file to the filesystem atomically.
459
460 =head2 write_to_fh
461
462 $codeowners->write_to_fh($fh);
463
464 Format the file contents and write to a filehandle.
465
466 =head2 write_to_string
467
468 $scalarref = $codeowners->write_to_string;
469
470 Format the file contents and return a reference to a formatted string.
471
472 =head2 write_to_array
473
474 $lines = $codeowners->write_to_array;
475
476 Format the file contents as an arrayref of lines.
477
478 =head2 match
479
480 $owners = $codeowners->match($filepath);
481
482 Match the given filepath against the available patterns and return just the
483 owners for the matching pattern. Patterns are checked in the reverse order
484 they were defined in the file.
485
486 Returns C<undef> if no patterns match.
487
488 =head2 owners
489
490 $owners = $codeowners->owners; # get all defined owners
491 $owners = $codeowners->owners($pattern);
492
493 Get an arrayref of owners defined in the file. If a pattern argument is given,
494 only owners for the given pattern are returned (or empty arrayref if the
495 pattern does not exist). If no argument is given, simply returns all owners
496 defined in the file.
497
498 =head2 patterns
499
500 $patterns = $codeowners->patterns;
501 $patterns = $codeowners->patterns($owner);
502
503 Get an arrayref of all patterns defined.
504
505 =head2 projects
506
507 $projects = $codeowners->projects;
508
509 Get an arrayref of all projects defined.
510
511 =head2 update_owners
512
513 $codeowners->update_owners($pattern => \@new_owners);
514
515 Set a new set of owners for a given pattern. If for some reason the file has
516 multiple such patterns, they will all be updated.
517
518 Nothing happens if the file does not already have at least one such pattern.
519
520 =head2 update_owners_by_project
521
522 $codeowners->update_owners_by_project($project => \@new_owners);
523
524 Set a new set of owners for all patterns under the given project.
525
526 Nothing happens if the file does not have a project with the given name.
527
528 =head2 rename_project
529
530 $codeowners->rename_project($old_name => $new_name);
531
532 Rename a project.
533
534 Nothing happens if the file does not have a project with the old name.
535
536 =head2 append
537
538 $codeowners->append(comment => $str);
539 $codeowners->append(pattern => $pattern, owners => \@owners);
540 $codeowners->append(); # blank line
541
542 Append a new line.
543
544 =head2 prepend
545
546 $codeowners->prepend(comment => $str);
547 $codeowners->prepend(pattern => $pattern, owners => \@owners);
548 $codeowners->prepend(); # blank line
549
550 Prepend a new line.
551
552 =head2 unowned
553
554 $filepaths = $codeowners->unowned;
555
556 Get the list of filepaths in the "unowned" section.
557
558 This parser supports an "extension" to the F<CODEOWNERS> file format which
559 lists unowned files at the end of the file. This list can be useful to have in
560 order to figure out what files we know are unowned versus what files we don't
561 know are unowned.
562
563 =head2 add_unowned
564
565 $codeowners->add_unowned($filepath, ...);
566
567 Add one or more filepaths to the "unowned" list.
568
569 This method does not check to make sure the filepath(s) actually do not match
570 any patterns in the file, so you might want to call L</match> first.
571
572 See L</unowned> for an explanation.
573
574 =head2 remove_unowned
575
576 $codeowners->remove_unowned($filepath, ...);
577
578 Remove one or more filepaths from the "unowned" list.
579
580 Silently ignores filepaths that are already not listed.
581
582 See L</unowned> for an explanation.
583
584 =head2 clear_unowned
585
586 $codeowners->clear_unowned;
587
588 Remove all filepaths from the "unowned" list.
589
590 See L</unowned> for an explanation.
591
592 =head1 BUGS
593
594 Please report any bugs or feature requests on the bugtracker website
595 L<https://github.com/chazmcgarvey/git-codeowners/issues>
596
597 When submitting a bug or request, please include a test-file or a
598 patch to an existing test-file that illustrates the bug or desired
599 feature.
600
601 =head1 AUTHOR
602
603 Charles McGarvey <chazmcgarvey@brokenzipper.com>
604
605 =head1 COPYRIGHT AND LICENSE
606
607 This software is copyright (c) 2019 by Charles McGarvey.
608
609 This is free software; you can redistribute it and/or modify it under
610 the same terms as the Perl 5 programming language system itself.
611
612 =cut
This page took 0.062746 seconds and 3 git commands to generate.