瀏覽代碼

Improved expression support in templates.

Taddeus Kroes 13 年之前
父節點
當前提交
ba551f937c
共有 4 個文件被更改,包括 318 次插入136 次删除
  1. 185 81
      template.php
  2. 2 2
      tests/_files/templates/full.tpl
  3. 2 2
      tests/_files/templates/variables.tpl
  4. 129 51
      tests/test_template.php

+ 185 - 81
template.php

@@ -22,14 +22,14 @@ require_once 'node.php';
  * <code>
  * &lt;html&gt;
  *     &lt;head&gt;
- *         &lt;title&gt;{page_title}&lt;/title&gt;
+ *         &lt;title&gt;{$page_title}&lt;/title&gt;
  *     &lt;/head&gt;
  *     &lt;body&gt;
- *         &lt;h1&gt;{page_title}&lt;/h1&gt;
- *         &lt;div id="content"&gt;{page_content}&lt;/div&gt;
+ *         &lt;h1&gt;{$page_title}&lt;/h1&gt;
+ *         &lt;div id="content"&gt;{$page_content}&lt;/div&gt;
  *         &lt;div id="ads"&gt;
  *             {block:ad}
- *             &lt;div class="ad"&gt;{ad_content}&lt;/div&gt;
+ *             &lt;div class="ad"&gt;{$ad_content}&lt;/div&gt;
  *             {end}
  *         &lt;/div&gt;
  *     &lt;/body&gt;
