File Coverage

blib/lib/Test/Changes/Strict/Simple.pm
Criterion Covered Total %
statement 214 219 97.7
branch 125 138 90.5
condition 40 50 80.0
subroutine 21 21 100.0
pod 1 1 100.0
total 401 429 93.4


line stmt bran cond sub pod time code
1             package Test::Changes::Strict::Simple;
2              
3 12     12   1463294 use 5.010;
  12         48  
4 12     12   64 use strict;
  12         37  
  12         357  
5 12     12   54 use warnings;
  12         32  
  12         636  
6 12     12   5387 use parent 'Exporter';
  12         3753  
  12         104  
7             our @EXPORT = qw(changes_strict_ok);
8              
9 12     12   5912 use version;
  12         25930  
  12         72  
10              
11 12     12   988 use Test::Builder;
  12         27  
  12         323  
12              
13 12     12   6743 use Time::Local;
  12         24066  
  12         861  
14 12     12   94 use Carp;
  12         24  
  12         857  
15 12     12   6067 use POSIX qw(strftime);
  12         107134  
  12         74  
16              
17             our $VERSION = '0.06';
18              
19             #
20             # The use of global variables is acceptable, as we never check more than one
21             # Changes file at a time.
22             #
23             my $TB = Test::Builder->new;
24              
25             my $Ver_Re = qr/\d+\.\d+/;
26              
27             use constant {
28             NOW => time,
29 12         29 map { $_ => $_ } qw(
  96         42528  
30             st_chlog_head
31             st_empty_after_head
32             st_version
33             st_empty_after_version
34             st_item
35             st_item_cont
36             st_empty_after_item
37             st_EOF
38             )
39 12     12   21225 };
  12         21  
