File Coverage

blib/lib/PFT/Conf.pm
Criterion Covered Total %
statement 126 151 83.4
branch 28 46 60.8
condition 11 17 64.7
subroutine 25 29 86.2
pod 8 10 80.0
total 198 253 78.2


line stmt bran cond sub pod time code
1             # Copyright 2014-2016 - Giovanni Simoni
2             #
3             # This file is part of PFT.
4             #
5             # PFT is free software: you can redistribute it and/or modify it under the
6             # terms of the GNU General Public License as published by the Free
7             # Software Foundation, either version 3 of the License, or (at your
8             # option) any later version.
9             #
10             # PFT is distributed in the hope that it will be useful, but WITHOUT ANY
11             # WARRANTY; without even the implied warranty of MERCHANTABILITY or
12             # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13             # for more details.
14             #
15             # You should have received a copy of the GNU General Public License along
16             # with PFT. If not, see .
17             #
18             package PFT::Conf v1.4.1;
19              
20             =encoding utf8
21              
22             =head1 NAME
23              
24             PFT::Conf - Configuration parser for PFT
25              
26             =head1 SYNOPSIS
27              
28             PFT::Conf->new_default() # Using default
29             PFT::Conf->new_load($root) # Load from conf file in directory
30             PFT::Conf->new_load_locate() # Load from conf file, find directory
31             PFT::Conf->new_load_locate($cwd)
32              
33             PFT::Conf::locate() # Locate root
34             PFT::Conf::locate($cwd)
35              
36             PFT::Conf::isroot($path) # Check if location exists under path.
37              
38             use Getopt::Long;
39             Getopt::Long::Configure 'bundling';
40             GetOptions(
41             PFT::Conf::wire_getopt(\my %opts),
42             'more-opt' => \$more,
43             );
44             PFT::Conf->new_getopt(\%opts); # Create with command line options
45              
46             =head1 DESCRIPTION
47              
48             Automatic loader and handler for the configuration file of a I site.
49              
50             The configuration is a simple I file with a conventional name. Some
51             keys are mandatory, while other are optional. This module allows a
52             headache free check for mandatory ones.
53              
54             =head2
55              
56             Many constructors are available, here described:
57              
58             =over
59              
60             =item new_default
61              
62             Creates a new configuration based on environment variables and common
63             sense.
64              
65             The configuration can later be stored on a file with the C
66             method.
67              
68             =item new_load
69              
70             Loads a configuration file which must already exist. Accepts as optional
71             argument the name of a directory (not encoded), which defaults on
72             the current directory.
73              
74             This constructor fails with C if the directory does not contain a
75             configuration file.
76              
77             =item new_load_locate
78              
79             Works as C, but before failing makes an attempt to locate the
80             configuration file in the parent directories up to the root level.
81              
82             This is handy for launching commands from the command line without
83             worrying on the current directory: it works as long as your I is
84             below a I root directory.
85              
86             =item wire_getopt and new_getopt
87              
88             This is a two-steps constructor meant for command line initializers.
89              
90             An example of usage can be found in the B section. In short, the
91             auxiliary function C provides a list of
92             ready-to-use options for the C Perl module. It expects a
93             hash reference as argument, which will be used as storage for selected
94             options. The C constructor expects as argument the same hash
95             reference.
96              
97             =back
98              
99             =cut
100              
101 3     3   65997 use utf8;
  3         14  
  3         18  
102 3     3   121 use v5.16;
  3         10  
103 3     3   16 use strict;
  3         5  
  3         79  
104 3     3   15 use warnings;
  3         6  
  3         79  
105              
106 3     3   13 use Carp;
  3         5  
  3         172  
107 3     3   19 use Cwd;
  3         7  
  3         178  
108 3     3   477 use Encode::Locale;
  3         13882  
  3         123  
109 3     3   19 use Encode;
  3         4  
  3         252  
