File Coverage

blib/lib/Pinwheel/TagSelect.pm
Criterion Covered Total %
statement 11 191 5.7
branch 0 138 0.0
condition 0 17 0.0
subroutine 5 19 26.3
pod n/a
total 16 365 4.3


line stmt bran cond sub pod time code
1             package Pinwheel::TagSelect;
2              
3 2     2   34244 use strict;
  2         4  
  2         73  
4 2     2   11 use warnings;
  2         4  
  2         60  
5              
6 2     2   12 use Carp qw(croak);
  2         4  
  2         6630  
7              
8             my $use_distinct_hack;
9 2     2   1263 eval 'use XML::LibXML 1.61; *read = \&_read_libxml; $use_distinct_hack = 1; 1'
  0     2   0  
  0         0  
  2         203675  
  0            
  0            
10             or
11             eval 'use XML::XPath; *read = \&_read_xpath; 1'
12             or
13             die "No usable XPath implementation!";
14              
15             sub new
16             {
17 0     0     my $class = shift;
18 0           my $self = bless({xp => undef}, $class);
19 0           return $self;
20             }
21              
22             sub _read_xpath
23             {
24 0     0     my ($self, $s) = @_;
25 0           $self->{xp} = XML::XPath->new(xml => $s);
26             }
27              
28             sub _read_libxml
29             {
30 0     0     my ($self, $s) = @_;
31 0           my ($parser, $doc);
32              
33 0           $parser = XML::LibXML->new();
34 0           $doc = $parser->parse_string($s);
35 0           $doc->documentElement->setAttribute('xmlns', '');
36 0           $self->{xp} = XML::LibXML::XPathContext->new($doc);
37             }
38              
39             # Work around an intermittent problem whereby the returned NodeList
40             # can have the same node in multiple times
41             sub _make_list_unique
42             {
43 0     0     my ($nodes) = @_;
44 0           my @uniq;
45 0 0 0       for (@$nodes) { push @uniq, $_ unless @uniq and $_->isSameNode($uniq[-1]) }
  0            
46 0 0         if (@uniq != $nodes->size)
47             {
48 0           warn sprintf "Warning: node list contained duplicates! was %d, now %d",
49             $nodes->size, 0+@uniq;
50 0           @$nodes = @uniq;
51             }
52             }
53              
54             sub select
55             {
56 0     0     my ($self, $selector, $args) = @_;
57 0           my ($xpath, $nodes);
58              
59 0           $xpath = selector_to_xpath($selector, $args);
60 0           $nodes = $self->{xp}->findnodes($xpath);
61 0 0         _make_list_unique($nodes) if $use_distinct_hack;
62 0           return $nodes;
63             }
64              
65             =item $xpath = selector_to_xpath($selector_text, \@args)
66              
67             Turns C<$selector_text> into an XPath selector.
68              
69             Each instance of "=?" in C<$selector_text> consumes one item from the front of C<@args>.
70              
71             Each instance of ".?" or "#?" in C<$selector_text> consumes one item from the front of C<@args>.
72              
73             =cut
74              
75             sub selector_to_xpath
76             {
77 0     0     my ($s, $args) = @_;
78 0           my ($lexer, $xpath);
79              
80 0           $s =~ s/=\?/'="' . shift(@$args) . '"'/ge;
  0            
81 0           $s =~ s/([.#])\?/$1 . shift(@$args)/ge;
  0            
82              
83             # Assume already XPath if it starts with '/'
84 0 0         return $s if $s =~ m[^/];
85              
86 0           $lexer = lexer($s);
87 0           $xpath = parse_selector($lexer);
88 0 0         croak 'Unexpected trailing content' if ($lexer->()[0] ne '');
89              
90 0 0         $xpath = ('*' . $xpath) if ($xpath =~ /^\[/);
91 0           return '//' . $xpath;
92             }
93              
94              
95             sub parse_element_name
96             {
97 0     0     my ($lexer) = @_;
98 0           my ($type, $value);
99              
100 0           $type = $lexer->(1)[0];
101 0 0 0       return if ($type ne '*' && $type ne 'ID' && $type ne 'NSID');
      0        
102              
103 0           $value = $lexer->()[1];
104 0 0         $value =~ s/\|/:/ if $type eq 'NSID';
105 0 0         return $value if $type ne '*';
106 0           return '*';
107             }
108              
109             sub parse_attrib
110             {
111 0     0     my ($lexer) = @_;
112 0           my ($token, $attrib, $cmp, $m1, $m2);
113              
114 0           $token = $lexer->();
115 0 0         croak 'Expected [' if ($token->[0] ne '[');
116 0           $token = $lexer->();
117 0 0 0       if ($token->[0] eq 'ID' || $token->[0] eq 'NSID') {
118 0           $attrib = '@' . $token->[1];
119 0           $attrib =~ s/\|/:/;
120             } else {
121 0           croak 'Expected attribute name';
122             }
123              
124 0           $token = $lexer->();
125 0 0         return '[' . $attrib . ']' if ($token->[0] eq ']');
126 0 0         croak 'Expected ] or comparison' unless ($token->[0] eq 'CMP');
127 0           $cmp = $token->[1];
128 0           $token = $lexer->();
129 0 0         croak 'Expected string' unless ($token->[0] eq 'STR');
130 0           $m1 = $token->[1];
131 0 0         croak 'Expected ]' unless ($lexer->()[0] eq ']');
132              
133 0 0         if ($cmp eq '~=') {
    0          
    0          
    0          
    0          
134 0           $m1 =~ s/^(.)(.*)(.)$/$1 $2 $3/;
135 0           return "[contains(concat(\" \",$attrib,\" \"),$m1)]";
136             } elsif ($cmp eq '^=') {
137 0           return "[starts-with($attrib,$m1)]";
138             } elsif ($cmp eq '$=') {
139 0           $m2 = $m1;
140 0           $m2 =~ s/\\././g;
141 0           $m2 = length($m2) - 2;
142             return
143 0           '[substring(' .
144             "$attrib," .
145             "string-length($attrib)-" . ($m2 - 1) . ',' . $m2 .
146             ")=$m1]";
147             } elsif ($cmp eq '*=') {
148 0           return "[contains($attrib,$m1)]";
149             } elsif ($cmp eq '|=') {
150 0           $m2 = $m1;
151 0           $m2 =~ s/^(.)(.*)(.)$/$1$2-$3/;
152 0           return "[$attrib=$m1 or starts-with($attrib,$m2)]";
153             } else {
154 0           return "[$attrib=$m1]";
155             }
156             }
157              
158             sub parse_function
159             {
160 0     0     my ($lexer) = @_;
161 0           my ($token, $name, $arg, $xpath);
162              
163 0           $token = $lexer->();
164 0 0         croak 'Expected function name' unless ($token->[0] eq 'ID');
165 0           $name = $token->[1];
166 0           $token = $lexer->();
167 0 0         croak 'Expected (' unless ($token->[0] eq '(');
168              
169 0 0         if ($name eq 'not') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
170 0           $xpath = parse_selector($lexer);
171 0           $xpath =~ s/^\[(.*)\]$/$1/;
172 0           $xpath = "[not($xpath)]";
173             } elsif ($name eq 'nth-child') {
174 0           $token = $lexer->();
175 0 0         croak 'Expected number' unless ($token->[0] eq 'NUM');
176 0           $arg = $token->[1] - 1;
177 0           $xpath = "[count(./preceding-sibling::*)=$arg]";
178             } elsif ($name eq 'nth-last-child') {
179 0           $token = $lexer->();
180 0 0         croak 'Expected number' unless ($token->[0] eq 'NUM');
181 0           $arg = $token->[1] - 1;
182 0           $xpath = "[count(./following-sibling::*)=$arg]";
183             } elsif ($name eq 'nth-of-type') {
184 0           $token = $lexer->();
185 0 0         croak 'Expected number' unless ($token->[0] eq 'NUM');
186 0           $arg = $token->[1];
187 0           $xpath = "[position()=$arg]";
188             } elsif ($name eq 'nth-last-of-type') {
189 0           $token = $lexer->();
190 0 0         croak 'Expected number' unless ($token->[0] eq 'NUM');
191 0           $arg = $token->[1] - 1;
192 0           $xpath = "[last()-position()=$arg]";
193             } elsif ($name eq 'first-of-type') {
194 0           $xpath = '[position()=1]';
195             } elsif ($name eq 'last-of-type') {
196 0           $xpath = '[position()=last()]';
197             } elsif ($name eq 'only-of-type') {
198 0           $xpath = '[last()=1]';
199             } else {
200 0           croak "Unknown function: $name";
201             }
202              
203 0 0         croak 'Expected )' unless $lexer->()[0] eq ')';
204 0           return $xpath;
205             }
206              
207             sub parse_pseudo
208             {
209 0     0     my ($lexer) = @_;
210 0           my ($token, $name, $xpath);
211              
212 0           $token = $lexer->();
213 0 0         croak 'Expected :' unless ($token->[0] eq ':');
214 0 0         return parse_function($lexer) if ($lexer->(2)[0] eq '(');
215              
216 0           $token = $lexer->();
217 0 0         croak 'Expected identifier' unless ($token->[0] eq 'ID');
218 0           $name = $token->[1];
219              
220 0 0         if ($name eq 'first-child') {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
221 0           $xpath = '[not(preceding-sibling::*)]';
222             } elsif ($name eq 'first-of-type') {
223 0           $xpath = '[position()=1]';
224             } elsif ($name eq 'last-child') {
225 0           $xpath = '[not(following-sibling::*)]';
226             } elsif ($name eq 'last-of-type') {
227 0           $xpath = '[position()=last()]';
228             } elsif ($name eq 'only-child') {
229 0           $xpath = '[not(preceding-sibling::* or following-sibling::*)]';
230             } elsif ($name eq 'only-of-type') {
231 0           $xpath = '[position()=1 and last()=1]';
232             } elsif ($name eq 'empty') {
233 0           $xpath =
234             '[count(*)=0 and (' .
235             'count(text())=0 or translate(text()," \t\r\n","")=""' .
236             ')]';
237             } elsif ($name eq 'checked') {
238 0           $xpath = '[@checked]';
239             } elsif ($name eq 'disabled') {
240 0           $xpath = '[@disabled]';
241             } elsif ($name eq 'enabled') {
242 0           $xpath = '[not(@disabled)]';
243             } else {
244 0           croak 'Unknown pseudo element or class';
245             }
246              
247 0           return $xpath;
248             }
249              
250             sub parse_selector
251             {
252 0     0     my ($lexer) = @_;
253 0           my ($token, $xpath);
254              
255 0           $xpath = '';
256 0           while (1) {
257 0   0       $xpath .= parse_element_name($lexer) || '';
258              
259 0           while (1) {
260 0           $token = $lexer->(1);
261 0 0         if ($token->[0] eq '#ID') {
    0          
    0          
    0          
262 0           $xpath .= '[@id="' . $lexer->()[1] . '"]';
263             } elsif ($token->[0] eq '.ID') {
264 0           $xpath .=
265             '[contains(' .
266             'concat(" ",@class," "),' .
267             '" ' . $lexer->()[1] . ' "' .
268             ')]';
269             } elsif ($token->[0] eq '[') {
270 0           $xpath .= parse_attrib($lexer);
271             } elsif ($token->[0] eq ':') {
272 0           $xpath .= parse_pseudo($lexer);
273             } else {
274 0           last;
275             }
276             }
277              
278 0           $token = $lexer->(1);
279 0 0 0       if ($token->[0] eq '') {
    0          
    0          
    0          
    0          
280 0           last;
281             } elsif ($token->[0] eq '>') {
282 0           $lexer->();
283 0           $xpath .= '/';
284             } elsif ($token->[0] eq '~') {
285 0           $lexer->();
286 0           $xpath .= '/following-sibling::*/self::';
287             } elsif ($token->[0] eq '+') {
288 0           $lexer->();
289 0           $xpath .= '/following-sibling::*[1]/self::';
290             } elsif ($token->[0] eq 'ID' || $token->[0] eq '*') {
291 0           $xpath .= '//';
292             } else {
293 0           last;
294             }
295             }
296              
297 0           return $xpath;
298             }
299              
300              
301             sub lexer
302             {
303 0     0     my $s = shift;
304 0           my @buf;
305             my $lexer = sub {
306 0     0     while (1) {
307             # The CSS 2 spec doesn't allow underscores in IDs / classes.
308             # See http://www.w3.org/TR/CSS2/grammar.html
309 0 0         return ['STR', $1] if $s =~ /\G((['"])((?:\\.|.)*?)\2)/gc;
310 0 0         return ['NUM', $1] if $s =~ /\G(\d+)/gc;
311 0 0         return ['NSID', $1] if $s =~ /\G([a-z][a-z0-9_-]*\|[a-z][a-z0-9_-]*)/igc;
312 0 0         return ['ID', $1] if $s =~ /\G([a-z][a-z0-9_-]*)/igc;
313 0 0         return ['#ID', $1] if $s =~ /\G#([a-z][a-z0-9-]*)/igc;
314 0 0         return ['@ID', $1] if $s =~ /\G@([a-z][a-z0-9-]*)/igc;
315 0 0         return ['.ID', $1] if $s =~ /\G\.([a-z][a-z0-9-]*)/igc;
316 0 0         return ['CMP', $1] if $s =~ /\G([\$*~^|]?=)/gc;
317 0 0         return [$1, ''] if $s =~ /\G(:|\.|[][>+~*()])/gc;
318 0 0         last if $s !~ /\G\s+/gc;
319             }
320 0           $s =~ /\G(.*)/;
321 0           return ['', $1];
322 0           };
323             return sub {
324 0 0   0     if ($_[0]) {
325 0           my $n = shift;
326 0           push @buf, &$lexer() while (@buf < $n);
327 0           return $buf[$n - 1];
328             } else {
329 0 0         return shift(@buf) if (@buf > 0);
330 0           return &$lexer();
331             }
332 0           };
333             }
334              
335              
336             1;