]> Dogcows Code - chaz/git-codeowners/blob - lib/File/Codeowners.pm
Version 0.49
[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.49'; # 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 my $charset = shift;
148
149 for my $line (@{$self->write_to_array($charset)}) {
150 print $fh "$line\n";
151 }
152 }
153
154
155 sub write_to_string {
156 my $self = shift;
157 my $charset = shift;
158
159 my $str = join("\n", @{$self->write_to_array($charset)}) . "\n";
160 return \$str;
161 }
162
163
164 sub write_to_array {
165 my $self = shift;
166 my $charset = shift;
167
168 my @format;
169
170 for my $line (@{$self->_lines}) {
171 if (my $comment = $line->{comment}) {
172 push @format, "#$comment";
173 }
174 elsif (my $pattern = $line->{pattern}) {
175 my $owners = join(' ', @{$line->{owners}});
176 push @format, "$pattern $owners";
177 }
178 else {
179 push @format, '';
180 }
181 }
182
183 my @unowned = sort keys %{$self->_unowned};
184 if (@unowned) {
185 push @format, '' if $format[-1];
186 push @format, '### UNOWNED (File::Codeowners)';
187 for my $unowned (@unowned) {
188 push @format, "# $unowned";
189 }
190 }
191
192 if (defined $charset) {
193 $_ = encode($charset, $_) for @format;
194 }
195 return \@format;
196 }
197
198
199 sub match {
200 my $self = shift;
201 my $filepath = shift or _usage(q{$codeowners->match($filepath)});
202
203 my $lines = $self->{match_lines} ||= [reverse grep { ($_ || {})->{pattern} } @{$self->_lines}];
204
205 for my $line (@$lines) {
206 my $matcher = $line->{matcher} ||= build_gitignore_matcher([$line->{pattern}]);
207 return { # deep copy
208 pattern => $line->{pattern},
209 owners => [@{$line->{owners} || []}],
210 $line->{project} ? (project => $line->{project}) : (),
211 } if $matcher->($filepath);
212 }
213
214 return undef; ## no critic (Subroutines::ProhibitExplicitReturn)
215 }
216
217
218 sub owners {
219 my $self = shift;
220 my $pattern = shift;
221
222 return $self->{owners} if !$pattern && $self->{owners};
223
224 my %owners;
225 for my $line (@{$self->_lines}) {
226 next if $pattern && $line->{pattern} && $pattern ne $line->{pattern};
227 $owners{$_}++ for (@{$line->{owners} || []});
228 }
229
230 my $owners = [sort keys %owners];
231 $self->{owners} = $owners if !$pattern;
232
233 return $owners;
234 }
235
236
237 sub patterns {
238 my $self = shift;
239 my $owner = shift;
240
241 return $self->{patterns} if !$owner && $self->{patterns};
242
243 my %patterns;
244 for my $line (@{$self->_lines}) {
245 next if $owner && !grep { $_ eq $owner } @{$line->{owners} || []};
246 my $pattern = $line->{pattern};
247 $patterns{$pattern}++ if $pattern;
248 }
249
250 my $patterns = [sort keys %patterns];
251 $self->{patterns} = $patterns if !$owner;
252
253 return $patterns;
254 }
255
256
257 sub projects {
258 my $self = shift;
259
260 return $self->{projects} if $self->{projects};
261
262 my %projects;
263 for my $line (@{$self->_lines}) {
264 my $project = $line->{project};
265 $projects{$project}++ if $project;
266 }
267
268 my $projects = [sort keys %projects];
269 $self->{projects} = $projects;
270
271 return $projects;
272 }
273
274
275 sub update_owners {
276 my $self = shift;
277 my $pattern = shift;
278 my $owners = shift;
279 $pattern && $owners or _usage(q{$codeowners->update_owners($pattern => \@owners)});
280
281 $owners = [$owners] if ref($owners) ne 'ARRAY';
282
283 $self->_clear;
284
285 my $count = 0;
286
287 for my $line (@{$self->_lines}) {
288 next if !$line->{pattern};
289 next if $pattern ne $line->{pattern};
290 $line->{owners} = [@$owners];
291 ++$count;
292 }
293
294 return $count;
295 }
296
297
298 sub update_owners_by_project {
299 my $self = shift;
300 my $project = shift;
301 my $owners = shift;
302 $project && $owners or _usage(q{$codeowners->update_owners_by_project($project => \@owners)});
303
304 $owners = [$owners] if ref($owners) ne 'ARRAY';
305
306 $self->_clear;
307
308 my $count = 0;
309
310 for my $line (@{$self->_lines}) {
311 next if !$line->{project} || !$line->{owners};
312 next if $project ne $line->{project};
313 $line->{owners} = [@$owners];
314 ++$count;
315 }
316
317 return $count;
318 }
319
320
321 sub rename_owner {
322 my $self = shift;
323 my $old_owner = shift;
324 my $new_owner = shift;
325 $old_owner && $new_owner or _usage(q{$codeowners->rename_owner($owner => $new_owner)});
326
327 $self->_clear;
328
329 my $count = 0;
330
331 for my $line (@{$self->_lines}) {
332 next if !exists $line->{owners};
333 for (my $i = 0; $i < @{$line->{owners}}; ++$i) {
334 next if $line->{owners}[$i] ne $old_owner;
335 $line->{owners}[$i] = $new_owner;
336 ++$count;
337 }
338 }
339
340 return $count;
341 }
342
343
344 sub rename_project {
345 my $self = shift;
346 my $old_project = shift;
347 my $new_project = shift;
348 $old_project && $new_project or _usage(q{$codeowners->rename_project($project => $new_project)});
349
350 $self->_clear;
351
352 my $count = 0;
353
354 for my $line (@{$self->_lines}) {
355 next if !exists $line->{project} || $old_project ne $line->{project};
356 $line->{project} = $new_project;
357 $line->{comment} = " Project: $new_project" if exists $line->{comment};
358 ++$count;
359 }
360
361 return $count;
362 }
363
364
365 sub append {
366 my $self = shift;
367 $self->_clear;
368 push @{$self->_lines}, (@_ ? {@_} : undef);
369 }
370
371
372 sub prepend {
373 my $self = shift;
374 $self->_clear;
375 unshift @{$self->_lines}, (@_ ? {@_} : undef);
376 }
377
378
379 sub unowned {
380 my $self = shift;
381 [sort keys %{$self->{unowned} || {}}];
382 }
383
384
385 sub add_unowned {
386 my $self = shift;
387 $self->_unowned->{$_}++ for @_;
388 }
389
390
391 sub remove_unowned {
392 my $self = shift;
393 delete $self->_unowned->{$_} for @_;
394 }
395
396 sub is_unowned {
397 my $self = shift;
398 my $filepath = shift;
399 $self->_unowned->{$filepath};
400 }
401
402
403 sub clear_unowned {
404 my $self = shift;
405 $self->{unowned} = {};
406 }
407
408 sub _lines { shift->{lines} ||= [] }
409 sub _unowned { shift->{unowned} ||= {} }
410
411 sub _clear {
412 my $self = shift;
413 delete $self->{match_lines};
414 delete $self->{owners};
415 delete $self->{patterns};
416 delete $self->{projects};
417 }
418
419 1;
420
421 __END__
422
423 =pod
424
425 =encoding UTF-8
426
427 =head1 NAME
428
429 File::Codeowners - Read and write CODEOWNERS files
430
431 =head1 VERSION
432
433 version 0.49
434
435 =head1 METHODS
436
437 =head2 new
438
439 $codeowners = File::Codeowners->new;
440
441 Construct a new L<File::Codeowners>.
442
443 =head2 parse
444
445 $codeowners = File::Codeowners->parse('path/to/CODEOWNERS');
446 $codeowners = File::Codeowners->parse($filehandle);
447 $codeowners = File::Codeowners->parse(\@lines);
448 $codeowners = File::Codeowners->parse(\$string);
449
450 Parse a F<CODEOWNERS> file.
451
452 This is a shortcut for the C<parse_from_*> methods.
453
454 =head2 parse_from_filepath
455
456 $codeowners = File::Codeowners->parse_from_filepath('path/to/CODEOWNERS');
457
458 Parse a F<CODEOWNERS> file from the filesystem.
459
460 =head2 parse_from_fh
461
462 $codeowners = File::Codeowners->parse_from_fh($filehandle);
463
464 Parse a F<CODEOWNERS> file from an open filehandle.
465
466 =head2 parse_from_array
467
468 $codeowners = File::Codeowners->parse_from_array(\@lines);
469
470 Parse a F<CODEOWNERS> file stored as lines in an array.
471
472 =head2 parse_from_string
473
474 $codeowners = File::Codeowners->parse_from_string(\$string);
475 $codeowners = File::Codeowners->parse_from_string($string);
476
477 Parse a F<CODEOWNERS> file stored as a string. String should be UTF-8 encoded.
478
479 =head2 write_to_filepath
480
481 $codeowners->write_to_filepath($filepath);
482
483 Write the contents of the file to the filesystem atomically.
484
485 =head2 write_to_fh
486
487 $codeowners->write_to_fh($fh);
488
489 Format the file contents and write to a filehandle.
490
491 =head2 write_to_string
492
493 $scalarref = $codeowners->write_to_string;
494
495 Format the file contents and return a reference to a formatted string.
496
497 =head2 write_to_array
498
499 $lines = $codeowners->write_to_array;
500
501 Format the file contents as an arrayref of lines.
502
503 =head2 match
504
505 $owners = $codeowners->match($filepath);
506
507 Match the given filepath against the available patterns and return just the
508 owners for the matching pattern. Patterns are checked in the reverse order
509 they were defined in the file.
510
511 Returns C<undef> if no patterns match.
512
513 =head2 owners
514
515 $owners = $codeowners->owners; # get all defined owners
516 $owners = $codeowners->owners($pattern);
517
518 Get an arrayref of owners defined in the file. If a pattern argument is given,
519 only owners for the given pattern are returned (or empty arrayref if the
520 pattern does not exist). If no argument is given, simply returns all owners
521 defined in the file.
522
523 =head2 patterns
524
525 $patterns = $codeowners->patterns;
526 $patterns = $codeowners->patterns($owner);
527
528 Get an arrayref of all patterns defined.
529
530 =head2 projects
531
532 $projects = $codeowners->projects;
533
534 Get an arrayref of all projects defined.
535
536 =head2 update_owners
537
538 $codeowners->update_owners($pattern => \@new_owners);
539
540 Set a new set of owners for a given pattern. If for some reason the file has
541 multiple such patterns, they will all be updated.
542
543 Nothing happens if the file does not already have at least one such pattern.
544
545 =head2 update_owners_by_project
546
547 $codeowners->update_owners_by_project($project => \@new_owners);
548
549 Set a new set of owners for all patterns under the given project.
550
551 Nothing happens if the file does not have a project with the given name.
552
553 =head2 rename_owner
554
555 $codeowners->rename_owner($old_name => $new_name);
556
557 Rename an owner.
558
559 Nothing happens if the file does not have an owner with the old name.
560
561 =head2 rename_project
562
563 $codeowners->rename_project($old_name => $new_name);
564
565 Rename a project.
566
567 Nothing happens if the file does not have a project with the old name.
568
569 =head2 append
570
571 $codeowners->append(comment => $str);
572 $codeowners->append(pattern => $pattern, owners => \@owners);
573 $codeowners->append(); # blank line
574
575 Append a new line.
576
577 =head2 prepend
578
579 $codeowners->prepend(comment => $str);
580 $codeowners->prepend(pattern => $pattern, owners => \@owners);
581 $codeowners->prepend(); # blank line
582
583 Prepend a new line.
584
585 =head2 unowned
586
587 $filepaths = $codeowners->unowned;
588
589 Get the list of filepaths in the "unowned" section.
590
591 This parser supports an "extension" to the F<CODEOWNERS> file format which
592 lists unowned files at the end of the file. This list can be useful to have in
593 order to figure out what files we know are unowned versus what files we don't
594 know are unowned.
595
596 =head2 add_unowned
597
598 $codeowners->add_unowned($filepath, ...);
599
600 Add one or more filepaths to the "unowned" list.
601
602 This method does not check to make sure the filepath(s) actually do not match
603 any patterns in the file, so you might want to call L</match> first.
604
605 See L</unowned> for an explanation.
606
607 =head2 remove_unowned
608
609 $codeowners->remove_unowned($filepath, ...);
610
611 Remove one or more filepaths from the "unowned" list.
612
613 Silently ignores filepaths that are already not listed.
614
615 See L</unowned> for an explanation.
616
617 =head2 clear_unowned
618
619 $codeowners->clear_unowned;
620
621 Remove all filepaths from the "unowned" list.
622
623 See L</unowned> for an explanation.
624
625 =head1 BUGS
626
627 Please report any bugs or feature requests on the bugtracker website
628 L<https://github.com/chazmcgarvey/git-codeowners/issues>
629
630 When submitting a bug or request, please include a test-file or a
631 patch to an existing test-file that illustrates the bug or desired
632 feature.
633
634 =head1 AUTHOR
635
636 Charles McGarvey <chazmcgarvey@brokenzipper.com>
637
638 =head1 COPYRIGHT AND LICENSE
639
640 This software is copyright (c) 2019 by Charles McGarvey.
641
642 This is free software; you can redistribute it and/or modify it under
643 the same terms as the Perl 5 programming language system itself.
644
645 =cut
This page took 0.064604 seconds and 4 git commands to generate.