File Coverage

blib/lib/Lingua/Anagrams.pm
Criterion Covered Total %
statement 347 349 99.4
branch 118 140 84.2
condition 33 44 75.0
subroutine 30 30 100.0
pod 5 5 100.0
total 533 568 93.8


line stmt bran cond sub pod time code
1             package Lingua::Anagrams;
2             $Lingua::Anagrams::VERSION = '0.018';
3             # ABSTRACT: pure Perl anagram finder
4              
5 1     1   668 use strict;
  1         2  
  1         30  
6 1     1   4 use warnings;
  1         2  
  1         26  
7              
8 1     1   12532 use List::MoreUtils qw(uniq);
  1         2520  
  1         5820  
9              
10              
11             # don't cache anagrams for bigger character counts than this
12             our $LIMIT = 20;
13              
14             # some global variables to be localized
15             # used to limit time spent copying values
16             our ( $limit, $known, $trie, %cache, $cleaner, @jumps, @indices );
17              
18              
19             sub new {
20 12     12 1 8330 my $class = shift;
21 12         20 my $wl = shift;
22 12 50       40 die 'first parameter expected to be an array reference'
23             unless ref $wl eq 'ARRAY';
24 12         28 my %params = _make_opts(@_);
25 12   33     51 $class = ref $class || $class;
26 12   50     57 local $cleaner = $params{clean} // \&_clean;
27 12         55 my @word_lists;
28 12 100       32 if ( ref $wl->[0] eq 'ARRAY' ) {
29 5         12 @word_lists = @$wl;
30             }
31             else {
32 7         14 @word_lists = ($wl);
33             }
34 12         32 _validate_lists( \@word_lists );
35 7         19 my $translator = { '' => 0 };
36 7         12 $translator->{$_} = scalar keys %$translator for @{ $word_lists[-1] };
  7         54  
37 7         13 my $offset; # used to reduce number of undef cells in tries and elsewhere
38 7         10 for my $w ( @{ $word_lists[-1] } ) {
  7         15  
39 31         100 my @ords = map ord, split //, $w;
40 31         55 for my $o (@ords) {
41 44 100       67 if ( defined $offset ) {
42 37 100       119 $offset = $o if $o < $offset;
43             }
44             else {
45 7         22 $offset = $o;
46             }
47             }
48             }
49 7         13 --$offset;
50 7         8 my @tries;
51 7         11 for my $words (@word_lists) {
52 8         20 my ( $trie, $known ) = _trieify( $words, $translator, $offset );
53 8         29 push @tries, [ $trie, $known ];
54             }
55 7         17 $translator = [ '', @{ $word_lists[-1] } ];
  7         26  
56 7   66     132 return bless {
      100        
57             limit => $params{limit} // $LIMIT,
58             sorted => $params{sorted} // 0,
59             min => $params{min},
60             clean => $cleaner,
61             tries => \@tries,
62             translator => $translator,
63             offset => $offset,
64             },
65             $class;
66             }
67              
68             # there should be no empty lists and each list should be subsumed
69             # by the next
70             sub _validate_lists {
71 12     12   18 my $lists = shift;
72 12         32 for my $i ( 0 .. $#$lists ) {
73 40   50     93 my @list = uniq grep length,
74 16         26 map { my $v = $_ // ''; $cleaner->($v); $v } @{ $lists->[$i] };
  40         70  
  40         201  
  16         40  
75 16 100       88 die 'empty list' unless @list;
76 14         46 $lists->[$i] = \@list;
77             }
78 10         27 for my $i ( 1 .. $#$lists ) {
79 4         13 my ( $prior, $list ) = @$lists[ $i - 1, $i ];
80 4 100       30 die 'lists misordered by length' if @$prior >= @$list;
81 2         5 my %set = map { $_ => 1 } @$list;
  5         14  
82 2         7 for my $word (@$prior) {
83 2 100       32 die 'smaller lists must be subsumed by larger' unless $set{$word};
84             }
85             }
86             }
87              
88             sub _trieify {
89 8     8   32 my ( $words, $translator, $offset ) = @_;
90 8         14 my $base = [];
91 8         10 my @known;
92 8         16 for my $word (@$words) {
93 32 50 50     97 next unless length( $word // '' );
94 32         86 my @chars = map { ord($_) - $offset } split //, $word;
  47         109  
95 32         78 _learn( \@known, \@chars );
96 32         76 _add( $base, \@chars, $word, $translator );
97             }
98 8         22 return $base, \@known;
99             }
100              
101             sub _learn {
102 32     32   46 my ( $known, $new ) = @_;
103 32         50 for my $i (@$new) {
104 47   100     185 $known->[$i] ||= 1;
105             }
106             }
107              
108             sub _add {
109 79     79   129 my ( $base, $chars, $word, $translator ) = @_;
110 79         144 my $i = shift @$chars;
111 79 100       136 if ($i) {
112 47   100     174 my $next = $base->[$i] //= [];
113 47         119 _add( $next, $chars, $word, $translator );
114             }
115             else { # store values in trie at the zero index
116 32         139 $base->[0] = $translator->{$word};
117             }
118             }
119              
120             # walk the trie looking for words you can make out of the current character count
121             sub _words_in {
122 71     71   122 my ( $counts, $total ) = @_;
123 71         81 my @words;
124 71         167 my @stack = ( [ 0, $trie ] );
125 71         89 while (1) {
126 698         891 my ( $c, $level ) = @{ $stack[-1] };
  698         1155  
127 698 100 100     4027 if ( $c == -1 || $c >= @$level ) {
128 160 100       352 last if @stack == 1;
129 89         110 pop @stack;
130 89         182 ++$total;
131 89         145 $c = \( $stack[-1][0] );
132 89         167 ++$counts->[$$c];
133 89         157 $$c = $jumps[$$c];
134             }
135             else {
136 538         747 my $l = $level->[$c];
137 538 100       1027 if ($l) { # trie holds corresponding node
138 388 100       871 if ($c) { # character
139 245 100       399 if ( $counts->[$c] ) {
140 149         332 push @stack, [ 0, $l ];
141 149         639 --$counts->[$c];
142 149         283 --$total;
143             }
144             else {
145 96         210 $stack[-1][0] = $jumps[$c];
146             }
147             }
148             else { # terminal
149 143         502 push @words, [ $l, [@$counts] ];
150 143 100       273 if ($total) {
151 83         184 $stack[-1][0] = $jumps[$c];
152             }
153             else {
154 60         75 pop @stack;
155 60         91 ++$total;
156 60         102 $c = \( $stack[-1][0] );
157 60         84 ++$counts->[$$c];
158 60         120 $$c = $jumps[$$c];
159             }
160             }
161             }
162             else { # try the next possible character
163 150         318 $stack[-1][0] = $jumps[$c];
164             }
165             }
166             }
167 71         212 \@words;
168             }
169              
170              
171             sub anagrams {
172 8     8 1 1780 my $self = shift;
173 8         15 my $phrase = shift;
174 8         21 my %opts = _make_opts(@_);
175 8         31 local ( $limit, $cleaner ) = @$self{qw(limit clean)};
176 8         18 $cleaner->($phrase);
177 8 50       20 return () unless length $phrase;
178 8         9 my ( $sort, $min );
179              
180 8 100       20 if ( exists $opts{sorted} ) {
181 3         7 $sort = $opts{sorted};
182             }
183             else {
184 5         11 $sort = $self->{sorted};
185             }
186 8 100       19 if ( exists $opts{min} ) {
187 2         4 $min = $opts{min};
188             }
189             else {
190 6         14 $min = $self->{min};
191             }
192 8   100     35 my $i = $opts{start_list} // 0;
193 8         10 my @pairs = @{ $self->{tries} };
  8         20  
194 8 100       20 if ($i) {
195 1 50       4 die "impossible index for start list: $i" unless defined $pairs[$i];
196 1 50       4 $i = @pairs + $i if $i < 0;
197 1         5 @pairs = @pairs[ $i .. $#pairs ];
198             }
199 8         16 my $offset = $self->{offset};
200 8         23 my $counts = _counts( $phrase, $offset );
201 8         11 my @translator = @{ $self->{translator} };
  8         31  
202 8         22 local @jumps = _jumps($counts);
203 8         23 local @indices = _indices($counts);
204 8         10 my @anagrams;
205 8         14 for my $pair (@pairs) {
206 8         26 local ( $trie, $known ) = @$pair;
207 8 50       18 next unless _all_known($counts);
208 8         18 local %cache = ();
209 8         21 @anagrams = _anagramize($counts);
210 8 50       27 next unless @anagrams;
211 8 50 66     27 next if $min and @anagrams < $min;
212 8         31 last;
213             }
214 8         13 @anagrams = map { [ @translator[@$_] ] } @anagrams;
  22         73  
215 8 100       24 if ($sort) {
216 24 100       50 @anagrams = sort {
217 16         51 my $ordered = @$a <= @$b ? 1 : -1;
218 24 100       50 my ( $d, $e ) = $ordered == 1 ? ( $a, $b ) : ( $b, $a );
219 24         43 for ( 0 .. $#$d ) {
220 27         40 my $c = $d->[$_] cmp $e->[$_];
221 27 100       91 return $ordered * $c if $c;
222             }
223 0         0 -$ordered;
224 4         6 } map { [ sort @$_ ] } @anagrams;
225             }
226 8         68 return @anagrams;
227             }
228              
229             sub _make_opts {
230 27 100   27   66 if ( @_ == 1 ) {
231 1         3 my $r = shift;
232 1 50       5 die 'options expected to be key value pairs or a hash ref'
233             unless 'HASH' eq ref $r;
234 1         5 return %$r;
235             }
236             else {
237 26         87 return @_;
238             }
239             }
240              
241              
242             our $null = sub { };
243              
244             sub iterator {
245 7     7 1 18421 my $self = shift;
246 7         15 my $phrase = shift;
247 7         24 my %opts = _make_opts(@_);
248 7   66     42 $opts{sorted} //= $self->{sorted};
249 7         26 $self->{clean}->($phrase);
250 7   100     75 my $i = $opts{start_list} // 0;
251 7         13 my @pairs = @{ $self->{tries} };
  7         24  
252 7 100       22 if ($i) {
253 1 50       5 die "impossible index for start list: $i" unless defined $pairs[$i];
254 1 50       4 $i = @pairs + $i if $i < 0;
255 1         4 @pairs = @pairs[ $i .. $#pairs ];
256             }
257 7 50       32 return $null unless length $phrase;
258 7         31 return _super_iterator( \@pairs, $phrase, \%opts,
259             @$self{qw(translator offset)} );
260             }
261              
262             # iterator that converts word indices back to words
263             sub _super_iterator {
264 7     7   17 my ( $tries, $phrase, $opts, $translator, $offset ) = @_;
265 7         19 my $counts = _counts( $phrase, $offset );
266 7         17 my @j = _jumps($counts);
267 7         19 my @ix = _indices($counts);
268 7         21 my $i = _iterator( $tries, $counts, $opts );
269 7         12 my %c;
270             return sub {
271 28     28   1286 my $rv;
272 28         73 local @jumps = @j;
273 28         274 local @indices = @ix;
274             {
275 28         36 $rv = $i->();
  54         91  
276 54 100       140 return unless $rv;
277 47         130 my $key = join ',', sort { $a <=> $b } @$rv;
  73         183  
278 47 100       150 redo if $c{$key}++;
279             }
280 21         62 $rv = [ @$translator[@$rv] ];
281 21 100       64 if ( $opts->{sorted} ) {
282 5         15 $rv = [ sort @$rv ];
283             }
284 21         76 $rv;
285 7         57 };
286             }
287              
288             # iterator that manages the trie list
289             sub _iterator {
290 49     49   78 my ( $tries, $counts, $opts ) = @_;
291 49         63 my $total = 0;
292 49         183 $total += $_ for @$counts[@indices];
293 49         487 my @t = @$tries;
294 49         56 my $i;
295             my $s = sub {
296 154     154   161 my $rv;
297             {
298 154 100       163 unless ($i) {
  199         488  
299 96 100       170 if (@t) {
300 49         75 my $pair = shift @t;
301 49         101 local ( $trie, $known ) = @$pair;
302 49 50       100 redo unless _all_known($counts);
303 49         87 my $words = _words_in( $counts, $total );
304 49 50       447 redo unless _worth_pursuing( $counts, $words );
305 49         99 $i = _sub_iterator( $tries, $words, $opts );
306             }
307             else {
308 47         89 return $rv;
309             }
310             }
311 152         237 $rv = $i->();
312 152 100       310 unless ($rv) {
313 45         90 undef $i;
314 45         287 redo;
315             }
316             }
317 107         168 $rv;
318 49         213 };
319 49         127 $s;
320             }
321              
322             # iterator that actually walks tries looking for anagrams
323             sub _sub_iterator {
324 49     49   75 my ( $tries, $words, $opts ) = @_;
325 49         106 my @pairs = @$words;
326             return sub {
327             {
328 152 100   152   165 return unless @pairs;
  237         509  
329 192 100       532 if ( $opts->{random} ) {
330 112         719 my $i = int rand scalar @pairs;
331 112 100       238 if ($i) {
332 47         61 my $p = $pairs[0];
333 47         60 $pairs[0] = $pairs[$i];
334 47         74 $pairs[$i] = $p;
335             }
336             }
337 192         227 my ( $w, $s ) = @{ $pairs[0] };
  192         318  
338 192 100       481 unless ( ref $s eq 'CODE' ) {
339 89 100       158 if ( _any($s) ) {
340 42         80 $s = _iterator( $tries, $s, $opts );
341             }
342             else {
343 47         69 my $next = [];
344             $s = sub {
345 92         125 my $rv = $next;
346 92         107 undef $next;
347 92         150 $rv;
348 47         162 };
349             }
350 89         161 $pairs[0][1] = $s;
351             }
352 192         334 my $remainder = $s->();
353 192 100       382 unless ($remainder) {
354 85         114 shift @pairs;
355 85         379 redo;
356             }
357 107         288 return [ $w, @$remainder ];
358             }
359 49         309 };
360             }
361              
362             # all character counts decremented
363             sub _worth_pursuing {
364 49     49   72 my ( $counts, $words ) = @_;
365              
366 49         50 my $c;
367              
368             # if any letter count didn't change, there's no hope
369 49         81 OUTER: for my $i (@indices) {
370 137 100       324 next unless $c = $counts->[$i];
371 68         137 for (@$words) {
372 89 100       284 next OUTER if $_->[1][$i] < $c;
373             }
374 0         0 return;
375             }
376 49         146 return 1;
377             }
378              
379             sub _indices {
380 15     15   21 my $counts = shift;
381 15         22 my @indices;
382 15         32 for my $i ( 0 .. $#$counts ) {
383 85 100       191 push @indices, $i if $counts->[$i];
384             }
385 15         63 return @indices;
386             }
387              
388             sub _jumps {
389 15     15   29 my $counts = shift;
390 15         47 my @jumps = (0) x @$counts;
391 15         21 my $j = 0;
392 15         33 while ( my $n = _next_jump( $counts, $j ) ) {
393 36         46 $jumps[$j] = $n;
394 36         82 $j = $n;
395             }
396 15         25 $jumps[-1] = -1;
397 15         62 return @jumps;
398             }
399              
400             sub _next_jump {
401 51     51   69 my ( $counts, $j ) = @_;
402 51         119 for my $i ( $j + 1 .. $#$counts ) {
403 70 100       210 return $i if $counts->[$i];
404             }
405 15         47 return;
406             }
407              
408             sub _clean {
409 61     61   119 $_[0] =~ s/\W+//g;
410 61         127 $_[0] = lc $_[0];
411             }
412              
413             sub _all_known {
414 57     57   77 my $counts = shift;
415 57 50       123 return if @$counts > @$known;
416 57         119 for my $i ( 0 .. $#$counts ) {
417 265 50 66     836 return if $counts->[$i] && !$known->[$i];
418             }
419 57         169 return 1;
420             }
421              
422              
423             sub key {
424 6     6 1 23854 my ( $self, $phrase ) = @_;
425 6         16 $self->{clean}->($phrase);
426 6         13 my $offset = $self->{offset};
427 6         8 my ( @counts, $lowest );
428 6         15 for my $c ( map { ord($_) - $offset } split //, $phrase ) {
  18         37  
429 18 100       32 if ( defined $lowest ) {
430 12 100       57 $lowest = $c if $c < $lowest;
431             }
432             else {
433 6         9 $lowest = $c;
434             }
435 18         126 $counts[$c]++;
436             }
437 6         29 @counts = @counts[ $lowest .. $#counts ];
438 6   100     100 $_ //= '' for @counts;
439 6         23 my $suffix = join '.', @counts;
440 6         34 $suffix =~ s/\.(\.+)\./'('.length($1).')'/ge;
  7         24  
441 6         52 return "$lowest:$suffix";
442             }
443              
444              
445             sub lists {
446 1     1 1 3 my $self = shift;
447 1         2 return scalar @{ $self->{tries} };
  1         9  
448             }
449              
450             sub _counts {
451 15     15   27 my ( $phrase, $offset ) = @_;
452 15         19 my @counts;
453 15         48 for my $c ( map { ord($_) - $offset } split //, $phrase ) {
  42         103  
454 42         79 $counts[$c]++;
455             }
456 15   100     289 $_ //= 0 for @counts;
457 15         41 \@counts;
458             }
459              
460             sub _any {
461 125     125   146 for ( @{ $_[0] } ) {
  125         288  
462 503 100       1070 return 1 if $_;
463             }
464 66         179 '';
465             }
466              
467             sub _anagramize {
468 25     25   33 my $counts = shift;
469 25         31 my $total = 0;
470 25         85 $total += $_ for @$counts[@indices];
471 25         37 my $key;
472 25 50       50 if ( $total <= $limit ) {
473 25         72 $key = join ',', @$counts[@indices];
474 25         38 my $cached = $cache{$key};
475 25 100       69 return @$cached if $cached;
476             }
477 22         24 my @anagrams;
478 22         37 my $words = _words_in( $counts, $total );
479 22 50       41 if ( _all_touched( $counts, $words ) ) {
480 22         38 for (@$words) {
481 36         80 my ( $word, $c ) = @$_;
482 36 100       67 if ( _any($c) ) {
483 17         42 push @anagrams, [ $word, @$_ ] for _anagramize($c);
484             }
485             else {
486 19         59 push @anagrams, [$word];
487             }
488             }
489 22         31 my %seen;
490 28         129 @anagrams = map {
491 22 50       29 $seen{ join ' ', sort { $a <=> $b } @$_ }++
  39         159  
492             ? ()
493             : $_
494             } @anagrams;
495             }
496 22 50       75 $cache{$key} = \@anagrams if $key;
497 22         151 @anagrams;
498             }
499              
500             sub _all_touched {
501 22     22   35 my ( $counts, $words ) = @_;
502              
503 22         23 my $c;
504              
505 22         28 my ( @tallies, @good_indices );
506 22         50 for (@$words) {
507 52         75 my $wc = $_->[1];
508 52         102 for (@indices) {
509 140 100       361 next unless $c = $counts->[$_];
510 114   66     255 $good_indices[$_] //= $_;
511 114 100       331 $tallies[$_]++ if $wc->[$_] < $c;
512             }
513             }
514              
515             # if any letter count didn't change, there's no hope
516 22 50       47 return unless @good_indices;
517 22         77 for (@good_indices) {
518 109 100       260 next unless $_;
519 36 50       91 return unless $tallies[$_];
520             }
521              
522             # find the letter with the fewest possibilities
523 22         30 my ( $best, $min, $n );
524 22         40 for (@good_indices) {
525 109 100       270 next unless $_;
526 36         45 $n = $tallies[$_];
527 36 100 66     115 if ( !$best || $n < $min ) {
528 22         30 $best = $_;
529 22         38 $min = $n;
530             }
531             }
532              
533             # we only need consider all the branches which affected a
534             # particular letter; we will find all possibilities in their
535             # ramifications
536 22         36 $c = $counts->[$best];
537 22         38 @$words = grep { $_->[1][$best] < $c } @$words;
  52         144  
538 22         75 return 1;
539             }
540              
541             1;
542              
543             __END__