From 34ae0311d94a97ae43282837ca2e12d7275b1b4a Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Mon, 9 Sep 2024 16:17:33 +0200 Subject: [PATCH] Improve the way one can deprecate a Twig callable --- CHANGELOG | 2 +- doc/advanced.rst | 48 ++++++++++---- doc/deprecated.rst | 19 ++++++ src/AbstractTwigCallable.php | 47 +++++++++++++- src/DeprecatedCallableInfo.php | 67 ++++++++++++++++++++ src/ExpressionParser.php | 25 +------- src/Extension/CoreExtension.php | 3 +- tests/DeprecatedCallableInfoTest.php | 80 ++++++++++++++++++++++++ tests/Fixtures/functions/deprecated.test | 4 +- tests/IntegrationTest.php | 3 +- tests/Util/DeprecationCollectorTest.php | 3 +- 11 files changed, 260 insertions(+), 41 deletions(-) create mode 100644 src/DeprecatedCallableInfo.php create mode 100644 tests/DeprecatedCallableInfoTest.php diff --git a/CHANGELOG b/CHANGELOG index d76769421b7..eefd094ac1b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.15.0 (2024-XX-XX) - * n/a + * Improve the way one can deprecate a Twig callable (use `deprecation_info` instead of the other callable options) # 3.14.0 (2024-09-09) diff --git a/doc/advanced.rst b/doc/advanced.rst index 6b3f1118ea2..01f03d98078 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -277,29 +277,53 @@ filter: ``('a', 'b', 'foo')``. Deprecated Filters ~~~~~~~~~~~~~~~~~~ -You can mark a filter as being deprecated by setting the ``deprecated`` option -to ``true``. You can also give an alternative filter that replaces the -deprecated one when that makes sense:: +.. versionadded:: 3.15 + + The ``deprecation_info`` option was added in Twig 3.15. + +You can mark a filter as being deprecated by setting the ``deprecation_info`` +option:: $filter = new \Twig\TwigFilter('obsolete', function () { // ... - }, ['deprecated' => true, 'alternative' => 'new_one']); + }, ['deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.11', 'new_one')]); -.. versionadded:: 3.11 +The ``DeprecatedCallableInfo`` constructor takes the following parameters: - The ``deprecating_package`` option was added in Twig 3.11. +* The Composer package name that defines the filter; +* The version when the filter was deprecated. -You can also set the ``deprecating_package`` option to specify the package that -is deprecating the filter, and ``deprecated`` can be set to the package version -when the filter was deprecated:: +Optionally, you can also provide the following parameters about an alternative: - $filter = new \Twig\TwigFilter('obsolete', function () { - // ... - }, ['deprecated' => '1.1', 'deprecating_package' => 'foo/bar']); +* The package name that contains the alternative filter; +* The alternative filter name that replaces the deprecated one; +* The package version that added the alternative filter. When a filter is deprecated, Twig emits a deprecation notice when compiling a template using it. See :ref:`deprecation-notices` for more information. +.. note:: + + Before Twig 3.15, you can mark a filter as being deprecated by setting the + ``deprecated`` option to ``true``. You can also give an alternative filter + that replaces the deprecated one when that makes sense:: + + $filter = new \Twig\TwigFilter('obsolete', function () { + // ... + }, ['deprecated' => true, 'alternative' => 'new_one']); + + .. versionadded:: 3.11 + + The ``deprecating_package`` option was added in Twig 3.11. + + You can also set the ``deprecating_package`` option to specify the package + that is deprecating the filter, and ``deprecated`` can be set to the + package version when the filter was deprecated:: + + $filter = new \Twig\TwigFilter('obsolete', function () { + // ... + }, ['deprecated' => '1.1', 'deprecating_package' => 'foo/bar']); + Functions --------- diff --git a/doc/deprecated.rst b/doc/deprecated.rst index a353518f000..c280702a5cb 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -237,3 +237,22 @@ Environment After:: $context += $twig->getGlobals(); + +Functions/Filters/Tests +----------------------- + +* The ``deprecated``, ``deprecating_package``, ``alternative`` options on Twig + functions/filters/Tests are deprecated as of Twig 3.15, and will be removed + in Twig 4.0. Use the ``deprecation_info`` option instead: + + Before:: + + $twig->addFunction(new TwigFunction('foo', 'foo', [ + 'deprecated' => '3.12', 'deprecating_package' => 'twig/twig', + ])); + + After:: + + $twig->addFunction(new TwigFunction('foo', 'foo', [ + 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12'), + ])); diff --git a/src/AbstractTwigCallable.php b/src/AbstractTwigCallable.php index f6718430064..2e9b34d18be 100644 --- a/src/AbstractTwigCallable.php +++ b/src/AbstractTwigCallable.php @@ -33,10 +33,35 @@ public function __construct(string $name, $callable = null, array $options = []) 'needs_context' => false, 'needs_charset' => false, 'is_variadic' => false, + 'deprecation_info' => null, 'deprecated' => false, 'deprecating_package' => '', 'alternative' => null, ], $options); + + if ($this->options['deprecation_info'] && !$this->options['deprecation_info'] instanceof DeprecatedCallableInfo) { + throw new \LogicException(\sprintf('The "deprecation_info" option must be an instance of "%s".', DeprecatedCallableInfo::class)); + } + + if ($this->options['deprecated']) { + if ($this->options['deprecation_info']) { + throw new \LogicException('When setting the "deprecation_info" option, you need to remove the obsolete deprecated options.'); + } + + trigger_deprecation('twig/twig', '3.15', 'Using the "deprecated", "deprecating_package", and "alternative" options is deprecated, pass a "deprecation_info" one instead.'); + + $this->options['deprecation_info'] = new DeprecatedCallableInfo( + $this->options['deprecating_package'], + $this->options['deprecated'], + null, + $this->options['alternative'], + ); + } + + if ($this->options['deprecation_info']) { + $this->options['deprecation_info']->setName($name); + $this->options['deprecation_info']->setType($this->getType()); + } } public function __toString(): string @@ -111,21 +136,41 @@ public function isVariadic(): bool public function isDeprecated(): bool { - return (bool) $this->options['deprecated']; + return (bool) $this->options['deprecation_info']; } + public function triggerDeprecation(?string $file = null, ?int $line = null): void + { + $this->options['deprecation_info']->triggerDeprecation($file, $line); + } + + /** + * @deprecated since Twig 3.15 + */ public function getDeprecatingPackage(): string { + trigger_deprecation('twig/twig', '3.15', 'The "%s" method is deprecated, use "%s::triggerDeprecation()" instead.', __METHOD__, static::class); + return $this->options['deprecating_package']; } + /** + * @deprecated since Twig 3.15 + */ public function getDeprecatedVersion(): string { + trigger_deprecation('twig/twig', '3.15', 'The "%s" method is deprecated, use "%s::triggerDeprecation()" instead.', __METHOD__, static::class); + return \is_bool($this->options['deprecated']) ? '' : $this->options['deprecated']; } + /** + * @deprecated since Twig 3.15 + */ public function getAlternative(): ?string { + trigger_deprecation('twig/twig', '3.15', 'The "%s" method is deprecated, use "%s::triggerDeprecation()" instead.', __METHOD__, static::class); + return $this->options['alternative']; } diff --git a/src/DeprecatedCallableInfo.php b/src/DeprecatedCallableInfo.php new file mode 100644 index 00000000000..2db9f3d28af --- /dev/null +++ b/src/DeprecatedCallableInfo.php @@ -0,0 +1,67 @@ + + */ +final class DeprecatedCallableInfo +{ + private string $type; + private string $name; + + public function __construct( + private string $package, + private string $version, + private ?string $altName = null, + private ?string $altPackage = null, + private ?string $altVersion = null, + ) { + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function triggerDeprecation(?string $file = null, ?int $line = null): void + { + $message = \sprintf('Twig %s "%s" is deprecated', ucfirst($this->type), $this->name); + + if ($this->altName) { + $message .= \sprintf('; use "%s"', $this->altName); + if ($this->altPackage) { + $message .= \sprintf(' from the "%s" package', $this->altPackage); + } + if ($this->altVersion) { + $message .= \sprintf(' (available since version %s)', $this->altVersion); + } + $message .= ' instead'; + } + + if ($file) { + $message .= \sprintf(' in %s', $file); + if ($line) { + $message .= \sprintf(' at line %d', $line); + } + } + + $message .= '.'; + + trigger_deprecation($this->package, $this->version, $message); + } +} diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index dc4a6015d7b..a610a390e58 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -773,15 +773,8 @@ private function getTest(int $line): TwigTest if ($test->isDeprecated()) { $stream = $this->parser->getStream(); - $message = \sprintf('Twig Test "%s" is deprecated', $test->getName()); - - if ($test->getAlternative()) { - $message .= \sprintf('. Use "%s" instead', $test->getAlternative()); - } $src = $stream->getSourceContext(); - $message .= \sprintf(' in %s at line %d.', $src->getPath() ?: $src->getName(), $stream->getCurrent()->getLine()); - - trigger_deprecation($test->getDeprecatingPackage(), $test->getDeprecatedVersion(), $message); + $test->triggerDeprecation($src->getPath() ?: $src->getName(), $stream->getCurrent()->getLine()); } return $test; @@ -797,14 +790,8 @@ private function getFunction(string $name, int $line): TwigFunction } if ($function->isDeprecated()) { - $message = \sprintf('Twig Function "%s" is deprecated', $function->getName()); - if ($function->getAlternative()) { - $message .= \sprintf('. Use "%s" instead', $function->getAlternative()); - } $src = $this->parser->getStream()->getSourceContext(); - $message .= \sprintf(' in %s at line %d.', $src->getPath() ?: $src->getName(), $line); - - trigger_deprecation($function->getDeprecatingPackage(), $function->getDeprecatedVersion(), $message); + $function->triggerDeprecation($src->getPath() ?: $src->getName(), $line); } return $function; @@ -820,14 +807,8 @@ private function getFilter(string $name, int $line): TwigFilter } if ($filter->isDeprecated()) { - $message = \sprintf('Twig Filter "%s" is deprecated', $filter->getName()); - if ($filter->getAlternative()) { - $message .= \sprintf('. Use "%s" instead', $filter->getAlternative()); - } $src = $this->parser->getStream()->getSourceContext(); - $message .= \sprintf(' in %s at line %d.', $src->getPath() ?: $src->getName(), $line); - - trigger_deprecation($filter->getDeprecatingPackage(), $filter->getDeprecatedVersion(), $message); + $filter->triggerDeprecation($src->getPath() ?: $src->getName(), $line); } return $filter; diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 3ed27a35cc3..550dc0f3851 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -11,6 +11,7 @@ namespace Twig\Extension; +use Twig\DeprecatedCallableInfo; use Twig\Environment; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -217,7 +218,7 @@ public function getFilters(): array new TwigFilter('striptags', [self::class, 'striptags']), new TwigFilter('trim', [self::class, 'trim']), new TwigFilter('nl2br', [self::class, 'nl2br'], ['pre_escape' => 'html', 'is_safe' => ['html']]), - new TwigFilter('spaceless', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecated' => '3.12', 'deprecating_package' => 'twig/twig']), + new TwigFilter('spaceless', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12')]), // array helpers new TwigFilter('join', [self::class, 'join']), diff --git a/tests/DeprecatedCallableInfoTest.php b/tests/DeprecatedCallableInfoTest.php new file mode 100644 index 00000000000..4e6d1583ce5 --- /dev/null +++ b/tests/DeprecatedCallableInfoTest.php @@ -0,0 +1,80 @@ +setType('function'); + $info->setName('foo'); + + $deprecations = []; + try { + set_error_handler(function ($type, $msg) use (&$deprecations) { + if (\E_USER_DEPRECATED === $type) { + $deprecations[] = $msg; + } + + return false; + }); + + $info->triggerDeprecation('foo.twig', 1); + } finally { + restore_error_handler(); + } + + $this->assertSame([$expected], $deprecations); + } + + public static function provideTestsForTriggerDeprecation(): iterable + { + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1')]; + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated; use "alt_foo" from the "all/bar" package (available since version 12.10) instead in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1', 'alt_foo', 'all/bar', '12.10')]; + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated; use "alt_foo" from the "all/bar" package instead in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1', 'alt_foo', 'all/bar')]; + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated; use "alt_foo" instead in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1', 'alt_foo')]; + } + + public function testTriggerDeprecationWithoutFileOrLine() + { + $info = new DeprecatedCallableInfo('foo/bar', '1.1'); + $info->setType('function'); + $info->setName('foo'); + + $deprecations = []; + try { + set_error_handler(function ($type, $msg) use (&$deprecations) { + if (\E_USER_DEPRECATED === $type) { + $deprecations[] = $msg; + } + + return false; + }); + + $info->triggerDeprecation(); + $info->triggerDeprecation('foo.twig'); + } finally { + restore_error_handler(); + } + + $this->assertSame([ + 'Since foo/bar 1.1: Twig Function "foo" is deprecated.', + 'Since foo/bar 1.1: Twig Function "foo" is deprecated in foo.twig.', + ], $deprecations); + } +} diff --git a/tests/Fixtures/functions/deprecated.test b/tests/Fixtures/functions/deprecated.test index 355e43303dd..42f2a5dd6f3 100644 --- a/tests/Fixtures/functions/deprecated.test +++ b/tests/Fixtures/functions/deprecated.test @@ -1,8 +1,8 @@ --TEST-- Functions can be deprecated_function --DEPRECATION-- -Since foo/bar 1.1: Twig Function "deprecated_function" is deprecated. Use "not_deprecated_function" instead in index.twig at line 2. -Since foo/bar 1.1: Twig Function "deprecated_function" is deprecated. Use "not_deprecated_function" instead in index.twig at line 4. +Since foo/bar 1.1: Twig Function "deprecated_function" is deprecated; use "not_deprecated_function" instead in index.twig at line 2. +Since foo/bar 1.1: Twig Function "deprecated_function" is deprecated; use "not_deprecated_function" instead in index.twig at line 4. --TEMPLATE-- {{ deprecated_function() }} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 65f207d90bb..49ccf76cb96 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -11,6 +11,7 @@ * file that was distributed with this source code. */ +use Twig\DeprecatedCallableInfo; use Twig\Extension\AbstractExtension; use Twig\Extension\DebugExtension; use Twig\Extension\SandboxExtension; @@ -185,7 +186,7 @@ public function getFunctions(): array new TwigFunction('*_path', [$this, 'dynamic_path']), new TwigFunction('*_foo_*_bar', [$this, 'dynamic_foo']), new TwigFunction('anon_foo', function ($name) { return '*'.$name.'*'; }), - new TwigFunction('deprecated_function', function () { return 'foo'; }, ['deprecated' => '1.1', 'deprecating_package' => 'foo/bar', 'alternative' => 'not_deprecated_function']), + new TwigFunction('deprecated_function', function () { return 'foo'; }, ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1', 'not_deprecated_function')]), ]; } diff --git a/tests/Util/DeprecationCollectorTest.php b/tests/Util/DeprecationCollectorTest.php index a3010d14d57..635a67f40cc 100644 --- a/tests/Util/DeprecationCollectorTest.php +++ b/tests/Util/DeprecationCollectorTest.php @@ -12,6 +12,7 @@ */ use PHPUnit\Framework\TestCase; +use Twig\DeprecatedCallableInfo; use Twig\Environment; use Twig\Loader\ArrayLoader; use Twig\TwigFunction; @@ -25,7 +26,7 @@ class DeprecationCollectorTest extends TestCase public function testCollect() { $twig = new Environment(new ArrayLoader()); - $twig->addFunction(new TwigFunction('deprec', [$this, 'deprec'], ['deprecated' => '1.1', 'deprecating_package' => 'foo/bar'])); + $twig->addFunction(new TwigFunction('deprec', [$this, 'deprec'], ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')])); $collector = new DeprecationCollector($twig); $deprecations = $collector->collect(new Iterator());