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/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", diff --git a/src/Compiler.php b/src/Compiler.php index 14262d6..57b17cf 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,200 @@ 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'; + $bp = $program->blockParams; + if ($bp) { + array_unshift($this->blockParamValues, $bp); + } + $body = $this->compileProgram($program); + if ($bp) { + array_shift($this->blockParamValues); + } + return self::blockClosure($body, (bool) $program->blockParams, $this->lastCompileProgramHadDirectBpRef); + } - $body = $this->compileProgramOrEmpty($block->program); - $else = $this->compileElseClause($block); + private function compileBlockHelper(BlockStatement $block, string $name): string + { + $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; - if ($this->resolveHelper('blockHelperMissing')) { - return "'." . self::getRuntimeFunc('hbbch', "\$cx, 'blockHelperMissing', [[$var],[]], \$in, false, function(\$cx, \$in) {return $body;}$else, $escapedName") . ".'"; + // 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)"; } - return "'." . self::getRuntimeFunc('sec', "\$cx, $var, [], \$in, false, function(\$cx, \$in) {return $body;}$else") . ".'"; - } + $blockFn = $this->compileProgramWithBlockParams($fnProgram); + [$fn, $else] = $inverted + ? ['null', $blockFn] + : [$blockFn, $this->compileElseClause($block)]; - private function compileInvertedSection(BlockStatement $block): string - { - $var = $this->compileExpression($block->path); - $body = $this->compileProgramOrEmpty($block->inverse); + $outerBp = $this->outerBlockParamsExpr(); + $params = $this->compileParams($block->params, $block->hash); + $helperName = self::quote($name); + $bpCount = count($fnProgram->blockParams); - return "'.(" . self::getRuntimeFunc('isec', $var) . " ? $body : '').'"; + $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 + /** + * 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 { - $bp = $block->program->blockParams ?? $block->inverse->blockParams ?? []; - $params = $this->compileParams($block->params, $block->hash, $bp); - $escapedName = $missingName === null ? 'null' : self::quote($missingName); + return $this->isKnownHelper($helperName) + && ($helperName === 'if' || $helperName === 'unless') + && count($block->params) === 1 + && $block->hash === null + && !$bp; + } - 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") . ".'"; + /** + * 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; + } - $body = $this->compileProgramWithBlockParams($block->program, $bp); + private function compileDynamicBlockHelper(BlockStatement $block, string $name, string $varPath = 'null'): string + { + $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 +387,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 +402,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 +420,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 +434,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 +466,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 +496,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) . ".'"; + if ($helperName !== null && ($this->isKnownHelper($helperName) || $mustache->params || $mustache->hash !== null)) { + $call = $this->buildInlineHelperCall($helperName, $mustache->params, $mustache->hash); + 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 ($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 +563,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 +601,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 +659,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 +710,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; @@ -769,8 +768,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; } @@ -789,8 +788,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 +805,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 +823,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 +841,7 @@ private function compilePartialParams(array $params, ?Hash $hash): string $named = $hash ? $this->Hash($hash) : ''; - return "[[$contextVar],[$named]]"; + return "$contextVar, [$named]"; } /** @@ -876,29 +873,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); - } - $body = $this->compileProgram($program); - if ($bp) { - array_shift($this->blockParamValues); + if (!$block->inverse) { + $this->lastCompileProgramHadDirectBpRef = false; + return 'null'; } - return $body; + return $this->compileProgramWithBlockParams($block->inverse); } /** @@ -906,11 +889,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 +920,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 +953,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 f9ba8b1..9b9fd4b 100644 --- a/src/Context.php +++ b/src/Context.php @@ -2,8 +2,6 @@ namespace DevTheorem\Handlebars; -use Closure; - /** * @internal */ @@ -12,12 +10,9 @@ final class Context /** * @param array $usedPartial * @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 */ public function __construct( public readonly Options $options, @@ -26,27 +21,20 @@ public function __construct( public int $usedDynPartial = 0, public int $usedPBlock = 0, 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; $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 317a0ea..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(Context, string):(string|null) $partialResolver + * @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..bb890b9 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 * @@ -97,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'; @@ -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 ($helperName !== null && isset($cx->helpers[$helperName])) { + return static::hbbch($cx, $cx->helpers[$helperName], $helperName, [], [], $in, $cb, $else); } - if ($each) { - return ($else !== null) ? $else($cx, $in) : ''; - } - - 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); + data: $cx->frame, + cx: $cx, + cb: $cb, + inv: $else, + )); + return static::resolveBlockResult($cx, $result, $in, $cb, $else); } - 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) : ''; - } - - $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,172 @@ 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; + /** @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, + ); } - $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, - ); - - return static::applyBlockHelperMissing($cx, static::exch($cx, $ch, $vars, $options), $_this, $cb, $else); + return $helper(...$positional); } /** - * 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 7871626..c5c4d5f 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; @@ -11,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 { @@ -77,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(); @@ -136,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 { @@ -150,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 @@ -170,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 ( @@ -794,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"}} @@ -933,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) @@ -955,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( @@ -1131,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: [ @@ -1144,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( @@ -1157,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( @@ -1184,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', ], [ @@ -1833,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'], @@ -1853,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", ], [ @@ -2314,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 (Context $context, 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); } } 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 {