File Coverage

blib/lib/HTML/Selector/XPath.pm
Criterion Covered Total %
statement 112 129 86.8
branch 102 122 83.6
condition 12 14 85.7
subroutine 12 13 92.3
pod 4 9 44.4
total 242 287 84.3


line stmt bran cond sub pod time code
1             package HTML::Selector::XPath;
2              
3 8     8   809751 use strict;
  8         87  
  8         253  
4 8     8   187 use 5.008_001;
  8         28  
5             our $VERSION = '0.26';
6              
7             require Exporter;
8             our @EXPORT_OK = qw(selector_to_xpath);
9             *import = \&Exporter::import;
10              
11 8     8   55 use Carp;
  8         25  
  8         20893  
12              
13             sub selector_to_xpath {
14 23     23 1 73560 __PACKAGE__->new(shift)->to_xpath(@_);
15             }
16              
17             # XXX: Identifiers should also allow any characters U+00A0 and higher, and any
18             # escaped characters.
19             my $ident = qr/(?![0-9]|-[-0-9])[-_a-zA-Z0-9]+/;
20              
21             my $reg = {
22             # tag name/id/class
23             element => qr/^([#.]?)([^\s'"#.\/:@,=~>()\[\]|]*)((\|)([a-z0-9\\*_-]*))?/i,
24             # attribute presence
25             attr1 => qr/^\[ \s* ($ident) \s* \]/x,
26             # attribute value match
27             attr2 => qr/^\[ \s* ($ident) \s*
28             ( [~|*^\$!]? = ) \s*
29             (?: ($ident) | "([^"]*)" | '([^']*)') \s* \] /x,
30             badattr => qr/^\[/,
31             attrN => qr/^:not\(/i, # we chop off the closing parenthesis below in the code
32             pseudo => qr/^:([()a-z0-9_+-]+)/i,
33             # adjacency/direct descendance
34             combinator => qr/^(\s*[>+~\s](?!,))/i,
35             # rule separator
36             comma => qr/^\s*,\s*/i,
37             };
38              
39             sub new {
40 145     145 1 206958 my($class, $exp) = @_;
41 145         570 bless { expression => $exp }, $class;
42             }
43              
44             sub selector {
45 0     0 0 0 my $self = shift;
46 0 0       0 $self->{expression} = shift if @_;
47 0         0 $self->{expression};
48             }
49              
50             sub convert_attribute_match {
51 26     26 0 108 my ($left,$op,$right) = @_;
52             # negation (e.g. [input!="text"]) isn't implemented in CSS, but include it anyway:
53 26 100       120 if ($op eq '!=') {
    100          
    100          
    100          
    100          
    100          
54 2         9 "\@$left!='$right'";
55             } elsif ($op eq '~=') { # substring attribute match
56 2         10 "contains(concat(' ', \@$left, ' '), ' $right ')";
57             } elsif ($op eq '*=') { # real substring attribute match
58 4         16 "contains(\@$left, '$right')";
59             } elsif ($op eq '|=') {
60 2         9 "\@$left='$right' or starts-with(\@$left, '$right-')";
61             } elsif ($op eq '^=') {
62 4         21 "starts-with(\@$left,'$^N')";
63             } elsif ($op eq '$=') {
64 2         5 my $n = length($^N) - 1;
65 2         12 "substring(\@$left,string-length(\@$left)-$n)='$^N'";
66             } else { # exact match
67 10         47 "\@$left='$^N'";
68             }
69             }
70              
71             sub _generate_child {
72 27     27   57 my ($direction,$a,$b) = @_;
73 27 100       66 if ($a == 0) { # 0n+b
    50          
74 23         41 $b--;
75 23         134 "[count($direction-sibling::*) = $b and parent::*]"
76             } elsif ($a > 0) { # an + b
77 4         33 return "[not((count($direction-sibling::*)+1)<$b) and ((count($direction-sibling::*) + 1) - $b) mod $a = 0 and parent::*]"
78             } else { # -an + $b
79 0         0 $a = -$a;
80 0         0 return "[not((count($direction-sibling::*)+1)>$b) and (($b - (count($direction-sibling::*) + 1)) mod $a) = 0 and parent::*]"
81             }
82             }
83              
84             sub nth_child {
85 22     22 0 50 my ($a,$b) = @_;
86 22 100       48 if (@_ == 1) {
87 18         37 ($a,$b) = (0,$a);
88             }
89 22         44 _generate_child('preceding', $a, $b);
90             }
91              
92             sub nth_last_child {
93 5     5 0 11 my ($a,$b) = @_;
94 5 50       13 if (@_ == 1) {
95 5         11 ($a,$b) = (0,$a);
96             }
97 5         9 _generate_child('following', $a, $b);
98             }
99              
100             # A hacky recursive descent
101             # Only descends for :not(...)
102             sub consume {
103 150     150 0 327 my ($self, $rule, %parms) = @_;
104 150   100     559 my $root = $parms{root} || '/';
105              
106 150 100       394 return [$rule,''] if $rule =~ m!^/!; # If we start with a slash, we're already an XPath?!
107              
108 149         420 my @parts = ("$root/");
109 149         267 my $last_rule = '';
110 149         228 my @next_parts;
111              
112             my $wrote_tag;
113 149         235 my $root_index = 0; # points to the current root
114             # Loop through each "unit" of the rule
115 149   100     546 while (length $rule && $rule ne $last_rule) {
116 217         412 $last_rule = $rule;
117              
118 217         1364 $rule =~ s/^\s*|\s*$//g;
119 217 50       544 last unless length $rule;
120              
121             # Prepend explicit first selector if we have an implicit selector
122             # (that is, if we start with a combinator)
123 217 100       923 if ($rule =~ /$reg->{combinator}/) {
124 1         3 $rule = "* $rule";
125             }
126              
127             # Match elements
128 217 50       1308 if ($rule =~ s/$reg->{element}//) {
129 217         774 my ($id_class,$name,$lang) = ($1,$2,$3);
130              
131             # to add *[1]/self:: for follow-sibling
132 217 50       516 if (@next_parts) {
133 0         0 push @parts, @next_parts; #, (pop @parts);
134 0         0 @next_parts = ();
135             }
136              
137 217 100 100     714 my $tag = $id_class eq '' ? $name || '*' : '*';
138              
139 217 100 100     553 if (defined $parms{prefix} and not $tag =~ /[*:|]/) {
140 12         33 $tag = join ':', $parms{prefix}, $tag;
141             }
142              
143 217 100       490 if (! $wrote_tag++) {
144 186         554 push @parts, $tag;
145             }
146              
147             # XXX Shouldn't the RE allow both, ID and class?
148 217 100       590 if ($id_class eq '#') { # ID
    100          
149 19         70 push @parts, "[\@id='$name']";
150             } elsif ($id_class eq '.') { # class
151 20         55 push @parts, "[contains(concat(' ', normalize-space(\@class), ' '), ' $name ')]";
152             };
153             };
154              
155             # Match attribute selectors
156 217 100       1523 if ($rule =~ s/$reg->{attr2}//) {
    100          
    100          
157 22         75 push @parts, "[", convert_attribute_match( $1, $2, $^N ), "]";
158             } elsif ($rule =~ s/$reg->{attr1}//) {
159             # If we have no tag output yet, write the tag:
160 6 50       20 if (! $wrote_tag++) {
161 0         0 push @parts, '*';
162             }
163 6         22 push @parts, "[\@$1]";
164             } elsif ($rule =~ $reg->{badattr}) {
165 14         1216 Carp::croak "Invalid attribute-value selector '$rule'";
166             }
167              
168             # Match negation
169 203 100       742 if ($rule =~ s/$reg->{attrN}//) {
170             # Now we parse the rest, and after parsing the subexpression
171             # has stopped, we must find a matching closing parenthesis:
172 9 100       71 if ($rule =~ s/$reg->{attr2}//) {
    50          
    50          
173 4         12 push @parts, "[not(", convert_attribute_match( $1, $2, $^N ), ")]";
174             } elsif ($rule =~ s/$reg->{attr1}//) {
175 0         0 push @parts, "[not(\@$1)]";
176             } elsif ($rule =~ /$reg->{badattr}/) {
177 0         0 Carp::croak "Invalid negated attribute-value selector ':not($rule)'";
178             } else {
179 5         49 my( $new_parts, $leftover ) = $self->consume( $rule, %parms );
180 5         11 shift @$new_parts; # remove '//'
181 5         17 my $xpath = join '', @$new_parts;
182              
183 5         14 push @parts, "[not(self::$xpath)]";
184 5         12 $rule = $leftover;
185             }
186 9 50       49 $rule =~ s!^\s*\)!!
187             or die "Unbalanced parentheses at '$rule'";
188             }
189              
190             # Ignore pseudoclasses/pseudoelements
191 203         741 while ($rule =~ s/$reg->{pseudo}//) {
192 60 100       190 if ( my @expr = $self->parse_pseudo($1, \$rule) ) {
    100          
    100          
    100          
    50          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    100          
    50          
    50          
    50          
    100          
    100          
    100          
    100          
    50          
193 3         54 push @parts, @expr;
194             } elsif ( $1 eq 'disabled') {
195 1         6 push @parts, '[@disabled]';
196             } elsif ( $1 eq 'checked') {
197 1         5 push @parts, '[@checked]';
198             } elsif ( $1 eq 'selected') {
199 1         5 push @parts, '[@selected]';
200             } elsif ( $1 eq 'text') {
201 0         0 push @parts, '*[@type="text"]';
202             } elsif ( $1 eq 'first-child') {
203             # Translates to :nth-child(1)
204 12         26 push @parts, nth_child(1);
205             } elsif ( $1 eq 'last-child') {
206 4         9 push @parts, nth_last_child(1);
207             } elsif ( $1 eq 'only-child') {
208 1         3 push @parts, nth_child(1), nth_last_child(1);
209             } elsif ($1 =~ /^lang\(([\w\-]+)\)$/) {
210 11         70 push @parts, "[\@xml:lang='$1' or starts-with(\@xml:lang, '$1-')]";
211             } elsif ($1 =~ /^nth-child\(odd\)$/) {
212 1         4 push @parts, nth_child(2, 1);
213             } elsif ($1 =~ /^nth-child\(even\)$/) {
214 1         5 push @parts, nth_child(2, 0);
215             } elsif ($1 =~ /^nth-child\((\d+)\)$/) {
216 5         13 push @parts, nth_child($1);
217             } elsif ($1 =~ /^nth-child\((\d+)n(?:\+(\d+))?\)$/) {
218 2   100     11 push @parts, nth_child($1, $2||0);
219             } elsif ($1 =~ /^nth-last-child\((\d+)\)$/) {
220 0         0 push @parts, nth_last_child($1);
221             } elsif ($1 =~ /^nth-last-child\((\d+)n(?:\+(\d+))?\)$/) {
222 0   0     0 push @parts, nth_last_child($1, $2||0);
223             } elsif ($1 =~ /^first-of-type$/) {
224 0         0 push @parts, "[1]";
225             } elsif ($1 =~ /^nth-of-type\((\d+)\)$/) {
226 1         7 push @parts, "[$1]";
227             } elsif ($1 =~ /^last-of-type$/) {
228 1         6 push @parts, "[last()]";
229             } elsif ($1 =~ /^contains\($/) {
230 10 100       58 if( $rule =~ s/^\s*"([^"]*)"\s*\)// ) {
    50          
231 8         55 push @parts, qq{[text()[contains(string(.),"$1")]]};
232             } elsif( $rule =~ s/^\s*'([^']*)'\s*\)// ) {
233 2         15 push @parts, qq{[text()[contains(string(.),"$1")]]};
234             } else {
235 0         0 return( \@parts, $rule );
236             #die "Malformed string in :contains(): '$rule'";
237             };
238             } elsif ( $1 eq 'root') {
239             # This will give surprising results if you do E > F:root
240 3         14 $parts[$root_index] = $root;
241             } elsif ( $1 eq 'empty') {
242 2         10 push @parts, "[not(* or text())]";
243             } else {
244 0         0 Carp::croak "Can't translate '$1' pseudo-class";
245             }
246             }
247              
248             # Match combinators (whitespace, >, + and ~)
249 203 100       655 if ($rule =~ s/$reg->{combinator}//) {
250 30         73 my $match = $1;
251 30 100       136 if ($match =~ />/) {
    100          
    100          
    50          
252 7         35 push @parts, "/";
253             } elsif ($match =~ /\+/) {
254 5         13 push @parts, "/following-sibling::*[1]/self::";
255             } elsif ($match =~ /\~/) {
256 9         17 push @parts, "/following-sibling::";
257             } elsif ($match =~ /^\s*$/) {
258 9         19 push @parts, "//"
259             } else {
260 0         0 die "Weird combinator '$match'"
261             }
262              
263             # new context
264 30         60 undef $wrote_tag;
265             }
266              
267             # Match commas
268 203 100       850 if ($rule =~ s/$reg->{comma}//) {
269 7         27 push @parts, " | ", "$root/"; # ending one rule and beginning another
270 7         14 $root_index = $#parts;
271 7         28 undef $wrote_tag;
272             }
273             }
274 135         539 return \@parts, $rule
275             }
276              
277             sub to_xpath {
278 145     145 1 460 my $self = shift;
279 145 50       425 my $rule = $self->{expression} or return;
280 145         291 my %parms = @_;
281              
282 145         430 my($result,$leftover) = $self->consume( $rule, %parms );
283 131 50       284 $leftover
284             and die "Invalid rule, couldn't parse '$leftover'";
285 131         668 return join '', @$result;
286              
287             }
288              
289       57 1   sub parse_pseudo {
290             # nop
291             }
292              
293             1;
294             __END__