| File: | blib/lib/App/FileSync/Ignore.pm |
| Coverage: | 93.2% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::FileSync::Ignore; | |||||
| 2 | ||||||
| 3 | # .gitignore-style exclusion patterns, compiled once per session and matched | |||||
| 4 | # against every path in the tree. | |||||
| 5 | ||||||
| 6 | 316 316 316 | 141429 270 4211 | use App::FileSync::Platform; | |||
| 7 | ||||||
| 8 | 316 316 316 | 508 216 2495 | use strict; | |||
| 9 | 316 316 316 | 414 247 5890 | use warnings; | |||
| 10 | 316 316 316 | 836 12181 635 | use autodie qw(:all); | |||
| 11 | ||||||
| 12 | 316 316 316 | 672885 3607 7887 | use Readonly; | |||
| 13 | 316 316 316 | 1102 55616 1066 | use Sub::Private { mode => 'enforce' }; | |||
| 14 | ||||||
| 15 | 316 316 316 | 30324 273 5635 | use App::FileSync::Args qw(named); | |||
| 16 | ||||||
| 17 | 316 316 316 | 535 251 755 | use version; our $VERSION = version->declare('0.001.1'); | |||
| 18 | ||||||
| 19 | # A line that is blank or a comment ("# note", or a lone "#"). "#*#" is a | |||||
| 20 | # pattern (Emacs autosave files), as .gitignore allows with a backslash. | |||||
| 21 | Readonly my $COMMENT => qr/\A\s*(?:#\s|#\z|\z)/; | |||||
| 22 | ||||||
| 23 | =encoding utf8 | |||||
| 24 | ||||||
| 25 - 89 | =head1 NAME
App::FileSync::Ignore - .gitignore-style path exclusion
=head1 SYNOPSIS
my $ignore = App::FileSync::Ignore->new(patterns => [ '*.tmp', '!keep.tmp', 'build/' ]);
print "skip\n" if $ignore->match('src/a.tmp');
=head1 DESCRIPTION
Patterns follow .gitignore: C<*> matches within one component, C<**/>
matches any number of directories, C<?> one character, a leading C<!>
re-includes, a leading C</> anchors at the root, and a pattern without a slash
matches at any depth. Later patterns win. A path inside an ignored directory
is ignored.
=head1 METHODS
=head2 new
Purpose: compile a list of patterns.
Args: C<patterns> (arrayref of strings; undefined entries, blanks and comments
are skipped), C<case_insensitive> (boolean).
Returns: an C<App::FileSync::Ignore> object.
Side Effects: none. The caller's list is not modified.
Usage:
my $ignore = App::FileSync::Ignore->new(patterns => $config->{ignore});
=head3 EXAMPLE
my $ignore = App::FileSync::Ignore->new(
patterns => [ '*.bak', '# editor files', '*~' ],
case_insensitive => 1,
);
=head3 API SPECIFICATION
=head4 INPUT
{
patterns => { type => 'arrayref', optional => 1 },
case_insensitive => { type => 'boolean', optional => 1 },
}
=head4 OUTPUT
{ type => 'object', isa => 'App::FileSync::Ignore' }
=head3 MESSAGES
Key Meaning Resolution
args.invalid patterns is not a list pass an arrayref
=head3 FORMAL SPECIFICATION
new(ps, ci) = â¨compiled ⦠â¨(neg(p), re(p, ci)) | p â ps, ¬ comment(p)â©â©
ps' = ps
=cut | |||||
| 90 | ||||||
| 91 | sub new { | |||||
| 92 | 582 | 252804 | my $class = shift; | |||
| 93 | 582 | 3768 | my $args = named(\@_, { | |||
| 94 | patterns => { type => 'arrayref', optional => 1 }, | |||||
| 95 | case_insensitive => { type => 'boolean', optional => 1 }, | |||||
| 96 | }, 'Ignore->new'); | |||||
| 97 | ||||||
| 98 | 582 | 1056 | my @compiled; | |||
| 99 | 582 582 | 599 1343 | for my $given (@{ $args->{patterns} || [] }) { | |||
| 100 | 7482 | 6291 | next unless defined $given; | |||
| 101 | 7482 | 8861 | next if $given =~ $COMMENT; | |||
| 102 | ||||||
| 103 | # Work on a copy: the list usually belongs to the repo config, which | |||||
| 104 | # the server sends on to the client after building this object. | |||||
| 105 | 7480 | 22050 | my $raw = $given; | |||
| 106 | 7480 | 6280 | my $negated = $raw =~ s/\A!// ? 1 : 0; | |||
| 107 | 7480 | 4876 | $raw =~ s/\A\s+//; | |||
| 108 | 7480 | 5207 | $raw =~ s/\s+\z//; | |||
| 109 | 7480 | 5513 | next unless length $raw; | |||
| 110 | ||||||
| 111 | 7480 | 6469 | my $re = _glob_to_re($raw); | |||
| 112 | push @compiled, { | |||||
| 113 | negated => $negated, | |||||
| 114 | 7480 | 59982 | re => $args->{case_insensitive} ? qr/\A$re\z/i : qr/\A$re\z/, | |||
| 115 | }; | |||||
| 116 | } | |||||
| 117 | ||||||
| 118 | 582 | 4094 | return bless { compiled => \@compiled }, $class; | |||
| 119 | } | |||||
| 120 | ||||||
| 121 - 171 | =head2 match
Purpose: decide whether a path is excluded.
Args: C<$path>, relative and C</>-separated.
Returns: 1 when the path, or any directory above it, is ignored; otherwise 0.
Side Effects: none.
Usage:
next if $ignore->match($relative_path);
=head3 EXAMPLE
my $ignore = App::FileSync::Ignore->new(patterns => [ 'build' ]);
print $ignore->match('build/out/a.o'), "\n"; # 1
=head3 API SPECIFICATION
=head4 INPUT
[
{ name => 'self', type => 'object' },
{ name => 'path', type => 'string', optional => 1 },
]
=head4 OUTPUT
{ type => 'integer', min => 0, max => 1 }
=head3 MESSAGES
None. Called once per path, so arguments are checked by hand.
=head3 FORMAL SPECIFICATION
ignored(c) â last ⨠¬neg(p) | p â compiled, c â re(p) â© = true
match(path) â â a : {path} ⪠ancestors(path) ⢠ignored(a)
=head3 PSEUDOCODE
candidates := path, then each directory above it
for each candidate:
state := not ignored
for each pattern in order: if it matches, state := not negated
if state is ignored: return 1
return 0
=cut | |||||
| 172 | ||||||
| 173 | sub match { | |||||
| 174 | 1531 | 1409 | my ($self, $path) = @_; | |||
| 175 | ||||||
| 176 | 1531 | 2512 | return 0 unless defined $path && length $path; | |||
| 177 | ||||||
| 178 | # A path inside an ignored directory is ignored too, even if the caller | |||||
| 179 | # only asks about the file (the scanner prunes at the directory, but this | |||||
| 180 | # keeps the answer correct for every caller). | |||||
| 181 | 1531 | 1341 | my @candidates = ($path); | |||
| 182 | 1531 | 1045 | my $prefix = $path; | |||
| 183 | 1531 | 1892 | push @candidates, $prefix while $prefix =~ s{/[^/]+\z}{}; | |||
| 184 | ||||||
| 185 | 1531 | 1216 | for my $candidate (@candidates) { | |||
| 186 | 1643 | 1005 | my $ignored = 0; | |||
| 187 | 1643 1643 | 945 1427 | for my $pattern (@{ $self->{compiled} }) { | |||
| 188 | 20548 | 22335 | $ignored = $pattern->{negated} ? 0 : 1 if $candidate =~ $pattern->{re}; | |||
| 189 | } | |||||
| 190 | 1643 | 1931 | return 1 if $ignored; | |||
| 191 | } | |||||
| 192 | ||||||
| 193 | 1333 | 1794 | return 0; | |||
| 194 | } | |||||
| 195 | ||||||
| 196 | # _glob_to_re | |||||
| 197 | # Purpose: translate one .gitignore-style glob into a regex body. | |||||
| 198 | # Entry: $pattern has had its "!" and surrounding blanks removed. | |||||
| 199 | # Exit: returns the regex source (not anchored; new() anchors it). | |||||
| 200 | # Side effects: none. Hand-written rather than Text::Glob, which has no | |||||
| 201 | # "**" and no "slash-less pattern matches at any depth" rule. | |||||
| 202 | sub _glob_to_re :Private { | |||||
| 203 | my ($pattern) = @_; | |||||
| 204 | ||||||
| 205 | # A leading slash anchors; a trailing one (.gitignore's directory marker) | |||||
| 206 | # does not, so "build/" matches "build" at any depth as git users expect. | |||||
| 207 | $pattern =~ s{/\z}{}; | |||||
| 208 | my $anchored = $pattern =~ s{\A/}{} ? 1 : 0; | |||||
| 209 | my $has_slash = $pattern =~ m{/} ? 1 : 0; | |||||
| 210 | ||||||
| 211 | # Translate token by token: "**/" any directories, "**" anything, "*" | |||||
| 212 | # within a component, "?" one character, everything else literally. | |||||
| 213 | my $re = ''; | |||||
| 214 | while (length $pattern) { | |||||
| 215 | if ($pattern =~ s{\A\*\*/}{}) { | |||||
| 216 | $re .= '(?:.*/)?'; | |||||
| 217 | } elsif ($pattern =~ s{\A\*\*}{}) { | |||||
| 218 | $re .= '.*'; | |||||
| 219 | } elsif ($pattern =~ s{\A\*}{}) { | |||||
| 220 | $re .= '[^/]*'; | |||||
| 221 | } elsif ($pattern =~ s{\A\?}{}) { | |||||
| 222 | $re .= '[^/]'; | |||||
| 223 | } else { | |||||
| 224 | $re .= quotemeta(substr($pattern, 0, 1, '')); | |||||
| 225 | } | |||||
| 226 | } | |||||
| 227 | ||||||
| 228 | # A pattern without a slash matches any path segment, like .gitignore. | |||||
| 229 | return ($anchored || $has_slash) ? $re : "(?:.*/)?$re"; | |||||
| 230 | 316 316 316 | 129798 265 4220 | } | |||
| 231 | ||||||
| 232 | 1; | |||||
| 233 | ||||||