Quellcode durchsuchen

Initial Mustache.php 2.0 commit.

Justin Hileman vor 13 Jahren
Ursprung
Commit
d2136d91b3

+ 129 - 0
src/Mustache/Buffer.php

@@ -0,0 +1,129 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Mustache output Buffer class.
+ *
+ * Buffer instances are used by Mustache Templates for collecting output during rendering
+ */
+class Buffer {
+	private $buffer  = '';
+	private $indent  = '';
+	private $charset = 'UTF-8';
+
+	/**
+	 * Mustache Buffer constructor.
+	 *
+	 * @param string $indent  Initial indent level for all lines of this buffer (default: '')
+	 * @param string $charset Override the character set used by `htmlspecialchars()` (default: 'UTF-8')
+	 */
+	public function __construct($indent = null, $charset = null) {
+		if ($indent !== null) {
+			$this->setIndent($indent);
+		}
+
+		if ($charset !== null) {
+			$this->charset = $charset;
+		}
+	}
+
+	/**
+	 * Get the current indent level.
+	 *
+	 * @return string
+	 */
+	public function getIndent() {
+		return $this->indent;
+	}
+
+	/**
+	 * Set the buffer indent level.
+	 *
+	 * Each line output by this buffer will be prefixed by this whitespace. This is used when rendering
+	 * partials and Lambda sections.
+	 *
+	 * @param string $indent
+	 */
+	public function setIndent($indent) {
+		$this->indent = $indent;
+	}
+
+	/**
+	 * Get the character set used when escaping values.
+	 *
+	 * @return string
+	 */
+	public function getCharset() {
+		return $this->charset;
+	}
+
+	/**
+	 * Write a newline to the Buffer.
+	 */
+	public function writeLine() {
+		$this->buffer .= "\n";
+	}
+
+	/**
+	 * Output text to the Buffer.
+	 *
+	 * @see \Mustache\Buffer::write
+	 *
+	 * @param string $text
+	 * @param bool $escape Escape this text with `htmlspecialchars()`? (default: false)
+	 */
+	public function writeText($text, $escape = false) {
+		$this->write($text, true, $escape);
+	}
+
+	/**
+	 * Add output to the Buffer.
+	 *
+	 * @param string $text
+	 * @param bool $indent Indent this line? (default: false)
+	 * @param bool $escape Escape this text with `htmlspecialchars()`? (default: false)
+	 */
+	public function write($text, $indent = false, $escape = false) {
+		$text = (string) $text;
+
+		if ($escape) {
+			$text = $this->escape($text);
+		}
+
+		if ($indent) {
+			$this->buffer .= $this->indent . $text;
+		} else {
+			$this->buffer .= $text;
+		}
+	}
+
+	/**
+	 * Flush the contents of the Buffer.
+	 *
+	 * Resets the buffer and returns the current contents.
+	 *
+	 * @return string
+	 */
+	public function flush() {
+		$buffer = $this->buffer;
+		$this->buffer = '';
+
+		return $buffer;
+	}
+
+	/**
+	 * Helper function to escape text.
+	 *
+	 * Uses the Buffer's character set (default 'UTF-8', passed as the second argument to `__construct`).
+	 *
+	 * @see htmlspecialchars
+	 *
+	 * @param string $text
+	 *
+	 * @return string Escaped text
+	 */
+	private function escape($text) {
+		return htmlspecialchars($text, ENT_COMPAT, $this->charset);
+	}
+}

+ 292 - 0
src/Mustache/Compiler.php

@@ -0,0 +1,292 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Mustache Compiler class.
+ *
+ * This class is responsible for turning a Mustache token parse tree into normal PHP source code.
+ */
+class Compiler {
+
+	/**
+	 * Compile a Mustache token parse tree into PHP source code.
+	 *
+	 * @param string $source Mustache Template source code
+	 * @param string $tree   Parse tree of Mustache tokens
+	 * @param string $name   Mustache Template class name
+	 *
+	 * @return string Generated PHP source code
+	 */
+	public function compile($source, array $tree, $name) {
+		$this->source = $source;
+
+		return $this->writeCode($tree, $name);
+	}
+
+	/**
+	 * Helper function for walking the Mustache token parse tree.
+	 *
+	 * @throws \InvalidArgumentException upon encountering unknown token types.
+	 *
+	 * @param array $tree  Parse tree of Mustache tokens
+	 * @param int   $level (default: 0)
+	 *
+	 * @return string Generated PHP source code;
+	 */
+	private function walk(array $tree, $level = 0) {
+		$code = '';
+		$level++;
+		foreach ($tree as $node) {
+			switch (is_string($node) ? 'text' : $node[Tokenizer::TAG]) {
+				case '#':
+					$code .= $this->section(
+						$node[Tokenizer::NODES],
+						$node[Tokenizer::NAME],
+						$node[Tokenizer::INDEX],
+						$node[Tokenizer::END],
+						$node[Tokenizer::OTAG],
+						$node[Tokenizer::CTAG],
+						$level
+					);
+					break;
+
+				case '^':
+					$code .= $this->invertedSection(
+						$node[Tokenizer::NODES],
+						$node[Tokenizer::NAME],
+						$level
+					);
+					break;
+
+				case '<':
+				case '>':
+					$code .= $this->partial(
+						$node[Tokenizer::NAME],
+						isset($node[Tokenizer::INDENT]) ? $node[Tokenizer::INDENT] : '',
+						$level
+					);
+					break;
+
+				case '{':
+				case '&':
+					$code .= $this->variable($node[Tokenizer::NAME], false, $level);
+					break;
+
+				case '!':
+					break;
+
+				case '_v':
+					$code .= $this->variable($node[Tokenizer::NAME], true, $level);
+					break;
+
+
+				case 'text':
+					$code .= $this->text($node, $level);
+					break;
+
+				default:
+					throw new \InvalidArgumentException('Unknown node type: '.json_encode($node));
+			}
+		}
+
+		return $code;
+	}
+
+	const KLASS = '<?php
+
+		class %s extends \Mustache\Template {
+			public function renderInternal(\Mustache\Context $context, $indent = \'\') {
+				$mustache = $this->mustache;
+				$buffer = new \Mustache\Buffer($indent, $mustache->getCharset());
+		%s
+
+				return $buffer->flush();
+			}
+		}';
+
+	/**
+	 * Generate Mustache Template class PHP source.
+	 *
+	 * @param array  $tree Parse tree of Mustache tokens
+	 * @param string $name Mustache Template class name
+	 *
+	 * @return string Generated PHP source code
+	 */
+	private function writeCode($tree, $name) {
+		return sprintf($this->prepare(self::KLASS, 0, false), $name, $this->walk($tree), $name);
+	}
+
+	const SECTION = '
+		// %s section
+		$value = $context->%s(%s);
+		if ($context->isCallable($value)) {
+			$source = %s;
+			$buffer->write(
+				$mustache
+					->loadLambda((string) call_user_func($value, $source)%s)
+					->renderInternal($context, $buffer->getIndent())
+			);
+		} elseif ($context->isTruthy($value)) {
+			$values = $context->isIterable($value) ? $value : array($value);
+			foreach ($values as $value) {
+				$context->push($value);%s
+				$context->pop();
+			}
+		}';
+
+	/**
+	 * Generate Mustache Template section PHP source.
+	 *
+	 * @param array  $nodes Array of child tokens
+	 * @param string $id    Section name
+	 * @param int    $start Section start offset
+	 * @param int    $end   Section end offset
+	 * @param string $otag  Current Mustache opening tag
+	 * @param string $ctag  Current Mustache closing tag
+	 * @param int    $level
+	 *
+	 * @return string Generated section PHP source code
+	 */
+	private function section($nodes, $id, $start, $end, $otag, $ctag, $level) {
+		$method = $this->getFindMethod($id);
+		$id     = var_export($id, true);
+		$source = var_export(substr($this->source, $start, $end - $start), true);
+
+		if ($otag !== '{{' || $ctag !== '}}') {
+			$delims = ', '.var_export(sprintf('{{= %s %s =}}', $otag, $ctag), true);
+		} else {
+			$delims = '';
+		}
+
+		return sprintf($this->prepare(self::SECTION, $level), $id, $method, $id, $source, $delims, $this->walk($nodes, $level + 1));
+	}
+
+	const INVERTED_SECTION = '
+		// %s inverted section
+		if (!$context->isTruthy($context->%s(%s))) {
+			%s
+		}';
+
+	/**
+	 * Generate Mustache Template inverted section PHP source.
+	 *
+	 * @param array  $nodes Array of child tokens
+	 * @param string $id    Section name
+	 * @param int    $level
+	 *
+	 * @return string Generated inverted section PHP source code
+	 */
+	private function invertedSection($nodes, $id, $level) {
+		$method = $this->getFindMethod($id);
+		$id     = var_export($id, true);
+
+		return sprintf($this->prepare(self::INVERTED_SECTION, $level), $id, $method, $id, $this->walk($nodes, $level));
+	}
+
+	const PARTIAL = '$buffer->write($mustache->loadPartial(%s)->renderInternal($context, %s));';
+
+	/**
+	 * Generate Mustache Template partial call PHP source.
+	 *
+	 * @param string $id     Partial name
+	 * @param string $indent Whitespace indent to apply to partial
+	 * @param int    $level
+	 *
+	 * @return string Generated partial call PHP source code
+	 */
+	private function partial($id, $indent, $level) {
+		return sprintf(
+			$this->prepare(self::PARTIAL, $level),
+			var_export($id, true),
+			var_export($indent, true)
+		);
+	}
+
+	const VARIABLE = '
+		$value = $context->%s(%s);
+		if ($context->isCallable($value)) {
+			$value = $mustache
+				->loadLambda((string) call_user_func($value))
+				->renderInternal($context, $buffer->getIndent());
+		}
+		$buffer->writeText($value, %s);
+	';
+
+	/**
+	 * Generate Mustache Template variable interpolation PHP source.
+	 *
+	 * @param string  $id     Variable name
+	 * @param boolean $escape Escape the variable value for output?
+	 * @param int     $level
+	 *
+	 * @return string Generated variable interpolation PHP source
+	 */
+	private function variable($id, $escape, $level) {
+		$method = $this->getFindMethod($id);
+		$id     = ($method !== 'last') ? var_export($id, true) : '';
+		$escape = $escape ? 'true' : 'false';
+
+		return sprintf($this->prepare(self::VARIABLE, $level), $method, $id, $escape);
+	}
+
+	const LINE = '$buffer->writeLine();';
+	const TEXT = '$buffer->writeText(%s);';
+
+	/**
+	 * Generate Mustache Template output Buffer call PHP source.
+	 *
+	 * @param string $text
+	 * @param int    $level
+	 *
+	 * @return string Generated output Buffer call PHP source
+	 */
+	private function text($text, $level) {
+		if ($text === "\n") {
+			return $this->prepare(self::LINE, $level);
+		} else {
+			return sprintf($this->prepare(self::TEXT, $level), var_export($text, true));
+		}
+	}
+
+	/**
+	 * Prepare PHP source code snippet for output.
+	 *
+	 * @param string  $text
+	 * @param int     $bonus Additional indent level (default: 0)
+	 * @param boolean $prependNewline Prepend a newline to the snippet? (default: true)
+	 *
+	 * @return string PHP source code snippet
+	 */
+	private function prepare($text, $bonus = 0, $prependNewline = true) {
+		$text = ($prependNewline ? "\n" : '').trim($text);
+		if ($prependNewline) {
+			$bonus++;
+		}
+
+		return preg_replace("/\n(\t\t)?/", "\n".str_repeat("\t", $bonus), $text);
+	}
+
+	/**
+	 * Select the appropriate Context `find` method for a given $id.
+	 *
+	 * The return value will be one of `find`, `findDot` or `last`.
+	 *
+	 * @see \Mustache\Context::find
+	 * @see \Mustache\Context::findDot
+	 * @see \Mustache\Context::last
+	 *
+	 * @param string $id Variable name
+	 *
+	 * @return string `find` method name
+	 */
+	private function getFindMethod($id) {
+		if ($id === '.') {
+			return 'last';
+		} elseif (strpos($id, '.') === false) {
+			return 'find';
+		} else {
+			return 'findDot';
+		}
+	}
+}

