From a7e1e09b2433422cb162fd7ea4c7382c28977d7c Mon Sep 17 00:00:00 2001 From: Reinhold Kainhofer <reinhold@kainhofer.com> Date: Tue, 18 Jun 2013 20:08:57 +0200 Subject: [PATCH] Implement functions and unary operators The shunting yard algorithm had to be severely extended (by using a temporary operand stack for the function arguments, and by storing the type of the previous token to distinguish unary operators from binary) --- .../de-DE.plg_vmshipment_rules_shipping.ini | 1 + .../en-GB.plg_vmshipment_rules_shipping.ini | 2 + rules_shipping_advanced.php | 221 ++++++++++++++---- rules_shipping_base.php | 55 ++++- 4 files changed, 229 insertions(+), 50 deletions(-) diff --git a/language/de-DE/de-DE.plg_vmshipment_rules_shipping.ini b/language/de-DE/de-DE.plg_vmshipment_rules_shipping.ini index 2473863..05b9644 100644 --- a/language/de-DE/de-DE.plg_vmshipment_rules_shipping.ini +++ b/language/de-DE/de-DE.plg_vmshipment_rules_shipping.ini @@ -37,6 +37,7 @@ VMSHIPMENT_RULES_UNKNOWN_VARIABLE="Unknown variable '%s' in rule '%s'" VMSHIPMENT_RULES_UNKNOWN_OPERATOR="Unknown operator '%s' in shipment rule '%s'" VMSHIPMENT_RULES_PARSE_MISSING_PAREN="Error during parsing expression '%s': Opening parenthesis cannot be found!" VMSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED="Error during parsing expression '%s': A parenthesis was not closed properly!" +VMSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED="Error during parsing expression '%s': A function call was not closed properly!" VMSHIPMENT_RULES_EVALUATE_NONNUMERIC="Encountered term '%s' during evaluation, that does not evaluate to a numeric value! (Full rule: '%s')" VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR="Syntax error during evaluation, RPN is not well formed! (Full rule: '%s')" diff --git a/language/en-GB/en-GB.plg_vmshipment_rules_shipping.ini b/language/en-GB/en-GB.plg_vmshipment_rules_shipping.ini index 696fe5f..79bf941 100644 --- a/language/en-GB/en-GB.plg_vmshipment_rules_shipping.ini +++ b/language/en-GB/en-GB.plg_vmshipment_rules_shipping.ini @@ -40,10 +40,12 @@ VMSHIPMENT_RULES_UNKNOWN_VARIABLE="Unknown variable '%s' in rule '%s'" VMSHIPMENT_RULES_UNKNOWN_OPERATOR="Unknown operator '%s' in shipment rule '%s'" VMSHIPMENT_RULES_PARSE_MISSING_PAREN="Error during parsing expression '%s': Opening parenthesis cannot be found!" VMSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED="Error during parsing expression '%s': A parenthesis was not closed properly!" +VMSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED="Error during parsing expression '%s': A function call was not closed properly!" VMSHIPMENT_RULES_EVALUATE_NONNUMERIC="Encountered term '%s' during evaluation, that does not evaluate to a numeric value! (Full rule: '%s')" VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR="Syntax error during evaluation, RPN is not well formed! (Full rule: '%s')" VMSHIPMENT_RULES_EVALUATE_UNKNOWN_OPERATOR="Unknown operator '%s' encountered during evaluation of rule '%s'." +VMSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION="Unknown function '%s' encountered during evaluation of rule '%s'." VMSHIPMENT_RULES_EVALUATE_UNKNOWN_ERROR="Unknown error occurred during evaluation of rule '%s'." VMSHIPMENT_RULES_EVALUATE_ASSIGNMENT_TOPLEVEL="Assignments are not allows inside expressions (rule given was '%s')" VMSHIPMENT_RULES_EVALUATE_UNKNOWN_VALUE="Evaluation yields unknown value while evaluating rule part '%s'." \ No newline at end of file diff --git a/rules_shipping_advanced.php b/rules_shipping_advanced.php index 30ff731..c95e74a 100644 --- a/rules_shipping_advanced.php +++ b/rules_shipping_advanced.php @@ -28,6 +28,26 @@ if (!class_exists ('plgVmShipmentRules_Shipping_Base')) { require (dirname(__FILE__).DS.'rules_shipping_base.php'); } + +function print_array($obj) { + $res = ""; + if (is_array($obj)) { + $res .= "array("; + $sep = ""; + foreach ($obj as $e) { + $res .= $sep . print_array($e); + $sep = ", "; + } + $res .= ")"; + } elseif (is_string($obj)) { + $res .= "\"$obj\""; + } else { + $res .= (string)$obj; + } + return $res; +} + + /** Shipping costs according to general rules. * Derived from the standard plugin, no need to change anything! The standard plugin already uses the advanced rules class defined below, if it can be found */ @@ -85,6 +105,7 @@ class plgVmShipmentRules_Shipping_Advanced extends plgVmShipmentRules_Shipping_B */ class ShippingRule_Advanced extends ShippingRule { var $operators = array( + ".-" => 100, ".+" => 100, "^" => 70, "*" => 60, "/" => 60, "%" => 60, "+" => 50, "-" => 50, @@ -94,7 +115,11 @@ class ShippingRule_Advanced extends ShippingRule { " OR " => 20, "OR" => 20, "=" => 10, - "(" => 0, ")" =>0 ); + "(" => 0, ")" =>0 + ); + var $unary_ops = array( + "-" => ".-", "+" => ".+" + ); function __construct ($rule, $countries, $tax_id) { parent::__construct ($rule, $countries, $tax_id); @@ -105,7 +130,7 @@ class ShippingRule_Advanced extends ShippingRule { $str_re = '/("[^"]*"|\'[^\']*\')/'; $strings = preg_split($str_re, $expression, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY); // Then split all other parts of the expression at the operators - $op_re = ':\s*( OR |&&| AND |<=|=>|>=|=>|<>|!=|==|<|=|>|~|\+|-|\*|/|%|\(|\)|\^)\s*:'; + $op_re = ':\s*( OR |&&| AND |<=|=>|>=|=>|<>|!=|==|<|=|>|~|\+|-|\*|/|%|\(|\)|\^|,)\s*:'; $atoms = array(); foreach ($strings as $s) { if (preg_match($str_re, $s)) { @@ -115,30 +140,13 @@ class ShippingRule_Advanced extends ShippingRule { $atoms = array_merge ($atoms, $newatoms); } } - // Finally handle the double meaning of -: After an operator and before an operand, it is NOT an operator, but belongs to the operand - $prevop = true; - $result = array(); - $leftover = ''; - foreach ($atoms as $a) { - if ($a == "-") { - $prev = end($result); - if (is_null ($prev) || (preg_match ($op_re, $prev) && $prev != ')')) { - $leftover = $a; - } else { - $result[] = $leftover.$a; - $leftover = ''; - } - } else { - $result[] = $leftover.$a; - $leftover = ''; - } - } - return $result; + return $atoms; } - /** parse the mathematical expressions (following Knuth 1962): + /** parse the mathematical expressions using the Shunting Yard Algorithm by Dijkstra (with some extensions to allow arbitrary functions): * First parse the string into an array of tokens (operators and operands) by a simple regexp with known operators as separators) + * TODO: Update this description to include unary operators and general function calls * Then convert the infix notation into postfix (RPN), taking care of operator precedence * 1) Initialize empty stack and empty result variable * 2) Read infix expression from left to right, one atom at a time @@ -153,7 +161,10 @@ class ShippingRule_Advanced extends ShippingRule { * 6b) push them to the result (not the opening parenthesis, of course) * 7) At the end of the input, pop all operators from the stack and onto the result * - * Afterwards, convert this RPN list into an expression tree to be evaluate + * Afterwards, convert this RPN list into an expression tree to be evaluated + * + * For the full algorithm, including function parsing, see Wikipedia: + * http://en.wikipedia.org/wiki/Shunting_yard_algorithm * */ function parseRulePart($rulepart) { @@ -180,32 +191,104 @@ class ShippingRule_Advanced extends ShippingRule { $is_assignment = false; $stack = array (); // 1) - $rpn = array (); + $prev_token_operator = false; + $function_args = array(); + $out_stack = array(); foreach ($atoms as $a) { // 2) - if (!isset($this->operators[$a])) { // 3) Operand - array_push ($rpn, $a); - } elseif ($a == "(") { // 5) parenthesis - array_push ($stack, $a); + + if ($a == ",") { // A function argument separator + // pop-and-apply all operators back to the left function paren + while (count($stack)>0) { // 4a) + $op = array_pop ($stack); + if ($op != "FUNCTION(") { + array_push ($out_stack, $op); + } else { + // No unary operator -> add it back to stack, exit loop + array_push ($stack, $op); + break; + } + } while (0); + $this_func = array_pop($function_args); + // Add current output stack as argument, reset temporary output stack + if (!empty($out_stack)) $this_func[] = $out_stack; + $function_args[] = $this_func; + $out_stack = array(); + $prev_token_operator = true; + + } elseif ($a == "(" and !$prev_token_operator) { // 5) parenthesis after operand -> FUNCTION CALL + array_push ($stack, "FUNCTION("); + // retrieve function name from RPN list (remove last entry from operand stack!) + $function = array_pop ($out_stack); + $new_stack = array(); + // Set up function call data structure on function_args stack: + $function_args[] = array(/* old operand stack: */$out_stack, $function); + // Use a the temporary operand stack until the closing paren restores the previous operand stack again + $out_stack = array(); + $prev_token_operator = true; + + } elseif ($a == "(" and $prev_token_operator) { // 5) real parenthesis + $stack[] = $a; + $prev_token_operator = true; + } elseif ($a == ")") { // 6) parenthesis do { $op=array_pop($stack); // 6a) - if (!is_null($op) && ($op != "(")) { - array_push ($rpn, $op); // 6b) - } else { - if ($op != "(") { - // If no ( can be found, the expression is wrong! - JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_MISSING_PAREN', $rulepart), 'error'); + if ($op == "(") { + break; // We have found the opening parenthesis + } elseif ($op =="FUNCTION(") { // Function call + // Remove function info from the functions stack; Format is array(PREVIOUS_OPERAND_STACK, FUNCTION, ARGS...) + $this_func = array_pop ($function_args); + // Append last argument (if not empty) + if (!empty($out_stack)) $this_func[] = $out_stack; + // restore old output/operand stack + $out_stack = array_shift($this_func); + // Function name is the next entry + $function = array_shift($this_func); + // All other entries are function arguments, so append them to the current operand stack + foreach ($this_func as $a) { + foreach ($a as $aa) { + $out_stack[] = $aa; + } } + $out_stack[] = array("FUNCTION", $function, count($this_func)); break; // We have found the opening parenthesis + } elseif (!is_null($op)) { + $out_stack[]=$op; // 6b) "normal" operators + } else { + // no ( and no operator, so the expression is wrong! + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_MISSING_PAREN', $rulepart), 'error'); + break; } } while (true); - } else { // 4) operators - // For operators, pop operators from the stack until you reach an opening parenthesis, + $prev_token_operator = false; + + } elseif (isset($this->unary_ops[$a]) && $prev_token_operator) { // 4) UNARY operators + // Unary and binary operators need to be handled differently: + // Unary operators must only pop other unary operators, never any binary operator + $unary_op = $this->unary_ops[$a]; + // For unary operators, pop other unary operators from the stack until you reach an opening parenthesis, // an operator of lower precedence, or a right associative symbol of equal precedence. + while (count($stack)>0) { // 4a) + $op = array_pop ($stack); + // Remove all other unary operators: + if (in_array ($op, $this->unary_ops)) { + array_push ($out_stack, $op); + } else { + // No unary operator -> add it back to stack, exit loop + array_push ($stack, $op); + break; + } + } while (0); + array_push ($stack, $unary_op); // 4b) + $prev_token_operator = true; + + } elseif (isset($this->operators[$a])) { // 4) BINARY operators $prec = $this->operators[$a]; $is_condition |= in_array($a, $condition_ops); $is_assignment |= ($a == "="); + // For operators, pop operators from the stack until you reach an opening parenthesis, + // an operator of lower precedence, or a right associative symbol of equal precedence. while (count($stack)>0) { // 4a) $op = array_pop ($stack); // The only right-associative operator is =, which we allow at most once! @@ -218,10 +301,15 @@ class ShippingRule_Advanced extends ShippingRule { array_push ($stack, $op); // 4b) break; } else { - array_push ($rpn, $op); + array_push ($out_stack, $op); } } while (0); array_push ($stack, $a); // 4b) + $prev_token_operator = true; + + } else { // 3) Everything else is an Operand + $out_stack[] = $a; + $prev_token_operator = false; } } // Finally, pop all operators from the stack and append them to the result @@ -230,11 +318,15 @@ class ShippingRule_Advanced extends ShippingRule { if ($op == "(") { JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_PAREN_NOT_CLOSED', $rulepart), 'error'); } else { - array_push ($rpn, $op); + array_push ($out_stack, $op); } } + if (!empty($function_args)) { + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_PARSE_FUNCTION_NOT_CLOSED', $rulepart), 'error'); + } + - /** Now, turn the RPN into an expression tree (i.e. "evaluate" it into a tree structure, according to Knuth: + /** Now, turn the RPN into an expression tree (i.e. "evaluate" it into a tree structure), according to Knuth: * 1) Initialize an empty stack * 2) Read the RPN from left to right * 3) If operand, push it onto the stack @@ -248,11 +340,31 @@ class ShippingRule_Advanced extends ShippingRule { */ $stack=array(); // 1) - foreach ($rpn as $e) { // 2) - if (!isset($this->operators[$e])) { // 3) - // Operand => push onto stack - array_push ($stack, $e); - } else { // 4) + foreach ($out_stack as $e) { // 2) + if (is_array($e) && $e[0]=="FUNCTION") { // A function call (#args is saved as $e[2], so remove that number of operands from the stack) + $function = $e[1]; + $argc = $e[2]; + $args = array(); + for ($i = $argc; $i > 0; $i--) { + $a = array_pop($stack); + array_unshift($args, $a); + } + array_unshift($args, $function); + array_unshift($args, "FUNCTION"); + $stack[] = $args; + } elseif (in_array($e, $this->unary_ops)) { // 4) unary operators + // Operator => apply to the last value on the stack + if (count($stack)<1) { // 4d) + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR', $rulepart), 'error'); + array_push($stack, 0); + continue; + } + $o1 = array_pop($stack); + // Special-case chained comparisons: if e is a comparison, and operator(o1) is also a comparison, + // insert the arguments to the existing comparison instead of creating a new one + $op = array ($e, $o1); // 4b) + array_push ($stack, $op); // 4c) + } elseif (isset($this->operators[$e])) { // 4) binary operators // Operator => apply to the last two values on the stack if (count($stack)<2) { // 4d) JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_SYNTAXERROR', $rulepart), 'error'); @@ -276,7 +388,11 @@ class ShippingRule_Advanced extends ShippingRule { $op = array ($e, $o1, $o2); // 4b) } array_push ($stack, $op); // 4c) + } else { // 3) + // Operand => push onto stack + array_push ($stack, $e); } + } // 5a) if (count($stack) != 1) { @@ -307,3 +423,20 @@ class ShippingRule_Advanced extends ShippingRule { } // No closing tag +/*$rule = new ShippingRule_Advanced("", array(), 0); +$rp="a+b+(-1+d)"; +$rp="1+maxx(a,b)"; +$rp="f(1,2,+)"; +$rp="1+year()"; +$rp = "1+max( 1,2,3,4,5) + min(9,10,101)"; +$rp = "abs(0-1.9999)"; +$rp="max(1,min(0,5,7), 9)"; +// $rp="f()"; +print_r($rule->tokenize_expression($rp)); + +$rule->parseRulePart($rp); +print_r($rule->conditions); +print_r($rule->shipping); + +print_r($rule->evaluateTerm($rule->shipping, array())); +*/ \ No newline at end of file diff --git a/rules_shipping_base.php b/rules_shipping_base.php index ab55f09..7c4b97f 100644 --- a/rules_shipping_base.php +++ b/rules_shipping_base.php @@ -704,6 +704,40 @@ class ShippingRule { return true; } + function evaluateFunction ($function, $args) { + $func = strtolower($function); + // Functions with no argument: + if (count($args) == 0) { + switch ($func) { + case "second": return getdate()["seconds"]; break; + case "minute": return getdate()["minutes"]; break; + case "hour": return getdate()["hours"]; break; + case "day": return getdate()["mday"]; break; + case "weekday":return getdate()["wday"]; break; + case "month": return getdate()["mon"]; break; + case "year": return getdate()["year"]; break; + case "yearday":return getdate()["yday"]; break; + } + } + // Functions with exactly one argument: + if (count($args) == 1) { + switch ($func) { + case "round": return round($args[0]); break; + case "ceil": return ceil ($args[0]); break; + case "floor": return floor($args[0]); break; + case "abs": return abs($args[0]); break; + } + } + // Functions with variable number of args + switch ($func) { + case "max": return max($args); break; + case "min": return min($args); break; + } + // No known function matches => print an error, return 0 + JFactory::getApplication()->enqueueMessage(JText::sprintf('VMSHIPMENT_RULES_EVALUATE_UNKNOWN_FUNCTION', $function, $this->rulestring), 'error'); + return 0; + } + function evaluateTerm ($expr, $vals) { if (is_null($expr)) { return $expr; @@ -724,17 +758,24 @@ class ShippingRule { $op = array_shift($expr); $args = array(); $evaluate = true; + if ($op == "FUNCTION") { + $evaluate = false; + } foreach ($expr as $e) { $term = $evaluate ? ($this->evaluateTerm($e, $vals)) : $e; if ($op == 'comparison') { // For comparisons, we only evaluate every other term (the operators are NOT evaluated!) $evaluate = !$evaluate; } + if ($op == "FUNCTION") { + $evaluate = true; + } if (is_null($term)) return null; $args[] = $term; } $res = false; switch ($op) { + // Logical operators: case 'OR': case ' OR ': foreach ($args as $a) { $res = ($res || $a); }; break; case '&&': @@ -742,6 +783,7 @@ class ShippingRule { case ' AND ': $res = true; foreach ($args as $a) { $res = ($res && $a); }; break; case 'in': $needle = array_shift($args); $res = in_array($needle, $args); break; + // Comparisons: case '<': case '<=': case '=<': @@ -756,6 +798,11 @@ class ShippingRule { case 'comparison': $res = $this->evaluateComparison($args, $vals); break; + // Unary operators: + case '.-': $res = -$args[0]; break; + case '.+': $res = $args[0]; break; + + // Binary operators case "+": $res = ($args[0] + $args[1]); break; case "-": $res = ($args[0] - $args[1]); break; case "*": $res = ($args[0] * $args[1]); break; @@ -763,12 +810,8 @@ class ShippingRule { case "%": $res = (fmod($args[0], $args[1])); break; case "^": $res = ($args[0] ^ $args[1]); break; - # TODO: Document these functions: - case "round": $res = round($args[0]); break; - case "ceil": $res = ceil($args[0]); break; - case "floor": $res = floor($args[0]); break; - case "max": $res = max($args); break; - case "min": $res = min($args); break; + // Functions: + case "FUNCTION": $func = array_shift($args); $res = $this->evaluateFunction($func, $args); break; default: $res = false; } -- GitLab