@@ -66,6 +66,27 @@ require_once 'node.php';
  * &lt;/html&gt;
  * </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
  */
 class Template extends Node {
@@ -180,7 +201,7 @@ class Template extends Node {
 				$current = $current->add('block')->set('name', $block_name);
 			} 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 {
 	 * @param Node $block The block to render.
 	 * @param Node $data The data block to search in for the variable values.
 	 * @return string The rendered block.
-	 * @uses replace_variable()
+	 * @uses evaluate_expression()
 	 */
 	private static function render_block(Node $block, Node $data) {
 		$html = '';
@@ -228,8 +249,8 @@ class Template extends Node {
 						$html .= self::render_block($child, $child_data);
 					
 					break;
-				case 'variable':
-					$html .= self::replace_variable($child->get('content'), $data);
+				case 'expression':
+					$html .= self::evaluate_expression($child->get('content'), $data);
 			}
 		}
 		
@@ -237,93 +258,176 @@ class Template extends Node {
 	}
 	
 	/**
-	 * Replace a variable name if it exists within a given data scope.
-	 * 
-	 * Applies any of the following macro's:
+	 * Evaluate a <variable> expression.
 	 * 
-	 * --------
-	 * Variable
-	 * --------
-	 * <code>{var_name[:func1:func2:...]}</code>
-	 * *var_name* can be of the form *foo.bar*. In this case, *foo* is the
-	 * name of an object or associative array variable. *bar* is a property
-	 * 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.
+	 * This function is a helper for {@link evaluate_expression()}.
 	 * 
-	 * ------------
-	 * 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.
+	 * @param array $matches Regex matches for variable pattern.
+	 * @return string The evaluation of the variable.
+	 * @param Node $data A data tree containing variable values to use.
+	 * @throws \BadMethodCallException If an error occured while calling a variable method.
+	 * @throws \OutOfBoundsException If an unexisting array key is requested.
+	 * @throws \UnexpectedValueException In some other error situations.
 	 */
-	private static function replace_variable($variable, Node $data) {
-		// If-(else-)statement
-		if( preg_match('/^if:([^:]*):(.*?)(?::else:(.*))?$/', $variable, $matches) ) {
-			$condition = $data->get($matches[1]);
-			$if = $data->get($matches[2]);
+	private static function evaluate_variable(array $matches, Node $data) {
+		$variable = $matches[1];
+		$value = $data->get($variable);
+		
+		if( count($matches) == 3 ) {
+			// $<name>.<name>
+			$attribute = $matches[2];
 			
-			if( $condition )
-				return $if;
+			if( $value === null ) {
+				throw new \UnexpectedValueException(
+					sprintf('Cannot get attribute "%s.%s": value is NULL', $variable, $attribute)
+				);
+			}
 			
-			return count($matches) > 3 ? self::replace_variable($matches[3], $data) : '';
-		}
-		
-		// Default: variable with optional helper functions
-		$parts = explode(':', $variable);
-		$name = $parts[0];
-		$helper_functions = array_slice($parts, 1);
-		
-		if( strpos($name, '.') !== false ) {
-			// Variable of the form 'foo.bar'
-			list($variable_name, $property) = explode('.', $name, 2);
-			$object = $data->get($variable_name);
+			$attr_error = function($error, $class='\UnexpectedValueException') use ($attribute, $variable) {
+				throw new $class(
+					sprintf('Cannot get attribute "%s.%s": %s', $variable, $attribute, $error)
+				);
+			};
+			
+			if( is_array($value) ) {
+				isset($value[$attribute]) || $attr_error('no such key', '\OutOfBoundsException');
+				$value = $value[$attribute];
+			} elseif( is_object($value) ) {
+				isset($value->$attribute) || $attr_error('no such attribute');
+				$value = $value->$attribute;
+			} else {
+				$attr_error('variable is no array or object');
+			}
+		} elseif( count($matches) == 4 ) {
+			// $<name>.<name>()
+			$method = $matches[2];
 			
-			if( is_object($object) && property_exists($object, $property) ) {
-				// Object property
-				$value = $object->$property;
-			} elseif( is_array($object) && isset($object[$property]) ) {
-				// Associative array index
-				$value = $object[$property];
+			if( $value === null ) {
+				throw new \UnexpectedValueException(
+					sprintf('Cannot call method "%s.%s()": object is NULL', $variable, $method)
+				);
+			}
+			
+			$method_error = function($error) use ($method, $variable) {
+				throw new \BadMethodCallException(
+					sprintf('Cannot call method "%s.%s()": %s', $variable, $method, $error)
+				);
+			};
+			
+			if( is_object($value) ) {
+				method_exists($value, $method) || $method_error('no such method');
+				$value = $value->$method();
+			} else {
+				$method_error('variable is no object');
 			}
 		}
 		
-		// Default: Simple variable name
-		if( !isset($value) ) {
-			$value = $data->get($name);
-			
-			if( $value === null && defined($name) )
-				$value = constant($name);
+		return $value;
+	}
+	
+	/**
+	 * Evaluate a conditional expression.
+	 * 
+	 * 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);
 		}
 		
-		// Don't continue if the variable name is not found in the data block
-		if( $value !== null ) {
-			// Apply helper functions to the variable's value iteratively
-			foreach( $helper_functions as $func ) {
-				if( !is_callable($func) ) {
-					throw new \UnexpectedValueException(
-						sprintf('Helper function "%s" is not callable.', $func)
-					);
-				}
-				
-				$value = $func($value);
-			}
+		// 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)
+			);
+		}
+		
+		$parameter_value = self::evaluate_expression($parameter, $data, false);
+		
+		return call_user_func($function, $parameter_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)?";
 			
-			return $value;
+			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 - 2
tests/_files/templates/full.tpl

@@ -2,8 +2,8 @@ bar
 {block:foo}
 foofoo
 	{block:bar}
-	{foobar}
+	{$foobar}
 	{end}
-{foobaz:strtolower}
+{strtolower($foobaz)}
 {end}
 baz

+ 2 - 2
tests/_files/templates/variables.tpl

@@ -1,5 +1,5 @@
 foo
-{foobar}
+{$foobar}
 bar
-{foobaz:strtolower}
+{strtolower($foobaz)}
 baz

+ 129 - 51
tests/test_template.php

@@ -5,6 +5,20 @@ use WebBasics\Template;
 use WebBasics\Node;
 
 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 {
 	/**
@@ -26,7 +40,7 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
 			'true' => true,
 			'false' => false,
 			'array' => array('foo' => 'bar', 'bar' => 'baz'),
-			'object' => $object,
+			'object' => new DataObject,
 			'foobar' => 'my_foobar_variable',
 			'foobaz' => 'MY_FOOBAZ_VARIABLE',
 		));
@@ -108,8 +122,8 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
 		$this->assertEquals($child_count, count($node->get_children()));
 	}
 	
-	function assert_is_variable_node($node, $brackets_content) {
-		$this->assertEquals('variable', $node->get_name());
+	function assert_is_exp_node($node, $brackets_content) {
+		$this->assertEquals('expression', $node->get_name());
 		$this->assertEquals($brackets_content, $node->get('content'));
 		$this->assertEquals(array(), $node->get_children());
 	}
@@ -172,9 +186,9 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
 		
 		list($foo, $foobar, $bar, $foobaz, $baz) = $root_block->get_children();
 		$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_variable_node($foobaz, 'foobaz:strtolower');
+		$this->assert_is_exp_node($foobaz, 'strtolower($foobaz)');
 		$this->assert_is_html_node($baz, "\nbaz");
 	}
 	
@@ -196,79 +210,143 @@ class TemplateTest extends PHPUnit_Framework_TestCase {
 		$this->assert_is_html_node($foofoo, "\nfoofoo\n\t");
 		$this->assert_is_block_node($bar, 'bar', 3);
 		$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");
 		
 		list($space_before, $foobar, $space_after) = $bar->get_children();
 		$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");
 	}
 	
-	function assert_replaces($expected, $variable) {
-		$rm = new ReflectionMethod('WebBasics\Template', 'replace_variable');
-		$rm->setAccessible(true);
-		$this->assertEquals($expected, $rm->invoke(null, $variable, $this->data));
+	function evaluate_expression() {
+		$args = func_get_args();
+		$eval = new ReflectionMethod('WebBasics\Template', 'evaluate_expression');
+		$eval->setAccessible(true);
+		return $eval->invokeArgs(null, $args);
 	}
 	
-	function test_replace_variable_simple() {
-		$this->assert_replaces('bar', 'foo');
+	function assert_evaluates($expected, $expression) {
+		$this->assertEquals($expected, $this->evaluate_expression($expression, $this->data));
 	}
 	
-	/**
-	 * @depends test_replace_variable_simple
+	/** 
+	 * @expectedException \UnexpectedValueException
 	 */
-	function test_replace_variable_helper_functions() {
-		$this->assert_replaces('Bar', 'foo:ucfirst');
-		$this->assert_replaces('bar', 'FOO:strtolower');
-		$this->assert_replaces('Bar', 'FOO:strtolower:ucfirst');
+	function test_evaluate_variable_attribute_null() {
+		$this->evaluate_expression('$foobarbaz.foo', $this->data);
 	}
 	
-	/**
-	 * @depends test_replace_variable_helper_functions
+	/** 
+	 * @expectedException \UnexpectedValueException
 	 */
-	function test_replace_variable_constant() {
-		define('FOOCONST', 'foobar');
-		$this->assert_replaces('foobar', 'FOOCONST');
-		$this->assert_replaces('FOOBAR', 'FOOCONST:strtoupper');
+	function test_evaluate_variable_attribute_no_such_attribute() {
+		$this->evaluate_expression('$object.foobar', $this->data);
 	}
 	
-	/**
-	 * @expectedException UnexpectedValueException
-	 * @expectedExceptionMessage Helper function "idonotexist" is not callable.
+	/** 
+	 * @expectedException \UnexpectedValueException
+	 */
+	function test_evaluate_variable_attribute_no_array_or_object() {
+		$this->evaluate_expression('$foo.bar', $this->data);
+	}
+	
+	/** 
+	 * @expectedException \UnexpectedValueException
 	 */
-	function test_replace_variable_non_callable_helper_function() {
-		$this->assert_replaces(null, 'foo:idonotexist');
+	function test_evaluate_variable_method_null() {
+		$this->evaluate_expression('$foobarbaz.foo()', $this->data);
 	}
 	
-	function test_replace_variable_not_found() {
-		$this->assert_replaces('{idonotexist}', 'idonotexist');
+	/** 
+	 * @expectedException \BadMethodCallException
+	 */
+	function test_evaluate_variable_method_no_such_method() {
+		$this->evaluate_expression('$object.foo()', $this->data);
 	}
 	
-	function test_replace_variable_associative_array() {
-		$this->assert_replaces('bar', 'array.foo');
-		$this->assert_replaces('baz', 'array.bar');
+	/** 
+	 * @expectedException \BadMethodCallException
+	 */
+	function test_evaluate_variable_method_no_object() {
+		$this->evaluate_expression('$foo.bar()', $this->data);
 	}
 	
-	function test_replace_variable_object_property() {
-		$this->assert_replaces('bar', 'object.foo');
-		$this->assert_replaces('baz', 'object.bar');
+	function test_evaluate_variable_success() {
+		$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_if_statement() {
-		$this->assert_replaces('bar', 'if:true:foo');
-		$this->assert_replaces('', 'if:false:foo');
-		$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 test_evaluate_constant() {
+		$this->assert_evaluates('foobar_const', 'FOOBAR');
+		$this->assert_evaluates('{NON_DEFINED_CONST}', 'NON_DEFINED_CONST');
 	}
 	
-	/*function assert_block_renders($expected_file, $block, $data) {
-		$rm = new ReflectionMethod('WebBasics\Template', 'render_block');
-		$rm->setAccessible(true);
-		$expected_file = "tests/_files/rendered/$expected_file.html";
-		$this->assertStringEqualsFile($expected_file, $rm->invoke(null, $block, $data));
-	}*/
+	function test_evaluate_no_expression() {
+		$this->assert_evaluates('{foo}', 'foo');
+	}
+	
+	function test_evaluate_condition_if() {
+		$this->assert_evaluates('bar', '$true?bar');
+		$this->assert_evaluates('', '$false?bar');
+	}
+	
+	function test_evaluate_condition_if_else() {
+		$this->assert_evaluates('bar', '$true?bar:baz');
+		$this->assert_evaluates('baz', '$false?bar:baz');
+	}
+	
+	/**
+	 * @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) {
 		$expected_file = "tests/_files/rendered/$expected_file.html";
@@ -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() {
 		$tpl = new Template('variables');