diff --git a/src/Mustache/Compiler.php b/src/Mustache/Compiler.php index 93a295a..2b0d1f9 100644 --- a/src/Mustache/Compiler.php +++ b/src/Mustache/Compiler.php @@ -117,6 +117,7 @@ class Mustache_Compiler case Mustache_Tokenizer::T_PARTIAL: $code .= $this->partial( $node[Mustache_Tokenizer::NAME], + isset($node[Mustache_Tokenizer::DYNAMIC]) ? $node[Mustache_Tokenizer::DYNAMIC] : false, isset($node[Mustache_Tokenizer::INDENT]) ? $node[Mustache_Tokenizer::INDENT] : '', $level ); @@ -125,6 +126,7 @@ class Mustache_Compiler case Mustache_Tokenizer::T_PARENT: $code .= $this->parent( $node[Mustache_Tokenizer::NAME], + isset($node[Mustache_Tokenizer::DYNAMIC]) ? $node[Mustache_Tokenizer::DYNAMIC] : false, isset($node[Mustache_Tokenizer::INDENT]) ? $node[Mustache_Tokenizer::INDENT] : '', $node[Mustache_Tokenizer::NODES], $level @@ -419,6 +421,30 @@ class Mustache_Compiler return sprintf($this->prepare(self::INVERTED_SECTION, $level), $method, $id, $filters, $this->walk($nodes, $level)); } + const DYNAMIC_NAME = '$this->resolveValue($context->%s(%s), $context)'; + + /** + * Generate Mustache Template dynamic name resolution PHP source. + * + * @param string $id Tag name + * @param bool $dynamic True if the name is dynamic + * + * @return string Dynamic name resolution PHP source code + */ + private function resolveDynamicName($id, $dynamic) + { + if (!$dynamic) { + return var_export($id, true); + } + + $method = $this->getFindMethod($id); + $id = ($method !== 'last') ? var_export($id, true) : ''; + + // TODO: filters? + + return sprintf(self::DYNAMIC_NAME, $method, $id); + } + const PARTIAL_INDENT = ', $indent . %s'; const PARTIAL = ' if ($partial = $this->mustache->loadPartial(%s)) { @@ -429,13 +455,14 @@ class Mustache_Compiler /** * Generate Mustache Template partial call PHP source. * - * @param string $id Partial name - * @param string $indent Whitespace indent to apply to partial + * @param string $id Partial name + * @param bool $dynamic Partial name is dynamic + * @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) + private function partial($id, $dynamic, $indent, $level) { if ($indent !== '') { $indentParam = sprintf(self::PARTIAL_INDENT, var_export($indent, true)); @@ -445,7 +472,7 @@ class Mustache_Compiler return sprintf( $this->prepare(self::PARTIAL, $level), - var_export($id, true), + $this->resolveDynamicName($id, $dynamic), $indentParam ); } @@ -469,23 +496,25 @@ class Mustache_Compiler * Generate Mustache Template inheritance parent call PHP source. * * @param string $id Parent tag name + * @param bool $dynamic Tag name is dynamic * @param string $indent Whitespace indent to apply to parent * @param array $children Child nodes * @param int $level * * @return string Generated PHP source code */ - private function parent($id, $indent, array $children, $level) + private function parent($id, $dynamic, $indent, array $children, $level) { $realChildren = array_filter($children, array(__CLASS__, 'onlyBlockArgs')); + $partialName = $this->resolveDynamicName($id, $dynamic); if (empty($realChildren)) { - return sprintf($this->prepare(self::PARENT_NO_CONTEXT, $level), var_export($id, true)); + return sprintf($this->prepare(self::PARENT_NO_CONTEXT, $level), $partialName); } return sprintf( $this->prepare(self::PARENT, $level), - var_export($id, true), + $partialName, $this->walk($realChildren, $level + 1) ); } diff --git a/src/Mustache/Engine.php b/src/Mustache/Engine.php index bbdc975..7a31ac0 100644 --- a/src/Mustache/Engine.php +++ b/src/Mustache/Engine.php @@ -23,18 +23,20 @@ */ class Mustache_Engine { - const VERSION = '2.14.2'; - const SPEC_VERSION = '1.2.2'; + const VERSION = '2.14.2'; + const SPEC_VERSION = '1.3.0'; - const PRAGMA_FILTERS = 'FILTERS'; - const PRAGMA_BLOCKS = 'BLOCKS'; - const PRAGMA_ANCHORED_DOT = 'ANCHORED-DOT'; + const PRAGMA_FILTERS = 'FILTERS'; + const PRAGMA_BLOCKS = 'BLOCKS'; + const PRAGMA_ANCHORED_DOT = 'ANCHORED-DOT'; + const PRAGMA_DYNAMIC_NAMES = 'DYNAMIC-NAMES'; // Known pragmas private static $knownPragmas = array( - self::PRAGMA_FILTERS => true, - self::PRAGMA_BLOCKS => true, - self::PRAGMA_ANCHORED_DOT => true, + self::PRAGMA_FILTERS => true, + self::PRAGMA_BLOCKS => true, + self::PRAGMA_ANCHORED_DOT => true, + self::PRAGMA_DYNAMIC_NAMES => true, ); // Template cache diff --git a/src/Mustache/Parser.php b/src/Mustache/Parser.php index 0ec4192..02e3d89 100644 --- a/src/Mustache/Parser.php +++ b/src/Mustache/Parser.php @@ -23,6 +23,7 @@ class Mustache_Parser private $pragmaFilters; private $pragmaBlocks; + private $pragmaDynamicNames; /** * Process an array of Mustache tokens and convert them into a parse tree. @@ -37,8 +38,9 @@ class Mustache_Parser $this->lineTokens = 0; $this->pragmas = $this->defaultPragmas; - $this->pragmaFilters = isset($this->pragmas[Mustache_Engine::PRAGMA_FILTERS]); - $this->pragmaBlocks = isset($this->pragmas[Mustache_Engine::PRAGMA_BLOCKS]); + $this->pragmaFilters = isset($this->pragmas[Mustache_Engine::PRAGMA_FILTERS]); + $this->pragmaBlocks = isset($this->pragmas[Mustache_Engine::PRAGMA_BLOCKS]); + $this->pragmaDynamicNames = isset($this->pragmas[Mustache_Engine::PRAGMA_DYNAMIC_NAMES]); return $this->buildTree($tokens); } @@ -84,11 +86,21 @@ class Mustache_Parser $this->lineTokens = 0; } - if ($this->pragmaFilters && isset($token[Mustache_Tokenizer::NAME])) { - list($name, $filters) = $this->getNameAndFilters($token[Mustache_Tokenizer::NAME]); - if (!empty($filters)) { - $token[Mustache_Tokenizer::NAME] = $name; - $token[Mustache_Tokenizer::FILTERS] = $filters; + if ($token[Mustache_Tokenizer::TYPE] !== Mustache_Tokenizer::T_COMMENT) { + if ($this->pragmaDynamicNames && isset($token[Mustache_Tokenizer::NAME])) { + list($name, $isDynamic) = $this->getDynamicName($token); + if ($isDynamic) { + $token[Mustache_Tokenizer::NAME] = $name; + $token[Mustache_Tokenizer::DYNAMIC] = true; + } + } + + if ($this->pragmaFilters && isset($token[Mustache_Tokenizer::NAME])) { + list($name, $filters) = $this->getNameAndFilters($token[Mustache_Tokenizer::NAME]); + if (!empty($filters)) { + $token[Mustache_Tokenizer::NAME] = $name; + $token[Mustache_Tokenizer::FILTERS] = $filters; + } } } @@ -115,7 +127,11 @@ class Mustache_Parser throw new Mustache_Exception_SyntaxException($msg, $token); } - if ($token[Mustache_Tokenizer::NAME] !== $parent[Mustache_Tokenizer::NAME]) { + $sameName = $token[Mustache_Tokenizer::NAME] !== $parent[Mustache_Tokenizer::NAME]; + $tokenDynamic = isset($token[Mustache_Tokenizer::DYNAMIC]) && $token[Mustache_Tokenizer::DYNAMIC]; + $parentDynamic = isset($parent[Mustache_Tokenizer::DYNAMIC]) && $parent[Mustache_Tokenizer::DYNAMIC]; + + if ($sameName || ($tokenDynamic !== $parentDynamic)) { $msg = sprintf( 'Nesting error: %s (on line %d) vs. %s (on line %d)', $parent[Mustache_Tokenizer::NAME], @@ -280,6 +296,52 @@ class Mustache_Parser } } + /** + * Parse dynamic names. + * + * @throws Mustache_Exception_SyntaxException when a tag does not allow * + * @throws Mustache_Exception_SyntaxException on multiple *s, or dots or filters with * + */ + private function getDynamicName(array $token) + { + $name = $token[Mustache_Tokenizer::NAME]; + $isDynamic = false; + + if (preg_match('/^\s*\*\s*/', $name)) { + $this->ensureTagAllowsDynamicNames($token); + $name = preg_replace('/^\s*\*\s*/', '', $name); + $isDynamic = true; + } + + return array($name, $isDynamic); + } + + /** + * Check whether the given token supports dynamic tag names. + * + * @throws Mustache_Exception_SyntaxException when a tag does not allow * + * + * @param array $token + */ + private function ensureTagAllowsDynamicNames(array $token) + { + switch ($token[Mustache_Tokenizer::TYPE]) { + case Mustache_Tokenizer::T_PARTIAL: + case Mustache_Tokenizer::T_PARENT: + case Mustache_Tokenizer::T_END_SECTION: + return; + } + + $msg = sprintf( + 'Invalid dynamic name: %s in %s tag', + $token[Mustache_Tokenizer::NAME], + Mustache_Tokenizer::getTagName($token[Mustache_Tokenizer::TYPE]) + ); + + throw new Mustache_Exception_SyntaxException($msg, $token); + } + + /** * Split a tag name into name and filters. * @@ -312,6 +374,10 @@ class Mustache_Parser case Mustache_Engine::PRAGMA_FILTERS: $this->pragmaFilters = true; break; + + case Mustache_Engine::PRAGMA_DYNAMIC_NAMES: + $this->pragmaDynamicNames = true; + break; } } } diff --git a/src/Mustache/Tokenizer.php b/src/Mustache/Tokenizer.php index e59d30b..d96f129 100644 --- a/src/Mustache/Tokenizer.php +++ b/src/Mustache/Tokenizer.php @@ -53,9 +53,26 @@ class Mustache_Tokenizer self::T_BLOCK_VAR => true, ); + private static $tagNames = array( + self::T_SECTION => 'section', + self::T_INVERTED => 'inverted section', + self::T_END_SECTION => 'section end', + self::T_COMMENT => 'comment', + self::T_PARTIAL => 'partial', + self::T_PARENT => 'parent', + self::T_DELIM_CHANGE => 'set delimiter', + self::T_ESCAPED => 'variable', + self::T_UNESCAPED => 'unescaped variable', + self::T_UNESCAPED_2 => 'unescaped variable', + self::T_PRAGMA => 'pragma', + self::T_BLOCK_VAR => 'block variable', + self::T_BLOCK_ARG => 'block variable', + ); + // Token properties const TYPE = 'type'; const NAME = 'name'; + const DYNAMIC = 'dynamic'; const OTAG = 'otag'; const CTAG = 'ctag'; const LINE = 'line'; @@ -357,6 +374,7 @@ class Mustache_Tokenizer return $end + $this->ctagLen - 1; } + private function throwUnclosedTagException() { $name = trim($this->buffer); @@ -375,4 +393,16 @@ class Mustache_Tokenizer self::INDEX => $this->seenTag - $this->otagLen, )); } + + /** + * Get the human readable name for a tag type. + * + * @param string $tagType One of the tokenizer T_* constants + * + * @return string + */ + static function getTagName($tagType) + { + return isset(self::$tagNames[$tagType]) ? self::$tagNames[$tagType] : 'unknown'; + } } diff --git a/test/Mustache/Test/Functional/DynamicPartialsTest.php b/test/Mustache/Test/Functional/DynamicPartialsTest.php new file mode 100644 index 0000000..88e1fd8 --- /dev/null +++ b/test/Mustache/Test/Functional/DynamicPartialsTest.php @@ -0,0 +1,86 @@ +mustache = new Mustache_Engine(array( + 'pragmas' => array(Mustache_Engine::PRAGMA_DYNAMIC_NAMES), + )); + } + + public function getValidDynamicNamesExamples() + { + // technically not all dynamic names, but also not invalid + return array( + array('{{>* foo }}'), + array('{{>* foo.bar.baz }}'), + array('{{=* *=}}'), + array('{{! *foo }}'), + array('{{! foo.*bar }}'), + array('{{% FILTERS }}{{! foo | *bar }}'), + array('{{% BLOCKS }}{{< *foo }}{{/ *foo }}'), + ); + } + + /** + * @dataProvider getValidDynamicNamesExamples + */ + public function testLegalInheritanceExamples($template) + { + $this->assertSame('', $this->mustache->render($template)); + } + + public function getDynamicNameParseErrors() + { + return array( + array('{{# foo }}{{/ *foo }}'), + array('{{^ foo }}{{/ *foo }}'), + array('{{% BLOCKS }}{{< foo }}{{/ *foo }}'), + array('{{% BLOCKS }}{{$ foo }}{{/ *foo }}'), + ); + } + + /** + * @dataProvider getDynamicNameParseErrors + * @expectedException Mustache_Exception_SyntaxException + * @expectedExceptionMessage Nesting error: + */ + public function testDynamicNameParseErrors($template) + { + $this->mustache->render($template); + } + + + public function testDynamicBlocks() + { + $tpl = '{{% BLOCKS }}{{< *partial }}{{$ bar }}{{ value }}{{/ bar }}{{/ *partial }}'; + + $this->mustache->setPartials(array( + 'foobarbaz' => '{{% BLOCKS }}{{$ foo }}foo{{/ foo }}{{$ bar }}bar{{/ bar }}{{$ baz }}baz{{/ baz }}', + 'qux' => 'qux', + )); + + $result = $this->mustache->render($tpl, array( + 'partial' => 'foobarbaz', + 'value' => 'BAR', + )); + + $this->assertSame($result, 'fooBARbaz'); + } +} diff --git a/test/Mustache/Test/Functional/MustacheDynamicNamesSpecTest.php b/test/Mustache/Test/Functional/MustacheDynamicNamesSpecTest.php new file mode 100644 index 0000000..e26c029 --- /dev/null +++ b/test/Mustache/Test/Functional/MustacheDynamicNamesSpecTest.php @@ -0,0 +1,52 @@ + array(Mustache_Engine::PRAGMA_DYNAMIC_NAMES), + )); + } + + /** + * 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 dynamic-names + * @dataProvider loadDynamicNamesSpec + */ + public function testDynamicNamesSpec($desc, $source, $partials, $data, $expected) + { + $template = self::loadTemplate($source, $partials); + $this->assertEquals($expected, $template->render($data), $desc); + } + + public function loadDynamicNamesSpec() + { + return $this->loadSpec('~dynamic-names'); + } +} diff --git a/vendor/spec b/vendor/spec index b2aeb3c..5d3b58e 160000 --- a/vendor/spec +++ b/vendor/spec @@ -1 +1 @@ -Subproject commit b2aeb3c283de931a7004b5f7a2cb394b89382369 +Subproject commit 5d3b58ea35ae309c40d7a8111bfedc4c5bcd43a6