cleanup context variables
[perl/list-index.git] / lib / List / Index.pm
1 package List::Index;
2
3 use 5.010;
4 use strict;
5 use warnings;
6
7 use Exporter 'import';
8
9 our $VERSION = '1.02';
10 our @EXPORT_OK = qw(rangematch);
11
12 sub new {
13         my ($class, $options) = @_;
14         $options ||= {};
15         bless $options, $class;
16 }
17
18 sub ranges {
19         my $self = shift;
20         my @rows = sort map { s/[^a-z]/./g; $_ } @{ shift() };
21         my $options = shift || {};
22         $options->{$_} //= $self->{$_} for keys %$self;
23
24         my $pagesize = $options->{pagesize} || 50;
25         my $context  = $options->{context } // 1 + ($pagesize >> 4);
26         my $length   = $options->{length  } || 4;
27         my $pages    = $options->{pages   } || 1 + int $#rows / $pagesize;
28
29         $pagesize = $pages >= $#rows ? 1 : @rows / $pages;
30         my $shrunk = 0;
31         my $enlarged = 0;
32
33         my @links = ('');
34         for (my $offset = $pagesize + .5; $offset < @rows; $offset += $pagesize) {
35                 my $link = substr $rows[$offset], 0, $length;
36                 if ($context) {
37                         my $lookbehind = -$context + $shrunk;
38                         my $lookahead  =  $context - $enlarged;
39                         $shrunk = $enlarged = 0;
40
41                         # take a value slightly before the current offset
42                         if ((my $before = $offset + $lookbehind) > 0) {
43                                 # see how much of it matches the current link
44                                 my $trim = 1;
45                                 for my $match (split //, $rows[$before - 1]) {
46                                         scalar $link =~ /\G\Q$match/g or last;
47                                         $trim++;
48                                 }
49                                 # truncate link upto where the earlier value starts to differ
50                                 if ($trim < length $link) {
51                                         substr($link, $trim) = '';
52                                         for (reverse $before .. $offset - 1) {
53                                                 $rows[$_] =~ /^\Q$link/ or last;
54                                                 $enlarged++;
55                                         }
56                                 }
57                         }
58
59                         # take a value after the current offset
60                         if ((my $after = $offset + $lookahead) < $#rows) {
61                                 # see how much of it matches the current link
62                                 my $trim = 1;
63                                 for my $match (split //, $rows[$after]) {
64                                         scalar $link =~ /\G\Q$match/g or last;
65                                         $trim++;
66                                 }
67                                 # use this link if it's shorter
68                                 if ($trim < length $link) {
69                                         $link = substr $rows[$after], 0, $trim;
70                                         # advance lookbehind offset on the next page
71                                         $enlarged = 0;
72                                         for ($offset + 1 .. $after) {
73                                                 $shrunk++;
74                                                 last if $rows[$_] =~ /^\Q$link/;
75                                         }
76                                 }
77                         }
78                 }
79
80                 push @links, $link unless $links[-1] eq $link;
81         }
82
83         # add range end to each link
84         for my $i (0 .. $#links - 1) {
85                 # end at start of next value with the last character decremented
86                 my $next = $links[$i + 1];
87                 $next =~ s{(.)$}{ $1 le 'a' ? '.' : chr( ord($1) - 1 ) }e;
88                 # amend range if it's ahead
89                 $links[$i] .= '-'.$next unless $next eq $links[$i];
90         }
91         # final value takes the rest
92         $links[-1] .= '-';
93
94         return \@links;
95 }
96
97 sub rangematch {
98         my ($link) = @_;
99         my ($s1, $s2) = $link =~ /([^-]*) - ([^-]*)/x
100                 or return qr/^\Q$link/i;
101         $s1 =~ s/\.$//;
102         my @allow;
103
104         if (length $s1) {
105                 if (length $s2) {
106                         $s1 le $s2 or $s1 =~ /^\Q$s2/ or return undef;
107                 }
108
109                 my $prefix = '';
110                 my $char;
111                 for my $i (0 .. length($s1) - 1) {
112                         my $lasti = $i == length($s1) - 1;
113                         $char = substr $s1, $i, 1;
114                         my $next = $char;
115                         # do not include prefix character in final range
116                         $next = chr( ord($char) + 1 ) unless $lasti;
117
118                         my $last = 'z';
119                         next if $next gt $last;
120                         if (length $s2 > $i) {
121                                 if ($s2 =~ /^\Q$prefix/) {
122                                         $last = substr $s2, $i, 1;
123                                         next if $char eq $last;
124                                         $last = chr( ord($last) - (length $s2 > 1) );
125                                         next if $next gt $last;
126                                 }
127                         }
128
129                         if ($char eq '.') {
130                                 if ($last eq 'z') {
131 #                                       push @allow, $prefix if $i and $lasti;
132 #                                       next;
133                                 }
134 #                               if ($last eq 'z') {
135 #                                       push @allow, $prefix if $i and $lasti;
136 #                                       next;
137 #                               }
138                                 $next = 'a';
139                         }
140
141                         push @allow, $prefix."[$next-$last]";
142                 }
143                 continue {
144                         $prefix .= $char eq '.' ? '[^a-z]' : $char;
145                 }
146         }
147
148         if (length $s2) {
149                 my $prefix = '';
150                 my $char;
151                 for my $i (0 .. length($s2) - 1) {
152                         $char = substr $s2, $i, 1;
153                         my $last = 'z';
154                         if (length $s1 > $i) {
155                                 my $c1 = substr $s1, $i, 1;
156                                 if ($s1 =~ /^\Q$prefix/) {
157                                         next if $c1 le $char;
158                                 }
159                         }
160
161                         if ($char eq '.') {
162                                 next if $i < length($s2) - 1;
163                         }
164
165                         push @allow, $prefix.'(?!['.($char eq '.' ? 'a' : $char)."-$last])"
166                                 if $i or $s1 eq '';
167                 }
168                 continue {
169                         $prefix .= $char eq '.' ? '[^a-z]' : $char;
170                 }
171
172                 push @allow, $prefix
173                         if $s2 =~ /^\Q$prefix/ and $s1 le $s2
174                         and not (length $s2 == 1 && length $s1 >= length $s2 && $s1 ne $s2);
175         }
176
177         my $match = sprintf @allow <= 1 ? '%s' : '(?:%s)', join('|', @allow);
178         return qr/^$match/i;
179 }
180
181 1;
182
183 __END__
184
185 =head1 NAME
186
187 List::Index - Find and apply prefix ranges to paginate keywords
188
189 =head1 SYNOPSIS
190
191         use List::Index;
192         my $index = List::Index->new({ pagesize => 50 });
193         my @pages = $index->ranges(\@values);
194         say "<a href='?q=$_'>$_</a>" for @pages;
195
196         use List::Index 'rangematch';
197         my $limit = rangematch('b-bmq');  # ge 'b' && le 'bmq'
198         @request = grep { $limit } @values;
199
200 =head1 DESCRIPTION
201
202 TODO
203
204 =head1 SEE ALSO
205
206 L<List::Maker|List::Maker> for complex ranges of numeric lists.
207
208 =head1 AUTHOR
209
210 Mischa POSLAWSKY <perl@shiar.org>
211
212 =head1 LICENSE
213
214 Copyright. All rights reserved.
215