File Coverage

blib/lib/ETL/Yertl/Command/yq/Regex.pm
Criterion Covered Total %
statement 134 149 89.9
branch 103 118 87.2
condition 32 45 71.1
subroutine 10 10 100.0
pod 0 3 0.0
total 279 325 85.8


line stmt bran cond sub pod time code
1             package ETL::Yertl::Command::yq::Regex;
2             our $VERSION = '0.037';
3             # ABSTRACT: A regex-based parser for programs
4              
5 9     9   3049 use ETL::Yertl;
  9         18  
  9         41  
6 9     9   277 use boolean qw( :all );
  9         18  
  9         45  
7 9     9   3245 use Regexp::Common;
  9         15900  
  9         39  
8 9     9   1037523 use Time::Local qw( timegm );
  9         13749  
  9         565  
9 9     9   60 use ETL::Yertl::Util qw( firstidx );
  9         18  
  9         14582  
10              
11             sub empty() {
12 5     5 0 61 bless {}, 'empty';
13             }
14              
15             sub is_empty($) {
16 180     180 0 527 return ref $_[0] eq 'empty';
17             }
18              
19             *diag = *yertl::diag;
20              
21             my $QUOTE_STRING = $RE{delimited}{-delim=>q{'"}};
22             my $EVAL_NUMS = qr{(?:0b$RE{num}{bin}|0$RE{num}{oct}|0x$RE{num}{hex})};
23              
24             # Match a document path
25             our $GRAMMAR = qr{
26             (?(DEFINE)
27             (?
28             (?:\$?[.](?:\w+|\[\d*\]))+ # hash/array lookup
29             |
30             \$?[.] # entire document
31             |
32             $QUOTE_STRING
33             |
34             $RE{num}{real}|$EVAL_NUMS
35             |
36             \w+ # Constant/bareword
37             )
38             (?eq|ne|==?|!=|>=?|<=?)
39             (?empty|select|grep|group_by|keys|length|sort|each|parse_time)
40             (?
41             \{\s*(?&FILTER)\s*:\s*(?0)\s*(?:,(?-1))*\} # Hash constructor
42             |
43             \[\s*(?0)\s*(?:,(?-1))*\] # Array constructor
44             |
45             (?&FUNC_NAME)(?:\(\s*(?&EXPR)\s*(?:,\s*(?&EXPR)\s*)*\))? # Function with optional argument(s)
46             |
47             (?:(?&FILTER)|(?&FUNC_NAME)(?:\(\s*(?&EXPR)\s*\))?)\s+(?&OP)\s+(?&EXPR) # Binop with filter
48             |
49             (?&FILTER)
50             )
51             )
52             }x;
53              
54             my $FILTER = qr{(?&FILTER)$GRAMMAR};
55             my $OP = qr{(?&OP)$GRAMMAR};
56             my $FUNC_NAME = qr{(?&FUNC_NAME)$GRAMMAR};
57             my $EXPR = qr{(?&EXPR)$GRAMMAR};
58             my $PIPE = qr{[|]};
59              
60             my @DAYS = qw< sun sunday mon monday tue tuesday wed wednesday thu thursday fri friday sat saturday sun sunday>;
61             my $DAYS = qr{@{[ join '|', @DAYS ]}}i;
62             my @MONTHS = qw< jan feb mar apr may jun jul aug sep oct nov dec >;
63             my $MONTHS = qr{@{[ join '|', @MONTHS ]}}i;
64              
65             my %PARSE_TIME = (
66             iso => qr{(?\d{4})-?(?\d{2})-?(?\d{2})(?:[ T]?(?\d{2}):?(?\d{2})(?::?(?\d{2})))?},
67             apache => qr{(?\d{2})/(?$MONTHS)/(?\d{4}):(?\d{2}):(?\d{2}):(?\d{2})},
68             );
69             $PARSE_TIME{auto} = qr{$PARSE_TIME{iso}|$PARSE_TIME{apache}};
70              
71             # Filter MUST NOT mutate $doc!
72             sub filter {
73 417     417 0 304798 my ( $class, $filter, $doc, $scope, $orig_doc ) = @_;
74 417   66     1175 $orig_doc ||= $doc;
75              
76             # Pipes: LEFT | RIGHT pipes the output of LEFT to the input of RIGHT
77 417 100       17080 if ( $filter =~ $PIPE ) {
    100          
    100          
    100          
    100          
    100          
    100          
    50          
78 11         504 my @exprs = split /\s*$PIPE\s*/, $filter;
79 11         38 my @in = ( $doc );
80 11         26 for my $expr ( @exprs ) {
81 22         38 my @out = ();
82 22         34 for my $doc ( @in ) {
83 27         120 push @out, $class->filter( $expr, $doc, $scope, $orig_doc );
84             }
85 22         158 @in = @out;
86             }
87 11         42 return @in;
88             }
89              
90             # Hash constructor
91             elsif ( $filter =~ /^{/ ) {
92 6         14 my %out;
93 6         38 my ( $inner ) = $filter =~ /^\{\s*([^\}]+?)\s*\}$/;
94 6         23 for my $pair ( split /\s*,\s*/, $inner ) {
95 8         34 my ( $key_filter, $value_expr ) = split /\s*:\s*/, $pair;
96 8         62 my $key = $class->filter( $key_filter, $doc, $scope, $orig_doc );
97 8         24 $out{ $key } = $class->filter( $value_expr, $doc, $scope, $orig_doc );
98             }
99 6         51 return \%out;
100             }
101              
102             # Array constructor
103             elsif ( $filter =~ /^\[/ ) {
104 1         2 my @out;
105 1         7 my ( $inner ) = $filter =~ /^\[\s*([^\]]+?)\s*\]$/;
106 1         7 for my $value_expr ( split /\s*,\s*/, $inner ) {
107 5         13 push @out, $class->filter( $value_expr, $doc, $scope, $orig_doc );
108             }
109 1         4 return \@out;
110             }
111              
112             # Function calls
113             elsif ( my ( $func, @args ) = $filter =~ /^((?&FUNC_NAME))(?:\(\s*((?&EXPR))\s*(?:,\s*((?&EXPR))\s*)*\))?$GRAMMAR$/ ) {
114 65   100     548 diag( 1, "F: $func, ARGS: " . ( join( ', ', grep defined, @args ) || '' ) );
115 65 100 100     452 if ( $func eq 'empty' ) {
    100          
    100          
    100          
    100          
    100          
    100          
    50          
116 1 50       5 if ( @args ) {
117 1         26 warn "empty does not take arguments\n";
118             }
119 1         23 return empty;
120             }
121             elsif ( $func eq 'select' || $func eq 'grep' ) {
122 8 50       20 if ( !@args ) {
123 0         0 warn "'$func' takes an expression argument";
124 0         0 return empty;
125             }
126 8 100       40 return $class->filter( $args[0], $doc, $scope, $orig_doc ) ? $doc : empty;
127             }
128             elsif ( $func eq 'group_by' ) {
129 9         24 my $grouping = $class->filter( $args[0], $doc, $scope, $orig_doc );
130 9         24 push @{ $scope->{ group_by }{ $grouping } }, $doc;
  9         27  
131 9         25 return;
132             }
133             elsif ( $func eq 'sort' ) {
134 3   50     8 $args[0] ||= '.';
135 3         8 my $value = $class->filter( $args[0], $doc, $scope, $orig_doc );
136 3         5 push @{ $scope->{sort} }, [ "$value", $doc ];
  3         10  
137 3         9 return;
138             }
139             elsif ( $func eq 'keys' ) {
140 5   100     13 $args[0] ||= '.';
141 5         16 my $value = $class->filter( $args[0], $doc, $scope, $orig_doc );
142 5 100       15 if ( ref $value eq 'HASH' ) {
    50          
143 4         22 return [ keys %$value ];
144             }
145             elsif ( ref $value eq 'ARRAY' ) {
146 1         3 return [ 0..$#{ $value } ];
  1         5  
147             }
148             else {
149 0         0 warn "keys() requires a hash or array";
150 0         0 return empty;
151             }
152             }
153             elsif ( $func eq 'each' ) {
154 7   100     30 $args[0] ||= '.';
155 7         24 my $value = $class->filter( $args[0], $doc, $scope, $orig_doc );
156 7 100       22 if ( ref $value eq 'HASH' ) {
    50          
157 6         64 return map +{ key => $_, value => $value->{ $_ } }, keys %$value;
158             }
159             elsif ( ref $value eq 'ARRAY' ) {
160 1         10 return map +{ key => $_, value => $value->[ $_ ] }, 0..$#$value;
161             }
162             else {
163 0         0 warn "each() requires a hash or array";
164 0         0 return empty;
165             }
166             }
167             elsif ( $func eq 'length' ) {
168 13   100     32 $args[0] ||= '.';
169 13         70 my $value = $class->filter( $args[0], $doc, $scope, $orig_doc );
170 13 100       44 if ( ref $value eq 'HASH' ) {
    100          
    50          
171 3         13 return scalar keys %$value;
172             }
173             elsif ( ref $value eq 'ARRAY' ) {
174 7         19 return scalar @$value;
175             }
176             elsif ( !ref $value ) {
177 3         14 return length $value;
178             }
179             else {
180 0         0 warn "length() requires a hash, array, string, or number";
181 0         0 return empty;
182             }
183             }
184             elsif ( $func eq 'parse_time' ) {
185 19         41 my ( $expr, $format ) = @args;
186 19   100     64 $format ||= 'auto';
187             die sprintf "Invalid format '%s' in parse_time()\n", $format
188 19 50       55 if !$PARSE_TIME{ $format};
189 19         62 my $value = $class->filter( $expr, $doc, $scope, $orig_doc );
190 19         82 diag( 1, "FMT: $PARSE_TIME{ $format }, VAL: $value" );
191 19 50       207 if ( $value =~ $PARSE_TIME{ $format } ) {
192 19     7   260 my @tlargs = @{+}{qw< s n h d m y >};
  7         1736  
  7         2335  
  7         9435  
193 19 100 66     126 if ( !$+{m} && ( my $mname = $+{mn} ) ) {
194 7     33   94 $tlargs[4] = firstidx { /$mname/i } @MONTHS;
  33         233  
195             }
196             else {
197 12         29 $tlargs[4] -= 1;
198             }
199 19         82 return timegm( @tlargs );
200             }
201 0         0 warn sprintf "time '%s' does not match format '%s'\n", $value, $format;
202 0         0 return empty;
203             }
204             }
205              
206             # Hash and array keys to traverse the data structure
207             elsif ( $filter =~ /^((?&FILTER))$GRAMMAR$/ ) {
208             # Extract quoted strings
209 236 100       2364 if ( $filter =~ /^(['"])(.+)(\1)$/ ) {
    100          
    100          
210 9         49 return $2;
211             }
212             # Eval numbers to allow bin, hex, and oct
213             elsif ( $filter =~ /^$EVAL_NUMS$/ ) {
214             ## no critic ( ProhibitStringyEval )
215 3         187 return eval $filter;
216             }
217             # Constants/barewords do not begin with .
218             elsif ( $filter !~ /^[\$.]/ ) {
219             # If it's not a reserved word, it's a string
220             # XXX: This is a very poor decision...
221 44         145 return $filter;
222             }
223              
224 180 100       432 if ( is_empty $doc ) {
225 2         6 return empty;
226             }
227              
228 178         526 my @keys = split /[.]/, $filter;
229 178 100 66     448 my $subdoc = $keys[0] && $keys[0] eq '$' ? $orig_doc : $doc;
230 178         456 for my $key ( @keys[1..$#keys] ) {
231 177 100       711 if ( $key =~ /^\[\]$/ ) {
    100          
    50          
232 1         2 return @{ $subdoc };
  1         6  
233             }
234             elsif ( $key =~ /^\[(\d+)\]$/ ) {
235 5         22 $subdoc = $subdoc->[ $1 ];
236             }
237             elsif ( $key =~ /^\w+$/ ) {
238 171         401 $subdoc = $subdoc->{ $key };
239             }
240             else {
241 0         0 die "Invalid filter key '$key'";
242             }
243             }
244 177         689 return $subdoc;
245             }
246              
247             # Binary operators (binops)
248             elsif ( $filter =~ /^((?&FILTER)|(?&FUNC_NAME)(?:\(\s*(?&EXPR)\s*\))?)\s+((?&OP))\s+((?&EXPR))$GRAMMAR$/ ) {
249 88         373 my ( $lhs_filter, $cond, $rhs_filter ) = ( $1, $2, $3 );
250 88 100       189 if ( $cond eq '=' ) {
251             # Get the referent from the left-hand side
252 29         88 my @keys = split /[.]/, $lhs_filter;
253 29 50 33     100 my $subdoc = $keys[0] && $keys[0] eq '$' ? \$orig_doc : \$doc;
254 29         84 for my $key ( @keys[1..$#keys] ) {
255 29 50       126 if ( $key =~ /^\[(\d+)\]$/ ) {
    50          
256 0         0 $subdoc = \( $$subdoc->[ $1 ] );
257             }
258             elsif ( $key =~ /^\w+$/ ) {
259 29         85 $subdoc = \( $$subdoc->{ $key } );
260             }
261             else {
262 0         0 die "Invalid filter key '$key'";
263             }
264             }
265              
266 29         112 my $rhs_value = $class->filter( $rhs_filter, $doc, $scope, $orig_doc );
267 29   50     718 diag( 1, join " ", "BINOP:", $lhs_filter, $cond, $rhs_value // '' );
268 29         52 $$subdoc = $rhs_value;
269 29         116 return $doc; # Assignment does not change current document
270             }
271             else {
272 59         211 my $lhs_value = $class->filter( $lhs_filter, $doc, $scope, $orig_doc );
273 59         149 my $rhs_value = $class->filter( $rhs_filter, $doc, $scope, $orig_doc );
274 59   50     369 diag( 1, join " ", "BINOP:", $lhs_value // '', $cond, $rhs_value // '' );
      50        
275             # These operators suppress undef warnings, treating undef as just
276             # another value. Undef will never be treated as '' or 0 here.
277 59 100       232 if ( $cond eq 'eq' ) {
    100          
    100          
    100          
    100          
    100          
    100          
    50          
278 10 100 66     73 return defined $lhs_value == defined $rhs_value
279             && $lhs_value eq $rhs_value ? true : false;
280             }
281             elsif ( $cond eq 'ne' ) {
282 4 100 66     21 return defined $lhs_value != defined $rhs_value
283             || $lhs_value ne $rhs_value ? true : false;
284             }
285             elsif ( $cond eq '==' ) {
286 14 100 66     84 return defined $lhs_value == defined $rhs_value
287             && $lhs_value == $rhs_value ? true : false;
288             }
289             elsif ( $cond eq '!=' ) {
290 4 100 66     28 return defined $lhs_value != defined $rhs_value
291             || $lhs_value != $rhs_value ? true : false;
292             }
293             # These operators allow undef warnings, since equating undef to 0 or ''
294             # can be a cause of problems.
295             elsif ( $cond eq '>' ) {
296 6 100       22 return $lhs_value > $rhs_value ? true : false;
297             }
298             elsif ( $cond eq '>=' ) {
299 9 100       32 return $lhs_value >= $rhs_value ? true : false;
300             }
301             elsif ( $cond eq '<' ) {
302 6 100       20 return $lhs_value < $rhs_value ? true : false;
303             }
304             elsif ( $cond eq '<=' ) {
305 6 100       40 return $lhs_value <= $rhs_value ? true : false;
306             }
307             }
308             }
309              
310             # Conditional (if/then/else)
311             # NOTE: If we're capturing using $EXPR, then we _must_ use named captures,
312             # because $EXPR has captures in itself
313             elsif ( $filter =~ /^if\s+(?$EXPR)\s+then\s+(?$FILTER)(?:\s+else\s+(?$FILTER))?$/ ) {
314 4         53 my ( $expr, $true_filter, $false_filter ) = @+{qw( expr true false )};
315 4         39 my $expr_value = $class->filter( $expr, $doc, $scope, $orig_doc );
316 4 100       86 if ( $expr_value ) {
317 3         30 return $class->filter( $true_filter, $doc, $scope, $orig_doc );
318             }
319             else {
320 1 50       10 return $false_filter ? $class->filter( $false_filter, $doc, $scope, $orig_doc ) : ();
321             }
322             }
323              
324             # , does multiple filters, yielding multiple documents
325             # This must be the least-specific rule because of all the other
326             # possible uses of the comma
327             # XXX: In the future, this should be used to parse function
328             # arguments to allow for recursion
329             elsif ( $filter =~ /,/ ) {
330 6         45 my @filters = split /\s*,\s*/, $filter;
331 6         17 return map { $class->filter( $_, $doc, $scope, $orig_doc ) } @filters;
  16         55  
332             }
333              
334             else {
335 0         0 die "Could not parse filter '$filter'\n";
336             }
337 0         0 return;
338             }
339              
340             1;
341              
342             __END__