File Coverage

blib/lib/Eval/Logic.pm
Criterion Covered Total %
statement 27 97 27.8
branch 10 42 23.8
condition 2 6 33.3
subroutine 6 13 46.1
pod 7 7 100.0
total 52 165 31.5


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             Eval::Logic - Evaluate simple logical expressions from a string.
4              
5             =head1 DESCRIPTION
6              
7             With this module simple logical expressions from strings which use logical
8             operators like and, or, not and the ternary operator can be evaluated.
9              
10             This module was created because I wanted to be able to use a simple argument
11             validator which can be fully configured from YAML. This module allows a
12             specification like "we require a_value and some_other_value, or a
13             a_third_option should be specified" to be expressed as a simple string
14             "(a_value && some_other_value) || a_third_option".
15              
16             The module uses eval() and while it does take care to check for anything
17             other than a logical expression you should take a lot of care when
18             evaluating expressions from an untrusted source (in fact, I would not
19             recommend doing that at all).
20              
21             =head1 SYNOPSIS
22              
23             $l = Eval::Logic->new ( '(a || b) && c' );
24             $l->evaluate ( a => 1, b => 0, c => 1 ); # returns 1 for true
25             $l->evaluate ( a => 1, b => 1, c => 0 ); # returns 0 for false
26             $l->evaluate_if_true ( 'a', 'b' ); # an alternative for that second example
27             $l->evaluate_if_false ( 'c' ); # and another alternative
28              
29             =head1 METHODS
30              
31             =cut
32              
33             package Eval::Logic;
34              
35 1     1   540 use strict;
  1         2  
  1         26  
36 1     1   4 use warnings;
  1         2  
  1         26  
37              
38 1     1   9 use Carp;
  1         2  
  1         55  
39 1     1   463 use Symbol;
  1         767  
  1         965  
