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