diff --git a/src/Mustache/Buffer.php b/src/Mustache/Buffer.php new file mode 100644 index 0000000..f2fac5d --- /dev/null +++ b/src/Mustache/Buffer.php @@ -0,0 +1,129 @@ +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); + } +} diff --git a/src/Mustache/Compiler.php b/src/Mustache/Compiler.php new file mode 100644 index 0000000..3ca3f48 --- /dev/null +++ b/src/Mustache/Compiler.php @@ -0,0 +1,292 @@ +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 = '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'; + } + } +} diff --git a/src/Mustache/Context.php b/src/Mustache/Context.php new file mode 100644 index 0000000..63ee1e8 --- /dev/null +++ b/src/Mustache/Context.php @@ -0,0 +1,203 @@ +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 ''; + } +} diff --git a/src/Mustache/Loader.php b/src/Mustache/Loader.php new file mode 100644 index 0000000..57991b6 --- /dev/null +++ b/src/Mustache/Loader.php @@ -0,0 +1,18 @@ + '{{ 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; + } +} diff --git a/src/Mustache/Loader/FilesystemLoader.php b/src/Mustache/Loader/FilesystemLoader.php new file mode 100644 index 0000000..e510fc9 --- /dev/null +++ b/src/Mustache/Loader/FilesystemLoader.php @@ -0,0 +1,108 @@ +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; + } +} diff --git a/src/Mustache/Loader/MutableLoader.php b/src/Mustache/Loader/MutableLoader.php new file mode 100644 index 0000000..3d45a5c --- /dev/null +++ b/src/Mustache/Loader/MutableLoader.php @@ -0,0 +1,24 @@ +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; + } +} diff --git a/src/Mustache/Mustache.php b/src/Mustache/Mustache.php index c97cd12..98df8b0 100644 --- a/src/Mustache/Mustache.php +++ b/src/Mustache/Mustache.php @@ -1,5 +1,11 @@ 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'; - - /** - * Pragmas are macro-like directives that, when invoked, change the behavior or - * syntax of Mustache. - * - * 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. - * - * Pragmas apply only to the current template. Partials, even those included after the - * {{%UNESCAPED}} call, will need their own pragma declaration. - * - * 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(); + // Environment + private $templateClassPrefix = '__Mustache_'; + private $cache = null; + private $loader; + private $partialsLoader; + private $charset = 'UTF-8'; /** * Mustache class constructor. * - * This method accepts a $template string and a $view object. Optionally, pass an associative - * array of partials as well. - * * Passing an $options array allows overriding certain Mustache options during instantiation: * * $options = array( - * // `charset` -- must be supported by `htmlspecialentities()`. defaults to 'UTF-8' + * // The class prefix for compiled templates. Defaults to '__Mustache_' + * 'template_class_prefix' => '\My\Namespace\Template\', + * + * // A cache directory for compiled templates. Mustache will not cache templates unless this is set + * 'cache' => __DIR__.'/tmp/cache/mustache', + * + * // A Mustache template loader instance. Uses a StringLoader if not specified + * 'loader' => new \Mustache\Loader\FilesystemLoader(__DIR__.'/views'), + * + * // A Mustache loader instance for partials. + * 'partials_loader' => new \Mustache\Loader\FilesystemLoader(__DIR__.'/views/partials'), + * + * // 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')), + * + * // character set for `htmlspecialchars`. Defaults to 'UTF-8' * 'charset' => 'ISO-8859-1', - * - * // 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, - * ), * ); * - * @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); - } + public function __construct(array $options = array()) { + if (isset($options['template_class_prefix'])) { + $this->templateClassPrefix = $options['template_class_prefix']; + } + + if (isset($options['cache'])) { + $this->cache = $options['cache']; + } + + if (isset($options['loader'])) { + $this->setLoader($options['loader']); + } + + if (isset($options['partials_loader'])) { + $this->setPartialsLoader($options['partials_loader']); + } + + if (isset($options['partials'])) { + $this->setPartials($options['partials']); + } - /** - * 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']; - } - - 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['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['throws_exceptions'])) { - foreach ($options['throws_exceptions'] as $exception => $value) { - $this->_throwsExceptions[$exception] = $value; - } + $this->charset = $options['charset']; } } /** - * Mustache class clone method. + * Get the current Mustache character set. * - * 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; - } - } - } - - /** - * 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); - } - - $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. - * - * @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; + public function setLoader(Loader $loader) { + $this->loader = $loader; + } - $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); + /** + * Get the current Mustache template Loader instance. + * + * If no Loader instance has been explicitly specified, this method will instantiate and return + * a StringLoader instance. + * + * @return \Mustache\Loader + */ + public function getLoader() { + if (!isset($this->loader)) { + $this->loader = new StringLoader; } - return $this->_renderTags($template); + return $this->loader; } /** - * Prepare a section RegEx string 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 _prepareSectionRegEx($otag, $ctag) { - return sprintf( - '/(?:(?<=\\n)[ \\t]*)?%s(?:(?P[%s])(?P.+?)|=(?P.*?)=)%s\\n?/s', - preg_quote($otag, '/'), - self::SECTION_TYPES, - preg_quote($ctag, '/') - ); + public function setPartialsLoader(Loader $partialsLoader) { + $this->partialsLoader = $partialsLoader; } /** - * Extract the first section from $template. + * Get the current Mustache partials Loader instance. * - * @access protected - * @param string $template - * @return array $before, $type, $tag_name, $content and $after + * If no Loader instance has been explicitly specified, this method will instantiate and return + * an ArrayLoader instance. + * + * @return \Mustache\Loader */ - protected function _findSection($template) { - $regEx = $this->_prepareSectionRegEx($this->_otag, $this->_ctag); + public function getPartialsLoader() { + if (!isset($this->partialsLoader)) { + $this->partialsLoader = new ArrayLoader; + } - $section_start = null; - $section_type = null; - $content_start = null; + return $this->partialsLoader; + } - $search_offset = 0; + /** + * Set partials for the current partials Loader instance. + * + * @throws \RuntimeException If the current Loader instance is immutable + * + * @param array $partials (default: array()) + */ + 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'); + } - $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; - } + $loader->setTemplates($partials); + } - $match = $matches[0][0]; - $offset = $matches[0][1]; - $type = $matches['type'][0]; - $tag_name = trim($matches['tag_name'][0]); + /** + * Set the Mustache Tokenizer instance. + * + * @param \Mustache\Tokenizer $tokenizer + */ + public function setTokenizer(Tokenizer $tokenizer) { + $this->tokenizer = $tokenizer; + } - $search_offset = $offset + strlen($match); + /** + * Get the current Mustache Tokenizer instance. + * + * If no Tokenizer instance has been explicitly specified, this method will instantiate and return a new one. + * + * @return \Mustache\Tokenizer + */ + public function getTokenizer() { + if (!isset($this->tokenizer)) { + $this->tokenizer = new Tokenizer; + } - 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); - } + return $this->tokenizer; + } + + /** + * Set the Mustache Parser instance. + * + * @param \Mustache\Parser $tokenizer + */ + public function setParser(Parser $parser) { + $this->parser = $parser; + } + + /** + * Get the current Mustache Parser instance. + * + * If no Parser instance has been explicitly specified, this method will instantiate and return a new one. + * + * @return \Mustache\Parser + */ + public function getParser() { + if (!isset($this->parser)) { + $this->parser = new Parser; + } + + return $this->parser; + } + + /** + * Set the Mustache Compiler instance. + * + * @param \Mustache\Compiler $tokenizer + */ + public function setCompiler(Compiler $compiler) { + $this->compiler = $compiler; + } + + /** + * Get the current Mustache Compiler instance. + * + * If no Compiler instance has been explicitly specified, this method will instantiate and return a new one. + * + * @return \Mustache\Compiler + */ + public function getCompiler() { + if (!isset($this->compiler)) { + $this->compiler = new Compiler; + } + + return $this->compiler; + } + + /** + * Helper method to generate a Mustache template class. + * + * @param string $source + * + * @return string Mustache Template class name + */ + public function getTemplateClassName($source) { + return $this->templateClassPrefix . md5(self::VERSION . ':' . $source); + } + + /** + * Load a Mustache Template by name. + * + * @param string $name + * + * @return \Mustache\Template + */ + public function loadTemplate($name) { + return $this->loadSource($this->getLoader()->load($name)); + } + + /** + * Load a Mustache partial Template by name. + * + * 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 + */ + public function loadPartial($name) { + return $this->loadSource($this->getPartialsLoader()->load($name)); + } + + /** + * Load a Mustache lambda Template by source. + * + * 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 + */ + public function loadLambda($source, $delims = null) { + if ($delims !== null) { + $source = $delims . "\n" . $source; + } + + return $this->loadSource($source); + } + + /** + * Instantiate and return a Mustache Template instance by source. + * + * @see \Mustache\Mustache::loadTemplate + * @see \Mustache\Mustache::loadPartial + * @see \Mustache\Mustache::loadLambda + * + * @param string $source + * + * @return \Mustache\Template + */ + private function loadSource($source) { + $className = $this->getTemplateClassName($source); + + 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 (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; - } - } - - if (!empty($section_stack)) { - if ($this->_throwsException(MustacheException::UNCLOSED_SECTION)) { - throw new MustacheException('Unclosed section: ' . $section_stack[0], MustacheException::UNCLOSED_SECTION); - } - } - } - - /** - * Prepare a pragma RegEx for the given opening/closing tags. - * - * @access protected - * @param string $otag - * @param string $ctag - * @return string - */ - protected function _preparePragmaRegEx($otag, $ctag) { - return sprintf( - '/%s%%\\s*(?P[\\w_-]+)(?P(?: [\\w]+=[\\w]+)*)\\s*%s\\n?/s', - preg_quote($otag, '/'), - preg_quote($ctag, '/') - ); - } - - /** - * Initialize pragmas and remove all pragma tags. - * - * @access protected - * @param string $template - * @return string - */ - protected function _renderPragmas($template) { - $this->_localPragmas = $this->_pragmas; - - // no pragmas - if (strpos($template, $this->_otag . '%') === false) { - return $template; - } - - $regEx = $this->_preparePragmaRegEx($this->_otag, $this->_ctag); - return preg_replace_callback($regEx, array($this, '_renderPragma'), $template); - } - - /** - * A preg_replace helper to remove {{%PRAGMA}} tags and enable requested pragma. - * - * @access protected - * @param mixed $matches - * @return void - * @throws MustacheException unknown pragma - */ - 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; - } - - return ''; - } - - /** - * Check whether this Mustache has a specific pragma. - * - * @access protected - * @param string $pragma_name - * @return bool - */ - protected function _hasPragma($pragma_name) { - if (array_key_exists($pragma_name, $this->_localPragmas) && $this->_localPragmas[$pragma_name]) { - return true; - } else { - return false; - } - } - - /** - * Return pragma options, if any. - * - * @access protected - * @param string $pragma_name - * @return mixed - * @throws MustacheException Unknown pragma - */ - 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); - } - } - - return (is_array($this->_localPragmas[$pragma_name])) ? $this->_localPragmas[$pragma_name] : array(); - } - - /** - * Check whether this Mustache instance throws a given exception. - * - * Expects exceptions to be MustacheException error codes (i.e. class constants). - * - * @access protected - * @param mixed $exception - * @return void - */ - protected function _throwsException($exception) { - return (isset($this->_throwsExceptions[$exception]) && $this->_throwsExceptions[$exception]); - } - - /** - * Prepare a tag RegEx for the given opening/closing tags. - * - * @access protected - * @param string $otag - * @param string $ctag - * @return string - */ - protected function _prepareTagRegEx($otag, $ctag, $first = false) { - return sprintf( - '/(?P(?:%s\\r?\\n)[ \\t]*)?%s(?P[%s]?)(?P.+?)(?:\\2|})?%s(?P\\s*(?:\\r?\\n|\\Z))?/s', - ($first ? '\\A|' : ''), - preg_quote($otag, '/'), - self::TAG_TYPES, - preg_quote($ctag, '/') - ); - } - - /** - * Loop through and render individual Mustache tags. - * - * @access protected - * @param string $template - * @return void - */ - 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); - } - } - - return $html . $template; - } - - /** - * Render the named tag, given the specified modifier. - * - * 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 - */ - 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); + require_once $fileName; } else { - return $this->_renderUnescaped($tag_name, $leading, $trailing); + eval('?>'.$this->compile($source)); } - 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; - } - } - - /** - * Returns true if any of its args contains the "\r" character. - * - * @access protected - * @param string $str - * @return boolean - */ - protected function _stringHasR($str) { - foreach (func_get_args() as $arg) { - if (strpos($arg, "\r") !== false) { - return true; } + + $this->templates[$className] = new $className($this); } - return false; + + return $this->templates[$className]; } /** - * Escape and return the requested tag. + * Helper method to tokenize a Mustache template. * - * @access protected - * @param string $tag_name - * @param string $leading Whitespace - * @param string $trailing Whitespace - * @return string + * @see \Mustache\Tokenizer::scan + * + * @param string $source + * + * @return array Tokens */ - protected function _renderEscaped($tag_name, $leading, $trailing) { - $rendered = htmlentities($this->_renderUnescaped($tag_name, '', ''), ENT_COMPAT, $this->_charset); - return $leading . $rendered . $trailing; + private function tokenize($source) { + return $this->getTokenizer()->scan($source); } /** - * Render a comment (i.e. return an empty string). + * Helper method to parse a Mustache template. * - * @access protected - * @param string $tag_name - * @param string $leading Whitespace - * @param string $trailing Whitespace - * @return string + * @see \Mustache\Parser::parse + * + * @param string $source + * + * @return array Token tree */ - 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; + private function parse($source) { + return $this->getParser()->parse($this->tokenize($source)); } /** - * Return the requested tag unescaped. + * Helper method to compile a Mustache template. * - * @access protected - * @param string $tag_name - * @param string $leading Whitespace - * @param string $trailing Whitespace - * @return string + * @see \Mustache\Compiler::compile + * + * @param string $source + * + * @return string generated Mustache template class code */ - 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; + private function compile($source) { + return $this->getCompiler()->compile($source, $this->parse($source), $this->getTemplateClassName($source)); } /** - * Render the requested partial. + * Helper method to generate a Mustache Template class cache filename. * - * @access protected - * @param string $tag_name - * @param string $leading Whitespace - * @param string $trailing Whitespace - * @return string + * @param string $source + * + * @return string Mustache Template class cache filename */ - 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); - } - - $view = clone($this); - - if ($leading !== null && $trailing !== null) { - return $leading . $view->render($partial); - } else { - return $leading . $view->render($partial) . $trailing; + private function getCacheFilename($source) { + if ($this->cache) { + return sprintf('%s/%s.php', $this->cache, $this->getTemplateClassName($source)); } } /** - * Change the Mustache tag delimiter. This method also replaces this object's current - * tag RegEx with one using the new delimiters. + * Helper method to dump a generated Mustache Template subclass to the file cache. * - * @access protected - * @param string $tag_name - * @param string $leading Whitespace - * @param string $trailing Whitespace - * @return string + * @throws \RuntimeException if unable to write to $fileName. + * + * @param string $fileName + * @param string $source */ - protected function _changeDelimiter($tag_name, $leading, $trailing) { - list($otag, $ctag) = explode(' ', $tag_name); - $this->_otag = $otag; - $this->_ctag = $ctag; - - $this->_tagRegEx = $this->_prepareTagRegEx($this->_otag, $this->_ctag); - - if ($leading !== null && $trailing !== null) { - if (strpos($leading, "\n") === false) { - return ''; - } - return $this->_stringHasR($leading, $trailing) ? "\r\n" : "\n"; + private function writeCacheFile($fileName, $source) { + if (!is_dir(dirname($fileName))) { + mkdir(dirname($fileName), 0777, true); } - 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->_context = $new; - } + $tempFile = tempnam(dirname($fileName), basename($fileName)); + if (false !== @file_put_contents($tempFile, $source)) { + if (@rename($tempFile, $fileName)) { + chmod($fileName, 0644); - /** - * 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; - } - - /** - * Get a variable from the context array. - * - * 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. - * - * @access protected - * @param string $tag_name - * @throws MustacheException Unknown variable name. - * @return string - */ - 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); - } - } - - /** - * Get a variable from the context array. Internal helper used by getVariable() to abstract - * variable traversal for dot notation. - * - * @access protected - * @param string $tag_name - * @param array $context - * @throws MustacheException Unknown variable name. - * @return string - */ - 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]; + return; } } - if ($this->_throwsException(MustacheException::UNKNOWN_VARIABLE)) { - throw new MustacheException("Unknown variable: " . $tag_name, MustacheException::UNKNOWN_VARIABLE); - } else { - return ''; - } - } - - /** - * Retrieve the partial corresponding to the requested tag name. - * - * Silently fails (i.e. returns '') when the requested partial is not found. - * - * @access protected - * @param string $tag_name - * @throws MustacheException Unknown partial name. - * @return string - */ - 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 ''; - } - } - - /** - * Check whether the given $var should be iterated (i.e. in a section context). - * - * @access protected - * @param mixed $var - * @return bool - */ - protected function _varIsIterable($var) { - return $var instanceof Traversable || (is_array($var) && !array_diff_key($var, array_keys(array_keys($var)))); - } - - /** - * 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: - * - * 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')` - * - * @access protected - * @param mixed $var - * @return bool - */ - protected function _varIsCallable($var) { - return !is_string($var) && is_callable($var); + throw new \RuntimeException(sprintf('Failed to write cache file "%s".', $fileName)); } } - - -/** - * 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; - - // An UNKNOWN_PARTIAL exception is thrown whenever a {{>partial}} tag appears - // with no associated partial. - const UNKNOWN_PARTIAL = 3; - - // An UNKNOWN_PRAGMA exception is thrown whenever a {{%PRAGMA}} tag appears - // which can't be handled by this Mustache instance. - const UNKNOWN_PRAGMA = 4; - -} diff --git a/src/Mustache/MustacheLoader.php b/src/Mustache/MustacheLoader.php deleted file mode 100644 index 9c4b386..0000000 --- a/src/Mustache/MustacheLoader.php +++ /dev/null @@ -1,85 +0,0 @@ -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; - } -} diff --git a/src/Mustache/Parser.php b/src/Mustache/Parser.php new file mode 100644 index 0000000..d330913 --- /dev/null +++ b/src/Mustache/Parser.php @@ -0,0 +1,80 @@ +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; + } +} diff --git a/src/Mustache/Template.php b/src/Mustache/Template.php new file mode 100644 index 0000000..a641593 --- /dev/null +++ b/src/Mustache/Template.php @@ -0,0 +1,66 @@ +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); +} diff --git a/src/Mustache/Tokenizer.php b/src/Mustache/Tokenizer.php new file mode 100644 index 0000000..1074397 --- /dev/null +++ b/src/Mustache/Tokenizer.php @@ -0,0 +1,262 @@ + 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; + } +} diff --git a/test/bootstrap.php b/test/bootstrap.php new file mode 100644 index 0000000..f022da1 --- /dev/null +++ b/test/bootstrap.php @@ -0,0 +1,16 @@ +