]> Dogcows Code - chaz/git-codeowners/blob - lib/File/Codeowners.pm
648390e1ea45888ab1ffd60f904a66b9b9db0b84
[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;
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 if ($comment =~ /^\h*Project:\h*(.+?)\h*$/i) {
102 $current_project = $1 || undef;
103 }
104 $lines[$lineno] = {
105 comment => $comment,
106 };
107 }
108 elsif ($line =~ /^\h*$/) {
109 # blank line
110 }
111 elsif ($line =~ /^\h*(.+?)(?<!\\)\h+(.+)/) {
112 my $pattern = $1;
113 my @owners = $2 =~ /( (?:\@+"[^"]*") | (?:\H+) )/gx;
114 $lines[$lineno] = {
115 pattern => $pattern,
116 owners => \@owners,
117 $current_project ? (project => $current_project) : (),
118 };
119 }
120 else {
121 die "Parse error on line $.: $line\n";
122 }
123 }
124
125 if ($parse_unowned) {
126 while (my $line = <$fh>) {
127 chomp $line;
128 if ($line =~ /# (.+)/) {
129 my $filepath = $1;
130 $unowned{$filepath}++;
131 }
132 }
133 }
134
135 $self->{lines} = \@lines;
136 $self->{unowned} = \%unowned;
137
138 return $self;
139 }
140
141 =method parse_from_array
142
143 $codeowners = File::Codeowners->parse_from_array(\@lines);
144
145 Parse a F<CODEOWNERS> file stored as lines in an array.
146
147 =cut
148
149 sub parse_from_array {
150 my $self = shift;
151 my $arr = shift or _usage(q{$codeowners->parse_from_array(\@lines)});
152
153 $self = bless({}, $self) if !ref($self);
154
155 $arr = [$arr, @_] if @_;
156 my $str = join("\n", @$arr);
157 return $self->parse_from_string(\$str);
158 }
159
160 =method parse_from_string
161
162 $codeowners = File::Codeowners->parse_from_string(\$string);
163 $codeowners = File::Codeowners->parse_from_string($string);
164
165 Parse a F<CODEOWNERS> file stored as a string. String should be UTF-8 encoded.
166
167 =cut
168
169 sub parse_from_string {
170 my $self = shift;
171 my $str = shift or _usage(q{$codeowners->parse_from_string(\$string)});
172
173 $self = bless({}, $self) if !ref($self);
174
175 my $ref = ref($str) eq 'SCALAR' ? $str : \$str;
176 open(my $fh, '<:encoding(UTF-8)', $ref) or die "open failed: $!";
177
178 return $self->parse_from_fh($fh);
179 }
180
181 =method write_to_filepath
182
183 $codeowners->write_to_filepath($filepath);
184
185 Write the contents of the file to the filesystem atomically.
186
187 =cut
188
189 sub write_to_filepath {
190 my $self = shift;
191 my $path = shift or _usage(q{$codeowners->write_to_filepath($filepath)});
192
193 path($path)->spew_utf8([map { "$_\n" } @{$self->write_to_array('')}]);
194 }
195
196 =method write_to_fh
197
198 $codeowners->write_to_fh($fh);
199
200 Format the file contents and write to a filehandle.
201
202 =cut
203
204 sub write_to_fh {
205 my $self = shift;
206 my $fh = shift or _usage(q{$codeowners->write_to_fh($fh)});
207
208 for my $line (@{$self->write_to_array}) {
209 print $fh "$line\n";
210 }
211 }
212
213 =method write_to_string
214
215 $scalarref = $codeowners->write_to_string;
216
217 Format the file contents and return a reference to a formatted string.
218
219 =cut
220
221 sub write_to_string {
222 my $self = shift;
223
224 my $str = join("\n", @{$self->write_to_array}) . "\n";
225 return \$str;
226 }
227
228 =method write_to_array
229
230 $lines = $codeowners->write_to_array;
231
232 Format the file contents as an arrayref of lines.
233
234 =cut
235
236 sub write_to_array {
237 my $self = shift;
238 my $charset = shift // 'UTF-8';
239
240 my @format;
241
242 for my $line (@{$self->_lines}) {
243 if (my $comment = $line->{comment}) {
244 push @format, "#$comment";
245 }
246 elsif (my $pattern = $line->{pattern}) {
247 my $owners = join(' ', @{$line->{owners}});
248 push @format, "$pattern $owners";
249 }
250 else {
251 push @format, '';
252 }
253 }
254
255 my @unowned = sort keys %{$self->_unowned};
256 if (@unowned) {
257 push @format, '' if $format[-1];
258 push @format, '### UNOWNED (File::Codeowners)';
259 for my $unowned (@unowned) {
260 push @format, "# $unowned";
261 }
262 }
263
264 if ($charset) {
265 $_ = encode($charset, $_) for @format;
266 }
267 return \@format;
268 }
269
270 =method match
271
272 $owners = $codeowners->match($filepath);
273
274 Match the given filepath against the available patterns and return just the
275 owners for the matching pattern. Patterns are checked in the reverse order
276 they were defined in the file.
277
278 Returns C<undef> if no patterns match.
279
280 =cut
281
282 sub match {
283 my $self = shift;
284 my $filepath = shift or _usage(q{$codeowners->match($filepath)});
285
286 my $lines = $self->{match_lines} ||= [reverse grep { ($_ || {})->{pattern} } @{$self->_lines}];
287
288 for my $line (@$lines) {
289 my $matcher = $line->{matcher} ||= build_gitignore_matcher([$line->{pattern}]);
290 return { # deep copy
291 pattern => $line->{pattern},
292 owners => [@{$line->{owners} || []}],
293 $line->{project} ? (project => $line->{project}) : (),
294 } if $matcher->($filepath);
295 }
296
297 return undef; ## no critic (Subroutines::ProhibitExplicitReturn)
298 }
299
300 =method owners
301
302 $owners = $codeowners->owners; # get all defined owners
303 $owners = $codeowners->owners($pattern);
304
305 Get an arrayref of owners defined in the file. If a pattern argument is given,
306 only owners for the given pattern are returned (or empty arrayref if the
307 pattern does not exist). If no argument is given, simply returns all owners
308 defined in the file.
309
310 =cut
311
312 sub owners {
313 my $self = shift;
314 my $pattern = shift;
315
316 return $self->{owners} if !$pattern && $self->{owners};
317
318 my %owners;
319 for my $line (@{$self->_lines}) {
320 next if $pattern && $line->{pattern} && $pattern ne $line->{pattern};
321 $owners{$_}++ for (@{$line->{owners} || []});
322 }
323
324 my $owners = [sort keys %owners];
325 $self->{owners} = $owners if !$pattern;
326
327 return $owners;
328 }
329
330 =method patterns
331
332 $patterns = $codeowners->patterns;
333 $patterns = $codeowners->patterns($owner);
334
335 Get an arrayref of all patterns defined.
336
337 =cut
338
339 sub patterns {
340 my $self = shift;
341 my $owner = shift;
342
343 return $self->{patterns} if !$owner && $self->{patterns};
344
345 my %patterns;
346 for my $line (@{$self->_lines}) {
347 next if $owner && !grep { $_ eq $owner } @{$line->{owners} || []};
348 my $pattern = $line->{pattern};
349 $patterns{$pattern}++ if $pattern;
350 }
351
352 my $patterns = [sort keys %patterns];
353 $self->{patterns} = $patterns if !$owner;
354
355 return $patterns;
356 }
357
358 =method projects
359
360 $projects = $codeowners->projects;
361
362 Get an arrayref of all projects defined.
363
364 =cut
365
366 sub projects {
367 my $self = shift;
368
369 return $self->{projects} if $self->{projects};
370
371 my %projects;
372 for my $line (@{$self->_lines}) {
373 my $project = $line->{project};
374 $projects{$project}++ if $project;
375 }
376
377 my $projects = [sort keys %projects];
378 $self->{projects} = $projects;
379
380 return $projects;
381 }
382
383 =method update_owners
384
385 $codeowners->update_owners($pattern => \@new_owners);
386
387 Set a new set of owners for a given pattern. If for some reason the file has
388 multiple such patterns, they will all be updated.
389
390 Nothing happens if the file does not already have at least one such pattern.
391
392 =cut
393
394 sub update_owners {
395 my $self = shift;
396 my $pattern = shift;
397 my $owners = shift;
398 $pattern && $owners or _usage(q{$codeowners->update_owners($pattern => \@owners)});
399
400 $owners = [$owners] if ref($owners) ne 'ARRAY';
401
402 $self->_clear;
403
404 for my $line (@{$self->_lines}) {
405 next if !$line->{pattern};
406 next if $pattern ne $line->{pattern};
407 $line->{owners} = [@$owners];
408 }
409 }
410
411 =method append
412
413 $codeowners->append(comment => $str);
414 $codeowners->append(pattern => $pattern, owners => \@owners);
415 $codeowners->append(); # blank line
416
417 Append a new line.
418
419 =cut
420
421 sub append {
422 my $self = shift;
423 $self->_clear;
424 push @{$self->_lines}, (@_ ? {@_} : undef);
425 }
426
427 =method prepend
428
429 $codeowners->prepend(comment => $str);
430 $codeowners->prepend(pattern => $pattern, owners => \@owners);
431 $codeowners->prepend(); # blank line
432
433 Prepend a new line.
434
435 =cut
436
437 sub prepend {
438 my $self = shift;
439 $self->_clear;
440 unshift @{$self->_lines}, (@_ ? {@_} : undef);
441 }
442
443 =method unowned
444
445 $filepaths = $codeowners->unowned;
446
447 Get the list of filepaths in the "unowned" section.
448
449 This parser supports an "extension" to the F<CODEOWNERS> file format which
450 lists unowned files at the end of the file. This list can be useful to have in
451 order to figure out what files we know are unowned versus what files we don't
452 know are unowned.
453
454 =cut
455
456 sub unowned {
457 my $self = shift;
458 [sort keys %{$self->{unowned} || {}}];
459 }
460
461 =method add_unowned
462
463 $codeowners->add_unowned($filepath, ...);
464
465 Add one or more filepaths to the "unowned" list.
466
467 This method does not check to make sure the filepath(s) actually do not match
468 any patterns in the file, so you might want to call L</match> first.
469
470 See L</unowned> for an explanation.
471
472 =cut
473
474 sub add_unowned {
475 my $self = shift;
476 $self->_unowned->{$_}++ for @_;
477 }
478
479 =method remove_unowned
480
481 $codeowners->remove_unowned($filepath, ...);
482
483 Remove one or more filepaths from the "unowned" list.
484
485 Silently ignores filepaths that are already not listed.
486
487 See L</unowned> for an explanation.
488
489 =cut
490
491 sub remove_unowned {
492 my $self = shift;
493 delete $self->_unowned->{$_} for @_;
494 }
495
496 sub is_unowned {
497 my $self = shift;
498 my $filepath = shift;
499 $self->_unowned->{$filepath};
500 }
501
502 =method clear_unowned
503
504 $codeowners->clear_unowned;
505
506 Remove all filepaths from the "unowned" list.
507
508 See L</unowned> for an explanation.
509
510 =cut
511
512 sub clear_unowned {
513 my $self = shift;
514 $self->{unowned} = {};
515 }
516
517 sub _lines { shift->{lines} ||= [] }
518 sub _unowned { shift->{unowned} ||= {} }
519
520 sub _clear {
521 my $self = shift;
522 delete $self->{match_lines};
523 delete $self->{owners};
524 delete $self->{patterns};
525 delete $self->{projects};
526 }
527
528 1;
This page took 0.058925 seconds and 3 git commands to generate.