Commit ba551f93 authored by Taddeus Kroes's avatar Taddeus Kroes

Improved expression support in templates.

parent 72509f0a
...@@ -22,14 +22,14 @@ require_once 'node.php'; ...@@ -22,14 +22,14 @@ require_once 'node.php';
* <code> * <code>
* &lt;html&gt; * &lt;html&gt;
* &lt;head&gt; * &lt;head&gt;
* &lt;title&gt;{page_title}&lt;/title&gt; * &lt;title&gt;{$page_title}&lt;/title&gt;
* &lt;/head&gt; * &lt;/head&gt;
* &lt;body&gt; * &lt;body&gt;
* &lt;h1&gt;{page_title}&lt;/h1&gt; * &lt;h1&gt;{$page_title}&lt;/h1&gt;
* &lt;div id="content"&gt;{page_content}&lt;/div&gt; * &lt;div id="content"&gt;{$page_content}&lt;/div&gt;
* &lt;div id="ads"&gt; * &lt;div id="ads"&gt;
* {block:ad} * {block:ad}
* &lt;div class="ad"&gt;{ad_content}&lt;/div&gt; * &lt;div class="ad"&gt;{$ad_content}&lt;/div&gt;
* {end} * {end}
* &lt;/div&gt; * &lt;/div&gt;
* &lt;/body&gt; * &lt;/body&gt;
...@@ -66,6 +66,27 @@ require_once 'node.php'; ...@@ -66,6 +66,27 @@ require_once 'node.php';
* &lt;/html&gt; * &lt;/html&gt;
* </code> * </code>
* *
* The variables of the form *{$variable}* that are used in the template
* above, are examples of expressions. An expression is always enclosed in
* curly brackets: *{expression}*. The grammar of all expressions that are
* currently supported can be described as follows:
* <code>
* &lt;expression&gt; : {&lt;nested_exp&gt;}
* | {&lt;nested_exp&gt;?&lt;nested_exp&gt;:&lt;nested_exp&gt;} # Conditional statement
* &lt;nested_exp&gt; : &lt;variable&gt;
* | &lt;function&gt;(&lt;nested_exp&gt;) # Static function call
* | &lt;constant&gt;
* | &lt;html&gt;
* &lt;variable&gt; : $&lt;name&gt; # Regular variable
* | $&lt;name&gt;.&lt;name&gt; # Object attribute or associative array value
* | $&lt;name&gt;.&lt;name&gt;() # Method call (no arguments allowed)
* &lt;function&gt; : &lt;name&gt; # Global function
* | &lt;name&gt;::&lt;name&gt; # Static class method
* &lt;constant&gt; : An all-caps PHP constant: [A-Z0-9_]+
* &lt;html&gt; : A string without parentheses, curly brackets or semicolons: [^(){}:]*
* &lt;name&gt; : A non-empty variable/method name consisting of [a-zA-Z0-9-_]+
* </code>
*
* @package WebBasics * @package WebBasics
*/ */
class Template extends Node { class Template extends Node {
...@@ -180,7 +201,7 @@ class Template extends Node { ...@@ -180,7 +201,7 @@ class Template extends Node {
$current = $current->add('block')->set('name', $block_name); $current = $current->add('block')->set('name', $block_name);
} else { } else {
// Variable or something else // Variable or something else
$current->add('variable')->set('content', $brackets_content); $current->add('expression')->set('content', $brackets_content);
} }
} }
...@@ -211,7 +232,7 @@ class Template extends Node { ...@@ -211,7 +232,7 @@ class Template extends Node {
* @param Node $block The block to render. * @param Node $block The block to render.
* @param Node $data The data block to search in for the variable values. * @param Node $data The data block to search in for the variable values.
* @return string The rendered block. * @return string The rendered block.
* @uses replace_variable() * @uses evaluate_expression()
*/ */
private static function render_block(Node $block, Node $data) { private static function render_block(Node $block, Node $data) {
$html = ''; $html = '';
...@@ -228,8 +249,8 @@ class Template extends Node { ...@@ -228,8 +249,8 @@ class Template extends Node {
$html .= self::render_block($child, $child_data); $html .= self::render_block($child, $child_data);
break; break;
case 'variable': case 'expression':
$html .= self::replace_variable($child->get('content'), $data); $html .= self::evaluate_expression($child->get('content'), $data);
} }
} }
...@@ -237,93 +258,176 @@ class Template extends Node { ...@@ -237,93 +258,176 @@ class Template extends Node {
} }
/** /**
* Replace a variable name if it exists within a given data scope. * Evaluate a <variable> expression.
* *
* Applies any of the following macro's: * This function is a helper for {@link evaluate_expression()}.
* *
* -------- * @param array $matches Regex matches for variable pattern.
* Variable * @return string The evaluation of the variable.
* -------- * @param Node $data A data tree containing variable values to use.
* <code>{var_name[:func1:func2:...]}</code> * @throws \BadMethodCallException If an error occured while calling a variable method.
* *var_name* can be of the form *foo.bar*. In this case, *foo* is the * @throws \OutOfBoundsException If an unexisting array key is requested.
* name of an object or associative array variable. *bar* is a property * @throws \UnexpectedValueException In some other error situations.
* name to get of the object, or the associative index to the array.
* *func1*, *func2*, etc. are helper functions that are executed in the
* same order as listed. The retuen value of each helper function replaces
* the previous variable value. *var_name* Can also be the name of a
* defined constant.
*
* ------------
* If-statement
* ------------
* <code>{if:condition:success_variable[:else:failure_variable]}</code>
* *condition* is evaluated to a boolean. If it evaluates to TRUE, the
* value of *success_variable* is used. Otherwise, the value of
* *failure_variable* is used (defaults to an empty string if no
* else-statement is specified).
*
* @param string $variable The variable to replace.
* @param Node $data The data block to search in for a value.
* @return string The variable's value if it exists, the original string
* with curly brackets otherwise.
* @throws \UnexpectedValueException If a helper function is not callable.
*/ */
private static function replace_variable($variable, Node $data) { private static function evaluate_variable(array $matches, Node $data) {
// If-(else-)statement $variable = $matches[1];
if( preg_match('/^if:([^:]*):(.*?)(?::else:(.*))?$/', $variable, $matches) ) { $value = $data->get($variable);
$condition = $data->get($matches[1]);
$if = $data->get($matches[2]);
if( $condition ) if( count($matches) == 3 ) {
return $if; // $<name>.<name>
$attribute = $matches[2];
return count($matches) > 3 ? self::replace_variable($matches[3], $data) : ''; if( $value === null ) {
throw new \UnexpectedValueException(
sprintf('Cannot get attribute "%s.%s": value is NULL', $variable, $attribute)
);
} }
// Default: variable with optional helper functions $attr_error = function($error, $class='\UnexpectedValueException') use ($attribute, $variable) {
$parts = explode(':', $variable); throw new $class(
$name = $parts[0]; sprintf('Cannot get attribute "%s.%s": %s', $variable, $attribute, $error)
$helper_functions = array_slice($parts, 1); );
};
if( strpos($name, '.') !== false ) {
// Variable of the form 'foo.bar' if( is_array($value) ) {
list($variable_name, $property) = explode('.', $name, 2); isset($value[$attribute]) || $attr_error('no such key', '\OutOfBoundsException');
$object = $data->get($variable_name); $value = $value[$attribute];
} elseif( is_object($value) ) {
if( is_object($object) && property_exists($object, $property) ) { isset($value->$attribute) || $attr_error('no such attribute');
// Object property $value = $value->$attribute;
$value = $object->$property; } else {
} elseif( is_array($object) && isset($object[$property]) ) { $attr_error('variable is no array or object');
// Associative array index
$value = $object[$property];
} }
} elseif( count($matches) == 4 ) {
// $<name>.<name>()
$method = $matches[2];
if( $value === null ) {
throw new \UnexpectedValueException(
sprintf('Cannot call method "%s.%s()": object is NULL', $variable, $method)
);
} }
// Default: Simple variable name $method_error = function($error) use ($method, $variable) {
if( !isset($value) ) { throw new \BadMethodCallException(
$value = $data->get($name); sprintf('Cannot call method "%s.%s()": %s', $variable, $method, $error)
);
};
if( $value === null && defined($name) ) if( is_object($value) ) {
$value = constant($name); method_exists($value, $method) || $method_error('no such method');
$value = $value->$method();
} else {
$method_error('variable is no object');
}
} }
// Don't continue if the variable name is not found in the data block return $value;
if( $value !== null ) { }
// Apply helper functions to the variable's value iteratively
foreach( $helper_functions as $func ) { /**
if( !is_callable($func) ) { * Evaluate a conditional expression.
throw new \UnexpectedValueException( *
sprintf('Helper function "%s" is not callable.', $func) * This function is a helper for {@link evaluate_expression()}.
*
* @param array $matches Regex matches for conditional pattern.
* @param Node $data A data tree containing variable values to use for
* variable expressions.
* @return string The evaluation of the condition.
*/
private static function evaluate_condition(array $matches, Node $data) {
if( self::evaluate_expression($matches[1], $data, false) ) {
// Condition evaluates to true: return 'if' evaluation
return self::evaluate_expression($matches[2], $data, false);
} elseif( count($matches) == 4 ) {
// <nested_exp>?<nested_exp>:<nested_exp>
return self::evaluate_expression($matches[3], $data, false);
}
// No 'else' specified: evaluation is an empty string
return '';
}
/**
* Evaluate a static function call expression.
*
* This function is a helper for {@link evaluate_expression()}.
*
* @param array $matches Regex matches for function pattern.
* @param Node $data A data tree containing variable values to use for
* variable expressions.
* @return string The evaluation of the function call.
* @throws \BadFunctionCallException If the function is undefined.
*/
private static function evaluate_function(array $matches, Node $data) {
$function = $matches[1];
$parameter = $matches[2];
if( !is_callable($function) ) {
throw new \BadFunctionCallException(
sprintf('Cannot call function "%s": function is not callable', $function)
); );
} }
$value = $func($value); $parameter_value = self::evaluate_expression($parameter, $data, false);
return call_user_func($function, $parameter_value);
} }
return $value; /**
* Evaluate a PHP-constant expression.
*
* This function is a helper for {@link evaluate_expression()}.
*
* @param string $constant The name of the PHP constant.
* @param bool $root_level Whether the expression was enclosed in curly
* brackets (FALSE for sub-expressions);
* @return string The evaluation of the constant if it is defined, the
* original constant name otherwise.
*/
private static function evaluate_constant($constant, $root_level) {
if( defined($constant) )
return constant($constant);
return $root_level ? '{' . $constant . '}' : $constant;
}
/**
* Evaluate an expression.
*
* The curly brackets should already have been stripped before passing an
* expression to this method.
*
* @param string $expression The expression to evaluate.
* @param Node $data A data tree containing variable values to use for
* variable expressions.
* @param bool $root_level Whether the expression was enclosed in curly
* brackets (FALSE for sub-expressions);
* @return string The evaluation of the expression if present, the
* original string enclosed in curly brackets otherwise.
*/
private static function evaluate_expression($expression, Node $data, $root_level=true) {
if( $expression ) {
$name = '[a-zA-Z0-9-_]+';
$function = "$name(?:::$name)?";
if( preg_match("/^([^?]*?)\s*\?([^:]*)(?::(.*))?$/", $expression, $matches) ) {
// <nested_exp>?<nested_exp> | <nested_exp>?<nested_exp>:<nested_exp>
return self::evaluate_condition($matches, $data);
} elseif( preg_match("/^\\$($name)(?:\.($name)(\(\))?)?$/", $expression, $matches) ) {
// $<name> | $<name>.<name> | $<name>.<name>()
return self::evaluate_variable($matches, $data);
} elseif( preg_match("/^($function)\((.+?)\)?$/", $expression, $matches) ) {
// <function>(<nested_exp>)
return self::evaluate_function($matches, $data);
} elseif( preg_match("/^([A-Z0-9_]+)$/", $expression, $matches) ) {
// <constant>
return self::evaluate_constant($expression, $root_level);
}
} }
return '{'.$variable.'}'; // No expression: return original string
return $root_level ? '{' . $expression . '}' : $expression;
} }
/** /**
......
...@@ -2,8 +2,8 @@ bar ...@@ -2,8 +2,8 @@ bar
{block:foo} {block:foo}
foofoo foofoo
{block:bar} {block:bar}
{foobar} {$foobar}
{end} {end}
{foobaz:strtolower} {strtolower($foobaz)}
{end} {end}
baz baz
\ No newline at end of file
foo foo
{foobar} {$foobar}
bar bar
{foobaz:strtolower} {strtolower($foobaz)}
baz baz
\ No newline at end of file
...@@ -5,6 +5,20 @@ use WebBasics\Template; ...@@ -5,6 +5,20 @@ use WebBasics\Template;
use WebBasics\Node; use WebBasics\Node;
define('TEMPLATES_DIR', 'tests/_files/templates/'); define('TEMPLATES_DIR', 'tests/_files/templates/');
define('FOOBAR', 'foobar_const');
class DataObject {
var $foo = 'bar';
var $bar = 'baz';
function baz() {
return 'foobar';
}
static function foobar($param) {
return ucfirst($param);
}
}
class TemplateTest extends PHPUnit_Framework_TestCase { class TemplateTest extends PHPUnit_Framework_TestCase {
/** /**
...@@ -26,7 +40,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase { ...@@ -26,7 +40,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
'true' => true, 'true' => true,
'false' => false, 'false' => false,
'array' => array('foo' => 'bar', 'bar' => 'baz'), 'array' => array('foo' => 'bar', 'bar' => 'baz'),
'object' => $object, 'object' => new DataObject,
'foobar' => 'my_foobar_variable', 'foobar' => 'my_foobar_variable',
'foobaz' => 'MY_FOOBAZ_VARIABLE', 'foobaz' => 'MY_FOOBAZ_VARIABLE',
)); ));
...@@ -108,8 +122,8 @@ class TemplateTest extends PHPUnit_Framework_TestCase { ...@@ -108,8 +122,8 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
$this->assertEquals($child_count, count($node->get_children())); $this->assertEquals($child_count, count($node->get_children()));
} }
function assert_is_variable_node($node, $brackets_content) { function assert_is_exp_node($node, $brackets_content) {
$this->assertEquals('variable', $node->get_name()); $this->assertEquals('expression', $node->get_name());
$this->assertEquals($brackets_content, $node->get('content')); $this->assertEquals($brackets_content, $node->get('content'));
$this->assertEquals(array(), $node->get_children()); $this->assertEquals(array(), $node->get_children());
} }
...@@ -172,9 +186,9 @@ class TemplateTest extends PHPUnit_Framework_TestCase { ...@@ -172,9 +186,9 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
list($foo, $foobar, $bar, $foobaz, $baz) = $root_block->get_children(); list($foo, $foobar, $bar, $foobaz, $baz) = $root_block->get_children();
$this->assert_is_html_node($foo, "foo\n"); $this->assert_is_html_node($foo, "foo\n");
$this->assert_is_variable_node($foobar, 'foobar'); $this->assert_is_exp_node($foobar, '$foobar');
$this->assert_is_html_node($bar, "\nbar\n"); $this->assert_is_html_node($bar, "\nbar\n");
$this->assert_is_variable_node($foobaz, 'foobaz:strtolower'); $this->assert_is_exp_node($foobaz, 'strtolower($foobaz)');
$this->assert_is_html_node($baz, "\nbaz"); $this->assert_is_html_node($baz, "\nbaz");
} }
...@@ -196,79 +210,143 @@ class TemplateTest extends PHPUnit_Framework_TestCase { ...@@ -196,79 +210,143 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
$this->assert_is_html_node($foofoo, "\nfoofoo\n\t"); $this->assert_is_html_node($foofoo, "\nfoofoo\n\t");
$this->assert_is_block_node($bar, 'bar', 3); $this->assert_is_block_node($bar, 'bar', 3);
$this->assert_is_html_node($first_space, "\n"); $this->assert_is_html_node($first_space, "\n");
$this->assert_is_variable_node($foobaz, 'foobaz:strtolower'); $this->assert_is_exp_node($foobaz, 'strtolower($foobaz)');
$this->assert_is_html_node($second_space, "\n"); $this->assert_is_html_node($second_space, "\n");
list($space_before, $foobar, $space_after) = $bar->get_children(); list($space_before, $foobar, $space_after) = $bar->get_children();
$this->assert_is_html_node($space_before, "\n\t"); $this->assert_is_html_node($space_before, "\n\t");
$this->assert_is_variable_node($foobar, 'foobar'); $this->assert_is_exp_node($foobar, '$foobar');
$this->assert_is_html_node($space_after, "\n\t"); $this->assert_is_html_node($space_after, "\n\t");
} }
function assert_replaces($expected, $variable) { function evaluate_expression() {
$rm = new ReflectionMethod('WebBasics\Template', 'replace_variable'); $args = func_get_args();
$rm->setAccessible(true); $eval = new ReflectionMethod('WebBasics\Template', 'evaluate_expression');
$this->assertEquals($expected, $rm->invoke(null, $variable, $this->data)); $eval->setAccessible(true);
return $eval->invokeArgs(null, $args);
}
function assert_evaluates($expected, $expression) {
$this->assertEquals($expected, $this->evaluate_expression($expression, $this->data));
}
/**
* @expectedException \UnexpectedValueException
*/
function test_evaluate_variable_attribute_null() {
$this->evaluate_expression('$foobarbaz.foo', $this->data);
}
/**
* @expectedException \UnexpectedValueException
*/
function test_evaluate_variable_attribute_no_such_attribute() {
$this->evaluate_expression('$object.foobar', $this->data);
} }
function test_replace_variable_simple() { /**
$this->assert_replaces('bar', 'foo'); * @expectedException \UnexpectedValueException
*/
function test_evaluate_variable_attribute_no_array_or_object() {
$this->evaluate_expression('$foo.bar', $this->data);
} }
/** /**
* @depends test_replace_variable_simple * @expectedException \UnexpectedValueException
*/ */
function test_replace_variable_helper_functions() { function test_evaluate_variable_method_null() {
$this->assert_replaces('Bar', 'foo:ucfirst'); $this->evaluate_expression('$foobarbaz.foo()', $this->data);
$this->assert_replaces('bar', 'FOO:strtolower');
$this->assert_replaces('Bar', 'FOO:strtolower:ucfirst');
} }
/** /**
* @depends test_replace_variable_helper_functions * @expectedException \BadMethodCallException
*/ */
function test_replace_variable_constant() { function test_evaluate_variable_method_no_such_method() {
define('FOOCONST', 'foobar'); $this->evaluate_expression('$object.foo()', $this->data);
$this->assert_replaces('foobar', 'FOOCONST');
$this->assert_replaces('FOOBAR', 'FOOCONST:strtoupper');
} }
/** /**
* @expectedException UnexpectedValueException * @expectedException \BadMethodCallException
* @expectedExceptionMessage Helper function "idonotexist" is not callable.
*/ */
function test_replace_variable_non_callable_helper_function() { function test_evaluate_variable_method_no_object() {
$this->assert_replaces(null, 'foo:idonotexist'); $this->evaluate_expression('$foo.bar()', $this->data);
} }
function test_replace_variable_not_found() { function test_evaluate_variable_success() {
$this->assert_replaces('{idonotexist}', 'idonotexist'); $this->assert_evaluates('bar', '$array.foo');
$this->assert_evaluates('bar', '$foo');
$this->assert_evaluates('baz', '$bar');
$this->assert_evaluates('bar', '$object.foo');
$this->assert_evaluates('baz', '$object.bar');
$this->assert_evaluates('foobar', '$object.baz()');
} }
function test_replace_variable_associative_array() { function test_evaluate_constant() {
$this->assert_replaces('bar', 'array.foo'); $this->assert_evaluates('foobar_const', 'FOOBAR');
$this->assert_replaces('baz', 'array.bar'); $this->assert_evaluates('{NON_DEFINED_CONST}', 'NON_DEFINED_CONST');
} }
function test_replace_variable_object_property() { function test_evaluate_no_expression() {
$this->assert_replaces('bar', 'object.foo'); $this->assert_evaluates('{foo}', 'foo');
$this->assert_replaces('baz', 'object.bar');
} }
function test_replace_variable_if_statement() { function test_evaluate_condition_if() {
$this->assert_replaces('bar', 'if:true:foo'); $this->assert_evaluates('bar', '$true?bar');
$this->assert_replaces('', 'if:false:foo'); $this->assert_evaluates('', '$false?bar');
$this->assert_replaces('bar', 'if:true:foo:else:bar');
$this->assert_replaces('baz', 'if:false:foo:else:bar');
$this->assert_replaces('Bar', 'if:false:foo:else:FOO:strtolower:ucfirst');
} }
/*function assert_block_renders($expected_file, $block, $data) { function test_evaluate_condition_if_else() {
$rm = new ReflectionMethod('WebBasics\Template', 'render_block'); $this->assert_evaluates('bar', '$true?bar:baz');
$rm->setAccessible(true); $this->assert_evaluates('baz', '$false?bar:baz');
$expected_file = "tests/_files/rendered/$expected_file.html"; }
$this->assertStringEqualsFile($expected_file, $rm->invoke(null, $block, $data));
}*/ /**
* @depends test_evaluate_condition_if
* @depends test_evaluate_condition_if_else
*/
function test_evaluate_condition_spaces() {
$this->assert_evaluates(' bar ', '$true? bar : baz');
$this->assert_evaluates(' baz', '$false? bar : baz');
$this->assert_evaluates(' bar ', '$true ? bar : baz');
$this->assert_evaluates(' baz', '$false ? bar : baz');
$this->assert_evaluates(' Foo bar ', '$true ? Foo bar : Baz foo');
$this->assert_evaluates(' Baz foo', '$false ? Foo bar : Baz foo');
}
/**
* @expectedException \BadFunctionCallException
*/
function test_evaluate_function_error() {
$this->evaluate_expression('undefined_function($foo)', $this->data);
}
function test_evaluate_function_success() {
$this->assert_evaluates('Bar', 'ucfirst($foo)');
$this->assert_evaluates('Bar', 'DataObject::foobar($foo)');
}
/**
* @depends test_evaluate_function_success
*/
function test_evaluate_function_nested() {
$this->assert_evaluates('Bar', 'ucfirst(strtolower($FOO))');
}
/**
* @depends test_evaluate_variable_success
* @depends test_evaluate_no_expression
* @depends test_evaluate_condition_spaces
* @depends test_evaluate_function_success
*/
function test_evaluate_expression_combined() {
$this->assert_evaluates('Bar', '$true?ucfirst($foo)');
$this->assert_evaluates('', '$false?ucfirst($foo)');
$this->assert_evaluates('Bar', '$true?ucfirst($foo):baz');
$this->assert_evaluates('baz', '$false?ucfirst($foo):baz');
$this->assert_evaluates('Baz', 'ucfirst($array.bar)');
}
function assert_renders($expected_file, $tpl) { function assert_renders($expected_file, $tpl) {
$expected_file = "tests/_files/rendered/$expected_file.html"; $expected_file = "tests/_files/rendered/$expected_file.html";
...@@ -280,7 +358,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase { ...@@ -280,7 +358,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
} }
/** /**
* @depends test_replace_variable_helper_functions * @depends test_evaluate_expression_combined
*/ */
function test_render_variable() { function test_render_variable() {
$tpl = new Template('variables'); $tpl = new Template('variables');
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment