ENHANCEMENT: Add Up support

This commit is contained in:
Hamish Friedlander 2011-02-18 17:06:11 +13:00
parent 829bf23192
commit 71892085fc
4 changed files with 346 additions and 48 deletions

View File

@ -67,12 +67,6 @@ class SSTemplateParser extends Parser {
*/ */
protected $includeDebuggingComments = false; protected $includeDebuggingComments = false;
function construct($name) {
$result = parent::construct($name);
$result['tags'] = array();
return $result;
}
/* Word: / [A-Za-z_] [A-Za-z0-9_]* / */ /* Word: / [A-Za-z_] [A-Za-z0-9_]* / */
function match_Word ($substack = array()) { function match_Word ($substack = array()) {
$result = array("name"=>"Word", "text"=>""); $result = array("name"=>"Word", "text"=>"");
@ -169,7 +163,7 @@ class SSTemplateParser extends Parser {
if (isset($res['php'])) $res['php'] .= ', '; if (isset($res['php'])) $res['php'] .= ', ';
else $res['php'] = ''; else $res['php'] = '';
$res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : $sub['php']; $res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : str_replace('$$FINAL', 'XML_val', $sub['php']);
} }
/* Call: Method:Word ( "(" < :CallArguments? > ")" )? */ /* Call: Method:Word ( "(" < :CallArguments? > ")" )? */
@ -354,7 +348,7 @@ class SSTemplateParser extends Parser {
function Lookup__construct(&$res) { function Lookup__construct(&$res) {
$res['php'] = '$item'; $res['php'] = '$scope';
$res['LookupSteps'] = array(); $res['LookupSteps'] = array();
} }
@ -381,7 +375,7 @@ class SSTemplateParser extends Parser {
} }
function Lookup_LastLookupStep(&$res, $sub) { function Lookup_LastLookupStep(&$res, $sub) {
$this->Lookup_AddLookupStep($res, $sub, 'XML_val'); $this->Lookup_AddLookupStep($res, $sub, '$$FINAL');
} }
/* SimpleInjection: '$' :Lookup */ /* SimpleInjection: '$' :Lookup */
@ -474,7 +468,7 @@ class SSTemplateParser extends Parser {
function Injection_STR(&$res, $sub) { function Injection_STR(&$res, $sub) {
$res['php'] = '$val .= '. $sub['Lookup']['php'] . ';'; $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php']) . ';';
} }
/* DollarMarkedLookup: SimpleInjection */ /* DollarMarkedLookup: SimpleInjection */
@ -777,11 +771,11 @@ class SSTemplateParser extends Parser {
function Comparison_Argument(&$res, $sub) { function Comparison_Argument(&$res, $sub) {
if ($sub['ArgumentMode'] == 'default') { if ($sub['ArgumentMode'] == 'default') {
if (isset($res['php'])) $res['php'] .= $sub['string_php']; if (isset($res['php'])) $res['php'] .= $sub['string_php'];
else $res['php'] = $sub['lookup_php']; else $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php']);
} }
else { else {
if (!isset($res['php'])) $res['php'] = ''; if (!isset($res['php'])) $res['php'] = '';
$res['php'] .= $sub['php']; $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php']);
} }
} }
@ -811,7 +805,7 @@ class SSTemplateParser extends Parser {
$php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']);
// TODO: kinda hacky - maybe we need a way to pass state down the parse chain so // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so
// Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val
$res['php'] = str_replace('->XML_val', '->hasValue', $php); $res['php'] = str_replace('$$FINAL', 'hasValue', $php);
} }
} }
@ -1519,9 +1513,9 @@ class SSTemplateParser extends Parser {
} }
/** /**
* This is an example of a block handler function. This one handles the control tag. * This is an example of a block handler function. This one handles the loop tag.
*/ */
function ClosedBlock_Handle_Control(&$res) { function ClosedBlock_Handle_Loop(&$res) {
if ($res['ArgumentCount'] != 1) { if ($res['ArgumentCount'] != 1) {
throw new SSTemplateParseException('Either no or too many arguments in control block. Must be one argument only.', $this); throw new SSTemplateParseException('Either no or too many arguments in control block. Must be one argument only.', $this);
} }
@ -1531,11 +1525,39 @@ class SSTemplateParser extends Parser {
throw new SSTemplateParseException('Control block cant take string as argument.', $this); throw new SSTemplateParseException('Control block cant take string as argument.', $this);
} }
$on = str_replace('->XML_val', '->obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
return return
'array_push($itemStack, $item); if($loop = '.$on.') foreach($loop as $key => $item) {' . PHP_EOL . $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL .
$res['Template']['php'] . PHP_EOL . $res['Template']['php'] . PHP_EOL .
'} $item = array_pop($itemStack); '; '}; $scope->popScope(); ';
}
/**
* The deprecated closed block handler for control blocks
* @deprecated
*/
function ClosedBlock_Handle_Control(&$res) {
return $this->ClosedBlock_Handle_Loop($res);
}
/**
* The closed block handler for with blocks
*/
function ClosedBlock_Handle_With(&$res) {
if ($res['ArgumentCount'] != 1) {
throw new SSTemplateParseException('Either no or too many arguments in with block. Must be one argument only.', $this);
}
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {
throw new SSTemplateParseException('Control block cant take string as argument.', $this);
}
$on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
return
$on . '; $scope->pushScope();' . PHP_EOL .
$res['Template']['php'] . PHP_EOL .
'; $scope->popScope(); ';
} }
/* OpenBlock: '<%' < !NotBlockTag BlockName:Word ( [ :BlockArguments ] )? > '%>' */ /* OpenBlock: '<%' < !NotBlockTag BlockName:Word ( [ :BlockArguments ] )? > '%>' */
@ -1642,12 +1664,12 @@ class SSTemplateParser extends Parser {
if($this->includeDebuggingComments) { // Add include filename comments on dev sites if($this->includeDebuggingComments) { // Add include filename comments on dev sites
return return
'$val .= \'<!-- include '.$php.' -->\';'. "\n". '$val .= \'<!-- include '.$php.' -->\';'. "\n".
'$val .= SSViewer::parse_template('.$php.', $item);'. "\n". '$val .= SSViewer::parse_template('.$php.', $scope->getItem());'. "\n".
'$val .= \'<!-- end include '.$php.' -->\';'. "\n"; '$val .= \'<!-- end include '.$php.' -->\';'. "\n";
} }
else { else {
return return
'$val .= SSViewer::execute_template('.$php.', $item);'. "\n"; '$val .= SSViewer::execute_template('.$php.', $scope->getItem());'. "\n";
} }
} }
@ -1655,7 +1677,7 @@ class SSTemplateParser extends Parser {
* This is an open block handler, for the <% debug %> utility tag * This is an open block handler, for the <% debug %> utility tag
*/ */
function OpenBlock_Handle_Debug(&$res) { function OpenBlock_Handle_Debug(&$res) {
if ($res['ArgumentCount'] == 0) return 'Debug::show($item);'; if ($res['ArgumentCount'] == 0) return '$scope->debug();';
else if ($res['ArgumentCount'] == 1) { else if ($res['ArgumentCount'] == 1) {
$arg = $res['Arguments'][0]; $arg = $res['Arguments'][0];

View File

@ -81,12 +81,6 @@ class SSTemplateParser extends Parser {
*/ */
protected $includeDebuggingComments = false; protected $includeDebuggingComments = false;
function construct($name) {
$result = parent::construct($name);
$result['tags'] = array();
return $result;
}
/*!* SSTemplateParser /*!* SSTemplateParser
Word: / [A-Za-z_] [A-Za-z0-9_]* / Word: / [A-Za-z_] [A-Za-z0-9_]* /
@ -107,7 +101,7 @@ class SSTemplateParser extends Parser {
if (isset($res['php'])) $res['php'] .= ', '; if (isset($res['php'])) $res['php'] .= ', ';
else $res['php'] = ''; else $res['php'] = '';
$res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : $sub['php']; $res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : str_replace('$$FINAL', 'XML_val', $sub['php']);
} }
/*!* /*!*
@ -128,7 +122,7 @@ class SSTemplateParser extends Parser {
*/ */
function Lookup__construct(&$res) { function Lookup__construct(&$res) {
$res['php'] = '$item'; $res['php'] = '$scope';
$res['LookupSteps'] = array(); $res['LookupSteps'] = array();
} }
@ -155,7 +149,7 @@ class SSTemplateParser extends Parser {
} }
function Lookup_LastLookupStep(&$res, $sub) { function Lookup_LastLookupStep(&$res, $sub) {
$this->Lookup_AddLookupStep($res, $sub, 'XML_val'); $this->Lookup_AddLookupStep($res, $sub, '$$FINAL');
} }
/*!* /*!*
@ -168,7 +162,7 @@ class SSTemplateParser extends Parser {
Injection: BracketInjection | SimpleInjection Injection: BracketInjection | SimpleInjection
*/ */
function Injection_STR(&$res, $sub) { function Injection_STR(&$res, $sub) {
$res['php'] = '$val .= '. $sub['Lookup']['php'] . ';'; $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php']) . ';';
} }
/*!* /*!*
@ -261,11 +255,11 @@ class SSTemplateParser extends Parser {
function Comparison_Argument(&$res, $sub) { function Comparison_Argument(&$res, $sub) {
if ($sub['ArgumentMode'] == 'default') { if ($sub['ArgumentMode'] == 'default') {
if (isset($res['php'])) $res['php'] .= $sub['string_php']; if (isset($res['php'])) $res['php'] .= $sub['string_php'];
else $res['php'] = $sub['lookup_php']; else $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php']);
} }
else { else {
if (!isset($res['php'])) $res['php'] = ''; if (!isset($res['php'])) $res['php'] = '';
$res['php'] .= $sub['php']; $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php']);
} }
} }
@ -289,7 +283,7 @@ class SSTemplateParser extends Parser {
$php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']);
// TODO: kinda hacky - maybe we need a way to pass state down the parse chain so // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so
// Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val
$res['php'] = str_replace('->XML_val', '->hasValue', $php); $res['php'] = str_replace('$$FINAL', 'hasValue', $php);
} }
} }
@ -429,9 +423,9 @@ class SSTemplateParser extends Parser {
} }
/** /**
* This is an example of a block handler function. This one handles the control tag. * This is an example of a block handler function. This one handles the loop tag.
*/ */
function ClosedBlock_Handle_Control(&$res) { function ClosedBlock_Handle_Loop(&$res) {
if ($res['ArgumentCount'] != 1) { if ($res['ArgumentCount'] != 1) {
throw new SSTemplateParseException('Either no or too many arguments in control block. Must be one argument only.', $this); throw new SSTemplateParseException('Either no or too many arguments in control block. Must be one argument only.', $this);
} }
@ -441,11 +435,39 @@ class SSTemplateParser extends Parser {
throw new SSTemplateParseException('Control block cant take string as argument.', $this); throw new SSTemplateParseException('Control block cant take string as argument.', $this);
} }
$on = str_replace('->XML_val', '->obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
return return
'array_push($itemStack, $item); if($loop = '.$on.') foreach($loop as $key => $item) {' . PHP_EOL . $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL .
$res['Template']['php'] . PHP_EOL . $res['Template']['php'] . PHP_EOL .
'} $item = array_pop($itemStack); '; '}; $scope->popScope(); ';
}
/**
* The deprecated closed block handler for control blocks
* @deprecated
*/
function ClosedBlock_Handle_Control(&$res) {
return $this->ClosedBlock_Handle_Loop($res);
}
/**
* The closed block handler for with blocks
*/
function ClosedBlock_Handle_With(&$res) {
if ($res['ArgumentCount'] != 1) {
throw new SSTemplateParseException('Either no or too many arguments in with block. Must be one argument only.', $this);
}
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {
throw new SSTemplateParseException('Control block cant take string as argument.', $this);
}
$on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']);
return
$on . '; $scope->pushScope();' . PHP_EOL .
$res['Template']['php'] . PHP_EOL .
'; $scope->popScope(); ';
} }
/*!* /*!*
@ -492,12 +514,12 @@ class SSTemplateParser extends Parser {
if($this->includeDebuggingComments) { // Add include filename comments on dev sites if($this->includeDebuggingComments) { // Add include filename comments on dev sites
return return
'$val .= \'<!-- include '.$php.' -->\';'. "\n". '$val .= \'<!-- include '.$php.' -->\';'. "\n".
'$val .= SSViewer::parse_template('.$php.', $item);'. "\n". '$val .= SSViewer::parse_template('.$php.', $scope->getItem());'. "\n".
'$val .= \'<!-- end include '.$php.' -->\';'. "\n"; '$val .= \'<!-- end include '.$php.' -->\';'. "\n";
} }
else { else {
return return
'$val .= SSViewer::execute_template('.$php.', $item);'. "\n"; '$val .= SSViewer::execute_template('.$php.', $scope->getItem());'. "\n";
} }
} }
@ -505,7 +527,7 @@ class SSTemplateParser extends Parser {
* This is an open block handler, for the <% debug %> utility tag * This is an open block handler, for the <% debug %> utility tag
*/ */
function OpenBlock_Handle_Debug(&$res) { function OpenBlock_Handle_Debug(&$res) {
if ($res['ArgumentCount'] == 0) return 'Debug::show($item);'; if ($res['ArgumentCount'] == 0) return '$scope->debug();';
else if ($res['ArgumentCount'] == 1) { else if ($res['ArgumentCount'] == 1) {
$arg = $res['Arguments'][0]; $arg = $res['Arguments'][0];

View File

@ -1,4 +1,129 @@
<?php <?php
/**
* This tracks the current scope for an SSViewer instance. It has three goals:
* - Handle entering & leaving sub-scopes in loops and withs
* - Track Up and Top
* - (As a side effect) Inject data that needs to be available globally (used to live in ViewableData)
*
* In order to handle up, rather than tracking it using a tree, which would involve constructing new objects
* for each step, we use indexes into the itemStack (which already has to exist).
*
* Each item has three indexes associated with it
*
* - Pop. Which item should become the scope once the current scope is popped out of
* - Up. Which item is up from this item
* - Current. Which item is the first time this object has appeared in the stack
*
* We also keep the index of the current starting point for lookups. A lookup is a sequence of obj calls -
* when in a loop or with tag the end result becomes the new scope, but for injections, we throw away the lookup
* and revert back to the original scope once we've got the value we're after
*
*/
class SSViewer_DataPresenter {
// The stack of previous "global" items
// And array of item, itemIterator, pop_index, up_index, current_index
private $itemStack = array();
private $item; // The current "global" item (the one any lookup starts from)
private $itemIterator; // If we're looping over the current "global" item, here's the iterator that tracks with item we're up to
private $popIndex; // A pointer into the item stack for which item should be scope on the next pop call
private $upIndex; // A pointer into the item stack for which item is "up" from this one
private $currentIndex; // A pointer into the item stack for which item is this one (or null if not in stack yet)
private $localIndex;
function __construct($item){
$this->item = $item;
$this->localIndex=0;
$this->itemStack[] = array($this->item, null, null, null, 0);
}
function getItem(){
return $this->itemIterator ? $this->itemIterator->current() : $this->item;
}
function resetLocalScope(){
list($this->item, $this->itemIterator, $this->popIndex, $this->upIndex, $this->currentIndex) = $this->itemStack[$this->localIndex];
array_splice($this->itemStack, $this->localIndex+1);
}
function obj($name){
switch ($name) {
case 'Up':
list($this->item, $this->itemIterator, $unused2, $this->upIndex, $this->currentIndex) = $this->itemStack[$this->upIndex];
break;
case 'Top':
list($this->item, $this->itemIterator, $unused2, $this->upIndex, $this->currentIndex) = $this->itemStack[0];
break;
default:
$on = $this->itemIterator ? $this->itemIterator->current() : $this->item;
$this->item = call_user_func_array(array($on, 'obj'), func_get_args());
$this->itemIterator = null;
$this->upIndex = $this->currentIndex ? $this->currentIndex : count($this->itemStack)-1;
$this->currentIndex = count($this->itemStack);
break;
}
$this->itemStack[] = array($this->item, $this->itemIterator, null, $this->upIndex, $this->currentIndex);
return $this;
}
function pushScope(){
$newLocalIndex = count($this->itemStack)-1;
$this->popIndex = $this->itemStack[$newLocalIndex][2] = $this->localIndex;
$this->localIndex = $newLocalIndex;
// We normally keep any previous itemIterator around, so local $Up calls reference the right element. But
// once we enter a new global scope, we need to make sure we use a new one
$this->itemIterator = $this->itemStack[$newLocalIndex][1] = null;
return $this;
}
function popScope(){
$this->localIndex = $this->popIndex;
$this->resetLocalScope();
return $this;
}
function next(){
if (!$this->item) return false;
if (!$this->itemIterator) {
if (is_array($this->item)) $this->itemIterator = new ArrayIterator($this->item);
else $this->itemIterator = $this->item->getIterator();
$this->itemStack[$this->localIndex][1] = $this->itemIterator;
$this->itemIterator->rewind();
}
else {
$this->itemIterator->next();
}
$this->resetLocalScope();
if (!$this->itemIterator->valid()) return false;
return $this->itemIterator->key();
}
function __call($name, $arguments) {
$on = $this->itemIterator ? $this->itemIterator->current() : $this->item;
$retval = call_user_func_array(array($on, $name), $arguments);
$this->resetLocalScope();
return $retval;
}
}
/** /**
* Parses a template file with an *.ss file extension. * Parses a template file with an *.ss file extension.
* *
@ -422,14 +547,12 @@ class SSViewer {
} }
} }
$itemStack = array(); $scope = new SSViewer_DataPresenter($item);
$val = ""; $val = ""; $valStack = array();
$valStack = array();
include($cacheFile); include($cacheFile);
$output = $val; $output = Requirements::includeInHTML($template, $val);
$output = Requirements::includeInHTML($template, $output);
array_pop(SSViewer::$topLevel); array_pop(SSViewer::$topLevel);
@ -528,7 +651,7 @@ class SSViewer_FromString extends SSViewer {
echo "</pre>"; echo "</pre>";
} }
$itemStack = array(); $scope = new SSViewer_DataPresenter($item);
$val = ""; $val = "";
$valStack = array(); $valStack = array();

View File

@ -329,6 +329,137 @@ after')
$this->assertEquals('A A1 A1 i A1 ii A2 A3', $rationalisedResult); $this->assertEquals('A A1 A1 i A1 ii A2 A3', $rationalisedResult);
} }
function assertEqualIgnoringWhitespace($a, $b) {
$this->assertEquals(preg_replace('/\s+/', '', $a), preg_replace('/\s+/', '', $b));
}
/**
* Test $Up works when the scope $Up refers to was entered with a "with" block
*/
function testUpInWith() {
// Data to run the loop tests on - three levels deep
$data = new ArrayData(array(
'Name' => 'Top',
'Foo' => new ArrayData(array(
'Name' => 'Foo',
'Bar' => new ArrayData(array(
'Name' => 'Bar',
'Baz' => new ArrayData(array(
'Name' => 'Baz'
)),
'Qux' => new ArrayData(array(
'Name' => 'Qux'
))
))
))
));
// Basic functionality
$this->assertEquals('BarFoo',
$this->render('<% with Foo %><% with Bar %>{$Name}{$Up.Name}<% end_with %><% end_with %>', $data));
// Two level with block, up refers to internally referenced Bar
$this->assertEquals('BarFoo',
$this->render('<% with Foo.Bar %>{$Name}{$Up.Name}<% end_with %>', $data));
// Stepping up & back down the scope tree
$this->assertEquals('BazBarQux',
$this->render('<% with Foo.Bar.Baz %>{$Name}{$Up.Name}{$Up.Qux.Name}<% end_with %>', $data));
// Using $Up in a with block
$this->assertEquals('BazBarQux',
$this->render('<% with Foo.Bar.Baz %>{$Name}<% with $Up %>{$Name}{$Qux.Name}<% end_with %><% end_with %>', $data));
// Stepping up & back down the scope tree with with blocks
$this->assertEquals('BazBarQuxBarBaz',
$this->render('<% with Foo.Bar.Baz %>{$Name}<% with $Up %>{$Name}<% with Qux %>{$Name}<% end_with %>{$Name}<% end_with %>{$Name}<% end_with %>', $data));
// Using $Up.Up, where first $Up points to a previous scope entered using $Up, thereby skipping up to Foo
$this->assertEquals('Foo',
$this->render('<% with Foo.Bar.Baz %><% with Up %><% with Qux %>{$Up.Up.Name}<% end_with %><% end_with %><% end_with %>', $data));
// Using $Up.Up, where first $Up points to an Up used in a local scope lookup, should still skip to Foo
$this->assertEquals('Foo',
$this->render('<% with Foo.Bar.Baz.Up.Qux %>{$Up.Up.Name}<% end_with %>', $data));
}
/**
* Test $Up works when the scope $Up refers to was entered with a "loop" block
*/
function testUpInLoop(){
// Data to run the loop tests on - one sequence of three items, each with a subitem
$data = new ArrayData(array(
'Name' => 'Top',
'Foo' => new DataObjectSet(array(
new ArrayData(array(
'Name' => '1',
'Sub' => new ArrayData(array(
'Name' => 'Bar'
))
)),
new ArrayData(array(
'Name' => '2',
'Sub' => new ArrayData(array(
'Name' => 'Baz'
))
)),
new ArrayData(array(
'Name' => '3',
'Sub' => new ArrayData(array(
'Name' => 'Qux'
))
))
))
));
// Make sure inside a loop, $Up refers to the current item of the loop
$this->assertEqualIgnoringWhitespace(
'111 222 333',
$this->render(
'<% loop $Foo %>$Name<% with $Sub %>$Up.Name<% end_with %>$Name<% end_loop %>',
$data
)
);
// Make sure inside a loop, looping over $Up uses a separate iterator,
// and doesn't interfere with the original iterator
$this->assertEqualIgnoringWhitespace(
'1Bar123Bar1 2Baz123Baz2 3Qux123Qux3',
$this->render(
'<% loop $Foo %>
$Name
<% with $Sub %>
$Name<
% loop $Up %>$Name<% end_loop %>
$Name
<% end_with %>
$Name
<% end_loop %>',
$data
)
);
// Make sure inside a loop, looping over $Up uses a separate iterator,
// and doesn't interfere with the original iterator or local lookups
$this->assertEqualIgnoringWhitespace(
'1 Bar1 123 1Bar 1 2 Baz2 123 2Baz 2 3 Qux3 123 3Qux 3',
$this->render(
'<% loop $Foo %>
$Name
<% with $Sub %>
{$Name}{$Up.Name}
<% loop $Up %>$Name<% end_loop %>
{$Up.Name}{$Name}
<% end_with %>
$Name
<% end_loop %>',
$data
)
);
}
} }
/** /**