File Coverage

blib/lib/Cron/Sequencer/Parser.pm
Criterion Covered Total %
statement 83 91 91.2
branch 52 62 83.8
condition 5 9 55.5
subroutine 11 12 91.6
pod 2 2 100.0
total 153 176 86.9


line stmt bran cond sub pod time code
1             #!perl
2              
3 2     2   643 use v5.20.0;
  2         5  
4 2     2   8 use warnings;
  2         5  
  2         51  
5              
6             # The parts of this that we use have been stable and unchanged since v5.20.0:
7 2     2   9 use feature qw(postderef);
  2         3  
  2         247  
8 2     2   10 no warnings 'experimental::postderef';
  2         4  
  2         132  
9              
10              
11             our $VERSION = '0.02';
12              
13             use Carp qw(croak confess);
14 2     2   11  
  2         3  
  2         128  
15             require Algorithm::Cron;
16             use Try::Tiny;
17 2     2   12  
  2         5  
  2         2187  
18             my %aliases = (
19             yearly => '0 0 1 1 *',
20             annually => '0 0 1 1 *',
21             monthly => '0 0 1 * *',
22             weekly => '0 0 * * 0',
23             daily => '0 0 * * *',
24             midnight => '0 0 * * *',
25             hourly => '0 * * * *',
26             );
27              
28             # scalar -> filename
29             # ref to scalar -> contents
30             # hashref -> fancy
31              
32             my ($class, $arg) = @_;
33             confess('new() called as an instance method')
34 29     29 1 51 if ref $class;
35 29 50       51  
36             my ($source, $crontab, $env, $ignore);
37             if (!defined $arg) {
38 29         40 croak(__PACKAGE__ . '->new($class, $arg)');
39 29 50       101 } elsif (ref $arg eq 'SCALAR') {
    100          
    100          
    50          
    100          
40 0         0 $source = "";
41             $crontab = $arg;
42 2         4 } elsif (ref $arg eq 'HASH') {
43 2         4 $source = $arg->{source};
44             $crontab = \$arg->{crontab}
45 23         38 if exists $arg->{crontab};
46             if (exists $arg->{env}) {
47 23 100       53 for my $pair ($arg->{env}->@*) {
48 23 100       41 # vixie crontab permits empty env variable names, so we should
49 1         3 # too we don't need it *here*, but we could implement "unset"
50             # syntax as FOO (ie no = sign)
51             my ($name, $value) = $pair =~ /\A([^=]*)=(.*)\z/;
52             croak("invalid environment variable assignment: '$pair'")
53 0         0 unless defined $value;
54 0 0       0 $env->{$name} = $value;
55             }
56 0         0 }
57             if (exists $arg->{ignore}) {
58             for my $val (map {
59 23 100       44 # Want to reach the croak if passed an empty string, so can't
60 18         37 # just call split, as that returns an empty list for that input
61             length $_ ? split /,/, $_ : $_
62             } $arg->{ignore}->@*) {
63 21 100       73 if ($val =~ /\A([1-9][0-9]*)-([1-9][0-9]*)\z/ && $2 >= $1) {
64             ++$ignore->{$_}
65 29 100 100     160 for $1 .. $2;
    100          
66             } elsif ($val =~ /\A[1-9][0-9]*\z/) {
67 8         44 ++$ignore->{$val};
68             } else {
69 14         30 croak("'ignore' must be a positive integer, not '$val'");
70             }
71 7         479 }
72             }
73             } elsif (ref $arg) {
74             confess(sprintf 'Unsupported %s reference passed to new()', ref $arg);
75             } elsif ($arg eq "") {
76 0         0 croak("empty string is not a valid filename");
77             } else {
78 1         63 $source = $arg;
79             }
80 3         5  
81             if (!$crontab) {
82             croak("you must provide a source filename or crontab contents")
83 21 100       45 unless length $source;
84 6 100       237 open my $fh, '<', $source
85             or croak("Can't open $source: $!");
86 4 100       239 local $/;
87             my $contents = <$fh>;
88 3         16 unless(defined $contents && close $fh) {
89 3         97 croak("Can't read $source: $!");
90 3 50 33     42 }
91 0         0 $crontab = \$contents;
92             }
93 3         18  
94             # vixie crontab refuses a crontab where the last line is missing a newline
95             # (but handles an empty file)
96             unless ($$crontab =~ /(?:\A|\n)\z/) {
97             $source = length $source ? " $source" : "";
98 18 100       79 croak("crontab$source doesn't end with newline");
99 2 100       7 }
100 2         180  
101             return bless _parser($crontab, $source, $env, $ignore), $class;
102             }
103 16         34  
104             my ($crontab, $source, $default_env, $ignore) = @_;
105             my $diag = length $source ? " of $source" : "";
106             my ($lineno, %env, @actions);
107 3897     3897   11347295 for my $line (split "\n", $$crontab) {
108 3897 100       12018 ++$lineno;
109 3897         5822  
110 3897         13620 next
111 7828         12040 if $ignore->{$lineno};
112              
113             # vixie crontab ignores leading tabs and spaces
114 7828 100       17631 # See skip_comments() in misc.c
115             # However the rest of the env parser uses isspace(), so will skip more
116             # whitespace characters. I guess this is because the parser was
117             # rewritten for version 4, and the more modern code can assume ANSI C.
118             $line =~ s/\A[ \t]+//;
119              
120             next
121 7793         25690 if $line =~ /\A(?:#|\z)/;
122              
123             # load_env() is attempted first
124 7793 100       19918 # Its parser has some quirks, which I have attempted to faithfully copy:
125             if ($line =~ /\A
126             (?:
127             # If ' opens, a second *must* be found to close
128 7764 100       44007 ' (*COMMIT) (?<name>[^=']*) '
    100          
129             |
130             " (*COMMIT) (?<name>[^="]*) "
131             |
132             # The C parser accepts empty variable names
133             (?<name>[^=\s\013]*)
134             )
135             [\s\013]* = [\s\013]*
136             (?:
137             # If ' opens, a second *must* be found to close
138             # *and* only trailing whitespace is permitted
139             ' (*COMMIT) (?<value>[^']*) '
140             |
141             " (*COMMIT) (?<value>[^"]*) "
142             |
143             # The C parser does not accept empty values
144             (?<value>.+?)
145             )
146             [\s\013]*
147             \z
148             /x) {
149             $env{$+{name}} = $+{value};
150             }
151             # else it gets passed load_entry()
152 2     2   873 elsif ($line =~ /\A\@reboot[\t ]/) {
  2         677  
  2         1073  
  3858         39087  
153             # We can't handle this, as we don't know when a reboot is
154             next;
155             } else {
156             my ($time, $truetime, $command);
157 1         4 if ($line =~ /\A\@([^\t ]+)[\t ]+(.*)\z/) {
158             $command = $2;
159 3905         5458 $time = '@' . $1;
160 3905 100       19861 $truetime = $aliases{$1};
    100          
161 8         20 croak("Unknown special string \@$1 at line $lineno$diag")
162 8         30 unless $truetime;
163 8         18 } elsif ($line =~ /\A
164 8 100       187 (
165             [*0-9]\S* [\t ]+
166             [*0-9]\S* [\t ]+
167             [*0-9]\S* [\t ]+
168             \S+ [\t ]+
169             \S+
170             )
171             [\t ]+
172             (
173             # vixie cron explicitly forbids * here:
174             [^*].*
175             )
176             \z
177             /x) {
178             $command = $2;
179             $time = $truetime = $1;
180             } else {
181 3882         9300 croak("Can't parse '$line' at line $lineno$diag");
182 3882         8095 }
183              
184 15         1020 my $whenever = try {
185             Algorithm::Cron->new(
186             base => 'utc',
187             crontab => $truetime,
188 3889     3889   163882 );
189             } catch {
190             croak("Can't parse time '$truetime' at line $lineno$diag: $_");
191             };
192              
193 0     0   0 my %entry = (
194 3889         31277 file => $source,
195             lineno => $lineno,
196 3889         426269 when => $time,
197             command => $command,
198             whenever => $whenever,
199             );
200              
201             my (@unset, %set);
202             for my $key (keys %$default_env) {
203             push @unset, $key
204 3889         6695 unless defined $env{$key};
205 3889         10206 }
206             for my $key (keys %env) {
207 0 0       0 $set{$key} = $env{$key}
208             unless defined $default_env->{$key} && $default_env->{$key} eq $env{$key};
209 3889         7471 }
210             $entry{unset} = [sort @unset]
211 3858 50 33     14023 if @unset;
212             $entry{env} = \%set
213 3889 50       8712 if %set;
214              
215 3889 100       8887 push @actions, \%entry;
216             }
217             }
218 3889         11483 return \@actions;
219             }
220              
221 3881         13102 # "actions", "entries", "events"?
222             # Vixie crontab parses these with load_entry() and %ENV setting with load_env(),
223             # so we're refer to them as entries:
224             my $self = shift;
225             return @$self;
226             }
227              
228 15     15 1 24 =head1 NAME
229 15         40  
230             Cron::Sequencer::Parser
231              
232             =head1 SYNOPSIS
233              
234             my $crontab = Cron::Sequencer::Parser->new("/path/to/crontab");
235              
236             =head1 DESCRIPTION
237              
238             This class parses a single crontab and converts it to a form that
239             C<Cron::Sequencer> can use.
240              
241             =head1 METHODS
242              
243             =head2 new
244              
245             C<new> takes a single argument representing a crontab file to parse. Various
246             formats are supported:
247              
248             =over 4
249              
250             =item plain scalar
251              
252             A file on disk
253              
254             =item reference to a scalar
255              
256             The contents of the crontab (as a single string of multiple lines)
257              
258             =item reference to a hash
259              
260             =over 4
261              
262             =item crontab
263              
264             The contents of a crontab, as a single string of multiple lines.
265             (Not a reference to a scalar containing this)
266              
267             =item source
268              
269             A file on disk. If both C<crontab> and C<source> are provided, then C<source>
270             is only used as the name of the crontab in output (and errors). No attempt is
271             made to read the file from disk.
272              
273             =item env
274              
275             Default values for environment variables set in the crontab, as a reference to
276             an array of strings in the form C<KEY=VALUE>. See below for examples.
277              
278             =item ignore
279              
280             Lines in the crontab to completely ignore, as an array of integers. These are
281             processed as the first step in the parser, so it's possible to ignore all of
282              
283             =over 4
284              
285             =item *
286              
287             command entries (particularly "chatty" entries such as C<* * * * *>)
288              
289             =item *
290              
291             setting environment variables
292              
293             =item *
294              
295             lines with syntax errors that otherwise would abort the parse
296              
297             =back
298              
299             =back
300              
301             This is the most flexible format. At least one of C<source> or C<crontab> must
302             be specified.
303              
304             =back
305              
306             The only way to provide C<env> or C<ignore> options is to pass a hashref.
307              
308             =head2 entries
309              
310             Returns a list of the crontab's command entries as data structures. Used
311             internally by C<Cron::Sequencer> and subject to change.
312              
313             =head1 EXAMPLES
314              
315             For this input
316              
317             POETS=Friday
318             30 12 * * * lunch!
319              
320             with default constructor options this code:
321              
322             use Cron::Sequencer;
323             use Data::Dump;
324            
325             my $crontab = Cron::Sequencer->new({source => "reminder"});
326             dd([$crontab->sequence(45000, 131400)]);
327              
328             would generate this output:
329              
330             [
331             [
332             {
333             command => "lunch!",
334             env => { POETS => "Friday" },
335             file => "reminder",
336             lineno => 2,
337             time => 45000,
338             unset => ["HUMP"],
339             when => "30 12 * * *",
340             },
341             ],
342             ]
343              
344             If we specify two environment variables:
345              
346             my $crontab = Cron::Sequencer->new({source => "reminder",
347             env => [
348             "POETS=Friday",
349             "HUMP=Wednesday"
350             ]});
351              
352             the output is:
353              
354             [
355             [
356             {
357             command => "lunch!",
358             env => undef,
359             file => "reminder",
360             lineno => 2,
361             time => 45000,
362             unset => ["HUMP"],
363             when => "30 12 * * *",
364             },
365             ],
366             ]
367              
368             (because C<POETS> matches the default, but C<HUMP> was never set in the crontab)
369              
370             If we ignore the first line:
371              
372             my $crontab = Cron::Sequencer->new({source => "reminder",
373             ignore => [1]});
374              
375             [
376             [
377             {
378             command => "lunch!",
379             env => undef,
380             file => "reminder",
381             lineno => 2,
382             time => 45000,
383             unset => undef,
384             when => "30 12 * * *",
385             },
386             ],
387             ]
388              
389             we ignore the line in the crontab that sets the environment variable.
390              
391             For completeness, if we ignore the line that declares an event:
392              
393             my $crontab = Cron::Sequencer->new({source => "reminder",
394             ignore => [2]});
395              
396              
397             there's nothing to output:
398              
399             []
400              
401             =head1 LICENSE
402              
403             This library is free software; you can redistribute it and/or modify it under
404             the same terms as Perl itself. If you would like to contribute documentation,
405             features, bug fixes, or anything else then please raise an issue / pull request:
406              
407             https://github.com/Humanstate/cron-sequencer
408              
409             =head1 AUTHOR
410              
411             Nicholas Clark - C<nick@ccl4.org>
412              
413             =cut
414              
415             1;