40              
41             # Forbidden list if truth value names; these are Perl operators with regular
42             # names that cannot be overridden by using 'use subs'.
43             our @forbidden_tv_names = qw( or and not xor );
44              
45             =head2 new (constructor)
46              
47             $l = Eval::Logic->new ( 'a && b' );
48            
49             Create a new instance of Eval::Logic. Optionally an expression can be
50             specified which is immediately loaded in the object, see the expression
51             method for more information about the expression syntax.
52              
53             =cut
54              
55             sub new {
56 6     6 1 3016 my $class = shift;
57 6         20 my $self = bless { undef_default => undef }, $class;
58 6 50       24 $self->expression ( @_ ) if ( @_ );
59 0         0 return $self;
60             }
61              
62             =head2 expression
63              
64             $expression = $l->expression;
65             $l->expression ( 'a && b' );
66            
67             If called without an argument the current expression is returned, otherwise
68             the current expression in this object is replaced by whatever was specified.
69             If multiple strings are specified they are combined in a single expression
70             that will require all individual expressions to be true.
71              
72             An expression is a string in which the truth values are specified as simple
73             (bare) words which can contain letters, digits and underscores and which
74             must not begin with a digit. In addition to this, the Perl logical
75             operators && (and), || (or), ! (not) can be used, as well as the ternary ?:
76             operator and parentheses. Whitespace is ignored.
77              
78             The barewords TRUE and FALSE have a special meaning which you can probably
79             guess.
80              
81             The method will croak if the expression provided is invalid.
82              
83             =cut
84              
85             sub expression {
86 6     6 1 6 my $self = shift;
87 6 50       13 if ( @_ ) {
88              
89 6 50       11 my $exp = @_ > 1 ? join ( ' && ', map { '(' . $_ . ')' } @_ ) : $_[0];
  0         0  
90              
91 6         7 my %tv;
92 6         41 foreach my $v (
93             split / # split on anything that cannot be a truth value:
94             (?:
95             && | # and operator,
96             \|\| | # or operator,
97             ! | # not operator,
98             \? | # the first part of the ternary operator,
99             \: | # the second part of the ternary operator,
100             \( | # opening parentheses,
101             \) | # closing parentheses,
102             \s # any whitespace
103             )+
104             /x, $exp
105             ) {
106 8 50       14 if ( $v ) {
107 8 100 66     37 next if (( $v eq 'TRUE' ) || ( $v eq 'FALSE' ));
108 7 100       8 if ( grep { $v eq $_ } @forbidden_tv_names ) {
  28 100       54  
109 2         186 croak "Invalid truth value in expression, named identical to Perl reserved word: '$v'";
110             } elsif ( $v =~ /^[a-zA-Z_][a-zA-Z_0-9]*$/ ) {
111 1         3 $tv{$v} = undef;
112             } else {
113 4         495 croak "Syntax error or invalid truth value in expression: '$v'";
114             }
115             }
116             }
117              
118             # Test the expression by evaluating it.
119 0           $self->_eval ( $exp, %tv );
120            
121             # If we're here, the expression checked out.
122 0           $self->{tv} = [ keys %tv ];
123 0           $self->{exp} = $exp;
124            
125             } else {
126 0           return $self->{exp};
127             }
128             }
129              
130             =head2 evaluate
131              
132             $outcome = $l->evaluate ( a => 1, b => 0 );
133            
134             Evaluate the logic expression given the specified truth values. If no
135             default for undefined truth values is specified and some truth values are
136             not defined or not present, a warning is given.
137              
138             The outcome is returned as 1 for true or 0 for false.
139              
140             =cut
141              
142             sub evaluate {
143 0     0 1   my $self = shift;
144 0           my %specified_tv = @_;
145            
146 0 0 0       croak 'TRUE or FALSE specified as a variable truth value' if (( exists $specified_tv{TRUE} ) || ( exists $specified_tv{FALSE} ));
147            
148 0 0         if ( defined $self->{exp} ) {
149 0           my %tv;
150 0           foreach my $v ( @{$self->{tv}} ) {
  0            
151 0 0         if ( defined $specified_tv{$v} ) {
    0          
152 0           $tv{$v} = $specified_tv{$v};
153             } elsif ( defined $self->{undef_default} ) {
154 0           $tv{$v} = $self->{undef_default};
155             } else {
156 0 0         carp (( exists $specified_tv{$v} ? 'Undefined' : 'Unspecified' ) . " truth value $v defaults to false" );
157 0           $tv{$v} = 0;
158             }
159             }
160 0           return $self->_eval ( $self->{exp}, %tv );
161             } else {
162 0           carp "No expression, returning false";
163 0           return 0;
164             }
165             }
166              
167             =head2 evaluate_if_false
168              
169             $outcome = $l->evaluate_if_false ( 'a' );
170            
171             Evaluate the logic expression given the specified values to be false, and
172             all other values to be true. This is a shortcut to the evaluate method.
173              
174             =cut
175              
176 0     0 1   sub evaluate_if_false { shift->_eval_if ( 0, @_ ) }
177              
178             =head2 evaluate_if_true
179              
180             $outcome = $l->evaluate_if_true ( 'b' );
181            
182             Evaluate the logic expression given the specified values to be true, and all
183             other values to be false. This is a shortcut to the evaluate method.
184              
185             =cut
186              
187 0     0 1   sub evaluate_if_true { shift->_eval_if ( 1, @_ ) }
188              
189             =head2 truth_values
190              
191             @truth_values = $l->truth_values;
192            
193             Return a list of all variable truth values which are present in the
194             currently loaded expression.
195              
196             =cut
197              
198             sub truth_values {
199 0     0 1   my $self = shift;
200 0 0         if ( defined $self->{exp} ) {
201 0           return @{$self->{tv}};
  0            
202             } else {
203 0           carp "No expression, returning empty list";
204 0           return ();
205             }
206             }
207              
208             =head2 undef_default
209              
210             $default = $l->undef_default;
211             $l->undef_default ( $default );
212              
213             Returns the current default for undefined truth values if specified without
214             an argument, or sets the default value to the specified argument. If you
215             want undefined values to default to false you must explicitly call this
216             method with an argument that is defined and evaluates to false to suppress
217             warnings given about undefined values by the evaluate method.
218              
219             =cut
220              
221             sub undef_default {
222 0     0 1   my $self = shift;
223 0 0         if ( @_ ) {
224 0           $self->{undef_default} = $_[0];
225             } else {
226 0           return $self->{undef_default};
227             }
228             }
229            
230             #
231             # The _eval method does the work: it creates a piece of Perl code and then
232             # evaluates it. It will get a bit dirty in here.
233             #
234              
235             sub _eval {
236 0     0     my $self = shift;
237 0           my ( $exp, %tv ) = @_;
238            
239             # Make sure TRUE and FALSE always mean what they say.
240 0           $tv{TRUE} = 1;
241 0           $tv{FALSE} = 0;
242            
243             # Generate a piece of code in a 'scratch' package which we will clean
244             # before using it.
245 0           my $code = '';
246              
247             # To parse any error messages we count the number of lines added.
248 0           my $our_lines = 0;
249            
250             # Begin with the package declaration and declare the subroutine names
251             # we're using to prevent them from calling core subroutines.
252 0           $code .= 'package ' . __PACKAGE__ . "::Scratch;\n"; $our_lines++;
  0            
253 0           $code .= 'use subs qw(' . join ( ' ', keys %tv ) . ");\n"; $our_lines++;
  0            
254              
255             # Generate a constant subroutine for every value.
256 0           while ( my ( $name, $truth ) = each %tv ) {
257            
258             # For true we use 1, for false we use an empty list because that will
259             # always evaluate to false, even in list context (think about stuff like
260             # '(FALSE)' which must evaluate to false, and not to a list of one
261             # element).
262            
263 0 0         $code .= 'sub ' . $name . '(){' . ( $truth ? '1' : '()' ) . "}\n";
264 0           $our_lines++;
265             }
266            
267             # Finally we add the expression itself.
268 0           $code .= $exp . "\n;";
269            
270             # Reset the package namespace and evaluate the generated code block.
271 0           Symbol::delete_package __PACKAGE__ . '::Scratch';
272 0 0         my $outcome = eval $code ? 1 : 0;
273              
274 0 0         if ( my $error = $@ ) {
275            
276             # Some error messages are changed on the fly to make them clearer...
277             # hopefully.
278 0           $error =~ s/Too many arguments for @{[__PACKAGE__]}::Scratch::(\S+)/Truth value '$1' not followed by boolean operator/;
  0            
279            
280             # An error occurred while evaluating our code; try to determine the
281             # location of the error.
282 0 0         if ( $error =~ /(at \(eval [0-9]+\) line ([0-9]+))/ ) {
283 0           my ( $location_text, $error_line ) = ( $1, $2 );
284 0           $error_line -= $our_lines;
285 0 0         if ( $error_line > 0 ) { # the error was in the expression, change the error message to be more descriptive
286 0           $error =~ s/\Q$location_text\E/at line $error_line in logical expression/;
287 0           croak $error;
288             } else { # woops
289 0           croak "Eval::Logic internal error while evaluating expression: $error";
290             }
291             }
292            
293             # If we're still here we just repeat whatever error we got.
294 0           croak $error;
295            
296             }
297            
298             # Make sure we always return 1 for true and 0 for false.
299 0 0         return $outcome ? 1 : 0;
300            
301             }
302              
303             #
304             # General implementation of evaluate_if_(true|false)
305             #
306              
307             sub _eval_if {
308 0     0     my $self = shift;
309 0           my $truth = shift;
310 0           my @values = @_;
311 0 0         my %tv = map { $_ => $truth ? 0 : 1 } @{$self->{tv}};
  0            
  0            
312 0           foreach ( @values) { $tv{$_} = $truth }
  0            
313 0           return $self->evaluate ( %tv );
314             }
315              
316             =head1 AUTHOR
317              
318             Sebastiaan Hoogeveen
319              
320             =head1 COPYRIGHT
321              
322             Copyright (c) 2016 Sebastiaan Hoogeveen. All rights reserved. This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
323              
324             See http://www.perl.com/perl/misc/Artistic.html
325              
326             =cut
327              
328             1;