File Coverage

blib/lib/JQ/Lite/Expression.pm
Criterion Covered Total %
statement 195 263 74.1
branch 88 144 61.1
condition 14 28 50.0
subroutine 15 18 83.3
pod 0 1 0.0
total 312 454 68.7


line stmt bran cond sub pod time code
1             package JQ::Lite::Expression;
2              
3 176     176   1493 use strict;
  176         420  
  176         7453  
4 176     176   923 use warnings;
  176         335  
  176         8689  
5              
6 176     176   912 use JSON::PP (); # lightweight decoder for string literals
  176         318  
  176         4180  
7 176     176   1220 use Scalar::Util qw(looks_like_number);
  176         526  
  176         563076  
8              
9             # Internal constant used to signal that parsing failed and callers should
10             # silently fall back to other heuristics.
11             my $PARSE_ERROR = "__JQ_LITE_EXPR_PARSE_ERROR__";
12              
13             sub evaluate {
14 62     62 0 422 my (%opts) = @_;
15              
16 62         163 my $expr = $opts{expr};
17 62         155 my $context = $opts{context};
18 62   33 0   200 my $resolve_path = $opts{resolve_path} || sub { return undef };
  0         0  
19 62   50     190 my $coerce_number = $opts{coerce_number} || \&_default_coerce_number;
20 62   50     253 my $builtins = $opts{builtins} || {};
21              
22 62 50       167 return (0, undef) unless defined $expr;
23              
24 62 100       213 my $tokens = _tokenize($expr) or return (0, undef);
25 35         139 my $state = {
26             tokens => $tokens,
27             pos => 0,
28             };
29              
30 35         103 my $ast = eval { _parse_expression($state, 0) };
  35         111  
31 35 100       108 if ($@) {
32 6 50       152 return (0, undef) if $@ =~ /\Q$PARSE_ERROR\E/;
33 0         0 die $@;
34             }
35              
36 29         69 my $next = _peek($state);
37 29 50       92 return (0, undef) unless $next->{type} eq 'EOF';
38              
39 29         59 my $value = eval {
40 29         195 _eval_node(
41             $ast,
42             {
43             context => $context,
44             resolve_path => $resolve_path,
45             coerce_number => $coerce_number,
46             builtins => $builtins,
47             }
48             );
49             };
50              
51 29 100       96 if ($@) {
52 23 100       451 return (0, undef) if $@ =~ /\Q$PARSE_ERROR\E/;
53 7         231 die $@;
54             }
55              
56 6         50 return (1, $value);
57             }
58              
59             sub _tokenize {
60 62     62   160 my ($expr) = @_;
61              
62 62         117 my @tokens;
63 62         136 my $len = length $expr;
64 62         106 my $i = 0;
65              
66 62         187 while ($i < $len) {
67 226         435 my $char = substr($expr, $i, 1);
68              
69 226 100       643 if ($char =~ /\s/) {
70 67         107 $i++;
71 67         133 next;
72             }
73              
74 159 100       436 if ($char =~ /[+\-*\/%]/) {
75 33         128 push @tokens, { type => 'OP', value => $char };
76 33         48 $i++;
77 33         60 next;
78             }
79              
80 126 100       317 if ($char eq '(') {
81 2         6 push @tokens, { type => 'LPAREN', value => '(' };
82 2         3 $i++;
83 2         5 next;
84             }
85              
86 124 100       298 if ($char eq ')') {
87 2         6 push @tokens, { type => 'RPAREN', value => ')' };
88 2         2 $i++;
89 2         5 next;
90             }
91              
92 122 50       340 if ($char eq ',') {
93 0         0 push @tokens, { type => 'COMMA', value => ',' };
94 0         0 $i++;
95 0         0 next;
96             }
97              
98 122 100       312 if ($char eq '.') {
99 52         169 my ($consumed, $path) = _consume_path($expr, $i);
100 52 50       133 return unless defined $consumed;
101 52         121 $i += $consumed;
102 52 100 66     233 if (!defined $path || $path eq '') {
103 6         18 push @tokens, { type => 'CURRENT' };
104             }
105             else {
106 46         203 push @tokens, { type => 'PATH', value => $path };
107             }
108 52         141 next;
109             }
110              
111 70 100       288 if ($char eq '"') {
112 1         7 my ($consumed, $value) = _consume_json_string($expr, $i);
113 1 50       2 return unless defined $consumed;
114 1         2 $i += $consumed;
115 1         3 push @tokens, { type => 'STRING', value => $value };
116 1         3 next;
117             }
118              
119 69 50       193 if ($char eq "'") {
120 0         0 my ($consumed, $value) = _consume_single_string($expr, $i);
121 0 0       0 return unless defined $consumed;
122 0         0 $i += $consumed;
123 0         0 push @tokens, { type => 'STRING', value => $value };
124 0         0 next;
125             }
126              
127 69 100       417 if (substr($expr, $i) =~ /\G(-?\d+(?:\.\d+)?)/) {
128 14         34 my $match = $1;
129 14         19 my $len_match = length $match;
130 14         60 push @tokens, { type => 'NUMBER', value => 0 + $match };
131 14         22 $i += $len_match;
132 14         59 next;
133             }
134              
135 55 100       261 if (substr($expr, $i) =~ /\G([A-Za-z_][A-Za-z0-9_]*)/) {
136 28         104 my $ident = $1;
137 28         192 push @tokens, { type => 'IDENT', value => $ident };
138 28         67 $i += length $ident;
139 28         125 next;
140             }
141              
142 27         275 return;
143             }
144              
145 35         129 push @tokens, { type => 'EOF', value => undef };
146 35         188 return \@tokens;
147             }
148              
149             sub _consume_path {
150 52     52   174 my ($expr, $start) = @_;
151              
152 52         99 my $len = length $expr;
153 52         111 my $i = $start + 1; # skip the leading '.'
154 52         85 my $depth = 0;
155 52         90 my $path = '';
156              
157 52         132 while ($i < $len) {
158 242         450 my $char = substr($expr, $i, 1);
159              
160 242 100 66     1011 last if $depth == 0 && $char =~ /[+\-*\/%(),\s]/;
161              
162 200 50       473 if ($char eq '[') {
    50          
163 0         0 $depth++;
164             }
165             elsif ($char eq ']') {
166 0 0       0 $depth-- if $depth;
167             }
168              
169 200         296 $path .= $char;
170 200         392 $i++;
171             }
172              
173 52         215 $path =~ s/\s+$//;
174              
175 52         184 return ($i - $start, $path);
176             }
177              
178             sub _consume_json_string {
179 1     1   4 my ($expr, $start) = @_;
180              
181 1         2 my $i = $start;
182 1         3 my $len = length $expr;
183 1         3 my $end = $i + 1;
184 1         1 my $escaped = 0;
185              
186 1         4 while ($end < $len) {
187 8         9 my $char = substr($expr, $end, 1);
188 8 50       11 if ($escaped) {
189 0         0 $escaped = 0;
190 0         0 $end++;
191 0         0 next;
192             }
193              
194 8 50       10 if ($char eq '\\') {
195 0         0 $escaped = 1;
196 0         0 $end++;
197 0         0 next;
198             }
199              
200 8 100       10 if ($char eq '"') {
201 1         3 my $text = substr($expr, $i, $end - $i + 1);
202 1         2 my $decoded = eval { JSON::PP::decode_json($text) };
  1         13  
203 1 50 33     162 return unless defined $decoded && !$@;
204 1         4 return ($end - $i + 1, $decoded);
205             }
206              
207 7         8 $end++;
208             }
209              
210 0         0 return;
211             }
212              
213             sub _consume_single_string {
214 0     0   0 my ($expr, $start) = @_;
215              
216 0         0 my $i = $start + 1;
217 0         0 my $len = length $expr;
218 0         0 my $value = '';
219 0         0 my $escaped = 0;
220              
221 0         0 while ($i < $len) {
222 0         0 my $char = substr($expr, $i, 1);
223              
224 0 0       0 if ($escaped) {
225 0         0 $value .= $char;
226 0         0 $escaped = 0;
227 0         0 $i++;
228 0         0 next;
229             }
230              
231 0 0       0 if ($char eq '\\') {
232 0         0 $escaped = 1;
233 0         0 $i++;
234 0         0 next;
235             }
236              
237 0 0       0 if ($char eq "'") {
238 0         0 return ($i - $start + 1, $value);
239             }
240              
241 0         0 $value .= $char;
242 0         0 $i++;
243             }
244              
245 0         0 return;
246             }
247              
248             my %LBP = (
249             '+' => 10,
250             '-' => 10,
251             '*' => 20,
252             '/' => 20,
253             '%' => 20,
254             );
255              
256             sub _parse_expression {
257 58     58   145 my ($state, $min_bp) = @_;
258              
259 58 50       218 my $token = _next($state) or _parse_error();
260 58         190 my $lhs = _nud($state, $token);
261              
262 52         88 while (1) {
263 68         146 my $next = _peek($state);
264 68 50       171 last unless $next;
265              
266 68 100 66     246 if ($next->{type} eq 'LPAREN' && _is_callable($lhs)) {
267 1         4 _next($state); # consume '('
268 1         1 my @args;
269 1 50       5 if (_peek($state)->{type} ne 'RPAREN') {
270 1         3 push @args, _parse_expression($state, 0);
271 1         3 while (_peek($state)->{type} eq 'COMMA') {
272 0         0 _next($state); # consume ','
273 0         0 push @args, _parse_expression($state, 0);
274             }
275             }
276 1         3 my $closing = _next($state);
277 1 50       5 _parse_error() unless $closing->{type} eq 'RPAREN';
278             $lhs = {
279             type => 'CALL',
280             name => $lhs->{name},
281 1         7 args => \@args,
282             };
283 1         3 next;
284             }
285              
286 67 100       196 last unless $next->{type} eq 'OP';
287 21         45 my $op = $next->{value};
288 21   50     60 my $lbp = $LBP{$op} || 0;
289 21 50       53 last if $lbp < $min_bp;
290              
291 21         60 _next($state); # consume operator
292 21         71 my $rhs = _parse_expression($state, $lbp + 1);
293 15         71 $lhs = {
294             type => 'BINARY',
295             op => $op,
296             left => $lhs,
297             right => $rhs,
298             };
299             }
300              
301 46         122 return $lhs;
302             }
303              
304             sub _nud {
305 58     58   145 my ($state, $token) = @_;
306              
307 58 100       220 if ($token->{type} eq 'NUMBER') {
308 11         54 return { type => 'NUMBER', value => $token->{value} };
309             }
310              
311 47 50       128 if ($token->{type} eq 'STRING') {
312 0         0 return { type => 'STRING', value => $token->{value} };
313             }
314              
315 47 50       127 if ($token->{type} eq 'CURRENT') {
316 0         0 return { type => 'CURRENT' };
317             }
318              
319 47 100       171 if ($token->{type} eq 'PATH') {
320 23         89 return { type => 'PATH', value => $token->{value} };
321             }
322              
323 24 100       116 if ($token->{type} eq 'IDENT') {
324 17 50       59 if ($token->{value} eq 'true') {
325 0         0 return { type => 'BOOLEAN', value => JSON::PP::true };
326             }
327 17 50       68 if ($token->{value} eq 'false') {
328 0         0 return { type => 'BOOLEAN', value => JSON::PP::false };
329             }
330 17 50       57 if ($token->{value} eq 'null') {
331 0         0 return { type => 'NULL', value => undef };
332             }
333 17         93 return { type => 'IDENT', name => $token->{value} };
334             }
335              
336 7 50 66     40 if ($token->{type} eq 'OP' && $token->{value} eq '-') {
337 0         0 my $rhs = _parse_expression($state, $LBP{'-'} + 1);
338 0         0 return { type => 'UNARY', op => '-', expr => $rhs };
339             }
340              
341 7 100       40 if ($token->{type} eq 'LPAREN') {
342 1         4 my $expr = _parse_expression($state, 0);
343 1         3 my $closing = _next($state);
344 1 50       8 _parse_error() unless $closing->{type} eq 'RPAREN';
345 1         2 return $expr;
346             }
347              
348 6         19 _parse_error();
349             }
350              
351             sub _is_callable {
352 1     1   3 my ($node) = @_;
353 1         8 return $node->{type} eq 'IDENT';
354             }
355              
356             sub _peek {
357 99     99   171 my ($state) = @_;
358 99   50     297 return $state->{tokens}[ $state->{pos} ] // { type => 'EOF', value => undef };
359             }
360              
361             sub _next {
362 82     82   141 my ($state) = @_;
363 82         261 return $state->{tokens}[ $state->{pos}++ ];
364             }
365              
366             sub _parse_error {
367 22     22   241 die $PARSE_ERROR;
368             }
369              
370             sub _eval_node {
371 60     60   148 my ($node, $opts) = @_;
372              
373 60 100       167 if ($node->{type} eq 'NUMBER') {
374 11         20 return $node->{value};
375             }
376              
377 49 50       126 if ($node->{type} eq 'STRING') {
378 0         0 return $node->{value};
379             }
380              
381 49 50       124 if ($node->{type} eq 'BOOLEAN') {
382 0         0 return $node->{value};
383             }
384              
385 49 50       127 if ($node->{type} eq 'NULL') {
386 0         0 return undef;
387             }
388              
389 49 50       120 if ($node->{type} eq 'CURRENT') {
390 0         0 return $opts->{context};
391             }
392              
393 49 100       130 if ($node->{type} eq 'PATH') {
394 17         58 return $opts->{resolve_path}->($opts->{context}, $node->{value});
395             }
396              
397 32 50       92 if ($node->{type} eq 'UNARY') {
398 0         0 my $value = _eval_node($node->{expr}, $opts);
399 0         0 my $num = $opts->{coerce_number}->($value, 'unary - operand');
400 0         0 return -$num;
401             }
402              
403 32 100       105 if ($node->{type} eq 'BINARY') {
404 15         47 my $left_value = _eval_node($node->{left}, $opts);
405 15         37 my $right_value = _eval_node($node->{right}, $opts);
406              
407 15         48 my $left_num = $opts->{coerce_number}->($left_value, 'left operand');
408 15         40 my $right_num = $opts->{coerce_number}->($right_value, 'right operand');
409              
410 15 100       41 if ($node->{op} eq '+') {
411 3         8 return $left_num + $right_num;
412             }
413 12 50       40 if ($node->{op} eq '-') {
414 0         0 return $left_num - $right_num;
415             }
416 12 100       33 if ($node->{op} eq '*') {
417 2         4 return $left_num * $right_num;
418             }
419 10 50       27 if ($node->{op} eq '/') {
420 10 100       91 die 'Division by zero' if $right_num == 0;
421 3         13 return $left_num / $right_num;
422             }
423 0 0       0 if ($node->{op} eq '%') {
424 0 0       0 die 'Modulo by zero' if $right_num == 0;
425 0         0 return $left_num % $right_num;
426             }
427             }
428              
429 17 100       61 if ($node->{type} eq 'CALL') {
430 1         3 my $name = $node->{name};
431 1         3 my $func = $opts->{builtins}{$name};
432 1 50       3 _parse_error() unless $func;
433 1         2 my @args = map { _eval_node($_, $opts) } @{ $node->{args} };
  1         2  
  1         4  
434 1         14 return $func->(@args);
435             }
436              
437 16         44 _parse_error();
438             }
439              
440             sub _default_coerce_number {
441 0     0     my ($value, $label) = @_;
442              
443 0   0       $label ||= 'value';
444              
445 0 0         die "$label must be a number" unless defined $value;
446              
447 0 0         if (ref($value) eq 'JSON::PP::Boolean') {
448 0 0         return $value ? 1 : 0;
449             }
450              
451 0 0         die "$label must be a number" if ref $value;
452 0 0         die "$label must be a number" unless looks_like_number($value);
453              
454 0           return 0 + $value;
455             }
456              
457             1;
458