From c5de48e387f05efad8187f7fbb8ca11be9c4f0db Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 22 Jun 2025 13:14:00 +0200 Subject: [PATCH 01/12] Refactor: properly handle callable array even when options are missing Improved handling for cases where `$callable` is an array but does not include the `$callable['options']` key. In such cases, the default options are preserved and no errors occur. Also streamlined the normalization logic by explicitly checking for valid entries (`!empty()`) --- modules/system/classes/MarkupManager.php | 27 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index 63c580aac1..cf3d12f89c 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -228,17 +228,26 @@ public function makeTwigFunctions($functions = []) foreach ($this->listFunctions() as $name => $callable) { $options = []; - if (is_array($callable) && isset($callable['options'])) { - $options = $callable['options']; - $callable = $callable['callable'] ?? $callable[0]; - - if (isset($options['is_safe']) && !is_array($options['is_safe'])) { - if (is_string($options['is_safe'])) { - $options['is_safe'] = [$options['is_safe']]; - } else { - $options['is_safe'] = []; + if (is_array($callable)) { + // Handle options + if (isset($callable['options'])) { + $options = $callable['options']; + + if (isset($options['is_safe']) && !is_array($options['is_safe'])) { + if (is_string($options['is_safe'])) { + $options['is_safe'] = [$options['is_safe']]; + } else { + $options['is_safe'] = []; + } } } + + // Normalize callable + if (!empty($callable['callable']) && is_callable($callable['callable'])) { + $callable = $callable['callable']; + } elseif (!empty($callable[0]) && is_callable($callable[0])) { + $callable = $callable[0]; + } } $options = array_merge($defaultOptions, $options); From e507757c0db1de68312fcc86042ad18d6c09001b Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:41:36 +0200 Subject: [PATCH 02/12] Add tests to MarkupManagerTest Expanded MarkupManagerTest to cover registration and validation of Twig functions, including handling of options, default options, empty callables, and invalid callables. --- .../tests/classes/MarkupManagerTest.php | 119 +++++++++++++++--- 1 file changed, 105 insertions(+), 14 deletions(-) diff --git a/modules/system/tests/classes/MarkupManagerTest.php b/modules/system/tests/classes/MarkupManagerTest.php index d5453d193d..8f7ab620db 100644 --- a/modules/system/tests/classes/MarkupManagerTest.php +++ b/modules/system/tests/classes/MarkupManagerTest.php @@ -2,17 +2,66 @@ namespace System\Tests\Classes; -use System\Tests\Bootstrap\TestCase; use System\Classes\MarkupManager; +use System\Tests\Bootstrap\TestCase; +use Twig\TwigFunction; +use Winter\Storm\Exception\SystemException; class MarkupManagerTest extends TestCase { + protected $manager; + protected $testFunctions; + protected $testFunctionsWithOptions; + + private const OPTIONS = ['is_safe_callback' => ['html']]; + private const EMPTY_CALLABLE_MESSAGE = 'The markup function ([]) for emptyFunction is not callable.'; + private const INVALID_CALLABLE_MESSAGE = 'The markup function ({"callable":"not_a_function"}) for invalidFunction is not callable.'; public function setUp() : void { parent::setUp(); include_once base_path() . '/modules/system/tests/fixtures/plugins/winter/tester/Plugin.php'; + $this->manager = MarkupManager::instance(); + + $this->testFunctions = $this->makeTestFunctionData(withOptions: false); + $this->testFunctionsWithOptions = $this->makeTestFunctionData(withOptions: true); + } + + private function makeTestFunctionData(bool $withOptions = false): array + { + $testFunction = function () { + return 'testFunction return value'; + }; + $options = $withOptions ? self::OPTIONS : []; + + return [ + 'testFunction1' => [ + 'callable' => $testFunction, + 'options' => $options + ], + 'testFunction2' => [ + $testFunction, + 'options' => $options + ], + ]; + } + + private function registerAndGetTwigFunctions(array $functions) + { + $this->manager->registerFunctions($functions); + return self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + } + + private function assertAllTwigFunctions(array $functions, string $message = '') + { + $this->assertIsArray($functions); + $this->assertNotEmpty($functions); + $this->assertContainsOnlyInstancesOf( + TwigFunction::class, + $functions, + $message ?: "All elements must be TwigFunction instances" + ); } // @@ -21,66 +70,108 @@ public function setUp() : void public function testIsWildCallable() { - $manager = MarkupManager::instance(); - /* * Negatives */ $callable = 'something'; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertFalse($result); $callable = ['Form', 'open']; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertFalse($result); $callable = function () { return 'O, Hai!'; }; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertFalse($result); /* * String */ $callable = 'something_*'; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertTrue($result); - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable, 'delicious']); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable, 'delicious']); $this->assertEquals('something_delicious', $result); /* * Array */ $callable = ['Class', 'foo_*']; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertTrue($result); - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable, 'bar']); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable, 'bar']); $this->assertArrayHasKey(0, $result); $this->assertArrayHasKey(1, $result); $this->assertEquals('Class', $result[0]); $this->assertEquals('foo_bar', $result[1]); $callable = ['My*', 'method']; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertTrue($result); - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable, 'Class']); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable, 'Class']); $this->assertArrayHasKey(0, $result); $this->assertArrayHasKey(1, $result); $this->assertEquals('MyClass', $result[0]); $this->assertEquals('method', $result[1]); $callable = ['My*', 'my*']; - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable]); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertTrue($result); - $result = self::callProtectedMethod($manager, 'isWildCallable', [$callable, 'Food']); + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable, 'Food']); $this->assertArrayHasKey(0, $result); $this->assertArrayHasKey(1, $result); $this->assertEquals('MyFood', $result[0]); $this->assertEquals('myFood', $result[1]); } + + public function testMakeTwigFunctionsHandlesCallableArrayWithOptions() + { + $functions = $this->registerAndGetTwigFunctions($this->testFunctionsWithOptions); + $this->assertAllTwigFunctions($functions); + } + + public function testMakeTwigFunctionsAppliesDefaultOptions() + { + $functions = $this->registerAndGetTwigFunctions($this->testFunctions); + $this->assertAllTwigFunctions($functions); + + foreach ($functions as $function) { + $options = self::getProtectedProperty($function, 'options'); + $this->assertArrayHasKey('is_safe', $options); + $this->assertEquals(['html'], $options['is_safe']); + } + } + + public function testMakeTwigFunctionsHandlesEmptyCallableArray() + { + $this->expectException(SystemException::class); + $this->expectExceptionMessage(self::EMPTY_CALLABLE_MESSAGE); + + $this->manager->registerFunctions([ + 'emptyFunction' => [] + ]); + + self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + } + + public function testMakeTwigFunctionsHandlesInvalidCallable() + { + $this->expectException(SystemException::class); + $this->expectExceptionMessage(self::INVALID_CALLABLE_MESSAGE); + + $this->manager->registerFunctions([ + 'invalidFunction' => [ + 'callable' => 'not_a_function' + ] + ]); + + self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + } } From 38fce62e082215fda1482f841cd5b85669ca9c16 Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:31:05 +0200 Subject: [PATCH 03/12] Refactor: makeTwigFilters() and tests --- modules/system/classes/MarkupManager.php | 29 +++-- .../tests/classes/MarkupManagerTest.php | 104 ++++++++++++++---- 2 files changed, 103 insertions(+), 30 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index cf3d12f89c..2745fed97b 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -286,22 +286,31 @@ public function makeTwigFilters($filters = []) foreach ($this->listFilters() as $name => $callable) { $options = []; - if (is_array($callable) && isset($callable['options'])) { - $options = $callable['options']; - $callable = $callable['callable'] ?? $callable[0]; - - if (isset($options['is_safe']) && !is_array($options['is_safe'])) { - if (is_string($options['is_safe'])) { - $options['is_safe'] = [$options['is_safe']]; - } else { - $options['is_safe'] = []; + if (is_array($callable)) { + // Handle options + if (isset($callable['options'])) { + $options = $callable['options']; + + if (isset($options['is_safe']) && !is_array($options['is_safe'])) { + if (is_string($options['is_safe'])) { + $options['is_safe'] = [$options['is_safe']]; + } else { + $options['is_safe'] = []; + } } } + + // Normalize callable + if (!empty($callable['callable']) && is_callable($callable['callable'])) { + $callable = $callable['callable']; + } elseif (!empty($callable[0]) && is_callable($callable[0])) { + $callable = $callable[0]; + } } $options = array_merge($defaultOptions, $options); /* - * Handle a wildcard function + * Handle a wildcard filter */ if (strpos($name, '*') !== false && $this->isWildCallable($callable)) { $callable = function ($name) use ($callable) { diff --git a/modules/system/tests/classes/MarkupManagerTest.php b/modules/system/tests/classes/MarkupManagerTest.php index 8f7ab620db..150cfd4d62 100644 --- a/modules/system/tests/classes/MarkupManagerTest.php +++ b/modules/system/tests/classes/MarkupManagerTest.php @@ -4,18 +4,21 @@ use System\Classes\MarkupManager; use System\Tests\Bootstrap\TestCase; +use Twig\TwigFilter; use Twig\TwigFunction; use Winter\Storm\Exception\SystemException; class MarkupManagerTest extends TestCase { protected $manager; - protected $testFunctions; - protected $testFunctionsWithOptions; + protected $testCallableData; + protected $testCallableDataWithOptions; private const OPTIONS = ['is_safe_callback' => ['html']]; - private const EMPTY_CALLABLE_MESSAGE = 'The markup function ([]) for emptyFunction is not callable.'; - private const INVALID_CALLABLE_MESSAGE = 'The markup function ({"callable":"not_a_function"}) for invalidFunction is not callable.'; + private const EMPTY_FUNCTION_MESSAGE = 'The markup function ([]) for emptyCallable is not callable.'; + private const INVALID_FUNCTION_MESSAGE = 'The markup function ({"callable":"not_a_callable"}) for invalidCallable is not callable.'; + private const EMPTY_FILTER_MESSAGE = 'The markup filter ([]) for emptyCallable is not callable.'; + private const INVALID_FILTER_MESSAGE = 'The markup filter ({"callable":"not_a_callable"}) for invalidCallable is not callable.'; public function setUp() : void { @@ -24,24 +27,24 @@ public function setUp() : void include_once base_path() . '/modules/system/tests/fixtures/plugins/winter/tester/Plugin.php'; $this->manager = MarkupManager::instance(); - $this->testFunctions = $this->makeTestFunctionData(withOptions: false); - $this->testFunctionsWithOptions = $this->makeTestFunctionData(withOptions: true); + $this->testCallableData = $this->makeTestCallableData(withOptions: false); + $this->testCallableDataWithOptions = $this->makeTestCallableData(withOptions: true); } - private function makeTestFunctionData(bool $withOptions = false): array + private function makeTestCallableData(bool $withOptions = false): array { - $testFunction = function () { - return 'testFunction return value'; + $testCallable = function () { + return 'testCallable return value'; }; $options = $withOptions ? self::OPTIONS : []; return [ - 'testFunction1' => [ - 'callable' => $testFunction, + 'testCallable1' => [ + 'callable' => $testCallable, 'options' => $options ], - 'testFunction2' => [ - $testFunction, + 'testCallable2' => [ + $testCallable, 'options' => $options ], ]; @@ -53,6 +56,12 @@ private function registerAndGetTwigFunctions(array $functions) return self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); } + private function registerAndGetTwigFilters(array $filters) + { + $this->manager->registerFilters($filters); + return self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + } + private function assertAllTwigFunctions(array $functions, string $message = '') { $this->assertIsArray($functions); @@ -64,6 +73,17 @@ private function assertAllTwigFunctions(array $functions, string $message = '') ); } + private function assertAllTwigFilters(array $filters, string $message = '') + { + $this->assertIsArray($filters); + $this->assertNotEmpty($filters); + $this->assertContainsOnlyInstancesOf( + TwigFilter::class, + $filters, + $message ?: "All elements must be TwigFunction instances" + ); + } + // // Tests // @@ -133,13 +153,19 @@ public function testIsWildCallable() public function testMakeTwigFunctionsHandlesCallableArrayWithOptions() { - $functions = $this->registerAndGetTwigFunctions($this->testFunctionsWithOptions); + $functions = $this->registerAndGetTwigFunctions($this->testCallableDataWithOptions); $this->assertAllTwigFunctions($functions); } + public function testMakeTwigFiltersHandlesCallableArrayWithOptions() + { + $filters = $this->registerAndGetTwigFilters($this->testCallableDataWithOptions); + $this->assertAllTwigFilters($filters); + } + public function testMakeTwigFunctionsAppliesDefaultOptions() { - $functions = $this->registerAndGetTwigFunctions($this->testFunctions); + $functions = $this->registerAndGetTwigFunctions($this->testCallableData); $this->assertAllTwigFunctions($functions); foreach ($functions as $function) { @@ -149,29 +175,67 @@ public function testMakeTwigFunctionsAppliesDefaultOptions() } } + public function testMakeTwigFiltersAppliesDefaultOptions() + { + $filters = $this->registerAndGetTwigFilters($this->testCallableData); + $this->assertAllTwigFilters($filters); + + foreach ($filters as $filter) { + $options = self::getProtectedProperty($filter, 'options'); + $this->assertArrayHasKey('is_safe', $options); + $this->assertEquals(['html'], $options['is_safe']); + } + } + public function testMakeTwigFunctionsHandlesEmptyCallableArray() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::EMPTY_CALLABLE_MESSAGE); + $this->expectExceptionMessage(self::EMPTY_FUNCTION_MESSAGE); $this->manager->registerFunctions([ - 'emptyFunction' => [] + 'emptyCallable' => [] ]); self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); } + public function testMakeTwigFiltersHandlesEmptyCallableArray() + { + $this->expectException(SystemException::class); + $this->expectExceptionMessage(self::EMPTY_FILTER_MESSAGE); + + $this->manager->registerFilters([ + 'emptyCallable' => [] + ]); + + self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + } + public function testMakeTwigFunctionsHandlesInvalidCallable() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::INVALID_CALLABLE_MESSAGE); + $this->expectExceptionMessage(self::INVALID_FUNCTION_MESSAGE); $this->manager->registerFunctions([ - 'invalidFunction' => [ - 'callable' => 'not_a_function' + 'invalidCallable' => [ + 'callable' => 'not_a_callable' ] ]); self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); } + + public function testMakeTwigFiltersHandlesInvalidCallable() + { + $this->expectException(SystemException::class); + $this->expectExceptionMessage(self::INVALID_FILTER_MESSAGE); + + $this->manager->registerFilters([ + 'invalidCallable' => [ + 'callable' => 'not_a_callable' + ] + ]); + + self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + } } From f2b004767b99e7a4c9fa7621e82e2c60fde2d8ac Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:54:31 +0200 Subject: [PATCH 04/12] introduce helpers --- modules/system/classes/MarkupManager.php | 109 ++++++++++------------- 1 file changed, 49 insertions(+), 60 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index 2745fed97b..40bb69336b 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -34,7 +34,7 @@ class MarkupManager /** * @var array Globally registered extension items */ - protected $items; + protected $items = []; /** * @var \System\Classes\PluginManager @@ -122,10 +122,6 @@ public function registerCallback(callable $callback): void */ public function registerExtensions(string $type, array $definitions): void { - if ($this->items === null) { - $this->items = []; - } - if (!array_key_exists($type, $this->items)) { $this->items[$type] = []; } @@ -174,17 +170,11 @@ public function registerTokenParsers(array $definitions): void */ public function listExtensions($type) { - $results = []; - - if ($this->items === null) { + if ($this->items === []) { $this->loadExtensions(); } - if (isset($this->items[$type]) && is_array($this->items[$type])) { - $results = $this->items[$type]; - } - - return $results; + return is_array($this->items[$type] ?? null) ? $this->items[$type] : []; } /** @@ -216,7 +206,7 @@ public function listTokenParsers() /** * Makes a set of Twig functions for use in a twig extension. - * @param array $functions Current collection + * @param array $functions Current collection * @return array */ public function makeTwigFunctions($functions = []) @@ -228,28 +218,12 @@ public function makeTwigFunctions($functions = []) foreach ($this->listFunctions() as $name => $callable) { $options = []; - if (is_array($callable)) { - // Handle options - if (isset($callable['options'])) { - $options = $callable['options']; - - if (isset($options['is_safe']) && !is_array($options['is_safe'])) { - if (is_string($options['is_safe'])) { - $options['is_safe'] = [$options['is_safe']]; - } else { - $options['is_safe'] = []; - } - } - } - - // Normalize callable - if (!empty($callable['callable']) && is_callable($callable['callable'])) { - $callable = $callable['callable']; - } elseif (!empty($callable[0]) && is_callable($callable[0])) { - $callable = $callable[0]; - } + if (is_array($callable) && isset($callable['options'])) { + $options = $this->normalizeOptions($callable['options'], $defaultOptions); + } else { + $options = $defaultOptions; } - $options = array_merge($defaultOptions, $options); + $callable = $this->normalizeCallable($callable); /* * Handle a wildcard function @@ -274,7 +248,7 @@ public function makeTwigFunctions($functions = []) /** * Makes a set of Twig filters for use in a twig extension. - * @param array $filters Current collection + * @param array $filters Current collection * @return array */ public function makeTwigFilters($filters = []) @@ -286,28 +260,12 @@ public function makeTwigFilters($filters = []) foreach ($this->listFilters() as $name => $callable) { $options = []; - if (is_array($callable)) { - // Handle options - if (isset($callable['options'])) { - $options = $callable['options']; - - if (isset($options['is_safe']) && !is_array($options['is_safe'])) { - if (is_string($options['is_safe'])) { - $options['is_safe'] = [$options['is_safe']]; - } else { - $options['is_safe'] = []; - } - } - } - - // Normalize callable - if (!empty($callable['callable']) && is_callable($callable['callable'])) { - $callable = $callable['callable']; - } elseif (!empty($callable[0]) && is_callable($callable[0])) { - $callable = $callable[0]; - } + if (is_array($callable) && isset($callable['options'])) { + $options = $this->normalizeOptions($callable['options'], $defaultOptions); + } else { + $options = $defaultOptions; } - $options = array_merge($defaultOptions, $options); + $callable = $this->normalizeCallable($callable); /* * Handle a wildcard filter @@ -332,7 +290,7 @@ public function makeTwigFilters($filters = []) /** * Makes a set of Twig token parsers for use in a twig extension. - * @param array $parsers Current collection + * @param array $parsers Current collection * @return array */ public function makeTwigTokenParsers($parsers = []) @@ -356,8 +314,8 @@ public function makeTwigTokenParsers($parsers = []) /** * Tests if a callable type contains a wildcard, also acts as a * utility to replace the wildcard with a string. - * @param callable $callable - * @param string|bool $replaceWith + * @param callable $callable + * @param string|bool $replaceWith * @return mixed */ protected function isWildCallable($callable, $replaceWith = false) @@ -392,4 +350,35 @@ protected function isWildCallable($callable, $replaceWith = false) return $isWild; } + + /** + * Normalize a callable definition. + * @param mixed $callable + * @return callable + */ + private function normalizeCallable($callable) + { + if (is_array($callable)) { + if (!empty($callable['callable']) && is_callable($callable['callable'])) { + return $callable['callable']; + } elseif (!empty($callable[0]) && is_callable($callable[0])) { + return $callable[0]; + } + } + return $callable; + } + + /** + * Normalize options for Twig extension definitions. + * @param array $options + * @param array $defaultOptions + * @return array + */ + private function normalizeOptions($options, $defaultOptions) + { + if (isset($options['is_safe']) && !is_array($options['is_safe'])) { + $options['is_safe'] = is_string($options['is_safe']) ? [$options['is_safe']] : []; + } + return array_merge($defaultOptions, $options); + } } From c112769ee3a5a92fe61dce88d5f44dbb4fff1600 Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sat, 12 Jul 2025 23:12:24 +0200 Subject: [PATCH 05/12] type hints and return types --- modules/system/classes/MarkupManager.php | 33 ++++++++++-------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index 40bb69336b..4e55826646 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -165,10 +165,10 @@ public function registerTokenParsers(array $definitions): void /** * Returns a list of the registered Twig extensions of a type. - * @param $type string The Twig extension type + * @param string $type The Twig extension type * @return array */ - public function listExtensions($type) + public function listExtensions(string $type): array { if ($this->items === []) { $this->loadExtensions(); @@ -181,7 +181,7 @@ public function listExtensions($type) * Returns a list of the registered Twig filters. * @return array */ - public function listFilters() + public function listFilters(): array { return $this->listExtensions(self::EXTENSION_FILTER); } @@ -190,7 +190,7 @@ public function listFilters() * Returns a list of the registered Twig functions. * @return array */ - public function listFunctions() + public function listFunctions(): array { return $this->listExtensions(self::EXTENSION_FUNCTION); } @@ -199,7 +199,7 @@ public function listFunctions() * Returns a list of the registered Twig token parsers. * @return array */ - public function listTokenParsers() + public function listTokenParsers(): array { return $this->listExtensions(self::EXTENSION_TOKEN_PARSER); } @@ -209,12 +209,9 @@ public function listTokenParsers() * @param array $functions Current collection * @return array */ - public function makeTwigFunctions($functions = []) + public function makeTwigFunctions(array $functions = []): array { $defaultOptions = ['is_safe' => ['html']]; - if (!is_array($functions)) { - $functions = []; - } foreach ($this->listFunctions() as $name => $callable) { $options = []; @@ -223,6 +220,7 @@ public function makeTwigFunctions($functions = []) } else { $options = $defaultOptions; } + $callable = $this->normalizeCallable($callable); /* @@ -251,12 +249,9 @@ public function makeTwigFunctions($functions = []) * @param array $filters Current collection * @return array */ - public function makeTwigFilters($filters = []) + public function makeTwigFilters(array $filters = []): array { $defaultOptions = ['is_safe' => ['html']]; - if (!is_array($filters)) { - $filters = []; - } foreach ($this->listFilters() as $name => $callable) { $options = []; @@ -265,6 +260,7 @@ public function makeTwigFilters($filters = []) } else { $options = $defaultOptions; } + $callable = $this->normalizeCallable($callable); /* @@ -293,13 +289,10 @@ public function makeTwigFilters($filters = []) * @param array $parsers Current collection * @return array */ - public function makeTwigTokenParsers($parsers = []) + public function makeTwigTokenParsers(array $parsers = []): array { - if (!is_array($parsers)) { - $parsers = []; - } - $extraParsers = $this->listTokenParsers(); + foreach ($extraParsers as $obj) { if (!$obj instanceof TwigTokenParser) { continue; @@ -365,6 +358,7 @@ private function normalizeCallable($callable) return $callable[0]; } } + return $callable; } @@ -374,11 +368,12 @@ private function normalizeCallable($callable) * @param array $defaultOptions * @return array */ - private function normalizeOptions($options, $defaultOptions) + private function normalizeOptions(array $options, array $defaultOptions): array { if (isset($options['is_safe']) && !is_array($options['is_safe'])) { $options['is_safe'] = is_string($options['is_safe']) ? [$options['is_safe']] : []; } + return array_merge($defaultOptions, $options); } } From 72a024570cf4f4aa736067939b6ba8602402d050 Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:03:33 +0200 Subject: [PATCH 06/12] makeTwigCallable() helper --- modules/system/classes/MarkupManager.php | 114 +++++++++++------------ 1 file changed, 52 insertions(+), 62 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index 4e55826646..2c1f368067 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -211,37 +211,12 @@ public function listTokenParsers(): array */ public function makeTwigFunctions(array $functions = []): array { - $defaultOptions = ['is_safe' => ['html']]; - - foreach ($this->listFunctions() as $name => $callable) { - $options = []; - if (is_array($callable) && isset($callable['options'])) { - $options = $this->normalizeOptions($callable['options'], $defaultOptions); - } else { - $options = $defaultOptions; - } - - $callable = $this->normalizeCallable($callable); - - /* - * Handle a wildcard function - */ - if (strpos($name, '*') !== false && $this->isWildCallable($callable)) { - $callable = function ($name) use ($callable) { - $arguments = array_slice(func_get_args(), 1); - $method = $this->isWildCallable($callable, Str::camel($name)); - return call_user_func_array($method, $arguments); - }; - } - - if (!is_callable($callable)) { - throw new SystemException(sprintf('The markup function (%s) for %s is not callable.', json_encode($callable), $name)); - } - - $functions[] = new TwigSimpleFunction($name, $callable, $options); - } - - return $functions; + return $this->makeTwigCallable( + $this->listFunctions(), + TwigSimpleFunction::class, + ['is_safe' => ['html']], + $functions + ); } /** @@ -251,37 +226,12 @@ public function makeTwigFunctions(array $functions = []): array */ public function makeTwigFilters(array $filters = []): array { - $defaultOptions = ['is_safe' => ['html']]; - - foreach ($this->listFilters() as $name => $callable) { - $options = []; - if (is_array($callable) && isset($callable['options'])) { - $options = $this->normalizeOptions($callable['options'], $defaultOptions); - } else { - $options = $defaultOptions; - } - - $callable = $this->normalizeCallable($callable); - - /* - * Handle a wildcard filter - */ - if (strpos($name, '*') !== false && $this->isWildCallable($callable)) { - $callable = function ($name) use ($callable) { - $arguments = array_slice(func_get_args(), 1); - $method = $this->isWildCallable($callable, Str::camel($name)); - return call_user_func_array($method, $arguments); - }; - } - - if (!is_callable($callable)) { - throw new SystemException(sprintf('The markup filter (%s) for %s is not callable.', json_encode($callable), $name)); - } - - $filters[] = new TwigSimpleFilter($name, $callable, $options); - } - - return $filters; + return $this->makeTwigCallable( + $this->listFilters(), + TwigSimpleFilter::class, + ['is_safe' => ['html']], + $filters + ); } /** @@ -344,6 +294,46 @@ protected function isWildCallable($callable, $replaceWith = false) return $isWild; } + /** + * Shared logic for creating Twig callable (functions/filters). + * @param array $definitions + * @param string $twigClass + * @param array $defaultOptions + * @param array $collection + * @return array + */ + private function makeTwigCallable(array $definitions, string $twigClass, array $defaultOptions, array $collection = []): array + { + foreach ($definitions as $name => $callable) { + $options = $defaultOptions; + if (is_array($callable) && isset($callable['options'])) { + $options = $this->normalizeOptions($callable['options'], $defaultOptions); + } + $callable = $this->normalizeCallable($callable); + + // Handle wildcard + if (strpos($name, '*') !== false && $this->isWildCallable($callable)) { + $callable = function ($name) use ($callable) { + $arguments = array_slice(func_get_args(), 1); + $method = $this->isWildCallable($callable, Str::camel($name)); + return call_user_func_array($method, $arguments); + }; + } + + if (!is_callable($callable)) { + throw new SystemException(sprintf( + 'The markup %s (%s) for %s is not callable.', + strtolower(class_basename($twigClass)), + json_encode($callable), + $name + )); + } + + $collection[] = new $twigClass($name, $callable, $options); + } + return $collection; + } + /** * Normalize a callable definition. * @param mixed $callable From 88325e06d262fc766a0a5801fb8180ceedb11d4c Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:24:30 +0200 Subject: [PATCH 07/12] fix tests --- .../tests/classes/MarkupManagerTest.php | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/modules/system/tests/classes/MarkupManagerTest.php b/modules/system/tests/classes/MarkupManagerTest.php index 150cfd4d62..e4c5a51369 100644 --- a/modules/system/tests/classes/MarkupManagerTest.php +++ b/modules/system/tests/classes/MarkupManagerTest.php @@ -15,10 +15,6 @@ class MarkupManagerTest extends TestCase protected $testCallableDataWithOptions; private const OPTIONS = ['is_safe_callback' => ['html']]; - private const EMPTY_FUNCTION_MESSAGE = 'The markup function ([]) for emptyCallable is not callable.'; - private const INVALID_FUNCTION_MESSAGE = 'The markup function ({"callable":"not_a_callable"}) for invalidCallable is not callable.'; - private const EMPTY_FILTER_MESSAGE = 'The markup filter ([]) for emptyCallable is not callable.'; - private const INVALID_FILTER_MESSAGE = 'The markup filter ({"callable":"not_a_callable"}) for invalidCallable is not callable.'; public function setUp() : void { @@ -50,6 +46,19 @@ private function makeTestCallableData(bool $withOptions = false): array ]; } + /** + * Generates a not-callable error message. + * + * @param string $type Either 'function' or 'filter' + * @param string $details The details string (e.g., '[]' or '{"callable":"not_a_callable"}') + * @param string $name The name of the callable + * @return string + */ + public static function notCallableExceptionMessage(string $type, string $details, string $name): string + { + return sprintf('The markup %s (%s) for %s is not callable.', $type, $details, $name); + } + private function registerAndGetTwigFunctions(array $functions) { $this->manager->registerFunctions($functions); @@ -190,7 +199,7 @@ public function testMakeTwigFiltersAppliesDefaultOptions() public function testMakeTwigFunctionsHandlesEmptyCallableArray() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::EMPTY_FUNCTION_MESSAGE); + $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFunction::class, '[]', 'emptyCallable')); $this->manager->registerFunctions([ 'emptyCallable' => [] @@ -202,7 +211,7 @@ public function testMakeTwigFunctionsHandlesEmptyCallableArray() public function testMakeTwigFiltersHandlesEmptyCallableArray() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::EMPTY_FILTER_MESSAGE); + $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFilter::class, '[]', 'emptyCallable')); $this->manager->registerFilters([ 'emptyCallable' => [] @@ -214,7 +223,7 @@ public function testMakeTwigFiltersHandlesEmptyCallableArray() public function testMakeTwigFunctionsHandlesInvalidCallable() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::INVALID_FUNCTION_MESSAGE); + $this->expectExceptionMessage(self::notCallable(TwigFunction::class, '{"callable":"not_a_callable"}', 'invalidCallable')); $this->manager->registerFunctions([ 'invalidCallable' => [ @@ -228,7 +237,7 @@ public function testMakeTwigFunctionsHandlesInvalidCallable() public function testMakeTwigFiltersHandlesInvalidCallable() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::INVALID_FILTER_MESSAGE); + $this->expectExceptionMessage(self::notCallable(TwigFilter::class, '{"callable":"not_a_callable"}', 'invalidCallable')); $this->manager->registerFilters([ 'invalidCallable' => [ From 7fc5c6961f84b4bed38c70c9b435295809884b67 Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:40:18 +0200 Subject: [PATCH 08/12] Fix isWildCallable() checks on $callable --- modules/system/classes/MarkupManager.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index 2c1f368067..e70ae0538a 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -267,9 +267,8 @@ protected function isWildCallable($callable, $replaceWith = false) if (is_string($callable) && strpos($callable, '*') !== false) { $isWild = $replaceWith ? str_replace('*', $replaceWith, $callable) : true; - } - - if (is_array($callable)) { + } elseif (is_array($callable)) { + // Check first element of array if (is_string($callable[0]) && strpos($callable[0], '*') !== false) { if ($replaceWith) { $isWild = $callable; @@ -280,7 +279,8 @@ protected function isWildCallable($callable, $replaceWith = false) } } - if (!empty($callable[1]) && strpos($callable[1], '*') !== false) { + // Check second element of array if it exists + if (!empty($callable[1]) && is_string($callable[1]) && strpos($callable[1], '*') !== false) { if ($replaceWith) { $isWild = $isWild ?: $callable; $isWild[1] = str_replace('*', $replaceWith, $callable[1]); From 4f88f2517ed124cf3d33d8944710a886eedb00cb Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:42:08 +0200 Subject: [PATCH 09/12] show full classname in exception message --- modules/system/classes/MarkupManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index e70ae0538a..ac997c9a69 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -323,7 +323,7 @@ private function makeTwigCallable(array $definitions, string $twigClass, array $ if (!is_callable($callable)) { throw new SystemException(sprintf( 'The markup %s (%s) for %s is not callable.', - strtolower(class_basename($twigClass)), + $twigClass, json_encode($callable), $name )); From 9b020c98cf7919f7971b5ee33858249b3449efcb Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:46:53 +0200 Subject: [PATCH 10/12] fix typo in tests --- modules/system/tests/classes/MarkupManagerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/system/tests/classes/MarkupManagerTest.php b/modules/system/tests/classes/MarkupManagerTest.php index e4c5a51369..49dcd4a145 100644 --- a/modules/system/tests/classes/MarkupManagerTest.php +++ b/modules/system/tests/classes/MarkupManagerTest.php @@ -223,7 +223,7 @@ public function testMakeTwigFiltersHandlesEmptyCallableArray() public function testMakeTwigFunctionsHandlesInvalidCallable() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::notCallable(TwigFunction::class, '{"callable":"not_a_callable"}', 'invalidCallable')); + $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFunction::class, '{"callable":"not_a_callable"}', 'invalidCallable')); $this->manager->registerFunctions([ 'invalidCallable' => [ @@ -237,7 +237,7 @@ public function testMakeTwigFunctionsHandlesInvalidCallable() public function testMakeTwigFiltersHandlesInvalidCallable() { $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::notCallable(TwigFilter::class, '{"callable":"not_a_callable"}', 'invalidCallable')); + $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFilter::class, '{"callable":"not_a_callable"}', 'invalidCallable')); $this->manager->registerFilters([ 'invalidCallable' => [ From 21c9cbd4379d109a08c61bd7936507f1cd11a121 Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:01:54 +0200 Subject: [PATCH 11/12] Refactor MarkupManagerTest with helpers --- .../tests/classes/MarkupManagerTest.php | 124 +++++++----------- 1 file changed, 49 insertions(+), 75 deletions(-) diff --git a/modules/system/tests/classes/MarkupManagerTest.php b/modules/system/tests/classes/MarkupManagerTest.php index 49dcd4a145..5a1c75717e 100644 --- a/modules/system/tests/classes/MarkupManagerTest.php +++ b/modules/system/tests/classes/MarkupManagerTest.php @@ -59,6 +59,32 @@ public static function notCallableExceptionMessage(string $type, string $details return sprintf('The markup %s (%s) for %s is not callable.', $type, $details, $name); } + private function expectExceptionForEmptyCallable(string $type) + { + $this->expectException(SystemException::class); + $this->expectExceptionMessage(self::notCallableExceptionMessage($type, '[]', 'emptyCallable')); + if ($type === TwigFunction::class) { + $this->manager->registerFunctions(['emptyCallable' => []]); + self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + } else { + $this->manager->registerFilters(['emptyCallable' => []]); + self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + } + } + + private function expectExceptionForInvalidCallable(string $type) + { + $this->expectException(SystemException::class); + $this->expectExceptionMessage(self::notCallableExceptionMessage($type, '{"callable":"not_a_callable"}', 'invalidCallable')); + if ($type === TwigFunction::class) { + $this->manager->registerFunctions(['invalidCallable' => ['callable' => 'not_a_callable']]); + self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + } else { + $this->manager->registerFilters(['invalidCallable' => ['callable' => 'not_a_callable']]); + self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + } + } + private function registerAndGetTwigFunctions(array $functions) { $this->manager->registerFunctions($functions); @@ -71,26 +97,11 @@ private function registerAndGetTwigFilters(array $filters) return self::callProtectedMethod($this->manager, 'makeTwigFilters', []); } - private function assertAllTwigFunctions(array $functions, string $message = '') + private function assertAllTwigInstances(array $items, string $class, string $message = '') { - $this->assertIsArray($functions); - $this->assertNotEmpty($functions); - $this->assertContainsOnlyInstancesOf( - TwigFunction::class, - $functions, - $message ?: "All elements must be TwigFunction instances" - ); - } - - private function assertAllTwigFilters(array $filters, string $message = '') - { - $this->assertIsArray($filters); - $this->assertNotEmpty($filters); - $this->assertContainsOnlyInstancesOf( - TwigFilter::class, - $filters, - $message ?: "All elements must be TwigFunction instances" - ); + $this->assertIsArray($items); + $this->assertNotEmpty($items); + $this->assertContainsOnlyInstancesOf($class, $items, $message ?: "All elements must be instances of $class"); } // @@ -102,19 +113,17 @@ public function testIsWildCallable() /* * Negatives */ - $callable = 'something'; - $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); - $this->assertFalse($result); - - $callable = ['Form', 'open']; - $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); - $this->assertFalse($result); - - $callable = function () { - return 'O, Hai!'; - }; - $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); - $this->assertFalse($result); + $negatives = [ + 'something', + ['Form', 'open'], + function () { + return 'O, Hai!'; + }, + ]; + foreach ($negatives as $callable) { + $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); + $this->assertFalse($result); + } /* * String @@ -122,7 +131,6 @@ public function testIsWildCallable() $callable = 'something_*'; $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable]); $this->assertTrue($result); - $result = self::callProtectedMethod($this->manager, 'isWildCallable', [$callable, 'delicious']); $this->assertEquals('something_delicious', $result); @@ -163,20 +171,19 @@ public function testIsWildCallable() public function testMakeTwigFunctionsHandlesCallableArrayWithOptions() { $functions = $this->registerAndGetTwigFunctions($this->testCallableDataWithOptions); - $this->assertAllTwigFunctions($functions); + $this->assertAllTwigInstances($functions, TwigFunction::class); } public function testMakeTwigFiltersHandlesCallableArrayWithOptions() { $filters = $this->registerAndGetTwigFilters($this->testCallableDataWithOptions); - $this->assertAllTwigFilters($filters); + $this->assertAllTwigInstances($filters, TwigFilter::class); } public function testMakeTwigFunctionsAppliesDefaultOptions() { $functions = $this->registerAndGetTwigFunctions($this->testCallableData); - $this->assertAllTwigFunctions($functions); - + $this->assertAllTwigInstances($functions, TwigFunction::class); foreach ($functions as $function) { $options = self::getProtectedProperty($function, 'options'); $this->assertArrayHasKey('is_safe', $options); @@ -187,8 +194,7 @@ public function testMakeTwigFunctionsAppliesDefaultOptions() public function testMakeTwigFiltersAppliesDefaultOptions() { $filters = $this->registerAndGetTwigFilters($this->testCallableData); - $this->assertAllTwigFilters($filters); - + $this->assertAllTwigInstances($filters, TwigFilter::class); foreach ($filters as $filter) { $options = self::getProtectedProperty($filter, 'options'); $this->assertArrayHasKey('is_safe', $options); @@ -198,53 +204,21 @@ public function testMakeTwigFiltersAppliesDefaultOptions() public function testMakeTwigFunctionsHandlesEmptyCallableArray() { - $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFunction::class, '[]', 'emptyCallable')); - - $this->manager->registerFunctions([ - 'emptyCallable' => [] - ]); - - self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + $this->expectExceptionForEmptyCallable(TwigFunction::class); } public function testMakeTwigFiltersHandlesEmptyCallableArray() { - $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFilter::class, '[]', 'emptyCallable')); - - $this->manager->registerFilters([ - 'emptyCallable' => [] - ]); - - self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + $this->expectExceptionForEmptyCallable(TwigFilter::class); } public function testMakeTwigFunctionsHandlesInvalidCallable() { - $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFunction::class, '{"callable":"not_a_callable"}', 'invalidCallable')); - - $this->manager->registerFunctions([ - 'invalidCallable' => [ - 'callable' => 'not_a_callable' - ] - ]); - - self::callProtectedMethod($this->manager, 'makeTwigFunctions', []); + $this->expectExceptionForInvalidCallable(TwigFunction::class); } public function testMakeTwigFiltersHandlesInvalidCallable() { - $this->expectException(SystemException::class); - $this->expectExceptionMessage(self::notCallableExceptionMessage(TwigFilter::class, '{"callable":"not_a_callable"}', 'invalidCallable')); - - $this->manager->registerFilters([ - 'invalidCallable' => [ - 'callable' => 'not_a_callable' - ] - ]); - - self::callProtectedMethod($this->manager, 'makeTwigFilters', []); + $this->expectExceptionForInvalidCallable(TwigFilter::class); } } From dd08d2279cf15bfd30725ec30d915c5c2d51ca37 Mon Sep 17 00:00:00 2001 From: Matteo Trubini <7964032+matteotrubini@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:53:22 +0200 Subject: [PATCH 12/12] Rename makeTwigCallable to makeTwigCallables --- modules/system/classes/MarkupManager.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/system/classes/MarkupManager.php b/modules/system/classes/MarkupManager.php index ac997c9a69..bfe7eb1c13 100644 --- a/modules/system/classes/MarkupManager.php +++ b/modules/system/classes/MarkupManager.php @@ -211,7 +211,7 @@ public function listTokenParsers(): array */ public function makeTwigFunctions(array $functions = []): array { - return $this->makeTwigCallable( + return $this->makeTwigCallables( $this->listFunctions(), TwigSimpleFunction::class, ['is_safe' => ['html']], @@ -226,7 +226,7 @@ public function makeTwigFunctions(array $functions = []): array */ public function makeTwigFilters(array $filters = []): array { - return $this->makeTwigCallable( + return $this->makeTwigCallables( $this->listFilters(), TwigSimpleFilter::class, ['is_safe' => ['html']], @@ -302,7 +302,7 @@ protected function isWildCallable($callable, $replaceWith = false) * @param array $collection * @return array */ - private function makeTwigCallable(array $definitions, string $twigClass, array $defaultOptions, array $collection = []): array + private function makeTwigCallables(array $definitions, string $twigClass, array $defaultOptions, array $collection = []): array { foreach ($definitions as $name => $callable) { $options = $defaultOptions;