|
| 1 | +package LANraragi::Utils::Ignore; |
| 2 | + |
| 3 | +use strict; |
| 4 | +use warnings; |
| 5 | +use utf8; |
| 6 | +use feature qw(signatures); |
| 7 | +no warnings 'experimental::signatures'; |
| 8 | + |
| 9 | +use File::Spec; |
| 10 | +use File::Basename qw(dirname); |
| 11 | +use Config; |
| 12 | + |
| 13 | +use LANraragi::Utils::Path qw(open_path); |
| 14 | +use LANraragi::Utils::String qw(trim); |
| 15 | + |
| 16 | +use Exporter 'import'; |
| 17 | +our @EXPORT_OK = qw( |
| 18 | + is_ignored |
| 19 | + build_ignore_rules |
| 20 | + update_ignore_rules |
| 21 | +); |
| 22 | + |
| 23 | +use constant IS_UNIX => ( $Config{osname} ne 'MSWin32' ); |
| 24 | + |
| 25 | +# Check if $file should be ignored based on pre-built rules. |
| 26 | +sub is_ignored ($file, $rules) { |
| 27 | + my $entries = $rules->{entries}; |
| 28 | + return 0 unless @$entries; |
| 29 | + return match_rules( $file, $entries ); |
| 30 | +} |
| 31 | + |
| 32 | +# Rules data structure: |
| 33 | +# $rules = { |
| 34 | +# content_dir => $path, # Root content directory for scanning |
| 35 | +# current_dir => $path, # Tracks the farthest directory reached |
| 36 | +# entries => [ # Ordered list of nested .lrrignore entries |
| 37 | +# { |
| 38 | +# dir => $path, # Directory where .lrrignore was loaded |
| 39 | +# patterns => [ # Parsed patterns from a single .lrrignore file |
| 40 | +# { |
| 41 | +# pattern => "*.tmp", # Original pattern string |
| 42 | +# regex => qr/.../i, # Compiled regex for matching |
| 43 | +# negation => 0|1, # 1 = ! re-include, 0 = ignore |
| 44 | +# }, |
| 45 | +# ... |
| 46 | +# ], |
| 47 | +# }, |
| 48 | +# ... |
| 49 | +# ], |
| 50 | +# } |
| 51 | +# |
| 52 | +sub build_ignore_rules ($content_dir, $file = undef) { |
| 53 | + $content_dir =~ s{\\}{/}g unless IS_UNIX; |
| 54 | + my $rules = { |
| 55 | + content_dir => $content_dir, |
| 56 | + current_dir => $content_dir, |
| 57 | + entries => [], |
| 58 | + }; |
| 59 | + |
| 60 | + load_ignore_file( $content_dir, $rules->{entries} ); |
| 61 | + update_ignore_rules( dirname($file), $rules ) if defined $file; |
| 62 | + |
| 63 | + return $rules; |
| 64 | +} |
| 65 | + |
| 66 | +# Pops stale entries and auto-loads .lrrignore for newly entered directories. |
| 67 | +sub update_ignore_rules ($dst_dir, $rules) { |
| 68 | + $dst_dir =~ s{\\}{/}g unless IS_UNIX; |
| 69 | + return if $rules->{current_dir} && $rules->{current_dir} eq $dst_dir; |
| 70 | + |
| 71 | + my $entries = $rules->{entries}; |
| 72 | + |
| 73 | + while ( !is_subpath( $rules->{current_dir}, $dst_dir ) ) { |
| 74 | + pop @$entries if @$entries && $rules->{current_dir} eq $entries->[-1]{dir}; |
| 75 | + $rules->{current_dir} = dirname( $rules->{current_dir} ); |
| 76 | + } |
| 77 | + |
| 78 | + my @missing; |
| 79 | + my $cur = $dst_dir; |
| 80 | + while ( $cur && $cur ne $rules->{current_dir} ) { |
| 81 | + unshift @missing, $cur; |
| 82 | + $cur = dirname($cur); |
| 83 | + } |
| 84 | + |
| 85 | + for my $d (@missing) { |
| 86 | + load_ignore_file( $d, $entries ); |
| 87 | + } |
| 88 | + |
| 89 | + $rules->{current_dir} = $dst_dir; |
| 90 | +} |
| 91 | + |
| 92 | +sub match_rules ($file, $entries) { |
| 93 | + for my $entry ( reverse @$entries ) { |
| 94 | + my $rel = File::Spec->abs2rel( $file, $entry->{dir} ); |
| 95 | + $rel =~ s{\\}{/}g unless IS_UNIX; |
| 96 | + utf8::decode($rel); |
| 97 | + my $result; |
| 98 | + for my $rule ( @{ $entry->{patterns} } ) { |
| 99 | + next unless $rel =~ $rule->{regex}; |
| 100 | + $result = $rule->{negation} ? 0 : 1; |
| 101 | + } |
| 102 | + return $result if defined $result; |
| 103 | + } |
| 104 | + return 0; |
| 105 | +} |
| 106 | + |
| 107 | +sub parse_ignore_file ($path) { |
| 108 | + my @patterns; |
| 109 | + |
| 110 | + open_path( my $fh, '<:encoding(UTF-8)', $path ) or return @patterns; |
| 111 | + my @lines = <$fh>; |
| 112 | + close($fh); |
| 113 | + |
| 114 | + for my $line (@lines) { |
| 115 | + $line = trim($line); |
| 116 | + next if $line eq ''; |
| 117 | + next if $line =~ /^#/; |
| 118 | + |
| 119 | + my $negation = 0; |
| 120 | + if ( $line =~ s/^!// ) { $negation = 1; } |
| 121 | + |
| 122 | + my $regex = glob_to_regex($line); |
| 123 | + next unless defined $regex; |
| 124 | + |
| 125 | + push @patterns, { |
| 126 | + pattern => $line, |
| 127 | + regex => $regex, |
| 128 | + negation => $negation, |
| 129 | + }; |
| 130 | + } |
| 131 | + |
| 132 | + return @patterns; |
| 133 | +} |
| 134 | + |
| 135 | +sub glob_to_regex ($pattern_str) { |
| 136 | + |
| 137 | + $pattern_str =~ s{/$}{/*}; |
| 138 | + |
| 139 | + return () if $pattern_str eq ''; |
| 140 | + |
| 141 | + my $anchored = 0; |
| 142 | + if ( $pattern_str =~ s{^/}{} ) { $anchored = 1; } |
| 143 | +
|
| 144 | + $pattern_str =~ s{^\./}{}; |
| 145 | +
|
| 146 | + $pattern_str = join '[^/]*', map { |
| 147 | + quotemeta($_ =~ s/\\(.)/$1/gr); |
| 148 | + } split(/(?<!\\)\*/, $pattern_str, -1); |
| 149 | + |
| 150 | + if (!$anchored) { |
| 151 | + $pattern_str = '(?:.+/)?' . $pattern_str; |
| 152 | + } |
| 153 | + |
| 154 | + return ( qr{^$pattern_str$}i ); |
| 155 | +} |
| 156 | + |
| 157 | +sub load_ignore_file ($dir, $entries) { |
| 158 | + my $ignore = File::Spec->catfile( $dir, ".lrrignore" ); |
| 159 | + return unless -f $ignore; |
| 160 | + my @parsed = parse_ignore_file($ignore); |
| 161 | + return unless @parsed; |
| 162 | + push @$entries, { dir => $dir, patterns => \@parsed }; |
| 163 | +} |
| 164 | + |
| 165 | + |
| 166 | +sub is_subpath ($ancestor, $target) { |
| 167 | + return 1 if $ancestor eq $target; |
| 168 | + $ancestor =~ s{[\\/]+$}{}; |
| 169 | + return index( $target, $ancestor . '/' ) == 0; |
| 170 | +} |
| 171 | + |
| 172 | + |
| 173 | +1; |
0 commit comments