Skip to content

Commit 2a17c2b

Browse files
committed
Implement Handlebars::createFrame() utility
This aligns the semantics of altering data state in a block helper with Handlebars.js. Also removed unnecessary `frame` property from RuntimeContext.
1 parent 2fa2dc5 commit 2a17c2b

7 files changed

Lines changed: 73 additions & 69 deletions

File tree

src/Compiler.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ private static function templateClosure(string $code, string $partials = '', str
9494
$use = $useVars !== '' ? " use ($useVars)" : '';
9595
return <<<PHP
9696
function (mixed \$in = null, array \$options = [])$use {
97-
\$cx = LR::createContext(\$in, \$options, [$partials]);
98-
\$cx->frame['root'] = &\$cx->data['root'];$stmts
97+
\$cx = LR::createContext(\$in, \$options, [$partials]);$stmts
9998
return $code;
10099
}
101100
PHP;
@@ -819,7 +818,7 @@ private function getSimpleHelperName(PathExpression|Literal $path): ?string
819818
private function buildBasePath(bool $data, int $depth): string
820819
{
821820
if ($data) {
822-
return '$cx->frame' . str_repeat("['_parent']", $depth);
821+
return '$cx->data' . str_repeat("['_parent']", $depth);
823822
}
824823
return $depth > 0 ? "\$cx->depths[count(\$cx->depths)-$depth]" : '$in';
825824
}

src/Handlebars.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ public static function template(string $templateSpec): \Closure
4444
return eval($templateSpec);
4545
}
4646