+ 203 - 0
src/Mustache/Context.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Mustache Template rendering Context.
+ */
+class Context {
+	private $stack = array();
+
+	/**
+	 * Mustache rendering Context constructor.
+	 *
+	 * @param mixed $context Default rendering context (default: null)
+	 */
+	public function __construct($context = null) {
+		if ($context !== null) {
+			$this->stack = array($context);
+		}
+	}
+
+	/**
+	 * Helper function to test whether a value is 'truthy'.
+	 *
+	 * @param mixed $value
+	 *
+	 * @return boolean True if the value is 'truthy'
+	 */
+	public function isTruthy($value) {
+		return !empty($value);
+	}
+
+	/**
+	 * Higher order sections helper: tests whether a value is a valid callback.
+	 *
+	 * In Mustache.php, a variable is considered 'callable' if the variable is:
+	 *
+	 *  1. An anonymous function.
+	 *  2. An object and the name of a public function, e.g. `array($someObject, 'methodName')`
+	 *  3. A class name and the name of a public static function, e.g. `array('SomeClass', 'methodName')`
+	 *
+	 * Note that this specifically excludes strings, which PHP would normally consider 'callable'.
+	 *
+	 * @param mixed $value
+	 *
+	 * @return boolean True if the value is 'callable'
+	 */
+	public function isCallable($value) {
+		return !is_string($value) && is_callable($value);
+	}
+
+	/**
+	 * Tests whether a value should be iterated over (e.g. in a section context).
+	 *
+	 * In most languages there are two distinct array types: list and hash (or whatever you want to call them). Lists
+	 * should be iterated, hashes should be treated as objects. Mustache follows this paradigm for Ruby, Javascript,
+	 * Java, Python, etc.
+	 *
+	 * PHP, however, treats lists and hashes as one primitive type: array. So Mustache.php needs a way to distinguish
+	 * between between a list of things (numeric, normalized array) and a set of variables to be used as section context
+	 * (associative array). In other words, this will be iterated over:
+	 *
+	 *     $items = array(
+	 *         array('name' => 'foo'),
+	 *         array('name' => 'bar'),
+	 *         array('name' => 'baz'),
+	 *     );
+	 *
+	 * ... but this will be used as a section context block:
+	 *
+	 *     $items = array(
+	 *         1        => array('name' => 'foo'),
+	 *         'banana' => array('name' => 'bar'),
+	 *         42       => array('name' => 'baz'),
+	 *     );
+	 *
+	 * @param mixed $value
+	 *
+	 * @return boolean True if the value is 'iterable'
+	 */
+	public function isIterable($value) {
+		if (is_object($value)) {
+			return $value instanceof \Traversable;
+		} elseif (is_array($value)) {
+			return !array_diff_key($value, array_keys(array_keys($value)));
+		}
+
+		return false;
+	}
+
+	/**
+	 * Push a new Context frame onto the stack.
+	 *
+	 * @param mixed $value Object or array to use for context
+	 */
+	public function push($value) {
+		array_push($this->stack, $value);
+	}
+
+	/**
+	 * Pop the last Context frame from the stack.
+	 *
+	 * @return mixed Last Context frame (object or array)
+	 */
+	public function pop() {
+		return array_pop($this->stack);
+	}
+
+	/**
+	 * Get the last Context frame.
+	 *
+	 * @return mixed Last Context frame (object or array)
+	 */
+	public function last() {
+		return end($this->stack);
+	}
+
+	/**
+	 * Find a variable in the Context stack.
+	 *
+	 * Starting with the last Context frame (the context of the innermost section), and working back to the top-level
+	 * rendering context, look for a variable with the given name:
+	 *
+	 *  * If the Context frame is an associative array which contains the key $id, returns the value of that element.
+	 *  * If the Context frame is an object, this will check first for a public method, then a public property named
+	 *    $id. Failing both of these, it will try `__isset` and `__get` magic methods.
+	 *  * If a value named $id is not found in any Context frame, returns an empty string.
+	 *
+	 * @param string $id Variable name
+	 *
+	 * @return mixed Variable value, or '' if not found
+	 */
+	public function find($id) {
+		return $this->findVariableInStack($id, $this->stack);
+	}
+
+	/**
+	 * Find a 'dot notation' variable in the Context stack.
+	 *
+	 * Note that dot notation traversal bubbles through scope differently than the regular find method. After finding
+	 * the initial chunk of the dotted name, each subsequent chunk is searched for only within the value of the previous
+	 * result. For example, given the following context stack:
+	 *
+	 *     $data = array(
+	 *         'name' => 'Fred',
+	 *         'child' => array(
+	 *             'name' => 'Bob'
+	 *         ),
+	 *     );
+	 *
+	 * ... and the Mustache following template:
+	 *
+	 *     {{ child.name }}
+	 *
+	 * ... the `name` value is only searched for within the `child` value of the global Context, not within parent
+	 * Context frames.
+	 *
+	 * @param string $id Dotted variable selector
+	 *
+	 * @return mixed Variable value, or '' if not found
+	 */
+	public function findDot($id) {
+		$chunks = explode('.', $id);
+		$first  = array_shift($chunks);
+		$value  = $this->findVariableInStack($first, $this->stack);
+
+		foreach ($chunks as $chunk) {
+			if ($value === '') {
+				return $value;
+			}
+
+			$value = $this->findVariableInStack($chunk, array($value));
+		}
+
+		return $value;
+	}
+
+	/**
+	 * Helper function to find a variable in the Context stack.
+	 *
+	 * @see \Mustache\Context::find
+	 *
+	 * @param string $id Variable name
+	 * @param array  $stack Context stack
+	 *
+	 * @return mixed Variable value, or '' if not found
+	 */
+	private function findVariableInStack($id, array $stack) {
+		for ($i = count($stack) - 1; $i >= 0; $i--) {
+			if (is_object($stack[$i])) {
+				if (method_exists($stack[$i], $id)) {
+					return $stack[$i]->$id();
+				} elseif (isset($stack[$i]->$id)) {
+					return $stack[$i]->$id;
+				}
+			} elseif (is_array($stack[$i]) && array_key_exists($id, $stack[$i])) {
+				return $stack[$i][$id];
+			}
+		}
+
+		return '';
+	}
+}

+ 18 - 0
src/Mustache/Loader.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Mustache Template Loader interface.
+ */
+interface Loader {
+
+	/**
+	 * Load a Template by name.
+	 *
+	 * @param  string $name
+	 *
+	 * @return string Mustache Template source
+	 */
+	function load($name);
+}

+ 70 - 0
src/Mustache/Loader/ArrayLoader.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Mustache\Loader;
+
+use Mustache\Loader;
+use Mustache\Loader\MutableLoader;
+
+/**
+ * Mustache Template array Loader implementation.
+ *
+ * An ArrayLoader instance loads Mustache Template source by name from an initial array:
+ *
+ *     $loader = new ArrayLoader(
+ *         'foo' => '{{ bar }}',
+ *         'baz' => 'Hey {{ qux }}!'
+ *     );
+ *
+ *     $tpl = $loader->load('foo'); // '{{ bar }}'
+ *
+ * The ArrayLoader is used internally as a partials loader by \Mustache\Mustache instance when an array of partials
+ * is set. It can also be used as a quick-and-dirty Template loader.
+ *
+ * @implements Loader
+ * @implements MutableLoader
+ */
+class ArrayLoader implements Loader, MutableLoader {
+
+	/**
+	 * ArrayLoader constructor.
+	 *
+	 * @param array $templates Associative array of Template source (default: array())
+	 */
+	public function __construct(array $templates = array()) {
+		$this->templates = $templates;
+	}
+
+	/**
+	 * Load a Template.
+	 *
+	 * @param  string $name
+	 *
+	 * @return string Mustache Template source
+	 */
+	public function load($name) {
+		if (!isset($this->templates[$name])) {
+			throw new \InvalidArgumentException('Template '.$name.' not found.');
+		}
+
+		return $this->templates[$name];
+	}
+
+	/**
+	 * Set an associative array of Template sources for this loader.
+	 *
+	 * @param array $templates
+	 */
+	public function setTemplates(array $templates) {
+		$this->templates = $templates;
+	}
+
+	/**
+	 * Set a Template source by name.
+	 *
+	 * @param string $name
+	 * @param string $template Mustache Template source
+	 */
+	public function setTemplate($name, $template) {
+		$this->templates[$name] = $template;
+	}
+}

