From b390e509747c42e940008b042fb0d3137dbbf5df Mon Sep 17 00:00:00 2001 From: pharixces Date: Fri, 3 Oct 2025 23:17:55 +0200 Subject: Add support for shorttags in functions (#1142) * Add support for shorttags in functions Co-authored-by: Anne Zijlstra Co-authored-by: Simon Wisselink --- src/Compile/AttributeCompiler.php | 144 +++++++++++++++ src/Compile/Base.php | 84 +-------- src/Compile/FunctionCallCompiler.php | 30 ++-- src/FunctionHandler/AttributeBase.php | 77 ++++++++ .../AttributeFunctionHandlerInterface.php | 15 ++ tests/UnitTests/Compile/AttributeCompilerTest.php | 193 +++++++++++++++++++++ .../UnitTests/Compile/FunctionCallCompilerTest.php | 62 +++++++ 7 files changed, 514 insertions(+), 91 deletions(-) create mode 100644 src/Compile/AttributeCompiler.php create mode 100644 src/FunctionHandler/AttributeBase.php create mode 100644 src/FunctionHandler/AttributeFunctionHandlerInterface.php create mode 100644 tests/UnitTests/Compile/AttributeCompilerTest.php create mode 100644 tests/UnitTests/Compile/FunctionCallCompilerTest.php diff --git a/src/Compile/AttributeCompiler.php b/src/Compile/AttributeCompiler.php new file mode 100644 index 00000000..077c4cfc --- /dev/null +++ b/src/Compile/AttributeCompiler.php @@ -0,0 +1,144 @@ +required_attributes = $required_attributes; + $this->optional_attributes = $optional_attributes; + $this->shorttag_order = $shorttag_order; + $this->option_flags = $option_flags; + } + + /** + * This function checks if the attributes passed are valid + * The attributes passed for the tag to compile are checked against the list of required and + * optional attributes. Required attributes must be present. Optional attributes are check against + * the corresponding list. The keyword '_any' specifies that any attribute will be accepted + * as valid + * + * @param object $compiler compiler object + * @param array $attributes attributes applied to the tag + * + * @return array of mapped attributes for further processing + */ + public function getAttributes($compiler, $attributes) + { + $_indexed_attr = []; + $options = array_fill_keys($this->option_flags, true); + foreach ($attributes as $key => $mixed) { + // shorthand ? + if (!is_array($mixed)) { + // options flag ? + if (isset($options[trim($mixed, '\'"')])) { + $_indexed_attr[trim($mixed, '\'"')] = true; + // shorthand attribute ? + } elseif (isset($this->shorttag_order[$key])) { + $_indexed_attr[$this->shorttag_order[$key]] = $mixed; + } else { + // too many shorthands + $compiler->trigger_template_error('too many shorthand attributes', null, true); + } + // named attribute + } else { + foreach ($mixed as $k => $v) { + // options flag? + if (isset($options[$k])) { + if (is_bool($v)) { + $_indexed_attr[$k] = $v; + } else { + if (is_string($v)) { + $v = trim($v, '\'" '); + } + + // Mapping array for boolean option value + static $optionMap = [1 => true, 0 => false, 'true' => true, 'false' => false]; + + if (isset($optionMap[$v])) { + $_indexed_attr[$k] = $optionMap[$v]; + } else { + $compiler->trigger_template_error( + "illegal value '" . var_export($v, true) . + "' for options flag '{$k}'", + null, + true + ); + } + } + // must be named attribute + } else { + $_indexed_attr[$k] = $v; + } + } + } + } + // check if all required attributes present + foreach ($this->required_attributes as $attr) { + if (!isset($_indexed_attr[$attr])) { + $compiler->trigger_template_error("missing '{$attr}' attribute", null, true); + } + } + // check for not allowed attributes + if ($this->optional_attributes !== ['_any']) { + $allowedAttributes = array_fill_keys( + array_merge( + $this->required_attributes, + $this->optional_attributes, + $this->option_flags + ), + true + ); + foreach ($_indexed_attr as $key => $dummy) { + if (!isset($allowedAttributes[$key]) && $key !== 0) { + $compiler->trigger_template_error("unexpected '{$key}' attribute", null, true); + } + } + } + // default 'false' for all options flags not set + foreach ($this->option_flags as $flag) { + if (!isset($_indexed_attr[$flag])) { + $_indexed_attr[$flag] = false; + } + } + + return $_indexed_attr; + } +} diff --git a/src/Compile/Base.php b/src/Compile/Base.php index 2d5c0c0e..10153501 100644 --- a/src/Compile/Base.php +++ b/src/Compile/Base.php @@ -82,84 +82,12 @@ abstract class Base implements CompilerInterface { * @return array of mapped attributes for further processing */ protected function getAttributes($compiler, $attributes) { - $_indexed_attr = []; - $options = array_fill_keys($this->option_flags, true); - foreach ($attributes as $key => $mixed) { - // shorthand ? - if (!is_array($mixed)) { - // options flag ? - if (isset($options[trim($mixed, '\'"')])) { - $_indexed_attr[trim($mixed, '\'"')] = true; - // shorthand attribute ? - } elseif (isset($this->shorttag_order[$key])) { - $_indexed_attr[$this->shorttag_order[$key]] = $mixed; - } else { - // too many shorthands - $compiler->trigger_template_error('too many shorthand attributes', null, true); - } - // named attribute - } else { - foreach ($mixed as $k => $v) { - // options flag? - if (isset($options[$k])) { - if (is_bool($v)) { - $_indexed_attr[$k] = $v; - } else { - if (is_string($v)) { - $v = trim($v, '\'" '); - } - - // Mapping array for boolean option value - static $optionMap = [1 => true, 0 => false, 'true' => true, 'false' => false]; - - if (isset($optionMap[$v])) { - $_indexed_attr[$k] = $optionMap[$v]; - } else { - $compiler->trigger_template_error( - "illegal value '" . var_export($v, true) . - "' for options flag '{$k}'", - null, - true - ); - } - } - // must be named attribute - } else { - $_indexed_attr[$k] = $v; - } - } - } - } - // check if all required attributes present - foreach ($this->required_attributes as $attr) { - if (!isset($_indexed_attr[$attr])) { - $compiler->trigger_template_error("missing '{$attr}' attribute", null, true); - } - } - // check for not allowed attributes - if ($this->optional_attributes !== ['_any']) { - $allowedAttributes = array_fill_keys( - array_merge( - $this->required_attributes, - $this->optional_attributes, - $this->option_flags - ), - true - ); - foreach ($_indexed_attr as $key => $dummy) { - if (!isset($allowedAttributes[$key]) && $key !== 0) { - $compiler->trigger_template_error("unexpected '{$key}' attribute", null, true); - } - } - } - // default 'false' for all options flags not set - foreach ($this->option_flags as $flag) { - if (!isset($_indexed_attr[$flag])) { - $_indexed_attr[$flag] = false; - } - } - - return $_indexed_attr; + return (new AttributeCompiler( + $this->required_attributes, + $this->optional_attributes, + $this->shorttag_order, + $this->option_flags + ))->getAttributes($compiler, $attributes); } /** diff --git a/src/Compile/FunctionCallCompiler.php b/src/Compile/FunctionCallCompiler.php index 107dd98b..3ce50da2 100644 --- a/src/Compile/FunctionCallCompiler.php +++ b/src/Compile/FunctionCallCompiler.php @@ -3,24 +3,18 @@ * Smarty Internal Plugin Compile Registered Function * Compiles code for the execution of a registered function * - - * @author Uwe Tews */ namespace Smarty\Compile; use Smarty\Compiler\Template; -use Smarty\CompilerException; +use Smarty\FunctionHandler\AttributeFunctionHandlerInterface; /** * Smarty Internal Plugin Compile Registered Function Class - * - - */ class FunctionCallCompiler extends Base { - /** * Attribute definition: Overwrites base class. * @@ -51,16 +45,26 @@ class FunctionCallCompiler extends Base { */ public function compile($args, Template $compiler, $parameter = [], $tag = null, $function = null): string { + if ($functionHandler = $compiler->getSmarty()->getFunctionHandler($function)) { - // check and get attributes - $_attr = $this->getAttributes($compiler, $args); - unset($_attr['nocache']); + $attribute_overrides = []; - $_paramsArray = $this->formatParamsArray($_attr); - $_params = 'array(' . implode(',', $_paramsArray) . ')'; + if ($functionHandler instanceof AttributeFunctionHandlerInterface) { + $attribute_overrides = $functionHandler->getSupportedAttributes(); + } + // check and get attributes + $_attr = (new AttributeCompiler( + $attribute_overrides['required_attributes'] ?? $this->required_attributes, + $attribute_overrides['optional_attributes'] ?? $this->optional_attributes, + $attribute_overrides['shorttag_order'] ?? $this->shorttag_order, + $attribute_overrides['option_flags'] ?? $this->option_flags + ))->getAttributes($compiler, $args); - if ($functionHandler = $compiler->getSmarty()->getFunctionHandler($function)) { + unset($_attr['nocache']); + + $_paramsArray = $this->formatParamsArray($_attr); + $_params = 'array(' . implode(',', $_paramsArray) . ')'; // not cacheable? $compiler->tag_nocache = $compiler->tag_nocache || !$functionHandler->isCacheable(); diff --git a/src/FunctionHandler/AttributeBase.php b/src/FunctionHandler/AttributeBase.php new file mode 100644 index 00000000..71a035dc --- /dev/null +++ b/src/FunctionHandler/AttributeBase.php @@ -0,0 +1,77 @@ +cacheable; + } + + /** + * Function body + * @param mixed $params The supplied parameters. + * @param Smarty\Template $template + * @return mixed + */ + abstract public function handle($params, Template $template): ?string; + + /** + * Return the support attributes for this function. + * @return array + */ + public function getSupportedAttributes(): array + { + return [ + 'required_attributes' => $this->required_attributes, + 'optional_attributes' => $this->optional_attributes, + 'shorttag_order' => $this->shorttag_order, + 'option_flags' => $this->option_flags, + ]; + } +} diff --git a/src/FunctionHandler/AttributeFunctionHandlerInterface.php b/src/FunctionHandler/AttributeFunctionHandlerInterface.php new file mode 100644 index 00000000..f76259ec --- /dev/null +++ b/src/FunctionHandler/AttributeFunctionHandlerInterface.php @@ -0,0 +1,15 @@ + + */ + public function getSupportedAttributes(): array; +} diff --git a/tests/UnitTests/Compile/AttributeCompilerTest.php b/tests/UnitTests/Compile/AttributeCompilerTest.php new file mode 100644 index 00000000..9a8fbf98 --- /dev/null +++ b/tests/UnitTests/Compile/AttributeCompilerTest.php @@ -0,0 +1,193 @@ +template_compiler = $this->createMock(Template::class); + + // reset attributes to empty arrays + $this->attributes = [ + 'required_attributes' => [], + 'optional_attributes' => [], + 'shorttag_order' => [], + 'option_flags' => [], + ]; + } + + /** + * Create the attribute compiler for testing. + */ + private function createAttributeCompiler() { + return new AttributeCompiler( + $this->attributes['required_attributes'], + $this->attributes['optional_attributes'], + $this->attributes['shorttag_order'], + $this->attributes['option_flags'] + ); + } + + /** + * Tests shorthand attribute compiling. + */ + public function testAttributeCompiler(): void + { + $this->attributes['shorttag_order'] = ['shorttag']; + $this->attributes['required_attributes'] = ['required']; + $this->attributes['option_flags'] = ['option', 'option_two']; + + $payload = [ + 0 => 'shorttag value', + 1 => [ + 'required' => 'required_value' + ], + 2 => 'option', + ]; + + $this->assertEquals( + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, $payload), + [ + 'shorttag' => 'shorttag value', + 'required' => 'required_value', + 'option' => true, + 'option_two' => false, + ] + ); + } + + /** + * Tests normal optional attribute compiling. + */ + public function testAttributeCompilerOptionalArguments(): void + { + $this->attributes['optional_attributes'] = ['optional']; + + $payload = [ + 0 => [ + 'optional' => 'optional value' + ], + ]; + + $this->assertEquals( + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, $payload), + [ + 'optional' => 'optional value', + ] + ); + + $this->assertEquals( + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, []), + [] + ); + } + + /** + * Tests any attribute compiling. + */ + public function testAttributeCompilerAnyOptionalArguments(): void + { + $this->attributes['optional_attributes'] = ['_any']; + + $payload = [ + 0 => [ + 'optional' => 'optional value' + ], + 1 => [ + 'optional_two' => 'optional value two' + ], + ]; + + $this->assertEquals( + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, $payload), + [ + 'optional' => 'optional value', + 'optional_two' => 'optional value two', + ] + ); + } + + /** + * Test if the attribute compiler tries to throw a too many shorthand attributes error. + */ + public function testAttributeCompilerTooManyShorthands(): void + { + $payload = [ + 0 => 'option one', + ]; + + $this->template_compiler + ->expects(self::once()) + ->method('trigger_template_error') + ->with('too many shorthand attributes', null, true); + + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, $payload); + } + + /** + * Test if the attribute compiler tries to throw a missing required attribute error. + */ + public function testAttributeCompilerWithMissingRequiredAttributes(): void + { + $this->attributes['required_attributes'] = ['required']; + + $this->template_compiler + ->expects(self::once()) + ->method('trigger_template_error') + ->with('missing \'required\' attribute', null, true); + + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, []); + } + + /** + * Test if the attribute compiler tries to throw a illegal value template error. + */ + public function testAttributeCompilerWithInvalidOptionAttribute(): void + { + $this->attributes['option_flags'] = ['option']; + + $this->template_compiler + ->expects(self::once()) + ->method('trigger_template_error') + ->with('illegal value \'\'foo\'\' for options flag \'option\'', null, true); + + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, [0 => ['option' => 'foo']]); + } + + /** + * Test if the attribute compiler tries to throw an unexpected attribute error. + */ + public function testAttributeCompilerWithInvalidUnexpectedAttribute(): void + { + $this->template_compiler + ->expects(self::once()) + ->method('trigger_template_error') + ->with('unexpected \'unexpected\' attribute', null, true); + + $this->createAttributeCompiler() + ->getAttributes($this->template_compiler, [0 => ['unexpected' => 'bar']]); + } +} diff --git a/tests/UnitTests/Compile/FunctionCallCompilerTest.php b/tests/UnitTests/Compile/FunctionCallCompilerTest.php new file mode 100644 index 00000000..2eb0e5a1 --- /dev/null +++ b/tests/UnitTests/Compile/FunctionCallCompilerTest.php @@ -0,0 +1,62 @@ +smarty = $this->createMock(Smarty::class); + $this->template_compiler = $this->createMock(Template::class); + $this->template_compiler + ->expects(self::once()) + ->method('getSmarty') + ->willReturn($this->smarty); + } + + public function testAttributeFunctionHandlerInterface(): void + { + $attribute_function_handler = $this->createMock(AttributeFunctionHandlerInterface::class); + + $attribute_function_handler + ->expects(self::once()) + ->method('getSupportedAttributes') + ->willReturn([ + 'required_attributes' => ['required'], + 'optional_attributes' => ['optional'], + 'shorttag_order' => ['short'], + 'option_flags' => ['option'], + ]); + + $args = [ + 0 => 'short', + 1 => 'option', + 2 => [ + 'optional' => 'optional', + ], + 3 => [ + 'required' => 'required', + ], + ]; + + $this->smarty + ->expects(self::once()) + ->method('getFunctionHandler') + ->with('method') + ->willReturn($attribute_function_handler); + + $function_call_compiler = new FunctionCallCompiler(); + + $this->assertEquals( + $function_call_compiler->compile($args, $this->template_compiler, [], null, 'method'), + '$_smarty_tpl->getSmarty()->getFunctionHandler(\'method\')->handle(array(\'short\'=>short,\'option\'=>1,\'optional\'=>optional,\'required\'=>required), $_smarty_tpl)' + ); + } +} -- cgit v1.3