]> Dogcows Code - chaz/git-codeowners/blob - lib/File/Codeowners.pm
fb3ec552c889bd6431be1ac7dd9d3c0c000f3fa3
[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 update_owners
359
360 $codeowners->update_owners($pattern => \@new_owners);
361
362 Set a new set of owners for a given pattern. If for some reason the file has
363 multiple such patterns, they will all be updated.
364
365 Nothing happens if the file does not already have at least one such pattern.
366
367 =cut
368
369 sub update_owners {
370 my $self = shift;
371 my $pattern = shift;
372 my $owners = shift;
373 $pattern && $owners or _usage(q{$codeowners->update_owners($pattern => \@owners)});
374
375 $owners = [$owners] if ref($owners) ne 'ARRAY';
376
377 $self->_clear;
378
379 for my $line (@{$self->_lines}) {
380 next if !$line->{pattern};
381 next if $pattern ne $line->{pattern};
382 $line->{owners} = [@$owners];
383 }
384 }
385
386 =method append
387
388 $codeowners->append(comment => $str);
389 $codeowners->append(pattern => $pattern, owners => \@owners);
390 $codeowners->append(); # blank line
391
392 Append a new line.
393
394 =cut
395
396 sub append {
397 my $self = shift;
398 $self->_clear;
399 push @{$self->_lines}, (@_ ? {@_} : undef);
400 }
401
402 =method prepend
403
404 $codeowners->prepend(comment => $str);
405 $codeowners->prepend(pattern => $pattern, owners => \@owners);
406 $codeowners->prepend(); # blank line
407
408 Prepend a new line.
409
410 =cut
411
412 sub prepend {
413 my $self = shift;
414 $self->_clear;
415 unshift @{$self->_lines}, (@_ ? {@_} : undef);
416 }
417
418 =method unowned
419
420 $filepaths = $codeowners->unowned;
421
422 Get the list of filepaths in the "unowned" section.
423
424 This parser supports an "extension" to the F<CODEOWNERS> file format which
425 lists unowned files at the end of the file. This list can be useful to have in
426 order to figure out what files we know are unowned versus what files we don't
427 know are unowned.
428
429 =cut
430
431 sub unowned {
432 my $self = shift;
433 [sort keys %{$self->{unowned} || {}}];
434 }
435
436 =method add_unowned
437
438 $codeowners->add_unowned($filepath, ...);
439
440 Add one or more filepaths to the "unowned" list.
441
442 This method does not check to make sure the filepath(s) actually do not match
443 any patterns in the file, so you might want to call L</match> first.
444
445 See L</unowned> for an explanation.
446
447 =cut
448
449 sub add_unowned {
450 my $self = shift;
451 $self->_unowned->{$_}++ for @_;
452 }
453
454 =method remove_unowned
455
456 $codeowners->remove_unowned($filepath, ...);
457
458 Remove one or more filepaths from the "unowned" list.
459
460 Silently ignores filepaths that are already not listed.
461
462 See L</unowned> for an explanation.
463
464 =cut
465
466 sub remove_unowned {
467 my $self = shift;
468 delete $self->_unowned->{$_} for @_;
469 }
470
471 sub is_unowned {
472 my $self = shift;
473 my $filepath = shift;
474 $self->_unowned->{$filepath};
475 }
476
477 =method clear_unowned
478
479 $codeowners->clear_unowned;
480
481 Remove all filepaths from the "unowned" list.
482
483 See L</unowned> for an explanation.
484
485 =cut
486
487 sub clear_unowned {
488 my $self = shift;
489 $self->{unowned} = {};
490 }
491
492 sub _lines { shift->{lines} ||= [] }
493 sub _unowned { shift->{unowned} ||= {} }
494
495 sub _clear {
496 my $self = shift;
497 delete $self->{match_lines};
498 delete $self->{owners};
499 delete $self->{patterns};
500 }
501
502 1;
This page took 0.05578 seconds and 3 git commands to generate.