+ 108 - 0
src/Mustache/Loader/FilesystemLoader.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace Mustache\Loader;
+
+use Mustache\Loader;
+
+/**
+ * Mustache Template filesystem Loader implementation.
+ *
+ * An ArrayLoader instance loads Mustache Template source from the filesystem by name:
+ *
+ *     $loader = new FilesystemLoader(__DIR__.'/views');
+ *     $tpl = $loader->load('foo'); // equivalent to `file_get_contents(__DIR__.'/views/foo.mustache');
+ *
+ * This is probably the most useful Mustache Loader implementation. It can be used for partials and normal Templates:
+ *
+ *     $m = new Mustache(array(
+ *          'loader'          => new FilesystemLoader(__DIR__.'/views'),
+ *          'partials_loader' => new FilesystemLoader(__DIR__.'/views/partials'),
+ *     ));
+ *
+ * @implements Loader
+ */
+class FilesystemLoader implements Loader {
+	private $baseDir;
+	private $extension = '.mustache';
+	private $templates = array();
+
+	/**
+	 * Mustache filesystem Loader constructor.
+	 *
+	 * Passing an $options array allows overriding certain Loader options during instantiation:
+	 *
+	 *     $options = array(
+	 *         // The filename extension used for Mustache templates. Defaults to '.mustache'
+	 *         'extension' => '.ms',
+	 *     );
+	 *
+	 * @throws \RuntimeException if $baseDir does not exist.
+	 *
+	 * @param string $baseDir Base directory containing Mustache template files.
+	 * @param array  $options Array of Loader options (default: array())
+	 */
+	public function __construct($baseDir, array $options = array()) {
+		$this->baseDir = rtrim(realpath($baseDir), '/');
+
+		if (!is_dir($this->baseDir)) {
+			throw new \RuntimeException('FilesystemLoader baseDir must be a directory: '.$baseDir);
+		}
+
+		if (isset($options['extension'])) {
+			$this->extension = '.' . ltrim($options['extension'], '.');
+		}
+	}
+
+	/**
+	 * Load a Template by name.
+	 *
+	 *     $loader = new FilesystemLoader(__DIR__.'/views');
+	 *     $loader->load('admin/dashboard'); // loads "./views/admin/dashboard.mustache";
+	 *
+	 * @param  string $name
+	 *
+	 * @return string Mustache Template source
+	 */
+	public function load($name) {
+		if (!isset($this->templates[$name])) {
+			$this->templates[$name] = $this->loadFile($name);
+		}
+
+		return $this->templates[$name];
+	}
+
+	/**
+	 * Helper function for loading a Mustache file by name.
+	 *
+	 * @throws \InvalidArgumentException if a template file is not found.
+	 *
+	 * @param string $name
+	 *
+	 * @return string Mustache Template source
+	 */
+	private function loadFile($name) {
+		$fileName = $this->getFileName($name);
+
+		if (!file_exists($fileName)) {
+			throw new \InvalidArgumentException('Template '.$name.' not found.');
+		}
+
+		return file_get_contents($fileName);
+	}
+
+	/**
+	 * Helper function for getting a Mustache template file name.
+	 *
+	 * @param string $name
+	 *
+	 * @return string Template file name
+	 */
+	private function getFileName($name) {
+		$fileName = $this->baseDir . '/' . $name;
+		if (substr($fileName, 0 - strlen($this->extension)) !== $this->extension) {
+			$fileName .= $this->extension;
+		}
+
+		return $fileName;
+	}
+}

+ 24 - 0
src/Mustache/Loader/MutableLoader.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace Mustache\Loader;
+
+/**
+ * Mustache Template mutable Loader interface.
+ */
+interface MutableLoader {
+
+	/**
+	 * Set an associative array of Template sources for this loader.
+	 *
+	 * @param array $templates
+	 */
+	function setTemplates(array $templates);
+
+	/**
+	 * Set a Template source by name.
+	 *
+	 * @param string $name
+	 * @param string $template Mustache Template source
+	 */
+	function setTemplate($name, $template);
+}

+ 35 - 0
src/Mustache/Loader/StringLoader.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Mustache\Loader;
+
+use Mustache\Loader;
+
+/**
+ * Mustache Template string Loader implementation.
+ *
+ * A StringLoader instance is essentially a noop. It simply passes the 'name' argument straight through:
+ *
+ *     $loader = new StringLoader;
+ *     $tpl = $loader->load('{{ foo }}'); // '{{ foo }}'
+ *
+ * This is the default Template Loader instance used by Mustache:
+ *
+ *     $m = new Mustache;
+ *     $tpl = $m->loadTemplate('{{ foo }}');
+ *     echo $tpl->render(array('foo' => 'bar')); // "bar"
+ *
+ * @implements Loader
+ */
+class StringLoader implements Loader {
+
+	/**
+	 * Load a Template by source.
+	 *
+	 * @param  string $name Mustache Template source
+	 *
+	 * @return string Mustache Template source
+	 */
+	public function load($name) {
+		return $name;
+	}
+}

+ 219 - 729
src/Mustache/Mustache.php

@@ -1,5 +1,11 @@
 <?php
 
