From 95e51a2e45741d5d6577612911b7bfa8550507d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Mystik=20Jon=C3=A1=C5=A1?= Date: Thu, 14 Oct 2021 16:10:55 +0200 Subject: [PATCH] Custom operators and filters in conditions (#19) --- docs/README.md | 37 +++++++++-- src/TextTemplate.php | 99 +++++++++++++++++++----------- src/buildInOperators.inc.php | 35 +++++++++++ test/operator.phpt | 17 +++++ test/unit/tpls/02_basic_if/_in.php | 3 +- test/unit/tpls/02_basic_if/_in.txt | 5 +- test/unit/tpls/02_basic_if/out.txt | 3 +- 7 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 src/buildInOperators.inc.php create mode 100644 test/operator.phpt diff --git a/docs/README.md b/docs/README.md index 91a70c0..5317f91 100644 --- a/docs/README.md +++ b/docs/README.md @@ -151,6 +151,39 @@ Complex logical expressions can be made using && (and), || (or) and brackets. {/if} ``` +You can use filters on values used in comparsions. + +``` +{if someArray|count > otherArray|count} + someArray has more items than otherArray +{/if} +``` + +### Adding Operators + +You can add custom operators for use in conditions. + +Adding a new Operator: + +```php +$tt->addOperator("contains", + function ($operand1, $operand2) { + return strpos($operand1, $operand2) !== false; + } +); +``` + +### Predefined Operators + +| Operator | Description | +|----------------|--------------------------------------------| +| == | Equal | +| != | Not equal | +| \> | Greater than | +| < | Less than | +| >= | Greater than or equal | +| <= | Less than or equal | + ## Conditions (else) ``` {if someVarName == "SomeValue"} @@ -386,10 +419,6 @@ $textTemplate->setOpenCloseTagChars("{{", "}}"); The above example will listen to `{{tag}}{{/tag}}`. -## Limitations - -The logic-Expression-Parser won't handle logic connections (OR / AND) in conditions. - ## Benchmark Although the parser is build of pure regular-expressions, I tried to avoid too expensive constructions like diff --git a/src/TextTemplate.php b/src/TextTemplate.php index 356a1ea..67bf104 100644 --- a/src/TextTemplate.php +++ b/src/TextTemplate.php @@ -36,6 +36,7 @@ // Require only when Class is first loaded by classloader require_once __DIR__."/buildInFilters.inc.php"; require_once __DIR__."/buildInFunctions.inc.php"; +require_once __DIR__."/buildInOperators.inc.php"; class TextTemplate { @@ -45,11 +46,13 @@ class TextTemplate { public static $__DEFAULT_FILTER = []; public static $__DEFAULT_FUNCTION = []; + public static $__DEFAULT_OPERATOR = []; public static $__DEFAULT_SECTIONS = []; private $mTemplateText; private $mFilter = []; private $mFunctions = []; + private $mOperators = []; private $OC = "{"; private $OCE = "\{"; @@ -67,6 +70,7 @@ public function __construct ($text="") { $this->mTemplateText = $text; $this->mFilter = self::$__DEFAULT_FILTER; $this->mFunctions = self::$__DEFAULT_FUNCTION; + $this->mOperators = self::$__DEFAULT_OPERATOR; $this->sections = self::$__DEFAULT_SECTIONS; } @@ -137,6 +141,22 @@ public function addFunction ($functionName, callable $callback) { return $this; } + + /** + * Register a operator you can use in conditions + * + * + * @param $operator + * @param callable $callback + * + * @return $this + */ + public function addOperator ($operator, callable $callback) { + $this->mOperators[$operator] = $callback; + return $this; + } + + public function addPlugin (TextTemplatePlugin $plugin) { $plugin->registerPlugin($this); @@ -343,30 +363,45 @@ private function _applyFilter ($filterNameAndParams, $value) { } - private function _parseValueOfTags ($context, $match, $softFail) { - $chain = explode("|", $match); + private function _parseItemChain ($item) { + $chain = explode("|", $item); for ($i=0; $i_parseItemChain($item); $varName = trim (array_shift($chain)); if ($varName === "__CONTEXT__") { $value = "\n----- __CONTEXT__ -----\n" . var_export($context, true) . "\n----- / __CONTEXT__ -----\n"; } else { - $value = $this->_getValueByName($context, $varName, $softFail); + $value = $this->_getItemValue($context, $varName, $softFail); } foreach ($chain as $curName) { + echo json_encode($value) ."\n"; $value = $this->_applyFilter($curName, $value); } + echo json_encode($value) ."\n"; return $value; } + private function _parseValueOfTags ($context, $item, $softFail) { + $value = $this->_getItemValueWithFilters($context, $item, $softFail); + + $chain = $this->_parseItemChain($item); + if ( ! in_array("raw", $chain)) { + $value = $this->_applyFilter("_DEFAULT_", $value); + } + + return $value; + } + private function _runFor (&$context, $content, $cmdParam, $softFail) { if ( ! preg_match ('/([a-z0-9\.\_]+) in ([a-z0-9\.\_]+)/i', $cmdParam, $matches)) { @@ -405,7 +440,7 @@ private function _runFor (&$context, $content, $cmdParam, $softFail) { } - private function _getItemValue ($compName, $context, $softFail) { + private function _getItemValue ($context, $compName, $softFail) { if (preg_match ('/^("|\')(.*?)\1$/i', $compName, $matches)) return $matches[2]; // string Value if (is_numeric($compName)) { @@ -423,18 +458,10 @@ private function _getItemValue ($compName, $context, $softFail) { private function _compareValues($operand1, $operand2, $operator) { - switch($operator) { - case "==": - return ($operand1 == $operand2); - case "!=": - return ($operand1 != $operand2); - case "<": - return ($operand1 < $operand2); - case ">": - return ($operand1 > $operand2); - default: - throw new TemplateParsingException("Unknown operator: '$operator'"); + if(!isset($this->mOperators[$operator])) { + throw new TemplateParsingException("Unknown operator: '$operator'"); } + return $this->mOperators[$operator]($operand1, $operand2); } @@ -445,26 +472,28 @@ private function _compareValues($operand1, $operand2, $operator) * @param $softFail * @return bool|string */ - private function _evaluateCondition($expression, $context, $softFail) + private function _evaluateCondition($context, $expression, $softFail) { - if(!preg_match('/(([\"\']?.*?[\"\']?)\s*(==|<|>|!=)\s*([\"\']?.*[\"\']?)|((!?)\s*(.*)))/i', $expression, $matches)) { + $operatorsRegExp = implode("|", array_filter(array_keys($this->mOperators), "preg_quote")); + + if(!preg_match('/(([\"\']?.*?[\"\']?)\s*(' . $operatorsRegExp .')\s*([\"\']?.*[\"\']?)|((!?)\s*(.*)))/i', $expression, $matches)) { throw new TemplateParsingException("Invalid expression: '$expression'"); } if(count($matches) == 8) { - $comp1 = $this->_getItemValue(trim($matches[7]), $context, $softFail); + $comp1 = $this->_getItemValueWithFilters($context, trim($matches[7]), $softFail); $operator = '=='; $comp2 = $matches[6] ? false : true; // ! prefix } elseif(count($matches) == 5) { - $comp1 = $this->_getItemValue(trim($matches[2]), $context, $softFail); + $comp1 = $this->_getItemValueWithFilters($context, trim($matches[2]), $softFail); $operator = trim($matches[3]); - $comp2 = $this->_getItemValue(trim($matches[4]), $context, $softFail); + $comp2 = $this->_getItemValueWithFilters($context, trim($matches[4]), $softFail); } else { throw new TemplateParsingException("Invalid expression: '$expression'"); } return $this->_compareValues($comp1, $comp2, $operator); } - private function _interpretExpressionValue(&$expressionComponents, &$index, $context, $softFail, $depth = 0) { + private function _interpretExpressionValue($context, &$expressionComponents, &$index, $softFail, $depth = 0) { if($index >= count($expressionComponents)) { throw new TemplateParsingException("Unexpected end of expression."); } @@ -476,16 +505,16 @@ private function _interpretExpressionValue(&$expressionComponents, &$index, $con throw new TemplateParsingException("Unexpected '$component' instead of value."); case "(": $index++; - return $this->_interpretExpression($expressionComponents, $index, $context, $softFail, $depth + 1); + return $this->_interpretExpression($context, $expressionComponents, $index, $softFail, $depth + 1); case "!(": $index++; - return !$this->_interpretExpression($expressionComponents, $index, $context, $softFail, $depth + 1); + return !$this->_interpretExpression($context, $expressionComponents, $index, $softFail, $depth + 1); default: - return $this->_evaluateCondition($component, $context, $softFail); + return $this->_evaluateCondition($context, $component, $softFail); } } - private function _interpretExpression(&$expressionComponents, &$index, $context, $softFail, $depth = 0) { + private function _interpretExpression($context, &$expressionComponents, &$index, $softFail, $depth = 0) { $value = null; while($index < count($expressionComponents)) { $component = $expressionComponents[$index]; @@ -495,14 +524,14 @@ private function _interpretExpression(&$expressionComponents, &$index, $context, throw new TemplateParsingException("Unexpected '$component'."); } $index++; - $value = $this->_interpretExpressionValue($expressionComponents, $index, $context, $softFail, $depth) && $value; + $value = $this->_interpretExpressionValue($context, $expressionComponents, $index, $softFail, $depth) && $value; break; case "||": if($value === null) { throw new TemplateParsingException("Unexpected '$component'."); } $index++; - $value = $this->_interpretExpressionValue($expressionComponents, $index, $context, $softFail, $depth) || $value; + $value = $this->_interpretExpressionValue($context, $expressionComponents, $index, $softFail, $depth) || $value; break; case ")": if($depth == 0) { @@ -513,7 +542,7 @@ private function _interpretExpression(&$expressionComponents, &$index, $context, if($value !== null) { throw new TemplateParsingException("Unexpected '$component'."); } - $value = $this->_interpretExpressionValue($expressionComponents, $index, $context, $softFail, $depth); + $value = $this->_interpretExpressionValue($context, $expressionComponents, $index, $softFail, $depth); } $index++; } @@ -531,7 +560,7 @@ private function _interpretExpression(&$expressionComponents, &$index, $context, * @param $softFail * @return bool|string */ - private function _evaluateConditionExpression($expression, $context, $softFail) + private function _evaluateConditionExpression($context, $expression, $softFail) { $expression = preg_replace("/\s+/", " ", $expression); $expression = preg_replace("/!\s+\(/", "!(", $expression); @@ -544,7 +573,7 @@ private function _evaluateConditionExpression($expression, $context, $softFail) ); $index = 0; try { - return $this->_interpretExpression($expressionComponents, $index, $context, $softFail); + return $this->_interpretExpression($context,$expressionComponents, $index, $softFail); } catch(TemplateParsingException $e) { throw new TemplateParsingException("Error parsing expression '$expression': " . $e->getMessage(), 0, $e); } @@ -574,7 +603,7 @@ private function _runIf (&$context, $content, $cmdParam, $softFail, &$ifConditio $ifConditionDidMatch = false; } - if ( ! $this->_evaluateConditionExpression($cmdParam, $context, $softFail)) { + if ( ! $this->_evaluateConditionExpression($context, $cmdParam, $softFail)) { return ""; } @@ -631,7 +660,7 @@ private function _parseFunctionParameters ($cmdParam, &$context, $softFail) { $paramArr = []; $cmdParamRest = preg_replace_callback('/(?[a-z0-9_]+)\s*=\s*(?((\"|\')(.*?)\4)|[a-z0-9\.\_]+)/i', function ($matches) use(&$paramArr, &$context, $softFail) { - $paramArr[$matches["name"]] = $this->_getItemValue($matches["sval"], $context, $softFail); + $paramArr[$matches["name"]] = $this->_getItemValue($context, $matches["sval"], $softFail); }, $cmdParam); $retAs = null; @@ -660,7 +689,7 @@ private function _parseBlock (&$context, $block, $softFail) { $result = preg_replace_callback("/({$this->OCE}(?!=)((?if|for|{$bCommands})(?[0-9]+))(?.*?){$this->CCE}(?.*?)\\n?{$this->OCE}\/\\2{$this->CCE}|{$this->OCE}(?!=)(?[a-z]+)\s*(?.*?){$this->CCE}|{$this->OCE}\=(?.+?){$this->CCE})/ism", function ($matches) use (&$context, $softFail) { if (isset ($matches["value"]) && $matches["value"] != null) { - return $this->_parseValueOfTags($context, $matches["value"], $softFail); + return $this-> _parseValueOfTags($context, $matches["value"], $softFail); } else if (isset ($matches["bcommand"]) && $matches["bcommand"] != null) { // Block-Commands diff --git a/src/buildInOperators.inc.php b/src/buildInOperators.inc.php new file mode 100644 index 0000000..7e19f5b --- /dev/null +++ b/src/buildInOperators.inc.php @@ -0,0 +1,35 @@ +"] = function ($operand1, $operand2) { + return $operand1 > $operand2; +}; + +TextTemplate::$__DEFAULT_OPERATOR["<"] = function ($operand1, $operand2) { + return $operand1 < $operand2; +}; + +TextTemplate::$__DEFAULT_OPERATOR[">="] = function ($operand1, $operand2) { + return $operand1 >= $operand2; +}; + +TextTemplate::$__DEFAULT_OPERATOR["<="] = function ($operand1, $operand2) { + return $operand1 <= $operand2; +}; diff --git a/test/operator.phpt b/test/operator.phpt new file mode 100644 index 0000000..0ee0b3e --- /dev/null +++ b/test/operator.phpt @@ -0,0 +1,17 @@ +addOperator("contains", function ($operand1, $operand2) { return strpos($operand1, $operand2) !== false; }); +Assert::equal("Hello", $p->apply(["text" => "Hello world"])); + diff --git a/test/unit/tpls/02_basic_if/_in.php b/test/unit/tpls/02_basic_if/_in.php index a50971a..de76711 100644 --- a/test/unit/tpls/02_basic_if/_in.php +++ b/test/unit/tpls/02_basic_if/_in.php @@ -5,5 +5,6 @@ "falseVal" => false, "value1" => "Some Value", "floatVal" => 1.234, - "emptyVal" => null + "emptyVal" => null, + "arrayVal" => ["A", "B", "C"] ]; \ No newline at end of file diff --git a/test/unit/tpls/02_basic_if/_in.txt b/test/unit/tpls/02_basic_if/_in.txt index 67c1c8e..e81de85 100644 --- a/test/unit/tpls/02_basic_if/_in.txt +++ b/test/unit/tpls/02_basic_if/_in.txt @@ -21,4 +21,7 @@ {if emptyVal} 8: Some empty string {/if} -9: Last text \ No newline at end of file +{if arrayVal|count > 2} +9: Array has more than 2 items +{/if} +10: Last text \ No newline at end of file diff --git a/test/unit/tpls/02_basic_if/out.txt b/test/unit/tpls/02_basic_if/out.txt index 5d3183c..8fe79a0 100644 --- a/test/unit/tpls/02_basic_if/out.txt +++ b/test/unit/tpls/02_basic_if/out.txt @@ -7,4 +7,5 @@ 6: falseVal is false 7: Yet another text 8: Some not empty string -9: Last text \ No newline at end of file +9: Array has more than 2 items +10: Last text \ No newline at end of file