1# -*- Mode: cperl; coding: utf-8; cperl-indent-level: 4 -*-
2# vim: ts=4 sts=4 sw=4:
3
4use 5.006;
5use strict;
6package CPAN::Distroprefs;
7
8use vars qw($VERSION);
9$VERSION = '6.0001';
10
11package CPAN::Distroprefs::Result;
12
13use File::Spec;
14
15sub new { bless $_[1] || {} => $_[0] }
16
17sub abs { File::Spec->catfile($_[0]->dir, $_[0]->file) }
18
19sub __cloner {
20    my ($class, $name, $newclass) = @_;
21    $newclass = 'CPAN::Distroprefs::Result::' . $newclass;
22    no strict 'refs';
23    *{$class . '::' . $name} = sub {
24        $newclass->new({
25            %{ $_[0] },
26            %{ $_[1] },
27        });
28    };
29}
30BEGIN { __PACKAGE__->__cloner(as_warning => 'Warning') }
31BEGIN { __PACKAGE__->__cloner(as_fatal   => 'Fatal') }
32BEGIN { __PACKAGE__->__cloner(as_success => 'Success') }
33
34sub __accessor {
35    my ($class, $key) = @_;
36    no strict 'refs';
37    *{$class . '::' . $key} = sub { $_[0]->{$key} };
38}
39BEGIN { __PACKAGE__->__accessor($_) for qw(type file ext dir) }
40
41sub is_warning { 0 }
42sub is_fatal   { 0 }
43sub is_success { 0 }
44
45package CPAN::Distroprefs::Result::Error;
46use vars qw(@ISA);
47BEGIN { @ISA = 'CPAN::Distroprefs::Result' } ## no critic
48BEGIN { __PACKAGE__->__accessor($_) for qw(msg) }
49
50sub as_string {
51    my ($self) = @_;
52    if ($self->msg) {
53        return sprintf $self->fmt_reason, $self->file, $self->msg;
54    } else {
55        return sprintf $self->fmt_unknown, $self->file;
56    }
57}
58
59package CPAN::Distroprefs::Result::Warning;
60use vars qw(@ISA);
61BEGIN { @ISA = 'CPAN::Distroprefs::Result::Error' } ## no critic
62sub is_warning { 1 }
63sub fmt_reason  { "Error reading distroprefs file %s, skipping: %s" }
64sub fmt_unknown { "Unknown error reading distroprefs file %s, skipping." }
65
66package CPAN::Distroprefs::Result::Fatal;
67use vars qw(@ISA);
68BEGIN { @ISA = 'CPAN::Distroprefs::Result::Error' } ## no critic
69sub is_fatal { 1 }
70sub fmt_reason  { "Error reading distroprefs file %s: %s" }
71sub fmt_unknown { "Unknown error reading distroprefs file %s." }
72
73package CPAN::Distroprefs::Result::Success;
74use vars qw(@ISA);
75BEGIN { @ISA = 'CPAN::Distroprefs::Result' } ## no critic
76BEGIN { __PACKAGE__->__accessor($_) for qw(prefs extension) }
77sub is_success { 1 }
78
79package CPAN::Distroprefs::Iterator;
80
81sub new { bless $_[1] => $_[0] }
82
83sub next { $_[0]->() }
84
85package CPAN::Distroprefs;
86
87use Carp ();
88use DirHandle;
89
90sub _load_method {
91    my ($self, $loader, $result) = @_;
92    return '_load_yaml' if $loader eq 'CPAN' or $loader =~ /^YAML(::|$)/;
93    return '_load_' . $result->ext;
94}
95
96sub _load_yaml {
97    my ($self, $loader, $result) = @_;
98    my $data = eval {
99        $loader eq 'CPAN'
100        ? $loader->_yaml_loadfile($result->abs)
101        : [ $loader->can('LoadFile')->($result->abs) ]
102    };
103    if (my $err = $@) {
104        die $result->as_warning({
105            msg  => $err,
106        });
107    } elsif (!$data) {
108        die $result->as_warning;
109    } else {
110        return @$data;
111    }
112}
113
114sub _load_dd {
115    my ($self, $loader, $result) = @_;
116    my @data;
117    {
118        package CPAN::Eval;
119        # this caused a die in CPAN.pm, and I am leaving it 'fatal', though I'm
120        # not sure why we wouldn't just skip the file as we do for all other
121        # errors. -- hdp
122        my $abs = $result->abs;
123        open FH, "<$abs" or die $result->as_fatal(msg => "$!");
124        local $/;
125        my $eval = <FH>;
126        close FH;
127        no strict;
128        eval $eval;
129        if (my $err = $@) {
130            die $result->as_warning({ msg => $err });
131        }
132        my $i = 1;
133        while (${"VAR$i"}) {
134            push @data, ${"VAR$i"};
135            $i++;
136        }
137    }
138    return @data;
139}
140
141sub _load_st {
142    my ($self, $loader, $result) = @_;
143    # eval because Storable is never forward compatible
144    my @data = eval { @{scalar $loader->can('retrieve')->($result->abs) } };
145    if (my $err = $@) {
146        die $result->as_warning({ msg => $err });
147    }
148    return @data;
149}
150
151sub _build_file_list {
152    if (@_ > 3) {
153        die "_build_file_list should be called with 3 arguments, was called with more. First argument is '$_[0]'.";
154    }
155    my ($dir, $dir1, $ext_re) = @_;
156    my @list;
157    my $dh;
158    unless (opendir($dh, $dir)) {
159        $CPAN::Frontend->mywarn("ignoring prefs directory '$dir': $!");
160        return @list;
161    }
162    while (my $fn = readdir $dh) {
163        next if $fn eq '.' || $fn eq '..';
164        if (-d "$dir/$fn") {
165            next if $fn =~ /^[._]/; # prune .svn, .git, .hg, _darcs and what the user wants to hide
166            push @list, _build_file_list("$dir/$fn", "$dir1$fn/", $ext_re);
167        } else {
168            if ($fn =~ $ext_re) {
169                push @list, "$dir1$fn";
170            }
171        }
172    }
173    return @list;
174}
175
176sub find {
177    my ($self, $dir, $ext_map) = @_;
178
179    return CPAN::Distroprefs::Iterator->new(sub { return }) unless %$ext_map;
180
181    my $possible_ext = join "|", map { quotemeta } keys %$ext_map;
182    my $ext_re = qr/\.($possible_ext)$/;
183
184    my @files = _build_file_list($dir, '', $ext_re);
185    @files = sort @files if @files;
186
187    # label the block so that we can use redo in the middle
188    return CPAN::Distroprefs::Iterator->new(sub { LOOP: {
189
190        my $fn = shift @files;
191        return unless defined $fn;
192        my ($ext) = $fn =~ $ext_re;
193
194        my $loader = $ext_map->{$ext};
195
196        my $result = CPAN::Distroprefs::Result->new({
197            file => $fn, ext => $ext, dir => $dir
198        });
199        # copied from CPAN.pm; is this ever actually possible?
200        redo unless -f $result->abs;
201
202        my $load_method = $self->_load_method($loader, $result);
203        my @prefs = eval { $self->$load_method($loader, $result) };
204        if (my $err = $@) {
205            if (ref($err) && eval { $err->isa('CPAN::Distroprefs::Result') }) {
206                return $err;
207            }
208            # rethrow any exceptions that we did not generate
209            die $err;
210        } elsif (!@prefs) {
211            # the loader should have handled this, but just in case:
212            return $result->as_warning;
213        }
214        return $result->as_success({
215            prefs => [
216                map { CPAN::Distroprefs::Pref->new({ data => $_ }) } @prefs
217            ],
218        });
219    } });
220}
221
222package CPAN::Distroprefs::Pref;
223
224use Carp ();
225
226sub new { bless $_[1] => $_[0] }
227
228sub data { shift->{data} }
229
230sub has_any_match { $_[0]->data->{match} ? 1 : 0 }
231
232sub has_match {
233    my $match = $_[0]->data->{match} || return 0;
234    exists $match->{$_[1]} || exists $match->{"not_$_[1]"}
235}
236
237sub has_valid_subkeys {
238    grep { exists $_[0]->data->{match}{$_} }
239        map { $_, "not_$_" }
240        $_[0]->match_attributes
241}
242
243sub _pattern {
244    my $re = shift;
245    my $p = eval sprintf 'qr{%s}', $re;
246    if ($@) {
247        $@ =~ s/\n$//;
248        die "Error in Distroprefs pattern qr{$re}\n$@";
249    }
250    return $p;
251}
252
253sub _match_scalar {
254    my ($match, $data) = @_;
255    my $qr = _pattern($match);
256    return $data =~ /$qr/;
257}
258
259sub _match_hash {
260    my ($match, $data) = @_;
261    for my $mkey (keys %$match) {
262	(my $dkey = $mkey) =~ s/^not_//;
263        my $val = defined $data->{$dkey} ? $data->{$dkey} : '';
264	if (_match_scalar($match->{$mkey}, $val)) {
265	    return 0 if $mkey =~ /^not_/;
266	}
267	else {
268	    return 0 if $mkey !~ /^not_/;
269	}
270    }
271    return 1;
272}
273
274sub _match {
275    my ($self, $key, $data, $matcher) = @_;
276    my $m = $self->data->{match};
277    if (exists $m->{$key}) {
278	return 0 unless $matcher->($m->{$key}, $data);
279    }
280    if (exists $m->{"not_$key"}) {
281	return 0 if $matcher->($m->{"not_$key"}, $data);
282    }
283    return 1;
284}
285
286sub _scalar_match {
287    my ($self, $key, $data) = @_;
288    return $self->_match($key, $data, \&_match_scalar);
289}
290
291sub _hash_match {
292    my ($self, $key, $data) = @_;
293    return $self->_match($key, $data, \&_match_hash);
294}
295
296# do not take the order of C<keys %$match> because "module" is by far the
297# slowest
298sub match_attributes { qw(env distribution perl perlconfig module) }
299
300sub match_module {
301    my ($self, $modules) = @_;
302    return $self->_match("module", $modules, sub {
303	my($match, $data) = @_;
304	my $qr = _pattern($match);
305	for my $module (@$data) {
306	    return 1 if $module =~ /$qr/;
307	}
308	return 0;
309    });
310}
311
312sub match_distribution { shift->_scalar_match(distribution => @_) }
313sub match_perl         { shift->_scalar_match(perl         => @_) }
314
315sub match_perlconfig   { shift->_hash_match(perlconfig => @_) }
316sub match_env          { shift->_hash_match(env        => @_) }
317
318sub matches {
319    my ($self, $arg) = @_;
320
321    my $default_match = 0;
322    for my $key (grep { $self->has_match($_) } $self->match_attributes) {
323        unless (exists $arg->{$key}) {
324            Carp::croak "Can't match pref: missing argument key $key";
325        }
326        $default_match = 1;
327        my $val = $arg->{$key};
328        # make it possible to avoid computing things until we have to
329        if (ref($val) eq 'CODE') { $val = $val->() }
330        my $meth = "match_$key";
331        return 0 unless $self->$meth($val);
332    }
333
334    return $default_match;
335}
336
3371;
338
339__END__
340
341=head1 NAME
342
343CPAN::Distroprefs -- read and match distroprefs
344
345=head1 SYNOPSIS
346
347    use CPAN::Distroprefs;
348
349    my %info = (... distribution/environment info ...);
350
351    my $finder = CPAN::Distroprefs->find($prefs_dir, \%ext_map);
352
353    while (my $result = $finder->next) {
354
355        die $result->as_string if $result->is_fatal;
356
357        warn($result->as_string), next if $result->is_warning;
358
359        for my $pref (@{ $result->prefs }) {
360            if ($pref->matches(\%info)) {
361                return $pref;
362            }
363        }
364    }
365
366
367=head1 DESCRIPTION
368
369This module encapsulates reading L<Distroprefs|CPAN> and matching them against CPAN distributions.
370
371=head1 INTERFACE
372
373    my $finder = CPAN::Distroprefs->find($dir, \%ext_map);
374
375    while (my $result = $finder->next) { ... }
376
377Build an iterator which finds distroprefs files in the tree below the
378given directory. Within the tree directories matching C<m/^[._]/> are
379pruned.
380
381C<%ext_map> is a hashref whose keys are file extensions and whose values are
382modules used to load matching files:
383
384    {
385        'yml' => 'YAML::Syck',
386        'dd'  => 'Data::Dumper',
387        ...
388    }
389
390Each time C<< $finder->next >> is called, the iterator returns one of two
391possible values:
392
393=over
394
395=item * a CPAN::Distroprefs::Result object
396
397=item * C<undef>, indicating that no prefs files remain to be found
398
399=back
400
401=head1 RESULTS
402
403L<C<find()>|/INTERFACE> returns CPAN::Distroprefs::Result objects to
404indicate success or failure when reading a prefs file.
405
406=head2 Common
407
408All results share some common attributes:
409
410=head3 type
411
412C<success>, C<warning>, or C<fatal>
413
414=head3 file
415
416the file from which these prefs were read, or to which this error refers (relative filename)
417
418=head3 ext
419
420the file's extension, which determines how to load it
421
422=head3 dir
423
424the directory the file was read from
425
426=head3 abs
427
428the absolute path to the file
429
430=head2 Errors
431
432Error results (warning and fatal) contain:
433
434=head3 msg
435
436the error message (usually either C<$!> or a YAML error)
437
438=head2 Successes
439
440Success results contain:
441
442=head3 prefs
443
444an arrayref of CPAN::Distroprefs::Pref objects
445
446=head1 PREFS
447
448CPAN::Distroprefs::Pref objects represent individual distroprefs documents.
449They are constructed automatically as part of C<success> results from C<find()>.
450
451=head3 data
452
453the pref information as a hashref, suitable for e.g. passing to Kwalify
454
455=head3 match_attributes
456
457returns a list of the valid match attributes (see the Distroprefs section in L<CPAN>)
458
459currently: C<env perl perlconfig distribution module>
460
461=head3 has_any_match
462
463true if this pref has a 'match' attribute at all
464
465=head3 has_valid_subkeys
466
467true if this pref has a 'match' attribute and at least one valid match attribute
468
469=head3 matches
470
471  if ($pref->matches(\%arg)) { ... }
472
473true if this pref matches the passed-in hashref, which must have a value for
474each of the C<match_attributes> (above)
475
476=head1 LICENSE
477
478This program is free software; you can redistribute it and/or modify it under
479the same terms as Perl itself.
480
481=cut
482