Przeglądaj źródła

Added template parser classes.

- The Template class represents a template file, consisting of blocks and
  variables.
- The Node class is used to create a tree structure of the blocks in a
  template file, and of the data that is mapped on the blocks.
Taddeus Kroes 13 lat temu
rodzic
commit
5c42019770

+ 3 - 2
base.php

@@ -69,9 +69,10 @@ class FileNotFoundError extends \RuntimeException {
 	 * Sets an error message of the form 'File "path/to/file.php" does not exist.'.
 	 * 
 	 * @param string $path Path to the file that does not exist.
+	 * @param bool $is_dir Whether the path points to a directory (defaults to false).
 	 */
-	function __construct($path) {
-		$this->message = sprintf('File "%s" does not exist.', $path);
+	function __construct($path, $is_dir=false) {
+		$this->message = sprintf('%s "%s" does not exist.', $is_dir ? 'Directory' : 'File', $path);
 	}
 }
 

+ 328 - 0
node.php

@@ -0,0 +1,328 @@
+<?php
+/**
+ * Tree data structure, used for rendering purposes.
+ * 
+ * @author Taddeus Kroes
+ * @version 1.0
+ * @date 13-07-2012
+ */
+
+namespace BasicWeb;
+
+require_once 'base.php';
+
+/**
+ * Tree node.
+ * 
+ * Each tree node has a (non-unique) name, a list of variables, and zero or
+ * more children.
+ * 
+ * @package BasicWeb
+ */
+class Node extends Base {
+	/**
+	 * The number of Node instances, used to create unique node id's.
+	 * 
+	 * @var int
+	 */
+	private static $count = 0;
+	
+	/**
+	 * The unique id of this Bloc.
+	 * 
+	 * @var int
+	 */
+	private $id;
+	
+	/**
+	 * The node's name.
+	 * 
+	 * @var string
+	 */
+	private $name;
+	
+	/**
+	 * An optional parent node.
+	 * 
+	 * If NULL, this node is the root of the data tree.
+	 * 
+	 * @var Node
+	 */
+	private $parent_node;
+	
+	/**
+	 * Child nodes.
+	 * 
+	 * @var array
+	 */
+	private $children = array();
+	
+	/**
+	 * Variables in this node.
+	 * 
+	 * All variables in a node are also available in its descendants through
+	 * {@link get()}.
+	 * 
+	 * @var array
+	 */
+	private $variables = array();
+	
+	/**
+	 * Constructor.
+	 * 
+	 * The id of the node is determined by the node counter.
+	 * 
+	 * @param string $name The node's name.
+	 * @param Node|null &$parent_node A parent node (optional).
+	 * @param int|null $id An id to assign. If none is specified, a new unique
+	 *                     id is generated.
+	 * @uses $count
+	 */
+	function __construct($name='', Node &$parent_node=null, $id=null) {
+		$this->id = $id ? $id : ++self::$count;
+		$this->name = $name;
+		$this->parent_node = $parent_node;
+	}
+	
+	/**
+	 * Get the node's unique id.
+	 * 
+	 * @return int The node's id.
+	 */
+	function get_id() {
+		return $this->id;
+	}
+	
+	/**
+	 * Get the node's name.
+	 * 
+	 * @return string The node's name.
+	 */
+	function get_name() {
+		return $this->name;
+	}
+	
+	/**
+	 * Get the node's parent.
+	 * 
+	 * @return Node|null The parent node if any, NULL otherwise.
+	 */
+	function get_parent() {
+		return $this->parent_node;
+	}
+	
+	/**
+	 * Get the node's children.
+	 * 
+	 * @return array A list of child nodes.
+	 */
+	function get_children() {
+		return $this->children;
+	}
+	
+	/**
+	 * Check if a node is the same instance or a copy of this node.
+	 * 
+	 * @param Node $node The node to compare this node to.
+	 * @return bool Whether the nodes have the same unique id.
+	 */
+	function is(Node $node) {
+		return $node->get_id() == $this->id;
+	}
+	
+	/**
+	 * Check if this node is the root node of the tree.
+	 * 
+	 * A node is the root node if it has no parent.
+	 * 
+	 * @return bool Whether this node is the root node.
+	 */
+	function is_root() {
+		return $this->parent_node === null;
+	}
+	
+	/**
+	 * Check if this node is a leaf node of the tree.
+	 * 
+	 * A node is a leaf if it has no children.
+	 * 
+	 * @return bool Whether this node is a leaf node.
+	 */
+	function is_leaf() {
+		return !count($this->children);
+	}
+	
+	/**
+	 * Add a child node.
+	 * 
+	 * @param Node &$node The child node to add.
+	 * @param bool $set_parent Whether to set this node as the child's parent
+	 *                         (defaults to TRUE).
+	 */
+	function add_child(Node &$node, $set_parent=true) {
+		$this->children[] = $node;
+		$set_parent && $node->set_parent($this);
+	}
+	
+	/**
+	 * Add a child node.
+	 * 
+	 * @param string $name The name of the node to add.
+	 * @param array $data Data to set in the created node (optional).
+	 * @return Node The created node.
+	 */
+	function add($name, array $data=array()) {
+		$node = new self($name, $this);
+		$this->add_child($node, false);
+		
+		return $node->set($data);
+	}
+	
+	/**
+	 * Remove a child node.
+	 * 
+	 * @param Node &$child The node to remove.
+	 */
+	function remove_child(Node &$child) {
+		foreach( $this->children as $i => $node )
+			$node->is($child) && array_splice($this->children, $i, 1);
+	}
+	
+	/**
+	 * Remove this node from its parent.
+	 * 
+	 * @throws \RuntimeException If the node has no parent.
+	 * @return Node This node.
+	 */
+	function remove() {
+		if( $this->is_root() )
+			throw new \RuntimeException('Cannot remove the root node of a tree.');
+		
+		$this->parent_node->remove_child($this);
+		
+		foreach( $this->children as $child )
+			$child->set_parent(null);
+		
+		return $this;
+	}
+	
+	/**
+	 * Set the node's parent.
+	 * 
+	 * Removes this node as child of the original parent, if a parent was
+	 * already set.
+	 * 
+	 * @param Node|null $parent The parent node to set.
+	 * @return Node This node.
+	 */
+	function set_parent($parent) {
+		if( $this->parent_node !== null )
+			$this->parent_node->remove_child($this);
+		
+		$this->parent_node = &$parent;
+		
+		return $this;
+	}
+	
+	/**
+	 * Set the value of one or more variables in the node.
+	 * 
+	 * @param string|array $name Either a single variable name, or a set of name/value pairs.
+	 * @param mixed $value The value of a single variable to set.
+	 * @return Node This node.
+	 */
+	function set($name, $value=null) {
+		if( is_array($name) ) {
+			foreach( $name as $var => $val )
+				$this->variables[$var] = $val;
+		} else {
+			$this->variables[$name] = $value;
+		}
+		
+		return $this;
+	}
+	
+	/**
+	 * Get the value of a variable.
+	 * 
+	 * @param string $name The name of the variable to get the value of.
+	 * @return mixed The value of the variable if it exists, NULL otherwise.
+	 */
+	function get($name) {
+		// Variable inside this node?
+		if( isset($this->variables[$name]) )
+			return $this->variables[$name];
+		
+		// Variable in one of ancestors?
+		if( $this->parent_node !== null )
+			return $this->parent_node->get($name);
+		
+		// All nodes up to the tree's root node do not contain the variable
+		return null;
+	}
+	
+	/**
+	 * Set the value of a variable.
+	 * 
+	 * This method provides a shortcut for {@link set()}.
+	 * 
+	 * @param string $name The name of the variable to set the value of.
+	 * @param mixed $value The value to set.
+	 */
+	function __set($name, $value) {
+		$this->set($name, $value);
+	}
+	
+	/**
+	 * Get the value of a variable.
+	 * 
+	 * This method provides a shortcut for {@link get()}.
+	 * 
+	 * @param string $name The name of the variable to get the value of.
+	 * @return mixed The value of the variable if it exists, NULL otherwise.
+	 */
+	function __get($name) {
+		return $this->get($name);
+	}
+	
+	/**
+	 * Find all child nodes that have the specified name.
+	 * 
+	 * @param string $name The name of the nodes to find.
+	 * @return array The positively matched nodes.
+	 */
+	function find($name) {
+		$has_name = function($child) use ($name) {
+			return $child->get_name() == $name;
+		};
+		
+		return array_values(array_filter($this->children, $has_name));
+	}
+	
+	/**
+	 * Create a copy of this node.
+	 * 
+	 * The copy will have the same list of children and variables. In case of
+	 * a 'deep copy', the list of children is also cloned recursively.
+	 * 
+	 * @param bool $deep Whether to create a deep copy.
+	 * @return Node A copy of this node.
+	 */
+	function copy($deep=false) {
+		$copy = new self($this->name, $this->parent_node, $this->id);
+		$copy->set($this->variables);
+		
+		foreach( $this->children as $child ) {
+			if( $deep ) {
+				$child_copy = $child->copy(true);
+				$copy->add_child($child_copy);
+			} else {
+				$copy->add_child($child, false);
+			}
+		}
+		
+		return $copy;
+	}
+}
+
+?>

+ 380 - 0
template.php

@@ -0,0 +1,380 @@
+<?php
+/**
+ * HTML template rendering functions.
+ * 
+ * @author Taddeus Kroes
+ * @version 1.0
+ * @date 14-07-2012
+ */
+
+namespace BasicWeb;
+
+require_once 'node.php';
+
+/**
+ * A Template object represents a template file.
+ * 
+ * A template file contains 'blocks' that can be rendered zero or more times.
+ * Each block has a set of properties that can be accessed using curly
+ * brackets ('{' and '}'). Curly brackets may contain macro's to minimize
+ * common view logic in controllers.
+ * 
+ * Example template 'page.tpl':
+ * <code>
+ * &lt;html&gt;
+ *     &lt;head&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;div id="ads"&gt;
+ *             {block:ad}
+ *             &lt;div class="ad"&gt;{ad_content}&lt;/div&gt;
+ *             {end}
+ *         &lt;/div&gt;
+ *     &lt;/body&gt;
+ * &lt;/html&gt;
+ * </code>
+ * And the corresponding PHP code:
+ * <code>
+ * $tpl = new Template('page');
+ * $tpl->set(array(
+ *     'page_title' => 'Some title',
+ *     'page_content' => 'Lorem ipsum ...'
+ * ));
+ * 
+ * foreach( array('Some ad', 'Another ad', 'More ads') as $ad )
+ *     $tpl->add('ad')->set('ad_content', $ad);
+ * 
+ * echo $tpl->render();
+ * </code>
+ * The output will be:
+ * <code>
+ * &lt;html&gt;
+ *     &lt;head&gt;
+ *         &lt;title&gt;Some title&lt;/title&gt;
+ *     &lt;/head&gt;
+ *     &lt;body&gt;
+ *         &lt;h1&gt;Some title&lt;/h1&gt;
+ *         &lt;div id="content"&gt;Some content&lt;/div&gt;
+ *         &lt;div id="ads"&gt;
+ *             &lt;div class="ad"&gt;Some ad&lt;/div&gt;
+ *             &lt;div class="ad"&gt;Another ad&lt;/div&gt;
+ *             &lt;div class="ad"&gt;More ads&lt;/div&gt;
+ *         &lt;/div&gt;
+ *     &lt;/body&gt;
+ * &lt;/html&gt;
+ * </code>
+ * 
+ * @package BasicWeb
+ */
+class Template extends Node {
+	/**
+	 * Default extension of template files.
+	 * 
+	 * @var array
+	 */
+	const DEFAULT_EXTENSION = '.tpl';
+	
+	/**
+	 * Root directories from which template files are included.
+	 * 
+	 * @var array
+	 */
+	private static $include_path = array();
+	
+	/**
+	 * The path the template was found in.
+	 * 
+	 * @var string
+	 */
+	private $path;
+	
+	/**
+	 * The content of the template file.
+	 * 
+	 * @var string
+	 */
+	private $file_content;
+	
+	/**
+	 * The block structure of the template file.
+	 * 
+	 * @var Node
+	 */
+	private $root_block;
+	
+	/**
+	 * Create a new Template object, representing a template file.
+	 * 
+	 * Template files are assumed to have the .tpl extension. If no extension
+	 * is specified, '.tpl' is appended to the filename.
+	 * 
+	 * @param string $filename The path to the template file from one of the root directories.
+	 */
+	function __construct($filename) {
+		// Add default extension if none is found
+		strpos($filename, '.') === false && $filename .= self::DEFAULT_EXTENSION;
+		
+		$look_in = count(self::$include_path) ? self::$include_path : array('.');
+		$found = false;
+		
+		foreach( $look_in as $root ) {
+			$path = $root.$filename;
+			
+			if( file_exists($path) ) {
+				$this->path = $path;
+				$this->file_content = file_get_contents($path);
+				$found = true;
+				break;
+			}
+		}
+		
+		if( !$found ) {
+			throw new \RuntimeException(
+				sprintf("Could not find template file \"%s\", looked in folders:\n%s",
+					$filename, implode("\n", $look_in))
+			);
+		}
+		
+		$this->parse_blocks();
+	}
+	
+	/**
+	 * Get the path to the template file (including one of the include paths).
+	 * 
+	 * @return string the path to the template file.
+	 */
+	function get_path() {
+		return $this->path;
+	}
+	
+	/**
+	 * Parse the content of the template file into a tree structure of blocks
+	 * and variables.
+	 */
+	private function parse_blocks() {
+		$current = $root = new Node('block');
+		$after = $this->file_content;
+		$line_count = 0;
+		
+		while( preg_match('/(.*?)\{([^}]+)}(.*)/s', $after, $matches) ) {
+			list($before, $brackets_content, $after) = array_slice($matches, 1);
+			$line_count += substr_count($before, "\n");
+			//var_dump(array_slice($matches, 1));
+			
+			// Everything before the new block belongs to its parent
+			$current->add('html')->set('content', $before);
+			
+			if( $brackets_content == 'end' ) {
+				// {end} encountered, go one level up in the tree
+				if( $current->is_root() )
+					throw new ParseError($this, 'unexpected {end}', $line_count + 1);
+				
+				$current = $current->get_parent();
+			} elseif( substr($brackets_content, 0, 6) == 'block:' ) {
+				// {block:...} encountered
+				$block_name = substr($brackets_content, 6);
+				// Go one level deeper into the tree
+				$current = $current->add('block')->set('name', $block_name);
+			} else {
+				// Variable or something else
+				$current->add('variable')->set('content', $brackets_content);
+			}
+		}
+		
+		$line_count += substr_count($after, "\n");
+		
+		if( $current !== $root )
+			throw new ParseError($this, 'missing {end}', $line_count + 1);
+		
+		// Add the last remaining content to the root node
+		$root->add('html')->set('content', $after);
+		
+		$this->root_block = $root;
+	}
+	
+	/**
+	 * Replace blocks and variables in the template's content.
+	 * 
+	 * @return string The template's content, with replaced blocks and variables.
+	 */
+	function render() {
+		// Use recursion to parse all blocks from the root level
+		return self::render_block($this->root_block, $this);
+	}
+	
+	/**
+	 * Render a single block, recursively parsing its sub-blocks with a given data scope.
+	 * 
+	 * @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()
+	 */
+	private static function render_block(Node $block, Node $data) {
+		$html = '';
+		
+		foreach( $block->get_children() as $child ) {
+			switch( $child->get_name() ) {
+				case 'html':
+					$html .= $child->get('content');
+					break;
+				case 'block':
+					$block_name = $child->get('name');
+					
+					foreach( $data->find($block_name) as $child_data )
+						$html .= self::render_block($child, $child_data);
+					
+					break;
+				case 'variable':
+					$html .= self::replace_variable($child->get('content'), $data);
+			}
+		}
+		
+		return $html;
+	}
+	
+	/**
+	 * Replace a variable name if it exists within a given data scope.
+	 * 
+	 * Applies any of the following macro's:
+	 * 
+	 * --------
+	 * 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.
+	 * 
+	 * ------------
+	 * 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) {
+		// If-(else-)statement
+		if( preg_match('/^if:([^:]*):(.*?)(?::else:(.*))?$/', $variable, $matches) ) {
+			$condition = $data->get($matches[1]);
+			$if = $data->get($matches[2]);
+			
+			if( $condition )
+				return $if;
+			
+			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);
+			
+			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];
+			}
+		}
+		
+		// Default: Simple variable name
+		if( !isset($value) )
+			$value = $data->get($name);
+		
+		// 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);
+			}
+			
+			return $value;
+		}
+		
+		return '{'.$variable.'}';
+	}
+	
+	/**
+	 * Remove all current include paths.
+	 */
+	static function clear_include_path() {
+		self::$include_path = array();
+	}
+	
+	/**
+	 * Replace all include paths by a single new one.
+	 * 
+	 * @param string $path The new path to set as root.
+	 * @uses clear_include_path()
+	 */
+	static function set_root($path) {
+		self::clear_include_path();
+		self::add_root($path);
+	}
+	
+	/**
+	 * Add a new include path.
+	 * 
+	 * @param string $path The path to add.
+	 * @throws FileNotFoundError If the path does not exist.
+	 */
+	static function add_root($path) {
+		if( $path[strlen($path) - 1] != '/' )
+			$path .= '/';
+		
+		if( !is_dir($path) )
+			throw new FileNotFoundError($path, true);
+		
+		self::$include_path[] = $path;
+	}
+}
+
+/**
+ * Error, thrown when an error occurs during the parsing of a template file.
+ * 
+ * @package BasicWeb
+ */
+class ParseError extends \RuntimeException {
+	/**
+	 * Constructor.
+	 * 
+	 * Sets an error message with the path to the template file and a line number.
+	 * 
+	 * @param Template $tpl The template in which the error occurred.
+	 * @param string $message A message describing the error.
+	 * @param int $line The line number at which the error occurred.
+	 */
+	function __construct(Template $tpl, $message, $line) {
+		$this->message = sprintf('Parse error in file %s, line %d: %s',
+			$tpl->get_path(), $line, $message);
+	}
+}
+
+?>

+ 0 - 0
tests/_files/other_templates/bar.tpl


+ 12 - 0
tests/_files/rendered/blocks.html

@@ -0,0 +1,12 @@
+
+foofoo
+	
+	foobar
+	
+	foobar
+	
+foobaz
+
+foofoo
+	
+foobaz

+ 17 - 0
tests/_files/rendered/full.html

@@ -0,0 +1,17 @@
+bar
+
+foofoo
+	
+	first_foobar_var
+	
+first_foobaz_var
+
+foofoo
+	
+	second_foobar_var
+	
+	third_foobar_var
+	
+second_foobaz_var
+
+baz

+ 9 - 0
tests/_files/rendered/full.tpl

@@ -0,0 +1,9 @@
+bar
+{block:foo}
+foofoo
+	{block:bar}
+	{foobar}
+	{end}
+{foobaz:strtolower}
+{end}
+baz

+ 5 - 0
tests/_files/rendered/variables.html

@@ -0,0 +1,5 @@
+foo
+my_foobar_variable
+bar
+my_foobaz_variable
+baz

+ 7 - 0
tests/_files/templates/blocks.tpl

@@ -0,0 +1,7 @@
+{block:foo}
+foofoo
+	{block:bar}
+	foobar
+	{end}
+foobaz
+{end}

+ 1 - 0
tests/_files/templates/foo.tpl

@@ -0,0 +1 @@
+test

+ 9 - 0
tests/_files/templates/full.tpl

@@ -0,0 +1,9 @@
+bar
+{block:foo}
+foofoo
+	{block:bar}
+	{foobar}
+	{end}
+{foobaz:strtolower}
+{end}
+baz

+ 6 - 0
tests/_files/templates/missing_end.tpl

@@ -0,0 +1,6 @@
+{block:foo}
+foofoo
+	{block:bar}
+	foobar
+	{end}
+foobaz

+ 5 - 0
tests/_files/templates/unexpected_end.tpl

@@ -0,0 +1,5 @@
+{block:foo}
+foofoo
+foobar
+{end}
+{end}

+ 5 - 0
tests/_files/templates/variables.tpl

@@ -0,0 +1,5 @@
+foo
+{foobar}
+bar
+{foobaz:strtolower}
+baz

+ 226 - 0
tests/test_node.php

@@ -0,0 +1,226 @@
+<?php
+
+require_once 'node.php';
+use \BasicWeb\Node;
+
+class NodeTest extends PHPUnit_Framework_TestCase {
+	var $autoloader;
+	
+	function setUp() {
+		$this->root = new Node('test node');
+	}
+	
+	function test_get_id() {
+		$this->assertEquals($this->root->get_id(), 1);
+		$this->assertEquals(Node::create('')->get_id(), 2);
+	}
+	
+	function test_get_name() {
+		$this->assertEquals($this->root->get_name(), 'test node');
+		$this->assertEquals(Node::create('second node')->get_name(), 'second node');
+	}
+	
+	function test_get_parent() {
+		$this->assertNull($this->root->get_parent());
+		$this->assertSame(Node::create('', $this->root)->get_parent(), $this->root);
+	}
+	
+	function test_is() {
+		$mirror = $this->root;
+		$this->assertTrue($mirror->is($this->root));
+		$this->assertFalse(Node::create('')->is($this->root));
+	}
+	
+	function test_is_root() {
+		$this->assertTrue($this->root->is_root());
+		$this->assertFalse(Node::create('', $this->root)->is_root());
+	}
+	
+	function test_add_child() {
+		$node = new Node('');
+		$this->root->add_child($node);
+		$this->assertAttributeEquals(array($node), 'children', $this->root);
+		$this->assertSame($node->get_parent(), $this->root);
+	}
+	
+	/**
+	 * @depends test_add_child
+	 */
+	function test_get_children() {
+		$this->assertEquals($this->root->get_children(), array());
+		$node = new Node('');
+		$this->root->add_child($node);
+		$this->assertSame($this->root->get_children(), array($node));
+	}
+	
+	function test_add_child_no_set_parent() {
+		$node = new Node('');
+		$this->root->add_child($node, false);
+		$this->assertAttributeEquals(array($node), 'children', $this->root);
+		$this->assertNull($node->get_parent());
+	}
+	
+	/**
+	 * @depends test_add_child
+	 */
+	function test_is_leaf() {
+		$node = new Node('');
+		$this->root->add_child($node);
+		$this->assertTrue($node->is_leaf());
+		$this->assertFalse($this->root->is_leaf());
+	}
+	
+	/**
+	 * @depends test_add_child
+	 */
+	function test_add() {
+		$node = $this->root->add('name', array('foo' => 'bar'));
+		$this->assertEquals($node->get_name(), 'name');
+		$this->assertEquals($node->get('foo'), 'bar');
+		$this->assertSame($node->get_parent(), $this->root);
+	}
+	
+	/**
+	 * @depends test_add
+	 */
+	function test_remove_child() {
+		$node1 = $this->root->add('name', array('foo' => 'bar'));
+		$node2 = $this->root->add('name', array('foo' => 'bar'));
+		$this->root->remove_child($node2);
+		$this->assertAttributeSame(array($node1), 'children', $this->root);
+	}
+	
+	/**
+	 * @depends test_remove_child
+	 */
+	function test_remove_leaf() {
+		$node1 = $this->root->add('name', array('foo' => 'bar'));
+		$node2 = $this->root->add('name', array('foo' => 'bar'));
+		$node1->remove();
+		$this->assertAttributeSame(array($node2), 'children', $this->root);
+	}
+	
+	/**
+	 * @depends test_remove_leaf
+	 */
+	function test_remove_node() {
+		$node = $this->root->add('node');
+		$leaf = $node->add('leaf');
+		$node->remove();
+		$this->assertAttributeEquals(array(), 'children', $this->root);
+		$this->assertNull($leaf->get_parent());
+	}
+	
+	/**
+	 * @depends test_remove_child
+	 * @expectedException \RuntimeException
+	 */
+	function test_remove_root() {
+		$node1 = $this->root->add('name', array('foo' => 'bar'));
+		$node2 = $this->root->add('name', array('foo' => 'bar'));
+		$this->root->remove();
+		$this->assertAttributeSame(array($node2), 'children', $this->root);
+	}
+	
+	function test_set_single() {
+		$this->root->set('foo', 'bar');
+		$this->assertAttributeEquals(array('foo' => 'bar'), 'variables', $this->root);
+		$this->root->set('bar', 'baz');
+		$this->assertAttributeEquals(array('foo' => 'bar', 'bar' => 'baz'), 'variables', $this->root);
+	}
+	
+	function test_set_return() {
+		$this->assertSame($this->root->set('foo', 'bar'), $this->root);
+	}
+	
+	function test_set_multiple() {
+		$this->root->set(array('foo' => 'bar'));
+		$this->assertAttributeEquals(array('foo' => 'bar'), 'variables', $this->root);
+		$this->root->set(array('bar' => 'baz'));
+		$this->assertAttributeEquals(array('foo' => 'bar', 'bar' => 'baz'), 'variables', $this->root);
+	}
+	
+	/**
+	 * @depends test_set_single
+	 */
+	function test___set() {
+		$this->root->foo = 'bar';
+		$this->assertAttributeEquals(array('foo' => 'bar'), 'variables', $this->root);
+		$this->root->bar = 'baz';
+		$this->assertAttributeEquals(array('foo' => 'bar', 'bar' => 'baz'), 'variables', $this->root);
+	}
+	
+	/**
+	 * @depends test_set_multiple
+	 */
+	function test_get_direct() {
+		$this->root->set(array('foo' => 'bar', 'bar' => 'baz'));
+		$this->assertEquals($this->root->get('foo'), 'bar');
+		$this->assertEquals($this->root->get('bar'), 'baz');
+	}
+	
+	/**
+	 * @depends test_get_direct
+	 */
+	function test___get() {
+		$this->root->set(array('foo' => 'bar', 'bar' => 'baz'));
+		$this->assertEquals($this->root->foo, 'bar');
+		$this->assertEquals($this->root->bar, 'baz');
+	}
+	
+	/**
+	 * @depends test_set_single
+	 */
+	function test_get_ancestor() {
+		$this->root->set('foo', 'bar');
+		$node = $this->root->add('');
+		$this->assertEquals($node->get('foo'), 'bar');
+	}
+	
+	function test_get_failure() {
+		$this->assertNull($this->root->get('foo'));
+	}
+	
+	/**
+	 * @depends test_get_name
+	 */
+	function test_find() {
+		$node1 = $this->root->add('foo');
+		$node2 = $this->root->add('bar');
+		$node3 = $this->root->add('foo');
+		$this->assertSame($this->root->find('foo'), array($node1, $node3));
+	}
+	
+	/**
+	 * @depends test_set_multiple
+	 */
+	function test_copy_simple() {
+		$copy = $this->root->copy();
+		$this->assertEquals($this->root, $copy);
+		$this->assertNotSame($this->root, $copy);
+	}
+	
+	/**
+	 * @depends test_copy_simple
+	 */
+	function test_copy_shallow() {
+		$child = $this->root->add('');
+		$copy = $this->root->copy();
+		$this->assertAttributeSame(array($child), 'children', $copy);
+	}
+	
+	/**
+	 * @depends test_get_children
+	 * @depends test_copy_simple
+	 */
+	function test_copy_deep() {
+		$child = $this->root->add('foo');
+		$copy = $this->root->copy(true);
+		$copy_children = $copy->get_children();
+		$child_copy = reset($copy_children);
+		$this->assertNotSame($copy_children, $this->root->get_children());
+		$this->assertSame($child_copy->get_parent(), $copy);
+	}
+}
+
+?>

+ 314 - 0
tests/test_template.php

@@ -0,0 +1,314 @@
+<?php
+
+require_once 'template.php';
+use BasicWeb\Template;
+use BasicWeb\Node;
+
+define('TEMPLATES_DIR', 'tests/_files/templates/');
+
+class TemplateTest extends PHPUnit_Framework_TestCase {
+	/**
+	 * @depends test_add_root_success
+	 */
+	function setUp() {
+		Template::set_root(TEMPLATES_DIR);
+		$this->tpl = new Template('foo');
+		$this->data = new Node();
+		
+		$object = new stdClass();
+		$object->foo = 'bar';
+		$object->bar = 'baz';
+		
+		$this->data->set(array(
+			'foo' => 'bar',
+			'bar' => 'baz',
+			'FOO' => 'BAR',
+			'true' => true,
+			'false' => false,
+			'array' => array('foo' => 'bar', 'bar' => 'baz'),
+			'object' => $object,
+			'foobar' => 'my_foobar_variable',
+			'foobaz' => 'MY_FOOBAZ_VARIABLE',
+		));
+	}
+	
+	/**
+	 * @expectedException BasicWeb\FileNotFoundError
+	 * @expectedExceptionMessage Directory "non_existing_folder/" does not exist.
+	 */
+	function test_add_root_failure() {
+		Template::add_root('non_existing_folder');
+	}
+	
+	function assert_include_path_equals($expected) {
+		$include_path = new ReflectionProperty('BasicWeb\Template', 'include_path');
+		$include_path->setAccessible(true);
+		$this->assertEquals($expected, $include_path->getValue());
+	}
+	
+	function test_clear_include_path() {
+		Template::clear_include_path();
+		$this->assert_include_path_equals(array());
+	}
+	
+	/**
+	 * @depends test_clear_include_path
+	 */
+	function test_add_root_success() {
+		Template::clear_include_path();
+		Template::add_root(TEMPLATES_DIR);
+		$this->assert_include_path_equals(array(TEMPLATES_DIR));
+		Template::add_root('tests/_files');
+		$this->assert_include_path_equals(array(TEMPLATES_DIR, 'tests/_files/'));
+	}
+	
+	/**
+	 * @depends test_add_root_success
+	 */
+	function test_set_root() {
+		Template::clear_include_path();
+		Template::add_root(TEMPLATES_DIR);
+		Template::add_root('tests/_files');
+		Template::set_root(TEMPLATES_DIR);
+		$this->assert_include_path_equals(array(TEMPLATES_DIR));
+	}
+	
+	/**
+	 * @expectedException RuntimeException
+	 */
+	function test_non_existing_template() {
+		$bar = new Template('bar');
+	}
+	
+	function test_other_root() {
+		Template::add_root('tests/_files/other_templates');
+		new Template('bar');
+	}
+	
+	function test_get_path() {
+		$this->assertEquals(TEMPLATES_DIR.'foo.tpl', $this->tpl->get_path());
+	}
+	
+	function get_property($object, $property_name) {
+		$rp = new ReflectionProperty($object, $property_name);
+		$rp->setAccessible(true);
+		return $rp->getValue($object);
+	}
+	
+	function test_parse_blocks_simple() {
+		$root_block = $this->get_property($this->tpl, 'root_block');
+		$this->assert_is_block_node($root_block, null, 1);
+		
+		list($child) = $root_block->get_children();
+		$this->assert_is_html_node($child, 'test');
+	}
+	
+	function assert_is_html_node($node, $content) {
+		$this->assertEquals('html', $node->get_name());
+		$this->assertEquals($content, $node->get('content'));
+		$this->assertEquals(array(), $node->get_children());
+	}
+	
+	function assert_is_block_node($node, $block_name, $child_count) {
+		$this->assertEquals('block', $node->get_name());
+		$this->assertSame($block_name, $node->get('name'));
+		$this->assertNull($node->get('content'));
+		$this->assertEquals($child_count, count($node->get_children()));
+	}
+	
+	function assert_is_variable_node($node, $brackets_content) {
+		$this->assertEquals('variable', $node->get_name());
+		$this->assertEquals($brackets_content, $node->get('content'));
+		$this->assertEquals(array(), $node->get_children());
+	}
+	
+	/**
+	 * @depends test_parse_blocks_simple
+	 */
+	function test_parse_blocks_blocks() {
+		$tpl = new Template('blocks');
+		$root_block = $this->get_property($tpl, 'root_block');
+		$this->assert_is_block_node($root_block, null, 3);
+		
+		list($before, $foo, $after) = $root_block->get_children();
+		$this->assert_is_html_node($before, '');
+		$this->assert_is_block_node($foo, 'foo', 3);
+		$this->assert_is_html_node($after, '');
+		
+		list($foofoo, $bar, $foobaz) = $foo->get_children();
+		$this->assert_is_html_node($foofoo, "\nfoofoo\n\t");
+		$this->assert_is_block_node($bar, 'bar', 1);
+		$this->assert_is_html_node($foobaz, "\nfoobaz\n");
+		
+		list($foobar) = $bar->get_children();
+		$this->assert_is_html_node($foobar, "\n\tfoobar\n\t");
+	}
+	
+	/**
+	 * @depends test_parse_blocks_blocks
+	 * @expectedException BasicWeb\ParseError
+	 * @expectedExceptionMessage Parse error in file tests/_files/templates/unexpected_end.tpl, line 5: unexpected {end}
+	 */
+	function test_parse_blocks_unexpected_end() {
+		new Template('unexpected_end');
+	}
+	
+	/**
+	 * @depends test_parse_blocks_blocks
+	 * @expectedException BasicWeb\ParseError
+	 * @expectedExceptionMessage Parse error in file tests/_files/templates/missing_end.tpl, line 6: missing {end}
+	 */
+	function test_parse_blocks_missing_end() {
+		new Template('missing_end');
+	}
+	
+	/**
+	 * @depends test_parse_blocks_simple
+	 */
+	function test_parse_blocks_variables() {
+		$tpl = new Template('variables');
+		$root_block = $this->get_property($tpl, 'root_block');
+		$this->assert_is_block_node($root_block, null, 5);
+		
+		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_html_node($bar, "\nbar\n");
+		$this->assert_is_variable_node($foobaz, 'foobaz:strtolower');
+		$this->assert_is_html_node($baz, "\nbaz");
+	}
+	
+	/**
+	 * @depends test_parse_blocks_blocks
+	 * @depends test_parse_blocks_variables
+	 */
+	function test_parse_blocks_full() {
+		$tpl = new Template('full');
+		$root_block = $this->get_property($tpl, 'root_block');
+		$this->assert_is_block_node($root_block, null, 3);
+		
+		list($bar, $foo, $baz) = $root_block->get_children();
+		$this->assert_is_html_node($bar, "bar\n");
+		$this->assert_is_block_node($foo, 'foo', 5);
+		$this->assert_is_html_node($baz, "\nbaz");
+		
+		list($foofoo, $bar, $first_space, $foobaz, $second_space) = $foo->get_children();
+		$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_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_html_node($space_after, "\n\t");
+	}
+	
+	function assert_replaces($expected, $variable) {
+		$rm = new ReflectionMethod('BasicWeb\Template', 'replace_variable');
+		$rm->setAccessible(true);
+		$this->assertEquals($expected, $rm->invoke(null, $variable, $this->data));
+	}
+	
+	function test_replace_variable_simple() {
+		$this->assert_replaces('bar', 'foo');
+	}
+	
+	/**
+	 * @depends test_replace_variable_simple
+	 */
+	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');
+	}
+	
+	/**
+	 * @expectedException UnexpectedValueException
+	 * @expectedExceptionMessage Helper function "idonotexist" is not callable.
+	 */
+	function test_replace_variable_non_callable_helper_function() {
+		$this->assert_replaces(null, 'foo:idonotexist');
+	}
+	
+	function test_replace_variable_not_found() {
+		$this->assert_replaces('{idonotexist}', 'idonotexist');
+	}
+	
+	function test_replace_variable_associative_array() {
+		$this->assert_replaces('bar', 'array.foo');
+		$this->assert_replaces('baz', 'array.bar');
+	}
+	
+	function test_replace_variable_object_property() {
+		$this->assert_replaces('bar', 'object.foo');
+		$this->assert_replaces('baz', 'object.bar');
+	}
+	
+	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 assert_block_renders($expected_file, $block, $data) {
+		$rm = new ReflectionMethod('BasicWeb\Template', 'render_block');
+		$rm->setAccessible(true);
+		$expected_file = "tests/_files/rendered/$expected_file.html";
+		$this->assertStringEqualsFile($expected_file, $rm->invoke(null, $block, $data));
+	}*/
+	
+	function assert_renders($expected_file, $tpl) {
+		$expected_file = "tests/_files/rendered/$expected_file.html";
+		$this->assertStringEqualsFile($expected_file, $tpl->render());
+	}
+	
+	function test_render_simple() {
+		$this->assertEquals('test', $this->tpl->render());
+	}
+	
+	/**
+	 * @depends test_replace_variable_helper_functions
+	 */
+	function test_render_variable() {
+		$tpl = new Template('variables');
+		$tpl->set(array(
+			'foobar' => 'my_foobar_variable',
+			'foobaz' => 'MY_FOOBAZ_VARIABLE'
+		));
+		$this->assert_renders('variables', $tpl);
+	}
+	
+	/**
+	 * @depends test_render_simple
+	 */
+	function test_render_blocks() {
+		$tpl = new Template('blocks');
+		
+		$foo = $tpl->add('foo');
+		$foo->add('bar');
+		$foo->add('bar');
+		$tpl->add('foo');
+		
+		$this->assert_renders('blocks', $tpl);
+	}
+	
+	/**
+	 * @depends test_render_variable
+	 * @depends test_render_blocks
+	 */
+	function test_render_full() {
+		$tpl = new Template('full');
+		$first_foo = $tpl->add('foo')->set('foobaz', 'FIRST_FOOBAZ_VAR');
+		$first_foo->add('bar')->set('foobar', 'first_foobar_var');
+		$second_foo = $tpl->add('foo')->set('foobaz', 'SECOND_FOOBAZ_VAR');
+		$second_foo->add('bar')->set('foobar', 'second_foobar_var');
+		$second_foo->add('bar')->set('foobar', 'third_foobar_var');
+		$this->assert_renders('full', $tpl);
+	}
+}
+
+?>