Sfoglia il codice sorgente

Merge branch 'release/2.1.0'

Justin Hileman 13 anni fa
parent
commit
6ac9cf03e4

+ 2 - 2
.gitmodules

@@ -1,6 +1,6 @@
 [submodule "vendor/spec"]
 	path = vendor/spec
-	url = git://github.com/mustache/spec.git
+	url = https://github.com/mustache/spec.git
 [submodule "vendor/yaml"]
 	path = vendor/yaml
-	url = git://github.com/fabpot/yaml.git
+	url = https://github.com/fabpot/yaml.git

+ 33 - 0
CONTRIBUTING.markdown

@@ -0,0 +1,33 @@
+# Contributions welcome!
+
+
+### Here's a quick guide:
+
+ 1. [Fork the repo on GitHub](https://github.com/bobthecow/mustache.php).
+
+ 2. Run the test suite. We only take pull requests with passing tests, and it's great to know that you have a clean slate. Make sure you have PHPUnit 3.5+, then run `phpunit` from the project directory.
+
+ 3. Add tests for your change. Only refactoring and documentation changes require no new tests. If you are adding functionality or fixing a bug, add a test!
+
+ 4. Make the tests pass.
+
+ 5. Push your fork to GitHub and submit a pull request against the `dev` branch.
+
+
+### You can do some things to increase the chance that your pull request is accepted the first time:
+
+ * Submit one pull request per fix or feature.
+ * To help with that, do all your work in a feature branch (e.g. `feature/my-alsome-feature`).
+ * Follow the conventions you see used in the project.
+ * Use `phpcs --standard=PSR2` to check your changes against the coding standard.
+ * Write tests that fail without your code, and pass with it.
+ * Don't bump version numbers. Those will be updated — per [semver](http://semver.org) — once your change is merged into `master`.
+ * Update any documentation: docblocks, README, examples, etc.
+ * ... Don't update the wiki until your change is merged and released, but make a note in your pull request so we don't forget.
+
+
+### Mustache.php follows the PSR-* coding standards:
+
+ * [PSR-0: Class and file naming conventions](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md)
+ * [PSR-1: Basic coding standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-1-basic-coding-standard.md)
+ * [PSR-2: Coding style guide](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)

+ 64 - 3
src/Mustache/Compiler.php

@@ -22,6 +22,7 @@ class Mustache_Compiler
     private $indentNextLine;
     private $customEscape;
     private $charset;
+    private $pragmas;
 
     /**
      * Compile a Mustache token parse tree into PHP source code.
@@ -36,6 +37,7 @@ class Mustache_Compiler
      */
     public function compile($source, array $tree, $name, $customEscape = false, $charset = 'UTF-8')
     {
+        $this->pragmas        = array();
         $this->sections       = array();
         $this->source         = $source;
         $this->indentNextLine = true;
@@ -61,6 +63,10 @@ class Mustache_Compiler
         $level++;
         foreach ($tree as $node) {
             switch ($node[Mustache_Tokenizer::TYPE]) {
+                case Mustache_Tokenizer::T_PRAGMA:
+                    $this->pragmas[$node[Mustache_Tokenizer::NAME]] = true;
+                    break;
+
                 case Mustache_Tokenizer::T_SECTION:
                     $code .= $this->section(
                         $node[Mustache_Tokenizer::NODES],
@@ -118,8 +124,11 @@ class Mustache_Compiler
 
         class %s extends Mustache_Template
         {
+            private $lambdaHelper;
+
             public function renderInternal(Mustache_Context $context, $indent = \'\', $escape = false)
             {
+                $this->lambdaHelper = new Mustache_LambdaHelper($this->mustache, $context);
                 $buffer = \'\';
         %s
 
@@ -159,7 +168,7 @@ class Mustache_Compiler
             if (!is_string($value) && is_callable($value)) {
                 $source = %s;
                 $buffer .= $this->mustache
-                    ->loadLambda((string) call_user_func($value, $source)%s)
+                    ->loadLambda((string) call_user_func($value, $source, $this->lambdaHelper)%s)
                     ->renderInternal($context, $indent);
             } elseif (!empty($value)) {
                 $values = $this->isIterable($value) ? $value : array($value);
@@ -260,7 +269,7 @@ class Mustache_Compiler
             $value = $this->mustache
                 ->loadLambda((string) call_user_func($value))
                 ->renderInternal($context, $indent);
-        }
+        }%s
         $buffer .= %s%s;
     ';
 
@@ -275,11 +284,63 @@ class Mustache_Compiler
      */
     private function variable($id, $escape, $level)
     {
+        $filters = '';
+
+        if (isset($this->pragmas[Mustache_Engine::PRAGMA_FILTERS])) {
+            list($id, $filters) = $this->getFilters($id, $level);
+        }
+
         $method = $this->getFindMethod($id);
         $id     = ($method !== 'last') ? var_export($id, true) : '';
         $value  = $escape ? $this->getEscape() : '$value';
 
-        return sprintf($this->prepare(self::VARIABLE, $level), $method, $id, $this->flushIndent(), $value);
+        return sprintf($this->prepare(self::VARIABLE, $level), $method, $id, $filters, $this->flushIndent(), $value);
+    }
+
+    /**
+     * Generate Mustache Template variable filtering PHP source.
+     *
+     * @param string $id    Variable name
+     * @param int    $level
+     *
+     * @return string Generated variable filtering PHP source
+     */
+    private function getFilters($id, $level)
+    {
+        $filters = array_map('trim', explode('|', $id));
+        $id      = array_shift($filters);
+
+        return array($id, $this->getFilter($filters, $level));
+    }
+
+    const FILTER = '
+        $filter = $context->%s(%s);
+        if (is_string($filter) || !is_callable($filter)) {
+            throw new UnexpectedValueException(%s);
+        }
+        $value = call_user_func($filter, $value);%s
+    ';
+
+    /**
+     * Generate PHP source for a single filter.
+     *
+     * @param array $filters
+     * @param int   $level
+     *
+     * @return string Generated filter PHP source
+     */
+    private function getFilter(array $filters, $level)
+    {
+        if (empty($filters)) {
+            return '';
+        }
+
+        $name   = array_shift($filters);
+        $method = $this->getFindMethod($name);
+        $filter = ($method !== 'last') ? var_export($name, true) : '';
+        $msg    = var_export(sprintf('Filter not found: %s', $name), true);
+
+        return sprintf($this->prepare(self::FILTER, $level), $method, $filter, $msg, $this->getFilter($filters, $level));
     }
 
     const LINE = '$buffer .= "\n";';

+ 1 - 1
src/Mustache/Context.php

@@ -133,7 +133,7 @@ class Mustache_Context
     private function findVariableInStack($id, array $stack)
     {
         for ($i = count($stack) - 1; $i >= 0; $i--) {
-            if (is_object($stack[$i])) {
+            if (is_object($stack[$i]) && !$stack[$i] instanceof Closure) {
                 if (method_exists($stack[$i], $id)) {
                     return $stack[$i]->$id();
                 } elseif (isset($stack[$i]->$id)) {

+ 125 - 13
src/Mustache/Engine.php

@@ -23,8 +23,10 @@
  */
 class Mustache_Engine
 {
-    const VERSION      = '2.0.2';
-    const SPEC_VERSION = '1.1.2';
+    const VERSION        = '2.1.0';
+    const SPEC_VERSION   = '1.1.2';
+
+    const PRAGMA_FILTERS = 'FILTERS';
 
     // Template cache
     private $templates = array();
@@ -32,11 +34,13 @@ class Mustache_Engine
     // Environment
     private $templateClassPrefix = '__Mustache_';
     private $cache = null;
+    private $cacheFileMode = null;
     private $loader;
     private $partialsLoader;
     private $helpers;
     private $escape;
     private $charset = 'UTF-8';
+    private $logger;
 
     /**
      * Mustache class constructor.
@@ -44,13 +48,17 @@ class Mustache_Engine
      * Passing an $options array allows overriding certain Mustache options during instantiation:
      *
      *     $options = array(
-     *         // The class prefix for compiled templates. Defaults to '__Mustache_'
+     *         // The class prefix for compiled templates. Defaults to '__Mustache_'.
      *         'template_class_prefix' => '__MyTemplates_',
      *
      *         // A cache directory for compiled templates. Mustache will not cache templates unless this is set
      *         'cache' => dirname(__FILE__).'/tmp/cache/mustache',
      *
-     *         // A Mustache template loader instance. Uses a StringLoader if not specified
+     *         // Override default permissions for cache files. Defaults to using the system-defined umask. It is
+     *         // *strongly* recommended that you configure your umask properly rather than overriding permissions here.
+     *         'cache_file_mode' => 0666,
+     *
+     *         // A Mustache template loader instance. Uses a StringLoader if not specified.
      *         'loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views'),
      *
      *         // A Mustache loader instance for partials.
@@ -64,16 +72,21 @@ class Mustache_Engine
      *         // sections), or any other valid Mustache context value. They will be prepended to the context stack,
      *         // so they will be available in any template loaded by this Mustache instance.
      *         'helpers' => array('i18n' => function($text) {
-     *              // do something translatey here...
-     *          }),
+     *             // do something translatey here...
+     *         }),
      *
      *         // An 'escape' callback, responsible for escaping double-mustache variables.
      *         'escape' => function($value) {
      *             return htmlspecialchars($buffer, ENT_COMPAT, 'UTF-8');
      *         },
      *
-     *         // character set for `htmlspecialchars`. Defaults to 'UTF-8'
+     *         // Character set for `htmlspecialchars`. Defaults to 'UTF-8'. Use 'UTF-8'.
      *         'charset' => 'ISO-8859-1',
+     *
+     *         // A Mustache Logger instance. No logging will occur unless this is set. Using a PSR-3 compatible
+     *         // logging library -- such as Monolog -- is highly recommended. A simple stream logger implementation is
+     *         // available as well:
+     *         'logger' => new Mustache_StreamLogger('php://stderr'),
      *     );
      *
      * @param array $options (default: array())
@@ -88,6 +101,10 @@ class Mustache_Engine
             $this->cache = $options['cache'];
         }
 
+        if (isset($options['cache_file_mode'])) {
+            $this->cacheFileMode = $options['cache_file_mode'];
+        }
+
         if (isset($options['loader'])) {
             $this->setLoader($options['loader']);
         }
@@ -115,6 +132,10 @@ class Mustache_Engine
         if (isset($options['charset'])) {
             $this->charset = $options['charset'];
         }
+
+        if (isset($options['logger'])) {
+            $this->setLogger($options['logger']);
+        }
     }
 
     /**
@@ -319,6 +340,30 @@ class Mustache_Engine
         $this->getHelpers()->remove($name);
     }
 
+    /**
+     * Set the Mustache Logger instance.
+     *
+     * @param Mustache_Logger|Psr\Log\LoggerInterface $logger
+     */
+    public function setLogger($logger = null)
+    {
+        if ($logger !== null && !($logger instanceof Mustache_Logger || is_a($logger, 'Psr\\Log\\LoggerInterface'))) {
+            throw new InvalidArgumentException('Expected an instance of Mustache_Logger or Psr\\Log\\LoggerInterface.');
+        }
+
+        $this->logger = $logger;
+    }
+
+    /**
+     * Get the current Mustache Logger instance.
+     *
+     * @return Mustache_Logger|Psr\Log\LoggerInterface
+     */
+    public function getLogger()
+    {
+        return $this->logger;
+    }
+
     /**
      * Set the Mustache Tokenizer instance.
      *
@@ -442,7 +487,12 @@ class Mustache_Engine
         try {
             return $this->loadSource($this->getPartialsLoader()->load($name));
         } catch (InvalidArgumentException $e) {
-            // If the named partial cannot be found, return null.
+            // If the named partial cannot be found, log then return null.
+            $this->log(
+                Mustache_Logger::WARNING,
+                'Partial not found: "{name}"',
+                array('name' => $name)
+            );
         }
     }
 
@@ -485,15 +535,33 @@ class Mustache_Engine
             if (!class_exists($className, false)) {
                 if ($fileName = $this->getCacheFilename($source)) {
                     if (!is_file($fileName)) {
+                        $this->log(
+                            Mustache_Logger::DEBUG,
+                            'Writing "{className}" class to template cache: "{fileName}"',
+                            array('className' => $className, 'fileName' => $fileName)
+                        );
+
                         $this->writeCacheFile($fileName, $this->compile($source));
                     }
 
                     require_once $fileName;
                 } else {
+                    $this->log(
+                        Mustache_Logger::WARNING,
+                        'Template cache disabled, evaluating "{className}" class at runtime',
+                        array('className' => $className)
+                    );
+
                     eval('?>'.$this->compile($source));
                 }
             }
 
+            $this->log(
+                Mustache_Logger::DEBUG,
+                'Instantiating template: "{className}"',
+                array('className' => $className)
+            );
+
             $this->templates[$className] = new $className($this);
         }
 
@@ -542,6 +610,12 @@ class Mustache_Engine
         $tree = $this->parse($source);
         $name = $this->getTemplateClassName($source);
 
+        $this->log(
+            Mustache_Logger::INFO,
+            'Compiling template to "{className}" class',
+            array('className' => $name)
+        );
+
         return $this->getCompiler()->compile($source, $tree, $name, isset($this->escape), $this->charset);
     }
 
@@ -562,7 +636,7 @@ class Mustache_Engine
     /**
      * Helper method to dump a generated Mustache Template subclass to the file cache.
      *
-     * @throws RuntimeException if unable to write to $fileName.
+     * @throws RuntimeException if unable to create the cache directory or write to $fileName.
      *
      * @param string $fileName
      * @param string $source
@@ -571,19 +645,57 @@ class Mustache_Engine
      */
     private function writeCacheFile($fileName, $source)
     {
-        if (!is_dir(dirname($fileName))) {
-            mkdir(dirname($fileName), 0777, true);
+        $dirName = dirname($fileName);
+        if (!is_dir($dirName)) {
+            $this->log(
+                Mustache_Logger::INFO,
+                'Creating Mustache template cache directory: "{dirName}"',
+                array('dirName' => $dirName)
+            );
+
+            @mkdir($dirName, 0777, true);
+            if (!is_dir($dirName)) {
+                throw new RuntimeException(sprintf('Failed to create cache directory "%s".', $dirName));
+            }
+
         }
 
-        $tempFile = tempnam(dirname($fileName), basename($fileName));
+        $this->log(
+            Mustache_Logger::DEBUG,
+            'Caching compiled template to "{fileName}"',
+            array('fileName' => $fileName)
+        );
+
+        $tempFile = tempnam($dirName, basename($fileName));
         if (false !== @file_put_contents($tempFile, $source)) {
             if (@rename($tempFile, $fileName)) {
-                chmod($fileName, 0644);
+                $mode = isset($this->cacheFileMode) ? $this->cacheFileMode : (0666 & ~umask());
+                @chmod($fileName, $mode);
 
                 return;
             }
+
+            $this->log(
+                Mustache_Logger::ERROR,
+                'Unable to rename Mustache temp cache file: "{tempName}" -> "{fileName}"',
+                array('tempName' => $tempFile, 'fileName' => $fileName)
+            );
         }
 
         throw new RuntimeException(sprintf('Failed to write cache file "%s".', $fileName));
     }
+
+    /**
+     * Add a log record if logging is enabled.
+     *
+     * @param  integer $level   The logging level
+     * @param  string  $message The log message
+     * @param  array   $context The log context
+     */
+    private function log($level, $message, array $context = array())
+    {
+        if (isset($this->logger)) {
+            $this->logger->log($level, $message, $context);
+        }
+    }
 }

+ 48 - 0
src/Mustache/LambdaHelper.php

@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Mustache Lambda Helper.
+ *
+ * Passed to section and interpolation lambdas, giving them access to a `render`
+ * method for rendering a string with the current context.
+ */
+class Mustache_LambdaHelper
+{
+    private $mustache;
+    private $context;
+
+    /**
+     * Mustache Lambda Helper constructor.
+     *
+     * @param Mustache_Engine  $mustache Mustache engine instance.
+     * @param Mustache_Context $context  Rendering context.
+     */
+    public function __construct(Mustache_Engine $mustache, Mustache_Context $context)
+    {
+        $this->mustache = $mustache;
+        $this->context  = $context;
+    }
+
+    /**
+     * Render a string as a Mustache template with the current rendering context.
+     *
+     * @param string $string
+     *
+     * @return Rendered template.
+     */
+    public function render($string)
+    {
+        return $this->mustache
+            ->loadLambda((string) $string)
+            ->renderInternal($this->context);
+    }
+}

+ 12 - 8
src/Mustache/Loader/FilesystemLoader.php

@@ -12,19 +12,19 @@
 /**
  * Mustache Template filesystem Loader implementation.
  *
- * An ArrayLoader instance loads Mustache Template source from the filesystem by name:
+ * A FilesystemLoader instance loads Mustache Template source from the filesystem by name:
  *
- *     $loader = new FilesystemLoader(dirname(__FILE__).'/views');
+ *     $loader = new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views');
  *     $tpl = $loader->load('foo'); // equivalent to `file_get_contents(dirname(__FILE__).'/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(dirname(__FILE__).'/views'),
- *          'partials_loader' => new FilesystemLoader(dirname(__FILE__).'/views/partials'),
+ *          'loader'          => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views'),
+ *          'partials_loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views/partials'),
  *     ));
  *
- * @implements Loader
+ * @implements Mustache_Loader
  */
 class Mustache_Loader_FilesystemLoader implements Mustache_Loader
 {
@@ -55,15 +55,19 @@ class Mustache_Loader_FilesystemLoader implements Mustache_Loader
             throw new RuntimeException('FilesystemLoader baseDir must be a directory: '.$baseDir);
         }
 
-        if (isset($options['extension'])) {
-            $this->extension = '.' . ltrim($options['extension'], '.');
+        if (array_key_exists('extension', $options)) {
+            if (empty($options['extension'])) {
+                $this->extension = '';
+            } else {
+                $this->extension = '.' . ltrim($options['extension'], '.');
+            }
         }
     }
 
     /**
      * Load a Template by name.
      *
-     *     $loader = new FilesystemLoader(dirname(__FILE__).'/views');
+     *     $loader = new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views');
      *     $loader->load('admin/dashboard'); // loads "./views/admin/dashboard.mustache";
      *
      * @param string $name

+ 135 - 0
src/Mustache/Logger.php

@@ -0,0 +1,135 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Describes a Mustache logger instance
+ *
+ * This is identical to the Psr\Log\LoggerInterface.
+ *
+ * The message MUST be a string or object implementing __toString().
+ *
+ * The message MAY contain placeholders in the form: {foo} where foo
+ * will be replaced by the context data in key "foo".
+ *
+ * The context array can contain arbitrary data, the only assumption that
+ * can be made by implementors is that if an Exception instance is given
+ * to produce a stack trace, it MUST be in a key named "exception".
+ *
+ * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md
+ * for the full interface specification.
+ */
+interface Mustache_Logger
+{
+    /**
+     * Psr\Log compatible log levels
+     */
+    const EMERGENCY = 'emergency';
+    const ALERT     = 'alert';
+    const CRITICAL  = 'critical';
+    const ERROR     = 'error';
+    const WARNING   = 'warning';
+    const NOTICE    = 'notice';
+    const INFO      = 'info';
+    const DEBUG     = 'debug';
+
+    /**
+     * System is unusable.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function emergency($message, array $context = array());
+
+    /**
+     * Action must be taken immediately.
+     *
+     * Example: Entire website down, database unavailable, etc. This should
+     * trigger the SMS alerts and wake you up.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function alert($message, array $context = array());
+
+    /**
+     * Critical conditions.
+     *
+     * Example: Application component unavailable, unexpected exception.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function critical($message, array $context = array());
+
+    /**
+     * Runtime errors that do not require immediate action but should typically
+     * be logged and monitored.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function error($message, array $context = array());
+
+    /**
+     * Exceptional occurrences that are not errors.
+     *
+     * Example: Use of deprecated APIs, poor use of an API, undesirable things
+     * that are not necessarily wrong.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function warning($message, array $context = array());
+
+    /**
+     * Normal but significant events.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function notice($message, array $context = array());
+
+    /**
+     * Interesting events.
+     *
+     * Example: User logs in, SQL logs.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function info($message, array $context = array());
+
+    /**
+     * Detailed debug information.
+     *
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function debug($message, array $context = array());
+
+    /**
+     * Logs with an arbitrary level.
+     *
+     * @param mixed $level
+     * @param string $message
+     * @param array $context
+     * @return null
+     */
+    public function log($level, $message, array $context = array());
+}

+ 121 - 0
src/Mustache/Logger/AbstractLogger.php

@@ -0,0 +1,121 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * This is a simple Logger implementation that other Loggers can inherit from.
+ *
+ * This is identical to the Psr\Log\AbstractLogger.
+ *
+ * It simply delegates all log-level-specific methods to the `log` method to
+ * reduce boilerplate code that a simple Logger that does the same thing with
+ * messages regardless of the error level has to implement.
+ */
+abstract class Mustache_Logger_AbstractLogger implements Mustache_Logger
+{
+    /**
+     * System is unusable.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function emergency($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::EMERGENCY, $message, $context);
+    }
+
+    /**
+     * Action must be taken immediately.
+     *
+     * Example: Entire website down, database unavailable, etc. This should
+     * trigger the SMS alerts and wake you up.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function alert($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::ALERT, $message, $context);
+    }
+
+    /**
+     * Critical conditions.
+     *
+     * Example: Application component unavailable, unexpected exception.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function critical($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::CRITICAL, $message, $context);
+    }
+
+    /**
+     * Runtime errors that do not require immediate action but should typically
+     * be logged and monitored.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function error($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::ERROR, $message, $context);
+    }
+
+    /**
+     * Exceptional occurrences that are not errors.
+     *
+     * Example: Use of deprecated APIs, poor use of an API, undesirable things
+     * that are not necessarily wrong.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function warning($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::WARNING, $message, $context);
+    }
+
+    /**
+     * Normal but significant events.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function notice($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::NOTICE, $message, $context);
+    }
+
+    /**
+     * Interesting events.
+     *
+     * Example: User logs in, SQL logs.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function info($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::INFO, $message, $context);
+    }
+
+    /**
+     * Detailed debug information.
+     *
+     * @param string $message
+     * @param array $context
+     */
+    public function debug($message, array $context = array())
+    {
+        $this->log(Mustache_Logger::DEBUG, $message, $context);
+    }
+}

+ 188 - 0
src/Mustache/Logger/StreamLogger.php

@@ -0,0 +1,188 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * A Mustache Stream Logger.
+ *
+ * The Stream Logger wraps a file resource instance (such as a stream) or a
+ * stream URL. All log messages over the threshold level will be appended to
+ * this stream.
+ *
+ * Hint: Try `php://stderr` for your stream URL.
+ */
+class Mustache_Logger_StreamLogger extends Mustache_Logger_AbstractLogger
+{
+    protected static $levels = array(
+        self::DEBUG     => 100,
+        self::INFO      => 200,
+        self::NOTICE    => 250,
+        self::WARNING   => 300,
+        self::ERROR     => 400,
+        self::CRITICAL  => 500,
+        self::ALERT     => 550,
+        self::EMERGENCY => 600,
+    );
+
+    protected $stream = null;
+    protected $url    = null;
+
+    /**
+     * @throws InvalidArgumentException if the logging level is unknown.
+     *
+     * @param string  $stream Resource instance or URL
+     * @param integer $level  The minimum logging level at which this handler will be triggered
+     */
+    public function __construct($stream, $level = Mustache_Logger::ERROR)
+    {
+        $this->setLevel($level);
+
+        if (is_resource($stream)) {
+            $this->stream = $stream;
+        } else {
+            $this->url = $stream;
+        }
+    }
+
+    /**
+     * Close stream resources.
+     */
+    public function __destruct()
+    {
+        if (is_resource($this->stream)) {
+            fclose($this->stream);
+        }
+    }
+
+    /**
+     * Set the minimum logging level.
+     *
+     * @throws InvalidArgumentException if the logging level is unknown.
+     *
+     * @param  integer $level The minimum logging level which will be written
+     */
+    public function setLevel($level)
+    {
+        if (!array_key_exists($level, self::$levels)) {
+            throw new InvalidArgumentException('Unexpected logging level: ' . $level);
+        }
+
+        $this->level = $level;
+    }
+
+    /**
+     * Get the current minimum logging level.
+     *
+     * @return integer
+     */
+    public function getLevel()
+    {
+        return $this->level;
+    }
+
+    /**
+     * Logs with an arbitrary level.
+     *
+     * @throws InvalidArgumentException if the logging level is unknown.
+     *
+     * @param mixed $level
+     * @param string $message
+     * @param array $context
+     */
+    public function log($level, $message, array $context = array())
+    {
+        if (!array_key_exists($level, self::$levels)) {
+            throw new InvalidArgumentException('Unexpected logging level: ' . $level);
+        }
+
+        if (self::$levels[$level] >= self::$levels[$this->level]) {
+            $this->writeLog($level, $message, $context);
+        }
+    }
+
+    /**
+     * Write a record to the log.
+     *
+     * @param  integer $level   The logging level
+     * @param  string  $message The log message
+     * @param  array   $context The log context
+     */
+    protected function writeLog($level, $message, array $context = array())
+    {
+        if (!is_resource($this->stream)) {
+            if (!isset($this->url)) {
+                throw new LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().');
+            }
+
+            $this->stream = fopen($this->url, 'a');
+            if (!is_resource($this->stream)) {
+                // @codeCoverageIgnoreStart
+                throw new UnexpectedValueException(sprintf('The stream or file "%s" could not be opened.', $this->url));
+                // @codeCoverageIgnoreEnd
+            }
+        }
+
+        fwrite($this->stream, self::formatLine($level, $message, $context));
+    }
+
+    /**
+     * Gets the name of the logging level.
+     *
+     * @throws InvalidArgumentException if the logging level is unknown.
+     *
+     * @param  integer $level
+     *
+     * @return string
+     */
+    protected static function getLevelName($level)
+    {
+        return strtoupper($level);
+    }
+
+    /**
+     * Format a log line for output.
+     *
+     * @param  integer $level   The logging level
+     * @param  string  $message The log message
+     * @param  array   $context The log context
+     *
+     * @return string
+     */
+    protected static function formatLine($level, $message, array $context = array())
+    {
+        return sprintf(
+            "%s: %s\n",
+            self::getLevelName($level),
+            self::interpolateMessage($message, $context)
+        );
+    }
+
+    /**
+     * Interpolate context values into the message placeholders.
+     *
+     * @param  string $message
+     * @param  array  $context
+     *
+     * @return string
+     */
+    protected static function interpolateMessage($message, array $context = array())
+    {
+        $message = (string) $message;
+
+        // build a replacement array with braces around the context keys
+        $replace = array();
+        foreach ($context as $key => $val) {
+            $replace['{' . $key . '}'] = $val;
+        }
+
+        // interpolate replacement values into the the message and return
+        return strtr($message, $replace);
+    }
+}

+ 22 - 0
src/Mustache/Tokenizer.php

@@ -34,6 +34,7 @@ class Mustache_Tokenizer
     const T_UNESCAPED    = '{';
     const T_UNESCAPED_2  = '&';
     const T_TEXT         = '_t';
+    const T_PRAGMA       = '%';
 
     // Valid token types
     private static $tagTypes = array(
@@ -47,6 +48,7 @@ class Mustache_Tokenizer
         self::T_ESCAPED      => true,
         self::T_UNESCAPED    => true,
         self::T_UNESCAPED_2  => true,
+        self::T_PRAGMA       => true,
     );
 
     // Interpolated tags
@@ -67,6 +69,7 @@ class Mustache_Tokenizer
     const NODES  = 'nodes';
     const VALUE  = 'value';
 
+    private $pragmas;
     private $state;
     private $tagType;
     private $tag;
@@ -126,6 +129,9 @@ class Mustache_Tokenizer
                     if ($this->tagType === self::T_DELIM_CHANGE) {
                         $i = $this->changeDelimiters($text, $i);
                         $this->state = self::IN_TEXT;
+                    } elseif ($this->tagType === self::T_PRAGMA) {
+                        $i = $this->addPragma($text, $i);
+                        $this->state = self::IN_TEXT;
                     } else {
                         if ($tag !== null) {
                             $i++;
@@ -168,6 +174,13 @@ class Mustache_Tokenizer
 
         $this->filterLine(true);
 
+        foreach ($this->pragmas as $pragma) {
+            array_unshift($this->tokens, array(
+                self::TYPE => self::T_PRAGMA,
+                self::NAME => $pragma,
+            ));
+        }
+
         return $this->tokens;
     }
 
@@ -185,6 +198,7 @@ class Mustache_Tokenizer
         $this->lineStart = 0;
         $this->otag      = '{{';
         $this->ctag      = '}}';
+        $this->pragmas   = array();
     }
 
     /**
@@ -270,6 +284,14 @@ class Mustache_Tokenizer
         return $closeIndex + strlen($close) - 1;
     }
 
+    private function addPragma($text, $index)
+    {
+        $end = strpos($text, $this->ctag, $index);
+        $this->pragmas[] = trim(substr($text, $index + 2, $end - $index - 2));
+
+        return $end + strlen($this->ctag) - 1;
+    }
+
     /**
      * Test whether it's time to change tags.
      *

+ 80 - 2
test/Mustache/Test/EngineTest.php

@@ -27,11 +27,14 @@ class Mustache_Test_EngineTest extends PHPUnit_Framework_TestCase
 
     public function testConstructor()
     {
+        $logger         = new Mustache_Logger_StreamLogger(tmpfile());
         $loader         = new Mustache_Loader_StringLoader;
         $partialsLoader = new Mustache_Loader_ArrayLoader;
         $mustache       = new Mustache_Engine(array(
             'template_class_prefix' => '__whot__',
-            'cache' => self::$tempDir,
+            'cache'  => self::$tempDir,
+            'cache_file_mode' => 777,
+            'logger' => $logger,
             'loader' => $loader,
             'partials_loader' => $partialsLoader,
             'partials' => array(
@@ -45,6 +48,7 @@ class Mustache_Test_EngineTest extends PHPUnit_Framework_TestCase
             'charset' => 'ISO-8859-1',
         ));
 
+        $this->assertSame($logger, $mustache->getLogger());
         $this->assertSame($loader, $mustache->getLoader());
         $this->assertSame($partialsLoader, $mustache->getPartialsLoader());
         $this->assertEquals('{{ foo }}', $partialsLoader->load('foo'));
@@ -85,12 +89,17 @@ class Mustache_Test_EngineTest extends PHPUnit_Framework_TestCase
 
     public function testSettingServices()
     {
+        $logger    = new Mustache_Logger_StreamLogger(tmpfile());
         $loader    = new Mustache_Loader_StringLoader;
         $tokenizer = new Mustache_Tokenizer;
         $parser    = new Mustache_Parser;
         $compiler  = new Mustache_Compiler;
         $mustache  = new Mustache_Engine;
 
+        $this->assertNotSame($logger, $mustache->getLogger());
+        $mustache->setLogger($logger);
+        $this->assertSame($logger, $mustache->getLogger());
+
         $this->assertNotSame($loader, $mustache->getLoader());
         $mustache->setLoader($loader);
         $this->assertSame($loader, $mustache->getLoader());
@@ -222,6 +231,74 @@ class Mustache_Test_EngineTest extends PHPUnit_Framework_TestCase
         $mustache->setHelpers('monkeymonkeymonkey');
     }
 
+    /**
+     * @expectedException InvalidArgumentException
+     */
+    public function testSetLoggerThrowsExceptions()
+    {
+        $mustache = new Mustache_Engine;
+        $mustache->setLogger(new StdClass);
+    }
+
+    public function testPartialLoadFailLogging()
+    {
+        $name     = tempnam(sys_get_temp_dir(), 'mustache-test');
+        $mustache = new Mustache_Engine(array(
+            'logger'   => new Mustache_Logger_StreamLogger($name, Mustache_Logger::WARNING),
+            'partials' => array(
+                'foo' => 'FOO',
+                'bar' => 'BAR',
+            ),
+        ));
+
+        $result = $mustache->render('{{> foo }}{{> bar }}{{> baz }}', array());
+        $this->assertEquals('FOOBAR', $result);
+
+        $this->assertContains('WARNING: Partial not found: "baz"', file_get_contents($name));
+    }
+
+    public function testCacheWarningLogging()
+    {
+        $name     = tempnam(sys_get_temp_dir(), 'mustache-test');
+        $mustache = new Mustache_Engine(array(
+            'logger'   => new Mustache_Logger_StreamLogger($name, Mustache_Logger::WARNING)
+        ));
+
+        $result = $mustache->render('{{ foo }}', array('foo' => 'FOO'));
+        $this->assertEquals('FOO', $result);
+
+        $this->assertContains('WARNING: Template cache disabled, evaluating', file_get_contents($name));
+    }
+
+    public function testLoggingIsNotTooAnnoying()
+    {
+        $name     = tempnam(sys_get_temp_dir(), 'mustache-test');
+        $mustache = new Mustache_Engine(array(
+            'logger'   => new Mustache_Logger_StreamLogger($name)
+        ));
+
+        $result = $mustache->render('{{ foo }}{{> bar }}', array('foo' => 'FOO'));
+        $this->assertEquals('FOO', $result);
+
+        $this->assertEmpty(file_get_contents($name));
+    }
+
+    public function testVerboseLoggingIsVerbose()
+    {
+        $name     = tempnam(sys_get_temp_dir(), 'mustache-test');
+        $mustache = new Mustache_Engine(array(
+            'logger'   => new Mustache_Logger_StreamLogger($name, Mustache_Logger::DEBUG)
+        ));
+
+        $result = $mustache->render('{{ foo }}{{> bar }}', array('foo' => 'FOO'));
+        $this->assertEquals('FOO', $result);
+
+        $log = file_get_contents($name);
+
+        $this->assertContains("DEBUG: Instantiating template: ", $log);
+        $this->assertContains("WARNING: Partial not found: \"bar\"", $log);
+    }
+
     private static function rmdir($path)
     {
         $path = rtrim($path, '/').'/';
@@ -244,7 +321,8 @@ class Mustache_Test_EngineTest extends PHPUnit_Framework_TestCase
     }
 }
 
-class MustacheStub extends Mustache_Engine {
+class MustacheStub extends Mustache_Engine
+{
     public $source;
     public $template;
     public function loadTemplate($source)

+ 30 - 0
test/Mustache/Test/FiveThree/Functional/ClosureQuirksTest.php

@@ -0,0 +1,30 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * @group lambdas
+ * @group functional
+ */
+class Mustache_Test_FiveThree_Functional_ClosuresQuirksTest extends PHPUnit_Framework_TestCase
+{
+    private $mustache;
+
+    public function setUp()
+    {
+        $this->mustache = new Mustache_Engine;
+    }
+
+    public function testClosuresDontLikeItWhenYouTouchTheirProperties()
+    {
+        $tpl = $this->mustache->loadTemplate('{{ foo.bar }}');
+        $this->assertEquals('', $tpl->render(array('foo' => function() { return 'FOO'; })));
+    }
+}

+ 94 - 0
test/Mustache/Test/FiveThree/Functional/FiltersTest.php

@@ -0,0 +1,94 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * @group filters
+ * @group functional
+ */
+class Mustache_Test_FiveThree_Functional_FiltersTest extends PHPUnit_Framework_TestCase
+{
+
+    private $mustache;
+
+    public function setUp()
+    {
+        $this->mustache = new Mustache_Engine;
+    }
+
+    public function testSingleFilter()
+    {
+        $tpl = $this->mustache->loadTemplate('{{% FILTERS }}{{ date | longdate }}');
+
+        $this->mustache->addHelper('longdate', function(\DateTime $value) {
+            return $value->format('Y-m-d h:m:s');
+        });
+
+        $foo = new \StdClass;
+        $foo->date = new DateTime('1/1/2000');
+
+        $this->assertEquals('2000-01-01 12:01:00', $tpl->render($foo));
+    }
+
+    public function testChainedFilters()
+    {
+        $tpl = $this->mustache->loadTemplate('{{% FILTERS }}{{ date | longdate | withbrackets }}');
+
+        $this->mustache->addHelper('longdate', function(\DateTime $value) {
+            return $value->format('Y-m-d h:m:s');
+        });
+
+        $this->mustache->addHelper('withbrackets', function($value) {
+            return sprintf('[[%s]]', $value);
+        });
+
+        $foo = new \StdClass;
+        $foo->date = new DateTime('1/1/2000');
+
+        $this->assertEquals('[[2000-01-01 12:01:00]]', $tpl->render($foo));
+    }
+
+    public function testInterpolateFirst()
+    {
+        $tpl = $this->mustache->loadTemplate('{{% FILTERS }}{{ foo | bar }}');
+        $this->assertEquals('win!', $tpl->render(array(
+            'foo' => 'FOO',
+            'bar' => function($value) {
+                return ($value === 'FOO') ? 'win!' : 'fail :(';
+            },
+        )));
+    }
+
+    /**
+     * @expectedException UnexpectedValueException
+     * @dataProvider getBrokenPipes
+     */
+    public function testThrowsExceptionForBrokenPipes($tpl, $data)
+    {
+        $this->mustache
+            ->loadTemplate(sprintf('{{%% FILTERS }}{{ %s }}', $tpl))
+                ->render($data);
+    }
+
+    public function getBrokenPipes()
+    {
+        return array(
+            array('foo | bar', array()),
+            array('foo | bar', array('foo' => 'FOO')),
+            array('foo | bar', array('foo' => 'FOO', 'bar' => 'BAR')),
+            array('foo | bar', array('foo' => 'FOO', 'bar' => array(1, 2))),
+            array('foo | bar | baz', array('foo' => 'FOO', 'bar' => function() { return 'BAR'; })),
+            array('foo | bar | baz', array('foo' => 'FOO', 'baz' => function() { return 'BAZ'; })),
+            array('foo | bar | baz', array('bar' => function() { return 'BAR'; })),
+            array('foo | bar | baz', array('baz' => function() { return 'BAZ'; })),
+            array('foo | bar.baz', array('foo' => 'FOO', 'bar' => function() { return 'BAR'; }, 'baz' => function() { return 'BAZ'; })),
+        );
+    }
+}

+ 48 - 42
test/Mustache/Test/FiveThree/Functional/HigherOrderSectionsTest.php

@@ -13,59 +13,65 @@
  * @group lambdas
  * @group functional
  */
-class Mustache_Test_FiveThree_Functional_HigherOrderSectionsTest extends PHPUnit_Framework_TestCase {
+class Mustache_Test_FiveThree_Functional_HigherOrderSectionsTest extends PHPUnit_Framework_TestCase
+{
+    private $mustache;
 
-	private $mustache;
+    public function setUp()
+    {
+        $this->mustache = new Mustache_Engine;
+    }
 
-	public function setUp() {
-		$this->mustache = new Mustache_Engine;
-	}
+    public function testAnonymousFunctionSectionCallback()
+    {
+        $tpl = $this->mustache->loadTemplate('{{#wrapper}}{{name}}{{/wrapper}}');
 
-	public function testAnonymousFunctionSectionCallback() {
-		$tpl = $this->mustache->loadTemplate('{{#wrapper}}{{name}}{{/wrapper}}');
+        $foo = new Mustache_Test_FiveThree_Functional_Foo;
+        $foo->name = 'Mario';
+        $foo->wrapper = function($text) {
+            return sprintf('<div class="anonymous">%s</div>', $text);
+        };
 
-		$foo = new Mustache_Test_FiveThree_Functional_Foo;
-		$foo->name = 'Mario';
-		$foo->wrapper = function($text) {
-			return sprintf('<div class="anonymous">%s</div>', $text);
-		};
+        $this->assertEquals(sprintf('<div class="anonymous">%s</div>', $foo->name), $tpl->render($foo));
+    }
 
-		$this->assertEquals(sprintf('<div class="anonymous">%s</div>', $foo->name), $tpl->render($foo));
-	}
+    public function testSectionCallback()
+    {
+        $one = $this->mustache->loadTemplate('{{name}}');
+        $two = $this->mustache->loadTemplate('{{#wrap}}{{name}}{{/wrap}}');
 
-	public function testSectionCallback() {
-		$one = $this->mustache->loadTemplate('{{name}}');
-		$two = $this->mustache->loadTemplate('{{#wrap}}{{name}}{{/wrap}}');
+        $foo = new Mustache_Test_FiveThree_Functional_Foo;
+        $foo->name = 'Luigi';
 
-		$foo = new Mustache_Test_FiveThree_Functional_Foo;
-		$foo->name = 'Luigi';
+        $this->assertEquals($foo->name, $one->render($foo));
+        $this->assertEquals(sprintf('<em>%s</em>', $foo->name), $two->render($foo));
+    }
 
-		$this->assertEquals($foo->name, $one->render($foo));
-		$this->assertEquals(sprintf('<em>%s</em>', $foo->name), $two->render($foo));
-	}
+    public function testViewArrayAnonymousSectionCallback()
+    {
+        $tpl = $this->mustache->loadTemplate('{{#wrap}}{{name}}{{/wrap}}');
 
-	public function testViewArrayAnonymousSectionCallback() {
-		$tpl = $this->mustache->loadTemplate('{{#wrap}}{{name}}{{/wrap}}');
+        $data = array(
+            'name' => 'Bob',
+            'wrap' => function($text) {
+                return sprintf('[[%s]]', $text);
+            }
+        );
 
-		$data = array(
-			'name' => 'Bob',
-			'wrap' => function($text) {
-				return sprintf('[[%s]]', $text);
-			}
-		);
-
-		$this->assertEquals(sprintf('[[%s]]', $data['name']), $tpl->render($data));
-	}
+        $this->assertEquals(sprintf('[[%s]]', $data['name']), $tpl->render($data));
+    }
 }
 
-class Mustache_Test_FiveThree_Functional_Foo {
-	public $name  = 'Justin';
-	public $lorem = 'Lorem ipsum dolor sit amet,';
-	public $wrap;
+class Mustache_Test_FiveThree_Functional_Foo
+{
+    public $name  = 'Justin';
+    public $lorem = 'Lorem ipsum dolor sit amet,';
+    public $wrap;
 
-	public function __construct() {
-		$this->wrap = function($text) {
-			return sprintf('<em>%s</em>', $text);
-		};
-	}
+    public function __construct()
+    {
+        $this->wrap = function($text) {
+            return sprintf('<em>%s</em>', $text);
+        };
+    }
 }

+ 39 - 0
test/Mustache/Test/FiveThree/Functional/LambdaHelperTest.php

@@ -0,0 +1,39 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * @group lambdas
+ * @group functional
+ */
+class Mustache_Test_FiveThree_Functional_LambdaHelperTest extends PHPUnit_Framework_TestCase
+{
+    private $mustache;
+
+    public function setUp()
+    {
+        $this->mustache = new Mustache_Engine;
+    }
+
+    public function testSectionLambdaHelper()
+    {
+        $one = $this->mustache->loadTemplate('{{name}}');
+        $two = $this->mustache->loadTemplate('{{#lambda}}{{name}}{{/lambda}}');
+
+        $foo = new StdClass;
+        $foo->name = 'Mario';
+        $foo->lambda = function($text, $mustache) {
+            return strtoupper($mustache->render($text));
+        };
+
+        $this->assertEquals('Mario', $one->render($foo));
+        $this->assertEquals('MARIO', $two->render($foo));
+    }
+}

+ 103 - 96
test/Mustache/Test/FiveThree/Functional/MustacheSpecTest.php

@@ -15,100 +15,107 @@
  * @group mustache-spec
  * @group functional
  */
-class Mustache_Test_FiveThree_Functional_MustacheSpecTest extends PHPUnit_Framework_TestCase {
-
-	private static $mustache;
-
-	public static function setUpBeforeClass() {
-		self::$mustache = new Mustache_Engine;
-	}
-
-	/**
-	 * For some reason data providers can't mark tests skipped, so this test exists
-	 * simply to provide a 'skipped' test if the `spec` submodule isn't initialized.
-	 */
-	public function testSpecInitialized() {
-		if (!file_exists(dirname(__FILE__).'/../../../../../vendor/spec/specs/')) {
-			$this->markTestSkipped('Mustache spec submodule not initialized: run "git submodule update --init"');
-		}
-	}
-
-	/**
-	 * @group lambdas
-	 * @dataProvider loadLambdasSpec
-	 */
-	public function testLambdasSpec($desc, $source, $partials, $data, $expected) {
-		$template = self::loadTemplate($source, $partials);
-		$this->assertEquals($expected, $template($this->prepareLambdasSpec($data)), $desc);
-	}
-
-	public function loadLambdasSpec() {
-		return $this->loadSpec('~lambdas');
-	}
-
-	/**
-	 * Extract and lambdafy any 'lambda' values found in the $data array.
-	 */
-	private function prepareLambdasSpec($data) {
-		foreach ($data as $key => $val) {
-			if ($key === 'lambda') {
-				if (!isset($val['php'])) {
-					$this->markTestSkipped(sprintf('PHP lambda test not implemented for this test.'));
-				}
-
-				$func = $val['php'];
-				$data[$key] = function($text = null) use ($func) {
-					return eval($func);
-				};
-			} else if (is_array($val)) {
-				$data[$key] = $this->prepareLambdasSpec($val);
-			}
-		}
-
-		return $data;
-	}
-
-	/**
-	 * Data provider for the mustache spec test.
-	 *
-	 * Loads YAML files from the spec and converts them to PHPisms.
-	 *
-	 * @access public
-	 * @return array
-	 */
-	private function loadSpec($name) {
-		$filename = dirname(__FILE__) . '/../../../../../vendor/spec/specs/' . $name . '.yml';
-		if (!file_exists($filename)) {
-			return array();
-		}
-
-		$data = array();
-		$yaml = new sfYamlParser;
-		$file = file_get_contents($filename);
-
-		// @hack: pre-process the 'lambdas' spec so the Symfony YAML parser doesn't complain.
-		if ($name === '~lambdas') {
-			$file = str_replace(" !code\n", "\n", $file);
-		}
-
-		$spec = $yaml->parse($file);
-
-		foreach ($spec['tests'] as $test) {
-			$data[] = array(
-				$test['name'] . ': ' . $test['desc'],
-				$test['template'],
-				isset($test['partials']) ? $test['partials'] : array(),
-				$test['data'],
-				$test['expected'],
-			);
-		}
-
-		return $data;
-	}
-
-	private static function loadTemplate($source, $partials) {
-		self::$mustache->setPartials($partials);
-
-		return self::$mustache->loadTemplate($source);
-	}
+class Mustache_Test_FiveThree_Functional_MustacheSpecTest extends PHPUnit_Framework_TestCase
+{
+    private static $mustache;
+
+    public static function setUpBeforeClass()
+    {
+        self::$mustache = new Mustache_Engine;
+    }
+
+    /**
+     * For some reason data providers can't mark tests skipped, so this test exists
+     * simply to provide a 'skipped' test if the `spec` submodule isn't initialized.
+     */
+    public function testSpecInitialized()
+    {
+        if (!file_exists(dirname(__FILE__).'/../../../../../vendor/spec/specs/')) {
+            $this->markTestSkipped('Mustache spec submodule not initialized: run "git submodule update --init"');
+        }
+    }
+
+    /**
+     * @group lambdas
+     * @dataProvider loadLambdasSpec
+     */
+    public function testLambdasSpec($desc, $source, $partials, $data, $expected)
+    {
+        $template = self::loadTemplate($source, $partials);
+        $this->assertEquals($expected, $template($this->prepareLambdasSpec($data)), $desc);
+    }
+
+    public function loadLambdasSpec()
+    {
+        return $this->loadSpec('~lambdas');
+    }
+
+    /**
+     * Extract and lambdafy any 'lambda' values found in the $data array.
+     */
+    private function prepareLambdasSpec($data)
+    {
+        foreach ($data as $key => $val) {
+            if ($key === 'lambda') {
+                if (!isset($val['php'])) {
+                    $this->markTestSkipped(sprintf('PHP lambda test not implemented for this test.'));
+                }
+
+                $func = $val['php'];
+                $data[$key] = function($text = null) use ($func) {
+                    return eval($func);
+                };
+            } elseif (is_array($val)) {
+                $data[$key] = $this->prepareLambdasSpec($val);
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Data provider for the mustache spec test.
+     *
+     * Loads YAML files from the spec and converts them to PHPisms.
+     *
+     * @access public
+     * @return array
+     */
+    private function loadSpec($name)
+    {
+        $filename = dirname(__FILE__) . '/../../../../../vendor/spec/specs/' . $name . '.yml';
+        if (!file_exists($filename)) {
+            return array();
+        }
+
+        $data = array();
+        $yaml = new sfYamlParser;
+        $file = file_get_contents($filename);
+
+        // @hack: pre-process the 'lambdas' spec so the Symfony YAML parser doesn't complain.
+        if ($name === '~lambdas') {
+            $file = str_replace(" !code\n", "\n", $file);
+        }
+
+        $spec = $yaml->parse($file);
+
+        foreach ($spec['tests'] as $test) {
+            $data[] = array(
+                $test['name'] . ': ' . $test['desc'],
+                $test['template'],
+                isset($test['partials']) ? $test['partials'] : array(),
+                $test['data'],
+                $test['expected'],
+            );
+        }
+
+        return $data;
+    }
+
+    private static function loadTemplate($source, $partials)
+    {
+        self::$mustache->setPartials($partials);
+
+        return self::$mustache->loadTemplate($source);
+    }
 }

+ 1 - 1
test/Mustache/Test/Functional/ExamplesTest.php

@@ -116,7 +116,7 @@ class Mustache_Test_Functional_ExamplesTest extends PHPUnit_Framework_TestCase
      *
      * @param string $path
      *
-     * @return array  $partials
+     * @return array $partials
      */
     private function loadPartials($path)
     {

+ 0 - 3
test/Mustache/Test/Functional/MustacheInjectionTest.php

@@ -49,7 +49,6 @@ class Mustache_Test_Functional_MustacheInjectionTest extends PHPUnit_Framework_T
         $this->assertEquals('{{ b }}', $tpl->render($data));
     }
 
-
     // sections
 
     public function testSectionInjection()
@@ -78,7 +77,6 @@ class Mustache_Test_Functional_MustacheInjectionTest extends PHPUnit_Framework_T
         $this->assertEquals('{{ c }}', $tpl->render($data));
     }
 
-
     // partials
 
     public function testPartialInjection()
@@ -111,7 +109,6 @@ class Mustache_Test_Functional_MustacheInjectionTest extends PHPUnit_Framework_T
         $this->assertEquals('{{ b }}', $tpl->render($data));
     }
 
-
     // lambdas
 
     public function testLambdaInterpolationInjection()

+ 13 - 0
test/Mustache/Test/Loader/FilesystemLoaderTest.php

@@ -30,6 +30,19 @@ class Mustache_Test_Loader_FilesystemLoaderTest extends PHPUnit_Framework_TestCa
         $this->assertEquals('two contents', $loader->load('two.mustache'));
     }
 
+    public function testEmptyExtensionString()
+    {
+        $baseDir = realpath(dirname(__FILE__).'/../../../fixtures/templates');
+
+        $loader = new Mustache_Loader_FilesystemLoader($baseDir, array('extension' => ''));
+        $this->assertEquals('one contents', $loader->load('one.mustache'));
+        $this->assertEquals('alpha contents', $loader->load('alpha.ms'));
+
+        $loader = new Mustache_Loader_FilesystemLoader($baseDir, array('extension' => null));
+        $this->assertEquals('two contents', $loader->load('two.mustache'));
+        $this->assertEquals('beta contents', $loader->load('beta.ms'));
+    }
+
     /**
      * @expectedException RuntimeException
      */

+ 60 - 0
test/Mustache/Test/Logger/AbstractLoggerTest.php

@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * @group unit
+ */
+class Mustache_Test_Logger_AbstractLoggerTest extends PHPUnit_Framework_TestCase
+{
+    public function testEverything()
+    {
+        $logger = new Mustache_Test_Logger_TestLogger;
+
+        $logger->emergency('emergency message');
+        $logger->alert('alert message');
+        $logger->critical('critical message');
+        $logger->error('error message');
+        $logger->warning('warning message');
+        $logger->notice('notice message');
+        $logger->info('info message');
+        $logger->debug('debug message');
+
+        $expected = array(
+            array(Mustache_Logger::EMERGENCY, 'emergency message', array()),
+            array(Mustache_Logger::ALERT, 'alert message', array()),
+            array(Mustache_Logger::CRITICAL, 'critical message', array()),
+            array(Mustache_Logger::ERROR, 'error message', array()),
+            array(Mustache_Logger::WARNING, 'warning message', array()),
+            array(Mustache_Logger::NOTICE, 'notice message', array()),
+            array(Mustache_Logger::INFO, 'info message', array()),
+            array(Mustache_Logger::DEBUG, 'debug message', array()),
+        );
+
+        $this->assertEquals($expected, $logger->log);
+    }
+}
+
+class Mustache_Test_Logger_TestLogger extends Mustache_Logger_AbstractLogger
+{
+    public $log = array();
+
+    /**
+     * Logs with an arbitrary level.
+     *
+     * @param mixed $level
+     * @param string $message
+     * @param array $context
+     */
+    public function log($level, $message, array $context = array())
+    {
+        $this->log[] = array($level, $message, $context);
+    }
+}

+ 206 - 0
test/Mustache/Test/Logger/StreamLoggerTest.php

@@ -0,0 +1,206 @@
+<?php
+
+/*
+ * This file is part of Mustache.php.
+ *
+ * (c) 2012 Justin Hileman
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * @group unit
+ */
+class Mustache_Test_Logger_StreamLoggerTest extends PHPUnit_Framework_TestCase
+{
+    public function testAcceptsFilename()
+    {
+        $name   = tempnam(sys_get_temp_dir(), 'mustache-test');
+        $logger = new Mustache_Logger_StreamLogger($name);
+        $logger->log(Mustache_Logger::CRITICAL, 'message');
+
+        $this->assertEquals("CRITICAL: message\n", file_get_contents($name));
+    }
+
+    public function testAcceptsResource()
+    {
+        $name   = tempnam(sys_get_temp_dir(), 'mustache-test');
+        $file   = fopen($name, 'a');
+        $logger = new Mustache_Logger_StreamLogger($file);
+        $logger->log(Mustache_Logger::CRITICAL, 'message');
+
+        $this->assertEquals("CRITICAL: message\n", file_get_contents($name));
+    }
+
+    /**
+     * @expectedException LogicException
+     */
+    public function testPrematurelyClosedStreamThrowsException()
+    {
+        $stream = tmpfile();
+        $logger = new Mustache_Logger_StreamLogger($stream);
+        fclose($stream);
+
+        $logger->log(Mustache_Logger::CRITICAL, 'message');
+    }
+
+    /**
+     * @dataProvider getLevels
+     */
+    public function testLoggingThresholds($logLevel, $level, $shouldLog)
+    {
+        $stream = tmpfile();
+        $logger = new Mustache_Logger_StreamLogger($stream, $logLevel);
+        $logger->log($level, "logged");
+
+        rewind($stream);
+        $result = fread($stream, 1024);
+
+        if ($shouldLog) {
+            $this->assertContains("logged", $result);
+        } else {
+            $this->assertEmpty($result);
+        }
+    }
+
+    public function getLevels()
+    {
+        // $logLevel, $level, $shouldLog
+        return array(
+            // identities
+            array(Mustache_Logger::EMERGENCY, Mustache_Logger::EMERGENCY, true),
+            array(Mustache_Logger::ALERT,     Mustache_Logger::ALERT,     true),
+            array(Mustache_Logger::CRITICAL,  Mustache_Logger::CRITICAL,  true),
+            array(Mustache_Logger::ERROR,     Mustache_Logger::ERROR,     true),
+            array(Mustache_Logger::WARNING,   Mustache_Logger::WARNING,   true),
+            array(Mustache_Logger::NOTICE,    Mustache_Logger::NOTICE,    true),
+            array(Mustache_Logger::INFO,      Mustache_Logger::INFO,      true),
+            array(Mustache_Logger::DEBUG,     Mustache_Logger::DEBUG,     true),
+
+            // one above
+            array(Mustache_Logger::ALERT,     Mustache_Logger::EMERGENCY, true),
+            array(Mustache_Logger::CRITICAL,  Mustache_Logger::ALERT,     true),
+            array(Mustache_Logger::ERROR,     Mustache_Logger::CRITICAL,  true),
+            array(Mustache_Logger::WARNING,   Mustache_Logger::ERROR,     true),
+            array(Mustache_Logger::NOTICE,    Mustache_Logger::WARNING,   true),
+            array(Mustache_Logger::INFO,      Mustache_Logger::NOTICE,    true),
+            array(Mustache_Logger::DEBUG,     Mustache_Logger::INFO,      true),
+
+            // one below
+            array(Mustache_Logger::EMERGENCY, Mustache_Logger::ALERT,     false),
+            array(Mustache_Logger::ALERT,     Mustache_Logger::CRITICAL,  false),
+            array(Mustache_Logger::CRITICAL,  Mustache_Logger::ERROR,     false),
+            array(Mustache_Logger::ERROR,     Mustache_Logger::WARNING,   false),
+            array(Mustache_Logger::WARNING,   Mustache_Logger::NOTICE,    false),
+            array(Mustache_Logger::NOTICE,    Mustache_Logger::INFO,      false),
+            array(Mustache_Logger::INFO,      Mustache_Logger::DEBUG,     false),
+        );
+    }
+
+    /**
+     * @dataProvider getLogMessages
+     */
+    public function testLogging($level, $message, $context, $expected)
+    {
+        $stream = tmpfile();
+        $logger = new Mustache_Logger_StreamLogger($stream, Mustache_Logger::DEBUG);
+        $logger->log($level, $message, $context);
+
+        rewind($stream);
+        $result = fread($stream, 1024);
+
+        $this->assertEquals($expected, $result);
+    }
+
+    public function getLogMessages()
+    {
+        // $level, $message, $context, $expected
+        return array(
+            array(Mustache_Logger::DEBUG,     'debug message',     array(),  "DEBUG: debug message\n"),
+            array(Mustache_Logger::INFO,      'info message',      array(),  "INFO: info message\n"),
+            array(Mustache_Logger::NOTICE,    'notice message',    array(),  "NOTICE: notice message\n"),
+            array(Mustache_Logger::WARNING,   'warning message',   array(),  "WARNING: warning message\n"),
+            array(Mustache_Logger::ERROR,     'error message',     array(),  "ERROR: error message\n"),
+            array(Mustache_Logger::CRITICAL,  'critical message',  array(),  "CRITICAL: critical message\n"),
+            array(Mustache_Logger::ALERT,     'alert message',     array(),  "ALERT: alert message\n"),
+            array(Mustache_Logger::EMERGENCY, 'emergency message', array(),  "EMERGENCY: emergency message\n"),
+
+            // with context
+            array(
+                Mustache_Logger::ERROR,
+                'error message',
+                array('name' => 'foo', 'number' => 42),
+                "ERROR: error message\n"
+            ),
+
+            // with interpolation
+            array(
+                Mustache_Logger::ERROR,
+                'error {name}-{number}',
+                array('name' => 'foo', 'number' => 42),
+                "ERROR: error foo-42\n"
+            ),
+
+            // with iterpolation false positive
+            array(
+                Mustache_Logger::ERROR,
+                'error {nothing}',
+                array('name' => 'foo', 'number' => 42),
+                "ERROR: error {nothing}\n"
+            ),
+
+            // with interpolation injection
+            array(
+                Mustache_Logger::ERROR,
+                '{foo}',
+                array('foo' => '{bar}', 'bar' => 'FAIL'),
+                "ERROR: {bar}\n"
+            ),
+        );
+    }
+
+    public function testChangeLoggingLevels()
+    {
+        $stream = tmpfile();
+        $logger = new Mustache_Logger_StreamLogger($stream);
+
+        $logger->setLevel(Mustache_Logger::ERROR);
+        $this->assertEquals(Mustache_Logger::ERROR, $logger->getLevel());
+
+        $logger->log(Mustache_Logger::WARNING, 'ignore this');
+
+        $logger->setLevel(Mustache_Logger::INFO);
+        $this->assertEquals(Mustache_Logger::INFO, $logger->getLevel());
+
+        $logger->log(Mustache_Logger::WARNING, 'log this');
+
+        $logger->setLevel(Mustache_Logger::CRITICAL);
+        $this->assertEquals(Mustache_Logger::CRITICAL, $logger->getLevel());
+
+        $logger->log(Mustache_Logger::ERROR, 'ignore this');
+
+        rewind($stream);
+        $result = fread($stream, 1024);
+
+        $this->assertEquals("WARNING: log this\n", $result);
+    }
+
+    /**
+     * @expectedException InvalidArgumentException
+     */
+    public function testThrowsInvalidArgumentExceptionWhenSettingUnknownLevels()
+    {
+        $logger = new Mustache_Logger_StreamLogger(tmpfile());
+        $logger->setLevel('bacon');
+    }
+
+    /**
+     * @expectedException InvalidArgumentException
+     */
+    public function testThrowsInvalidArgumentExceptionWhenLoggingUnknownLevels()
+    {
+        $logger = new Mustache_Logger_StreamLogger(tmpfile());
+        $logger->log('bacon', 'CODE BACON ERROR!');
+    }
+}

+ 7 - 6
test/fixtures/examples/partials/Partials.php

@@ -1,9 +1,10 @@
 <?php
 
-class Partials {
-	public $page = array(
-		'title'    => 'Page Title',
-		'subtitle' => 'Page Subtitle',
-		'content'  => 'Lorem ipsum dolor sit amet.',
-	);
+class Partials
+{
+    public $page = array(
+        'title'    => 'Page Title',
+        'subtitle' => 'Page Subtitle',
+        'content'  => 'Lorem ipsum dolor sit amet.',
+    );
 }