110 3     3   19 use File::Basename qw/dirname/;
  3         6  
  3         179  
111 3     3   18 use File::Path qw/make_path/;
  3         4  
  3         157  
112 3     3   1315 use File::Spec::Functions qw/updir catfile catdir rootdir/;
  3         2589  
  3         200  
113 3     3   594 use YAML::Tiny;
  3         5711  
  3         149  
114              
115             =head2 Shared variables
116              
117             C<$PFT::Conf::CONF_NAME> is a string. Defines the name of the
118             configuration file.
119              
120             =cut
121              
122 3     3   18 use Exporter 'import';
  3         5  
  3         6138  
123             our @EXPORT_OK = qw(
124             pod_autogen
125             bash_completion_autogen
126             );
127             our $CONF_NAME = 'pft.yaml';
128              
129             # %CONF_RECIPE maps configuration names to an array.
130             #
131             # Keys of this map correspond to keys in the configuratoin file. They use dashes
132             # to identify the hierarchy, so that, for instance, site-author corresponds to
133             # the key 'author' in the section 'site' of the configuration file.
134             #
135             # Keys are also used for generating automatically the POD user guide and the
136             # command line options of the `pft init` command.
137             #
138             # The semantics of each array item is defined by the following $IDX_* variables:
139             my(
140             $IDX_MANDATORY, # 1 if the configuration is mandatory
141             $IDX_GETOPT_SUFFIX, # Corresponding suffix in getopt (see Getopt::Long)
142             $IDX_DEFAULT, # The default value when generating a configuration
143             $IDX_HELP, # A human readable text descrbing the option
144             $IDX_HELP_OPTARG_NAME, # Option argument placeholder, undef if the
145             # option doesn't take arguments.
146             $IDX_HELP_DEFAULT, # Optional human readable text explaining
147             # the default value. A representation of the
148             # actual default is used if this is missing.
149             ) = 0 .. 5;
150             my %CONF_RECIPE = do {
151             my $user = $ENV{USER} || 'anon';
152             (
153             'site-author' => [
154             1,
155             '=s',
156             $user,
157             'Global Author, can be overriden by individual entries',
158             'USER',
159             'C<$USER> (environment variable)',
160             ],
161             'site-template' => [
162             1,
163             '=s',
164             'default.html',
165             'Global HTML template, can be overriden by individual entires',
166             'TEMPLATE',
167             undef,
168             ],
169             'site-theme' => [
170             0,
171             '=s',
172             'light',
173             'Global theme (e.g. C or C) optionally honored by'.
174             ' templates. Specific accepted values depend on the template'.
175             ' implementation',
176             'THEME',
177             undef,
178             ],
179             'site-title' => [
180             1,
181             '=s',
182             'My PFT website',
183             'Title of the website',
184             'TITLE',
185             undef,
186             ],
187             'site-url' => [
188             0,
189             '=s',
190             'http://example.org',
191             'Base url for the website',
192             'URL',
193             undef,
194             ],
195             'site-home' => [
196             1,
197             '=s',
198             'Welcome',
199             'First page, where C will redirect the browsers',
200             'PAGE_NAME',
201             undef,
202             ],
203             'site-encoding' => [
204             1,
205             '=s',
206             $Encode::Locale::ENCODING_LOCALE,
207             'Charset of the generated web pages',
208             'ENC',
209             'what is defined by L',
210             ],
211             'site-feed-path' => [
212             0,
213             '=s',
214             'feed.rss',
215             'File name of the RSS XML to be published by L',
216             'PATH',
217             undef,
218             ],
219             'site-feed-length' => [
220             0,
221             '=i',
222             10,
223             'Number of most recent blog entries to list in the RSS feed',
224             'N',
225             undef,
226             ],
227             'site-feed-description' => [
228             0,
229             '=s',
230             'News from a PFT website',
231             'Description of the channel (CdescriptionE> in the XML)',
232             'DESC',
233             undef,
234             ],
235             'publish-method' => [
236             1,
237             '=s',
238             'rsync+ssh',
239             'Method used for publishing (see L)',
240             'NAME',
241             undef,
242             ],
243             'publish-host' => [
244             0,
245             '=s',
246             'example.org',
247             'Remote host where to publish (see L)',
248             'HOST',
249             undef,
250             ],
251             'publish-user' => [
252             0,
253             '=s',
254             $user,
255             'User login on publishing host (see L)',
256             'USER',
257             '$USER (environment variable)',
258             ],
259             'publish-port' => [
260             0,
261             '=i',
262             22,
263             'Port for connection on publishing host (see L)',
264             'PORT',
265             undef,
266             ],
267             'publish-path' => [
268             0,
269             '=s',
270             "/home/$user/public_html",
271             'Remote path on publishing host (see L)',
272             'PATH',
273             'C, as by tradition',
274             ],
275             'system-editor' => [
276             0,
277             '=s',
278             $ENV{EDITOR} || 'vi',
279             'Editor to be invoked by L. You may specify an'.
280             ' executable, or a L command where "%s" gets replaced'.
281             ' with the file name (e.g.'.
282             ' "vim +\'set filetype=markdown spell\' %s")',
283             'EDITOR',
284             'C<$EDITOR> (environment variable), or C if not defined'
285             ],
286             'system-browser' => [
287             0,
288             '=s',
289             $ENV{BROWSER} || 'firefox',
290             'Browser to be invoked by B. You may specify an'.
291             ' executable, or a L command where "%s" gets replaced'.
292             ' with the file name (e.g. "firefox -profile x \'%s\'")',
293             'BROWSER',
294             'C<$BROWSER> (environment variable), or C if not defined'
295             ],
296             )
297             };
298              
299             # Transforms a flat mapping as $CONF_RECIPE into 'deep' hash table. Items in the
300             # form 'foo-bar-baz' will be accessible as _hashify()->{foo}{bar}{baz}.
301             sub _hashify {
302 7     7   105 my %out;
303              
304 7 50       26 @_ % 2 and die "Odd number of args";
305 7         26 for (my $i = 0; $i < @_; $i += 2) {
306 75 50       152 defined(my $val = $_[$i + 1]) or next;
307 75         151 my @keys = split /-/, $_[$i];
308              
309 75 50       126 die "Key is empty? \"$_[$i]\"" unless @keys;
310 75         104 my $dst = \%out;
311 75         132 while (@keys > 1) {
312 89         146 my $k = shift @keys;
313             $dst = exists $dst->{$k}
314             ? $dst->{$k}
315 89 100       163 : do { $dst->{$k} = {} };
  21         55  
316 89 100       430 ref $dst ne 'HASH' and croak "Not pointing to hash: $_[$i]";
317             }
318 74         107 my $k = shift @keys;
319 74 100 66     260 exists $dst->{$k} && ref $dst->{$k} eq 'HASH'
320             and croak "Overwriting $_[$i]";
321 73         219 $dst->{$k} = $val;
322             }
323              
324 5         17 \%out;
325             }
326              
327             # Read the %CONF_RECIPE map and return a mapping between each key and the
328             # associated field. The first parameter is the index to select. The second
329             # parameter is optional: if true retrieves only the configuration which evaluate
330             # as true.
331             sub _read_recipe {
332 7     7   13 my $select = shift;
333 7         13 my @out;
334 7 100       28 if (my $filter = shift) {
335 3         13 while (my($k, $vs) = each %CONF_RECIPE) {
336 51 100       120 my $v = $vs->[$select] or next;
337 18         43 push @out, $k => $v;
338             }
339             } else {
340 4         27 while (my($k, $vs) = each %CONF_RECIPE) {
341 68         219 push @out, $k => $vs->[$select];
342             }
343             }
344 7         56 @out;
345             }
346              
347             sub pod_autogen {
348 0     0 0 0 my @out = ('=over', '');
349              
350 0         0 for my $key (sort keys %CONF_RECIPE) {
351 0         0 my $info = $CONF_RECIPE{$key};
352              
353 0         0 my $optitem = "=item B<--${key}>";
354 0 0       0 if (my $optarg_name = $info->[$IDX_HELP_OPTARG_NAME]) {
355 0         0 $optitem .= "=I<${optarg_name}>"
356             }
357              
358 0         0 my $default = $info->[$IDX_HELP_DEFAULT];
359 0 0       0 unless (defined $default) {
360             # The semantic explanation on the default is missing, using the
361             # textual representation of the actual default.
362 0         0 $default = "C<$info->[$IDX_DEFAULT]>"
363             }
364              
365 0         0 push @out,
366             "$optitem\n",
367             "$info->[$IDX_HELP].",
368             "Defaults to $default.", '',
369             }
370              
371 0         0 join "\n", @out, '=back';# '', '=cut';
372             }
373              
374             sub bash_completion_autogen {
375 0     0 0 0 '--' . join "\n--", keys %CONF_RECIPE;
376             }
377              
378             sub new_default {
379 4     4 1 17 my $self = _hashify(_read_recipe($IDX_DEFAULT));
380 4         21 $self->{_root} = undef;
381 4         19 bless $self, shift;
382             }
383              
384             sub _check_assign {
385 3     3   49 my $self = shift;
386 3         6 local $" = '-';
387 3         5 my $i;
388              
389 3         8 for my $mandk (grep { ++$i % 2 } _read_recipe($IDX_MANDATORY, 1)) {
  36         52  
390 16         33 my @keys = split /-/, $mandk;
391 16         18 my @path;
392              
393 16         20 my $c = $self;
394 16         27 while (@keys > 1) {
395 16         26 push @path, (my $k = shift @keys);
396 16 50       35 confess "Missing section \"@path\"" unless $c->{$k};
397 16         19 $c = $c->{$k};
398 16 50       41 confess "Seeking \"@keys\" in \"@path\""
399             unless ref $c eq 'HASH';
400             }
401 16         23 push @path, shift @keys;
402 16 100       188 confess "Missing @path" unless exists $c->{$path[-1]};
403             }
404             }
405              
406             sub new_load {
407 2     2 1 9 my($cls, $root) = @_;
408              
409 2         5 my $self = do {
410 2 50       4 my $enc_fname = isroot($root)
411             or croak "$root is not a PFT site: $CONF_NAME is missing";
412 2 50       68 open(my $f, '<:encoding(locale)', $enc_fname)
413             or croak "Cannot open $CONF_NAME in $root $!";
414 2         106 local $/ = undef;
415 2         64 my $yaml = <$f>;
416 2         21 close $f;
417              
418 2         10 YAML::Tiny::Load($yaml);
419             };
420 2         2552 _check_assign($self);
421              
422 2         5 $self->{_root} = $root;
423 2         13 bless $self, $cls;
424             }
425              
426             sub new_load_locate {
427 1     1 1 4 my $cls = shift;
428 1         3 my $root = locate(my $start = shift);
429 1 50       5 croak "Not a PFT site (or any parent up to $start)"
430             unless defined $root;
431              
432 1         3 $cls->new_load($root);
433             }
434              
435             sub new_getopt {
436 0     0 1 0 my($cls, $wired_hash) = @_;
437              
438 0         0 my $self = _hashify(
439             _read_recipe($IDX_DEFAULT), # defaults
440             %$wired_hash, # override via wire_getopt
441             );
442 0         0 $self->{_root} = undef;
443 0         0 bless $self, $cls;
444             }
445              
446             =head2 Utility functions
447              
448             =over
449              
450             =item isroot
451              
452             The C function searches for the configuration file in
453             the given directory path (not encoded).
454              
455             Returns C if the file was not found, and the encoded file name
456             (according to locale) if it was found.
457              
458             =cut
459              
460             sub isroot {
461 24     24 1 138 my $f = encode(locale_fs => catfile(shift, $CONF_NAME));
462 24 100       1518 -e $f ? $f : undef
463             }
464              
465             =item locate
466              
467             The C function locates a I configuration file.
468              
469             It accepts as optional parameter a directory path (not encoded),
470             defaulting on the current working directory.
471              
472             Possible return values:
473              
474             =over
475              
476             =item The input directory itself if the configuration file was
477             found in it;
478              
479             =item The first encountered parent directory containing the configuration
480             file;
481              
482             =item C if no configuration file was found, up to the root of all
483             directories.
484              
485             =back
486              
487             =back
488              
489             =cut
490              
491             sub locate {
492 9   66 9 1 8607 my $cur = shift || Cwd::getcwd;
493 9         26 my $root;
494              
495 9 50       31 croak "Not a directory: $cur" unless -d encode(locale_fs => $cur);
496              
497             # No single root directory on Windows. File::Spec->rootdir does not
498             # work as intended. Workaround: $prev is like $cur on the previous
499             # step: we stay on the same directory even going up, we reached the
500             # root. Thanks to Alexandr Ciornii for checking this.
501 9         923 my $prev = '';
502 9   100     69 until ($cur eq rootdir or $cur eq $prev or defined($root)) {
      66        
503 20         101 $prev = $cur;
504 20 100       50 if (isroot($cur)) {
505 4         25 $root = $cur
506             } else {
507 16         410 $cur = Cwd::abs_path catdir($cur, updir)
508             }
509             }
510 9         56 $root;
511             }
512              
513             sub wire_getopt {
514 0     0 1 0 my $hash = shift;
515 0 0       0 confess 'Needs hash' unless ref $hash eq 'HASH';
516              
517 0         0 my @out;
518 0         0 my @recipe = _read_recipe($IDX_GETOPT_SUFFIX);
519 0         0 for (my $i = 0; $i < @recipe; $i += 2) {
520 0         0 push @out, $recipe[$i] . $recipe[$i + 1] => \$hash->{$recipe[$i]}
521             }
522 0         0 @out;
523             }
524              
525             =head2 Methods
526              
527             =over 1
528              
529             =item save_to
530              
531             Save the configuration to a file. This will also update the inner root
532             reference, so the intsance will point to the saved file.
533              
534             =cut
535              
536             sub save_to {
537 3     3 1 34 my($self, $root) = @_;
538              
539 3         64 my $orig_root = delete $self->{_root};
540              
541             # YAML::Tiny does not like blessed items. I could unbless with
542             # Data::Structure::Util, or easily do a shallow copy
543 3         23 my $yaml = YAML::Tiny::Dump {%$self};
544              
545 3         3264 eval {
546 3         35 my $enc_root = encode(locale_fs => $root);
547 3 50 33     213 -e $enc_root or make_path $enc_root
548             or die "Cannot mkdir $root: $!";
549 3 50   3   257 open(my $out,
  3         6  
  3         19  
  3         53  
550             '>:encoding(locale)',
551             encode(locale_fs => catfile($root, $CONF_NAME)),
552             ) or die "Cannot open $CONF_NAME in $root: $!";
553 3         3060 print $out $yaml;
554 3         171 close $out;
555              
556 3         100 $self->{_root} = $root;
557             };
558 3 50       28 $@ and do {
559 0           $self->{_root} = $orig_root;
560 0           croak $@ =~ s/ at.*$//sr;
561             }
562             }
563              
564             =back
565              
566             =cut
567              
568             use overload
569 4   50 4   392 '""' => sub { 'PFT::Conf[ ' . (shift->{_root} || '?') . ' ]' },
570 3     3   1201 ;
  3         983  
  3         23  
571              
572             1;