+namespace Mustache;
+
+use Mustache\Loader\ArrayLoader;
+use Mustache\Loader\MutableLoader;
+use Mustache\Loader\StringLoader;
+
 /**
  * A Mustache implementation in PHP.
  *
@@ -13,898 +19,382 @@
  * @author Justin Hileman {@link http://justinhileman.com}
  */
 class Mustache {
-
-	const VERSION      = '1.0.0';
+	const VERSION      = '2.0.0-a1';
 	const SPEC_VERSION = '1.1.2';
 
-	/**
-	 * Should this Mustache throw exceptions when it finds unexpected tags?
-	 *
-	 * @see self::_throwsException()
-	 */
-	protected $_throwsExceptions = array(
-		MustacheException::UNKNOWN_VARIABLE         => false,
-		MustacheException::UNCLOSED_SECTION         => true,
-		MustacheException::UNEXPECTED_CLOSE_SECTION => true,
-		MustacheException::UNKNOWN_PARTIAL          => false,
-		MustacheException::UNKNOWN_PRAGMA           => true,
-	);
+	// Template cache
+	private $templates = array();
 
-	// Override charset passed to htmlentities() and htmlspecialchars(). Defaults to UTF-8.
-	protected $_charset = 'UTF-8';
+	// Environment
+	private $templateClassPrefix = '__Mustache_';
+	private $cache = null;
+	private $loader;
+	private $partialsLoader;
+	private $charset = 'UTF-8';
 
 	/**
-	 * Pragmas are macro-like directives that, when invoked, change the behavior or
-	 * syntax of Mustache.
+	 * Mustache class constructor.
 	 *
-	 * They should be considered extremely experimental. Most likely their implementation
-	 * will change in the future.
-	 */
-
-	/**
-	 * The {{%UNESCAPED}} pragma swaps the meaning of the {{normal}} and {{{unescaped}}}
-	 * Mustache tags. That is, once this pragma is activated the {{normal}} tag will not be
-	 * escaped while the {{{unescaped}}} tag will be escaped.
+	 * Passing an $options array allows overriding certain Mustache options during instantiation:
 	 *
-	 * Pragmas apply only to the current template. Partials, even those included after the
-	 * {{%UNESCAPED}} call, will need their own pragma declaration.
+	 *     $options = array(
+	 *         // The class prefix for compiled templates. Defaults to '__Mustache_'
+	 *         'template_class_prefix' => '\My\Namespace\Template\',
 	 *
-	 * This may be useful in non-HTML Mustache situations.
-	 */
-	const PRAGMA_UNESCAPED    = 'UNESCAPED';
-
-	/**
-	 * Constants used for section and tag RegEx
-	 */
-	const SECTION_TYPES = '\^#\/';
-	const TAG_TYPES = '#\^\/=!<>\\{&';
-
-	protected $_otag = '{{';
-	protected $_ctag = '}}';
-
-	protected $_tagRegEx;
-
-	protected $_template = '';
-	protected $_context  = array();
-	protected $_partials = array();
-	protected $_pragmas  = array();
-
-	protected $_pragmasImplemented = array(
-		self::PRAGMA_UNESCAPED
-	);
-
-	protected $_localPragmas = array();
-
-	/**
-	 * Mustache class constructor.
+	 *         // A cache directory for compiled templates. Mustache will not cache templates unless this is set
+	 *         'cache' => __DIR__.'/tmp/cache/mustache',
 	 *
-	 * This method accepts a $template string and a $view object. Optionally, pass an associative
-	 * array of partials as well.
+	 *         // A Mustache template loader instance. Uses a StringLoader if not specified
+	 *         'loader' => new \Mustache\Loader\FilesystemLoader(__DIR__.'/views'),
 	 *
-	 * Passing an $options array allows overriding certain Mustache options during instantiation:
+	 *         // A Mustache loader instance for partials.
+	 *         'partials_loader' => new \Mustache\Loader\FilesystemLoader(__DIR__.'/views/partials'),
 	 *
-	 *     $options = array(
-	 *         // `charset` -- must be supported by `htmlspecialentities()`. defaults to 'UTF-8'
-	 *         'charset' => 'ISO-8859-1',
+	 *         // An array of Mustache partials. Useful for quick-and-dirty string template loading, but not as
+	 *         // efficient or lazy as a Filesystem (or database) loader.
+	 *         'partials' => array('foo' => file_get_contents(__DIR__.'/views/partials/foo.mustache')),
 	 *
-	 *         // opening and closing delimiters, as an array or a space-separated string
-	 *         'delimiters' => '<% %>',
-	 *
-	 *         // an array of pragmas to enable/disable
-	 *         'pragmas' => array(
-	 *             Mustache::PRAGMA_UNESCAPED => true
-	 *         ),
-	 *
-	 *         // an array of thrown exceptions to enable/disable
-	 *         'throws_exceptions' => array(
-	 *             MustacheException::UNKNOWN_VARIABLE         => false,
-	 *             MustacheException::UNCLOSED_SECTION         => true,
-	 *             MustacheException::UNEXPECTED_CLOSE_SECTION => true,
-	 *             MustacheException::UNKNOWN_PARTIAL          => false,
-	 *             MustacheException::UNKNOWN_PRAGMA           => true,
-	 *         ),
+	 *         // character set for `htmlspecialchars`. Defaults to 'UTF-8'
+	 *         'charset' => 'ISO-8859-1',
 	 *     );
 	 *
-	 * @access public
-	 * @param string $template (default: null)
-	 * @param mixed $view (default: null)
-	 * @param array $partials (default: null)
 	 * @param array $options (default: array())
-	 * @return void
-	 */
-	public function __construct($template = null, $view = null, $partials = null, array $options = null) {
-		if ($template !== null) $this->_template = $template;
-		if ($partials !== null) $this->_partials = $partials;
-		if ($view !== null)     $this->_context = array($view);
-		if ($options !== null)  $this->_setOptions($options);
-	}
-
-	/**
-	 * Helper function for setting options from constructor args.
-	 *
-	 * @access protected
-	 * @param array $options
-	 * @return void
 	 */
-	protected function _setOptions(array $options) {
-		if (isset($options['charset'])) {
-			$this->_charset = $options['charset'];
+	public function __construct(array $options = array()) {
+		if (isset($options['template_class_prefix'])) {
+			$this->templateClassPrefix = $options['template_class_prefix'];
 		}
 
-		if (isset($options['delimiters'])) {
-			$delims = $options['delimiters'];
-			if (!is_array($delims)) {
-				$delims = array_map('trim', explode(' ', $delims, 2));
-			}
-			$this->_otag = $delims[0];
-			$this->_ctag = $delims[1];
+		if (isset($options['cache'])) {
+			$this->cache = $options['cache'];
 		}
 
-		if (isset($options['pragmas'])) {
-			foreach ($options['pragmas'] as $pragma_name => $pragma_value) {
-				if (!in_array($pragma_name, $this->_pragmasImplemented, true)) {
-					throw new MustacheException('Unknown pragma: ' . $pragma_name, MustacheException::UNKNOWN_PRAGMA);
-				}
-			}
-			$this->_pragmas = $options['pragmas'];
+		if (isset($options['loader'])) {
+			$this->setLoader($options['loader']);
 		}
 
-		if (isset($options['throws_exceptions'])) {
-			foreach ($options['throws_exceptions'] as $exception => $value) {
-				$this->_throwsExceptions[$exception] = $value;
-			}
+		if (isset($options['partials_loader'])) {
+			$this->setPartialsLoader($options['partials_loader']);
 		}
-	}
 
-	/**
-	 * Mustache class clone method.
-	 *
-	 * A cloned Mustache instance should have pragmas, delimeters and root context
-	 * reset to default values.
-	 *
-	 * @access public
-	 * @return void
-	 */
-	public function __clone() {
-		$this->_otag = '{{';
-		$this->_ctag = '}}';
-		$this->_localPragmas = array();
-
-		if ($keys = array_keys($this->_context)) {
-			$last = array_pop($keys);
-			if ($this->_context[$last] instanceof Mustache) {
-				$this->_context[$last] =& $this;
-			}
+		if (isset($options['partials'])) {
+			$this->setPartials($options['partials']);
 		}
-	}
-
-	/**
-	 * Render the given template and view object.
-	 *
-	 * Defaults to the template and view passed to the class constructor unless a new one is provided.
-	 * Optionally, pass an associative array of partials as well.
-	 *
-	 * @access public
-	 * @param string $template (default: null)
-	 * @param mixed $view (default: null)
-	 * @param array $partials (default: null)
-	 * @return string Rendered Mustache template.
-	 */
-	public function render($template = null, $view = null, $partials = null) {
-		if ($template === null) $template = $this->_template;
-		if ($partials !== null) $this->_partials = $partials;
 
-		$otag_orig = $this->_otag;
-		$ctag_orig = $this->_ctag;
-
-		if ($view) {
-			$this->_context = array($view);
-		} else if (empty($this->_context)) {
-			$this->_context = array($this);
+		if (isset($options['charset'])) {
+			$this->charset = $options['charset'];
 		}
-
-		$template = $this->_renderPragmas($template);
-		$template = $this->_renderTemplate($template);
-
-		$this->_otag = $otag_orig;
-		$this->_ctag = $ctag_orig;
-
-		return $template;
 	}
 
 	/**
-	 * Wrap the render() function for string conversion.
+	 * Get the current Mustache character set.
 	 *
-	 * @access public
 	 * @return string
 	 */
-	public function __toString() {
-		// PHP doesn't like exceptions in __toString.
-		// catch any exceptions and convert them to strings.
-		try {
-			$result = $this->render();
-			return $result;
-		} catch (Exception $e) {
-			return "Error rendering mustache: " . $e->getMessage();
-		}
+	public function getCharset() {
+		return $this->charset;
 	}
 
 	/**
-	 * Internal render function, used for recursive calls.
+	 * Set the Mustache template Loader instance.
 	 *
-	 * @access protected
-	 * @param string $template
-	 * @return string Rendered Mustache template.
+	 * @param \Mustache\Loader $loader
 	 */
-	protected function _renderTemplate($template) {
-		if ($section = $this->_findSection($template)) {
-			list($before, $type, $tag_name, $content, $after) = $section;
-
-			$rendered_before = $this->_renderTags($before);
-
-			$rendered_content = '';
-			$val = $this->_getVariable($tag_name);
-			switch($type) {
-				// inverted section
-				case '^':
-					if (empty($val)) {
-						$rendered_content = $this->_renderTemplate($content);
-					}
-					break;
-
-				// regular section
-				case '#':
-					// higher order sections
-					if ($this->_varIsCallable($val)) {
-						$rendered_content = $this->_renderTemplate(call_user_func($val, $content));
-					} else if ($this->_varIsIterable($val)) {
-						foreach ($val as $local_context) {
-							$this->_pushContext($local_context);
-							$rendered_content .= $this->_renderTemplate($content);
-							$this->_popContext();
-						}
-					} else if ($val) {
-						if (is_array($val) || is_object($val)) {
-							$this->_pushContext($val);
-							$rendered_content = $this->_renderTemplate($content);
-							$this->_popContext();
-						} else {
-							$rendered_content = $this->_renderTemplate($content);
-						}
-					}
-					break;
-			}
-
-			return $rendered_before . $rendered_content . $this->_renderTemplate($after);
-		}
-
-		return $this->_renderTags($template);
+	public function setLoader(Loader $loader) {
+		$this->loader = $loader;
 	}
 
 	/**
-	 * Prepare a section RegEx string for the given opening/closing tags.
+	 * Get the current Mustache template Loader instance.
 	 *
-	 * @access protected
-	 * @param string $otag
-	 * @param string $ctag
-	 * @return string
-	 */
-	protected function _prepareSectionRegEx($otag, $ctag) {
-		return sprintf(
-			'/(?:(?<=\\n)[ \\t]*)?%s(?:(?P<type>[%s])(?P<tag_name>.+?)|=(?P<delims>.*?)=)%s\\n?/s',
-			preg_quote($otag, '/'),
-			self::SECTION_TYPES,
-			preg_quote($ctag, '/')
-		);
-	}
-
-	/**
-	 * Extract the first section from $template.
+	 * If no Loader instance has been explicitly specified, this method will instantiate and return
+	 * a StringLoader instance.
 	 *
-	 * @access protected
-	 * @param string $template
-	 * @return array $before, $type, $tag_name, $content and $after
+	 * @return \Mustache\Loader
 	 */
-	protected function _findSection($template) {
-		$regEx = $this->_prepareSectionRegEx($this->_otag, $this->_ctag);
-
-		$section_start = null;
-		$section_type  = null;
-		$content_start = null;
-
-		$search_offset = 0;
-
-		$section_stack = array();
-		$matches = array();
-		while (preg_match($regEx, $template, $matches, PREG_OFFSET_CAPTURE, $search_offset)) {
-			if (isset($matches['delims'][0])) {
-				list($otag, $ctag) = explode(' ', $matches['delims'][0]);
-				$regEx = $this->_prepareSectionRegEx($otag, $ctag);
-				$search_offset = $matches[0][1] + strlen($matches[0][0]);
-				continue;
-			}
-
-			$match    = $matches[0][0];
-			$offset   = $matches[0][1];
-			$type     = $matches['type'][0];
-			$tag_name = trim($matches['tag_name'][0]);
-
-			$search_offset = $offset + strlen($match);
-
-			switch ($type) {
-				case '^':
-				case '#':
-					if (empty($section_stack)) {
-						$section_start = $offset;
-						$section_type  = $type;
-						$content_start = $search_offset;
-					}
-					array_push($section_stack, $tag_name);
-					break;
-				case '/':
-					if (empty($section_stack) || ($tag_name !== array_pop($section_stack))) {
-						if ($this->_throwsException(MustacheException::UNEXPECTED_CLOSE_SECTION)) {
-							throw new MustacheException('Unexpected close section: ' . $tag_name, MustacheException::UNEXPECTED_CLOSE_SECTION);
-						}
-					}
-
-					if (empty($section_stack)) {
-						// $before, $type, $tag_name, $content, $after
-						return array(
-							substr($template, 0, $section_start),
-							$section_type,
-							$tag_name,
-							substr($template, $content_start, $offset - $content_start),
-							substr($template, $search_offset),
-						);
-					}
-					break;
-			}
+	public function getLoader() {
+		if (!isset($this->loader)) {
+			$this->loader = new StringLoader;
 		}
 
-		if (!empty($section_stack)) {
-			if ($this->_throwsException(MustacheException::UNCLOSED_SECTION)) {
-				throw new MustacheException('Unclosed section: ' . $section_stack[0], MustacheException::UNCLOSED_SECTION);
-			}
-		}
+		return $this->loader;
 	}
 
 	/**
-	 * Prepare a pragma RegEx for the given opening/closing tags.
+	 * Set the Mustache partials Loader instance.
 	 *
-	 * @access protected
-	 * @param string $otag
-	 * @param string $ctag
-	 * @return string
+	 * @param \Mustache\Loader $partialsLoader
 	 */
-	protected function _preparePragmaRegEx($otag, $ctag) {
-		return sprintf(
-			'/%s%%\\s*(?P<pragma_name>[\\w_-]+)(?P<options_string>(?: [\\w]+=[\\w]+)*)\\s*%s\\n?/s',
-			preg_quote($otag, '/'),
-			preg_quote($ctag, '/')
-		);
+	public function setPartialsLoader(Loader $partialsLoader) {
+		$this->partialsLoader = $partialsLoader;
 	}
 
 	/**
-	 * Initialize pragmas and remove all pragma tags.
+	 * Get the current Mustache partials Loader instance.
 	 *
-	 * @access protected
-	 * @param string $template
-	 * @return string
+	 * If no Loader instance has been explicitly specified, this method will instantiate and return
+	 * an ArrayLoader instance.
+	 *
+	 * @return \Mustache\Loader
 	 */
-	protected function _renderPragmas($template) {
-		$this->_localPragmas = $this->_pragmas;
-
-		// no pragmas
-		if (strpos($template, $this->_otag . '%') === false) {
-			return $template;
+	public function getPartialsLoader() {
+		if (!isset($this->partialsLoader)) {
+			$this->partialsLoader = new ArrayLoader;
 		}
 
-		$regEx = $this->_preparePragmaRegEx($this->_otag, $this->_ctag);
-		return preg_replace_callback($regEx, array($this, '_renderPragma'), $template);
+		return $this->partialsLoader;
 	}
 
 	/**
-	 * A preg_replace helper to remove {{%PRAGMA}} tags and enable requested pragma.
+	 * Set partials for the current partials Loader instance.
+	 *
+	 * @throws \RuntimeException If the current Loader instance is immutable
 	 *
-	 * @access protected
-	 * @param mixed $matches
-	 * @return void
-	 * @throws MustacheException unknown pragma
+	 * @param array $partials (default: array())
 	 */
-	protected function _renderPragma($matches) {
-		$pragma         = $matches[0];
-		$pragma_name    = $matches['pragma_name'];
-		$options_string = $matches['options_string'];
-
-		if (!in_array($pragma_name, $this->_pragmasImplemented)) {
-			if ($this->_throwsException(MustacheException::UNKNOWN_PRAGMA)) {
-				throw new MustacheException('Unknown pragma: ' . $pragma_name, MustacheException::UNKNOWN_PRAGMA);
-			} else {
-				return '';
-			}
-		}
-
-		$options = array();
-		foreach (explode(' ', trim($options_string)) as $o) {
-			if ($p = trim($o)) {
-				$p = explode('=', $p);
-				$options[$p[0]] = $p[1];
-			}
-		}
-
-		if (empty($options)) {
-			$this->_localPragmas[$pragma_name] = true;
-		} else {
-			$this->_localPragmas[$pragma_name] = $options;
+	public function setPartials(array $partials = array()) {
+		$loader = $this->getPartialsLoader();
+		if (!$loader instanceof MutableLoader) {
+			throw new \RuntimeException('Unable to set partials on an immutable Mustache Loader instance');
 		}
 
-		return '';
+		$loader->setTemplates($partials);
 	}
 
 	/**
-	 * Check whether this Mustache has a specific pragma.
+	 * Set the Mustache Tokenizer instance.
 	 *
-	 * @access protected
-	 * @param string $pragma_name
-	 * @return bool
+	 * @param \Mustache\Tokenizer $tokenizer
 	 */
-	protected function _hasPragma($pragma_name) {
-		if (array_key_exists($pragma_name, $this->_localPragmas) && $this->_localPragmas[$pragma_name]) {
-			return true;
-		} else {
-			return false;
-		}
+	public function setTokenizer(Tokenizer $tokenizer) {
+		$this->tokenizer = $tokenizer;
 	}
 
 	/**
-	 * Return pragma options, if any.
+	 * Get the current Mustache Tokenizer instance.
+	 *
+	 * If no Tokenizer instance has been explicitly specified, this method will instantiate and return a new one.
 	 *
-	 * @access protected
-	 * @param string $pragma_name
-	 * @return mixed
-	 * @throws MustacheException Unknown pragma
+	 * @return \Mustache\Tokenizer
 	 */
-	protected function _getPragmaOptions($pragma_name) {
-		if (!$this->_hasPragma($pragma_name)) {
-			if ($this->_throwsException(MustacheException::UNKNOWN_PRAGMA)) {
-				throw new MustacheException('Unknown pragma: ' . $pragma_name, MustacheException::UNKNOWN_PRAGMA);
-			}
+	public function getTokenizer() {
+		if (!isset($this->tokenizer)) {
+			$this->tokenizer = new Tokenizer;
 		}
 
-		return (is_array($this->_localPragmas[$pragma_name])) ? $this->_localPragmas[$pragma_name] : array();
+		return $this->tokenizer;
 	}
 
 	/**
-	 * Check whether this Mustache instance throws a given exception.
+	 * Set the Mustache Parser instance.
 	 *
-	 * Expects exceptions to be MustacheException error codes (i.e. class constants).
-	 *
-	 * @access protected
-	 * @param mixed $exception
-	 * @return void
+	 * @param \Mustache\Parser $tokenizer
 	 */
-	protected function _throwsException($exception) {
-		return (isset($this->_throwsExceptions[$exception]) && $this->_throwsExceptions[$exception]);
+	public function setParser(Parser $parser) {
+		$this->parser = $parser;
 	}
 
 	/**
-	 * Prepare a tag RegEx for the given opening/closing tags.
+	 * Get the current Mustache Parser instance.
 	 *
-	 * @access protected
-	 * @param string $otag
-	 * @param string $ctag
-	 * @return string
-	 */
-	protected function _prepareTagRegEx($otag, $ctag, $first = false) {
-		return sprintf(
-			'/(?P<leading>(?:%s\\r?\\n)[ \\t]*)?%s(?P<type>[%s]?)(?P<tag_name>.+?)(?:\\2|})?%s(?P<trailing>\\s*(?:\\r?\\n|\\Z))?/s',
-			($first ? '\\A|' : ''),
-			preg_quote($otag, '/'),
-			self::TAG_TYPES,
-			preg_quote($ctag, '/')
-		);
-	}
-
-	/**
-	 * Loop through and render individual Mustache tags.
+	 * If no Parser instance has been explicitly specified, this method will instantiate and return a new one.
 	 *
-	 * @access protected
-	 * @param string $template
-	 * @return void
+	 * @return \Mustache\Parser
 	 */
-	protected function _renderTags($template) {
-		if (strpos($template, $this->_otag) === false) {
-			return $template;
-		}
-
-		$first = true;
-		$this->_tagRegEx = $this->_prepareTagRegEx($this->_otag, $this->_ctag, true);
-
-		$html = '';
-		$matches = array();
-		while (preg_match($this->_tagRegEx, $template, $matches, PREG_OFFSET_CAPTURE)) {
-			$tag      = $matches[0][0];
-			$offset   = $matches[0][1];
-			$modifier = $matches['type'][0];
-			$tag_name = trim($matches['tag_name'][0]);
-
-			if (isset($matches['leading']) && $matches['leading'][1] > -1) {
-				$leading = $matches['leading'][0];
-			} else {
-				$leading = null;
-			}
-
-			if (isset($matches['trailing']) && $matches['trailing'][1] > -1) {
-				$trailing = $matches['trailing'][0];
-			} else {
-				$trailing = null;
-			}
-
-			$html .= substr($template, 0, $offset);
-
-			$next_offset = $offset + strlen($tag);
-			if ((substr($html, -1) == "\n") && (substr($template, $next_offset, 1) == "\n")) {
-				$next_offset++;
-			}
-			$template = substr($template, $next_offset);
-
-			$html .= $this->_renderTag($modifier, $tag_name, $leading, $trailing);
-
-			if ($first == true) {
-				$first = false;
-				$this->_tagRegEx = $this->_prepareTagRegEx($this->_otag, $this->_ctag);
-			}
+	public function getParser() {
+		if (!isset($this->parser)) {
+			$this->parser = new Parser;
 		}
 
-		return $html . $template;
+		return $this->parser;
 	}
 
 	/**
-	 * Render the named tag, given the specified modifier.
+	 * Set the Mustache Compiler instance.
 	 *
-	 * Accepted modifiers are `=` (change delimiter), `!` (comment), `>` (partial)
-	 * `{` or `&` (don't escape output), or none (render escaped output).
-	 *
-	 * @access protected
-	 * @param string $modifier
-	 * @param string $tag_name
-	 * @param string $leading Whitespace
-	 * @param string $trailing Whitespace
-	 * @throws MustacheException Unmatched section tag encountered.
-	 * @return string
+	 * @param \Mustache\Compiler $tokenizer
 	 */
-	protected function _renderTag($modifier, $tag_name, $leading, $trailing) {
-		switch ($modifier) {
-			case '=':
-				return $this->_changeDelimiter($tag_name, $leading, $trailing);
-				break;
-			case '!':
-				return $this->_renderComment($tag_name, $leading, $trailing);
-				break;
-			case '>':
-			case '<':
-				return $this->_renderPartial($tag_name, $leading, $trailing);
-				break;
-			case '{':
-				// strip the trailing } ...
-				if ($tag_name[(strlen($tag_name) - 1)] == '}') {
-					$tag_name = substr($tag_name, 0, -1);
-				}
-			case '&':
-				if ($this->_hasPragma(self::PRAGMA_UNESCAPED)) {
-					return $this->_renderEscaped($tag_name, $leading, $trailing);
-				} else {
-					return $this->_renderUnescaped($tag_name, $leading, $trailing);
-				}
-				break;
-			case '#':
-			case '^':
-			case '/':
-				// remove any leftover section tags
-				return $leading . $trailing;
-				break;
-			default:
-				if ($this->_hasPragma(self::PRAGMA_UNESCAPED)) {
-					return $this->_renderUnescaped($modifier . $tag_name, $leading, $trailing);
-				} else {
-					return $this->_renderEscaped($modifier . $tag_name, $leading, $trailing);
-				}
-				break;
-		}
+	public function setCompiler(Compiler $compiler) {
+		$this->compiler = $compiler;
 	}
 
 	/**
-	 * Returns true if any of its args contains the "\r" character.
+	 * Get the current Mustache Compiler instance.
+	 *
+	 * If no Compiler instance has been explicitly specified, this method will instantiate and return a new one.
 	 *
-	 * @access protected
-	 * @param string $str
-	 * @return boolean
+	 * @return \Mustache\Compiler
 	 */
-	protected function _stringHasR($str) {
-		foreach (func_get_args() as $arg) {
-			if (strpos($arg, "\r") !== false) {
-				return true;
-			}
+	public function getCompiler() {
+		if (!isset($this->compiler)) {
+			$this->compiler = new Compiler;
 		}
-		return false;
+
+		return $this->compiler;
 	}
 
 	/**
-	 * Escape and return the requested tag.
+	 * Helper method to generate a Mustache template class.
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @param string $leading Whitespace
-	 * @param string $trailing Whitespace
-	 * @return string
+	 * @param string $source
+	 *
+	 * @return string Mustache Template class name
 	 */
-	protected function _renderEscaped($tag_name, $leading, $trailing) {
-		$rendered = htmlentities($this->_renderUnescaped($tag_name, '', ''), ENT_COMPAT, $this->_charset);
-		return $leading . $rendered . $trailing;
+	public function getTemplateClassName($source) {
+		return $this->templateClassPrefix . md5(self::VERSION . ':' . $source);
 	}
 
 	/**
-	 * Render a comment (i.e. return an empty string).
+	 * Load a Mustache Template by name.
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @param string $leading Whitespace
-	 * @param string $trailing Whitespace
-	 * @return string
+	 * @param string $name
+	 *
+	 * @return \Mustache\Template
 	 */
-	protected function _renderComment($tag_name, $leading, $trailing) {
-		if ($leading !== null && $trailing !== null) {
-			if (strpos($leading, "\n") === false) {
-				return '';
-			}
-			return $this->_stringHasR($leading, $trailing) ? "\r\n" : "\n";
-		}
-		return $leading . $trailing;
+	public function loadTemplate($name) {
+		return $this->loadSource($this->getLoader()->load($name));
 	}
 
 	/**
-	 * Return the requested tag unescaped.
+	 * Load a Mustache partial Template by name.
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @param string $leading Whitespace
-	 * @param string $trailing Whitespace
-	 * @return string
+	 * This is a helper method used internally by Template instances for loading partial templates. You can most likely
+	 * ignore it completely.
+	 *
+	 * @param string $name
+	 *
+	 * @return \Mustache\Template
 	 */
-	protected function _renderUnescaped($tag_name, $leading, $trailing) {
-		$val = $this->_getVariable($tag_name);
-
-		if ($this->_varIsCallable($val)) {
-			$val = $this->_renderTemplate(call_user_func($val));
-		}
-
-		return $leading . $val . $trailing;
+	public function loadPartial($name) {
+		return $this->loadSource($this->getPartialsLoader()->load($name));
 	}
 
 	/**
-	 * Render the requested partial.
+	 * Load a Mustache lambda Template by source.
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @param string $leading Whitespace
-	 * @param string $trailing Whitespace
-	 * @return string
+	 * This is a helper method used by Template instances to generate subtemplates for Lambda sections. You can most
+	 * likely ignore it completely.
+	 *
+	 * @param string $source
+	 * @param string $delims (default: null)
+	 *
+	 * @return \Mustache\Template
 	 */
-	protected function _renderPartial($tag_name, $leading, $trailing) {
-		$partial = $this->_getPartial($tag_name);
-		if ($leading !== null && $trailing !== null) {
-			$whitespace = trim($leading, "\r\n");
-			$partial = preg_replace('/(\\r?\\n)(?!$)/s', "\\1" . $whitespace, $partial);
+	public function loadLambda($source, $delims = null) {
+		if ($delims !== null) {
+			$source = $delims . "\n" . $source;
 		}
 
-		$view = clone($this);
-
-		if ($leading !== null && $trailing !== null) {
-			return $leading . $view->render($partial);
-		} else {
-			return $leading . $view->render($partial) . $trailing;
-		}
+		return $this->loadSource($source);
 	}
 
 	/**
-	 * Change the Mustache tag delimiter. This method also replaces this object's current
-	 * tag RegEx with one using the new delimiters.
+	 * Instantiate and return a Mustache Template instance by source.
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @param string $leading Whitespace
-	 * @param string $trailing Whitespace
-	 * @return string
+	 * @see \Mustache\Mustache::loadTemplate
+	 * @see \Mustache\Mustache::loadPartial
+	 * @see \Mustache\Mustache::loadLambda
+	 *
+	 * @param string $source
+	 *
+	 * @return \Mustache\Template
 	 */
-	protected function _changeDelimiter($tag_name, $leading, $trailing) {
-		list($otag, $ctag) = explode(' ', $tag_name);
-		$this->_otag = $otag;
-		$this->_ctag = $ctag;
+	private function loadSource($source) {
+		$className = $this->getTemplateClassName($source);
 
-		$this->_tagRegEx = $this->_prepareTagRegEx($this->_otag, $this->_ctag);
+		if (!isset($this->templates[$className])) {
+			if (!class_exists($className, false)) {
+				if ($fileName = $this->getCacheFilename($source)) {
+					if (!is_file($fileName)) {
+						$this->writeCacheFile($fileName, $this->compile($source));
+					}
 
-		if ($leading !== null && $trailing !== null) {
-			if (strpos($leading, "\n") === false) {
-				return '';
+					require_once $fileName;
+				} else {
+					eval('?>'.$this->compile($source));
+				}
 			}
-			return $this->_stringHasR($leading, $trailing) ? "\r\n" : "\n";
-		}
-		return $leading . $trailing;
-	}
 
-	/**
-	 * Push a local context onto the stack.
-	 *
-	 * @access protected
-	 * @param array &$local_context
-	 * @return void
-	 */
-	protected function _pushContext(&$local_context) {
-		$new = array();
-		$new[] =& $local_context;
-		foreach (array_keys($this->_context) as $key) {
-			$new[] =& $this->_context[$key];
+			$this->templates[$className] = new $className($this);
 		}
-		$this->_context = $new;
-	}
-
-	/**
-	 * Remove the latest context from the stack.
-	 *
-	 * @access protected
-	 * @return void
-	 */
-	protected function _popContext() {
-		$new = array();
 
-		$keys = array_keys($this->_context);
-		array_shift($keys);
-		foreach ($keys as $key) {
-			$new[] =& $this->_context[$key];
-		}
-		$this->_context = $new;
+		return $this->templates[$className];
 	}
 
 	/**
-	 * Get a variable from the context array.
+	 * Helper method to tokenize a Mustache template.
 	 *
-	 * If the view is an array, returns the value with array key $tag_name.
-	 * If the view is an object, this will check for a public member variable
-	 * named $tag_name. If none is available, this method will execute and return
-	 * any class method named $tag_name. Failing all of the above, this method will
-	 * return an empty string.
+	 * @see \Mustache\Tokenizer::scan
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @throws MustacheException Unknown variable name.
-	 * @return string
+	 * @param string $source
+	 *
+	 * @return array Tokens
 	 */
-	protected function _getVariable($tag_name) {
-		if ($tag_name === '.') {
-			return $this->_context[0];
-		} else if (strpos($tag_name, '.') !== false) {
-			$chunks = explode('.', $tag_name);
-			$first = array_shift($chunks);
-
-			$ret = $this->_findVariableInContext($first, $this->_context);
-			foreach ($chunks as $next) {
-				// Slice off a chunk of context for dot notation traversal.
-				$c = array($ret);
-				$ret = $this->_findVariableInContext($next, $c);
-			}
-			return $ret;
-		} else {
-			return $this->_findVariableInContext($tag_name, $this->_context);
-		}
+	private function tokenize($source) {
+		return $this->getTokenizer()->scan($source);
 	}
 
 	/**
-	 * Get a variable from the context array. Internal helper used by getVariable() to abstract
-	 * variable traversal for dot notation.
+	 * Helper method to parse a Mustache template.
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @param array $context
-	 * @throws MustacheException Unknown variable name.
-	 * @return string
+	 * @see \Mustache\Parser::parse
+	 *
+	 * @param string $source
+	 *
+	 * @return array Token tree
 	 */
-	protected function _findVariableInContext($tag_name, $context) {
-		foreach ($context as $view) {
-			if (is_object($view)) {
-				if (method_exists($view, $tag_name)) {
-					return $view->$tag_name();
-				} else if (isset($view->$tag_name)) {
-					return $view->$tag_name;
-				}
-			} else if (is_array($view) && array_key_exists($tag_name, $view)) {
-				return $view[$tag_name];
-			}
-		}
-
-		if ($this->_throwsException(MustacheException::UNKNOWN_VARIABLE)) {
-			throw new MustacheException("Unknown variable: " . $tag_name, MustacheException::UNKNOWN_VARIABLE);
-		} else {
-			return '';
-		}
+	private function parse($source) {
+		return $this->getParser()->parse($this->tokenize($source));
 	}
 
 	/**
-	 * Retrieve the partial corresponding to the requested tag name.
+	 * Helper method to compile a Mustache template.
 	 *
-	 * Silently fails (i.e. returns '') when the requested partial is not found.
+	 * @see \Mustache\Compiler::compile
 	 *
-	 * @access protected
-	 * @param string $tag_name
-	 * @throws MustacheException Unknown partial name.
-	 * @return string
+	 * @param string $source
+	 *
+	 * @return string generated Mustache template class code
 	 */
-	protected function _getPartial($tag_name) {
-		if ((is_array($this->_partials) || $this->_partials instanceof ArrayAccess) && isset($this->_partials[$tag_name])) {
-			return $this->_partials[$tag_name];
-		}
-
-		if ($this->_throwsException(MustacheException::UNKNOWN_PARTIAL)) {
-			throw new MustacheException('Unknown partial: ' . $tag_name, MustacheException::UNKNOWN_PARTIAL);
-		} else {
-			return '';
-		}
+	private function compile($source) {
+		return $this->getCompiler()->compile($source, $this->parse($source), $this->getTemplateClassName($source));
 	}
 
 	/**
-	 * Check whether the given $var should be iterated (i.e. in a section context).
+	 * Helper method to generate a Mustache Template class cache filename.
 	 *
-	 * @access protected
-	 * @param mixed $var
-	 * @return bool
+	 * @param string $source
+	 *
+	 * @return string Mustache Template class cache filename
 	 */
-	protected function _varIsIterable($var) {
-		return $var instanceof Traversable || (is_array($var) && !array_diff_key($var, array_keys(array_keys($var))));
+	private function getCacheFilename($source) {
+		if ($this->cache) {
+			return sprintf('%s/%s.php', $this->cache, $this->getTemplateClassName($source));
+		}
 	}
 
 	/**
-	 * Higher order sections helper: tests whether the section $var is a valid callback.
-	 *
-	 * In Mustache.php, a variable is considered 'callable' if the variable is:
+	 * Helper method to dump a generated Mustache Template subclass to the file cache.
 	 *
-	 *  1. an anonymous function.
-	 *  2. an object and the name of a public function, i.e. `array($SomeObject, 'methodName')`
-	 *  3. a class name and the name of a public static function, i.e. `array('SomeClass', 'methodName')`
+	 * @throws \RuntimeException if unable to write to $fileName.
 	 *
-	 * @access protected
-	 * @param mixed $var
-	 * @return bool
+	 * @param string $fileName
+	 * @param string $source
 	 */
-	protected function _varIsCallable($var) {
-	  return !is_string($var) && is_callable($var);
-	}
-}
-
-
-/**
- * MustacheException class.
- *
- * @extends Exception
- */
-class MustacheException extends Exception {
-
-	// An UNKNOWN_VARIABLE exception is thrown when a {{variable}} is not found
-	// in the current context.
-	const UNKNOWN_VARIABLE         = 0;
-
-	// An UNCLOSED_SECTION exception is thrown when a {{#section}} is not closed.
-	const UNCLOSED_SECTION         = 1;
-
-	// An UNEXPECTED_CLOSE_SECTION exception is thrown when {{/section}} appears
-	// without a corresponding {{#section}} or {{^section}}.
-	const UNEXPECTED_CLOSE_SECTION = 2;
+	private function writeCacheFile($fileName, $source) {
+		if (!is_dir(dirname($fileName))) {
+			mkdir(dirname($fileName), 0777, true);
+		}
 
-	// An UNKNOWN_PARTIAL exception is thrown whenever a {{>partial}} tag appears
-	// with no associated partial.
-	const UNKNOWN_PARTIAL          = 3;
+		$tempFile = tempnam(dirname($fileName), basename($fileName));
+		if (false !== @file_put_contents($tempFile, $source)) {
+			if (@rename($tempFile, $fileName)) {
+				chmod($fileName, 0644);
 
-	// An UNKNOWN_PRAGMA exception is thrown whenever a {{%PRAGMA}} tag appears
-	// which can't be handled by this Mustache instance.
-	const UNKNOWN_PRAGMA           = 4;
+				return;
+			}
+		}
 
+		throw new \RuntimeException(sprintf('Failed to write cache file "%s".', $fileName));
+	}
 }

+ 0 - 85
src/Mustache/MustacheLoader.php

@@ -1,85 +0,0 @@
-<?php
-
-/**
- * A Mustache Partial filesystem loader.
- *
- * @author Justin Hileman {@link http://justinhileman.com}
- */
-class MustacheLoader implements ArrayAccess {
-
-	protected $baseDir;
-	protected $partialsCache = array();
-	protected $extension;
-
-	/**
-	 * MustacheLoader constructor.
-	 *
-	 * @access public
-	 * @param  string $baseDir   Base template directory.
-	 * @param  string $extension File extension for Mustache files (default: 'mustache')
-	 * @return void
-	 */
-	public function __construct($baseDir, $extension = 'mustache') {
-		if (!is_dir($baseDir)) {
-			throw new InvalidArgumentException('$baseDir must be a valid directory, ' . $baseDir . ' given.');
-		}
-
-		$this->baseDir   = $baseDir;
-		$this->extension = $extension;
-	}
-
-	/**
-	 * @param  string $offset Name of partial
-	 * @return boolean
-	 */
-	public function offsetExists($offset) {
-		return (isset($this->partialsCache[$offset]) || file_exists($this->pathName($offset)));
-	}
-	
-	/**
-	 * @throws InvalidArgumentException if the given partial doesn't exist
-	 * @param  string $offset Name of partial
-	 * @return string Partial template contents
-	 */
-	public function offsetGet($offset) {
-		if (!$this->offsetExists($offset)) {
-			throw new InvalidArgumentException('Partial does not exist: ' . $offset);
-		}
-
-		if (!isset($this->partialsCache[$offset])) {
-			$this->partialsCache[$offset] = file_get_contents($this->pathName($offset));
-		}
-
-		return $this->partialsCache[$offset];
-	}
-	
-	/**
-	 * MustacheLoader is an immutable filesystem loader. offsetSet throws a LogicException if called.
-	 *
-	 * @throws LogicException
-	 * @return void
-	 */
-	public function offsetSet($offset, $value) {
-		throw new LogicException('Unable to set offset: MustacheLoader is an immutable ArrayAccess object.');
-	}
-	
-	/**
-	 * MustacheLoader is an immutable filesystem loader. offsetUnset throws a LogicException if called.
-	 *
-	 * @throws LogicException
-	 * @return void
-	 */
-	public function offsetUnset($offset) {
-		throw new LogicException('Unable to unset offset: MustacheLoader is an immutable ArrayAccess object.');
-	}
-
-	/**
-	 * An internal helper for generating path names.
-	 * 
-	 * @param  string $file Partial name
-	 * @return string File path
-	 */
-	protected function pathName($file) {
-		return $this->baseDir . '/' . $file . '.' . $this->extension;
-	}
-}

+ 80 - 0
src/Mustache/Parser.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Mustache Parser class.
+ *
+ * This class is responsible for turning a set of Mustache tokens into a parse tree.
+ */
+class Parser {
+
+	/**
+	 * Process an array of Mustache tokens and convert them into a parse tree.
+	 *
+	 * @param array $tree Set of Mustache tokens
+	 *
+	 * @return array Mustache token parse tree
+	 */
+	public function parse(array $tokens = array()) {
+		return $this->buildTree(new \ArrayIterator($tokens));
+	}
+
+	/**
+	 * Helper method for recursively building a parse tree.
+	 *
+	 * @throws \LogicException when nesting errors or mismatched section tags are encountered.
+	 *
+	 * @param \ArrayIterator $tokens Stream of Mustache tokens
+	 * @param array          $parent Parent token (default: null)
+	 *
+	 * @return array Mustache Token parse tree
+	 */
+	private function buildTree(\ArrayIterator $tokens, array $parent = null) {
+		$nodes = array();
+
+		do {
+			$token = $tokens->current();
+			$tokens->next();
+
+			if ($token === null) {
+				continue;
+			} elseif (is_array($token)) {
+				switch ($token[Tokenizer::TAG]) {
+					case '#':
+					case '^':
+						$nodes[] = $this->buildTree($tokens, $token);
+						break;
+
+					case '/':
+						if (!isset($parent)) {
+							throw new \LogicException('Unexpected closing tag: /'. $token[Tokenizer::NAME]);
+						}
+
+						if ($token[Tokenizer::NAME] !== $parent[Tokenizer::NAME]) {
+							throw new \LogicException('Nesting error: ' . $parent[Tokenizer::NAME] . ' vs. ' . $token[Tokenizer::NAME]);
+						}
+
+						$parent[Tokenizer::END]   = $token[Tokenizer::INDEX];
+						$parent[Tokenizer::NODES] = $nodes;
+
+						return $parent;
+						break;
+
+					default:
+						$nodes[] = $token;
+						break;
+				}
+			} else {
+				$nodes[] = $token;
+			}
+
+		} while($tokens->valid());
+
+		if (isset($parent)) {
+			throw new \LogicException('Missing closing tag: ' . $parent[Tokenizer::NAME]);
+		}
+
+		return $nodes;
+	}
+}

+ 66 - 0
src/Mustache/Template.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Abstract Mustache Template class.
+ *
+ * @abstract
+ */
+abstract class Template {
+
+	/**
+	 * @var \Mustache\Mustache
+	 */
+	protected $mustache;
+
+	/**
+	 * Mustache Template constructor.
+	 *
+	 * @param \Mustache\Mustache $mustache
+	 */
+	public function __construct(Mustache $mustache) {
+		$this->mustache = $mustache;
+	}
+
+	/**
+	 * Mustache Template instances can be treated as a function and rendered by simply calling them:
+	 *
+	 *     $m = new Mustache;
+	 *     $tpl = $m->loadTemplate('Hello, {{ name }}!');
+	 *     echo $tpl(array('name' => 'World')); // "Hello, World!"
+	 *
+	 * @see \Mustache\Template::render
+	 *
+	 * @param mixed $context Array or object rendering context (default: array())
+	 *
+	 * @return string Rendered template
+	 */
+	public function __invoke($context = array()) {
+		return $this->render($context);
+	}
+
+	/**
+	 * Render this template given the rendering context.
+	 *
+	 * @param mixed $context Array or object rendering context (default: array())
+	 *
+	 * @return string Rendered template
+	 */
+	public function render($context = array()) {
+		return $this->renderInternal(new Context($context));
+	}
+
+	/**
+	 * Internal rendering method implemented by Mustache Template concrete subclasses.
+	 *
+	 * This is where the magic happens :)
+	 *
+	 * @abstract
+	 *
+	 * @param \Mustache\Context $context
+	 *
+	 * @return string Rendered template
+	 */
+	abstract public function renderInternal(Context $context);
+}

+ 262 - 0
src/Mustache/Tokenizer.php

@@ -0,0 +1,262 @@
+<?php
+
+namespace Mustache;
+
+/**
+ * Mustache Tokenizer class.
+ *
+ * This class is responsible for turning raw template source into a set of Mustache tokens.
+ */
+class Tokenizer {
+
+	// Finite state machine states
+	const IN_TEXT     = 0;
+	const IN_TAG_TYPE = 1;
+	const IN_TAG      = 2;
+
+	// Token types
+	const T_SECTION      = 1;
+	const T_INVERTED     = 2;
+	const T_END_SECTION  = 3;
+	const T_COMMENT      = 4;
+	const T_PARTIAL      = 5;
+	const T_PARTIAL_2    = 6;
+	const T_DELIM_CHANGE = 7;
+	const T_ESCAPED      = 8;
+	const T_UNESCAPED    = 9;
+	const T_UNESCAPED_2  = 10;
+
+	// Token types map
+	private static $tagTypes = array(
+		'#'  => self::T_SECTION,
+		'^'  => self::T_INVERTED,
+		'/'  => self::T_END_SECTION,
+		'!'  => self::T_COMMENT,
+		'>'  => self::T_PARTIAL,
+		'<'  => self::T_PARTIAL_2,
+		'='  => self::T_DELIM_CHANGE,
+		'_v' => self::T_ESCAPED,
+		'{'  => self::T_UNESCAPED,
+		'&'  => self::T_UNESCAPED_2,
+	);
+
+	// Token properties
+	const NODES  = 'nodes';
+	const TAG    = 'tag';
+	const NAME   = 'name';
+	const OTAG   = 'otag';
+	const CTAG   = 'ctag';
+	const INDEX  = 'index';
+	const END    = 'end';
+	const INDENT = 'indent';
+
+	private $state;
+	private $tagType;
+	private $tag;
+	private $buf;
+	private $tokens;
+	private $seenTag;
+	private $lineStart;
+	private $otag;
+	private $ctag;
+
+	/**
+	 * Scan and tokenize template source.
+	 *
+	 * @param string $text       Mustache template source to tokenize
+	 * @param string $delimiters Optionally, pass initial opening and closing delimiters (default: null)
+	 *
+	 * @return array Set of Mustache tokens
+	 */
+	public function scan($text, $delimiters = null) {
+		$this->reset();
+
+		if ($delimiters = trim($delimiters)) {
+			list($otag, $ctag) = explode(' ', $delimiters);
+			$this->otag = $otag;
+			$this->ctag = $ctag;
+		}
+
+		$len = strlen($text);
+		for ($i = 0; $i < $len; $i++) {
+			switch ($this->state) {
+				case self::IN_TEXT:
+					if ($this->tagChange($this->otag, $text, $i)) {
+						$i--;
+						$this->flushBuffer();
+						$this->state = self::IN_TAG_TYPE;
+					} else {
+						if ($text[$i] == "\n") {
+							$this->filterLine();
+						} else {
+							$this->buffer .= $text[$i];
+						}
+					}
+					break;
+
+				case self::IN_TAG_TYPE:
+					$i += strlen($this->otag) - 1;
+					$tag = isset(self::$tagTypes[$text[$i + 1]]) ? self::$tagTypes[$text[$i + 1]] : null;
+					$this->tagType = $tag ? $text[$i + 1] : '_v';
+					if ($this->tagType === '=') {
+						$i = $this->changeDelimiters($text, $i);
+						$this->state = self::IN_TEXT;
+					} else {
+						if ($tag) {
+							$i++;
+						}
+						$this->state = self::IN_TAG;
+					}
+					$this->seenTag = $i;
+					break;
+
+				default:
+					if ($this->tagChange($this->ctag, $text, $i)) {
+						$this->tokens[] = array(
+							self::TAG   => $this->tagType,
+							self::NAME  => trim($this->buffer),
+							self::OTAG  => $this->otag,
+							self::CTAG  => $this->ctag,
+							self::INDEX => ($this->tagType == '/') ? $this->seenTag - strlen($this->otag) : $i + strlen($this->ctag)
+						);
+
+						$this->buffer = '';
+						$i += strlen($this->ctag) - 1;
+						$this->state = self::IN_TEXT;
+						if ($this->tagType == '{') {
+							if ($this->ctag == '}}') {
+								$i++;
+							} else {
+								$this->cleanTripleStache($this->tokens[count($this->tokens) - 1]);
+							}
+						}
+					} else {
+						$this->buffer .= $text[$i];
+					}
+					break;
+			}
+		}
+
+		$this->filterLine(true);
+
+		return $this->tokens;
+	}
+
+	/**
+	 * Helper function to reset tokenizer internal state.
+	 */
+	private function reset() {
+		$this->state     = self::IN_TEXT;
+		$this->tagType   = null;
+		$this->tag       = null;
+		$this->buffer    = '';
+		$this->tokens    = array();
+		$this->seenTag   = false;
+		$this->lineStart = 0;
+		$this->otag      = '{{';
+		$this->ctag      = '}}';
+	}
+
+	/**
+	 * Flush the current buffer to a token.
+	 */
+	private function flushBuffer() {
+		if (!empty($this->buffer)) {
+			$this->tokens[] = $this->buffer;
+			$this->buffer   = '';
+		}
+	}
+
+	/**
+	 * Test whether the current line is entirely made up of whitespace.
+	 *
+	 * @return boolean True if the current line is all whitespace
+	 */
+	private function lineIsWhitespace() {
+		$tokensCount = count($this->tokens);
+		for ($j = $this->lineStart; $j < $tokensCount; $j++) {
+			$token = $this->tokens[$j];
+			if (is_array($token) && isset(self::$tagTypes[$token[self::TAG]])) {
+				if (self::$tagTypes[$token[self::TAG]] >= self::T_ESCAPED) {
+					return false;
+				}
+			} elseif (is_string($token)) {
+				if (preg_match('/\S/', $token)) {
+					return false;
+				}
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Filter out whitespace-only lines and store indent levels for partials.
+	 *
+	 * @param bool $noNewLine Suppress the newline? (default: false)
+	 */
+	private function filterLine($noNewLine = false) {
+		$this->flushBuffer();
+		if ($this->seenTag && $this->lineIsWhitespace()) {
+			$tokensCount = count($this->tokens);
+			for ($j = $this->lineStart; $j < $tokensCount; $j++) {
+				if (!is_array($this->tokens[$j])) {
+					if (isset($this->tokens[$j+1]) && is_array($this->tokens[$j+1]) && $this->tokens[$j+1][self::TAG] == '>') {
+						$this->tokens[$j+1][self::INDENT] = (string) $this->tokens[$j];
+					}
+
+					$this->tokens[$j] = null;
+				}
+			}
+		} elseif (!$noNewLine) {
+			$this->tokens[] = "\n";
+		}
+
+		$this->seenTag   = false;
+		$this->lineStart = count($this->tokens);
+	}
+
+	/**
+	 * Change the current Mustache delimiters. Set new `otag` and `ctag` values.
+	 *
+	 * @param string $text  Mustache template source
+	 * @param int    $index Current tokenizer index
+	 *
+	 * @return int New index value
+	 */
+	private function changeDelimiters($text, $index) {
+		$startIndex = strpos($text, '=', $index) + 1;
+		$close      = '='.$this->ctag;
+		$closeIndex = strpos($text, $close, $index);
+
+		list($otag, $ctag) = explode(' ', trim(substr($text, $startIndex, $closeIndex - $startIndex)));
+		$this->otag = $otag;
+		$this->ctag = $ctag;
+
+		return $closeIndex + strlen($close) - 1;
+	}
+
+	/**
+	 * Clean up `{{{ tripleStache }}}` style tokens.
+	 *
+	 * @param array &$token
+	 */
+	private function cleanTripleStache(&$token) {
+		if (substr($token[self::NAME], -1) === '}') {
+			$token[self::NAME] = trim(substr($token[self::NAME], 0, -1));
+		}
+	}
+
+	/**
+	 * Test whether it's time to change tags.
+	 *
+	 * @param string $tag   Current tag name
+	 * @param string $text  Mustache template source
+	 * @param int    $index Current tokenizer index
+	 *
+	 * @return boolean True if this is a closing section tag
+	 */
+	private function tagChange($tag, $text, $index) {
+		return substr($text, $index, strlen($tag)) === $tag;
+	}
+}

+ 16 - 0
test/bootstrap.php

@@ -0,0 +1,16 @@
+<?php
+
+require __DIR__.'/../src/Mustache/Buffer.php';
+require __DIR__.'/../src/Mustache/Compiler.php';
+require __DIR__.'/../src/Mustache/Context.php';
+require __DIR__.'/../src/Mustache/Loader.php';
+require __DIR__.'/../src/Mustache/Loader/MutableLoader.php';
+require __DIR__.'/../src/Mustache/Loader/ArrayLoader.php';
+require __DIR__.'/../src/Mustache/Loader/StringLoader.php';
+require __DIR__.'/../src/Mustache/Loader/FilesystemLoader.php';
+require __DIR__.'/../src/Mustache/Mustache.php';
+require __DIR__.'/../src/Mustache/Parser.php';
+require __DIR__.'/../src/Mustache/Template.php';
+require __DIR__.'/../src/Mustache/Tokenizer.php';
+
+require __DIR__.'/lib/yaml/lib/sfYamlParser.php';

+ 1 - 0
test/fixtures/templates/alpha.ms

@@ -0,0 +1 @@
+alpha contents

+ 1 - 0
test/fixtures/templates/beta.ms

@@ -0,0 +1 @@
+beta contents

+ 1 - 0
test/fixtures/templates/one.mustache

@@ -0,0 +1 @@
+one contents

+ 1 - 0
test/fixtures/templates/two.mustache

@@ -0,0 +1 @@
+two contents