Skip to content
Snippets Groups Projects
Commit 475e030c authored by Elmar Ludwig's avatar Elmar Ludwig Committed by David Siegfried
Browse files

extend exTpl syntax, closes #896

Merge request studip/studip!507
parent 044134b1
No related branches found
No related tags found
No related merge requests found
...@@ -20,6 +20,7 @@ namespace exTpl; ...@@ -20,6 +20,7 @@ namespace exTpl;
class Context class Context
{ {
private $bindings; private $bindings;
private $escape;
private $parent; private $parent;
/** /**
...@@ -50,4 +51,30 @@ class Context ...@@ -50,4 +51,30 @@ class Context
return NULL; return NULL;
} }
/**
* Enables or disables automatic escaping for template values.
*
* @param callable $escape escape callback or NULL
*/
public function autoescape($escape)
{
$this->escape = $escape;
}
/**
* Escapes the given value using the configured strategy.
*
* @param mixed $value expression value
*/
public function escape($value)
{
if (isset($this->escape)) {
$value = call_user_func($this->escape, $value);
} else if ($this->parent) {
$value = $this->parent->escape($value);
}
return $value;
}
} }
...@@ -71,6 +71,14 @@ class SymbolExpression implements Expression ...@@ -71,6 +71,14 @@ class SymbolExpression implements Expression
$this->name = $name; $this->name = $name;
} }
/**
* Returns the name of this symbol.
*/
public function name()
{
return $this->name;
}
/** /**
* Returns the value of this expression. * Returns the value of this expression.
* *
...@@ -132,6 +140,22 @@ class NotExpression extends UnaryExpression ...@@ -132,6 +140,22 @@ class NotExpression extends UnaryExpression
} }
} }
/**
* RawExpression represents the "raw" filter function.
*/
class RawExpression extends UnaryExpression
{
/**
* Returns the value of this expression.
*
* @param Context $context symbol table
*/
public function value($context)
{
return $this->expr->value($context);
}
}
/** /**
* BinaryExpression represents a binary operator. * BinaryExpression represents a binary operator.
*/ */
...@@ -261,3 +285,45 @@ class ConditionExpression implements Expression ...@@ -261,3 +285,45 @@ class ConditionExpression implements Expression
$this->left->value($context) : $this->right->value($context); $this->left->value($context) : $this->right->value($context);
} }
} }
/**
* FunctionExpression represents a function call.
*/
class FunctionExpression implements Expression
{
protected $name;
protected $arguments;
/**
* Initializes a new Expression instance.
*
* @param Expression $name function name
* @param array $arguments function arguments
*/
public function __construct(Expression $name, $arguments)
{
$this->name = $name;
$this->arguments = $arguments;
}
/**
* Returns the value of this expression.
*
* @param Context $context symbol table
*/
public function value($context)
{
$callable = $this->name->value($context);
$arguments = array();
foreach ($this->arguments as $expr) {
$arguments[] = $expr->value($context);
}
if (is_callable($callable)) {
return call_user_func_array($callable, $arguments);
}
return NULL;
}
}
...@@ -81,7 +81,13 @@ class ExpressionNode implements Node ...@@ -81,7 +81,13 @@ class ExpressionNode implements Node
*/ */
public function render($context) public function render($context)
{ {
return $this->expr->value($context); $value = $this->expr->value($context);
if (!($this->expr instanceof RawExpression)) {
$value = $context->escape($value);
}
return $value;
} }
} }
...@@ -121,20 +127,26 @@ class ArrayNode implements Node ...@@ -121,20 +127,26 @@ class ArrayNode implements Node
/** /**
* IteratorNode represents a single iterator tag: * IteratorNode represents a single iterator tag:
* "{foreach ARRAY}...{endforeach}". * "{foreach ARRAY [as [KEY =>] VALUE]}...{endforeach}".
*/ */
class IteratorNode extends ArrayNode class IteratorNode extends ArrayNode
{ {
protected $expr; protected $expr;
protected $key_name;
protected $val_name;
/** /**
* Initializes a new Node instance with the given expression. * Initializes a new Node instance with the given expression.
* *
* @param Expression $expr expression object * @param Expression $expr expression object
* @param string $key_name name of variable on each iteration
* @param string $val_name name of variable on each iteration
*/ */
public function __construct(Expression $expr) public function __construct(Expression $expr, $key_name, $val_name)
{ {
$this->expr = $expr; $this->expr = $expr;
$this->key_name = $key_name;
$this->val_name = $val_name;
} }
/** /**
...@@ -149,7 +161,7 @@ class IteratorNode extends ArrayNode ...@@ -149,7 +161,7 @@ class IteratorNode extends ArrayNode
$result = ''; $result = '';
if (is_array($values) && is_int(key($values))) { if (is_array($values) && is_int(key($values))) {
$bindings = array('index' => &$key, 'this' => &$value); $bindings = array($this->key_name => &$key, $this->val_name => &$value);
$context = new Context($bindings, $context); $context = new Context($bindings, $context);
foreach ($values as $key => $value) { foreach ($values as $key => $value) {
......
...@@ -43,7 +43,9 @@ class Scanner ...@@ -43,7 +43,9 @@ class Scanner
$token = next($this->tokens); $token = next($this->tokens);
$key = key($this->tokens); $key = key($this->tokens);
while ($token[0] === T_STRING && // FIXME this workaround should be dropped
while ($token && $token[0] === T_STRING &&
isset($this->tokens[$key + 2]) &&
$this->tokens[++$key] === '-' && $this->tokens[++$key] === '-' &&
$this->tokens[++$key][0] === T_STRING) { $this->tokens[++$key][0] === T_STRING) {
$token[1] .= '-' . $this->tokens[$key][1]; $token[1] .= '-' . $this->tokens[$key][1];
......
...@@ -24,6 +24,8 @@ class Template ...@@ -24,6 +24,8 @@ class Template
{ {
private static $tag_start = '{'; private static $tag_start = '{';
private static $tag_end = '}'; private static $tag_end = '}';
private $escape;
private $functions;
private $template; private $template;
/** /**
...@@ -47,9 +49,31 @@ class Template ...@@ -47,9 +49,31 @@ class Template
public function __construct($string) public function __construct($string)
{ {
$this->template = new ArrayNode(); $this->template = new ArrayNode();
$this->functions = array('count' => 'count', 'strlen' => 'mb_strlen');
self::parseTemplate($this->template, $string, 0); self::parseTemplate($this->template, $string, 0);
} }
/**
* Enables or disables automatic escaping for template values.
* Currently supported strategies: NULL, 'html', 'json', 'xml'
*
* @param string|callable $escape escape strategy or callback
*/
public function autoescape($escape)
{
if ($escape === 'html' || $escape === 'xml') {
$this->escape = 'htmlspecialchars';
} else if ($escape === 'json') {
$this->escape = 'json_encode';
} else if (is_callable($escape)) {
$this->escape = $escape;
} else if ($escape === NULL) {
$this->escape = NULL;
} else {
throw new InvalidArgumentException("invalid escape strategy: $escape");
}
}
/** /**
* Renders the template to a string using the given array of * Renders the template to a string using the given array of
* bindings to resolve symbol references inside the template. * bindings to resolve symbol references inside the template.
...@@ -60,7 +84,10 @@ class Template ...@@ -60,7 +84,10 @@ class Template
*/ */
public function render(array $bindings) public function render(array $bindings)
{ {
return $this->template->render(new Context($bindings)); $context = new Context($bindings + $this->functions);
$context->autoescape($this->escape);
return $this->template->render($context);
} }
/** /**
...@@ -125,7 +152,34 @@ class Template ...@@ -125,7 +152,34 @@ class Template
switch ($scanner->nextToken()) { switch ($scanner->nextToken()) {
case T_FOREACH: case T_FOREACH:
$scanner->nextToken(); $scanner->nextToken();
$child = new IteratorNode(self::parseExpr($scanner)); $expr = self::parseExpr($scanner);
$key_name = 'index';
$val_name = 'this';
if ($scanner->tokenType() === T_AS) {
$scanner->nextToken();
if ($scanner->tokenType() !== T_STRING) {
throw new TemplateParserException('symbol expected', $scanner);
}
$val_name = $scanner->tokenValue();
$scanner->nextToken();
if ($scanner->tokenType() === T_DOUBLE_ARROW) {
$scanner->nextToken();
if ($scanner->tokenType() !== T_STRING) {
throw new TemplateParserException('symbol expected', $scanner);
}
$key_name = $val_name;
$val_name = $scanner->tokenValue();
$scanner->nextToken();
}
}
$child = new IteratorNode($expr, $key_name, $val_name);
$pos = self::parseTemplate($child, $string, $pos); $pos = self::parseTemplate($child, $string, $pos);
$node->addChild($child); $node->addChild($child);
break; break;
...@@ -193,13 +247,46 @@ class Template ...@@ -193,13 +247,46 @@ class Template
} }
/** /**
* index: value | index '[' expr ']' | index '.' SYMBOL * function: value | function '(' ')' | function '(' expr { ',' expr } ')'
*/ */
private static function parseIndex(Scanner $scanner) private static function parseFunction(Scanner $scanner)
{ {
$result = self::parseValue($scanner); $result = self::parseValue($scanner);
$type = $scanner->tokenType(); $type = $scanner->tokenType();
while ($type === '(') {
$scanner->nextToken();
$arguments = array();
if ($scanner->tokenType() !== ')') {
$arguments[] = self::parseExpr($scanner);
while ($scanner->tokenType() === ',') {
$scanner->nextToken();
$arguments[] = self::parseExpr($scanner);
}
if ($scanner->tokenType() !== ')') {
throw new TemplateParserException('missing ")"', $scanner);
}
}
$scanner->nextToken();
$result = new FunctionExpression($result, $arguments);
$type = $scanner->tokenType();
}
return $result;
}
/**
* index: function | index '[' expr ']' | index '.' SYMBOL
*/
private static function parseIndex(Scanner $scanner)
{
$result = self::parseFunction($scanner);
$type = $scanner->tokenType();
while ($type === '[' || $type === '.') { while ($type === '[' || $type === '.') {
$scanner->nextToken(); $scanner->nextToken();
...@@ -224,7 +311,57 @@ class Template ...@@ -224,7 +311,57 @@ class Template
} }
/** /**
* sign: '!' sign | '+' sign | '-' sign | index * filter: index | filter '|' SYMBOL | filter '|' SYMBOL '(' expr { ',' expr } ')'
*/
private static function parseFilter(Scanner $scanner)
{
$result = self::parseIndex($scanner);
$type = $scanner->tokenType();
while ($type === '|') {
$scanner->nextToken();
if ($scanner->tokenType() !== T_STRING) {
throw new TemplateParserException('symbol expected', $scanner);
}
$arguments = array($result);
$symbol = new SymbolExpression($scanner->tokenValue());
$scanner->nextToken();
if ($scanner->tokenType() === '(') {
$scanner->nextToken();
if ($scanner->tokenType() !== ')') {
$arguments[] = self::parseExpr($scanner);
while ($scanner->tokenType() === ',') {
$scanner->nextToken();
$arguments[] = self::parseExpr($scanner);
}
if ($scanner->tokenType() !== ')') {
throw new TemplateParserException('missing ")"', $scanner);
}
}
$scanner->nextToken();
}
if ($symbol->name() === 'raw') {
$result = new RawExpression($result);
} else {
$result = new FunctionExpression($symbol, $arguments);
}
$type = $scanner->tokenType();
}
return $result;
}
/**
* sign: '!' sign | '+' sign | '-' sign | filter
*/ */
private static function parseSign(Scanner $scanner) private static function parseSign(Scanner $scanner)
{ {
...@@ -242,7 +379,7 @@ class Template ...@@ -242,7 +379,7 @@ class Template
$result = new MinusExpression(self::parseSign($scanner)); $result = new MinusExpression(self::parseSign($scanner));
break; break;
default: default:
$result = self::parseIndex($scanner); $result = self::parseFilter($scanner);
} }
return $result; return $result;
...@@ -353,7 +490,7 @@ class Template ...@@ -353,7 +490,7 @@ class Template
} }
/** /**
* expr: or | or '?' expr ':' expr * expr: or | or '?' expr ':' expr | or '?' ':' expr
*/ */
private static function parseExpr(Scanner $scanner) private static function parseExpr(Scanner $scanner)
{ {
...@@ -361,7 +498,12 @@ class Template ...@@ -361,7 +498,12 @@ class Template
if ($scanner->tokenType() === '?') { if ($scanner->tokenType() === '?') {
$scanner->nextToken(); $scanner->nextToken();
$expr = self::parseExpr($scanner);
if ($scanner->tokenType() !== ':') {
$expr = self::parseExpr($scanner);
} else {
$expr = $result;
}
if ($scanner->tokenType() !== ':') { if ($scanner->tokenType() !== ':') {
throw new TemplateParserException('missing ":"', $scanner); throw new TemplateParserException('missing ":"', $scanner);
......
...@@ -14,7 +14,7 @@ require 'Template.php'; ...@@ -14,7 +14,7 @@ require 'Template.php';
use exTpl\Template; use exTpl\Template;
class TemplateTest extends PHPUnit_Framework_TestCase class template_test extends PHPUnit\Framework\TestCase
{ {
public function testSimpleString() public function testSimpleString()
{ {
...@@ -36,6 +36,16 @@ class TemplateTest extends PHPUnit_Framework_TestCase ...@@ -36,6 +36,16 @@ class TemplateTest extends PHPUnit_Framework_TestCase
$this->assertEquals($expected, $tmpl_obj->render($bindings)); $this->assertEquals($expected, $tmpl_obj->render($bindings));
} }
public function testConditionExpression()
{
$bindings = array('a' => 0, 'b' => 42);
$template = 'answer is {"" ?: a ?: b}';
$expected = 'answer is 42';
$tmpl_obj = new Template($template);
$this->assertEquals($expected, $tmpl_obj->render($bindings));
}
public function testStringEscapes() public function testStringEscapes()
{ {
$bindings = array(); $bindings = array();
...@@ -93,7 +103,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase ...@@ -93,7 +103,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase
2 => array('user' => 'mike', 'phone' => '230-28382'), 2 => array('user' => 'mike', 'phone' => '230-28382'),
3 => array('user' => 'john', 'phone' => '911-19212') 3 => array('user' => 'john', 'phone' => '911-19212')
)); ));
$template = '<ul>{foreach persons}<li>{index~":"~this.user~":"~phone}</li>{endforeach}</ul>'; $template = '<ul>{foreach persons as person}<li>{index~":"~person.user~":"~phone}</li>{endforeach}</ul>';
$expected = '<ul>' . $expected = '<ul>' .
'<li>1:jane:555-81281</li>' . '<li>1:jane:555-81281</li>' .
'<li>2:mike:230-28382</li>' . '<li>2:mike:230-28382</li>' .
...@@ -145,4 +155,57 @@ class TemplateTest extends PHPUnit_Framework_TestCase ...@@ -145,4 +155,57 @@ class TemplateTest extends PHPUnit_Framework_TestCase
$this->assertEquals($expected, $tmpl_obj->render($bindings)); $this->assertEquals($expected, $tmpl_obj->render($bindings));
} }
public function testFunctionCall()
{
$bindings = array('val' => array(0, 1, 2, 3));
$template = '{strlen("foobar") + count(val)}';
$expected = '10';
$tmpl_obj = new Template($template);
$this->assertEquals($expected, $tmpl_obj->render($bindings));
}
public function testFilters()
{
$bindings = array('pi' => 3.14159, 'format_number' => 'number_format', 'upper' => 'strtoupper');
$template = '{pi|format_number(3) ~ ":" ~ "foobar"|upper}';
$expected = '3.142:FOOBAR';
$tmpl_obj = new Template($template);
$this->assertEquals($expected, $tmpl_obj->render($bindings));
}
public function testRawFilter()
{
$bindings = array('foo' => '<img>', 'upper' => 'strtoupper');
$template = '{foo}:{foo|upper|raw}';
$expected = '&lt;img&gt;:<IMG>';
$tmpl_obj = new Template($template);
$tmpl_obj->autoescape('html');
$this->assertEquals($expected, $tmpl_obj->render($bindings));
}
public function testHtmlAutoEscape()
{
$bindings = array('foo' => '<img>', 'pi' => 3.14159);
$template = '{foo}:{pi}';
$expected = '&lt;img&gt;:3.14159';
$tmpl_obj = new Template($template);
$tmpl_obj->autoescape('html');
$this->assertEquals($expected, $tmpl_obj->render($bindings));
}
public function testJsonAutoEscape()
{
$bindings = array('foo' => '<img>', 'pi' => 3.14159);
$template = '{foo}:{pi}';
$expected = '"<img>":3.14159';
$tmpl_obj = new Template($template);
$tmpl_obj->autoescape('json');
$this->assertEquals($expected, $tmpl_obj->render($bindings));
}
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment