File Coverage

blib/lib/Test/Spelling.pm
Criterion Covered Total %
statement 90 143 62.9
branch 14 50 28.0
condition 4 15 26.6
subroutine 19 25 76.0
pod 8 11 72.7
total 135 244 55.3


line stmt bran cond sub pod time code
1             package Test::Spelling;
2              
3 4     4   282716 use strict;
  4         42  
  4         117  
4 4     4   29 use warnings;
  4         10  
  4         127  
5              
6 4     4   20 use Exporter qw(import);
  4         8  
  4         121  
7 4     4   1916 use Pod::Spell;
  4         41243  
  4         126  
8 4     4   29 use Test::Builder;
  4         9  
  4         74  
9 4     4   21 use Text::Wrap;
  4         8  
  4         264  
10 4     4   26 use File::Spec;
  4         8  
  4         86  
11 4     4   2016 use IPC::Run3;
  4         129977  
  4         254  
12 4     4   32 use Symbol 'gensym';
  4         12  
  4         6195  
13              
14             our $VERSION = '0.23';
15              
16             our @EXPORT = qw(
17             pod_file_spelling_ok
18             all_pod_files_spelling_ok
19             add_stopwords
20             set_spell_cmd
21             all_pod_files
22             set_pod_file_filter
23             has_working_spellchecker
24             set_pod_parser
25             );
26              
27             my $TEST = Test::Builder->new;
28              
29             my $SPELLCHECKER;
30             my $FILE_FILTER = sub { 1 };
31             my $POD_PARSER;
32             our %ALL_WORDS;
33              
34             sub spellchecker_candidates {
35             # if they've specified a spellchecker, use only that one
36 7 100   7 0 33 return $SPELLCHECKER if $SPELLCHECKER;
37              
38             return (
39 3         10 'spell', # for back-compat, this is the top candidate ...
40             'aspell list -l en -p /dev/null', # ... but this should become first soon
41             'ispell -l',
42             'hunspell -l',
43             );
44             }
45              
46             sub has_working_spellchecker {
47 3     3 1 959 my $dryrun_results = _get_spellcheck_results("dry run", 1);
48              
49 3 50       41 if (ref $dryrun_results) {
50 3         29 return;
51             }
52              
53 0         0 return $SPELLCHECKER;
54             }
55              
56             sub _get_spellcheck_results {
57 7     7   553 my $document = shift;
58 7         21 my $dryrun = shift;
59              
60 7         28 my @errors;
61              
62 7         23 for my $spellchecker (spellchecker_candidates()) {
63 16         30 my @words;
64 16         28 my $ok = eval {
65              
66 16         30 my ($spellcheck_results, $errors);
67 16         109 IPC::Run3::run3($spellchecker, \$document, \$spellcheck_results, \$errors);
68              
69 4         25999 @words = split /\n/, $spellcheck_results;
70              
71 4 100       58 die "spellchecker had errors: $errors" if length $errors;
72              
73 3         25 1;
74             };
75              
76 16 100       36559 if ($ok) {
77             # remember the one we used, so that it's consistent for all the files
78             # this run, and we don't keep retrying the same spellcheckers that will
79             # never work. also we need to expose the spellchecker we're using in
80             # has_working_spellchecker
81 3 50       40 set_spell_cmd($spellchecker)
82             if !$SPELLCHECKER;
83 3         55 return @words;
84             }
85              
86 13         132 push @errors, "Unable to run '$spellchecker': $@";
87             }
88              
89             # no working spellcheckers during a dry run
90 4 100       147 return \"no spellchecker" if $dryrun;
91              
92             # no working spellcheckers; report all the errors
93 1         25 require Carp;
94             Carp::croak
95             "Unable to find a working spellchecker:\n"
96 1         14 . join("\n", map { " $_\n" } @errors)
  1         518  
97             }
98              
99             sub invalid_words_in {
100 4     4 0 10 my $file = shift;
101              
102 4         7 my $document = '';
103 4     1   83 open my $handle, '>', \$document;
  1         8  
  1         1  
  1         10  
104 4     1   858 open my $infile, '<:encoding(UTF-8)', $file;
  1         6  
  1         2  
  1         4  
105              
106             # save digested POD to the string $document
107 4         12080 get_pod_parser()->parse_from_filehandle($infile, $handle);
108              
109 4         393232 my @words = _get_spellcheck_results($document);
110              
111 3         40 chomp for @words;
112 3         287 return @words;
113             }
114              
115             sub pod_file_spelling_ok {
116 4     4 1 304 my $file = shift;
117 4   33     33 my $name = shift || "POD spelling for $file";
118              
119 4 50       98 if (!-r $file) {
120 0         0 $TEST->ok(0, $name);
121 0         0 $TEST->diag("$file does not exist or is unreadable");
122 0         0 return;
123             }
124              
125 4         25 my @words = invalid_words_in($file);
126              
127             # remove stopwords, select unique errors
128 3         22 my $WL = \%Pod::Wordlist::Wordlist;
129 3   33     14 @words = grep { !$WL->{$_} && !$WL->{lc $_} } @words;
  1         50  
130 3         23 $ALL_WORDS{$_}++ for @words;
131 3         7 my %seen;
132 3         17 @seen{@words} = ();
133 3         16 @words = sort keys %seen;
134              
135             # emit output
136 3         22 my $ok = @words == 0;
137 3         137 $TEST->ok($ok, "$name");
138 3 100       3073 if (!$ok) {
139 1         8 $TEST->diag("Errors:\n" . join '', map { " $_\n" } @words);
  1         40  
140             }
141              
142 3         223 return $ok;
143             }
144              
145             sub all_pod_files_spelling_ok {
146 0     0 1 0 my @files = all_pod_files(@_);
147 0         0 local %ALL_WORDS;
148 0 0       0 if (!has_working_spellchecker()) {
149 0         0 return $TEST->plan(skip_all => "no working spellchecker found");
150             }
151              
152 0         0 $TEST->plan(tests => scalar @files);
153              
154 0         0 my $ok = 1;
155 0         0 for my $file (@files) {
156 0         0 local $Test::Builder::Level = $Test::Builder::Level + 1;
157 0 0       0 pod_file_spelling_ok($file) or undef $ok;
158             }
159 0 0       0 if ( keys %ALL_WORDS ) {
160             # Invert k => v to v => [ k ]
161 0         0 my %values;
162 0         0 push @{ $values{ $ALL_WORDS{$_} } }, $_ for keys %ALL_WORDS;
  0         0  
163              
164 0         0 my $labelformat = q[%6s: ];
165 0         0 my $indent = q[ ] x 10;
166              
167             $TEST->diag(qq[\nAll incorrect words, by number of occurrences:\n] .
168 0         0 join qq[\n], map { wrap( ( sprintf $labelformat, $_ ), $indent, join q[, ], sort @{ $values{$_} } ) }
  0         0  
169 0         0 sort { $a <=> $b } keys %values
  0         0  
170             );
171             }
172 0         0 return $ok;
173             }
174              
175             sub all_pod_files {
176 0 0   0 1 0 my @queue = @_ ? @_ : _starting_points();
177 0         0 my @pod;
178              
179 0         0 while (@queue) {
180 0         0 my $file = shift @queue;
181              
182             # recurse into subdirectories
183 0 0       0 if (-d $file) {
184 0 0       0 opendir(my $dirhandle, $file) or next;
185 0         0 my @newfiles = readdir($dirhandle);
186 0         0 closedir $dirhandle;
187              
188 0         0 @newfiles = File::Spec->no_upwards(@newfiles);
189 0 0       0 @newfiles = grep { $_ ne "CVS" && $_ ne ".svn" } @newfiles;
  0         0  
190              
191 0         0 push @queue, map "$file/$_", @newfiles;
192             }
193              
194             # add the file if it meets our criteria
195 0 0       0 if (-f $file) {
196 0 0       0 next unless _is_perl($file);
197 0 0       0 next unless $FILE_FILTER->($file);
198 0         0 push @pod, $file;
199             }
200             }
201              
202 0         0 return @pod;
203             }
204              
205             sub _starting_points {
206 0 0   0   0 return 'blib' if -d 'blib';
207 0         0 return 'lib';
208             }
209              
210             sub _is_perl {
211 0     0   0 my $file = shift;
212              
213 0 0       0 return 1 if $file =~ /\.PL$/;
214 0 0       0 return 1 if $file =~ /\.p(l|lx|m|od)$/;
215 0 0       0 return 1 if $file =~ /\.t$/;
216              
217 0 0       0 open my $handle, '<', $file or return;
218 0         0 my $first = <$handle>;
219              
220 0 0 0     0 return 1 if defined $first && ($first =~ /^#!.*perl/);
221              
222 0         0 return 0;
223             }
224              
225             sub add_stopwords {
226 1     1 1 11 for (@_) {
227             # explicit copy so we don't modify constants as in add_stopwords("SQLite")
228 1         7 my $word = $_;
229              
230             # XXX: the processing this performs is to support "perl t/spell.t 2>>
231             # t/spell.t" which is bunk. in the near future the processing here will
232             # become more modern
233 1         19 $word =~ s/^#?\s*//;
234 1         9 $word =~ s/\s+$//;
235 1 50 33     24 next if $word =~ /\s/ or $word =~ /:/;
236 1         15 $Pod::Wordlist::Wordlist{$word} = 1;
237             }
238             }
239              
240             sub set_spell_cmd {
241 2     2 1 103 $SPELLCHECKER = shift;
242             }
243              
244             sub set_pod_file_filter {
245 0     0 1 0 $FILE_FILTER = shift;
246             }
247              
248             # A new Pod::Spell object should be used for every file; people
249             # providing custom pod parsers will have to do this themselves
250             sub get_pod_parser {
251 4   33 4 0 108 return $POD_PARSER || Pod::Spell->new;
252             }
253              
254             sub set_pod_parser {
255 0     0 1 0 $POD_PARSER = shift;
256             }
257              
258             1;
259              
260             __END__