From 7f397d191f4fa615101630414f255d1d14e4e7c6 Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Wed, 4 Mar 2026 08:07:13 -0600 Subject: [PATCH 1/6] Remove context argument from partialResolver closure This is an internal class which shouldn't be part of the public API. --- src/Compiler.php | 4 ++-- src/Context.php | 7 +------ src/Options.php | 2 +- tests/RegressionTest.php | 3 +-- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/Compiler.php b/src/Compiler.php index 14262d6..4b5da73 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -769,8 +769,8 @@ private function resolvePartial(string &$name): ?string if (isset($this->context->partials[$name])) { return $this->context->partials[$name]; } - if ($this->context->partialResolver) { - return ($this->context->partialResolver)($this->context, $name); + if ($this->context->options->partialResolver) { + return ($this->context->options->partialResolver)($name); } return null; } diff --git a/src/Context.php b/src/Context.php index f9ba8b1..da1a7e9 100644 --- a/src/Context.php +++ b/src/Context.php @@ -2,8 +2,6 @@ namespace DevTheorem\Handlebars; -use Closure; - /** * @internal */ @@ -14,7 +12,6 @@ final class Context * @param array $partialCode * @param array $usedHelpers * @param array $partials - * @param null|Closure(Context, string):(string|null) $partialResolver * @param array $partialBlock * @param array $inlinePartial * @param array $helpers @@ -28,20 +25,18 @@ public function __construct( public int $partialBlockId = 0, public array $usedHelpers = [], public array $partials = [], - public ?Closure $partialResolver = null, public array $partialBlock = [], public array $inlinePartial = [], public array $helpers = [], ) { $this->partials = $options->partials; - $this->partialResolver = $options->partialResolver; $this->helpers = $options->helpers; } /** * Update from another context. */ - public function merge(Context $context): void + public function merge(self $context): void { $this->helpers = $context->helpers; $this->partials = $context->partials; diff --git a/src/Options.php b/src/Options.php index 317a0ea..4943337 100644 --- a/src/Options.php +++ b/src/Options.php @@ -9,7 +9,7 @@ /** * @param array $helpers * @param array $partials - * @param null|Closure(Context, string):(string|null) $partialResolver + * @param null|Closure(string):(string|null) $partialResolver */ public function __construct( public bool $knownHelpersOnly = false, diff --git a/tests/RegressionTest.php b/tests/RegressionTest.php index 7871626..c8504f0 100644 --- a/tests/RegressionTest.php +++ b/tests/RegressionTest.php @@ -2,7 +2,6 @@ namespace DevTheorem\Handlebars\Test; -use DevTheorem\Handlebars\Context; use DevTheorem\Handlebars\Handlebars; use DevTheorem\Handlebars\HelperOptions; use DevTheorem\Handlebars\Options; @@ -2321,7 +2320,7 @@ public static function issueProvider(): array [ 'template' => '{{>foo}} and {{>bar}}', 'options' => new Options( - partialResolver: function (Context $context, string $name) { + partialResolver: function (string $name) { return "PARTIAL: $name"; }, ), From 055a877806dda21411ef460455bc142d8c90a1c2 Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Sun, 1 Mar 2026 09:47:42 -0600 Subject: [PATCH 2/6] Replace compile-time helpers with runtime helpers The `helpers` compilation option has been removed, and all helpers must now be passed at runtime when executing the template (just like in Handlebars.js). This can significantly reduce compile time, since PHP files no longer have to be read and tokenized to extract helper functions. It also enables sharing helper closures across multiple templates, and removes limitations on what they can access and do. Additionally, implemented `knownHelpers` option and updated `knownHelpersOnly` to work the same as in Handlebars.js. This now makes it possible to disable individual built-in helpers. Also unified partial closures and improved param types. Compiled templates and partials now have the same function signature to avoid duplicated logic. Memory usage when running tests decreased from 20 MB to 18 MB. --- README.md | 67 +- src/Compiler.php | 684 ++++---- src/Context.php | 7 - src/Exporter.php | 96 -- src/Handlebars.php | 7 +- src/HelperOptions.php | 162 +- src/Options.php | 12 +- src/Runtime.php | 622 ++++---- src/RuntimeContext.php | 14 +- tests/ErrorTest.php | 132 +- tests/ExporterTest.php | 26 - tests/HandlebarsSpecTest.php | 54 +- tests/RegressionTest.php | 2848 ++++++++++++++++------------------ 13 files changed, 2189 insertions(+), 2542 deletions(-) delete mode 100644 src/Exporter.php delete mode 100644 tests/ExporterTest.php diff --git a/README.md b/README.md index 338295d..77ea786 100644 --- a/README.md +++ b/README.md @@ -58,16 +58,24 @@ $template = Handlebars::compile('Hi {{first}} {{last}}!', new Options( echo $template(['first' => 'John']); // Error: Runtime: last does not exist ``` -**Available Options:** +### Available Options + +* `knownHelpers`: Associative array (`helperName => bool`) of helpers known to exist at template execution time. + Passing this allows the compiler to optimize a number of cases. + Builtin helpers are automatically included in this list and may be omitted by setting that value to `false`. * `knownHelpersOnly`: Enable to allow further optimizations based on the known helpers list. * `noEscape`: Enable to not HTML escape any content. * `strict`: Run in strict mode. In this mode, templates will throw rather than silently ignore missing fields. -* `assumeObjects`: Removes object existence checks when traversing paths. This is a subset of strict mode that generates optimized templates when the data inputs are known to be safe. + This has the side effect of disabling inverse operations such as `{{^foo}}{{/foo}}` + unless fields are explicitly included in the source object. +* `assumeObjects`: Removes object existence checks when traversing paths. + This is a subset of strict mode that generates optimized templates when the data inputs are known to be safe. * `preventIndent`: Prevent indented partial-call from indenting the entire partial output by the same amount. -* `ignoreStandalone`: Disables standalone tag removal. When set, blocks and partials that are on their own line will not remove the whitespace on that line. -* `explicitPartialContext`: Disables implicit context for partials. When enabled, partials that are not passed a context value will execute against an empty object. -* `helpers`: Provide a key => value array of custom helper functions. -* `partials`: Provide a key => value array of custom partial templates. +* `ignoreStandalone`: Disables standalone tag removal. + When set, blocks and partials that are on their own line will not remove the whitespace on that line. +* `explicitPartialContext`: Disables implicit context for partials. + When enabled, partials that are not passed a context value will execute against an empty object. +* `partials`: Provide a `name => value` array of custom partial templates. * `partialResolver`: A closure which will be called for any partial not in the `partials` array to return a template for it. ## Custom Helpers @@ -80,28 +88,30 @@ This object contains properties for accessing `hash` arguments, `data`, and the For example, a custom `#equals` helper with JS equality semantics could be implemented as follows: ```php -use DevTheorem\Handlebars\{Handlebars, HelperOptions, Options}; +use DevTheorem\Handlebars\{Handlebars, HelperOptions}; -$template = Handlebars::compile('{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', new Options( - helpers: [ +$template = Handlebars::compile('{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}'); +$options = [ + 'helpers' => [ 'equals' => function (mixed $a, mixed $b, HelperOptions $options) { $jsEquals = function (mixed $a, mixed $b): bool { - if ($a === null || $b === null) { - // in JS, null is not equal to blank string or false or zero + if ($a === null || $b === null || is_string($a) && is_string($b)) { + // In JS, null is not equal to blank string or false or zero, + // and when both operands are strings no coercion is performed. return $a === $b; } - + return $a == $b; }; - + return $jsEquals($a, $b) ? $options->fn() : $options->inverse(); }, ], -)); +]; -echo $template(['my_var' => 0]); // Equal to false -echo $template(['my_var' => 1]); // Not equal -echo $template(['my_var' => null]); // Not equal +echo $template(['my_var' => 0], $options); // Equal to false +echo $template(['my_var' => 1], $options); // Not equal +echo $template(['my_var' => null], $options); // Not equal ``` ## Hooks @@ -115,13 +125,13 @@ a helper that is not registered, even when the name matches a property in the cu For example: ```php -use DevTheorem\Handlebars\{Handlebars, HelperOptions, Options}; +use DevTheorem\Handlebars\{Handlebars, HelperOptions}; -$templateStr = '{{foo 2 "value"}} -{{#person}}{{firstName}} {{lastName}}{{/person}}'; +$template = Handlebars::compile('{{foo 2 "value"}} +{{#person}}{{firstName}} {{lastName}}{{/person}}'); -$template = Handlebars::compile($templateStr, new Options( - helpers: [ +$options = [ + 'helpers' => [ 'helperMissing' => function (...$args) { $options = array_pop($args); return "Missing {$options->name}(" . implode(',', $args) . ')'; @@ -130,9 +140,9 @@ $template = Handlebars::compile($templateStr, new Options( return "'{$options->name}' not found. Printing block: {$options->fn($context)}"; }, ], -)); +]; -echo $template(['person' => ['firstName' => 'John', 'lastName' => 'Doe']]); +echo $template(['person' => ['firstName' => 'John', 'lastName' => 'Doe']], $options); ``` Output: > Missing foo(2,value) @@ -147,7 +157,10 @@ Helpers may return a `DevTheorem\Handlebars\SafeString` instance to prevent esca When constructing the string that will be marked as safe, any external content should be properly escaped using the `Handlebars::escapeExpression()` method to avoid potential security concerns. -## Missing features +## Missing Features + +All syntax and language features from Handlebars.js 4.7.8 should work the same in PHP Handlebars, +with the following exceptions: -All syntax from Handlebars.js 4.7.8 should work the same in this implementation, with the following exception: -* Decorators ([deprecated in Handlebars.js](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/decorators-api.md)) have not been implemented. +* Custom Decorators have not been implemented, as they are [deprecated in Handlebars.js](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/decorators-api.md). +* The `data` and `compat` compilation options have not been implemented. diff --git a/src/Compiler.php b/src/Compiler.php index 4b5da73..60fd852 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -32,11 +32,26 @@ final class Compiler /** * Compile-time stack of block param name arrays, innermost first. - * Only populated for constructs that push to $cx->blParam at runtime (currently #each). + * Populated for any block that declares block params (e.g. {{#each items as |item i|}}). * @var list */ private array $blockParamValues = []; + /** + * Stack of booleans, one per active compileProgram() call. + * Each entry is set to true if that invocation directly emitted a $blockParams reference. + * Used to distinguish direct references from nested closure declarations that merely contain + * '$blockParams' as a parameter name in the generated string. + * @var bool[] + */ + private array $bpRefStack = []; + + /** + * Set when compiling a program to reflect whether that compilation directly + * referenced $blockParams (as opposed to nesting a closure that does). + */ + private bool $lastCompileProgramHadDirectBpRef = false; + /** * True while compiling helper params/hash values. * In strict mode, helper arguments may be undefined without throwing. @@ -51,16 +66,9 @@ public function compile(Program $program, Context $context): string { $this->context = $context; $this->blockParamValues = []; - return $this->compileBody($program); - } - - private function compileBody(Program $program): string - { - $code = ''; - foreach ($program->body as $statement) { - $code .= $this->accept($statement); - } - return $code; + $this->bpRefStack = []; + $this->lastCompileProgramHadDirectBpRef = false; + return $this->compileProgram($program); } /** @@ -71,40 +79,41 @@ private function compileBody(Program $program): string public function composePHPRender(string $code): string { $runtime = Runtime::class; - $helperOptions = HelperOptions::class; - $safeStringClass = SafeString::class; - $runtimeContext = RuntimeContext::class; - $helpers = Exporter::helpers($this->context); $partials = implode(",\n", $this->context->partialCode); + $closure = self::templateClosure($code, $partials, "\n \$in = &\$cx->data['root'];"); + return "use {$runtime} as LR;\nreturn $closure;"; + } - // Return generated PHP code string. - return << \$p) { - \$partials[\$name] = fn(RuntimeContext \$cx, mixed \$in) => \$p(\$in, ['_partials' => \$cx->partials, 'helpers' => \$cx->helpers, 'partialId' => \$cx->partialId]); - } - \$cx = new RuntimeContext( - helpers: isset(\$options['helpers']) ? array_merge(\$helpers, \$options['helpers']) : \$helpers, - partials: \$partials, - data: isset(\$options['data']) ? array_merge(['root' => \$in], \$options['data']) : ['root' => \$in], - partialId: \$options['partialId'] ?? 0, - ); - \$in = &\$cx->data['root']; - return '$code'; - }; - VAREND; + /** + * Build a partial closure string: a Template-format closure that calls createContext + * with empty compiled partials, inheriting context from $partialContext. + * @param string $code PHP expression to return (e.g. the result of compileProgram()) + * @param string $useVars comma-separated variables to capture (e.g. '$blockParams'), or '' for none + */ + private static function templateClosure(string $code, string $partials = '', string $stmts = '', string $useVars = ''): string + { + $use = $useVars !== '' ? " use ($useVars)" : ''; + return <<frame['root'] = &\$cx->data['root'];$stmts + return $code; + } + PHP; } private function compileProgram(Program $program): string { - return "'" . $this->compileBody($program) . "'"; + $this->bpRefStack[] = false; + $parts = []; + foreach ($program->body as $statement) { + $part = $this->accept($statement); + if ($part !== '' && $part !== "''") { + $parts[] = $part; + } + } + $this->lastCompileProgramHadDirectBpRef = array_pop($this->bpRefStack); + return $parts ? implode('.', $parts) : "''"; } private function accept(Node $node): string @@ -144,28 +153,15 @@ private function BlockStatement(BlockStatement $block): string $helperName = $this->getSimpleHelperName($block->path); if ($helperName !== null) { - // Custom block helper takes priority - if ($this->resolveHelper($helperName)) { + if ($this->isKnownHelper($helperName)) { return $this->compileBlockHelper($block, $helperName); } - // Built-in block helpers - switch ($helperName) { - case 'if': - return $this->compileIf($block, false); - case 'unless': - return $this->compileIf($block, true); - case 'each': - return $this->compileEach($block); - case 'with': - return $this->compileWith($block); - } - - if ($block->params) { - if ($this->resolveHelper('helperMissing')) { - return $this->compileBlockHelper($block, 'helperMissing', $helperName); + if ($block->params || $block->hash !== null) { + if ($this->context->options->knownHelpersOnly) { + $this->throwKnownHelpersOnly($helperName); } - throw new \Exception('Missing helper: "' . $helperName . '"'); + return $this->compileDynamicBlockHelper($block, $helperName); } } @@ -173,171 +169,147 @@ private function BlockStatement(BlockStatement $block): string if ($block->path instanceof Literal) { $literalKey = $this->getLiteralKeyName($block->path); - if ($this->resolveHelper($literalKey)) { + if ($this->isKnownHelper($literalKey)) { return $this->compileBlockHelper($block, $literalKey); } $escapedKey = self::quote($literalKey); - $miss = $this->missValue($literalKey); - $var = "\$in[$escapedKey] ?? $miss"; + $var = "\$in[$escapedKey] ?? " . $this->missValue($literalKey); if ($block->program === null) { - // Inverted section: {{^"foo"}}...{{/"foo"}} - $body = $this->compileProgramOrEmpty($block->inverse); - return "'.(" . self::getRuntimeFunc('isec', $var) . " ? $body : '').'"; + return $this->compileInvertedSection($block, $var, null); } - // Regular section: {{#"foo"}}...{{/"foo"}} - $body = $this->compileProgram($block->program); - $else = $this->compileElseClause($block); - return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) {return $body;}$else") . ".'"; + return $this->compileSection($block, $var, $escapedKey); } + $var = $this->compileExpression($block->path); + // Inverted section: {{^var}}...{{/var}} if ($block->program === null) { - return $this->compileInvertedSection($block); + $escapedName = $helperName !== null ? self::quote($helperName) : null; + return $this->compileInvertedSection($block, $var, $escapedName); } // Non-simple path with params: invoke as a dynamic block helper call if ($block->params) { - return $this->compileDynamicBlockHelper($block); - } - - // Block with hash but no positional params → helperMissing - if ($block->hash !== null && $this->resolveHelper('helperMissing')) { - return $this->compileBlockHelper($block, 'helperMissing', $block->path->original); + return $this->compileDynamicBlockHelper($block, (string) $block->path->original, $var); } // Regular section: {{#var}}...{{/var}} - return $this->compileSection($block); + return $this->compileSection($block, $var, self::quote($block->path->original)); } - private function compileDynamicBlockHelper(BlockStatement $block): string + private function isKnownHelper(string $helperName): bool { - if (!$block->program) { - throw new \Exception('Dynamic block program must not be empty'); - } - $varPath = $this->compileExpression($block->path); - $bp = $block->program->blockParams; - $params = $this->compileParams($block->params, $block->hash, $bp ?: null); - $body = $this->compileProgramWithBlockParams($block->program, $bp); - $else = $this->compileElseClause($block); - $name = self::quote((string) $block->path->original); - return "'." . self::getRuntimeFunc('dynhbbch', "\$cx, $name, $varPath, $params, \$in, function(\$cx, \$in) {return $body;}$else") . ".'"; + return $this->context->options->knownHelpers[$helperName] ?? false; } - private function resolveHelper(string $helperName): bool + private function compileSection(BlockStatement $block, string $var, string $escapedName): string { - if (isset($this->context->helpers[$helperName])) { - $this->context->usedHelpers[$helperName] = true; - return true; - } + assert($block->program !== null); - return false; - } + $blockFn = $this->compileProgramWithBlockParams($block->program); + $else = $this->compileElseClause($block); - private function compileIf(BlockStatement $block, bool $unless): string - { - if (count($block->params) !== 1) { - $helper = $unless ? '#unless' : '#if'; - throw new \Exception("$helper requires exactly one argument"); + if ($this->context->options->knownHelpersOnly) { + return self::getRuntimeFunc('sec', "\$cx, $var, \$in, $blockFn, $else"); } - $savedHelperArgs = $this->compilingHelperArgs; - $this->compilingHelperArgs = true; - $var = $this->compileExpression($block->params[0]); - $includeZero = $this->getIncludeZero($block->hash); - $this->compilingHelperArgs = $savedHelperArgs; - - $then = $this->compileProgramOrEmpty($block->program); - - if ($block->inverse && $block->inverse->chained) { - // {{else if ...}} chain — compile the inner block directly - $elseCode = ''; - foreach ($block->inverse->body as $stmt) { - $elseCode .= $this->accept($stmt); - } - $else = "'" . $elseCode . "'"; - } else { - $else = $this->compileProgramOrEmpty($block->inverse); + $bp = $block->program->blockParams; + if ($block->hash !== null || $bp) { + $params = $this->compileParams([], $block->hash); + $outerBp = $this->outerBlockParamsExpr(); + return self::getRuntimeFunc('dynhbbch', "\$cx, $escapedName, $var, $params, \$in, $blockFn, $else, " . count($bp) . ", $outerBp"); } - $negate = $unless ? '!' : ''; - $dv = self::getRuntimeFunc('dv', "$var, \$in"); - return "'.({$negate}" . self::getRuntimeFunc('ifvar', "$dv, $includeZero") . " ? $then : $else).'"; + return self::getRuntimeFunc('sec', "\$cx, $var, \$in, $blockFn, $else, $escapedName"); } - private function compileEach(BlockStatement $block): string + private function compileInvertedSection(BlockStatement $block, string $var, ?string $escapedName): string { - if (count($block->params) !== 1) { - throw new \Exception('Must pass iterator to #each'); - } + $body = $this->compileProgramOrEmpty($block->inverse); - $var = $this->compileExpression($block->params[0]); - [$bp, $bs] = $this->getProgramBlockParams($block->program); + if ($escapedName !== null) { + $blockFn = self::blockClosure($body, inheritsBp: $this->lastCompileProgramHadDirectBpRef); + return self::getRuntimeFunc('isech', "\$cx, $var, \$in, $blockFn, $escapedName"); + } - $body = $block->program ? $this->compileProgramWithBlockParams($block->program, $bp) : "''"; - $else = $this->compileElseClause($block); + return "(" . self::getRuntimeFunc('isec', $var) . " ? $body : '')"; + } - $dv = self::getRuntimeFunc('dv', "$var, \$in"); - return "'." . self::getRuntimeFunc('sec', "\$cx, $dv, $bs, \$in, true, function(\$cx, \$in) {return $body;}$else") . ".'"; + /** Returns '$blockParams' when inside a block-param scope (for use() capture), '' otherwise. */ + private function blockParamsUseVars(): string + { + return $this->blockParamValues ? '$blockParams' : ''; } - private function compileWith(BlockStatement $block): string + /** + * Returns the PHP expression for the outer block param stack at the current compile-time scope. + * '$blockParams' when inside a bp-declaring block; '[]' otherwise (top-level for this each/helper). + * When returning '$blockParams', marks the current bpRefStack frame so the enclosing closure + * captures $blockParams via use(), even if it doesn't directly access block param values. + */ + private function outerBlockParamsExpr(): string { - if (count($block->params) !== 1) { - throw new \Exception('#with requires exactly one argument'); + if (!$this->blockParamValues) { + return '[]'; } - - $var = $this->compileExpression($block->params[0]); - [$bp, $bs] = $this->getProgramBlockParams($block->program); - - $body = $this->compileProgramOrEmpty($block->program); - $else = $this->compileElseClause($block); - - $dv = self::getRuntimeFunc('dv', "$var, \$in"); - return "'." . self::getRuntimeFunc('wi', "\$cx, $dv, $bs, \$in, function(\$cx, \$in) {return $body;}$else") . ".'"; + if ($this->bpRefStack) { + $this->bpRefStack[array_key_last($this->bpRefStack)] = true; + } + return '$blockParams'; } - private function compileSection(BlockStatement $block): string + /** + * Compile a block program, pushing/popping its block params around the compilation. + */ + private function compileProgramWithBlockParams(Program $program): string { - $var = $this->compileExpression($block->path); - $escapedName = $block->path instanceof PathExpression ? self::quote($block->path->original) : 'null'; - - $body = $this->compileProgramOrEmpty($block->program); - $else = $this->compileElseClause($block); - - if ($this->resolveHelper('blockHelperMissing')) { - return "'." . self::getRuntimeFunc('hbbch', "\$cx, 'blockHelperMissing', [[$var],[]], \$in, false, function(\$cx, \$in) {return $body;}$else, $escapedName") . ".'"; + $bp = $program->blockParams; + if ($bp) { + array_unshift($this->blockParamValues, $bp); } - - return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) {return $body;}$else") . ".'"; + $body = $this->compileProgram($program); + if ($bp) { + array_shift($this->blockParamValues); + } + return self::blockClosure($body, (bool) $program->blockParams, $this->lastCompileProgramHadDirectBpRef); } - private function compileInvertedSection(BlockStatement $block): string + private function compileBlockHelper(BlockStatement $block, string $name): string { - $var = $this->compileExpression($block->path); - $body = $this->compileProgramOrEmpty($block->inverse); + $inverted = $block->program === null; + if ($inverted) { + assert($block->inverse !== null); + } + // For inverted blocks the fn body comes from the inverse program; for normal blocks, the program. + $fnProgram = $inverted ? $block->inverse : $block->program; + $blockFn = $this->compileProgramWithBlockParams($fnProgram); + [$fn, $else] = $inverted + ? ['null', $blockFn] + : [$blockFn, $this->compileElseClause($block)]; - return "'.(" . self::getRuntimeFunc('isec', $var) . " ? $body : '').'"; + $outerBp = $this->outerBlockParamsExpr(); + $params = $this->compileParams($block->params, $block->hash); + $helperName = self::quote($name); + $bpCount = count($fnProgram->blockParams); + + $trailingArgs = ($bpCount > 0 || $outerBp !== '[]') ? ", $bpCount, $outerBp" : ''; + return self::getRuntimeFunc('hbbch', "\$cx, \$cx->helpers[$helperName], $helperName, $params, \$in, $fn, $else$trailingArgs"); } - private function compileBlockHelper(BlockStatement $block, string $helperName, ?string $missingName = null): string + private function compileDynamicBlockHelper(BlockStatement $block, string $name, string $varPath = 'null'): string { - $bp = $block->program->blockParams ?? $block->inverse->blockParams ?? []; - $params = $this->compileParams($block->params, $block->hash, $bp); - $escapedName = $missingName === null ? 'null' : self::quote($missingName); - - if ($block->program === null) { - // inverted block - $body = $this->compileProgramOrEmpty($block->inverse); - return "'." . self::getRuntimeFunc('hbbch', "\$cx, '$helperName', $params, \$in, true, function(\$cx, \$in) {return $body;}, null, $escapedName") . ".'"; - } - - $body = $this->compileProgramWithBlockParams($block->program, $bp); + $bp = $block->program->blockParams ?? []; + $params = $this->compileParams($block->params, $block->hash); + $blockFn = $block->program !== null + ? $this->compileProgramWithBlockParams($block->program) + : self::blockClosure("''"); $else = $this->compileElseClause($block); - - return "'." . self::getRuntimeFunc('hbbch', "\$cx, '$helperName', $params, \$in, false, function(\$cx, \$in) {return $body;}$else, $escapedName") . ".'"; + $outerBp = $this->outerBlockParamsExpr(); + $helperName = self::quote($name); + return self::getRuntimeFunc('dynhbbch', "\$cx, $helperName, $varPath, $params, \$in, $blockFn, $else, " . count($bp) . ", $outerBp"); } private function DecoratorBlock(BlockStatement $block): string @@ -362,7 +334,10 @@ private function DecoratorBlock(BlockStatement $block): string // Do NOT add to partialCode - `in()` handles runtime registration, keeping inline partials block-scoped. $this->context->usedPartial[$partialName] = ''; - return "'." . self::getRuntimeFunc('in', "\$cx, " . self::quote($partialName) . ", function(\$cx, \$in) {return $body;}") . ".'"; + // Capture $blockParams if we're inside a block-param scope so the inline partial body can access them. + $useVars = $this->blockParamsUseVars(); + $escapedName = self::quote($partialName); + return self::getRuntimeFunc('in', "\$cx, $escapedName, " . self::templateClosure($body, useVars: $useVars)); } private function Decorator(Decorator $decorator): never @@ -374,16 +349,13 @@ private function PartialStatement(PartialStatement $statement): string { $name = $statement->name; - if ($name instanceof PathExpression) { - $p = self::quote($name->original); - $this->resolveAndCompilePartial($name->original); - } elseif ($name instanceof SubExpression) { + if ($name instanceof SubExpression) { $p = $this->SubExpression($name); $this->context->usedDynPartial++; - } elseif ($name instanceof NumberLiteral || $name instanceof StringLiteral) { - $literalName = $this->getLiteralKeyName($name); - $p = self::quote($literalName); - $this->resolveAndCompilePartial($literalName); + } elseif ($name instanceof PathExpression || $name instanceof StringLiteral || $name instanceof NumberLiteral) { + $partialName = $name instanceof PathExpression ? $name->original : $this->getLiteralKeyName($name); + $p = self::quote($partialName); + $this->resolveAndCompilePartial($partialName); } else { $p = $this->compileExpression($name); } @@ -395,10 +367,10 @@ private function PartialStatement(PartialStatement $statement): string // appendContent opcode) and invoke the partial with an empty indent so its lines are // not additionally indented. if ($this->context->options->preventIndent && $statement->indent !== '') { - return "'.$indent." . self::getRuntimeFunc('p', "\$cx, $p, $vars, 0, ''") . ".'"; + return "$indent." . self::getRuntimeFunc('p', "\$cx, $p, $vars, 0, ''"); } - return "'." . self::getRuntimeFunc('p', "\$cx, $p, $vars, 0, $indent") . ".'"; + return self::getRuntimeFunc('p', "\$cx, $p, $vars, 0, $indent"); } private function PartialBlockStatement(PartialBlockStatement $statement): string @@ -409,21 +381,18 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string // Hoist inline partial registrations so they run before the partial is called. // Without this, inline partials defined in the block would only be registered when // {{> @partial-block}} is invoked, too late for partials that call them directly. - $hoisted = ''; + $hoistedParts = []; foreach ($statement->program->body as $stmt) { if ($stmt instanceof BlockStatement && $stmt->type === 'DecoratorBlock') { - $hoisted .= $this->accept($stmt); + $hoistedParts[] = $this->accept($stmt); } } $name = $statement->name; $body = $this->compileProgram($statement->program); - if ($name instanceof PathExpression) { - $partialName = $name->original; - $p = self::quote($partialName); - } elseif ($name instanceof StringLiteral || $name instanceof NumberLiteral) { - $partialName = $this->getLiteralKeyName($name); + if ($name instanceof PathExpression || $name instanceof StringLiteral || $name instanceof NumberLiteral) { + $partialName = $name instanceof PathExpression ? $name->original : $this->getLiteralKeyName($name); $p = self::quote($partialName); } else { $p = $this->compileExpression($name); @@ -444,19 +413,25 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string } if (!$found) { - // Register fallback body as the partial - $func = "function (\$cx, \$in) {return $body;}"; + // Mark as known so LR::p() can resolve it at runtime. $this->context->usedPartial[$partialName] = ''; - $this->context->partialCode[$partialName] = self::quote($partialName) . " => $func"; + // Don't add to partialCode — register via LR::in() at runtime so $blockParams + // is captured from the enclosing scope when block params are in use. } } $vars = $this->compilePartialParams($statement->params, $statement->hash); - return $hoisted - . "'." - . self::getRuntimeFunc('in', "\$cx, '@partial-block$pid', function(\$cx, \$in) {return $body;}") . "." - . self::getRuntimeFunc('p', "\$cx, $p, $vars, $pid, ''") . ".'"; + // Capture $blockParams if we're inside a block-param scope so the partial block body can access them. + $useVars = $this->blockParamsUseVars(); + $fallbackParts = ($partialName !== null && !$found) + ? [self::getRuntimeFunc('inFallback', "\$cx, " . self::quote($partialName) . ', ' . self::templateClosure($body, useVars: $useVars))] + : []; + $parts = [...$hoistedParts, ...$fallbackParts, + self::getRuntimeFunc('in', "\$cx, '@partial-block$pid', " . self::templateClosure($body, useVars: $useVars)), + self::getRuntimeFunc('p', "\$cx, $p, $vars, $pid, ''"), + ]; + return implode('.', $parts); } private function MustacheStatement(MustacheStatement $mustache): string @@ -468,79 +443,61 @@ private function MustacheStatement(MustacheStatement $mustache): string if ($path instanceof PathExpression) { $helperName = $this->getSimpleHelperName($path); - // Registered helper - if ($helperName !== null && $this->resolveHelper($helperName)) { - $params = $this->compileParams($mustache->params, $mustache->hash); - $call = self::getRuntimeFunc('hbch', "\$cx, '$helperName', $params, \$in"); - return "'." . self::getRuntimeFunc($fn, $call) . ".'"; - } - - // Built-in: lookup - if ($helperName === 'lookup') { - return $this->compileLookup($mustache, $raw); - } - - // Built-in: log - if ($helperName === 'log') { - return $this->compileLog($mustache); + if ($helperName !== null && ($this->isKnownHelper($helperName) || $mustache->params || $mustache->hash !== null)) { + $call = $this->buildInlineHelperCall($helperName, $mustache->params, $mustache->hash); + return self::getRuntimeFunc($fn, $call); } - if ($mustache->params) { + if ($mustache->params || $mustache->hash !== null) { // Non-simple path with params (data var or pathed expression): invoke via dv() - if ($helperName === null) { - $varPath = $this->PathExpression($path); - $args = array_map(fn($p) => $this->compileExpression($p), $mustache->params); - $call = self::getRuntimeFunc('dv', "$varPath, " . implode(', ', $args)); - return "'." . self::getRuntimeFunc($fn, $call) . ".'"; - } - if (!$this->context->options->strict && $this->resolveHelper('helperMissing')) { - $params = $this->compileParams($mustache->params, $mustache->hash); - $escapedName = self::quote($helperName); - $call = self::getRuntimeFunc('hbch', "\$cx, 'helperMissing', $params, \$in, $escapedName"); - return "'." . self::getRuntimeFunc($fn, $call) . ".'"; - } - throw new \Exception('Missing helper: "' . $helperName . '"'); + $varPath = $this->PathExpression($path); + $args = array_map(fn($p) => $this->compileExpression($p), $mustache->params); + $call = self::getRuntimeFunc('dv', "$varPath, " . implode(', ', $args)); + return self::getRuntimeFunc($fn, $call); } - // Plain variable — if helperMissing is registered, route missing identifiers to it - if ($helperName !== null && !$this->context->options->strict && $this->resolveHelper('helperMissing')) { - $bpIdx = $this->lookupBlockParam($helperName); - if ($bpIdx === null) { - $escapedKey = self::quote($helperName); - $hbch = self::getRuntimeFunc('hbch', "\$cx, 'helperMissing', [[],[]], \$in, $escapedKey"); - $val = "(is_array(\$in) && array_key_exists($escapedKey, \$in) ? \$in[$escapedKey] : $hbch)"; - return "'." . self::getRuntimeFunc($fn, $val) . ".'"; + // When not strict/assumeObjects, check runtime helpers for bare identifiers. + if ($helperName !== null && !$this->context->options->strict && !$this->context->options->assumeObjects + && $this->lookupBlockParam($helperName) === null) { + $escapedKey = self::quote($helperName); + if ($this->context->options->knownHelpersOnly) { + return self::getRuntimeFunc($fn, self::getRuntimeFunc('cv', "\$in, $escapedKey")); } + return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', "\$cx, $escapedKey, \$in")); } - // Plain variable; wrap in dv() to support lambda context values + // Plain variable. Data variables (@foo) may be closures, so wrap in dv() to invoke + // them. Context variables (e.g. user.name) are never closures per the spec — all + // lambda tests use single-segment identifiers which go through hv()/cv() — and + // dv() doesn't pass context to them anyway, so skip it. $varPath = $this->PathExpression($path); - return "'." . self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $varPath)) . ".'"; + if (!$path->data) { + return self::getRuntimeFunc($fn, $varPath); + } + return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $varPath)); } // Literal path — treat as named context lookup or helper call $literalKey = $this->getLiteralKeyName($path); - if ($this->resolveHelper($literalKey)) { - $params = $this->compileParams($mustache->params, $mustache->hash); - $escapedKey = self::quote($literalKey); - $call = self::getRuntimeFunc('hbch', "\$cx, $escapedKey, $params, \$in"); - return "'." . self::getRuntimeFunc($fn, $call) . ".'"; + if ($this->isKnownHelper($literalKey) || $mustache->params || $mustache->hash !== null) { + $call = $this->buildInlineHelperCall($literalKey, $mustache->params, $mustache->hash); + return self::getRuntimeFunc($fn, $call); } - if ($mustache->params) { - throw new \Exception('Missing helper: "' . $literalKey . '"'); + $escapedKey = self::quote($literalKey); + + if (!$this->context->options->strict && !$this->context->options->knownHelpersOnly) { + return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', "\$cx, $escapedKey, \$in")); } - $escapedKey = self::quote($literalKey); $miss = $this->missValue($literalKey); - $val = "\$in[$escapedKey] ?? $miss"; - return "'." . self::getRuntimeFunc($fn, $val) . ".'"; + return self::getRuntimeFunc($fn, "\$in[$escapedKey] ?? $miss"); } private function ContentStatement(ContentStatement $statement): string { - return self::escape($statement->value); + return self::quote($statement->value); } private function CommentStatement(CommentStatement $statement): string @@ -553,31 +510,17 @@ private function CommentStatement(CommentStatement $statement): string private function SubExpression(SubExpression $expression): string { $path = $expression->path; - $helperName = null; - - if ($path instanceof PathExpression) { - $helperName = $this->getSimpleHelperName($path); - } elseif ($path instanceof Literal) { - $helperName = $this->getLiteralKeyName($path); - } - - // Registered helper - if ($helperName !== null && $this->resolveHelper($helperName)) { - $params = $this->compileParams($expression->params, $expression->hash); - $escapedName = self::quote($helperName); - return self::getRuntimeFunc('hbch', "\$cx, $escapedName, $params, \$in"); - } - - // Built-in: lookup (as subexpression) - if ($helperName === 'lookup') { - return $this->getWithLookup($expression->params[0], $expression->params[1]); - } + $helperName = match (true) { + $path instanceof Literal => $this->getLiteralKeyName($path), + $path instanceof PathExpression => $this->getSimpleHelperName($path), + default => null, + }; - if ($helperName !== null) { - throw new \Exception('Missing helper: "' . $helperName . '"'); + if ($helperName === null) { + throw new \Exception('Sub-expression must be a helper call'); } - throw new \Exception('Sub-expression must be a helper call'); + return $this->buildInlineHelperCall($helperName, $expression->params, $expression->hash); } private function PathExpression(PathExpression $expression): string @@ -605,11 +548,15 @@ private function PathExpression(PathExpression $expression): string // Check block params (depth-0, non-data, non-scoped paths only) if (!$data && $depth === 0 && !self::scopedId($expression)) { - $bpIdx = $this->lookupBlockParam($stringParts[0]); - if ($bpIdx !== null) { - $escapedName = self::quote($stringParts[0]); - $bpBase = "\$cx->blParam[$bpIdx][$escapedName]"; + $bp = $this->lookupBlockParam($stringParts[0]); + if ($bp !== null) { + [$bpDepth, $bpIndex] = $bp; + $bpBase = "\$blockParams[$bpDepth][$bpIndex]"; $remaining = self::buildKeyAccess(array_slice($stringParts, 1)); + // Mark the current compileProgram() level as having a direct $blockParams reference. + if ($this->bpRefStack) { + $this->bpRefStack[array_key_last($this->bpRefStack)] = true; + } return "$bpBase$remaining ?? $miss"; } } @@ -659,7 +606,8 @@ private function PathExpression(PathExpression $expression): string $escapedOriginal = self::quote($expression->original); $expr = $base; foreach ($stringParts as $part) { - $expr = self::getRuntimeFunc('strictLookup', "$expr, " . self::quote($part) . ", $escapedOriginal"); + $escapedKey = self::quote($part); + $expr = self::getRuntimeFunc('strictLookup', "$expr, $escapedKey, $escapedOriginal"); } return $expr; } @@ -709,19 +657,17 @@ private function getLiteralKeyName(Literal $literal): string } /** - * Find the $cx->blParam index for a block param name, or null if not a block param. - * Iterates blockParamValues from innermost to outermost; only non-empty levels - * increment the runtime blParam index. + * Return [$depth, $index] if $name is a block param in any enclosing scope, null otherwise. + * $depth=0 is the innermost scope; each outer scope increments $depth. + * @return array{int,int}|null */ - private function lookupBlockParam(string $name): ?int + private function lookupBlockParam(string $name): ?array { - $blParamIndex = 0; - foreach ($this->blockParamValues as $levelParams) { - if (in_array($name, $levelParams, true)) { - return $blParamIndex; - } - if ($levelParams) { - $blParamIndex++; + foreach ($this->blockParamValues as $depth => $levelParams) { + $index = array_search($name, $levelParams, true); + if ($index !== false) { + assert(is_int($index)); + return [$depth, $index]; } } return null; @@ -789,8 +735,7 @@ private function compilePartialTemplate(string $name, string $template): void $code = (new Compiler($this->parser))->compile($program, $tmpContext); $this->context->merge($tmpContext); - $func = "function (\$cx, \$in) {return '$code';}"; - $this->context->partialCode[$name] = self::quote($name) . " => $func"; + $this->context->partialCode[$name] = self::quote($name) . ' => ' . self::templateClosure($code); } public function handleDynamicPartials(): void @@ -807,12 +752,12 @@ public function handleDynamicPartials(): void // ── Helpers ────────────────────────────────────────────────────── /** - * Build the [[positional],[named]] or [[positional],[named],[blockParams]] param format. + * Build the positional and named param components as separate arguments. + * Returns '[$a,$b], [hash]'. * * @param Expression[] $params - * @param string[]|null $blockParams */ - private function compileParams(array $params, ?Hash $hash, ?array $blockParams = null): string + private function compileParams(array $params, ?Hash $hash): string { $savedHelperArgs = $this->compilingHelperArgs; $this->compilingHelperArgs = true; @@ -825,12 +770,11 @@ private function compileParams(array $params, ?Hash $hash, ?array $blockParams = $named = $hash ? $this->Hash($hash) : ''; $this->compilingHelperArgs = $savedHelperArgs; - $bp = $blockParams ? ',' . self::listString($blockParams) : ''; - return '[[' . implode(',', $positional) . '],[' . $named . "]$bp]"; + return '[' . implode(',', $positional) . '], [' . $named . ']'; } /** - * Build params for partial calls: [[$context],[named]]. + * Build context and hash arguments for partial calls: "$context, [named]". * * @param Expression[] $params */ @@ -844,7 +788,7 @@ private function compilePartialParams(array $params, ?Hash $hash): string $named = $hash ? $this->Hash($hash) : ''; - return "[[$contextVar],[$named]]"; + return "$contextVar, [$named]"; } /** @@ -876,29 +820,15 @@ private function getSimpleHelperName(PathExpression|Literal $path): ?string } /** - * Compile the else/inverse clause of a block as a trailing closure argument, or '' if absent. + * Compile the else/inverse clause of a block as a trailing closure argument, or 'null' if absent. */ private function compileElseClause(BlockStatement $block): string { - return $block->inverse - ? ", function(\$cx, \$in) {return " . $this->compileProgram($block->inverse) . ";}" - : ', null'; - } - - /** - * Compile a block program, pushing/popping block params around the compilation. - * @param string[] $bp - */ - private function compileProgramWithBlockParams(Program $program, array $bp): string - { - if ($bp) { - array_unshift($this->blockParamValues, $bp); + if (!$block->inverse) { + $this->lastCompileProgramHadDirectBpRef = false; + return 'null'; } - $body = $this->compileProgram($program); - if ($bp) { - array_shift($this->blockParamValues); - } - return $body; + return $this->compileProgramWithBlockParams($block->inverse); } /** @@ -906,11 +836,11 @@ private function compileProgramWithBlockParams(Program $program, array $bp): str */ private function buildBasePath(bool $data, int $depth): string { - $base = $data ? '$cx->data' : '$in'; + $base = $data ? '$cx->frame' : '$in'; if ($depth > 0) { $base = $data ? $base . str_repeat("['_parent']", $depth) - : "\$cx->scopes[count(\$cx->scopes)-$depth]"; + : "\$cx->depths[count(\$cx->depths)-$depth]"; } return $base; } @@ -937,23 +867,30 @@ private static function getRuntimeFunc(string $name, string $args): string return "LR::$name($args)"; } - private static function escape(string $string): string + /** + * @param bool $declaresBp true when this closure receives new block param values as its third argument + * @param bool $inheritsBp true when this closure must capture $blockParams from the enclosing scope + */ + private static function blockClosure(string $body, bool $declaresBp = false, bool $inheritsBp = false): string { - return addcslashes($string, "'\\"); + $preamble = ''; + if (str_contains($body, '$cx->depths[count($cx->depths)-')) { + $preamble = '$sc=count($cx->depths);'; + $body = str_replace('$cx->depths[count($cx->depths)-', '$cx->depths[$sc-', $body); + } + if ($declaresBp) { + return "function(\$cx, \$in, array \$blockParams = []) {{$preamble}return $body;}"; + } + if ($inheritsBp) { + // Inherits block params from the enclosing closure's $blockParams variable. + return "function(\$cx, \$in) use (\$blockParams) {{$preamble}return $body;}"; + } + return "function(\$cx, \$in) {{$preamble}return $body;}"; } private static function quote(string $string): string { - return "'" . self::escape($string) . "'"; - } - - /** - * Get string presentation for a string list - * @param string[] $list - */ - private static function listString(array $list): string - { - return '[' . implode(',', array_map(self::quote(...), $list)) . ']'; + return "'" . addcslashes($string, "'\\") . "'"; } private function missValue(string $key): string @@ -963,99 +900,44 @@ private function missValue(string $key): string : 'null'; } - /** @return array{string[], string} [$bp, $bs] */ - private function getProgramBlockParams(?Program $program): array - { - $bp = $program ? $program->blockParams : []; - $bs = self::listString($bp); - return [$bp, $bs]; - } - private function compileProgramOrEmpty(?Program $program): string { - return $program ? $this->compileProgram($program) : "''"; + if (!$program) { + $this->lastCompileProgramHadDirectBpRef = false; + return "''"; + } + return $this->compileProgram($program); } - /** - * Return only the string parts of a mixed parts array, re-indexed. - * @param array $parts - * @return list - */ - private static function stringPartsOf(array $parts): array + private function throwKnownHelpersOnly(string $helperName): never { - return array_values(array_filter($parts, fn($p) => is_string($p))); + throw new \Exception("You specified knownHelpersOnly, but used the unknown helper $helperName"); } /** - * Get includeZero value from hash. + * Build an hbch (known) or dynhbch (unknown) inline helper call string. + * @param Expression[] $params */ - private function getIncludeZero(?Hash $hash): string + private function buildInlineHelperCall(string $name, array $params, ?Hash $hash): string { - if ($hash) { - foreach ($hash->pairs as $pair) { - if ($pair->key === 'includeZero') { - return $this->compileExpression($pair->value); - } - } + $compiledParams = $this->compileParams($params, $hash); + $helperName = self::quote($name); + if ($this->isKnownHelper($name)) { + return self::getRuntimeFunc('hbch', "\$cx, \$cx->helpers[$helperName], $helperName, $compiledParams, \$in"); } - return 'false'; - } - - /** - * Compile {{lookup items idx}} in mustache context. - */ - private function compileLookup(MustacheStatement $mustache, bool $raw): string - { - $fn = $raw ? 'raw' : 'encq'; - - if (count($mustache->params) !== 2) { - throw new \Exception('{{lookup}} requires 2 arguments'); + if ($this->context->options->knownHelpersOnly) { + $this->throwKnownHelpersOnly($name); } - - $itemsExpr = $mustache->params[0]; - $idxExpr = $mustache->params[1]; - $varCode = $this->getWithLookup($itemsExpr, $idxExpr); - - return "'." . self::getRuntimeFunc($fn, $varCode) . ".'"; + return self::getRuntimeFunc('dynhbch', "\$cx, $helperName, $compiledParams, \$in"); } /** - * Compile a path with an additional dynamic lookup segment. - */ - private function compilePathWithLookup(PathExpression $path, string $lookupCode): string - { - $data = $path->data; - $depth = $path->depth; - $parts = self::stringPartsOf($path->parts); - - $base = $this->buildBasePath($data, $depth); - $n = self::buildKeyAccess($parts); - - $miss = $this->missValue($path->original); - - return $base . $n . "[$lookupCode] ?? $miss"; - } - - /** - * Compile {{log ...}} built-in. + * Return only the string parts of a mixed parts array, re-indexed. + * @param array $parts + * @return list */ - private function compileLog(MustacheStatement $mustache): string - { - $params = $this->compileParams($mustache->params, $mustache->hash); - return "'." . self::getRuntimeFunc('lo', $params) . ".'"; - } - - private function getWithLookup(Expression $itemsExpr, Expression $idxExpr): string + private static function stringPartsOf(array $parts): array { - $idxCode = $this->compileExpression($idxExpr); - - if ($itemsExpr instanceof PathExpression) { - $varCode = $this->compilePathWithLookup($itemsExpr, $idxCode); - } else { - $itemsCode = $this->compileExpression($itemsExpr); - $miss = $this->missValue('lookup'); - $varCode = $itemsCode . "[$idxCode] ?? $miss"; - } - return $varCode; + return array_values(array_filter($parts, fn($p) => is_string($p))); } } diff --git a/src/Context.php b/src/Context.php index da1a7e9..9b9fd4b 100644 --- a/src/Context.php +++ b/src/Context.php @@ -10,11 +10,9 @@ final class Context /** * @param array $usedPartial * @param array $partialCode - * @param array $usedHelpers * @param array $partials * @param array $partialBlock * @param array $inlinePartial - * @param array $helpers */ public function __construct( public readonly Options $options, @@ -23,14 +21,11 @@ public function __construct( public int $usedDynPartial = 0, public int $usedPBlock = 0, public int $partialBlockId = 0, - public array $usedHelpers = [], public array $partials = [], public array $partialBlock = [], public array $inlinePartial = [], - public array $helpers = [], ) { $this->partials = $options->partials; - $this->helpers = $options->helpers; } /** @@ -38,10 +33,8 @@ public function __construct( */ public function merge(self $context): void { - $this->helpers = $context->helpers; $this->partials = $context->partials; $this->partialCode = $context->partialCode; - $this->usedHelpers = $context->usedHelpers; $this->usedDynPartial = $context->usedDynPartial; $this->usedPBlock = $context->usedPBlock; $this->partialBlockId = $context->partialBlockId; diff --git a/src/Exporter.php b/src/Exporter.php deleted file mode 100644 index 93acbe1..0000000 --- a/src/Exporter.php +++ /dev/null @@ -1,96 +0,0 @@ -helpers as $name => $func) { - if (!isset($context->usedHelpers[$name])) { - continue; - } - if ($func instanceof \Closure) { - $ret .= (" '$name' => " . static::closure($func) . ",\n"); - continue; - } - if (is_string($func)) { - $ret .= " '$name' => '$func',\n"; - } else { - throw new \Exception('Unexpected helper type: ' . gettype($func)); - } - } - - return "[$ret]"; - } - - public static function getClosureSource(\ReflectionFunction $fn): string - { - $filename = $fn->getFileName(); - if ($filename === false) { - throw new \Exception("Failed to get file for closure"); - } - $fileContents = file_get_contents($filename); - if ($fileContents === false) { - throw new \Exception("Unable to read file: $filename"); - } - $startLine = $fn->getStartLine(); - $endLine = $fn->getEndLine(); - $enteredFnToken = null; - $depth = 0; - $code = ''; - - foreach (\PhpToken::tokenize($fileContents) as $token) { - if ($token->line < $startLine) { - continue; - } elseif ($token->line > $endLine) { - break; - } elseif (!$enteredFnToken) { - if ($token->id !== T_FUNCTION && $token->id !== T_FN) { - continue; - } - $enteredFnToken = $token; - } - - $name = $token->getTokenName(); - - if (in_array($name, ['(', '[', '{', 'T_CURLY_OPEN'])) { - $depth++; - } elseif (in_array($name, [')', ']', '}'])) { - if ($depth === 0 && $enteredFnToken->id === T_FN) { - return rtrim($code); - } - $depth--; - } - - if ($depth === 0) { - if ($enteredFnToken->id === T_FUNCTION && $name === '}') { - $code .= $token->text; - return $code; - } elseif ($enteredFnToken->id === T_FN && in_array($name, [';', ','])) { - return $code; - } - } - - $code .= $token->text; - } - - return $code; - } -} diff --git a/src/Handlebars.php b/src/Handlebars.php index eb2bbcc..38cdc43 100644 --- a/src/Handlebars.php +++ b/src/Handlebars.php @@ -4,11 +4,15 @@ use DevTheorem\HandlebarsParser\ParserFactory; +/** + * @phpstan-type RenderOptions array{data?: array, helpers?: array, partials?: array} + * @phpstan-type Template \Closure(mixed=, RenderOptions=): string + */ final class Handlebars { /** * Compiles a template so it can be executed immediately. - * @return \Closure(mixed=, array=):string + * @return Template */ public static function compile(string $template, Options $options = new Options()): \Closure { @@ -33,6 +37,7 @@ public static function precompile(string $template, Options $options = new Optio /** * Sets up a template that was precompiled with precompile(). + * @return Template */ public static function template(string $templateSpec): \Closure { diff --git a/src/HelperOptions.php b/src/HelperOptions.php index 2796ae7..075b6b2 100644 --- a/src/HelperOptions.php +++ b/src/HelperOptions.php @@ -2,29 +2,171 @@ namespace DevTheorem\Handlebars; +use Closure; + +/** @internal */ +enum Scope +{ + /** Sentinel default for fn()/inverse() meaning "use the current scope unchanged". */ + case Use; +} + class HelperOptions { /** - * @param array $hash * @param array $data + * @param array $hash + * @param array $outerBlockParams outer block param stack, passed as trailing elements of the stack */ public function __construct( - public readonly string $name, - public readonly array $hash, - public readonly \Closure $fn, - public readonly \Closure $inverse, - public readonly int $blockParams, public mixed &$scope, public array &$data, + public readonly string $name = '', + public readonly array $hash = [], + public readonly int $blockParams = 0, + private readonly ?RuntimeContext $cx = null, + private readonly ?Closure $cb = null, + private readonly ?Closure $inv = null, + private readonly array $outerBlockParams = [], ) {} - public function fn(mixed ...$args): string + /** + * Allows isset($options->fn) and isset($options->inverse) to check whether the block exists. + */ + public function __isset(string $name): bool + { + if ($name === 'fn') { + return $this->cb !== null; + } elseif ($name === 'inverse') { + return $this->inv !== null; + } + return false; + } + + public function fn(mixed $context = Scope::Use, mixed $data = null): string + { + if ($this->cx === null) { + throw new \Exception('fn() is not supported for inline helpers'); + } elseif ($this->cb === null) { + return ''; + } + $cx = $this->cx; + $scope = $this->scope; + + // Save partials so that any {{#* inline}} partials registered inside the block body + // don't leak out after fn() returns. The spec requires inline partials to be + // block-scoped. PHP copy-on-write makes this assignment cheap when no inline partials are registered. + $savedPartials = $cx->partials; + + // Skip depths push for explicit same-context pass (equivalent to HBS.js options.fn(this)) + $skipDepths = $context === $scope; + $resolvedContext = $skipDepths ? $scope : ($context === Scope::Use ? $scope : $context); + $ret = $this->callBlock($this->cb, $resolvedContext, !$skipDepths, $data); + + $cx->partials = $savedPartials; + return $ret; + } + + public function inverse(mixed $context = null, mixed $data = null): string { - return ($this->fn)(...$args); + if ($this->cx === null) { + throw new \Exception('inverse() is not supported for inline helpers'); + } elseif ($this->inv === null) { + return ''; + } + return $this->callBlock($this->inv, $context ?? $this->scope, $context !== null, $data); } - public function inverse(mixed ...$args): string + /** @param array|null $data */ + private function callBlock(\Closure $closure, mixed $context, bool $pushDepths, ?array $data): string { - return ($this->inverse)(...$args); + $cx = $this->cx; + assert($cx !== null); + $savedFrame = null; + $bpStack = null; + + if (isset($data['data'])) { + $savedFrame = $cx->frame; + // Fast path: only root in frame, no user @-data to inherit + $newFrame = count($savedFrame) === 1 ? $data['data'] : array_replace($savedFrame, $data['data']); + $newFrame['root'] = &$cx->data['root']; + $newFrame['_parent'] = $savedFrame; + $cx->frame = $newFrame; + } + + if (isset($data['blockParams'])) { + // Build block params stack: current level prepended to outer stack. + $bpStack = [$data['blockParams'], ...$this->outerBlockParams]; + } + + if ($pushDepths) { + // Push the current scope onto depths so that ../ path expressions inside the block + // body can traverse back up to the caller's context. + $cx->depths[] = $this->scope; + } + $ret = $closure($cx, $context, $bpStack); + if ($pushDepths) { + array_pop($cx->depths); + } + if ($savedFrame !== null) { + $cx->frame = $savedFrame; + } + return $ret; + } + + /** + * Optimized iteration for each-like helpers: performs depths push and partials save/restore + * once around the entire loop rather than once per fn() call. + * + * HBS.js achieves the same effect by capturing the depths array at sub-program creation time + * (before the loop), so all iterations share the same static depths reference. + * + * @param array $items + * @internal + */ + public function iterate(array $items): string + { + if (!$items) { + return $this->inverse(); + } + if ($this->cb === null) { + return ''; + } + $cx = $this->cx; + assert($cx !== null); + $cb = $this->cb; + + // Push depths and save partials once for the entire loop. + $cx->depths[] = $this->scope; + $savedPartials = $cx->partials; + + $last = count($items) - 1; + $ret = ''; + $i = 0; + $outerFrame = $cx->frame; + // Fast path: when only root is in the frame, skip array_replace. + $simpleFrame = count($outerFrame) === 1; + // Pre-allocate bpStack once; mutate [0][0] and [0][1] per iteration. + // PHP COW ensures the inner array's refcount returns to 1 after $cb() returns, + // so the next iteration's assignment is an in-place mutation, not a copy. + $bpStack = [[null, null], ...$this->outerBlockParams]; + + foreach ($items as $index => $value) { + $iterData = ['key' => $index, 'index' => $i, 'first' => $i === 0, 'last' => $i === $last]; + $newFrame = $simpleFrame ? $iterData : array_replace($outerFrame, $iterData); + $newFrame['root'] = &$cx->data['root']; + $newFrame['_parent'] = $outerFrame; + $cx->frame = $newFrame; + + $bpStack[0][0] = $value; + $bpStack[0][1] = $index; + $ret .= $cb($cx, $value, $bpStack); + $i++; + } + + $cx->frame = $outerFrame; + array_pop($cx->depths); + $cx->partials = $savedPartials; + return $ret; } } diff --git a/src/Options.php b/src/Options.php index 4943337..e89d428 100644 --- a/src/Options.php +++ b/src/Options.php @@ -6,12 +6,16 @@ readonly class Options { + /** @var array */ + public array $knownHelpers; + /** - * @param array $helpers + * @param array $knownHelpers * @param array $partials * @param null|Closure(string):(string|null) $partialResolver */ public function __construct( + array $knownHelpers = [], public bool $knownHelpersOnly = false, public bool $noEscape = false, public bool $strict = false, @@ -19,8 +23,10 @@ public function __construct( public bool $preventIndent = false, public bool $ignoreStandalone = false, public bool $explicitPartialContext = false, - public array $helpers = [], public array $partials = [], public ?Closure $partialResolver = null, - ) {} + ) { + $builtIn = ['if' => true, 'unless' => true, 'each' => true, 'with' => true, 'lookup' => true, 'log' => true]; + $this->knownHelpers = array_replace($builtIn, $knownHelpers); + } } diff --git a/src/Runtime.php b/src/Runtime.php index 18ba789..2722008 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -6,9 +6,111 @@ /** * @internal + * @phpstan-import-type RenderOptions from Handlebars */ final class Runtime { + /** @var array|null */ + private static ?array $defaultHelpers = null; + /** Parent RuntimeContext during a user-partial invocation, null at top level. */ + private static ?RuntimeContext $partialContext = null; + + /** + * Default implementations of the built-in Handlebars helpers. + * These are pre-registered in every runtime context and can be overridden. + * + * @return array + */ + public static function defaultHelpers(): array + { + return self::$defaultHelpers ??= [ + 'if' => static function (mixed ...$args): string { + if (count($args) !== 2) { + throw new \Exception('#if requires exactly one argument'); + } + /** @var HelperOptions $options */ + $options = $args[1]; + $condition = $args[0] instanceof \Closure ? $args[0]($options->scope) : $args[0]; + return static::ifvar($condition, (bool) ($options->hash['includeZero'] ?? false)) + ? $options->fn($options->scope) + : $options->inverse(); + }, + 'unless' => static function (mixed ...$args): string { + if (count($args) !== 2) { + throw new \Exception('#unless requires exactly one argument'); + } + /** @var HelperOptions $options */ + $options = $args[1]; + $condition = $args[0] instanceof \Closure ? $args[0]($options->scope) : $args[0]; + return static::ifvar($condition, (bool) ($options->hash['includeZero'] ?? false)) + ? $options->inverse() + : $options->fn($options->scope); + }, + 'each' => static function (mixed $context, ?HelperOptions $options = null): string { + if (!$options) { + throw new \Exception('Must pass iterator to #each'); + } + if ($context instanceof \Closure) { + $context = $context($options->scope); + } + if ($context instanceof \Traversable) { + $context = iterator_to_array($context); + } elseif (!is_array($context)) { + $context = []; + } + return $options->iterate($context); + }, + 'with' => static function (mixed ...$args): string { + if (count($args) !== 2) { + throw new \Exception('#with requires exactly one argument'); + } + /** @var HelperOptions $options */ + $options = $args[1]; + $context = $args[0] instanceof \Closure ? $args[0]($options->scope) : $args[0]; + if (static::ifvar($context)) { + return $options->fn($context, ['blockParams' => [$context]]); + } + return $options->inverse(); + }, + 'lookup' => static function (mixed $obj, string|int $key): mixed { + if (is_array($obj)) { + return $obj[$key] ?? null; + } + if (is_object($obj)) { + return $obj->$key ?? null; + } + return null; + }, + 'log' => static function (mixed ...$args): string { + array_pop($args); // remove HelperOptions + error_log(var_export($args, true)); + return ''; + }, + 'helperMissing' => static function (mixed ...$args): mixed { + /** @var HelperOptions $options */ + $options = end($args); + if (count($args) === 1 && !isset($options->fn)) { + // Bare variable lookup with no match — return null (mirrors HBS.js undefined). + return null; + } + throw new \Exception('Missing helper: "' . $options->name . '"'); + }, + 'blockHelperMissing' => static function (mixed $context, HelperOptions $options): string { + if ($context instanceof \Traversable) { + $context = iterator_to_array($context); + } + if (is_array($context)) { + return array_is_list($context) ? $options->iterate($context) : $options->fn($context); + } + if ($context === false || $context === null) { + return $options->inverse(); + } + // true renders with the outer scope unchanged; any other truthy value becomes the new scope. + return $options->fn($context === true ? $options->scope : $context); + }, + ]; + } + /** * Throw exception for missing expression. Only used in strict mode. */ @@ -29,6 +131,41 @@ public static function strictLookup(mixed $base, string $key, string $original): return $base[$key]; } + /** + * Build a RuntimeContext from raw render options and compile-time partial closures. + * + * @param RenderOptions $options + * @param array $compiledPartials + */ + public static function createContext(mixed $context, array $options, array $compiledPartials): RuntimeContext + { + $parentCx = self::$partialContext; + $root = ['root' => $context]; + + if ($parentCx !== null) { + // Partial context: reuse the parent's already-merged helpers and partials directly. + // PHP copy-on-write ensures partials is only copied if in() registers a new inline partial. + // Inherit the parent's current frame so @index, @key, etc. remain accessible inside partials. + // templateClosure will update frame['root'] to reference this partial's own data['root']. + return new RuntimeContext( + helpers: $parentCx->helpers, + partials: $parentCx->partials, + depths: $parentCx->depths, + data: $root, + partialId: $parentCx->partialId, + frame: $parentCx->frame, + ); + } + + $data = $options['data'] ?? []; + return new RuntimeContext( + helpers: array_replace(Runtime::defaultHelpers(), $options['helpers'] ?? []), + partials: array_replace($compiledPartials, $options['partials'] ?? []), + data: ['root' => $data['root'] ?? $context], + frame: $data, + ); + } + /** * Invoke $v if it is callable, passing any extra args; otherwise return $v as-is. * Used for data variables that may hold functions (e.g. {{@hello}} or {{@hello "arg"}}). @@ -39,13 +176,34 @@ public static function dv(mixed $v, mixed ...$args): mixed } /** - * For {{log}}. - * @param array $v + * Context variable lookup for knownHelpersOnly mode. + * Looks up $name in $_this; if the value is a Closure, invokes it with $_this as context. + * Skips helper dispatch (the compiler has already ruled out known helpers). + * + * @param mixed $_this current rendering context */ - public static function lo(array $v): string + public static function cv(mixed &$_this, string $name): mixed { - error_log(var_export($v[0], true)); - return ''; + $v = is_array($_this) ? ($_this[$name] ?? null) : null; + return $v instanceof \Closure ? $v($_this) : $v; + } + + /** + * Helper-or-variable lookup for bare {{identifier}} expressions. + * Checks runtime helpers first, then context value, then helperMissing fallback. + * + * @param mixed $_this current rendering context + */ + public static function hv(RuntimeContext $cx, string $name, mixed &$_this): mixed + { + $helper = $cx->helpers[$name] ?? null; + if ($helper !== null) { + return static::hbch($cx, $helper, $name, [], [], $_this); + } + if (is_array($_this) && array_key_exists($name, $_this)) { + return static::dv($_this[$name]); + } + return static::hbch($cx, $cx->helpers['helperMissing'], $name, [], [], $_this); } /** @@ -67,7 +225,7 @@ public static function ifvar(mixed $v, bool $zero = false): bool } /** - * For {{^var}} . + * Returns true if an inverse block {{^var}} should be rendered. * * @param array|string|int>|string|int|bool|null $v value to be tested * @@ -78,6 +236,17 @@ public static function isec(mixed $v): bool return $v === null || $v === false || (is_array($v) && !$v); } + /** + * Inverted section with runtime helper check. + */ + public static function isech(RuntimeContext $cx, mixed $v, mixed $in, \Closure $else, string $helperName): string + { + if (isset($cx->helpers[$helperName])) { + return static::hbbch($cx, $cx->helpers[$helperName], $helperName, [], [], $in, null, $else); + } + return static::hbbch($cx, $cx->helpers['blockHelperMissing'], $helperName, [$v], [], $in, null, $else); + } + /** * HTML encode {{var}} just like handlebars.js * @@ -123,161 +292,32 @@ public static function raw(array|string|StringObject|int|bool|null $v): string } /** - * For {{#var}} or {{#each}} . + * For {{#var}} sections. * - * @param array|string|int>|string|int|bool|null|\Closure|\Traversable $v value for the section - * @param array $bp block parameters - * @param array|string|int>|string|int|null $in input data with current scope - * @param bool $each true when rendering #each + * @param mixed $in input data with current scope * @param \Closure $cb callback function to render child context * @param \Closure|null $else callback function to render child context when {{else}} */ - public static function sec(RuntimeContext $cx, mixed $v, array $bp, mixed $in, bool $each, \Closure $cb, ?\Closure $else = null): string + public static function sec(RuntimeContext $cx, mixed $value, mixed $in, \Closure $cb, ?\Closure $else = null, ?string $helperName = null): string { - if ($else !== null && (is_array($v) || $v instanceof \ArrayObject)) { - return $else($cx, $in); - } - - $push = $in !== $v || $each; - $isAry = is_array($v) || $v instanceof \ArrayObject; - $isTrav = $v instanceof \Traversable; - $loop = $each || (is_array($v) && array_is_list($v)); - - if (($loop && $isAry || $isTrav) && is_iterable($v)) { - $last = null; - $isObj = false; - $isSparseArray = false; - if (is_array($v)) { - $keys = array_keys($v); - $last = count($keys) - 1; - $isObj = !array_is_list($v); - $isSparseArray = $isObj && !array_filter($keys, is_string(...)); - } - $ret = []; - $cx = clone $cx; - if ($push) { - $cx->scopes[] = $in; - } - $i = 0; - $oldData = $cx->data ?? []; - $cx->data = array_merge(['root' => $oldData['root'] ?? null], $oldData, ['_parent' => $oldData]); - - foreach ($v as $index => $raw) { - $cx->data['first'] = ($i === 0); - $cx->data['last'] = ($i === $last); - $cx->data['key'] = $index; - $cx->data['index'] = $isSparseArray ? $index : $i; - $i++; - if ($bp) { - $bpEntry = []; - if (isset($bp[0])) { - $bpEntry[$bp[0]] = $raw; - $raw = static::merge($raw, [$bp[0] => $raw]); - } - if (isset($bp[1])) { - $bpEntry[$bp[1]] = $index; - $raw = static::merge($raw, [$bp[1] => $index]); - } - array_unshift($cx->blParam, $bpEntry); - } - $ret[] = $cb($cx, $raw); - if ($bp) { - array_shift($cx->blParam); - } - } - - if ($isObj) { - unset($cx->data['key']); - } else { - unset($cx->data['last']); - } - unset($cx->data['index'], $cx->data['first']); - - if ($push) { - array_pop($cx->scopes); - } - return join('', $ret); - } - - if ($each) { - return ($else !== null) ? $else($cx, $in) : ''; + if ($helperName !== null && isset($cx->helpers[$helperName])) { + return static::hbbch($cx, $cx->helpers[$helperName], $helperName, [], [], $in, $cb, $else); } - if ($isAry) { - if ($push) { - $cx->scopes[] = $in; - } - $ret = $cb($cx, $v); - if ($push) { - array_pop($cx->scopes); - } - return $ret; - } - - if ($v instanceof \Closure) { - $options = new HelperOptions( - name: '', - hash: [], - fn: function ($context = null) use ($cx, $in, $cb) { - if ($context === null || $context === $in) { - return $cb($cx, $in); - } - return static::withScope($cx, $in, $context, $cb); - }, - inverse: function ($context = null) use ($cx, $in, $else) { - if ($else === null) { - return ''; - } - if ($context === null || $context === $in) { - return $else($cx, $in); - } - return static::withScope($cx, $in, $context, $else); - }, - blockParams: 0, + // Lambda functions in block position receive HelperOptions directly. + // This must be checked before blockHelperMissing routing. + if ($value instanceof \Closure) { + $result = $value(new HelperOptions( scope: $in, - data: $cx->data, - ); - $result = $v($options); - return static::applyBlockHelperMissing($cx, $result, $in, $cb, $else); - } - - if ($v !== null && $v !== false) { - return $cb($cx, $v === true ? $in : $v); - } - - return $else !== null ? $else($cx, $in) : ''; - } - - /** - * For {{#with}} . - * - * @param array|string|int>|string|int|null $v value to be the new context - * @param array $bp block parameters - * @param array|string|int>|\stdClass|null $in input data with current scope - * @param \Closure $cb callback function to render child context - * @param \Closure|null $else callback function to render child context when {{else}} - */ - public static function wi(RuntimeContext $cx, mixed $v, array $bp, array|\stdClass|null $in, \Closure $cb, ?\Closure $else = null): string - { - if (isset($bp[0])) { - $v = static::merge($v, [$bp[0] => $v]); - } - - if ($v === null || is_array($v) && !$v) { - return $else ? $else($cx, $in) : ''; + data: $cx->frame, + cx: $cx, + cb: $cb, + inv: $else, + )); + return static::resolveBlockResult($cx, $result, $in, $cb, $else); } - $savedPartials = $cx->partials; - - if ($v === $in) { - $ret = $cb($cx, $v); - } else { - $ret = static::withScope($cx, $in, $v, $cb); - } - - $cx->partials = $savedPartials; - - return $ret; + return static::hbbch($cx, $cx->helpers['blockHelperMissing'], $helperName ?? '', [$value], [], $in, $cb, $else); } /** @@ -310,22 +350,31 @@ public static function merge(mixed $a, mixed $b): mixed /** * For {{> partial}} . * - * @param string $p partial name - * @param array> $v value to be the new context + * @param string $name partial name + * @param mixed $context the partial's context value + * @param array $hash named hash overrides merged into the context * @param string $indent whitespace to prepend to each line of the partial's output */ - public static function p(RuntimeContext $cx, string $p, array $v, int $pid, string $indent): string + public static function p(RuntimeContext $cx, string $name, mixed $context, array $hash, int $pid, string $indent): string { - $pp = ($p === '@partial-block') ? $p . ($pid > 0 ? $pid : $cx->partialId) : $p; + $pp = ($name === '@partial-block') ? $name . ($pid > 0 ? $pid : $cx->partialId) : $name; - if (!isset($cx->partials[$pp])) { - throw new \Exception("The partial $p could not be found"); + $fn = $cx->partials[$pp] ?? null; + if ($fn === null) { + throw new \Exception("The partial $name could not be found"); } $savedPartialId = $cx->partialId; - $cx->partialId = ($p === '@partial-block') ? ($pid > 0 ? $pid : ($cx->partialId > 0 ? $cx->partialId - 1 : 0)) : $pid; + $cx->partialId = ($name === '@partial-block') ? ($pid > 0 ? $pid : ($cx->partialId > 0 ? $cx->partialId - 1 : 0)) : $pid; - $result = $cx->partials[$pp]($cx, static::merge($v[0][0], $v[1])); + $context = $hash ? static::merge($context, $hash) : $context; + $prev = self::$partialContext; + self::$partialContext = $cx; + try { + $result = $fn($context); + } finally { + self::$partialContext = $prev; + } $cx->partialId = $savedPartialId; if ($indent !== '') { @@ -344,217 +393,158 @@ public static function p(RuntimeContext $cx, string $p, array $v, int $pid, stri return $result; } + /** + * Like in() but only registers the partial if it is not already present in the context. + * Used for {{#> partial}}fallback{{/partial}} blocks when the partial was not found at compile time. + * + * @param string $name partial name + * @param \Closure $partial the compiled fallback partial + */ + public static function inFallback(RuntimeContext $cx, string $name, \Closure $partial): string + { + $cx->partials[$name] ??= $partial; + return ''; + } + /** * For {{#* inlinepartial}} . * - * @param string $p partial name - * @param \Closure $code the compiled partial code + * @param string $name partial name + * @param \Closure $partial the compiled partial */ - public static function in(RuntimeContext $cx, string $p, \Closure $code): void + public static function in(RuntimeContext $cx, string $name, \Closure $partial): string { - if (str_starts_with($p, '@partial-block')) { + if (str_starts_with($name, '@partial-block')) { // Capture the outer partialId at registration time so that when this // block closure runs, any {{>@partial-block}} inside it resolves to - // the correct outer partial block (not partialId - 1). + // the correct outer partial block rather than following the pid-decrement chain. $outerPartialId = $cx->partialId; - $cx->partials[$p] = function (RuntimeContext $cx, mixed $in) use ($code, $outerPartialId): string { - $cx->partialId = $outerPartialId; - return $code($cx, $in); + $cx->partials[$name] = function (mixed $context = null, array $options = []) use ($partial, $outerPartialId): string { + $callingCx = self::$partialContext; + assert($callingCx !== null); + $savedId = $callingCx->partialId; + $callingCx->partialId = $outerPartialId; + $result = $partial($context, $options); + $callingCx->partialId = $savedId; + return $result; }; } else { - $cx->partials[$p] = $code; + $cx->partials[$name] = $partial; } + return ''; } /** - * For single custom helpers. - * - * @param string $ch the name of custom helper to be executed - * @param array> $vars variables for the helper - * @param array|string|int> $_this current rendering context for the helper - * @param string|null $logicalName when set, use as options.name instead of $ch + * @param array $positional + * @param array $hash */ - public static function hbch(RuntimeContext $cx, string $ch, array $vars, mixed &$_this, ?string $logicalName = null): mixed + public static function dynhbch(RuntimeContext $cx, string $name, array $positional, array $hash, mixed &$_this): mixed { - if (isset($cx->blParam[0][$ch])) { - return $cx->blParam[0][$ch]; + $helper = $cx->helpers[$name] ?? null; + if ($helper !== null) { + return static::hbch($cx, $helper, $name, $positional, $hash, $_this); } - $options = new HelperOptions( - name: $logicalName ?? $ch, - hash: $vars[1], - fn: fn() => '', - inverse: fn() => '', - blockParams: 0, - scope: $_this, - data: $cx->data, - ); + $fn = $_this[$name] ?? null; + if ($fn instanceof \Closure) { + return static::hbch($cx, $fn, $name, $positional, $hash, $_this); + } + + if (!$positional && !$hash) { + // No arguments: must be a helper call (e.g. sub-expression), not a property lookup. + throw new \Exception('Missing helper: "' . $name . '"'); + } - return static::exch($cx, $ch, $vars, $options); + return static::hbch($cx, $cx->helpers['helperMissing'], $name, $positional, $hash, $_this); } /** - * For block custom helpers. + * For single known helpers. * - * @param string $ch the name of custom helper to be executed - * @param array> $vars variables for the helper - * @param array|string|int> $_this current rendering context for the helper - * @param bool $inverted the logic will be inverted - * @param \Closure $cb callback function to render child context - * @param \Closure|null $else callback function to render child context when {{else}} - * @param string|null $logicalName when set, use as options.name instead of $ch + * @param array $positional + * @param array $hash + * @param mixed $_this current rendering context for the helper */ - public static function hbbch(RuntimeContext $cx, string $ch, array $vars, mixed &$_this, bool $inverted, \Closure $cb, ?\Closure $else = null, ?string $logicalName = null): mixed + public static function hbch(RuntimeContext $cx, \Closure $helper, string $name, array $positional, array $hash, mixed &$_this): mixed { - $blockParams = isset($vars[2]) ? count($vars[2]) : 0; - $data = &$cx->data; - - // invert the logic - if ($inverted) { - $tmp = $else; - $else = $cb; - $cb = $tmp; - } - $options = new HelperOptions( - name: $logicalName ?? $ch, - hash: $vars[1], - fn: static::makeBlockFn($cx, $_this, $cb, $vars), - inverse: static::makeInverseFn($cx, $_this, $else), - blockParams: $blockParams, scope: $_this, - data: $data, + data: $cx->frame, + name: $name, + hash: $hash, ); - - return static::applyBlockHelperMissing($cx, static::exch($cx, $ch, $vars, $options), $_this, $cb, $else); + $args = $positional; + $args[] = $options; + return $helper(...$args); } /** - * Like hbbch but for non-registered paths (pathed/depthed/scoped block calls with params). - * @param array> $vars variables for the helper - * @param array|string|int> $_this current rendering context for the helper - * @param \Closure $cb callback function to render child context + * For block custom helpers. + * + * @param array $positional + * @param array $hash + * @param mixed $_this current rendering context for the helper + * @param \Closure|null $cb callback function to render child context (null for inverted blocks) * @param \Closure|null $else callback function to render child context when {{else}} + * @param array $outerBlockParams outer block param stack for block params declared by the template */ - public static function dynhbbch(RuntimeContext $cx, string $name, mixed $callable, array $vars, mixed &$_this, \Closure $cb, ?\Closure $else = null): mixed + public static function hbbch(RuntimeContext $cx, \Closure $helper, string $name, array $positional, array $hash, mixed &$_this, ?\Closure $cb, ?\Closure $else, int $blockParamCount = 0, array $outerBlockParams = []): string { - if (!$callable instanceof \Closure) { - throw new \Exception('"' . $name . '" is not a block helper function'); - } - - $blockParams = isset($vars[2]) ? count($vars[2]) : 0; - $data = &$cx->data; - - $options = new HelperOptions( - name: '', - hash: $vars[1], - fn: static::makeBlockFn($cx, $_this, $cb, $vars), - inverse: static::makeInverseFn($cx, $_this, $else), - blockParams: $blockParams, + $positional[] = new HelperOptions( scope: $_this, - data: $data, + data: $cx->frame, + name: $name, + hash: $hash, + blockParams: $blockParamCount, + cx: $cx, + cb: $cb, + inv: $else, + outerBlockParams: $outerBlockParams, ); - - $args = $vars[0]; - $args[] = $options; - try { - $result = $callable(...$args); - } catch (\Throwable $e) { - throw new \Exception('Runtime: dynamic block helper error: ' . $e->getMessage()); - } - - return static::applyBlockHelperMissing($cx, $result, $_this, $cb, $else); + return static::resolveBlockResult($cx, $helper(...$positional), $_this, $cb, $else); } /** - * Build the $fn closure passed to HelperOptions for block helpers. - * Handles private variable updates, block-param injection, and context scope pushing. - * - * @param array> $vars + * Like hbbch but for non-registered paths (pathed/depthed/scoped block calls with params). + * @param array $positional + * @param array $hash + * @param array|string|int> $_this current rendering context for the helper + * @param \Closure $cb callback function to render child context + * @param \Closure|null $else callback function to render child context when {{else}} + * @param array $outerBlockParams outer block param stack for block params declared by the template */ - private static function makeBlockFn(RuntimeContext $cx, mixed $_this, ?\Closure $cb, array $vars): \Closure + public static function dynhbbch(RuntimeContext $cx, string $name, mixed $callable, array $positional, array $hash, mixed &$_this, \Closure $cb, ?\Closure $else, int $blockParamCount, array $outerBlockParams): mixed { - if (!$cb) { - return fn() => ''; + $helper = $cx->helpers[$name] ?? null; + if ($helper !== null) { + return static::hbbch($cx, $helper, $name, $positional, $hash, $_this, $cb, $else, $blockParamCount, $outerBlockParams); } - return function ($context = null, $data = null) use ($cx, $_this, $cb, $vars) { - $cx = clone $cx; - $oldData = $cx->data; - if (isset($data['data'])) { - $cx->data = array_merge(['root' => $oldData['root']], $data['data'], ['_parent' => $oldData]); - } - - if (isset($data['blockParams'], $vars[2])) { - $ex = array_combine($vars[2], array_slice($data['blockParams'], 0, count($vars[2]))); - array_unshift($cx->blParam, $ex); - } - - if ($context === null || $context === $_this) { - $ret = $cb($cx, $_this); - } else { - $ret = static::withScope($cx, $_this, $context, $cb); - } - - if (isset($data['data'])) { - $cx->data = $oldData; - } - return $ret; - }; - } + if (!$callable instanceof \Closure) { + return static::hbbch($cx, $cx->helpers['helperMissing'], $name, $positional, $hash, $_this, $cb, $else, $blockParamCount, $outerBlockParams); + } - /** - * Build the $inverse closure passed to HelperOptions for block helpers. - */ - private static function makeInverseFn(RuntimeContext $cx, mixed $_this, ?\Closure $else): \Closure - { - return $else - ? function ($context = null) use ($cx, $_this, $else) { - if ($context === null) { - return $else($cx, $_this); - } - return static::withScope($cx, $_this, $context, $else); - } - : fn() => ''; + return static::hbbch($cx, $callable, '', $positional, $hash, $_this, $cb, $else, $blockParamCount, $outerBlockParams); } /** - * Apply blockHelperMissing semantics: if the helper returned a string/SafeString, - * pass it through; otherwise treat the return value as a section context. + * Resolve the return value of a block helper call: + * pass through string/SafeString, stringify arrays, or delegate non-string values to blockHelperMissing. */ - private static function applyBlockHelperMissing(RuntimeContext $cx, mixed $result, mixed $_this, ?\Closure $cb, ?\Closure $else): string + private static function resolveBlockResult(RuntimeContext $cx, mixed $result, mixed $_this, ?\Closure $cb, ?\Closure $else): string { if (is_string($result) || $result instanceof SafeString) { return (string) $result; } - // $cb may be null (inverted block with no else clause) after the inversion swap in hbbch - return static::sec($cx, $result, [], $_this, false, $cb ?? static fn() => '', $else); - } - - private static function withScope(RuntimeContext $cx, mixed $scope, mixed $context, \Closure $cb): string - { - $cx->scopes[] = $scope; - $ret = $cb($cx, $context); - array_pop($cx->scopes); - return $ret; - } - - /** - * Execute custom helper with prepared options - * - * @param string $ch the name of custom helper to be executed - * @param array> $vars variables for the helper - */ - public static function exch(RuntimeContext $cx, string $ch, array $vars, HelperOptions $options): mixed - { - $args = $vars[0]; - $args[] = $options; + // Arrays stringify like JS Array.prototype.toString(), regardless of fn block. + if (is_array($result)) { + return implode(',', $result); + } - try { - return ($cx->helpers[$ch])(...$args); - } catch (\Throwable $e) { - throw new \Exception("Custom helper '$ch' error: " . $e->getMessage()); + if ($cb === null) { + return ''; // can occur when compiled from an inverted block helper (e.g. {{^helper}}...{{/helper}}) } + return static::hbbch($cx, $cx->helpers['blockHelperMissing'], '', [$result], [], $_this, $cb, $else); } } diff --git a/src/RuntimeContext.php b/src/RuntimeContext.php index 11481e0..bcca385 100644 --- a/src/RuntimeContext.php +++ b/src/RuntimeContext.php @@ -5,21 +5,21 @@ /** * @internal */ -class RuntimeContext +final class RuntimeContext { /** - * @param array $helpers - * @param array $partials - * @param array $scopes + * @param array $helpers + * @param array $partials + * @param array $depths * @param array $data - * @param array $blParam + * @param array $frame */ public function __construct( public array $helpers = [], public array $partials = [], - public array $scopes = [], + public array $depths = [], public array $data = [], - public array $blParam = [], public int $partialId = 0, + public array $frame = [], ) {} } diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index a82bce5..59ed3af 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -8,21 +8,31 @@ use PHPUnit\Framework\TestCase; /** - * @phpstan-type RenderTest array{template: string, options?: Options, data?: array, expected: string} + * @phpstan-type RenderTest array{ + * template: string, options?: Options, helpers?: array, data?: array, expected: string, + * } * @phpstan-type ErrorCase array{template: string, options?: Options, expected: string} */ class ErrorTest extends TestCase { /** + * @param array $helpers * @param array $data */ #[DataProvider("renderErrorProvider")] - public function testRenderingException(string $template, string $expected, ?Options $options = null, array $data = []): void - { + public function testRenderingException( + string $template, + string $expected, + ?Options $options = null, + array $helpers = [], + array $data = [], + ): void { $php = Handlebars::precompile($template, $options ?? new Options()); $renderer = Handlebars::template($php); try { - $renderer($data); + $renderer($data, [ + 'helpers' => $helpers, + ]); $this->fail("Expected to throw exception: {$expected}. CODE: $php"); } catch (\Exception $e) { $this->assertEquals($expected, $e->getMessage(), $php); @@ -67,19 +77,15 @@ public static function renderErrorProvider(): array [ // strict mode should override helperMissing 'template' => '{{foo}}', - 'options' => new Options( - strict: true, - helpers: ['helperMissing' => fn() => 'bad'], - ), + 'options' => new Options(strict: true), + 'helpers' => ['helperMissing' => fn() => 'bad'], 'expected' => '"foo" not defined', ], [ // strict mode should override blockHelperMissing 'template' => '{{#foo}}OK{{/foo}}', - 'options' => new Options( - strict: true, - helpers: ['blockHelperMissing' => fn() => 'bad'], - ), + 'options' => new Options(strict: true), + 'helpers' => ['blockHelperMissing' => fn() => 'bad'], 'expected' => '"foo" not defined', ], [ @@ -94,20 +100,55 @@ public static function renderErrorProvider(): array ], [ 'template' => '{{foo}}', - 'options' => new Options( - helpers: [ - 'foo' => function () { - throw new \Exception('Expect the unexpected'); - }, - ], - ), - 'expected' => 'Custom helper \'foo\' error: Expect the unexpected', + 'helpers' => [ + 'foo' => function () { + throw new \Exception('Expect the unexpected'); + }, + ], + 'expected' => 'Expect the unexpected', ], // ensure that callable strings in data aren't treated as functions [ 'template' => "{{#foo.bar 'arg'}}{{/foo.bar}}", 'data' => ['foo' => ['bar' => 'strlen']], - 'expected' => '"foo.bar" is not a block helper function', + 'expected' => 'Missing helper: "foo.bar"', + ], + [ + 'template' => '{{typeof hello}}', + 'expected' => 'Missing helper: "typeof"', + ], + [ + 'template' => '{{#test foo}}{{/test}}', + 'expected' => 'Missing helper: "test"', + ], + [ + 'template' => '{{test_join (foo bar)}}', + 'helpers' => [ + 'test_join' => function ($input) { + return join('.', $input); + }, + ], + 'expected' => 'Missing helper: "foo"', + ], + [ + 'template' => '{{> (foo) bar}}', + 'expected' => 'Missing helper: "foo"', + ], + [ + 'template' => '{{#with}}OK!{{/with}}', + 'expected' => '#with requires exactly one argument', + ], + [ + 'template' => '{{#if}}OK!{{/if}}', + 'expected' => '#if requires exactly one argument', + ], + [ + 'template' => '{{#unless}}OK!{{/unless}}', + 'expected' => '#unless requires exactly one argument', + ], + [ + 'template' => '{{#each}}OK!{{/each}}', + 'expected' => 'Must pass iterator to #each', ], ]; } @@ -131,50 +172,23 @@ public static function errorProvider(): array return [ [ 'template' => '{{typeof hello}}', - 'expected' => 'Missing helper: "typeof"', - ], - [ - 'template' => '{{#with}}OK!{{/with}}', - 'expected' => '#with requires exactly one argument', - ], - [ - 'template' => '{{#if}}OK!{{/if}}', - 'expected' => '#if requires exactly one argument', - ], - [ - 'template' => '{{#unless}}OK!{{/unless}}', - 'expected' => '#unless requires exactly one argument', - ], - [ - 'template' => '{{#each}}OK!{{/each}}', - 'expected' => 'Must pass iterator to #each', - ], - [ - 'template' => '{{lookup}}', - 'expected' => '{{lookup}} requires 2 arguments', - ], - [ - 'template' => '{{lookup foo}}', - 'expected' => '{{lookup}} requires 2 arguments', + 'options' => new Options(knownHelpersOnly: true), + 'expected' => 'You specified knownHelpersOnly, but used the unknown helper typeof', ], [ - 'template' => '{{#test foo}}{{/test}}', - 'expected' => 'Missing helper: "test"', + 'template' => '{{#test "arg"}}{{/test}}', + 'options' => new Options(knownHelpersOnly: true), + 'expected' => 'You specified knownHelpersOnly, but used the unknown helper test', ], [ - 'template' => '{{test_join (foo bar)}}', - 'options' => new Options( - helpers: [ - 'test_join' => function ($input) { - return join('.', $input); - }, - ], - ), - 'expected' => 'Missing helper: "foo"', + 'template' => '{{#list id="nav-bar"}}{{/list}}', + 'options' => new Options(knownHelpersOnly: true), + 'expected' => 'You specified knownHelpersOnly, but used the unknown helper list', ], [ - 'template' => '{{> (foo) bar}}', - 'expected' => 'Missing helper: "foo"', + 'template' => '{{#if true}}nope{{/if}}', + 'options' => new Options(knownHelpers: ['if' => false], knownHelpersOnly: true), + 'expected' => 'You specified knownHelpersOnly, but used the unknown helper if', ], [ 'template' => '{{#*help me}}{{/help}}', diff --git a/tests/ExporterTest.php b/tests/ExporterTest.php deleted file mode 100644 index ce084a6..0000000 --- a/tests/ExporterTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertSame($expected, Exporter::closure( - function ($a) { - return 1 + $a; - }, - )); - - $this->assertSame('fn() => 1 + 1', Exporter::closure(fn() => 1 + 1)); - $this->assertSame('fn(int $a) => $a * 2', Exporter::closure( - fn(int $a) => $a * 2, - )); - } -} diff --git a/tests/HandlebarsSpecTest.php b/tests/HandlebarsSpecTest.php index e934337..0dad045 100644 --- a/tests/HandlebarsSpecTest.php +++ b/tests/HandlebarsSpecTest.php @@ -25,7 +25,7 @@ public static function createFrame(mixed $data): mixed /** * @phpstan-type JsonSpec array{ - * file: string, no: int, message: string|null, data: null|int|bool|string|array, + * file: string, no: int, message: string|null, data: null|int|bool|string|array|stdClass, * it: string, description: string, expected: string|null, helpers: array, * partials: array, compileOptions: array, template: string, * exception: string|null, runtimeOptions: array, number: string|null, @@ -33,8 +33,6 @@ public static function createFrame(mixed $data): mixed */ class HandlebarsSpecTest extends TestCase { - private int $tested = 0; - /** * @param JsonSpec $spec */ @@ -64,44 +62,35 @@ public function testSpecs(array $spec): void // this method may be useful in JS, but not in PHP || $spec['description'] === 'helpers - the lookupProperty-option' + + // PHP doesn't have the same concept of sparse arrays as JS, so there's no need to skip over holes. + || $spec['it'] === 'GH-1065: Sparse arrays' ) { $this->markTestIncomplete('Not supported case: just skip it'); } // FIX SPEC - if ($spec['it'] === 'helper block with complex lookup expression' && isset($spec['helpers']['goodbyes']['php'])) { - $spec['helpers']['goodbyes']['php'] = str_replace('$options->fn();', '$options->fn([]);', $spec['helpers']['goodbyes']['php']); - } if ($spec['it'] === 'should take presednece over parent block params') { - $spec['helpers']['goodbyes']['php'] = 'function($options) { static $value; if ($value === null) { $value = 1; } return $options->fn(["value" => "bar"], ["blockParams" => $options->blockParams === 1 ? [$value++, $value++] : null]);}'; + $spec['helpers']['goodbyes']['php'] = str_replace('static $value = 0;', 'static $value = 1;', $spec['helpers']['goodbyes']['php']); } - if ($spec['it'] === 'Functions are bound to the context in knownHelpers only mode' && is_array($spec['data'])) { - $spec['data']['foo']['php'] = 'function($options) { return $options->scope[\'bar\']; }'; - } - - self::unsetRecursive($spec['data'], '!sparsearray'); self::addDataHelpers($spec); // setup helpers - $this->tested++; $helpers = []; - $helpersList = ''; + $helpersList = '['; foreach ($spec['helpers'] as $name => $func) { if (!isset($func['php'])) { - $this->markTestIncomplete("Skip [{$spec['file']}#{$spec['description']}]#{$spec['no']} , no PHP helper code provided for this case."); + $this->markTestIncomplete("No PHP helper code provided for [{$spec['file']}#{$spec['description']}]#{$spec['no']}"); } - $helperName = preg_replace('/[.\\/]/', '_', "custom_helper_{$spec['no']}_{$this->tested}_$name"); - $helpers[$name] = $helperName; - $helper = self::patchSafeString( - preg_replace('/function/', "function $helperName", $func['php'], 1), - ); + $helper = self::patchSafeString($func['php']); $helper = str_replace('$options[\'name\']', '$options->name', $helper); $helper = str_replace('$options[\'data\']', '$options->data', $helper); $helper = str_replace('$options[\'hash\']', '$options->hash', $helper); $helper = str_replace('$arguments[count($arguments)-1][\'name\'];', '$arguments[count($arguments)-1]->name;', $helper); - $helpersList .= "$helper\n"; + $helpersList .= "\n '$name' => $helper,\n"; + eval('$helpers[\'' . $name . '\'] = ' . $helper . ';'); } - eval($helpersList); + $helpersList .= ']'; // Convert "!code" partials (callable PHP strings) into actual callables. $partials = []; @@ -123,14 +112,13 @@ public function testSpecs(array $spec): void $explicitPartialContext = $spec['compileOptions']['explicitPartialContext'] ?? false; $php = Handlebars::precompile($spec['template'], new Options( + knownHelpers: $spec['compileOptions']['knownHelpers'] ?? [], knownHelpersOnly: $knownHelpersOnly, strict: $strict, assumeObjects: $assumeObjects, preventIndent: $preventIndent, ignoreStandalone: $ignoreStandalone, explicitPartialContext: $explicitPartialContext, - /** @phpstan-ignore argument.type */ - helpers: $helpers, partials: $stringPartials, )); } catch (\Exception $e) { @@ -144,6 +132,7 @@ public function testSpecs(array $spec): void try { $ropt = [ + 'helpers' => $helpers, 'partials' => $partials, ]; if (is_array($spec['runtimeOptions']['data'] ?? null)) { @@ -177,7 +166,7 @@ public function testSpecs(array $spec): void */ private static function getSpecDetails(array $spec, string $code, string $helpers): string { - return "{$spec['file']}#{$spec['description']}]#{$spec['no']}:{$spec['it']}\nHelpers:\n$helpers\nPHP code:\n$code"; + return "{$spec['file']}#{$spec['description']}]#{$spec['no']}:{$spec['it']}\nHelpers: $helpers\nPHP code:\n$code"; } /** @@ -265,21 +254,6 @@ private static function evalNestedCode(array &$data): void } } - private static function unsetRecursive(mixed &$array, string $unwanted_key): void - { - if (!is_array($array)) { - return; - } - if (isset($array[$unwanted_key])) { - unset($array[$unwanted_key]); - } - foreach ($array as &$value) { - if (is_array($value)) { - self::unsetRecursive($value, $unwanted_key); - } - } - } - private static function patchSafeString(string $code): string { $classname = '\\DevTheorem\\Handlebars\\SafeString'; diff --git a/tests/RegressionTest.php b/tests/RegressionTest.php index c8504f0..c5c4d5f 100644 --- a/tests/RegressionTest.php +++ b/tests/RegressionTest.php @@ -10,7 +10,10 @@ use PHPUnit\Framework\TestCase; /** - * @phpstan-type RegIssue array{id?: int, template: string, data?: array, options?: Options, expected: string} + * @phpstan-type RegIssue array{ + * desc?: string, template: string, data?: mixed, options?: Options, + * helpers?: array, expected: string, + * } */ class RegressionTest extends TestCase { @@ -76,36 +79,31 @@ public function testRuntimePartials(): void } /** - * @param RegIssue $issue + * @param array|null $data + * @param array $helpers */ - #[DataProvider("issueProvider")] - public function testIssues(array $issue): void + #[DataProvider("helperProvider")] + #[DataProvider("partialProvider")] + #[DataProvider("builtInProvider")] + #[DataProvider("syntaxProvider")] + public function testIssues(string $template, string $expected, mixed $data = null, ?Options $options = null, array $helpers = [], string $desc = ''): void { - $templateSpec = Handlebars::precompile($issue['template'], $issue['options'] ?? new Options()); + $templateSpec = Handlebars::precompile($template, $options ?? new Options()); try { $template = Handlebars::template($templateSpec); - $result = $template($issue['data'] ?? null); + $result = $template($data, ['helpers' => $helpers]); } catch (\Throwable $e) { - $this->fail("Error: {$e->getMessage()}\nPHP code:\n$templateSpec"); + $this->fail("$desc\nError: {$e->getMessage()}\nPHP code:\n$templateSpec"); } - $this->assertEquals($issue['expected'], $result, "PHP CODE:\n$templateSpec"); + $this->assertEquals($expected, $result, "$desc\nPHP code:\n$templateSpec"); } /** - * @return array + * @return list */ - public static function issueProvider(): array + public static function helperProvider(): array { - $test_helpers = ['ouch' => fn() => 'ok']; - - $test_helpers2 = ['ouch' => fn() => 'wa!']; - - $test_helpers3 = [ - 'ouch' => fn() => 'wa!', - 'god' => fn() => 'yo', - ]; - $myIf = function ($conditional, HelperOptions $options) { if ($conditional) { return $options->fn(); @@ -135,6 +133,13 @@ public static function issueProvider(): array }; $myDash = fn($a, $b) => "$a-$b"; + $list = fn($arg) => join(',', $arg); + $keys = fn($arg) => array_keys($arg); + $echo = fn($arg) => "ECHO: $arg"; + $testArray = fn($input) => is_array($input) ? 'IS_ARRAY' : 'NOT_ARRAY'; + $returnBlock = fn(HelperOptions $options) => $options->fn(); + $getThisBar = fn($input, HelperOptions $options) => $input . '-' . $options->scope['bar']; + $equal = fn($a, $b) => $a === $b; $equals = function (mixed $a, mixed $b, HelperOptions $options) { $jsEquals = function (mixed $a, mixed $b): bool { @@ -149,9 +154,21 @@ public static function issueProvider(): array return $jsEquals($a, $b) ? $options->fn() : $options->inverse(); }; - $issues = [ + $inlineHashProp = function ($arg, HelperOptions $options) { + return "$arg-{$options->hash['bar']}"; + }; + + $inlineHashArr = function (HelperOptions $options) { + $ret = ''; + foreach ($options->hash as $k => $v) { + $ret .= "$k : $v,"; + } + return $ret; + }; + + return [ [ - 'id' => 2, + 'desc' => 'LNC #2', 'template' => <<<_hbs {{#ifEquals @root.item "foo"}} phooey @@ -169,620 +186,311 @@ public static function issueProvider(): array {{/if}} {{/ifEquals}} _hbs, - 'options' => new Options( - helpers: ['ifEquals' => $equals], - ), + 'helpers' => ['ifEquals' => $equals], 'data' => ['item' => 'buzz'], 'expected' => " buzzy\n", ], [ - 'id' => 7, - 'template' => "

\n {{> list}}\n

", - 'data' => ['items' => ['Hello', 'World']], - 'options' => new Options( - partials: ['list' => "{{#each items}}{{this}}\n{{/each}}"], - ), - 'expected' => "

\n Hello\n World\n

", - ], - - [ - 'id' => 39, - 'template' => '{{{tt}}}', - 'data' => ['tt' => 'bla bla bla'], - 'expected' => 'bla bla bla', - ], - - [ - 'id' => 44, + 'desc' => 'LNC #44', 'template' => '
{{render "artists-terms"}}
', - 'options' => new Options( - helpers: [ - 'render' => function ($view, $data = []) { - return 'OK!'; - }, - ], - ), - 'data' => ['tt' => 'bla bla bla'], - 'expected' => '
OK!
', - ], - - [ - 'id' => 46, - 'template' => '{{{this.id}}}, {{a.id}}', - 'data' => ['id' => 'bla bla bla', 'a' => ['id' => 'OK!']], - 'expected' => 'bla bla bla, OK!', + 'helpers' => [ + 'render' => fn($view) => $view . '.html', + ], + 'expected' => '
artists-terms.html
', ], [ - 'id' => 49, - 'template' => '{{date_format}} 1, {{date_format2}} 2, {{date_format3}} 3, {{date_format4}} 4', - 'options' => new Options( - helpers: [ - 'date_format' => function () { - return "OKOK~1"; - }, - 'date_format2' => function () { - return "OKOK~2"; - }, - 'date_format3' => function () { - return "OKOK~3"; - }, - 'date_format4' => function () { - return "OKOK~4"; - }, - ], - ), - 'expected' => 'OKOK~1 1, OKOK~2 2, OKOK~3 3, OKOK~4 4', + 'desc' => 'LNC #49', + 'template' => '{{date_format}} 1, {{date_format2}} 2', + 'helpers' => [ + 'date_format' => fn() => "OKOK~1", + 'date_format2' => fn() => "OKOK~2", + ], + 'expected' => 'OKOK~1 1, OKOK~2 2', ], [ - 'id' => 52, + 'desc' => 'LNC #52', 'template' => '{{{test_array tmp}}} should be happy!', - 'options' => new Options( - helpers: [ - 'test_array' => function ($input) { - return is_array($input) ? 'IS_ARRAY' : 'NOT_ARRAY'; - }, - ], - ), + 'helpers' => ['test_array' => $testArray], 'data' => ['tmp' => ['A', 'B', 'C']], 'expected' => 'IS_ARRAY should be happy!', ], [ - 'id' => 62, + 'desc' => 'LNC #62', 'template' => '{{{test_join @root.foo.bar}}} should be happy!', - 'options' => new Options( - helpers: [ - 'test_join' => fn($input) => join('.', $input), - ], - ), + 'helpers' => ['test_join' => $list], 'data' => ['foo' => ['A', 'B', 'bar' => ['C', 'D']]], - 'expected' => 'C.D should be happy!', - ], - - [ - 'id' => 64, - 'template' => '{{#each foo}} Test! {{this}} {{/each}}{{> test1}} ! >>> {{>recursive}}', - 'options' => new Options( - partials: [ - 'test1' => "123\n", - 'recursive' => "{{#if foo}}{{bar}} -> {{#with foo}}{{>recursive}}{{/with}}{{else}}END!{{/if}}\n", - ], - ), - 'data' => [ - 'bar' => 1, - 'foo' => [ - 'bar' => 3, - 'foo' => [ - 'bar' => 5, - 'foo' => [ - 'bar' => 7, - 'foo' => [ - 'bar' => 11, - 'foo' => [ - 'no foo here', - ], - ], - ], - ], - ], - ], - 'expected' => " Test! 3 Test! [object Object] 123\n ! >>> 1 -> 3 -> 5 -> 7 -> 11 -> END!\n\n\n\n\n\n", - ], - - [ - 'id' => 66, - 'template' => '{{&foo}} , {{foo}}, {{{foo}}}', - 'data' => ['foo' => 'Test & " \' :)'], - 'expected' => 'Test & " \' :) , Test & " ' :), Test & " \' :)', + 'expected' => 'C,D should be happy!', ], [ - 'id' => 68, + 'desc' => 'LNC #68', 'template' => '{{#myeach foo}} Test! {{this}} {{/myeach}}', - 'options' => new Options( - helpers: ['myeach' => $myEach], - ), + 'helpers' => ['myeach' => $myEach], 'data' => ['foo' => ['A', 'B', 'bar' => ['C', 'D', 'E']]], 'expected' => ' Test! A Test! B Test! C,D,E ', ], [ - 'id' => 81, - 'template' => '{{#with ../person}} {{^name}} Unknown {{/name}} {{/with}}?!', - 'data' => ['parent?!' => ['A', 'B', 'bar' => ['C', 'D', 'E']]], - 'expected' => '?!', - ], - - [ - 'id' => 83, - 'template' => '{{> tests/test1}}', - 'options' => new Options( - partials: [ - 'tests/test1' => "123\n", - ], - ), - 'expected' => "123\n", - ], - - [ - 'id' => 85, + 'desc' => 'LNC #85', 'template' => '{{helper 1 foo bar="q"}}', - 'options' => new Options( - helpers: [ - 'helper' => function ($arg1, $arg2, HelperOptions $options) { - return "ARG1:$arg1, ARG2:$arg2, HASH:{$options->hash['bar']}"; - }, - ], - ), + 'helpers' => [ + 'helper' => function ($arg1, $arg2, HelperOptions $options) { + return "ARG1:$arg1, ARG2:$arg2, HASH:{$options->hash['bar']}"; + }, + ], 'data' => ['foo' => 'BAR'], 'expected' => 'ARG1:1, ARG2:BAR, HASH:q', ], [ - 'id' => 88, - 'template' => '{{>test2}}', - 'options' => new Options( - partials: [ - 'test2' => "a{{> test1}}b\n", - 'test1' => "123\n", - ], - ), - 'expected' => "a123\nb\n", - ], - - [ - 'id' => 90, - 'template' => '{{#items}}{{#value}}{{.}}{{/value}}{{/items}}', - 'data' => ['items' => [['value' => '123']]], - 'expected' => '123', - ], - - [ - 'id' => 109, - 'template' => '{{#if "OK"}}it\'s great!{{/if}}', - 'options' => new Options(noEscape: true), - 'expected' => 'it\'s great!', - ], - - [ - 'id' => 110, - 'template' => 'ABC{{#block "YES!"}}DEF{{foo}}GHI{{/block}}JKL', - 'options' => new Options( - helpers: [ - 'block' => function ($name, HelperOptions $options) { - return "1-$name-2-" . $options->fn() . '-3'; - }, - ], - ), + 'desc' => 'LNC #110', + 'template' => 'ABC{{#block "YES!"}}DEF{{foo}}GHI{{else}}NO~{{/block}}JKL', + 'helpers' => [ + 'block' => function ($name, HelperOptions $options) { + return "1-$name-2-" . $options->fn() . '-3'; + }, + ], 'data' => ['foo' => 'bar'], 'expected' => 'ABC1-YES!-2-DEFbarGHI-3JKL', ], - [ - 'id' => 109, - 'template' => '{{foo}} {{> test}}', - 'options' => new Options( - noEscape: true, - partials: ['test' => '{{foo}}'], - ), - 'data' => ['foo' => '<'], - 'expected' => '< <', + 'template' => 'ABC{{#block "YES!"}}TRUE{{else}}DEF{{foo}}GHI{{/block}}JKL', + 'helpers' => [ + 'block' => function ($name, HelperOptions $options) { + return "1-$name-2-" . $options->inverse() . '-3'; + }, + ], + 'data' => ['foo' => 'bar'], + 'expected' => 'ABC1-YES!-2-DEFbarGHI-3JKL', ], [ - 'id' => 114, + 'desc' => 'LNC #114', 'template' => '{{^myeach .}}OK:{{.}},{{else}}NOT GOOD{{/myeach}}', - 'options' => new Options( - helpers: ['myeach' => $myEach], - ), + 'helpers' => ['myeach' => $myEach], 'data' => [1, 'foo', 3, 'bar'], 'expected' => 'NOT GOODNOT GOODNOT GOODNOT GOOD', ], [ - 'id' => 124, + 'desc' => 'LNC #124', 'template' => '{{list foo bar abc=(lt 10 3) def=(lt 3 10)}}', - 'options' => new Options( - helpers: [ - 'lt' => function ($a, $b) { - return ($a > $b) ? new SafeString("$a>$b") : ''; - }, - 'list' => function (...$args) { - $out = 'List:'; - /** @var HelperOptions $opts */ - $opts = array_pop($args); - - foreach ($args as $v) { - if ($v) { - $out .= ")$v , "; - } + 'helpers' => [ + 'lt' => function ($a, $b) { + return ($a > $b) ? new SafeString("$a>$b") : ''; + }, + 'list' => function (...$args) { + $out = 'List:'; + /** @var HelperOptions $opts */ + $opts = array_pop($args); + + foreach ($args as $v) { + if ($v) { + $out .= ")$v , "; } + } - foreach ($opts->hash as $k => $v) { - if ($v) { - $out .= "]$k=$v , "; - } + foreach ($opts->hash as $k => $v) { + if ($v) { + $out .= "]$k=$v , "; } - return new SafeString($out); - }, - ], - ), + } + return new SafeString($out); + }, + ], 'data' => ['foo' => 'OK!', 'bar' => 'OK2', 'abc' => false, 'def' => 123], 'expected' => 'List:)OK! , )OK2 , ]abc=10>3 , ', ], - [ - 'id' => 124, + 'desc' => 'LNC #124', 'template' => '{{#if (equal \'OK\' cde)}}YES!{{/if}}', - 'options' => new Options( - helpers: [ - 'equal' => fn($a, $b) => $a === $b, - ], - ), + 'helpers' => ['equal' => $equal], 'data' => ['cde' => 'OK'], 'expected' => 'YES!', ], - [ - 'id' => 124, + 'desc' => 'LNC #124', 'template' => '{{#if (equal true (equal \'OK\' cde))}}YES!{{/if}}', - 'options' => new Options( - helpers: [ - 'equal' => fn($a, $b) => $a === $b, - ], - ), + 'helpers' => ['equal' => $equal], 'data' => ['cde' => 'OK'], 'expected' => 'YES!', ], [ - 'id' => 125, + 'desc' => 'LNC #125', 'template' => '{{#if (equal true ( equal \'OK\' cde))}}YES!{{/if}}', - 'options' => new Options( - helpers: [ - 'equal' => fn($a, $b) => $a === $b, - ], - ), + 'helpers' => ['equal' => $equal], 'data' => ['cde' => 'OK'], 'expected' => 'YES!', ], - [ - 'id' => 125, + 'desc' => 'LNC #125', 'template' => '{{#if (equal true (equal \' OK\' cde))}}YES!{{/if}}', - 'options' => new Options( - helpers: [ - 'equal' => fn($a, $b) => $a === $b, - ], - ), + 'helpers' => ['equal' => $equal], 'data' => ['cde' => ' OK'], 'expected' => 'YES!', ], - [ - 'id' => 125, + 'desc' => 'LNC #125', 'template' => '{{#if (equal true (equal \' ==\' cde))}}YES!{{/if}}', - 'options' => new Options( - helpers: [ - 'equal' => fn($a, $b) => $a === $b, - ], - ), + 'helpers' => ['equal' => $equal], 'data' => ['cde' => ' =='], 'expected' => 'YES!', ], - [ - 'id' => 125, + 'desc' => 'LNC #125', 'template' => '{{#if (equal true (equal " ==" cde))}}YES!{{/if}}', - 'options' => new Options( - helpers: [ - 'equal' => fn($a, $b) => $a === $b, - ], - ), + 'helpers' => ['equal' => $equal], 'data' => ['cde' => ' =='], 'expected' => 'YES!', ], - - [ - 'id' => 125, - 'template' => '{{[ abc]}}', - 'data' => [' abc' => 'YES!'], - 'expected' => 'YES!', - ], - [ - 'id' => 125, + 'desc' => 'LNC #125', 'template' => '{{list [ abc] " xyz" \' def\' "==" \'==\' "OK"}}', - 'options' => new Options( - helpers: [ - 'list' => function ($a, $b) { - $out = 'List:'; - $args = func_get_args(); - array_pop($args); - foreach ($args as $v) { - if ($v) { - $out .= ")$v , "; - } + 'helpers' => [ + 'list' => function (...$args) { + $out = 'List:'; + array_pop($args); + foreach ($args as $v) { + if ($v) { + $out .= ")$v , "; } - return $out; - }, - ], - ), + } + return $out; + }, + ], 'data' => [' abc' => 'YES!'], 'expected' => 'List:)YES! , ) xyz , ) def , )== , )== , )OK , ', ], [ - 'id' => 127, + 'desc' => 'LNC #127', 'template' => '{{#each array}}#{{#if true}}{{name}}-{{../name}}-{{../../name}}-{{../../../name}}{{/if}}##{{#myif true}}{{name}}={{../name}}={{../../name}}={{../../../name}}{{/myif}}###{{#mywith true}}{{name}}~{{../name}}~{{../../name}}~{{../../../name}}{{/mywith}}{{/each}}', 'data' => ['name' => 'john', 'array' => [1, 2, 3]], - 'options' => new Options( - helpers: ['myif' => $myIf, 'mywith' => $myWith], - ), - // PENDING ISSUE, check for https://github.com/wycats/handlebars.js/issues/1135 - // 'expected' => '#--john-##==john=###~~john~#--john-##==john=###~~john~#--john-##==john=###~~john~', - 'expected' => '#-john--##=john==###~~john~#-john--##=john==###~~john~#-john--##=john==###~~john~', - ], - - [ - 'id' => 128, - 'template' => 'foo: {{foo}} , parent foo: {{../foo}}', - 'data' => ['foo' => 'OK'], - 'expected' => 'foo: OK , parent foo: ', + 'helpers' => ['myif' => $myIf, 'mywith' => $myWith], + // HBS.js output is different due to context coercion (https://github.com/handlebars-lang/handlebars.js/issues/1135): + // 'expected' => '#-john--##==john=###~john~~#-john--##==john=###~~john~#-john--##==john=###~~john~' + 'expected' => '#-john--##==john=###~~john~#-john--##==john=###~~john~#-john--##==john=###~~john~', ], [ - 'id' => 132, + 'desc' => 'LNC #132', 'template' => '{{list (keys .)}}', 'data' => ['foo' => 'bar', 'test' => 'ok'], - 'options' => new Options( - helpers: [ - 'keys' => function ($arg) { - return array_keys($arg); - }, - 'list' => function ($arg) { - return join(',', $arg); - }, - ], - ), + 'helpers' => ['keys' => $keys, 'list' => $list], 'expected' => 'foo,test', ], [ - 'id' => 133, + 'desc' => 'LNC #133', 'template' => "{{list (keys\n .\n ) \n}}", 'data' => ['foo' => 'bar', 'test' => 'ok'], - 'options' => new Options( - helpers: [ - 'keys' => function ($arg) { - return array_keys($arg); - }, - 'list' => function ($arg) { - return join(',', $arg); - }, - ], - ), + 'helpers' => ['keys' => $keys, 'list' => $list], 'expected' => 'foo,test', ], - [ - 'id' => 133, + 'desc' => 'LNC #133', 'template' => "{{list\n .\n \n \n}}", 'data' => ['foo', 'bar', 'test'], - 'options' => new Options( - helpers: [ - 'list' => fn($arg) => join(',', $arg), - ], - ), + 'helpers' => ['list' => $list], 'expected' => 'foo,bar,test', ], [ - 'id' => 134, + 'desc' => 'LNC #134', 'template' => "{{#if 1}}{{list (keys names)}}{{/if}}", 'data' => ['names' => ['foo' => 'bar', 'test' => 'ok']], - 'options' => new Options( - helpers: [ - 'keys' => fn($arg) => array_keys($arg), - 'list' => fn($arg) => join(',', $arg), - ], - ), + 'helpers' => ['keys' => $keys, 'list' => $list], 'expected' => 'foo,test', ], [ - 'id' => 138, + 'desc' => 'LNC #138', 'template' => "{{#each (keys .)}}={{.}}{{/each}}", 'data' => ['foo' => 'bar', 'test' => 'ok', 'Haha'], - 'options' => new Options( - helpers: [ - 'keys' => fn($arg) => array_keys($arg), - ], - ), + 'helpers' => ['keys' => $keys], 'expected' => '=foo=test=0', ], [ - 'id' => 140, + 'desc' => 'LNC #140', 'template' => "{{[a.good.helper] .}}", 'data' => ['ha', 'hey', 'ho'], - 'options' => new Options( - helpers: [ - 'a.good.helper' => fn($arg) => join(',', $arg), - ], - ), + 'helpers' => [ + 'a.good.helper' => fn($arg) => join(',', $arg), + ], 'expected' => 'ha,hey,ho', ], [ - 'id' => 141, + 'desc' => 'LNC #141', 'template' => "{{#with foo}}{{#getThis bar}}{{/getThis}}{{/with}}", 'data' => ['foo' => ['bar' => 'Good!']], - 'options' => new Options( - helpers: [ - 'getThis' => function ($input, HelperOptions $options) { - return $input . '-' . $options->scope['bar']; - }, - ], - ), + 'helpers' => ['getThis' => $getThisBar], 'expected' => 'Good!-Good!', ], - [ - 'id' => 141, + 'desc' => 'LNC #141', 'template' => "{{#with foo}}{{getThis bar}}{{/with}}", 'data' => ['foo' => ['bar' => 'Good!']], - 'options' => new Options( - helpers: [ - 'getThis' => function ($input, HelperOptions $options) { - return $input . '-' . $options->scope['bar']; - }, - ], - ), + 'helpers' => ['getThis' => $getThisBar], 'expected' => 'Good!-Good!', ], [ - 'id' => 143, + 'desc' => 'LNC #143', 'template' => "{{testString foo bar=\" \"}}", 'data' => ['foo' => 'good!'], - 'options' => new Options( - helpers: [ - 'testString' => function ($arg, HelperOptions $options) { - return $arg . '-' . $options->hash['bar']; - }, - ], - ), + 'helpers' => ['testString' => $inlineHashProp], 'expected' => 'good!- ', ], - [ - 'id' => 143, + 'desc' => 'LNC #143', 'template' => "{{testString foo bar=\"\"}}", 'data' => ['foo' => 'good!'], - 'options' => new Options( - helpers: [ - 'testString' => function ($arg, HelperOptions $options) { - return $arg . '-' . $options->hash['bar']; - }, - ], - ), + 'helpers' => ['testString' => $inlineHashProp], 'expected' => 'good!-', ], - [ - 'id' => 143, + 'desc' => 'LNC #143', 'template' => "{{testString foo bar=' '}}", 'data' => ['foo' => 'good!'], - 'options' => new Options( - helpers: [ - 'testString' => function ($arg, HelperOptions $options) { - return $arg . '-' . $options->hash['bar']; - }, - ], - ), + 'helpers' => ['testString' => $inlineHashProp], 'expected' => 'good!- ', ], - [ - 'id' => 143, + 'desc' => 'LNC #143', 'template' => "{{testString foo bar=''}}", 'data' => ['foo' => 'good!'], - 'options' => new Options( - helpers: [ - 'testString' => function ($arg, HelperOptions $options) { - return $arg . '-' . $options->hash['bar']; - }, - ], - ), + 'helpers' => ['testString' => $inlineHashProp], 'expected' => 'good!-', ], [ - 'id' => 143, - 'template' => "{{testString foo bar=\" \"}}", - 'data' => ['foo' => 'good!'], - 'options' => new Options( - helpers: [ - 'testString' => function ($arg1, HelperOptions $options) { - return $arg1 . '-' . $options->hash['bar']; - }, - ], - ), - 'expected' => 'good!- ', + 'desc' => 'LNC #153', + 'template' => '{{echo "test[]"}}', + 'helpers' => ['echo' => $echo], + 'expected' => "ECHO: test[]", ], - [ - 'id' => 147, - 'template' => '{{> test/test3 foo="bar"}}', - 'data' => ['test' => 'OK!', 'foo' => 'error'], - 'options' => new Options( - partials: ['test/test3' => '{{test}}, {{foo}}'], - ), - 'expected' => 'OK!, bar', + 'desc' => 'LNC #153', + 'template' => '{{echo \'test[]\'}}', + 'helpers' => ['echo' => $echo], + 'expected' => "ECHO: test[]", ], [ - 'id' => 153, - 'template' => '{{echo "test[]"}}', - 'options' => new Options( - helpers: [ - 'echo' => fn($in) => "-$in-", - ], - ), - 'expected' => "-test[]-", - ], - - [ - 'id' => 153, - 'template' => '{{echo \'test[]\'}}', - 'options' => new Options( - helpers: [ - 'echo' => fn($in) => "-$in-", - ], - ), - 'expected' => "-test[]-", - ], - - [ - 'id' => 154, - 'template' => 'O{{! this is comment ! ... }}K!', - 'expected' => "OK!", - ], - - [ - 'id' => 157, + 'desc' => 'LNC #157', 'template' => '{{{du_mp text=(du_mp "123")}}}', - 'options' => new Options( - helpers: [ - 'du_mp' => function (HelperOptions|string $a) { - return '>' . print_r($a->hash ?? $a, true); - }, - ], - ), + 'helpers' => [ + 'du_mp' => function (HelperOptions|string $a) { + return '>' . print_r($a->hash ?? $a, true); + }, + ], 'expected' => <<Array ( @@ -793,136 +501,25 @@ public static function issueProvider(): array ], [ - 'id' => 157, - 'template' => '{{>test_js_partial}}', - 'options' => new Options( - partials: [ - 'test_js_partial' => << - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){console.log('works!')};})(); - - VAREND, - ], - ), - 'expected' => << - (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){console.log('works!')};})(); - - VAREND, - ], - - [ - 'id' => 159, - 'template' => '{{#.}}true{{else}}false{{/.}}', - 'data' => new \ArrayObject(), - 'expected' => "false", - ], - - [ - 'id' => 169, - 'template' => '{{{{a}}}}true{{else}}false{{{{/a}}}}', - 'data' => ['a' => true], - 'expected' => "true{{else}}false", - ], - - [ - 'id' => 171, + 'desc' => 'LNC #171', 'template' => '{{#my_private_each .}}{{@index}}:{{.}},{{/my_private_each}}', 'data' => ['a', 'b', 'c'], - 'options' => new Options( - helpers: [ - 'my_private_each' => function ($context, HelperOptions $options) { - $data = $options->data; - $out = ''; - foreach ($context as $idx => $cx) { - $data['index'] = $idx; - $out .= $options->fn($cx, ['data' => $data]); - } - return $out; - }, - ], - ), + 'helpers' => [ + 'my_private_each' => function ($context, HelperOptions $options) { + $data = $options->data; + $out = ''; + foreach ($context as $idx => $cx) { + $data['index'] = $idx; + $out .= $options->fn($cx, ['data' => $data]); + } + return $out; + }, + ], 'expected' => '0:a,1:b,2:c,', ], [ - 'id' => 175, - 'template' => 'a{{!-- {{each}} haha {{/each}} --}}b', - 'expected' => 'ab', - ], - - [ - 'id' => 175, - 'template' => 'c{{>test}}d', - 'options' => new Options( - partials: [ - 'test' => 'a{{!-- {{each}} haha {{/each}} --}}b', - ], - ), - 'expected' => 'cabd', - ], - - [ - 'id' => 177, - 'template' => '{{{{a}}}} {{{{b}}}} {{{{/b}}}} {{{{/a}}}}', - 'data' => ['a' => true], - 'expected' => ' {{{{b}}}} {{{{/b}}}} ', - ], - - [ - 'id' => 177, - 'template' => '{{{{a}}}} {{{{b}}}} {{{{/b}}}} {{{{/a}}}}', - 'data' => ['a' => true], - 'options' => new Options( - helpers: [ - 'a' => fn(HelperOptions $options) => $options->fn(), - ], - ), - 'expected' => ' {{{{b}}}} {{{{/b}}}} ', - ], - - [ - 'id' => 177, - 'template' => '{{{{a}}}} {{{{b}}}} {{{{/b}}}} {{{{/a}}}}', - 'expected' => '', - ], - - [ - 'id' => 199, - 'template' => '{{#if foo}}1{{else if bar}}2{{else}}3{{/if}}', - 'expected' => '3', - ], - - [ - 'id' => 199, - 'template' => '{{#if foo}}1{{else if bar}}2{{/if}}', - 'data' => ['bar' => true], - 'expected' => '2', - ], - - [ - 'id' => 200, - 'template' => '{{#unless 0}}1{{else if foo}}2{{else}}3{{/unless}}', - 'data' => ['foo' => false], - 'expected' => '1', - ], - [ - 'id' => 201, - 'template' => '{{#unless 0 includeZero=true}}1{{else if foo}}2{{else}}3{{/unless}}', - 'data' => ['foo' => true], - 'expected' => '2', - ], - [ - 'id' => 202, - 'template' => '{{#unless 0 includeZero=true}}1{{else if foo}}2{{else}}3{{/unless}}', - 'data' => ['foo' => false], - 'expected' => '3', - ], - - [ - // based on examples at https://handlebarsjs.com/guide/hooks.html + 'desc' => 'Hooks (https://handlebarsjs.com/guide/hooks.html)', 'template' => <<<_hbs {{foo}} {{foo "value"}} @@ -932,18 +529,16 @@ public static function issueProvider(): array {{#person}}{{firstName}} {{lastName}}{{/person}} _hbs, 'data' => ['person' => ['firstName' => 'Yehuda', 'lastName' => 'Katz']], - 'options' => new Options( - helpers: [ - 'helperMissing' => function (...$args) { - $options = array_pop($args); - $argVals = array_map(fn($arg) => is_bool($arg) ? ($arg ? 'true' : 'false') : $arg, $args); - return "Missing {$options->name}(" . implode(',', $argVals) . ')'; - }, - 'blockHelperMissing' => function (mixed $context, HelperOptions $options) { - return "Helper '{$options->name}' not found. Printing block: {$options->fn($context)}"; - }, - ], - ), + 'helpers' => [ + 'helperMissing' => function (...$args) { + $options = array_pop($args); + $argVals = array_map(fn($arg) => is_bool($arg) ? ($arg ? 'true' : 'false') : $arg, $args); + return "Missing {$options->name}(" . implode(',', $argVals) . ')'; + }, + 'blockHelperMissing' => function (mixed $context, HelperOptions $options) { + return "Helper '{$options->name}' not found. Printing block: {$options->fn($context)}"; + }, + ], 'expected' => <<<_result Missing foo() Missing foo(value) @@ -954,170 +549,630 @@ public static function issueProvider(): array _result, ], [ - 'id' => 201, + 'desc' => 'LNC #201', 'template' => '{{#foo "test"}}World{{/foo}}', - 'options' => new Options( - helpers: [ - 'helperMissing' => fn(string $name, HelperOptions $options) => "$name = {$options->fn()}", - ], - ), + 'helpers' => [ + 'helperMissing' => fn(string $name, HelperOptions $options) => "$name = {$options->fn()}", + ], 'expected' => 'test = World', ], [ - 'id' => 204, - 'template' => '{{#> test name="A"}}B{{/test}}{{#> test name="C"}}D{{/test}}', - 'data' => ['bar' => true], - 'options' => new Options( - partials: [ - 'test' => '{{name}}:{{> @partial-block}},', - ], - ), - 'expected' => 'A:B,C:D,', - ], - - [ - 'id' => 206, - 'template' => '{{#with bar}}{{#../foo}}YES!{{/../foo}}{{/with}}', - 'data' => ['foo' => 999, 'bar' => true], - 'expected' => 'YES!', - ], - - [ - 'id' => 213, + 'desc' => 'LNC #213', 'template' => '{{#if foo}}foo{{else if bar}}{{#moo moo}}moo{{/moo}}{{/if}}', 'data' => ['foo' => true], - 'options' => new Options( - helpers: [ - 'moo' => fn($arg1) => $arg1 === null, - ], - ), + 'helpers' => ['moo' => fn($arg1) => $arg1 === null], 'expected' => 'foo', ], [ - 'id' => 213, - 'template' => '{{#with .}}bad{{else}}Good!{{/with}}', - 'data' => [], - 'expected' => 'Good!', + 'desc' => 'LNC #227', + 'template' => '{{#if moo}}A{{else if bar}}B{{else foo}}C{{/if}}', + 'helpers' => ['foo' => $returnBlock], + 'expected' => 'C', ], [ - 'id' => 216, - 'template' => '{{foo.length}}', - 'data' => ['foo' => []], - 'expected' => '0', + 'desc' => 'LNC #233', + 'template' => '{{#if foo}}FOO{{else}}BAR{{/if}}', + // Opt out of compile-time inlining so the custom runtime helper is dispatched + 'options' => new Options(knownHelpers: ['if' => false]), + 'helpers' => [ + 'if' => fn($arg, HelperOptions $options) => $options->fn(), + ], + 'expected' => 'FOO', ], [ - 'id' => 221, - 'template' => 'a{{ouch}}b', - 'options' => new Options( - helpers: $test_helpers, - ), - 'expected' => 'aokb', + 'desc' => 'LNC #252', + 'template' => '{{foo (lookup bar 1)}}', + 'data' => [ + 'bar' => ['nil', [3, 5]], + ], + 'helpers' => ['foo' => $testArray], + 'expected' => 'IS_ARRAY', ], [ - 'id' => 221, - 'template' => 'a{{ouch}}b', - 'options' => new Options( - helpers: $test_helpers2, - ), - 'expected' => 'awa!b', + 'desc' => 'LNC #253', + 'template' => '{{foo.bar}}', + 'data' => ['foo' => ['bar' => 'OK!']], + 'helpers' => ['foo' => fn() => 'bad'], + 'expected' => 'OK!', ], [ - 'id' => 221, - 'template' => 'a{{ouch}}b', - 'options' => new Options( - helpers: $test_helpers3, - ), - 'expected' => 'awa!b', + 'desc' => 'LNC #257', + 'template' => '{{foo a=(foo a=(foo a="ok"))}}', + 'helpers' => [ + 'foo' => fn(HelperOptions $opt) => $opt->hash['a'], + ], + 'expected' => 'ok', ], [ - 'id' => 224, - 'template' => '{{#> foo bar}}a,b,{{.}},{{!-- comment --}},d{{/foo}}', - 'data' => ['bar' => 'BA!'], - 'options' => new Options( - partials: ['foo' => 'hello, {{> @partial-block}}'], - ), - 'expected' => 'hello, a,b,BA!,,d', + 'desc' => 'LNC #268', + 'template' => '{{foo}}{{bar}}', + 'helpers' => [ + 'foo' => function (HelperOptions $opt) { + $opt->scope['change'] = true; + }, + 'bar' => fn(HelperOptions $opt) => $opt->scope['change'] ? 'ok' : 'bad', + ], + 'expected' => 'ok', ], [ - 'id' => 224, - 'template' => '{{#> foo bar}}{{#if .}}OK! {{.}}{{else}}no bar{{/if}}{{/foo}}', - 'data' => ['bar' => 'BA!'], - 'options' => new Options( - partials: ['foo' => 'hello, {{> @partial-block}}'], - ), - 'expected' => 'hello, OK! BA!', + 'desc' => 'LNC #281', + 'template' => '{{echo (echo "foo bar (moo).")}}', + 'helpers' => ['echo' => $echo], + 'expected' => 'ECHO: ECHO: foo bar (moo).', ], [ - 'id' => 227, - 'template' => '{{#if moo}}A{{else if bar}}B{{else foo}}C{{/if}}', - 'options' => new Options( - helpers: [ - 'foo' => fn(HelperOptions $options) => $options->fn(), - ], - ), - 'expected' => 'C', + 'desc' => 'LNC #281', + 'template' => "{{test 'foo bar' (toRegex '^(foo|bar|baz)')}}", + 'helpers' => [ + 'toRegex' => fn($regex) => "/$regex/", + 'test' => fn(string $str, string $regex) => (bool) preg_match($regex, $str), + ], + 'expected' => 'true', ], [ - 'id' => 227, - 'template' => '{{#if moo}}A{{else if bar}}B{{else with foo}}C{{.}}{{/if}}', - 'data' => ['foo' => 'D'], - 'expected' => 'CD', + 'desc' => 'LNC #297', + 'template' => '{{test "foo" bar="\" "}}', + 'helpers' => ['test' => $inlineHashProp], + 'expected' => 'foo-" ', ], [ - 'id' => 227, - 'template' => '{{#if moo}}A{{else if bar}}B{{else each foo}}C{{.}}{{/if}}', - 'data' => ['foo' => [1, 3, 5]], - 'expected' => 'C1C3C5', + 'desc' => 'LNC #298', + 'template' => '{{test "\"\"\"" bar="\"\"\""}}', + 'helpers' => ['test' => $inlineHashProp], + 'expected' => '"""-"""', + ], + [ + 'template' => "{{test '\'\'\'' bar='\'\'\''}}", + 'helpers' => ['test' => $inlineHashProp], + 'expected' => ''''-'''', ], [ - 'id' => 229, - 'template' => '{{#if foo.bar.moo}}TRUE{{else}}FALSE{{/if}}', - 'data' => [], - 'expected' => 'FALSE', + 'desc' => 'LNC #310', + 'template' => <<<_tpl + {{#custom-block 'some-text' data=(custom-helper + opt_a='foo' + opt_b='bar' + )}}...{{/custom-block}} + _tpl, + 'helpers' => [ + 'custom-block' => function ($string, HelperOptions $opts) { + return strtoupper($string) . '-' . $opts->hash['data'] . $opts->fn(); + }, + 'custom-helper' => function (HelperOptions $options) { + return $options->hash['opt_a'] . $options->hash['opt_b']; + }, + ], + 'expected' => 'SOME-TEXT-foobar...', ], [ - 'id' => 233, - 'template' => '{{#if foo}}FOO{{else}}BAR{{/if}}', - 'data' => [], - 'options' => new Options( - helpers: [ - 'if' => function ($arg, HelperOptions $options) { - return $options->fn(); - }, + 'desc' => 'LNC #315', + 'template' => '{{#each foo}}#{{@key}}({{@index}})={{.}}-{{moo}}-{{@irr}}{{/each}}', + 'helpers' => [ + 'moo' => function (HelperOptions $opts) { + $opts->data['irr'] = '123'; + return '321'; + }, + ], + 'data' => [ + 'foo' => [ + 'a' => 'b', + 'c' => 'd', + 'e' => 'f', ], - ), - 'expected' => 'FOO', + ], + 'expected' => '#a(0)=b-321-123#c(1)=d-321-123#e(2)=f-321-123', ], [ - 'id' => 234, - 'template' => '{{> (lookup foo 2)}}', - 'data' => ['foo' => ['a', 'b', 'c']], - 'options' => new Options( - partials: [ - 'a' => '1st', - 'b' => '2nd', - 'c' => '3rd', - ], - ), - 'expected' => '3rd', + 'desc' => 'LNC #350', + 'template' => <<<_hbs + Before: {{var}} + (Setting Variable) {{setvar "var" "Foo"}} + After: {{var}} + _hbs, + 'data' => ['var' => 'value'], + 'helpers' => [ + 'setvar' => function ($name, $value, HelperOptions $options) { + $options->data['root'][$name] = $value; + }, + ], + 'expected' => "Before: value\n(Setting Variable) \nAfter: Foo", ], [ - 'id' => 235, + 'desc' => 'LNC #357', + 'template' => '{{echo (echo "foobar(moo).")}}', + 'helpers' => ['echo' => $echo], + 'expected' => 'ECHO: ECHO: foobar(moo).', + ], + [ + 'desc' => 'LNC #357', + 'template' => "{{{debug (debug 'foobar(moo).')}}}", + 'helpers' => ['debug' => $echo], + 'expected' => 'ECHO: ECHO: foobar(moo).', + ], + [ + 'desc' => 'LNC #357', + 'template' => '{{echo (echo "foobar(moo)." (echo "moobar(foo)"))}}', + 'helpers' => ['echo' => $echo], + 'expected' => 'ECHO: ECHO: foobar(moo).', + ], + + [ + 'desc' => 'LNC #367', + 'template' => "{{#each (myfunc 'foo(bar)' ) }}{{.}},{{/each}}", + 'helpers' => [ + 'myfunc' => fn($arg) => explode('(', $arg), + ], + 'expected' => 'foo,bar),', + ], + + [ + 'desc' => 'LNC #371', + 'template' => <<<_tpl + {{#myeach '[{"a":"ayy", "b":"bee"},{"a":"zzz", "b":"ccc"}]' as | newContext index | }} + Foo {{newContext.a}} {{index}} + {{/myeach}} + _tpl, + 'helpers' => [ + 'myeach' => function ($context, HelperOptions $options) { + $theArray = json_decode($context, true); + $ret = ''; + foreach ($theArray as $i => $value) { + $ret .= $options->fn([], ['blockParams' => [$value, $i]]); + } + return $ret; + }, + ], + 'expected' => "Foo ayy 0\nFoo zzz 1\n", + ], + + [ + 'template' => '{{testNull null undefined 1}}', + 'data' => 'test', + 'helpers' => [ + 'testNull' => function ($arg1, $arg2) { + return ($arg1 === null && $arg2 === null) ? 'YES!' : 'no'; + }, + ], + 'expected' => 'YES!', + ], + + [ + 'template' => '{{[helper]}}', + 'helpers' => ['helper' => fn() => 'DEF'], + 'expected' => 'DEF', + ], + + [ + 'template' => '{{#[helper3]}}ABC{{/[helper3]}}', + 'helpers' => ['helper3' => fn() => 'DEF'], + 'expected' => 'DEF', + ], + + [ + 'template' => '{{hash abc=["def=123"]}}', + 'helpers' => ['hash' => $inlineHashArr], + 'data' => ['"def=123"' => 'La!'], + 'expected' => 'abc : La!,', + ], + + [ + 'template' => '{{hash abc=[\'def=123\']}}', + 'helpers' => ['hash' => $inlineHashArr], + 'data' => ["'def=123'" => 'La!'], + 'expected' => 'abc : La!,', + ], + + [ + 'template' => '-{{getroot}}=', + 'helpers' => [ + 'getroot' => fn(HelperOptions $options) => $options->data['root'], + ], + 'data' => 'ROOT!', + 'expected' => '-ROOT!=', + ], + + [ + 'desc' => 'inverted helpers should support hash arguments', + 'template' => '{{^helper fizz="buzz"}}{{/helper}}', + 'helpers' => [ + 'helper' => fn(HelperOptions $options) => $options->hash['fizz'], + ], + 'expected' => 'buzz', + ], + [ + 'template' => '{{#myif foo}}YES{{else}}NO{{/myif}}', + 'helpers' => ['myif' => $myIf], + 'expected' => 'NO', + ], + + [ + 'template' => '{{#myif foo}}YES{{else}}NO{{/myif}}', + 'data' => ['foo' => 1], + 'helpers' => ['myif' => $myIf], + 'expected' => 'YES', + ], + + [ + 'template' => '{{#mylogic 0 foo bar}}YES:{{.}}{{else}}NO:{{.}}{{/mylogic}}', + 'data' => ['foo' => 'FOO', 'bar' => 'BAR'], + 'helpers' => ['mylogic' => $myLogic], + 'expected' => 'NO:BAR', + ], + + [ + 'template' => '{{#mylogic true foo bar}}YES:{{.}}{{else}}NO:{{.}}{{/mylogic}}', + 'data' => ['foo' => 'FOO', 'bar' => 'BAR'], + 'helpers' => ['mylogic' => $myLogic], + 'expected' => 'YES:FOO', + ], + + [ + 'template' => '{{#mywith foo}}YA: {{name}}{{/mywith}}', + 'data' => ['name' => 'OK?', 'foo' => ['name' => 'OK!']], + 'helpers' => ['mywith' => $myWith], + 'expected' => 'YA: OK!', + ], + + [ + 'template' => '{{mydash \'abc\' "dev"}}', + 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], + 'helpers' => ['mydash' => $myDash], + 'expected' => 'abc-dev', + ], + + [ + 'template' => '{{mydash \'a b c\' "d e f"}}', + 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], + 'helpers' => ['mydash' => $myDash], + 'expected' => 'a b c-d e f', + ], + + [ + 'template' => '{{mydash "abc" (test_array 1)}}', + 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], + 'helpers' => [ + 'mydash' => $myDash, + 'test_array' => $testArray, + ], + 'expected' => 'abc-NOT_ARRAY', + ], + + [ + 'template' => '{{mydash "abc" (mydash a b)}}', + 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], + 'helpers' => ['mydash' => $myDash], + 'expected' => 'abc-a-b', + ], + + [ + 'template' => '{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', + 'data' => ['my_var' => 0], + 'helpers' => ['equals' => $equals], + 'expected' => 'Equal to false', + ], + [ + 'template' => '{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', + 'data' => ['my_var' => 1], + 'helpers' => ['equals' => $equals], + 'expected' => 'Not equal', + ], + [ + 'template' => '{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', + 'helpers' => ['equals' => $equals], + 'expected' => 'Not equal', + ], + + [ + 'template' => << +
  • 1. {{helper1 name}}
  • +
  • 2. {{helper1 value}}
  • +
  • 3. {{helper2 name}}
  • +
  • 4. {{helper2 value}}
  • +
  • 9. {{link name}}
  • +
  • 10. {{link value}}
  • +
  • 11. {{alink url text}}
  • +
  • 12. {{{alink url text}}}
  • + + VAREND + , + 'data' => ['name' => 'John', 'value' => 10000, 'url' => 'http://yahoo.com', 'text' => 'You&Me!'], + 'helpers' => [ + 'helper1' => fn($arg) => is_array($arg) ? '-Array-' : "-$arg-", + 'helper2' => fn($arg) => is_array($arg) ? '=Array=' : "=$arg=", + 'link' => function ($arg) { + if (is_array($arg)) { + $arg = 'Array'; + } + return "click here"; + }, + 'alink' => function ($u, $t) { + $u = is_array($u) ? 'Array' : $u; + $t = is_array($t) ? 'Array' : $t; + return "$t"; + }, + ], + 'expected' => << +
  • 1. -John-
  • +
  • 2. -10000-
  • +
  • 3. =John=
  • +
  • 4. =10000=
  • +
  • 9. <a href="John">click here</a>
  • +
  • 10. <a href="10000">click here</a>
  • +
  • 11. <a href="http://yahoo.com">You&Me!</a>
  • +
  • 12. You&Me!
  • + + VAREND, + ], + + [ + 'template' => ">{{helper1 \"===\"}}<", + 'helpers' => [ + 'helper1' => fn($arg) => is_array($arg) ? '-Array-' : "-$arg-", + ], + 'expected' => ">-===-<", + ], + + [ + 'desc' => 'inverted block helper with block params', + 'template' => '{{^helper items as |foo bar baz|}}{{foo}}{{bar}}{{baz}}{{/helper}}', + 'helpers' => [ + 'helper' => function (array $items, HelperOptions $options) { + return $options->inverse(null, ['blockParams' => [1, 2, 3]]); + }, + ], + 'data' => ['items' => []], + 'expected' => '123', + ], + [ + 'desc' => 'inverted block helper returning truthy non-string: stringified like JS', + 'template' => '{{^helper}}block{{/helper}}', + 'helpers' => ['helper' => fn() => ['truthy', 'array']], + 'expected' => 'truthy,array', + ], + [ + 'desc' => 'block helper returning truthy non-string: stringified like JS', + 'template' => '{{#helper}}block{{/helper}}', + 'helpers' => ['helper' => fn() => ['truthy', 'array']], + 'expected' => 'truthy,array', + ], + [ + 'template' => '{{^helper}}block{{/helper}}', + 'options' => new Options(knownHelpers: ['helper' => true]), + 'helpers' => ['helper' => fn() => ['truthy', 'array']], + 'expected' => 'truthy,array', + ], + [ + 'desc' => 'block helper returning truthy non-string: stringified like JS', + 'template' => '{{#helper}}block{{/helper}}', + 'options' => new Options(knownHelpers: ['helper' => true]), + 'helpers' => ['helper' => fn() => ['truthy', 'array']], + 'expected' => 'truthy,array', + ], + + [ + 'desc' => 'ensure that literal block path helper names are correctly escaped', + 'template' => '{{#"it\'s"}}YES{{/"it\'s"}}', + 'options' => new Options(knownHelpers: ["it's" => true]), + 'helpers' => ["it's" => fn(HelperOptions $options) => $options->fn()], + 'expected' => 'YES', + ], + + [ + 'desc' => 'closures in data can be used like helpers', + 'template' => '{{test "Hello"}}', + 'data' => ['test' => fn(string $arg) => "$arg runtime data"], + 'expected' => 'Hello runtime data', + ], + [ + 'desc' => 'helpers always take precedence over data closures', + 'template' => '{{test "Hello"}}', + 'data' => ['test' => fn(string $arg) => "$arg runtime data"], + 'helpers' => ['test' => fn(string $arg) => "$arg runtime helper"], + 'expected' => 'Hello runtime helper', + ], + ]; + } + + /** + * @return list + */ + public static function partialProvider(): array + { + return [ + [ + 'desc' => 'LNC #7', + 'template' => "

    \n {{> list}}\n

    ", + 'data' => ['items' => ['Hello', 'World']], + 'options' => new Options( + partials: ['list' => "{{#each items}}{{this}}\n{{/each}}"], + ), + 'expected' => "

    \n Hello\n World\n

    ", + ], + + [ + 'desc' => 'LNC #64', + 'template' => '{{#each foo}} Test! {{this}} {{/each}}{{> test1}} ! >>> {{>recursive}}', + 'options' => new Options( + partials: [ + 'test1' => "123\n", + 'recursive' => "{{#if foo}}{{bar}} -> {{#with foo}}{{>recursive}}{{/with}}{{else}}END!{{/if}}\n", + ], + ), + 'data' => [ + 'bar' => 1, + 'foo' => [ + 'bar' => 3, + 'foo' => [ + 'bar' => 5, + 'foo' => [ + 'bar' => 7, + 'foo' => [ + 'bar' => 11, + 'foo' => [ + 'no foo here', + ], + ], + ], + ], + ], + ], + 'expected' => " Test! 3 Test! [object Object] 123\n ! >>> 1 -> 3 -> 5 -> 7 -> 11 -> END!\n\n\n\n\n\n", + ], + + [ + 'desc' => 'LNC #83', + 'template' => '{{> tests/test1}}', + 'options' => new Options( + partials: ['tests/test1' => "123\n"], + ), + 'expected' => "123\n", + ], + + [ + 'desc' => 'LNC #88', + 'template' => '{{>test2}}', + 'options' => new Options( + partials: [ + 'test2' => "a{{> test1}}b\n", + 'test1' => "123\n", + ], + ), + 'expected' => "a123\nb\n", + ], + + [ + 'desc' => 'LNC #109', + 'template' => '{{foo}} {{> test}}', + 'options' => new Options( + noEscape: true, + partials: ['test' => '{{foo}}'], + ), + 'data' => ['foo' => '<'], + 'expected' => '< <', + ], + + [ + 'desc' => 'LNC #147', + 'template' => '{{> test/test3 foo="bar"}}', + 'data' => ['test' => 'OK!', 'foo' => 'error'], + 'options' => new Options( + partials: ['test/test3' => '{{test}}, {{foo}}'], + ), + 'expected' => 'OK!, bar', + ], + + [ + 'desc' => 'LNC #157', + 'template' => '{{>test_js_partial}}', + 'options' => new Options( + partials: [ + 'test_js_partial' => << + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){console.log('works!')};})(); + + VAREND, + ], + ), + 'expected' => << + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){console.log('works!')};})(); + + VAREND, + ], + + [ + 'desc' => 'LNC #175', + 'template' => 'c{{>test}}d', + 'options' => new Options( + partials: ['test' => 'a{{!-- {{each}} haha {{/each}} --}}b'], + ), + 'expected' => 'cabd', + ], + + [ + 'desc' => 'LNC #204', + 'template' => '{{#> test name="A"}}B{{/test}}{{#> test name="C"}}D{{/test}}', + 'data' => ['bar' => true], + 'options' => new Options( + partials: ['test' => '{{name}}:{{> @partial-block}},'], + ), + 'expected' => 'A:B,C:D,', + ], + + [ + 'desc' => 'LNC #224', + 'template' => '{{#> foo bar}}a,b,{{.}},{{!-- comment --}},d{{/foo}}', + 'data' => ['bar' => 'BA!'], + 'options' => new Options( + partials: ['foo' => 'hello, {{> @partial-block}}'], + ), + 'expected' => 'hello, a,b,BA!,,d', + ], + [ + 'desc' => 'LNC #224', + 'template' => '{{#> foo bar}}{{#if .}}OK! {{.}}{{else}}no bar{{/if}}{{/foo}}', + 'data' => ['bar' => 'BA!'], + 'options' => new Options( + partials: ['foo' => 'hello, {{> @partial-block}}'], + ), + 'expected' => 'hello, OK! BA!', + ], + + [ + 'desc' => 'LNC #234', + 'template' => '{{> (lookup foo 2)}}', + 'data' => ['foo' => ['a', 'b', 'c']], + 'options' => new Options( + partials: [ + 'a' => '1st', + 'b' => '2nd', + 'c' => '3rd', + ], + ), + 'expected' => '3rd', + ], + + [ + 'desc' => 'LNC #235', 'template' => '{{#> "myPartial"}}{{#> myOtherPartial}}{{ @root.foo}}{{/myOtherPartial}}{{/"myPartial"}}', 'data' => ['foo' => 'hello!'], 'options' => new Options( @@ -1130,7 +1185,7 @@ public static function issueProvider(): array ], [ - 'id' => 236, + 'desc' => 'LNC #236', 'template' => 'A{{#> foo}}B{{#> bar}}C{{>moo}}D{{/bar}}E{{/foo}}F', 'options' => new Options( partials: [ @@ -1143,7 +1198,7 @@ public static function issueProvider(): array ], [ - 'id' => 241, + 'desc' => 'LNC #241', 'template' => '{{#>foo}}{{#*inline "bar"}}GOOD!{{#each .}}>{{.}}{{/each}}{{/inline}}{{/foo}}', 'data' => ['1', '3', '5'], 'options' => new Options( @@ -1156,21 +1211,7 @@ public static function issueProvider(): array ], [ - 'id' => 243, - 'template' => '{{lookup . 3}}', - 'data' => ['3' => 'OK'], - 'expected' => 'OK', - ], - - [ - 'id' => 243, - 'template' => '{{lookup . "test"}}', - 'data' => ['test' => 'OK'], - 'expected' => 'OK', - ], - - [ - 'id' => 244, + 'desc' => 'LNC #244', 'template' => '{{#>outer}}content{{/outer}}', 'data' => ['test' => 'OK'], 'options' => new Options( @@ -1183,647 +1224,432 @@ public static function issueProvider(): array ], [ - 'id' => 245, - 'template' => '{{#each foo}}{{#with .}}{{bar}}-{{../../name}}{{/with}}{{/each}}', - 'data' => ['name' => 'bad', 'foo' => [ - ['bar' => 1], - ['bar' => 2], - ]], - 'expected' => '1-2-', + 'desc' => 'LNC #284', + 'template' => '{{> foo}}', + 'options' => new Options( + partials: ['foo' => "12'34"], + ), + 'expected' => "12'34", ], - [ - 'id' => 252, - 'template' => '{{foo (lookup bar 1)}}', - 'data' => ['bar' => [ - 'nil', - [3, 5], - ]], + 'desc' => 'LNC #284', + 'template' => '{{> (lookup foo 2)}}', + 'data' => ['foo' => ['a', 'b', 'c']], 'options' => new Options( - helpers: [ - 'foo' => fn($arg1) => is_array($arg1) ? 'OK' : 'bad', + partials: [ + 'a' => '1st', + 'b' => '2nd', + 'c' => "3'r'd", ], ), - 'expected' => 'OK', + 'expected' => "3'r'd", ], [ - 'id' => 253, - 'template' => '{{foo.bar}}', - 'data' => ['foo' => ['bar' => 'OK!']], + 'desc' => 'LNC #292', + 'template' => '{ {{#>outer}} {{#>innerBlock}} Hello {{/innerBlock}} {{>simple}} {{/outer}} }', 'options' => new Options( - helpers: [ - 'foo' => fn() => 'bad', + partials: [ + 'outer' => '( {{#>nested}} « {{>@partial-block}} » {{/nested}} )', + 'nested' => '[ {{>@partial-block}} ]', + 'innerBlock' => '< {{>@partial-block}} >', + 'simple' => 'World!', ], ), - 'expected' => 'OK!', + 'expected' => '{ ( [ « < Hello > World! » ] ) }', ], [ - 'id' => 254, - 'template' => '{{#if a}}a{{else if b}}b{{else}}c{{/if}}{{#if a}}a{{else if b}}b{{/if}}', - 'data' => ['b' => 1], - 'expected' => 'bb', + 'desc' => 'LNC #295', + 'template' => '{{> MyPartial (newObject name="John Doe") message=(echo message=(echo message="Hello World!"))}}', + 'options' => new Options( + partials: ['MyPartial' => '{{name}} says: "{{message}}"'], + ), + 'helpers' => [ + 'newObject' => fn(HelperOptions $options) => $options->hash, + 'echo' => fn(HelperOptions $options) => $options->hash['message'], + ], + 'expected' => 'John Doe says: "Hello World!"', ], [ - 'id' => 255, - 'template' => '{{foo.length}}', - 'data' => ['foo' => [1, 2]], - 'expected' => '2', + 'desc' => 'LNC #302', + 'template' => "{{#*inline \"t1\"}}{{#if imageUrl}}{{else}}
    {{/if}}{{/inline}}{{#*inline \"t2\"}}{{#if imageUrl}}{{else}}
    {{/if}}{{/inline}}{{#*inline \"t3\"}}{{#if imageUrl}}{{else}}
    {{/if}}{{/inline}}", + 'expected' => '', ], [ - 'id' => 3, - 'template' => 'ok{{{lookup . "missing"}}}', - 'expected' => 'ok', + 'desc' => 'LNC #303', + 'template' => '{{#*inline "t1"}} {{#if url}} {{else if imageUrl}} {{else}} {{/if}} {{/inline}}', + 'expected' => '', ], [ - 'id' => 257, - 'template' => '{{foo a=(foo a=(foo a="ok"))}}', + 'desc' => 'LNC #316', + 'template' => '{{> StrongPartial text="Use the syntax: {{varName}}."}}', 'options' => new Options( - helpers: [ - 'foo' => fn(HelperOptions $opt) => $opt->hash['a'], - ], + partials: ['StrongPartial' => '{{text}}'], ), - 'expected' => 'ok', - ], - - [ - 'id' => 261, - 'template' => '{{#each foo as |bar|}}?{{bar.[0]}}{{/each}}', - 'data' => ['foo' => [['a'], ['b']]], - 'expected' => '?a?b', - ], - - [ - 'id' => 267, - 'template' => '{{#each . as |v k|}}#{{k}}>{{v}}|{{.}}{{/each}}', - 'data' => ['a' => 'b', 'c' => 'd'], - 'expected' => '#a>b|b#c>d|d', + 'data' => ['varName' => 'unused'], + 'expected' => 'Use the syntax: {{varName}}.', ], [ - 'id' => 268, - 'template' => '{{foo}}{{bar}}', + 'template' => '{{> (pname foo) bar}}', + 'data' => ['bar' => 'OK! SUBEXP+PARTIAL!', 'foo' => 'test/test3'], 'options' => new Options( - helpers: [ - 'foo' => function (HelperOptions $opt) { - $opt->scope['change'] = true; - }, - 'bar' => function (HelperOptions $opt) { - return $opt->scope['change'] ? 'ok' : 'bad'; - }, - ], + partials: ['test/test3' => '{{.}}'], ), - 'expected' => 'ok', + 'helpers' => ['pname' => fn($arg) => $arg], + 'expected' => 'OK! SUBEXP+PARTIAL!', ], [ - 'id' => 278, - 'template' => '{{#foo}}-{{#bar}}={{moo}}{{/bar}}{{/foo}}', + 'template' => '{{> (partial_name_helper type)}}', 'data' => [ - 'foo' => [ - ['bar' => 0, 'moo' => 'A'], - ['bar' => 1, 'moo' => 'B'], - ['bar' => false, 'moo' => 'C'], - ['bar' => true, 'moo' => 'D'], + 'type' => 'dog', + 'name' => 'Lucky', + 'age' => 5, + ], + 'options' => new Options( + partials: [ + 'people' => 'This is {{name}}, he is {{age}} years old.', + 'animal' => 'This is {{name}}, it is {{age}} years old.', + 'default' => 'This is {{name}}.', ], + ), + 'helpers' => [ + 'partial_name_helper' => function (string $type) { + return match ($type) { + 'man', 'woman' => 'people', + 'dog', 'cat' => 'animal', + default => 'default', + }; + }, ], - 'expected' => '-=-=--=D', + 'expected' => 'This is Lucky, it is 5 years old.', ], [ - 'id' => 281, - 'template' => '{{echo (echo "foo bar (moo).")}}', + 'template' => '{{> testpartial newcontext mixed=foo}}', + 'data' => ['foo' => 'OK!', 'newcontext' => ['bar' => 'test']], 'options' => new Options( - helpers: [ - 'echo' => fn($arg1) => "ECHO: $arg1", - ], + partials: ['testpartial' => '{{bar}}-{{mixed}}'], ), - 'expected' => 'ECHO: ECHO: foo bar (moo).', + 'expected' => 'test-OK!', ], [ - 'id' => 281, - 'template' => "{{test 'foo bar' (toRegex '^(foo|bar|baz)')}}", + 'desc' => 'ensure that partial names are correctly escaped', + 'template' => '{{> "foo\button\'"}} {{> "bar\\\link"}}', 'options' => new Options( - helpers: [ - 'toRegex' => fn($regex) => "/$regex/", - 'test' => fn(string $str, string $regex) => (bool) preg_match($regex, $str), + partials: [ + 'foo\button\'' => 'Button!', + 'bar\\\link' => 'Link!', ], ), - 'expected' => 'true', + 'expected' => 'Button! Link!', ], [ - 'id' => 284, - 'template' => '{{> foo}}', + 'template' => '{{#each .}}->{{>tests/test3}}{{/each}}', + 'data' => ['a', 'b', 'c'], 'options' => new Options( - partials: ['foo' => "12'34"], + partials: ['tests/test3' => 'New context:{{.}}'], ), - 'expected' => "12'34", + 'expected' => "->New context:a->New context:b->New context:c", ], - [ - 'id' => 284, - 'template' => '{{> (lookup foo 2)}}', - 'data' => ['foo' => ['a', 'b', 'c']], + 'template' => '{{#each .}}->{{>tests/test3 ../foo}}{{/each}}', + 'data' => ['a', 'foo' => ['d', 'e', 'f']], 'options' => new Options( - partials: [ - 'a' => '1st', - 'b' => '2nd', - 'c' => "3'r'd", - ], + partials: ['tests/test3' => 'New context:{{.}}'], ), - 'expected' => "3'r'd", - ], - - [ - 'id' => 289, - 'template' => "1\n2\n{{~foo~}}\n3", - 'data' => ['foo' => 'OK'], - 'expected' => "1\n2OK3", - ], - - [ - 'id' => 289, - 'template' => "1\n2\n{{#test}}\n3TEST\n{{/test}}\n4", - 'data' => ['test' => 1], - 'expected' => "1\n2\n3TEST\n4", - ], - - [ - 'id' => 289, - 'template' => "1\n2\n{{~#test}}\n3TEST\n{{/test}}\n4", - 'data' => ['test' => 1], - 'expected' => "1\n23TEST\n4", - ], - - [ - 'id' => 289, - 'template' => "1\n2\n{{#>test}}\n3TEST\n{{/test}}\n4", - 'expected' => "1\n2\n3TEST\n4", - ], - - [ - 'id' => 289, - 'template' => "1\n2\n\n{{#>test}}\n3TEST\n{{/test}}\n4", - 'expected' => "1\n2\n\n3TEST\n4", - ], - - [ - 'id' => 289, - 'template' => "1\n2\n\n{{#>test~}}\n\n3TEST\n{{/test}}\n4", - 'expected' => "1\n2\n\n3TEST\n4", - ], - - [ - 'id' => 290, - 'template' => '{{foo}} }} OK', - 'data' => [ - 'foo' => 'YES', - ], - 'expected' => 'YES }} OK', - ], - - [ - 'id' => 290, - 'template' => '{{foo}}{{#with "}"}}{{.}}{{/with}}OK', - 'data' => [ - 'foo' => 'YES', - ], - 'expected' => 'YES}OK', - ], - - [ - 'id' => 290, - 'template' => '{ {{foo}}', - 'data' => [ - 'foo' => 'YES', - ], - 'expected' => '{ YES', + 'expected' => "->New context:d,e,f->New context:d,e,f", ], [ - 'id' => 290, - 'template' => '{{#with "{{"}}{{.}}{{/with}}{{foo}}{{#with "{{"}}{{.}}{{/with}}', - 'data' => [ - 'foo' => 'YES', - ], - 'expected' => '{{YES{{', + 'template' => '{{#*inline}}{{/inline}}', + 'expected' => '', ], [ - 'id' => 292, - 'template' => '{ {{#>outer}} {{#>innerBlock}} Hello {{/innerBlock}} {{>simple}} {{/outer}} }', + 'template' => <<<_tpl +
    + {{> partialA}} + {{> partialB}} +
    + _tpl, 'options' => new Options( partials: [ - 'outer' => '( {{#>nested}} « {{>@partial-block}} » {{/nested}} )', - 'nested' => '[ {{>@partial-block}} ]', - 'innerBlock' => '< {{>@partial-block}} >', - 'simple' => 'World!', + 'partialA' => "
    \n Partial A\n {{> partialB}}\n
    \n", + 'partialB' => "

    \n Partial B\n

    \n", ], ), - 'expected' => '{ ( [ « < Hello > World! » ] ) }', + 'expected' => <<<_result +
    +
    + Partial A +

    + Partial B +

    +
    +

    + Partial B +

    +
    + _result, ], [ - 'id' => 295, - 'template' => '{{> MyPartial (newObject name="John Doe") message=(echo message=(echo message="Hello World!"))}}', + 'template' => "{{>test1}}\n {{>test1}}\nDONE\n", 'options' => new Options( - helpers: [ - 'newObject' => fn(HelperOptions $options) => $options->hash, - 'echo' => fn(HelperOptions $options) => $options->hash['message'], - ], - partials: [ - 'MyPartial' => '{{name}} says: "{{message}}"', - ], + partials: ['test1' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n"], ), - 'expected' => 'John Doe says: "Hello World!"', + 'expected' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n 1:A\n 2:B\n 3:C\n 4:D\n 5:E\nDONE\n", ], - [ - 'id' => 297, - 'template' => '{{test "foo" prop="\" "}}', + 'template' => "{{>test1}}\n {{>test1}}\nDONE\n", 'options' => new Options( - helpers: [ - 'test' => function ($arg1, HelperOptions $options) { - return "{$arg1} {$options->hash['prop']}"; - }, - ], + preventIndent: true, + partials: ['test1' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n"], ), - 'expected' => 'foo " ', + 'expected' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n 1:A\n 2:B\n 3:C\n 4:D\n5:E\nDONE\n", ], [ - 'id' => 298, - 'template' => '{{test "\"\"\"" prop="\"\"\""}}', + 'template' => "{{>test}}\n", + 'data' => ['foo' => 'ha', 'bar' => 'hey'], 'options' => new Options( - helpers: [ - 'test' => function ($arg1, HelperOptions $options) { - return "{$arg1} {$options->hash['prop']}"; - }, - ], + partials: ['test' => "{{foo}}\n {{bar}}\n"], ), - 'expected' => '""" """', + 'expected' => "ha\n hey\n", ], [ - 'template' => "{{test '\'\'\'' prop='\'\'\''}}", + 'template' => " {{>test}}\n", + 'data' => ['foo' => 'ha', 'bar' => 'hey'], 'options' => new Options( - helpers: [ - 'test' => function ($arg1, HelperOptions $options) { - return "{$arg1} {$options->hash['prop']}"; - }, - ], + preventIndent: true, + partials: ['test' => "{{foo}}\n {{bar}}\n"], ), - 'expected' => '''' '''', + 'expected' => " ha\n hey\n", ], - [ - 'id' => 302, - 'template' => "{{#*inline \"t1\"}}{{#if imageUrl}}{{else}}
    {{/if}}{{/inline}}{{#*inline \"t2\"}}{{#if imageUrl}}{{else}}
    {{/if}}{{/inline}}{{#*inline \"t3\"}}{{#if imageUrl}}{{else}}
    {{/if}}{{/inline}}", - 'expected' => '', + 'template' => "\n {{>test}}\n", + 'data' => ['foo' => 'ha', 'bar' => 'hey'], + 'options' => new Options( + preventIndent: true, + partials: ['test' => "{{foo}}\n {{bar}}\n"], + ), + 'expected' => "\n ha\n hey\n", ], [ - 'id' => 303, - 'template' => '{{#*inline "t1"}} {{#if url}} {{else if imageUrl}} {{else}} {{/if}} {{/inline}}', - 'expected' => '', + 'template' => "ST:\n{{#foo}}\n {{>test1}}\n{{/foo}}\nOK\n", + 'data' => ['foo' => [1, 2]], + 'options' => new Options( + partials: ['test1' => "1:A\n 2:B({{@index}})\n"], + ), + 'expected' => "ST:\n 1:A\n 2:B(0)\n 1:A\n 2:B(1)\nOK\n", ], [ - 'id' => 310, - 'template' => <<<_tpl - {{#custom-block 'some-text' data=(custom-helper - opt_a='foo' - opt_b='bar' - )}}...{{/custom-block}} - _tpl, + 'template' => '{{>foo}} and {{>bar}}', 'options' => new Options( - helpers: [ - 'custom-block' => function ($string, HelperOptions $opts) { - return strtoupper($string) . '-' . $opts->hash['data'] . $opts->fn(); - }, - 'custom-helper' => function (HelperOptions $options) { - return $options->hash['opt_a'] . $options->hash['opt_b']; - }, - ], + partialResolver: fn(string $name) => "PARTIAL: $name", ), - 'expected' => 'SOME-TEXT-foobar...', + 'expected' => 'PARTIAL: foo and PARTIAL: bar', ], [ - 'id' => 313, - 'template' => <<<_tpl - {{#if conditionA}} - {{#if conditionA1}} - Do something then do more stuff conditionally - {{#if conditionA1.x}} - Do something here - {{else if conditionA1.y}} - Do something else here - {{/if}} - {{/if}} - {{else if conditionB}} - Do something else - {{else}} - Finally, do this last thing if all else fails - {{/if}} - _tpl, - 'expected' => " Finally, do this last thing if all else fails\n", + 'desc' => '{{#* inline}} partials registered inside a block section do not leak out after the block ends', + 'template' => '{{#* inline "p"}}BEFORE{{/inline}}{{#section}}{{#* inline "p"}}INSIDE{{/inline}}{{> p}}{{/section}}{{> p}}', + 'options' => new Options(knownHelpersOnly: true), + 'data' => ['section' => ['x' => 1]], + 'expected' => 'INSIDEBEFORE', ], [ - 'id' => 315, - 'template' => '{{#each foo}}#{{@key}}({{@index}})={{.}}-{{moo}}-{{@irr}}{{/each}}', - 'options' => new Options( - helpers: [ - 'moo' => function (HelperOptions $opts) { - $opts->data['irr'] = '123'; - return '321'; - }, - ], - ), - 'data' => [ - 'foo' => [ - 'a' => 'b', - 'c' => 'd', - 'e' => 'f', - ], - ], - 'expected' => '#a(0)=b-321-123#c(1)=d-321-123#e(2)=f-321-123', + 'template' => '{{#>foo}}inline\'partial{{/foo}}', + 'expected' => 'inline\'partial', ], [ - 'id' => 316, - 'template' => '{{> StrongPartial text="Use the syntax: {{varName}}."}}', - 'options' => new Options( - partials: [ - 'StrongPartial' => '{{text}}', - ], - ), - 'data' => [ - 'varName' => 'unused', - ], - 'expected' => 'Use the syntax: {{varName}}.', + 'template' => "{{#> testPartial}}\n before\n {{#> innerPartial}}\n inner!\n {{/innerPartial}}\n after\n {{/testPartial}}", + 'expected' => " before\n inner!\n after\n", ], + ]; + } + /** + * @return list + */ + public static function builtInProvider(): array + { + return [ [ - 'id' => 344, - 'template' => '{{{{raw}}}} {{bar}} {{{{/raw}}}} {{bar}}', - 'data' => [ - 'raw' => true, - 'bar' => 'content', - ], - 'expected' => ' {{bar}} content', + 'desc' => 'LNC #81', + 'template' => '{{#with ../person}} {{^name}} Unknown {{/name}} {{/with}}?!', + 'data' => ['parent?!' => ['A', 'B', 'bar' => ['C', 'D', 'E']]], + 'expected' => '?!', ], [ - 'id' => 350, - 'template' => <<<_hbs - Before: {{var}} - (Setting Variable) {{setvar "var" "Foo"}} - After: {{var}} - _hbs, - 'data' => ['var' => 'value'], - 'options' => new Options( - helpers: [ - 'setvar' => function ($name, $value, HelperOptions $options) { - $options->data['root'][$name] = $value; - }, - ], - ), - 'expected' => "Before: value\n(Setting Variable) \nAfter: Foo", + 'desc' => 'LNC #109', + 'template' => '{{#if "OK"}}it\'s great!{{/if}}', + 'options' => new Options(noEscape: true), + 'expected' => 'it\'s great!', ], [ - 'id' => 357, - 'template' => '{{echo (echo "foobar(moo).")}}', - 'options' => new Options( - helpers: [ - 'echo' => fn($arg1) => "ECHO: $arg1", - ], - ), - 'expected' => 'ECHO: ECHO: foobar(moo).', + 'desc' => 'LNC #199', + 'template' => '{{#if foo}}1{{else if bar}}2{{else}}3{{/if}}', + 'expected' => '3', ], [ - 'id' => 357, - 'template' => "{{{debug (debug 'foobar(moo).')}}}", - 'options' => new Options( - helpers: [ - 'debug' => fn($arg1) => "ECHO: $arg1", - ], - ), - 'expected' => 'ECHO: ECHO: foobar(moo).', + 'desc' => 'LNC #199', + 'template' => '{{#if foo}}1{{else if bar}}2{{/if}}', + 'data' => ['bar' => true], + 'expected' => '2', ], [ - 'id' => 357, - 'template' => '{{echo (echo "foobar(moo)." (echo "moobar(foo)"))}}', - 'options' => new Options( - helpers: [ - 'echo' => fn($arg1) => "ECHO: $arg1", - ], - ), - 'expected' => 'ECHO: ECHO: foobar(moo).', + 'desc' => 'LNC #200', + 'template' => '{{#unless 0}}1{{else if foo}}2{{else}}3{{/unless}}', + 'data' => ['foo' => false], + 'expected' => '1', ], - [ - 'id' => 367, - 'template' => "{{#each (myfunc 'foo(bar)' ) }}{{.}},{{/each}}", - 'options' => new Options( - helpers: [ - 'myfunc' => fn($arg) => explode('(', $arg), - ], - ), - 'expected' => 'foo,bar),', + 'desc' => 'LNC #201', + 'template' => '{{#unless 0 includeZero=true}}1{{else if foo}}2{{else}}3{{/unless}}', + 'data' => ['foo' => true], + 'expected' => '2', ], - [ - 'id' => 369, - 'template' => '{{#each paragraphs}}

    {{this}}

    {{else}}

    {{foo}}

    {{/each}}', - 'data' => ['foo' => 'bar'], - 'expected' => '

    bar

    ', + 'desc' => 'LNC #202', + 'template' => '{{#unless 0 includeZero=true}}1{{else if foo}}2{{else}}3{{/unless}}', + 'data' => ['foo' => false], + 'expected' => '3', ], [ - 'id' => 370, - 'template' => '{{@root.items.length}}', - 'data' => ['items' => [1, 2, 3]], - 'expected' => '3', + 'desc' => 'LNC #206', + 'template' => '{{#with bar}}{{#../foo}}YES!{{/../foo}}{{/with}}', + 'data' => ['foo' => 999, 'bar' => true], + 'expected' => 'YES!', ], [ - 'id' => 371, - 'template' => <<<_tpl - {{#myeach '[{"a":"ayy", "b":"bee"},{"a":"zzz", "b":"ccc"}]' as | newContext index | }} - Foo {{newContext.a}} {{index}} - {{/myeach}} - _tpl, - 'options' => new Options( - helpers: [ - 'myeach' => function ($context, HelperOptions $options) { - $theArray = json_decode($context, true); - if (!is_array($theArray)) { - return ''; - } - - $ret = ''; - foreach ($theArray as $i => $value) { - $ret .= $options->fn([], ['blockParams' => [$value, $i]]); - } - return $ret; - }, - ], - ), - 'expected' => "Foo ayy 0\nFoo zzz 1\n", + 'desc' => 'LNC #213', + 'template' => '{{#with .}}bad{{else}}Good!{{/with}}', + 'expected' => 'Good!', ], [ - 'template' => '{{#each . as |v k|}}#{{k}}{{/each}}', - 'data' => ['a' => [], 'c' => []], - 'expected' => '#a#c', + 'desc' => 'LNC #227', + 'template' => '{{#if moo}}A{{else if bar}}B{{else with foo}}C{{.}}{{/if}}', + 'data' => ['foo' => 'D'], + 'expected' => 'CD', + ], + [ + 'desc' => 'LNC #227', + 'template' => '{{#if moo}}A{{else if bar}}B{{else each foo}}C{{.}}{{/if}}', + 'data' => ['foo' => [1, 3, 5]], + 'expected' => 'C1C3C5', ], [ - 'template' => '{{testNull null undefined 1}}', - 'data' => 'test', - 'options' => new Options( - helpers: [ - 'testNull' => function ($arg1, $arg2) { - return ($arg1 === null && $arg2 === null) ? 'YES!' : 'no'; - }, - ], - ), - 'expected' => 'YES!', + 'desc' => 'LNC #229', + 'template' => '{{#if foo.bar.moo}}TRUE{{else}}FALSE{{/if}}', + 'expected' => 'FALSE', ], [ - 'template' => '{{> (pname foo) bar}}', - 'data' => ['bar' => 'OK! SUBEXP+PARTIAL!', 'foo' => 'test/test3'], - 'options' => new Options( - helpers: [ - 'pname' => fn($arg) => $arg, - ], - partials: ['test/test3' => '{{.}}'], - ), - 'expected' => 'OK! SUBEXP+PARTIAL!', + 'desc' => 'LNC #243', + 'template' => '{{lookup . 3}}', + 'data' => ['3' => 'OK'], + 'expected' => 'OK', + ], + [ + 'desc' => 'LNC #243', + 'template' => '{{lookup . "test"}}', + 'data' => ['test' => 'OK'], + 'expected' => 'OK', ], [ - 'template' => '{{> (partial_name_helper type)}}', + 'desc' => 'LNC #245', + 'template' => '{{#each foo}}{{#with .}}{{bar}}-{{../../name}}{{/with}}{{/each}}', 'data' => [ - 'type' => 'dog', - 'name' => 'Lucky', - 'age' => 5, + 'name' => 'bad', + 'foo' => [['bar' => 1], ['bar' => 2]], ], - 'options' => new Options( - helpers: [ - 'partial_name_helper' => function (string $type) { - return match ($type) { - 'man', 'woman' => 'people', - 'dog', 'cat' => 'animal', - default => 'default', - }; - }, - ], - partials: [ - 'people' => 'This is {{name}}, he is {{age}} years old.', - 'animal' => 'This is {{name}}, it is {{age}} years old.', - 'default' => 'This is {{name}}.', - ], - ), - 'expected' => 'This is Lucky, it is 5 years old.', + 'expected' => '1-2-', ], [ - 'template' => '{{> testpartial newcontext mixed=foo}}', - 'data' => ['foo' => 'OK!', 'newcontext' => ['bar' => 'test']], - 'options' => new Options( - partials: ['testpartial' => '{{bar}}-{{mixed}}'], - ), - 'expected' => 'test-OK!', + 'desc' => 'LNC #254', + 'template' => '{{#if a}}a{{else if b}}b{{else}}c{{/if}}{{#if a}}a{{else if b}}b{{/if}}', + 'data' => ['b' => 1], + 'expected' => 'bb', ], [ - 'template' => '{{[helper]}}', - 'options' => new Options( - helpers: [ - 'helper' => fn() => 'DEF', - ], - ), - 'data' => [], - 'expected' => 'DEF', + 'desc' => 'LNC #261', + 'template' => '{{#each foo as |bar|}}?{{bar.[0]}}{{/each}}', + 'data' => ['foo' => [['a'], ['b']]], + 'expected' => '?a?b', ], [ - 'template' => '{{#[helper3]}}ABC{{/[helper3]}}', - 'options' => new Options( - helpers: [ - 'helper3' => fn() => 'DEF', - ], - ), - 'data' => [], - 'expected' => 'DEF', + 'desc' => 'LNC #267', + 'template' => '{{#each . as |v k|}}#{{k}}>{{v}}|{{.}}{{/each}}', + 'data' => ['a' => 'b', 'c' => 'd'], + 'expected' => '#a>b|b#c>d|d', ], [ - 'template' => '{{hash abc=["def=123"]}}', - 'options' => new Options( - helpers: [ - 'hash' => function (HelperOptions $options) { - $ret = ''; - foreach ($options->hash as $k => $v) { - $ret .= "$k : $v,"; - } - return $ret; - }, - ], - ), - 'data' => ['"def=123"' => 'La!'], - 'expected' => 'abc : La!,', + 'desc' => 'LNC #313', + 'template' => <<<_tpl + {{#if conditionA}} + {{#if conditionA1}} + Do something then do more stuff conditionally + {{#if conditionA1.x}} + Do something here + {{else if conditionA1.y}} + Do something else here + {{/if}} + {{/if}} + {{else if conditionB}} + Do something else + {{else}} + Finally, do this last thing if all else fails + {{/if}} + _tpl, + 'expected' => " Finally, do this last thing if all else fails\n", ], [ - 'template' => '{{hash abc=[\'def=123\']}}', - 'options' => new Options( - helpers: [ - 'hash' => function (HelperOptions $options) { - $ret = ''; - foreach ($options->hash as $k => $v) { - $ret .= "$k : $v,"; - } - return $ret; - }, - ], - ), - 'data' => ["'def=123'" => 'La!'], - 'expected' => 'abc : La!,', + 'desc' => 'LNC #369', + 'template' => '{{#each paragraphs}}

    {{this}}

    {{else}}

    {{foo}}

    {{/each}}', + 'data' => ['foo' => 'bar'], + 'expected' => '

    bar

    ', ], [ - 'template' => 'ABC{{#block "YES!"}}DEF{{foo}}GHI{{else}}NO~{{/block}}JKL', - 'options' => new Options( - helpers: [ - 'block' => function ($name, HelperOptions $options) { - return "1-$name-2-" . $options->fn() . '-3'; - }, - ], - ), - 'data' => ['foo' => 'bar'], - 'expected' => 'ABC1-YES!-2-DEFbarGHI-3JKL', + 'desc' => '#3', + 'template' => 'ok{{{lookup . "missing"}}}', + 'expected' => 'ok', ], [ - 'template' => '-{{getroot}}=', - 'options' => new Options( - helpers: [ - 'getroot' => fn(HelperOptions $options) => $options->data['root'], - ], - ), - 'data' => 'ROOT!', - 'expected' => '-ROOT!=', + 'template' => '{{#each . as |v k|}}#{{k}}{{/each}}', + 'data' => ['a' => [], 'c' => []], + 'expected' => '#a#c', + ], + [ + 'template' => '{{#each . as |item|}}{{item.foo}}{{/each}}', + 'data' => [['foo' => 'bar'], ['foo' => 'baz']], + 'expected' => 'barbaz', ], [ @@ -1832,19 +1658,6 @@ public static function issueProvider(): array 'expected' => 'A-=b,a,0,0~%-=d,c,0,1~%-=f,e,0,2~%B', ], - [ - 'template' => 'ABC{{#block "YES!"}}TRUE{{else}}DEF{{foo}}GHI{{/block}}JKL', - 'options' => new Options( - helpers: [ - 'block' => function ($name, HelperOptions $options) { - return "1-$name-2-" . $options->inverse() . '-3'; - }, - ], - ), - 'data' => ['foo' => 'bar'], - 'expected' => 'ABC1-YES!-2-DEFbarGHI-3JKL', - ], - [ 'template' => '{{#each .}}{{..}}>{{/each}}', 'data' => ['a', 'b', 'c'], @@ -1852,445 +1665,372 @@ public static function issueProvider(): array ], [ - // ensure that partial names are correctly escaped - 'template' => '{{> "foo\button\'"}} {{> "bar\\\link"}}', - 'options' => new Options( - partials: [ - 'foo\button\'' => 'Button!', - 'bar\\\link' => 'Link!', - ], - ), - 'expected' => 'Button! Link!', + 'desc' => 'inverted each: non-empty array renders nothing, empty array renders body', + 'template' => '{{^each items}}EMPTY{{/each}}', + 'data' => ['items' => ['a', 'b']], + 'expected' => '', + ], + [ + 'template' => '{{^each items}}EMPTY{{/each}}', + 'data' => ['items' => []], + 'expected' => 'EMPTY', + ], + + [ + 'desc' => 'inverted if with else clause', + 'template' => '{{^if exists}}bad{{else}}OK{{/if}}', + 'data' => ['exists' => true], + 'expected' => 'OK', ], [ - // ensure that block parameters are correctly escaped + 'desc' => 'ensure that block parameters are correctly escaped', 'template' => "{{#each items as |[it\\'s] item|}}{{item}}{{/each}}", 'data' => ['items' => ['one', 'two']], 'expected' => '01', ], [ - 'template' => '{{#each .}}->{{>tests/test3}}{{/each}}', - 'data' => ['a', 'b', 'c'], - 'options' => new Options( - partials: ['tests/test3' => 'New context:{{.}}'], - ), - 'expected' => "->New context:a->New context:b->New context:c", + 'template' => '{{#with "{{"}}{{.}}{{/with}}', + 'expected' => '{{', + ], + [ + 'template' => '{{#with true}}{{.}}{{/with}}', + 'expected' => 'true', ], [ - 'template' => '{{#each .}}->{{>tests/test3 ../foo}}{{/each}}', - 'data' => ['a', 'foo' => ['d', 'e', 'f']], - 'options' => new Options( - partials: ['tests/test3' => 'New context:{{.}}'], - ), - 'expected' => "->New context:d,e,f->New context:d,e,f", + 'template' => '{{#if .}}YES{{else}}NO{{/if}}', + 'data' => true, + 'expected' => 'YES', ], [ - 'template' => '{{{"{{"}}}', - 'data' => ['{{' => ':D'], - 'expected' => ':D', + 'template' => '{{#with items}}OK!{{/with}}', + 'expected' => '', + ], + [ + 'template' => '{{log}}', + 'expected' => '', ], [ - 'template' => '{{{\'{{\'}}}', - 'data' => ['{{' => ':D'], - 'expected' => ':D', + 'template' => '{{#with people}}Yes , {{name}}{{else}}No, {{name}}{{/with}}', + 'data' => ['people' => ['name' => 'Peter'], 'name' => 'NoOne'], + 'expected' => 'Yes , Peter', ], [ - 'template' => '{{#with "{{"}}{{.}}{{/with}}', - 'expected' => '{{', + 'template' => '{{#with people}}Yes , {{name}}{{else}}No, {{name}}{{/with}}', + 'data' => ['name' => 'NoOne'], + 'expected' => 'No, NoOne', ], + [ - 'template' => '{{#with true}}{{.}}{{/with}}', - 'expected' => 'true', + 'template' => '{{#each foo}}{{@key}}: {{.}},{{/each}}', + 'data' => ['foo' => [1, 'a' => 'b', 5]], + 'expected' => '0: 1,a: b,1: 5,', ], [ - 'template' => '{{good_helper}}', - 'options' => new Options( - helpers: [ - 'good_helper' => fn() => 'OK!', - ], - ), - 'expected' => 'OK!', + 'template' => '{{#each foo}}{{@key}}: {{.}},{{/each}}', + 'data' => ['foo' => new TwoDimensionIterator(2, 3)], + 'expected' => '0x0: 0,1x0: 0,0x1: 0,1x1: 1,0x2: 0,1x2: 2,', ], [ - 'template' => '-{{.}}-', - 'data' => 'abc', - 'expected' => '-abc-', + 'template' => " {{#if foo}}\nYES\n{{else}}\nNO\n{{/if}}\n", + 'expected' => "NO\n", ], [ - 'template' => '-{{this}}-', - 'data' => 123, - 'expected' => '-123-', + 'template' => " {{#each foo}}\n{{@key}}: {{.}}\n{{/each}}\nDONE", + 'data' => ['foo' => ['a' => 'A', 'b' => 'BOY!']], + 'expected' => "a: A\nb: BOY!\nDONE", ], [ - 'template' => '{{#if .}}YES{{else}}NO{{/if}}', - 'data' => true, - 'expected' => 'YES', + 'template' => "\n{{#each foo~}}\n
  • {{.}}
  • \n{{~/each}}\n\nOK", + 'data' => ['foo' => ['ha', 'hu']], + 'expected' => "\n
  • ha
  • hu
  • \nOK", ], + ]; + } - // data can contain closures + /** + * @return list + */ + public static function syntaxProvider(): array + { + return [ [ - 'template' => '{{foo}}', - 'data' => ['foo' => fn() => 'OK'], - 'expected' => 'OK', + 'desc' => 'LNC #39', + 'template' => '{{{tt}}}', + 'data' => ['tt' => 'bla bla bla'], + 'expected' => 'bla bla bla', ], - // but callable strings or arrays should NOT be treated as functions + [ - 'template' => '{{foo}}', - 'data' => ['foo' => 'hrtime'], - 'expected' => 'hrtime', + 'desc' => 'LNC #46', + 'template' => '{{{this.id}}}, {{a.id}}', + 'data' => ['id' => 'bla bla bla', 'a' => ['id' => 'OK!']], + 'expected' => 'bla bla bla, OK!', ], + [ - 'template' => '{{#foo}}OK{{else}}bad{{/foo}}', - 'data' => ['foo' => 'is_string'], - 'expected' => 'OK', + 'desc' => 'LNC #66', + 'template' => '{{&foo}} , {{foo}}, {{{foo}}}', + 'data' => ['foo' => 'Test & " \' :)'], + 'expected' => 'Test & " \' :) , Test & " ' :), Test & " \' :)', ], [ - 'template' => '{{foo}}', - 'data' => ['foo' => 'OK'], - 'expected' => 'OK', + 'desc' => 'LNC #90', + 'template' => '{{#items}}{{#value}}{{.}}{{/value}}{{/items}}', + 'data' => ['items' => [['value' => '123']]], + 'expected' => '123', ], [ - 'template' => '{{foo}}', - 'expected' => '', + 'desc' => 'LNC #125', + 'template' => '{{[ abc]}}', + 'data' => [' abc' => 'YES!'], + 'expected' => 'YES!', ], + [ - 'template' => '{{foo.bar}}', - 'expected' => '', + 'desc' => 'LNC #128', + 'template' => 'foo: {{foo}} , parent foo: {{../foo}}', + 'data' => ['foo' => 'OK'], + 'expected' => 'foo: OK , parent foo: ', ], + [ - 'template' => '{{foo.bar}}', - 'data' => ['foo' => []], - 'expected' => '', + 'desc' => 'LNC #154', + 'template' => 'O{{! this is comment ! ... }}K!', + 'expected' => "OK!", ], + [ - 'template' => '{{#with items}}OK!{{/with}}', - 'expected' => '', + 'desc' => 'LNC #159', + 'template' => '{{#.}}true{{else}}false{{/.}}', + 'data' => new \ArrayObject(), + 'expected' => "false", ], + [ - 'template' => '{{log}}', - 'expected' => '', + 'desc' => 'non-empty list in a section with {{else}} must iterate, not show the else branch', + 'template' => '{{#items}}{{.}},{{else}}empty{{/items}}', + 'data' => ['items' => ['a', 'b', 'c']], + 'expected' => 'a,b,c,', ], [ - 'template' => '{{#*inline}}{{/inline}}', - 'expected' => '', + 'desc' => 'non-empty ArrayObject should also iterate', + 'template' => '{{#.}}{{@index}}:{{.}},{{else}}empty{{/.}}', + 'data' => new \ArrayObject(['x', 'y']), + 'expected' => '0:x,1:y,', ], [ - 'template' => '{{#myif foo}}YES{{else}}NO{{/myif}}', - 'options' => new Options( - helpers: ['myif' => $myIf], - ), - 'expected' => 'NO', + 'desc' => 'LNC #169', + 'template' => '{{{{a}}}}true{{else}}false{{{{/a}}}}', + 'data' => ['a' => true], + 'expected' => "true{{else}}false", ], [ - 'template' => '{{#myif foo}}YES{{else}}NO{{/myif}}', - 'data' => ['foo' => 1], - 'options' => new Options( - helpers: ['myif' => $myIf], - ), - 'expected' => 'YES', + 'desc' => 'LNC #175', + 'template' => 'a{{!-- {{each}} haha {{/each}} --}}b', + 'expected' => 'ab', ], [ - 'template' => '{{#mylogic 0 foo bar}}YES:{{.}}{{else}}NO:{{.}}{{/mylogic}}', - 'data' => ['foo' => 'FOO', 'bar' => 'BAR'], - 'options' => new Options( - helpers: ['mylogic' => $myLogic], - ), - 'expected' => 'NO:BAR', + 'desc' => 'LNC #177', + 'template' => '{{{{a}}}} {{{{b}}}} {{{{/b}}}} {{{{/a}}}}', + 'data' => ['a' => true], + 'expected' => ' {{{{b}}}} {{{{/b}}}} ', ], - [ - 'template' => '{{#mylogic true foo bar}}YES:{{.}}{{else}}NO:{{.}}{{/mylogic}}', - 'data' => ['foo' => 'FOO', 'bar' => 'BAR'], - 'options' => new Options( - helpers: ['mylogic' => $myLogic], - ), - 'expected' => 'YES:FOO', + 'desc' => 'LNC #177', + 'template' => '{{{{a}}}} {{{{b}}}} {{{{/b}}}} {{{{/a}}}}', + 'data' => ['a' => true], + 'helpers' => ['a' => fn(HelperOptions $options) => $options->fn()], + 'expected' => ' {{{{b}}}} {{{{/b}}}} ', + ], + [ + 'desc' => 'LNC #177', + 'template' => '{{{{a}}}} {{{{b}}}} {{{{/b}}}} {{{{/a}}}}', + 'expected' => '', ], [ - 'template' => '{{#mywith foo}}YA: {{name}}{{/mywith}}', - 'data' => ['name' => 'OK?', 'foo' => ['name' => 'OK!']], - 'options' => new Options( - helpers: ['mywith' => $myWith], - ), - 'expected' => 'YA: OK!', + 'desc' => 'LNC #216', + 'template' => '{{foo.length}}', + 'data' => ['foo' => []], + 'expected' => '0', + ], + [ + 'desc' => 'LNC #255', + 'template' => '{{foo.length}}', + 'data' => ['foo' => [1, 2]], + 'expected' => '2', ], [ - 'template' => '{{mydash \'abc\' "dev"}}', - 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], - 'options' => new Options( - helpers: ['mydash' => $myDash], - ), - 'expected' => 'abc-dev', + 'desc' => 'LNC #278', + 'template' => '{{#foo}}-{{#bar}}={{moo}}{{/bar}}{{/foo}}', + 'data' => [ + 'foo' => [ + ['bar' => 0, 'moo' => 'A'], + ['bar' => 1, 'moo' => 'B'], + ['bar' => false, 'moo' => 'C'], + ['bar' => true, 'moo' => 'D'], + ], + ], + 'expected' => '-=-=--=D', ], [ - 'template' => '{{mydash \'a b c\' "d e f"}}', - 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], - 'options' => new Options( - helpers: ['mydash' => $myDash], - ), - 'expected' => 'a b c-d e f', + 'desc' => 'LNC #289', + 'template' => "1\n2\n{{~foo~}}\n3", + 'data' => ['foo' => 'OK'], + 'expected' => "1\n2OK3", ], - [ - 'template' => '{{mydash "abc" (test_array 1)}}', - 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], - 'options' => new Options( - helpers: [ - 'mydash' => $myDash, - 'test_array' => function ($input) { - return is_array($input) ? 'IS_ARRAY' : 'NOT_ARRAY'; - }, - ], - ), - 'expected' => 'abc-NOT_ARRAY', + 'desc' => 'LNC #289', + 'template' => <<<'HBS' + 1 + 2 + {{#test}} + 3TEST + {{/test}} + 4 + HBS, + 'data' => ['test' => 1], + 'expected' => "1\n2\n3TEST\n4", ], - [ - 'template' => '{{mydash "abc" (myjoin a b)}}', - 'data' => ['a' => 'a', 'b' => 'b', 'c' => ['c' => 'c'], 'd' => 'd', 'e' => 'e'], - 'options' => new Options( - helpers: [ - 'mydash' => $myDash, - 'myjoin' => function ($a, $b) { - return "$a$b"; - }, - ], - ), - 'expected' => 'abc-ab', + 'desc' => 'LNC #289', + 'template' => "1\n2\n{{~#test}}\n3TEST\n{{/test}}\n4", + 'data' => ['test' => 1], + 'expected' => "1\n23TEST\n4", ], - [ - 'template' => '{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', - 'data' => ['my_var' => 0], - 'options' => new Options( - helpers: ['equals' => $equals], - ), - 'expected' => 'Equal to false', + 'desc' => 'LNC #289', + 'template' => "1\n2\n{{#>test}}\n3TEST\n{{/test}}\n4", + 'expected' => "1\n2\n3TEST\n4", ], [ - 'template' => '{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', - 'data' => ['my_var' => 1], - 'options' => new Options( - helpers: ['equals' => $equals], - ), - 'expected' => 'Not equal', + 'desc' => 'LNC #289', + 'template' => "1\n2\n\n{{#>test}}\n3TEST\n{{/test}}\n4", + 'expected' => "1\n2\n\n3TEST\n4", ], [ - 'template' => '{{#equals my_var false}}Equal to false{{else}}Not equal{{/equals}}', - 'data' => [], - 'options' => new Options( - helpers: ['equals' => $equals], - ), - 'expected' => 'Not equal', + 'desc' => 'LNC #289', + 'template' => "1\n2\n\n{{#>test~}}\n\n3TEST\n{{/test}}\n4", + 'expected' => "1\n2\n\n3TEST\n4", ], [ - 'template' => '{{#with people}}Yes , {{name}}{{else}}No, {{name}}{{/with}}', - 'data' => ['people' => ['name' => 'Peter'], 'name' => 'NoOne'], - 'expected' => 'Yes , Peter', + 'desc' => 'LNC #290', + 'template' => '{{foo}} }} OK', + 'data' => ['foo' => 'YES'], + 'expected' => 'YES }} OK', ], - [ - 'template' => '{{#with people}}Yes , {{name}}{{else}}No, {{name}}{{/with}}', - 'data' => ['name' => 'NoOne'], - 'expected' => 'No, NoOne', + 'desc' => 'LNC #290', + 'template' => '{{foo}}{{#with "}"}}{{.}}{{/with}}OK', + 'data' => ['foo' => 'YES'], + 'expected' => 'YES}OK', ], - [ - 'template' => << -
  • 1. {{helper1 name}}
  • -
  • 2. {{helper1 value}}
  • -
  • 3. {{helper2 name}}
  • -
  • 4. {{helper2 value}}
  • -
  • 9. {{link name}}
  • -
  • 10. {{link value}}
  • -
  • 11. {{alink url text}}
  • -
  • 12. {{{alink url text}}}
  • - - VAREND - , - 'data' => ['name' => 'John', 'value' => 10000, 'url' => 'http://yahoo.com', 'text' => 'You&Me!'], - 'options' => new Options( - helpers: [ - 'helper1' => function ($arg) { - $arg = is_array($arg) ? 'Array' : $arg; - return "-$arg-"; - }, - 'helper2' => function ($arg) { - return is_array($arg) ? '=Array=' : "=$arg="; - }, - 'link' => function ($arg) { - if (is_array($arg)) { - $arg = 'Array'; - } - return "
    click here"; - }, - 'alink' => function ($u, $t) { - $u = is_array($u) ? 'Array' : $u; - $t = is_array($t) ? 'Array' : $t; - return "$t"; - }, - ], - ), - 'expected' => << -
  • 1. -John-
  • -
  • 2. -10000-
  • -
  • 3. =John=
  • -
  • 4. =10000=
  • -
  • 9. <a href="John">click here</a>
  • -
  • 10. <a href="10000">click here</a>
  • -
  • 11. <a href="http://yahoo.com">You&Me!</a>
  • -
  • 12. You&Me!
  • - - VAREND, + 'desc' => 'LNC #290', + 'template' => '{ {{foo}}', + 'data' => ['foo' => 'YES'], + 'expected' => '{ YES', ], - [ - 'template' => '{{#each foo}}{{@key}}: {{.}},{{/each}}', - 'data' => ['foo' => [1, 'a' => 'b', 5]], - 'expected' => '0: 1,a: b,1: 5,', + 'desc' => 'LNC #290', + 'template' => '{{#with "{{"}}{{.}}{{/with}}{{foo}}{{#with "{{"}}{{.}}{{/with}}', + 'data' => ['foo' => 'YES'], + 'expected' => '{{YES{{', ], [ - 'template' => '{{#each foo}}{{@key}}: {{.}},{{/each}}', - 'data' => ['foo' => new TwoDimensionIterator(2, 3)], - 'expected' => '0x0: 0,1x0: 0,0x1: 0,1x1: 1,0x2: 0,1x2: 2,', + 'desc' => 'LNC #344', + 'template' => '{{{{raw}}}} {{bar}} {{{{/raw}}}} {{bar}}', + 'data' => [ + 'raw' => true, + 'bar' => 'content', + ], + 'expected' => ' {{bar}} content', ], [ - 'template' => " {{#if foo}}\nYES\n{{else}}\nNO\n{{/if}}\n", - 'expected' => "NO\n", + 'desc' => 'LNC #370', + 'template' => '{{@root.items.length}}', + 'data' => ['items' => [1, 2, 3]], + 'expected' => '3', ], [ - 'template' => " {{#each foo}}\n{{@key}}: {{.}}\n{{/each}}\nDONE", - 'data' => ['foo' => ['a' => 'A', 'b' => 'BOY!']], - 'expected' => "a: A\nb: BOY!\nDONE", + 'template' => '{{{"{{"}}}', + 'data' => ['{{' => ':D'], + 'expected' => ':D', ], - [ - 'template' => <<<_tpl -
    - {{> partialA}} - {{> partialB}} -
    - _tpl, - 'options' => new Options( - partials: [ - 'partialA' => "
    \n Partial A\n {{> partialB}}\n
    \n", - 'partialB' => "

    \n Partial B\n

    \n", - ], - ), - 'expected' => <<<_result -
    -
    - Partial A -

    - Partial B -

    -
    -

    - Partial B -

    -
    - _result, + 'template' => '{{{\'{{\'}}}', + 'data' => ['{{' => ':D'], + 'expected' => ':D', ], [ - 'template' => "{{>test1}}\n {{>test1}}\nDONE\n", - 'options' => new Options( - partials: ['test1' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n"], - ), - 'expected' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n 1:A\n 2:B\n 3:C\n 4:D\n 5:E\nDONE\n", + 'template' => '-{{.}}-', + 'data' => 'abc', + 'expected' => '-abc-', ], - [ - 'template' => "{{>test1}}\n {{>test1}}\nDONE\n", - 'options' => new Options( - preventIndent: true, - partials: ['test1' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n"], - ), - 'expected' => "1:A\n 2:B\n 3:C\n 4:D\n5:E\n 1:A\n 2:B\n 3:C\n 4:D\n5:E\nDONE\n", + 'template' => '-{{this}}-', + 'data' => 123, + 'expected' => '-123-', ], [ - 'template' => "{{foo}}\n {{bar}}\n", - 'data' => ['foo' => 'ha', 'bar' => 'hey'], - 'expected' => "ha\n hey\n", + 'desc' => 'data can contain closures', + 'template' => '{{foo}}', + 'data' => ['foo' => fn() => 'OK'], + 'expected' => 'OK', ], - [ - 'template' => "{{>test}}\n", - 'data' => ['foo' => 'ha', 'bar' => 'hey'], - 'options' => new Options( - partials: ['test' => "{{foo}}\n {{bar}}\n"], - ), - 'expected' => "ha\n hey\n", + 'desc' => 'callable strings or arrays should NOT be treated as functions', + 'template' => '{{foo}}', + 'data' => ['foo' => 'hrtime'], + 'expected' => 'hrtime', ], - [ - 'template' => " {{>test}}\n", - 'data' => ['foo' => 'ha', 'bar' => 'hey'], - 'options' => new Options( - preventIndent: true, - partials: ['test' => "{{foo}}\n {{bar}}\n"], - ), - 'expected' => " ha\n hey\n", + 'template' => '{{#foo}}OK{{else}}bad{{/foo}}', + 'data' => ['foo' => 'is_string'], + 'expected' => 'OK', ], [ - 'template' => "\n {{>test}}\n", - 'data' => ['foo' => 'ha', 'bar' => 'hey'], - 'options' => new Options( - preventIndent: true, - partials: ['test' => "{{foo}}\n {{bar}}\n"], - ), - 'expected' => "\n ha\n hey\n", + 'template' => '{{foo}}', + 'expected' => '', ], - [ - 'template' => "\n{{#each foo~}}\n
  • {{.}}
  • \n{{~/each}}\n\nOK", - 'data' => ['foo' => ['ha', 'hu']], - 'expected' => "\n
  • ha
  • hu
  • \nOK", + 'template' => '{{foo.bar}}', + 'expected' => '', ], - [ - 'template' => "ST:\n{{#foo}}\n {{>test1}}\n{{/foo}}\nOK\n", - 'data' => ['foo' => [1, 2]], - 'options' => new Options( - partials: ['test1' => "1:A\n 2:B({{@index}})\n"], - ), - 'expected' => "ST:\n 1:A\n 2:B(0)\n 1:A\n 2:B(1)\nOK\n", + 'template' => '{{foo.bar}}', + 'data' => ['foo' => []], + 'expected' => '', ], [ - 'template' => ">{{helper1 \"===\"}}<", - 'options' => new Options( - helpers: [ - 'helper1' => fn($arg) => is_array($arg) ? '-Array-' : "-$arg-", - ], - ), - 'expected' => ">-===-<", + 'template' => "{{foo}}\n {{bar}}\n", + 'data' => ['foo' => 'ha', 'bar' => 'hey'], + 'expected' => "ha\n hey\n", ], [ @@ -2313,29 +2053,39 @@ public static function issueProvider(): array ], [ - 'template' => '{{#>foo}}inline\'partial{{/foo}}', - 'expected' => 'inline\'partial', + 'desc' => 'knownHelpersOnly: array section values are correctly handled', + 'template' => '{{#items}}{{name}}{{/items}}', + 'options' => new Options(knownHelpersOnly: true), + 'data' => ['items' => ['name' => 'foo']], + 'expected' => 'foo', ], - [ - 'template' => '{{>foo}} and {{>bar}}', - 'options' => new Options( - partialResolver: function (string $name) { - return "PARTIAL: $name"; - }, - ), - 'expected' => 'PARTIAL: foo and PARTIAL: bar', + 'desc' => 'empty array renders else block', + 'template' => '{{#items}}YES{{else}}NO{{/items}}', + 'options' => new Options(knownHelpersOnly: true), + 'data' => ['items' => []], + 'expected' => 'NO', ], - [ - 'template' => "{{#> testPartial}}\n ERROR: testPartial is not found!\n {{#> innerPartial}}\n ERROR: innerPartial is not found!\n ERROR: innerPartial is not found!\n {{/innerPartial}}\n ERROR: testPartial is not found!\n {{/testPartial}}", - 'expected' => " ERROR: testPartial is not found!\n ERROR: innerPartial is not found!\n ERROR: innerPartial is not found!\n ERROR: testPartial is not found!\n", + 'desc' => 'non-empty array renders fn block even when else is present', + 'template' => '{{#items}}{{@index}}: {{.}}{{#if @last}}last!{{else}}, {{/if}}{{else}}NO{{/items}}', + 'options' => new Options(knownHelpersOnly: true), + 'data' => ['items' => ['a', 'b']], + 'expected' => '0: a, 1: blast!', + ], + [ + 'desc' => '../path resolves correctly when array context differs from enclosing context', + 'template' => '{{#items}}{{name}}/{{../name}}{{/items}}', + 'options' => new Options(knownHelpersOnly: true), + 'data' => ['name' => 'outer', 'items' => ['name' => 'inner']], + 'expected' => 'inner/outer', + ], + [ + 'desc' => '../path inside a true-valued section is empty (matches HBS.js: no depths push for true)', + 'template' => '{{#flag}}{{../name}}{{/flag}}', + 'data' => ['flag' => true, 'name' => 'outer'], + 'expected' => '', ], - ]; - - return array_map(function ($i) { - return [$i]; - }, $issues); } } From 23627e04ecc8d77679cf265dbacd297ebc4eba55 Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Sun, 15 Mar 2026 23:12:39 -0500 Subject: [PATCH 3/6] Optimize inline helpers which don't use options --- src/Runtime.php | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/Runtime.php b/src/Runtime.php index 2722008..6dffb89 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -467,15 +467,29 @@ public static function dynhbch(RuntimeContext $cx, string $name, array $position */ public static function hbch(RuntimeContext $cx, \Closure $helper, string $name, array $positional, array $hash, mixed &$_this): mixed { - $options = new HelperOptions( - scope: $_this, - data: $cx->frame, - name: $name, - hash: $hash, - ); - $args = $positional; - $args[] = $options; - return $helper(...$args); + /** @var \WeakMap<\Closure, int>|null $paramCounts */ + static $paramCounts = null; + $paramCounts ??= new \WeakMap(); + + $numParams = $paramCounts[$helper] ?? null; + if ($numParams === null) { + // Cache the number of parameters for the closure so HelperOptions doesn't have to be instantiated + // when it isn't used. This can boost runtime performance by 20% for complex templates. + $rf = new \ReflectionFunction($helper); + $params = $rf->getParameters(); + $numParams = $params && end($params)->isVariadic() ? 0 : $rf->getNumberOfParameters(); + $paramCounts[$helper] = $numParams; + } + if ($numParams === 0 || $numParams > count($positional)) { + $positional[] = new HelperOptions( + scope: $_this, + data: $cx->frame, + name: $name, + hash: $hash, + ); + } + + return $helper(...$positional); } /** From 2458a3349c50be9770df22eb0ae4be6a4d4721e3 Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Sun, 15 Mar 2026 23:43:40 -0500 Subject: [PATCH 4/6] Compile if/unless to native ternary when possible This can double runtime performance for complex templates with conditions in nested loops. --- src/Compiler.php | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/Compiler.php b/src/Compiler.php index 60fd852..57b17cf 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -285,6 +285,17 @@ private function compileBlockHelper(BlockStatement $block, string $name): string } // For inverted blocks the fn body comes from the inverse program; for normal blocks, the program. $fnProgram = $inverted ? $block->inverse : $block->program; + + // Inline if/unless as ternary — eliminates hbbch dispatch and HelperOptions allocation. + // Safe because if/unless don't change scope, so $cx and $in are already correct. + // Negate for 'unless' in a normal block, or 'if' in an inverted block (swapped semantics). + if ($this->canInlineConditional($block, $name, $fnProgram->blockParams)) { + $cond = $this->compileConditionalExpr($block->params[0], $name === ($inverted ? 'if' : 'unless')); + $body = $this->compileProgram($fnProgram); + $elseBody = $inverted ? "''" : $this->compileProgramOrEmpty($block->inverse); + return "($cond ? $body : $elseBody)"; + } + $blockFn = $this->compileProgramWithBlockParams($fnProgram); [$fn, $else] = $inverted ? ['null', $blockFn] @@ -299,6 +310,48 @@ private function compileBlockHelper(BlockStatement $block, string $name): string return self::getRuntimeFunc('hbbch', "\$cx, \$cx->helpers[$helperName], $helperName, $params, \$in, $fn, $else$trailingArgs"); } + /** + * Returns true when an if/unless block can be safely inlined as a ternary expression. + * Requires: no hash options (e.g. includeZero), no block params, exactly one condition param. + * @param string[] $bp + */ + private function canInlineConditional(BlockStatement $block, string $helperName, array $bp): bool + { + return $this->isKnownHelper($helperName) + && ($helperName === 'if' || $helperName === 'unless') + && count($block->params) === 1 + && $block->hash === null + && !$bp; + } + + /** + * Compile the condition expression for an inlined if/unless ternary. + * For simple single-segment paths, routes through cv() which already resolves closures, + * so ifvar() suffices. For all other expressions, closures at nested paths are not + * invoked (not a real-world or spec concern). + * @param bool $negate true for `unless` or inverted `{{^if}}` + */ + private function compileConditionalExpr(Expression $condExpr, bool $negate): string + { + $part = $condExpr instanceof PathExpression ? ($condExpr->parts[0] ?? null) : null; + if ($condExpr instanceof PathExpression + && !$condExpr->data + && $condExpr->depth === 0 + && is_string($part) + && !self::scopedId($condExpr) + && $this->lookupBlockParam($part) === null + ) { + $val = self::getRuntimeFunc('cv', '$in, ' . self::quote($part)); + } else { + $savedHelperArgs = $this->compilingHelperArgs; + $this->compilingHelperArgs = true; + $val = $this->compileExpression($condExpr); + $this->compilingHelperArgs = $savedHelperArgs; + } + $cond = self::getRuntimeFunc('ifvar', $val); + return $negate ? "!$cond" : $cond; + } + private function compileDynamicBlockHelper(BlockStatement $block, string $name, string $varPath = 'null'): string { $bp = $block->program->blockParams ?? []; From 7834a10e6cb9cddfc83329cc0999bd14159b3cbb Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Mon, 16 Mar 2026 19:10:18 -0500 Subject: [PATCH 5/6] Update to PHPStan 2.1.42 --- composer.json | 2 +- composer.lock | 58 +++++++++++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/composer.json b/composer.json index b2eb9a8..509d047 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.94", "jbboehr/handlebars-spec": "dev-master", - "phpstan/phpstan": "^2.1.40", + "phpstan/phpstan": "^2.1.42", "phpunit/phpunit": "^11.5" }, "autoload": { diff --git a/composer.lock b/composer.lock index 6665a1f..77736f8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9f7050f83541f2ec84f18a1920b5498d", + "content-hash": "bae8f70488ae219aa647854db7c16bde", "packages": [ { "name": "devtheorem/php-handlebars-parser", @@ -836,11 +836,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.42", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", "shasum": "" }, "require": { @@ -885,7 +885,7 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-03-17T14:58:32+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3063,16 +3063,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -3137,7 +3137,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -3157,7 +3157,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3389,16 +3389,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.0", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", - "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { @@ -3435,7 +3435,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -3455,20 +3455,20 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", "shasum": "" }, "require": { @@ -3503,7 +3503,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.6" }, "funding": [ { @@ -3523,7 +3523,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-01-29T09:40:50+00:00" }, { "name": "symfony/options-resolver", @@ -4395,16 +4395,16 @@ }, { "name": "symfony/string", - "version": "v7.4.4", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", - "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", + "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", "shasum": "" }, "require": { @@ -4462,7 +4462,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.4" + "source": "https://github.com/symfony/string/tree/v7.4.6" }, "funding": [ { @@ -4482,7 +4482,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T10:54:30+00:00" + "time": "2026-02-09T09:33:46+00:00" }, { "name": "theseer/tokenizer", From dc4ca3d25d071cdbd8735cb88d53bd759ae853ad Mon Sep 17 00:00:00 2001 From: Yurii Date: Fri, 20 Mar 2026 12:35:47 +0200 Subject: [PATCH 6/6] Add float type to the `raw` method. Added a test for the `raw` method. --- src/Runtime.php | 2 +- tests/RuntimeTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Runtime.php b/src/Runtime.php index 6dffb89..bb890b9 100644 --- a/src/Runtime.php +++ b/src/Runtime.php @@ -266,7 +266,7 @@ public static function encq($var): string * * @param array|string|StringObject|int|bool|null $v value to be output */ - public static function raw(array|string|StringObject|int|bool|null $v): string + public static function raw(array|string|StringObject|int|float|bool|null $v): string { if ($v === true) { return 'true'; diff --git a/tests/RuntimeTest.php b/tests/RuntimeTest.php index cf9bbe8..7321bac 100644 --- a/tests/RuntimeTest.php +++ b/tests/RuntimeTest.php @@ -34,6 +34,16 @@ public function testIsec(): void $this->assertFalse(Runtime::isec(['1'])); } + public function testRaw(): void + { + $this->assertEquals('1', Runtime::raw(1)); + $this->assertEquals('1.1', Runtime::raw(1.1)); + $this->assertEquals('true', Runtime::raw(true)); + $this->assertEquals('false', Runtime::raw(false)); + $this->assertEquals('0,1', Runtime::raw([0, 1])); + $this->assertEquals('', Runtime::raw(null)); + } + private static function createStringable(string $value): \Stringable { return new class ($value) implements \Stringable {