40              
41              
42             my $Test_Name = "Changes file passed strict checks";
43             my $Empty_Line_After_Version;
44             my $Chk_Dots = 1;
45             my $Reverse;
46              
47             sub import {
48 12     12   102 my $class = shift;
49              
50 12         30 my %opts; # Option key/value pairs.
51             my @exports; # Requested symbols to export.
52              
53             # Separate options (starting with '-') from export symbols.
54 12         67 while (@_) {
55 9 50 33     92 if (@_ >= 2 && $_[0] =~ /^-/) {
56 9         34 my ($key, $val) = splice(@_, 0, 2);
57 9         33 $key =~ s/^-//; # Remove leading dash.
58 9         37 $opts{$key} = $val;
59             } else {
60 0         0 push(@exports, shift);
61             }
62             }
63              
64             # Process known options.
65 12         34 my $no_export = delete $opts{no_export};
66 12         32 $Empty_Line_After_Version = delete $opts{empty_line_after_version};
67 12 100       36 if (exists($opts{version_re})) {
68 2         9 $Ver_Re = delete $opts{version_re};
69 2 50       7 croak("-version_re: option has an invalid value") if ref($Ver_Re) ne "Regexp";
70             }
71 12 100       31 $Chk_Dots = delete $opts{check_dots} if exists($opts{check_dots});
72 12         21 $Reverse = delete $opts{reverse_version_order};
73              
74             # Fail on unknown options.
75 12 50       33 croak("Unknown option(s): " . join(", ", keys %opts)) if %opts;
76              
77             # Export logic.
78              
79 12 50       49 if (@exports) {
    100          
80             # Explicit symbol list provided ==> export exactly those.
81 0         0 __PACKAGE__->export_to_level(1, $class, @exports);
82             } elsif ($no_export) {
83             # -no_export requested ==> export nothing.
84 1         1854 return;
85             } else {
86             # No arguments ==> preserve standard Exporter behaviour.
87             # This keeps the distinction between:
88             # use Test::Changes::Strict::Simple;
89             # use Test::Changes::Strict::Simple ();
90 11         3746 __PACKAGE__->export_to_level(1, $class, @EXPORT);
91             }
92             }
93              
94              
95             # ------------------
96              
97             sub changes_strict_ok {
98 123     123 1 3124801 my %args = @_;
99 123   100     543 my $changes_file = delete($args{changes_file}) // "Changes";
100 123         253 my $release_today = delete($args{release_today});
101 123         180 my $mod_version;
102 123 100       354 if (exists($args{module_version})) {
103 3   66     36 $mod_version = delete($args{module_version}) // croak("module_version is undef");
104             }
105 122 50       276 croak("Unknown arguments(s): " . join(", ", keys %args)) if %args;
106              
107 122         207 my $test_name = "Changes file passed strict checks";
108              
109 122         190 my @lines;
110 122 100       320 _read_file($changes_file, \@lines) or return;
111              
112 114 100       403 _check_and_clean_spaces(\@lines) or return;
113              
114 102         302 my $trailing_empty_lines = _trim_trailing_empty_lines(\@lines);
115 102 100       262 _check_title(\@lines) or return;
116              
117 92         153 my @versions;
118 92 100       237 _check_changes(\@lines, \@versions) or return;
119 27 100       88 @versions = reverse(@versions) if $Reverse;
120 27 100       101 _check_version_monotonic(\@versions) or return;
121 19 100       64 if ($mod_version) {
122 2         6 my $top_ver = $versions[0]->{version_str};
123 2 100       16 $mod_version eq $top_ver or
124             return _not_ok("Highest version in changelog is $top_ver, not $mod_version as expected");
125             }
126 18 50       53 if ($release_today) {
127 0         0 my $top_date = $versions[0]->{date};
128 0 0       0 $top_date eq strftime('%Y-%m-%d', localtime) or
129             return _not_ok("The date of the latest version is not today");
130             }
131              
132 18 100       104 my $ok = $TB->ok($trailing_empty_lines <= 3, $Test_Name) or
133             $TB->diag("more than 3 empty lines at end of file");
134 18         8059 return $ok;
135             }
136              
137              
138             sub _read_file {
139 122     122   256 my ($fname, $lines) = @_;
140 122         230 local $Test::Builder::Level = $Test::Builder::Level + 1;
141 122 100       2717 -e $fname or return _not_ok("The '$fname' file does not exist");
142 120 50 66     10901 -f $fname && -r $fname && -T $fname or
      66        
143             return _not_ok("The '$fname' file is not a readable text file");
144 118 50       3730 open(my $fh, '<', $fname) or return _not_ok("Cannot open '$fname': $!");
145 118 100       1865 @$lines = <$fh> or return _not_ok("The '$fname' file empty");
146 116 100       625 substr($lines->[-1], -1) eq "\n" or return _not_ok("'$fname': no newline at end of file");
147 114         320 chomp(@$lines);
148 114         1366 return !0;
149             }
150              
151              
152             sub _trim_trailing_empty_lines {
153 102     102   198 my ($aref) = @_;
154 102         259 my $removed = 0;
155              
156 102   66     449 while (@$aref && $aref->[-1] eq '') {
157 31         50 pop(@$aref);
158 31         94 $removed++;
159             }
160 102         293 push(@$aref, q{}); # We need exactly 1 trailing empty line.
161 102         183 return $removed;
162             }
163              
164              
165             sub _check_and_clean_spaces {
166 114     114   259 my ($lines) = @_;
167 114         215 local $Test::Builder::Level = $Test::Builder::Level + 1;
168 114         200 my (@other_spaces, @trailing_spaces);
169 114         355 for (my $i = 1; $i <= @$lines; ++$i) {
170 943 100       2295 $lines->[$i - 1] =~ s/[^\S\ ]/\ /g and push(@other_spaces, $i);
171 943 100       3035 $lines->[$i - 1] =~ s/\s+$// and push(@trailing_spaces, $i);
172             }
173 114         176 my $diag;
174 114 100       256 if (@other_spaces) {
175 6 100       22 my $plural = @other_spaces > 1 ? "s" : "";
176 6         29 $diag = "Non-space white character found at line$plural " . join(', ', @other_spaces);
177             }
178 114 100       240 if (@trailing_spaces) {
179 8 100       67 my $plural = @trailing_spaces > 1 ? "s" : "";
180 8   66     60 $diag = join('. ',
181             ($diag // ()),
182             "Trailing white character at line$plural " . join(', ', @trailing_spaces));
183             }
184 114 100       460 return $diag ? _not_ok($diag) : !0;
185             }
186              
187              
188             sub _check_title {
189 102     102   168 my ($lines) = @_;
190 102         176 local $Test::Builder::Level = $Test::Builder::Level + 1;
191 102         218 my $test_name = "Header line ok";
192 102         1494 my $ok = $lines->[0] =~ qr/
193             ^
194             Revision\ history\ for\ (?:
195             (?:perl\ )?
196             (?:
197             (?:module\ \w+(?:::\w+)*)
198             |
199             (?:distribution\ \w+(?:-\w+)*)
200             )
201             )
202             $
203             /x;
204 102 100       557 return $ok ? !0 : _not_ok("Missing or malformed 'Revision history ...' at line 1");
205             }
206              
207              
208             sub _check_changes {
209 92     92   188 my ($lines, $versions) = @_;
210 92         160 local $Test::Builder::Level = $Test::Builder::Level + 1;
211 92 100       1064 my %states = (
212             +st_chlog_head => [st_empty_after_head],
213             +st_empty_after_head => [st_version],
214             $Empty_Line_After_Version ? (
215             +st_version => [st_empty_after_version],
216             +st_empty_after_version => [st_item],
217             )
218             : (
219             +st_version => [st_item],
220             ),
221             +st_item => [st_item, st_item_cont, st_empty_after_item, st_EOF],
222             +st_item_cont => [st_item, st_item_cont, st_empty_after_item],
223             +st_empty_after_item => [st_version, st_EOF],
224             +st_EOF => [],
225             );
226 92         421 $_ = { map { $_ => undef } @$_ } for values %states;
  1147         2746  
227 92 100       333 my %empty_line_st = (+st_chlog_head => st_empty_after_head,
228             +st_item => st_empty_after_item,
229             $Empty_Line_After_Version ? (+st_version => st_empty_after_version) : (),
230             );
231 92         225 my %item_line = (+st_item => undef, +st_item_cont => undef);
232 92         128 my $indent;
233 92         154 my $state = st_chlog_head;
234 92         160 my %errors;
235 92     88   365 my $err = sub { push(@{$errors{$_[0]}}, $_[1]); };
  88         127  
  88         318  
236 92         245 my $i = 2;
237 92         256 for (; $i <= @$lines; ++$i) {
238 643         1102 my $line = $lines->[$i - 1];
239 643 100       2651 if ($line eq "") {
    100          
    100          
    50          
240 240         328 my $old_state = $state;
241             $err->($i - 1, "missing dot at end of line")
242 240 100 100     1156 if $Chk_Dots && (exists($item_line{$old_state}) && $lines->[$i - 2] !~ /\.$/);
      100        
243              
244              
245 240 100 66     724 if (exists($item_line{$old_state}) || $old_state eq st_empty_after_item) {
246 100         181 my $next_line = $lines->[$i];
247 100 100 100     447 if (defined($next_line) && $next_line !~ /^\S/) {
248 6         16 $err->($i, "unexpected empty line");
249 6         13 last;
250             }
251 94         235 $state = st_empty_after_item;
252             } else {
253 140 100       562 $state = $empty_line_st{$old_state} or do { $err->($i, "unexpected empty line");
  5         13  
254 5         11 last;
255             };
256             }
257             } elsif ($line =~ /^[^-\s]/) {
258 143 100       463 exists($states{$state}->{+st_version}) or do { $err->($i, "unexpected version line");
  5         16  
259 5         10 last;
260             };
261 138         269 $state = st_version;
262 138         334 my $result = _version_line_check($line);
263 138 100       1198 if (ref($result)) {
264 117         319 $result->{line} = $i;
265 117         429 push(@$versions, $result);
266             } else {
267 21         73 $err->($i, "version check: $result");
268 21         51 last;
269             }
270             } elsif ($line =~ s/^(\s*)-//) {
271 175         378 my $heading_spaces = $1;
272 175 100       439 exists($states{$state}->{+st_item}) or do { $err->($i, "unexpected item line");
  2         8  
273 2         5 last;
274             };
275             $err->($i - 1, "missing dot at end of line")
276 173 100 100     802 if $Chk_Dots && (exists($item_line{$state}) && $lines->[$i - 2] !~ /\.$/);
      100        
277 173 100       527 $line =~ /^ \S+/ or do { $err->($i, "invalid item content");
  6         18  
278 6         15 last;
279             };
280 167         294 $state = st_item;
281 167 100       380 if ($heading_spaces eq "") {
    100          
282 2         8 $err->($i, "no indentation");
283             } elsif (defined($indent)) {
284 111 100       341 $err->($i, "wrong indentation") if length($heading_spaces) != $indent;
285             } else {
286 54         147 $indent = length($heading_spaces);
287             }
288             } elsif ($line =~ /^(\s+)[^-\s]/) {
289 85 100       201 exists($states{$state}->{+st_item_cont}) or do { $err->($i, "unexpected item continuation");
  7         23  
290 7         16 last;
291             };
292 78         134 my $state = st_item_cont;
293 78         202 my $heading_spaces = $1;
294 78 100       234 length($heading_spaces) == $indent + 2 or do { $err->($i, "wrong indentation"); };
  8         21  
295             }
296             }
297 92         147 my $diag;
298 92 100 66     378 if (%errors || ($i > @$lines && !exists($states{$state}->{+st_EOF}))) {
      100        
299 65 100       119 if (%errors) {
300             $diag = join("\n",
301 82         195 (map {"Line $_: " . join("; ", @{$errors{$_}})}
  82         413  
302 62         234 (sort { $a <=> $b } keys(%errors))));
  30         76  
303             }
304             $diag = join("\n", ($diag // ()), "Unexpected end of file")
305 65 100 66     230 if ($i > @$lines && !exists($states{$state}->{+st_EOF}));
      100        
306             }
307 92 100       576 return $diag ? _not_ok($diag) : !0;
308             }
309              
310              
311             sub _check_version_monotonic {
312 27     27   59 my ($versions) = @_;
313 27         57 local $Test::Builder::Level = $Test::Builder::Level + 1;
314 27         43 my $diag;
315 27 50       65 if (@$versions) {
316 27         95 for (my $i = 0; $i < $#$versions; ++$i) {
317 31         62 my ($v1, $e1) = @{$versions->[$i] }{qw(version epoch)};
  31         98  
318 31         94 my ($v2, $e2) = @{$versions->[$i + 1]}{qw(version epoch)};
  31         84  
319 31 100       201 unless ($v1 > $v2) {
320 5         13 my $vs1 = $versions->[$i]->{version_str};
321 5         15 my $vs2 = $versions->[$i + 1]->{version_str};
322 5 100       30 $diag = $v1 == $v2 ? "$vs1: duplicate version" : "$vs1 vs. $vs2: wrong order of versions";
323 5         17 last;
324             }
325 26 100       114 if ($e1 < $e2) {
326 3         11 my $d1 = $versions->[$i]->{date};
327 3         11 my $d2 = $versions->[$i + 1]->{date};
328 3         10 $diag = "date $d1 < $d2: chronologically inconsistent";
329 3         11 last;
330             }
331             }
332             } else {
333 0         0 $diag = "No versions to check";
334             }
335 27 100       95 return $diag ? _not_ok($diag) : !0;
336             }
337              
338              
339             # ---------------------------- Helper functions ---------------------------------------
340              
341             sub _version_line_check {
342             # Line is already trimmed!
343 138     138   227 my $line = shift;
344 138 100       596 (my ($ver_str, $date) = split(/\s+/, $line)) == 2 or return("not exactly two values");
345 134 100       1527 $ver_str =~ /^$Ver_Re$/ or return "$ver_str: invalid version";
346 129         273 my $version;
347 129 50       214 eval { $version = version->parse($ver_str); 1; } or return("$ver_str: invalid version");
  129         1080  
  129         385  
348 129 100       798 $date =~ /^(\d{4})-(\d{2})-(\d{2})$/ or return("$date: invalid date: wrong format");
349 125         519 my ($y, $m, $d) = ($1, $2, $3);
350 125         171 my $epoch;
351 125 100       198 eval {
352 125         604 $epoch = Time::Local::timelocal(0, 0, 0, $d, $m - 1, $y);
353 121         9528 1;
354             } or return "'$date': invalid date";
355 121 100       306 $y >= 1987 or return "$date: before Perl era";
356 119 100       371 $epoch <= NOW or return "$date: date is in the future.";
357 117         741 return { version => $version,
358             version_str => $ver_str,
359             date => $date,
360             epoch => $epoch};
361             }
362              
363              
364             #---------------------------------------------------------
365              
366             sub _not_ok {
367 104     104   224 my ($diag) = @_;
368 104         210 local $Test::Builder::Level = $Test::Builder::Level + 1;
369 104         638 $TB->ok(0, $Test_Name);
370 104         102922 $TB->diag($diag);
371 104         46490 return !1;
372             }
373              
374              
375             1; # End of Test::Changes::Strict::Simple
376              
377              
378              
379             __END__