47+
/**
48+
* Creates a child @data frame inheriting fields from the given frame.
49+
* Use this in block helpers before passing a data array to fn() or inverse(),
50+
* equivalent to Handlebars.createFrame() in Handlebars.js.
51+
* @param array<mixed> $data
52+
* @return array<mixed>
53+
*/
54+
public static function createFrame(array $data): array
55+
{
56+
$frame = $data;
57+
$frame['_parent'] = $data;
58+
return $frame;
59+
}
60+
4761
/**
4862
* HTML escapes the passed string, making it safe for rendering as text within HTML content.
4963
* The output of all expressions except for triple-braced expressions are passed through this method.

src/HelperOptions.php

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,12 @@ private function invokeBlock(\Closure $closure, mixed $context, mixed $data): st
9797
// HBS.js options.fn(this) / options.inverse(this)), since the scope level isn't changing.
9898
$pushDepths = $context !== $scope;
9999
$resolvedContext = $pushDepths ? ($context === Scope::Use ? $scope : $context) : $scope;
100-
$savedFrame = null;
100+
$outerFrame = null;
101101
$bpStack = null;
102102

103103
if (isset($data['data'])) {
104-
$savedFrame = $cx->frame;
105-
// Fast path: only root in frame, no user @-data to inherit
106-
$newFrame = count($savedFrame) === 1 ? $data['data'] : array_replace($savedFrame, $data['data']);
107-
$newFrame['root'] = &$cx->data['root'];
108-
$newFrame['_parent'] = $savedFrame;
109-
$cx->frame = $newFrame;
104+
$outerFrame = $cx->data;
105+
$cx->data = $data['data'];
110106
}
111107

112108
if (isset($data['blockParams'])) {
@@ -123,8 +119,8 @@ private function invokeBlock(\Closure $closure, mixed $context, mixed $data): st
123119
if ($pushDepths) {
124120
array_pop($cx->depths);
125121
}
126-
if ($savedFrame !== null) {
127-
$cx->frame = $savedFrame;
122+
if ($outerFrame !== null) {
123+
$cx->data = $outerFrame;
128124
}
129125
$cx->inlinePartials = $savedInlinePartials;
130126
return $ret;
@@ -158,28 +154,28 @@ public function iterate(array $items): string
158154
$last = count($items) - 1;
159155
$ret = '';
160156
$i = 0;
161-
$outerFrame = $cx->frame;
162-
// Fast path: when only root is in the frame, skip array_replace.
163-
$simpleFrame = count($outerFrame) === 1;
157+
$outerFrame = $cx->data;
164158
// Pre-allocate bpStack once; mutate [0][0] and [0][1] per iteration.
165159
// PHP COW ensures the inner array's refcount returns to 1 after $cb() returns,
166160
// so the next iteration's assignment is an in-place mutation, not a copy.
167161
$bpStack = [[null, null], ...$this->outerBlockParams];
162+
$data = Handlebars::createFrame($outerFrame);
163+
$data['first'] = true;
168164

169165
foreach ($items as $index => $value) {
170-
$iterData = ['key' => $index, 'index' => $i, 'first' => $i === 0, 'last' => $i === $last];
171-
$newFrame = $simpleFrame ? $iterData : array_replace($outerFrame, $iterData);
172-
$newFrame['root'] = &$cx->data['root'];
173-
$newFrame['_parent'] = $outerFrame;
174-
$cx->frame = $newFrame;
166+
$data['key'] = $index;
167+
$data['index'] = $i;
168+
$data['last'] = $i === $last;
169+
$cx->data = $data;
175170

176171
$bpStack[0][0] = $value;
177172
$bpStack[0][1] = $index;
178173
$ret .= $cb($cx, $value, $bpStack);
174+
$data['first'] = false;
179175
$i++;
180176
}
181177

182-
$cx->frame = $outerFrame;
178+
$cx->data = $outerFrame;
183179
array_pop($cx->depths);
184180
$cx->inlinePartials = $savedInlinePartials;
185181
return $ret;

src/Runtime.php

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -169,30 +169,33 @@ public static function lookupLength(mixed $base, bool $strict = false): mixed
169169
public static function createContext(mixed $context, array $options, array $compiledPartials): RuntimeContext
170170
{
171171
$parentCx = self::$partialContext;
172-
$root = ['root' => $context];
173172

174173
if ($parentCx !== null) {
175174
// Partial context: reuse the parent's already-merged helpers and partials directly.
176-
// PHP copy-on-write ensures inlinePartials is only copied if in() registers a new inline partial.
177-
// Inherit the parent's current frame so @index, @key, etc. remain accessible inside partials.
178-
// templateClosure will update frame['root'] to reference this partial's own data['root'].
175+
// PHP copy-on-write ensures inlinePartials is only copied if the partial registers a new {{#* inline}} partial.
176+
// Inherit the parent's current data so @index, @key, etc. remain accessible inside partials.
177+
// Unset 'root' first to break the reference established by `$in = &$cx->data['root']` in the
178+
// calling template; a direct assignment would write through it and corrupt the caller's $in.
179+
$data = $parentCx->data;
180+
unset($data['root']);
181+
$data['root'] = $context;
179182
return new RuntimeContext(
180183
helpers: $parentCx->helpers,
181184
partials: $parentCx->partials,
182185
inlinePartials: $parentCx->inlinePartials,
183186
depths: $parentCx->depths,
184-
data: $root,
185-
frame: $parentCx->frame,
187+
data: $data,
186188
partialBlock: $parentCx->partialBlock,
187189
);
188190
}
189191

190192
$data = $options['data'] ?? [];
193+
$data['root'] = $data['root'] ?? $context;
194+
$extraHelpers = $options['helpers'] ?? [];
191195
return new RuntimeContext(
192-
helpers: array_replace(Runtime::defaultHelpers(), $options['helpers'] ?? []),
196+
helpers: $extraHelpers ? array_replace(Runtime::defaultHelpers(), $extraHelpers) : Runtime::defaultHelpers(),
193197
partials: array_replace($compiledPartials, $options['partials'] ?? []),
194-
data: ['root' => $data['root'] ?? $context],
195-
frame: $data,
198+
data: $data,
196199
);
197200
}
198201

@@ -251,8 +254,8 @@ public static function ifvar(mixed $v, bool $zero = false): bool
251254
&& $v !== false
252255
&& ($zero || ($v !== 0 && $v !== 0.0))
253256
&& $v !== ''
254-
&& (!$v instanceof \Stringable || (string) $v !== '')
255-
&& (!is_array($v) || $v);
257+
&& (!is_array($v) || $v)
258+
&& (!$v instanceof \Stringable || (string) $v !== '');
256259
}
257260

258261
/**
@@ -305,16 +308,17 @@ public static function raw(mixed $value): string
305308
*/
306309
public static function sec(RuntimeContext $cx, mixed $value, mixed $in, ?\Closure $cb, ?\Closure $else = null, ?string $helperName = null): string
307310
{
308-
if ($helperName !== null && isset($cx->helpers[$helperName])) {
309-
return static::hbbch($cx, $cx->helpers[$helperName], $helperName, [], [], $in, $cb, $else);
311+
$helper = $helperName !== null ? ($cx->helpers[$helperName] ?? null) : null;
312+
if ($helper !== null) {
313+
return static::hbbch($cx, $helper, $helperName, [], [], $in, $cb, $else);
310314
}
311315

312316
// Lambda functions in block position: simple-path identifiers ($helperName set) receive
313317
// HelperOptions so they can render fn/inverse; complex paths ($helperName null) are called
314318
// with no arguments, mirroring HBS.js which does not treat them as helper calls.
315319
if ($value instanceof \Closure) {
316320
$result = $helperName !== null
317-
? $value(new HelperOptions(scope: $in, data: $cx->frame, cx: $cx, cb: $cb, inv: $else))
321+
? $value(new HelperOptions(scope: $in, data: $cx->data, cx: $cx, cb: $cb, inv: $else))
318322
: $value();
319323
return static::resolveBlockResult($cx, $result, $in, $cb, $else);
320324
}
@@ -471,7 +475,7 @@ public static function hbch(RuntimeContext $cx, \Closure $helper, string $name,
471475
if ($numParams === 0 || $numParams > count($positional)) {
472476
$positional[] = new HelperOptions(
473477
scope: $_this,
474-
data: $cx->frame,
478+
data: $cx->data,
475479
cx: $cx,
476480
name: $name,
477481
hash: $hash,
@@ -495,7 +499,7 @@ public static function hbbch(RuntimeContext $cx, \Closure $helper, string $name,
495499
{
496500
$positional[] = new HelperOptions(
497501
scope: $_this,
498-
data: $cx->frame,
502+
data: $cx->data,
499503
cx: $cx,
500504
name: $name,
501505
hash: $hash,

src/RuntimeContext.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ final class RuntimeContext
1313
* @param array<string, \Closure> $inlinePartials block-scoped {{#* inline}} partials (reset on fn() return)
1414
* @param array<mixed> $depths
1515
* @param array<mixed> $data
16-
* @param array<mixed> $frame
1716
*/
1817
public function __construct(
1918
public array $helpers = [],
2019
public array $partials = [],
2120
public array $inlinePartials = [],
2221
public array $depths = [],
2322
public array $data = [],
24-
public array $frame = [],
2523
public ?\Closure $partialBlock = null,
2624
) {}
2725
}

tests/HandlebarsSpecTest.php

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,15 @@
11
<?php
22

3+
namespace DevTheorem\Handlebars\Test;
4+
35
use DevTheorem\Handlebars\Handlebars;
46
use DevTheorem\Handlebars\Options;
57
use PHPUnit\Framework\Attributes\DataProvider;
68
use PHPUnit\Framework\TestCase;
79

8-
/**
9-
* Used by vendor/jbboehr/handlebars-spec/spec/data.json
10-
*/
11-
class Utils
12-
{
13-
public static function createFrame(mixed $data): mixed
14-
{
15-
if (is_array($data)) {
16-
$r = [];
17-
foreach ($data as $k => $v) {
18-
$r[$k] = $v;
19-
}
20-
return $r;
21-
}
22-
return $data;
23-
}
24-
}
25-
2610
/**
2711
* @phpstan-type JsonSpec array{
28-
* file: string, no: int, message: string|null, data: null|int|bool|string|array<mixed>|stdClass,
12+
* file: string, no: int, message: string|null, data: mixed,
2913
* it: string, description: string, expected: string|null, helpers: array<mixed>,
3014
* partials: array<mixed>, compileOptions: array<mixed>, template: string,
3115
* exception: string|null, runtimeOptions: array<mixed>, number: string|null,
@@ -82,11 +66,7 @@ public function testSpecs(array $spec): void
8266
if (!isset($func['php'])) {
8367
$this->markTestIncomplete("No PHP helper code provided for [{$spec['file']}#{$spec['description']}]#{$spec['no']}");
8468
}
85-
$helper = self::patchSafeString($func['php']);
86-
$helper = str_replace('$options[\'name\']', '$options->name', $helper);
87-
$helper = str_replace('$options[\'data\']', '$options->data', $helper);
88-
$helper = str_replace('$options[\'hash\']', '$options->hash', $helper);
89-
$helper = str_replace('$arguments[count($arguments)-1][\'name\'];', '$arguments[count($arguments)-1]->name;', $helper);
69+
$helper = self::patchHelperCode($func['php']);
9070
$helpersList .= "\n '$name' => $helper,\n";
9171
eval('$helpers[\'' . $name . '\'] = ' . $helper . ';');
9272
}
@@ -182,7 +162,7 @@ public static function jsonSpecProvider(): array
182162

183163
$files = glob('vendor/jbboehr/handlebars-spec/spec/*.json');
184164
if ($files === false) {
185-
throw new Exception("Failed to read JSON spec files");
165+
throw new \Exception("Failed to read JSON spec files");
186166
}
187167

188168
foreach ($files as $file) {
@@ -192,7 +172,7 @@ public static function jsonSpecProvider(): array
192172
}
193173
$contents = file_get_contents($file);
194174
if ($contents === false) {
195-
throw new Exception("Failed to read JSON spec file {$file}");
175+
throw new \Exception("Failed to read JSON spec file {$file}");
196176
}
197177
$i = 0;
198178
$json = json_decode($contents, true);
@@ -254,10 +234,14 @@ private static function evalNestedCode(array &$data): void
254234
}
255235
}
256236

257-
private static function patchSafeString(string $code): string
237+
private static function patchHelperCode(string $code): string
258238
{
259-
$classname = '\\DevTheorem\\Handlebars\\SafeString';
260-
return preg_replace('/ (\\\Handlebars\\\)?SafeString(\s*\(.*?\))?/', ' ' . $classname . '$2', $code)
261-
?? throw new Exception("Failed to patch SafeString in $code");
239+
$code = preg_replace('/ (\\\Handlebars\\\)?SafeString(\s*\(.*?\))?/', ' \\DevTheorem\\Handlebars\\SafeString$2', $code)
240+
?? throw new \Exception("Failed to patch SafeString in $code");
241+
$code = str_replace('Utils::createFrame(', '\DevTheorem\Handlebars\Handlebars::createFrame(', $code);
242+
$code = str_replace('$options[\'name\']', '$options->name', $code);
243+
$code = str_replace('$options[\'data\']', '$options->data', $code);
244+
$code = str_replace('$options[\'hash\']', '$options->hash', $code);
245+
return str_replace('$arguments[count($arguments)-1][\'name\'];', '$arguments[count($arguments)-1]->name;', $code);
262246
}
263247
}

tests/RegressionTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,15 @@ public static function helperProvider(): array
857857
'helpers' => ['equals' => $equals],
858858
'expected' => 'Not equal',
859859
],
860+
861+
'data state when helper does not create child frame should match Handlebars.js' => [
862+
'template' => '{{#each foo}}{{#helper}}{{@key}} {{.}} {{@foo}}{{@root.t}}, {{/helper}} {{@key}} {{.}} {{@foo}}{{@root.t}}; {{/each}}',
863+
'helpers' => [
864+
'helper' => fn(HelperOptions $options) => $options->fn($options->scope, ['data' => ['foo' => 'bar']]),
865+
],
866+
'data' => ['t' => 'val', 'foo' => ['1st', '2nd']],
867+
'expected' => ' 1st bar, 0 1st val; 2nd bar, 1 2nd val; ',
868+
],
860869
];
861870
}
862871

0 commit comments

Comments
 (0)