Version in base suite: 3.20.0-2 Base version: php-twig_3.20.0-2 Target version: php-twig_3.27.0-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/php-twig/php-twig_3.20.0-2.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/php-twig/php-twig_3.27.0-0+deb13u1.dsc .gitattributes | 8 .github/workflows/ci.yml | 61 .github/workflows/fabbot.yml | 14 .php-cs-fixer.dist.php | 38 CHANGELOG | 105 bin/generate_operators_precedence.php | 109 composer.json | 3 debian/changelog | 50 debian/clean | 11 debian/control | 2 debian/gbp.conf | 2 debian/patches/0002-Skip-failing-test-without-composer-install.patch | 4 debian/patches/0004-Switch-to-static-data-provides-for-integration-tests.patch | 8 debian/patches/0005-PHPUnit-11.patch | 753 ++++-- debian/patches/0007-Twig-4-Add-PHPUnit-as-dev-dependency.patch | 121 debian/patches/0008-Compatibility-with-recent-PHPUnit-12.patch | 323 ++ debian/patches/0009-Drop-redundant-require_once-in-tests.patch | 21 debian/patches/series | 1 debian/rules | 52 doc/_build/build.php | 15 doc/advanced.rst | 187 + doc/api.rst | 33 doc/coding_standards.rst | 7 doc/deprecated.rst | 167 + doc/filters/escape.rst | 34 doc/filters/filter.rst | 2 doc/filters/format_datetime.rst | 23 doc/filters/format_number.rst | 40 doc/filters/html_attr_merge.rst | 141 + doc/filters/html_attr_type.rst | 123 + doc/filters/index.rst | 3 doc/filters/inky_to_html.rst | 2 doc/filters/number_format.rst | 14 doc/filters/reduce.rst | 10 doc/filters/replace.rst | 2 doc/filters/singular.rst | 6 doc/filters/slug.rst | 10 doc/filters/spaceless.rst | 10 doc/filters/u.rst | 2 doc/functions/country_timezones.rst | 4 doc/functions/date.rst | 2 doc/functions/enum.rst | 16 doc/functions/enum_cases.rst | 3 doc/functions/html_attr.rst | 202 + doc/functions/html_cva.rst | 31 doc/functions/include.rst | 6 doc/functions/index.rst | 1 doc/functions/random.rst | 5 doc/functions/template_from_string.rst | 10 doc/index.rst | 1 doc/operators_precedence.rst | 204 + doc/recipes.rst | 18 doc/sandbox.rst | 61 doc/tags/block.rst | 2 doc/tags/cache.rst | 2 doc/tags/extends.rst | 10 doc/tags/index.rst | 2 doc/tags/set.rst | 10 doc/templates.rst | 303 +- doc/tests/index.rst | 2 extra/cache-extra/.gitattributes | 1 extra/cache-extra/Tests/FunctionalTest.php | 2 extra/cache-extra/TokenParser/CacheTokenParser.php | 9 extra/cache-extra/composer.json | 4 extra/cssinliner-extra/.gitattributes | 1 extra/cssinliner-extra/CssInlinerExtension.php | 6 extra/cssinliner-extra/Resources/functions.php | 4 extra/cssinliner-extra/Tests/FunctionalTest.php | 47 extra/html-extra/.gitattributes | 1 extra/html-extra/Cva.php | 2 extra/html-extra/HtmlAttr/AttributeValueInterface.php | 31 extra/html-extra/HtmlAttr/InlineStyle.php | 70 extra/html-extra/HtmlAttr/MergeableInterface.php | 55 extra/html-extra/HtmlAttr/SeparatedTokenList.php | 68 extra/html-extra/HtmlExtension.php | 144 + extra/html-extra/Tests/Fixtures/html_attr.test | 40 extra/html-extra/Tests/Fixtures/html_attr_merge.test | 10 extra/html-extra/Tests/HtmlAttrMergeTest.php | 223 + extra/html-extra/Tests/HtmlAttrTest.php | 319 ++ extra/html-extra/composer.json | 2 extra/inky-extra/.gitattributes | 1 extra/inky-extra/InkyExtension.php | 6 extra/inky-extra/Resources/functions.php | 4 extra/inky-extra/Tests/FunctionalTest.php | 33 extra/intl-extra/.gitattributes | 1 extra/intl-extra/IntlExtension.php | 8 extra/intl-extra/Tests/Fixtures/country_timezones.test | 4 extra/intl-extra/Tests/Fixtures/script_names.test | 8 extra/intl-extra/Tests/IntlExtensionTest.php | 31 extra/intl-extra/composer.json | 2 extra/markdown-extra/.gitattributes | 1 extra/markdown-extra/DefaultMarkdown.php | 3 extra/markdown-extra/ErusevMarkdown.php | 6 extra/markdown-extra/LeagueMarkdown.php | 3 extra/markdown-extra/MarkdownExtension.php | 4 extra/markdown-extra/Tests/Fixtures/html_to_markdown_escaping.test | 12 extra/markdown-extra/Tests/FunctionalTest.php | 25 extra/markdown-extra/composer.json | 2 extra/string-extra/.gitattributes | 1 extra/string-extra/composer.json | 2 extra/twig-extra-bundle/.gitattributes | 1 extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php | 33 extra/twig-extra-bundle/DependencyInjection/Configuration.php | 12 extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php | 26 extra/twig-extra-bundle/Extensions.php | 6 extra/twig-extra-bundle/Tests/DependencyInjection/TwigExtraExtensionTest.php | 6 extra/twig-extra-bundle/Tests/Fixture/Kernel.php | 9 extra/twig-extra-bundle/Tests/IntegrationTest.php | 9 extra/twig-extra-bundle/TwigExtraBundle.php | 25 extra/twig-extra-bundle/composer.json | 6 extra/twig-extra-bundle/phpunit-bootstrap.php | 17 extra/twig-extra-bundle/phpunit.xml.dist | 2 phpstan-baseline.neon | 6 splitsh.json | 16 src/AbstractTwigCallable.php | 8 src/Attribute/AsTwigFilter.php | 58 src/Attribute/AsTwigFunction.php | 54 src/Attribute/AsTwigTest.php | 50 src/Compiler.php | 12 src/Environment.php | 33 src/Error/Error.php | 123 - src/ExpressionParser.php | 834 ------ src/ExpressionParser/AbstractExpressionParser.php | 35 src/ExpressionParser/ExpressionParserDescriptionInterface.php | 17 src/ExpressionParser/ExpressionParserInterface.php | 36 src/ExpressionParser/ExpressionParserType.php | 33 src/ExpressionParser/ExpressionParsers.php | 155 + src/ExpressionParser/Infix/ArgumentsTrait.php | 83 src/ExpressionParser/Infix/ArrowExpressionParser.php | 53 src/ExpressionParser/Infix/AssignmentExpressionParser.php | 66 src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php | 80 src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php | 62 src/ExpressionParser/Infix/DotExpressionParser.php | 108 src/ExpressionParser/Infix/FilterExpressionParser.php | 85 src/ExpressionParser/Infix/FunctionExpressionParser.php | 96 src/ExpressionParser/Infix/IsExpressionParser.php | 84 src/ExpressionParser/Infix/IsNotExpressionParser.php | 33 src/ExpressionParser/Infix/SquareBracketExpressionParser.php | 91 src/ExpressionParser/InfixAssociativity.php | 18 src/ExpressionParser/InfixExpressionParserInterface.php | 27 src/ExpressionParser/PrecedenceChange.php | 42 src/ExpressionParser/Prefix/GroupingExpressionParser.php | 88 src/ExpressionParser/Prefix/LiteralExpressionParser.php | 233 + src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php | 72 src/ExpressionParser/PrefixExpressionParserInterface.php | 25 src/Extension/AbstractExtension.php | 5 src/Extension/AttributeExtension.php | 176 + src/Extension/CoreExtension.php | 255 +- src/Extension/EscaperExtension.php | 29 src/Extension/ExtensionInterface.php | 11 src/Extension/SandboxExtension.php | 73 src/Extension/StringLoaderExtension.php | 3 src/ExtensionSet.php | 119 src/FileExtensionEscapingStrategy.php | 2 src/Lexer.php | 79 src/Loader/ChainLoader.php | 4 src/Markup.php | 9 src/Node/CheckSecurityCallNode.php | 1 src/Node/CheckSecurityNode.php | 17 src/Node/CheckToStringNode.php | 7 src/Node/CoercesChildrenToStringInterface.php | 40 src/Node/DeprecatedNode.php | 16 src/Node/EmbedNode.php | 6 src/Node/Expression/ArrayExpression.php | 142 - src/Node/Expression/ArrowFunctionExpression.php | 25 src/Node/Expression/AssignNameExpression.php | 2 src/Node/Expression/Binary/AbstractBinary.php | 4 src/Node/Expression/Binary/AddBinary.php | 3 src/Node/Expression/Binary/AndBinary.php | 3 src/Node/Expression/Binary/BitwiseAndBinary.php | 3 src/Node/Expression/Binary/BitwiseOrBinary.php | 3 src/Node/Expression/Binary/BitwiseXorBinary.php | 3 src/Node/Expression/Binary/ConcatBinary.php | 9 src/Node/Expression/Binary/DivBinary.php | 3 src/Node/Expression/Binary/EndsWithBinary.php | 3 src/Node/Expression/Binary/EqualBinary.php | 9 src/Node/Expression/Binary/FloorDivBinary.php | 3 src/Node/Expression/Binary/GreaterBinary.php | 9 src/Node/Expression/Binary/GreaterEqualBinary.php | 9 src/Node/Expression/Binary/HasEveryBinary.php | 5 src/Node/Expression/Binary/HasSomeBinary.php | 5 src/Node/Expression/Binary/InBinary.php | 9 src/Node/Expression/Binary/LessBinary.php | 9 src/Node/Expression/Binary/LessEqualBinary.php | 9 src/Node/Expression/Binary/MatchesBinary.php | 17 src/Node/Expression/Binary/ModBinary.php | 3 src/Node/Expression/Binary/MulBinary.php | 3 src/Node/Expression/Binary/NotEqualBinary.php | 9 src/Node/Expression/Binary/NotInBinary.php | 9 src/Node/Expression/Binary/NotSameAsBinary.php | 23 src/Node/Expression/Binary/ObjectDestructuringSetBinary.php | 77 src/Node/Expression/Binary/OrBinary.php | 3 src/Node/Expression/Binary/PowerBinary.php | 3 src/Node/Expression/Binary/RangeBinary.php | 9 src/Node/Expression/Binary/SameAsBinary.php | 23 src/Node/Expression/Binary/SequenceDestructuringSetBinary.php | 67 src/Node/Expression/Binary/SetBinary.php | 44 src/Node/Expression/Binary/SpaceshipBinary.php | 9 src/Node/Expression/Binary/StartsWithBinary.php | 3 src/Node/Expression/Binary/SubBinary.php | 3 src/Node/Expression/Binary/XorBinary.php | 3 src/Node/Expression/BlockReferenceExpression.php | 22 src/Node/Expression/CallExpression.php | 35 src/Node/Expression/ConstantExpression.php | 6 src/Node/Expression/EmptyExpression.php | 33 src/Node/Expression/Filter/DefaultFilter.php | 2 src/Node/Expression/Filter/RawFilter.php | 2 src/Node/Expression/FilterExpression.php | 11 src/Node/Expression/FunctionExpression.php | 23 src/Node/Expression/FunctionNode/EnumCasesFunction.php | 9 src/Node/Expression/FunctionNode/EnumFunction.php | 9 src/Node/Expression/GetAttrExpression.php | 93 src/Node/Expression/ListExpression.php | 41 src/Node/Expression/MacroReferenceExpression.php | 27 src/Node/Expression/MethodCallExpression.php | 9 src/Node/Expression/NameExpression.php | 11 src/Node/Expression/NullCoalesceExpression.php | 4 src/Node/Expression/ReturnArrayInterface.php | 16 src/Node/Expression/ReturnBoolInterface.php | 16 src/Node/Expression/ReturnNumberInterface.php | 16 src/Node/Expression/ReturnPrimitiveTypeInterface.php | 16 src/Node/Expression/ReturnStringInterface.php | 16 src/Node/Expression/SupportDefinedTestDeprecationTrait.php | 44 src/Node/Expression/SupportDefinedTestInterface.php | 24 src/Node/Expression/SupportDefinedTestTrait.php | 27 src/Node/Expression/TempNameExpression.php | 4 src/Node/Expression/Ternary/ConditionalTernary.php | 7 src/Node/Expression/Test/DefinedTest.php | 44 src/Node/Expression/Test/DivisiblebyTest.php | 6 src/Node/Expression/Test/EvenTest.php | 6 src/Node/Expression/Test/NullTest.php | 8 src/Node/Expression/Test/OddTest.php | 6 src/Node/Expression/Test/SameasTest.php | 6 src/Node/Expression/Test/TrueTest.php | 40 src/Node/Expression/TestExpression.php | 22 src/Node/Expression/Unary/AbstractUnary.php | 2 src/Node/IfNode.php | 9 src/Node/ImportNode.php | 12 src/Node/IncludeNode.php | 15 src/Node/ModuleNode.php | 35 src/Node/Node.php | 3 src/Node/PrintNode.php | 7 src/Node/SetNode.php | 2 src/Node/TypesNode.php | 9 src/NodeVisitor/EscaperNodeVisitor.php | 2 src/NodeVisitor/OptimizerNodeVisitor.php | 2 src/NodeVisitor/SafeAnalysisNodeVisitor.php | 13 src/NodeVisitor/SandboxNodeVisitor.php | 79 src/NodeVisitor/YieldNotReadyNodeVisitor.php | 2 src/OperatorPrecedenceChange.php | 24 src/Parser.php | 227 + src/Profiler/Dumper/BaseDumper.php | 7 src/Profiler/Dumper/HtmlDumper.php | 14 src/Profiler/Profile.php | 2 src/Resources/core.php | 49 src/Resources/debug.php | 4 src/Resources/string_loader.php | 4 src/Runtime/EscaperRuntime.php | 46 src/Sandbox/SecurityPolicy.php | 47 src/Sandbox/SourcePolicyInterface.php | 2 src/Template.php | 67 src/Test/IntegrationTestCase.php | 24 src/Token.php | 56 src/TokenParser/AbstractTokenParser.php | 29 src/TokenParser/ApplyTokenParser.php | 11 src/TokenParser/AutoEscapeTokenParser.php | 2 src/TokenParser/BlockTokenParser.php | 2 src/TokenParser/DeprecatedTokenParser.php | 7 src/TokenParser/DoTokenParser.php | 2 src/TokenParser/EmbedTokenParser.php | 2 src/TokenParser/ExtendsTokenParser.php | 2 src/TokenParser/ForTokenParser.php | 4 src/TokenParser/FromTokenParser.php | 2 src/TokenParser/GuardTokenParser.php | 10 src/TokenParser/IfTokenParser.php | 4 src/TokenParser/ImportTokenParser.php | 2 src/TokenParser/IncludeTokenParser.php | 4 src/TokenParser/MacroTokenParser.php | 4 src/TokenParser/SandboxTokenParser.php | 6 src/TokenParser/SetTokenParser.php | 18 src/TokenParser/TypesTokenParser.php | 2 src/TokenParser/UseTokenParser.php | 2 src/TokenParser/WithTokenParser.php | 2 src/TwigCallableInterface.php | 2 src/Util/CallableArgumentsExtractor.php | 4 src/Util/DeprecationCollector.php | 2 src/Util/TemplateDirIterator.php | 4 tests/Cache/ChainTest.php | 9 tests/Cache/FilesystemTest.php | 9 tests/Cache/ReadOnlyFilesystemTest.php | 9 tests/CompilerTest.php | 27 tests/ContainerRuntimeLoaderTest.php | 9 tests/CustomExtensionTest.php | 13 tests/DeprecatedCallableInfoTest.php | 13 tests/DummyBackedEnum.php | 9 tests/DummyUnitEnum.php | 9 tests/EnvironmentTest.php | 49 tests/ErrorTest.php | 211 + tests/ExpressionParserTest.php | 256 +- tests/Extension/AttributeExtensionTest.php | 172 + tests/Extension/CoreTest.php | 50 tests/Extension/EscaperTest.php | 28 tests/Extension/Fixtures/ExtensionWithAttributes.php | 136 + tests/Extension/Fixtures/FilterWithoutValue.php | 22 tests/Extension/Fixtures/TestWithoutValue.php | 22 tests/Extension/SandboxStateChangeTest.php | 428 +++ tests/Extension/SandboxTest.php | 1229 +++++++++- tests/FactoryRuntimeLoaderTest.php | 9 tests/FileExtensionEscapingStrategyTest.php | 9 tests/FilesystemHelper.php | 9 tests/Fixtures/errors/extends/include.twig | 4 tests/Fixtures/errors/extends/index.twig | 1 tests/Fixtures/errors/no_line_and_context_exception.twig | 3 tests/Fixtures/errors/no_line_and_context_exception_include_line_1.twig | 1 tests/Fixtures/errors/no_line_and_context_exception_include_line_5.twig | 5 tests/Fixtures/exceptions/strict_comparison_operator.test | 6 tests/Fixtures/exceptions/syntax_error_in_reused_template.test | 4 tests/Fixtures/expressions/array.test | 5 tests/Fixtures/expressions/array_dynamic_keys.test | 59 tests/Fixtures/expressions/dot_as_concatenation.test | 2 tests/Fixtures/expressions/postfix.test | 4 tests/Fixtures/expressions/sameas.test | 8 tests/Fixtures/expressions/set.test | 61 tests/Fixtures/expressions/spread_ternary_precedence.test | 14 tests/Fixtures/filters/arrow_reserved_names.test | 2 tests/Fixtures/filters/escape_html_attr_relaxed.test | 15 tests/Fixtures/filters/spaceless_pre_escape.legacy.test | 22 tests/Fixtures/operators/contat_vs_add_sub.legacy.test | 4 tests/Fixtures/operators/minus_vs_pipe.legacy.test | 10 tests/Fixtures/operators/not_precedence.legacy.test | 2 tests/Fixtures/operators/not_precedence.test | 2 tests/Fixtures/regression/4701-block-inheritance-issue.test | 21 tests/Fixtures/regression/markup_test.test | 18 tests/Fixtures/tags/guard/basic.test | 17 tests/Fixtures/tags/guard/throwing_handler.test | 18 tests/Fixtures/tests/null_coalesce.legacy.test | 20 tests/IntegrationTest.php | 40 tests/LexerTest.php | 69 tests/Loader/ArrayTest.php | 9 tests/Loader/ChainTest.php | 9 tests/Loader/FilesystemTest.php | 9 tests/Node/AutoEscapeTest.php | 9 tests/Node/BlockReferenceTest.php | 9 tests/Node/BlockTest.php | 12 tests/Node/DeprecatedTest.php | 12 tests/Node/DoTest.php | 9 tests/Node/EmbedTest.php | 91 tests/Node/Expression/ArrayTest.php | 9 tests/Node/Expression/Binary/AddTest.php | 9 tests/Node/Expression/Binary/AndTest.php | 9 tests/Node/Expression/Binary/ConcatTest.php | 9 tests/Node/Expression/Binary/DivTest.php | 9 tests/Node/Expression/Binary/FloorDivTest.php | 9 tests/Node/Expression/Binary/ModTest.php | 9 tests/Node/Expression/Binary/MulTest.php | 9 tests/Node/Expression/Binary/NullCoalesceTest.php | 9 tests/Node/Expression/Binary/OrTest.php | 9 tests/Node/Expression/Binary/SubTest.php | 9 tests/Node/Expression/CallTest.php | 10 tests/Node/Expression/ConditionalTest.php | 9 tests/Node/Expression/ConstantTest.php | 9 tests/Node/Expression/Filter/RawTest.php | 9 tests/Node/Expression/FilterTest.php | 29 tests/Node/Expression/FilterTestExtension.php | 9 tests/Node/Expression/FunctionTest.php | 11 tests/Node/Expression/GetAttrTest.php | 27 tests/Node/Expression/MacroReferenceTest.php | 41 tests/Node/Expression/NullCoalesceTest.php | 9 tests/Node/Expression/ParentTest.php | 9 tests/Node/Expression/Ternary/ConditionalTernaryTest.php | 9 tests/Node/Expression/TestTest.php | 11 tests/Node/Expression/Unary/NegTest.php | 9 tests/Node/Expression/Unary/NotTest.php | 9 tests/Node/Expression/Unary/PosTest.php | 9 tests/Node/Expression/Variable/AssignContextVariableTest.php | 9 tests/Node/Expression/Variable/ContextVariableTest.php | 15 tests/Node/ForTest.php | 9 tests/Node/IfTest.php | 9 tests/Node/ImportTest.php | 11 tests/Node/IncludeTest.php | 22 tests/Node/MacroTest.php | 15 tests/Node/ModuleTest.php | 48 tests/Node/NodeTest.php | 20 tests/Node/PrintTest.php | 9 tests/Node/SandboxTest.php | 9 tests/Node/SetTest.php | 15 tests/Node/TextTest.php | 9 tests/Node/TypesTest.php | 9 tests/NodeVisitor/OptimizerTest.php | 11 tests/NodeVisitor/SandboxTest.php | 72 tests/ParserTest.php | 34 tests/Profiler/Dumper/BlackfireTest.php | 12 tests/Profiler/Dumper/HtmlTest.php | 42 tests/Profiler/Dumper/ProfilerTestCase.php | 13 tests/Profiler/Dumper/TextTest.php | 12 tests/Profiler/ProfileTest.php | 36 tests/Resources/LegacyCoreTest.php | 297 ++ tests/Runtime/EscaperRuntimeTest.php | 36 tests/TemplateTest.php | 60 tests/TemplateWrapperTest.php | 9 tests/TokenParser/GuardTokenParserTest.php | 11 tests/TokenParser/TypesTokenParserTest.php | 9 tests/TokenStreamTest.php | 9 tests/Util/CallableArgumentsExtractorTest.php | 9 tests/Util/DeprecationCollectorTest.php | 9 tests/drupal_test.sh | 51 406 files changed, 12721 insertions(+), 2442 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp5_esom3i/php-twig_3.20.0-2.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp5_esom3i/php-twig_3.27.0-0+deb13u1.dsc: no acceptable signature found diff -Nru php-twig-3.20.0/.gitattributes php-twig-3.27.0/.gitattributes --- php-twig-3.20.0/.gitattributes 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/.gitattributes 2026-05-27 13:05:51.000000000 +0000 @@ -1,9 +1,11 @@ -/.github/ export-ignore +/bin/ export-ignore /doc/ export-ignore /extra/ export-ignore /tests/ export-ignore /.editorconfig export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore /.php-cs-fixer.dist.php export-ignore /phpunit.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore +/splitsh.json export-ignore diff -Nru php-twig-3.20.0/.github/workflows/ci.yml php-twig-3.27.0/.github/workflows/ci.yml --- php-twig-3.20.0/.github/workflows/ci.yml 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/.github/workflows/ci.yml 2026-05-27 13:05:51.000000000 +0000 @@ -64,6 +64,9 @@ continue-on-error: true + env: + SYMFONY_PHPUNIT_VERSION: ${{ matrix.php-version == '8.1' && '9.6' || '11.3' }} + strategy: matrix: php-version: @@ -95,16 +98,7 @@ - name: "Add PHPUnit matcher" run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: "Composer install Twig" - run: composer install - - - name: "Install PHPUnit" - run: vendor/bin/simple-phpunit install - - - name: "PHPUnit version" - run: vendor/bin/simple-phpunit --version - - - name: "Prevent installing symfony/translation-contract 3.0" + - name: "Prevent installing symfony/translation-contracts 3.0" if: "matrix.extension == 'twig-extra-bundle'" working-directory: extra/${{ matrix.extension }} run: "composer require --no-update 'symfony/translation-contracts:^1.1|^2.0'" @@ -113,6 +107,14 @@ working-directory: extra/${{ matrix.extension }} run: composer install + - name: "Install PHPUnit" + working-directory: extra/${{ matrix.extension }} + run: vendor/bin/simple-phpunit install + + - name: "PHPUnit version" + working-directory: extra/${{ matrix.extension }} + run: vendor/bin/simple-phpunit --version + - name: "Switch use_yield to true" if: "matrix.php-version == '8.2'" run: | @@ -120,38 +122,13 @@ - name: "Run tests for ${{ matrix.extension }}" working-directory: extra/${{ matrix.extension }} - run: ../../vendor/bin/simple-phpunit - - integration-tests: - needs: - - 'tests' - - name: "Integration tests with PHP ${{ matrix.php-version }}" - - runs-on: 'ubuntu-latest' - - continue-on-error: true - - strategy: - matrix: - php-version: - - '8.2' - - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - - name: "Install PHP with extensions" - uses: shivammathur/setup-php@v2 - with: - coverage: "none" - extensions: "gd, pdo_sqlite, uuid" - php-version: ${{ matrix.php-version }} - ini-values: memory_limit=-1 - tools: composer:v2 - - - run: bash ./tests/drupal_test.sh - shell: "bash" + env: + # intl-extra's integration tests expect en_US-formatted ICU output. The Ubuntu + # runner default (C.UTF-8) makes ICU fall back to the "C" locale, producing e.g. + # "€ 1000000.00" instead of "€1,000,000.00". phpunit.xml.dist's entry is + # too late because ICU caches the default locale at the first call. + LANG: 'en_US.UTF-8' + run: vendor/bin/simple-phpunit phpstan: name: "PHPStan" diff -Nru php-twig-3.20.0/.github/workflows/fabbot.yml php-twig-3.27.0/.github/workflows/fabbot.yml --- php-twig-3.20.0/.github/workflows/fabbot.yml 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/.github/workflows/fabbot.yml 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,14 @@ +name: CS + +on: + pull_request: + +permissions: + contents: read + +jobs: + call-fabbot: + name: Fabbot + uses: symfony-tools/fabbot/.github/workflows/fabbot.yml@main + with: + package: Twig diff -Nru php-twig-3.20.0/.php-cs-fixer.dist.php php-twig-3.27.0/.php-cs-fixer.dist.php --- php-twig-3.20.0/.php-cs-fixer.dist.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/.php-cs-fixer.dist.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,21 +1,45 @@ setRules([ '@Symfony' => true, '@Symfony:risky' => true, - '@PHPUnit75Migration:risky' => true, + '@PHPUnit7x5Migration:risky' => true, 'php_unit_dedicate_assert' => ['target' => '5.6'], 'array_syntax' => ['syntax' => 'short'], 'php_unit_fqcn_annotation' => true, 'no_unreachable_default_argument_value' => false, - 'braces' => ['allow_single_line_closure' => true], 'heredoc_to_nowdoc' => false, + 'single_line_throw' => false, 'ordered_imports' => true, 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], - // TODO: Remove once the "compiler_optimized" set includes "sprintf" - 'native_function_invocation' => ['include' => ['@compiler_optimized', 'sprintf'], 'scope' => 'all'], - ]) + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], + ]) + ->setRuleCustomisationPolicy(new class implements PhpCsFixer\Config\RuleCustomisationPolicyInterface { + public function getPolicyVersionForCache(): string + { + return hash_file('xxh128', __FILE__); + } + + public function getRuleCustomisers(): array + { + return [ + 'void_return' => static function (SplFileInfo $file) { + // temporary hack due to bug: https://github.com/symfony/symfony/issues/62734 + if (!$file instanceof Symfony\Component\Finder\SplFileInfo) { + return false; + } + + return !str_contains($file->getRelativePathname(), '/tests/'); + }, + ]; + } + }) ->setRiskyAllowed(true) - ->setFinder((new PhpCsFixer\Finder())->in(__DIR__)) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setFinder((new Finder())->in(__DIR__)) ; diff -Nru php-twig-3.20.0/CHANGELOG php-twig-3.27.0/CHANGELOG --- php-twig-3.20.0/CHANGELOG 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/CHANGELOG 2026-05-27 13:05:51.000000000 +0000 @@ -1,3 +1,106 @@ +# 3.27.0 (2026-05-27) + + * Add a strict mode to `Twig\Sandbox\SecurityPolicy` to opt-in to the 4.0 behavior for the `extends`/`use` tags and the `parent`/`block`/`attribute` functions, which are otherwise still implicitly allowed in a sandbox + * Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template + * Fix sandbox filter/tag/function allow-list bypass when the sandbox state changed between renders of a cached `Template` instance + * Fix PHP 8.1+ implicit float-to-int deprecation triggered by sandboxed `ArrayAccess` attribute access with a float key + * Restrict allowed classes in `Twig\Profiler\Profile::unserialize()` to prevent arbitrary class instantiation + * Escape root profile name in `HtmlDumper` + * Fix sandbox bypass in deprecated internal wrappers `twig_array_some()`, `twig_array_every()`, and `twig_check_arrow_in_sandbox()` (`src/Resources/core.php`) + * Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement + * Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface` + * Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`) + * Fix sandbox `__toString` bypass via the `in` and `not in` operators + * Prevent a stack overflow in `SandboxExtension::ensureToStringAllowed()` when a self-referencing iterable is passed to a sandboxed template + * Add support for any expression as a dynamic mapping key (attribute access, filters, ...) + * Fix sandbox `__toString` policy bypass via dynamic mapping keys + +# 3.26.0 (2026-05-20) + + * Document that the sandbox doesn't protect against resource exhaustion + * Document `template_from_string` caveats when used in a sandboxed environment + * Add docs on `Markup` about the goal of this class in the context of a sandbox + * Pre-escape HTML input on the `spaceless` filter + * Pre-escape HTML input on `inline_css` and `inky_to_html` filters + * Fix XSS by adjusting `is_safe` annotation on HTML-emitting filters + * [Profiler] Escape template and profile names in `HtmlDumper` + * Fix unbounded memoisation of `IntlDateFormatter` / `NumberFormatter` + * Fix sandbox bypass in the "column" filter + * Fix sandbox bypass in the `{% sandbox %}` tag when including a preloaded template + * Fix sandbox bypass: PHP code injection via `{% use %}` template name + * Fix sandbox bypass: PHP code injection via `_self` / import macro reference + * Fix sandbox bypass in object destructuring assignment + * Fix sandbox bypass: propagate `Source` to `checkArrow` for source-policy sandboxing + * Encode single quotes as `\x27` in `Compiler::string()` as a defense-in-depth measure + * Fix sandbox `__toString` bypasses + * Add `Twig\Node\CoercesChildrenToStringInterface` to let nodes declare which of their child nodes will be string-coerced at runtime so the sandbox wraps them with a `__toString` check + +# 3.25.0 (2026-05-17) + + * Add a `needs_is_sandboxed` option for filters, functions, and tests + * Use deterministic suffixes for generated embed classes + * Lazy-load `EscaperRuntime` in `EscaperExtension` + +# 3.24.0 (2026-03-17) + + * Deprecate not implementing the `getOperatorTokens()` method in `ExpressionParserInterface` implementations + * Deprecate passing a non-`AbstractExpression` node to `Twig\Node\Expression\Binary\MatchesBinary` constructor + * Deprecate passing a non-`AbstractExpression` node to `Parser::setParent()` + * Add support for renaming variables in object destructuring (`{name: userName} = user`) + * Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names + * Add support for short-circuiting in null-safe operator chains + * Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters + +# 3.23.0 (2026-01-23) + + * Add `=` assignment operator (allows to set variables in expression or to replace the short-form of the set tag) + * Add sequence, mapping, and object destructuring + * Add `?.` null-safe operator + * Add `===` and `!==` operators (equivalent to the `same as` and `not same as` tests) + * Fix opcache preload warning for unlinked anonymous class + * Fix spread operator behavior + +# 3.22.2 (2025-12-14) + + * Fix "cycle" with non-countable ArrayAccess + Traversable objects + * Use "getShareDir" as an indicator of Symfony version in Symfony bundle + * Fix escaper compatibility with PHP 8.5 + +# 3.22.1 (2025-11-16) + + * Add support for Symfony 8 + +# 3.22.0 (2025-10-29) + + * Add support for two words test in guard tag + * Add `Environment::registerUndefinedTestCallback()` + * Fix compatibility with Symfony 8 + * Fix accessing arrays with stringable objects as key + * Avoid errors when failing to guess the template info for an error + * Fix expression parser compatibility layer + * Fix compiling 'index' with repr (not string) in EmbedNode + * Update configuration keys + allow extra keys for CommonMark extensions + * Allow usage of other Markdown converters than CommonMark in LeagueMarkdown + +# 3.21.1 (2025-05-03) + + * Fix ExtensionSet usage of BinaryOperatorExpressionParser + +# 3.21.0 (2025-05-02) + + * Fix wrong array index + * Deprecate `Template::loadTemplate()` + * Fix testing and expression when it evaluates to an instance of `Markup` + * Add `ReturnPrimitiveTypeInterface` (and sub-interfaces for number, boolean, string, and array) + * Add `SupportDefinedTestInterface` for expression nodes supporting the `defined` test + * Deprecate using the `|` operator in an expression with `+` or `-` without using parentheses to clarify precedence + * Deprecate operator precedence outside of the [0, 512] range + * Introduce expression parser classes to describe operators and operands provided by extensions + instead of arrays (it comes with many deprecations that are documented in + the ``deprecated`` documentation chapter) + * Deprecate the `Twig\ExpressionParser`, and `Twig\OperatorPrecedenceChange` classes + * Add attributes `AsTwigFilter`, `AsTwigFunction`, and `AsTwigTest` to ease extension development + # 3.20.0 (2025-02-13) * Fix support for ignoring syntax errors in an undefined handler in guard @@ -25,6 +128,7 @@ # 3.18.0 (2024-12-29) + * Support for invoking closures * Fix unary operator precedence change * Ignore `SyntaxError` exceptions from undefined handlers when using the `guard` tag * Add a way to stream template rendering (`TemplateWrapper::stream()` and `TemplateWrapper::streamBlock()`) @@ -33,7 +137,6 @@ * Fix the null coalescing operator when the test returns null * Fix the Elvis operator when used as '? :' instead of '?:' - * Support for invoking closures # 3.17.0 (2024-12-10) diff -Nru php-twig-3.20.0/bin/generate_operators_precedence.php php-twig-3.27.0/bin/generate_operators_precedence.php --- php-twig-3.20.0/bin/generate_operators_precedence.php 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/bin/generate_operators_precedence.php 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,109 @@ +getExpressionParsers() as $expressionParser) { + if (!$seen->offsetExists($expressionParser)) { + $expressionParsers[] = $expressionParser; + $seen->offsetSet($expressionParser, true); + $descriptionLength = max($descriptionLength, $expressionParser instanceof ExpressionParserDescriptionInterface ? strlen($expressionParser->getDescription()) : ''); + } +} + +fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); +fwrite($output, '| Precedence | Operator | Type | Associativity | Description'.str_repeat(' ', $descriptionLength - 11)." |\n"); +fwrite($output, '+============+==================+=========+===============+'.str_repeat('=', $descriptionLength + 2).'+'); + +usort($expressionParsers, static fn ($a, $b) => $b->getPrecedence() <=> $a->getPrecedence()); + +$previous = null; +foreach ($expressionParsers as $expressionParser) { + if (null !== $previous) { + fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2).'+'); + } + $precedence = $expressionParser->getPrecedence(); + $previousPrecedence = $previous ? $previous->getPrecedence() : \PHP_INT_MAX; + $associativity = $expressionParser instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $expressionParser->getAssociativity() ? 'Left' : 'Right') : 'n/a'; + $previousAssociativity = $previous ? ($previous instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $previous->getAssociativity() ? 'Left' : 'Right') : 'n/a') : 'n/a'; + if ($previousPrecedence !== $precedence) { + $previous = null; + } + $operatorName = '``'.$expressionParser->getName().'``'; + if ($expressionParser->getAliases()) { + $operatorName .= ', ``'.implode('``, ``', $expressionParser->getAliases()).'``'; + } + fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", + (!$previous || $previousPrecedence !== $precedence ? $precedence : '').($expressionParser->getPrecedenceChange() ? ' => '.$expressionParser->getPrecedenceChange()->getNewPrecedence() : ''), + $operatorName, + !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', + !$previous || $previousAssociativity !== $associativity ? $associativity : '', + $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', + ))); + $previous = $expressionParser; +} +fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); +fwrite($output, "\nWhen a precedence will change in 4.0, the new precedence is indicated by the arrow ``=>``.\n"); + +fwrite($output, "\nHere is the same table for Twig 4.0 with adjusted precedences:\n"); + +fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); +fwrite($output, '| Precedence | Operator | Type | Associativity | Description'.str_repeat(' ', $descriptionLength - 11)." |\n"); +fwrite($output, '+============+==================+=========+===============+'.str_repeat('=', $descriptionLength + 2).'+'); + +usort($expressionParsers, static function ($a, $b) { + $aPrecedence = $a->getPrecedenceChange() ? $a->getPrecedenceChange()->getNewPrecedence() : $a->getPrecedence(); + $bPrecedence = $b->getPrecedenceChange() ? $b->getPrecedenceChange()->getNewPrecedence() : $b->getPrecedence(); + + return $bPrecedence - $aPrecedence; +}); + +$previous = null; +foreach ($expressionParsers as $expressionParser) { + if (null !== $previous) { + fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2).'+'); + } + $precedence = $expressionParser->getPrecedenceChange() ? $expressionParser->getPrecedenceChange()->getNewPrecedence() : $expressionParser->getPrecedence(); + $previousPrecedence = $previous ? ($previous->getPrecedenceChange() ? $previous->getPrecedenceChange()->getNewPrecedence() : $previous->getPrecedence()) : \PHP_INT_MAX; + $associativity = $expressionParser instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $expressionParser->getAssociativity() ? 'Left' : 'Right') : 'n/a'; + $previousAssociativity = $previous ? ($previous instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $previous->getAssociativity() ? 'Left' : 'Right') : 'n/a') : 'n/a'; + if ($previousPrecedence !== $precedence) { + $previous = null; + } + $operatorName = '``'.$expressionParser->getName().'``'; + if ($expressionParser->getAliases()) { + $operatorName .= ', ``'.implode('``, ``', $expressionParser->getAliases()).'``'; + } + fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", + !$previous || $previousPrecedence !== $precedence ? $precedence : '', + $operatorName, + !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', + !$previous || $previousAssociativity !== $associativity ? $associativity : '', + $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', + ))); + $previous = $expressionParser; +} +fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); + +fclose($output); diff -Nru php-twig-3.20.0/composer.json php-twig-3.27.0/composer.json --- php-twig-3.20.0/composer.json 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/composer.json 2026-05-27 13:05:51.000000000 +0000 @@ -32,7 +32,8 @@ "require-dev": { "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0", "psr/container": "^1.0|^2.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0@stable", + "php-cs-fixer/shim": "^3.0@stable" }, "autoload": { "files": [ diff -Nru php-twig-3.20.0/debian/changelog php-twig-3.27.0/debian/changelog --- php-twig-3.20.0/debian/changelog 2025-02-19 06:20:19.000000000 +0000 +++ php-twig-3.27.0/debian/changelog 2026-05-27 21:21:18.000000000 +0000 @@ -1,3 +1,53 @@ +php-twig (3.27.0-0+deb13u1) trixie-security; urgency=medium + + [ Fabien Potencier ] + * Fix sandbox bypass: propagate sandbox state to checkArrow for source-policy + sandboxing [CVE-2026-24425] + * Fix sandbox `__toString` bypasses [CVE-2026-47732] + * Pre-escape HTML input on the `spaceless` filter [CVE-2026-46628] + * Document template_from_string caveats when used in a sandboxed env + [CVE-2026-46634] + * Document that the sandbox doesn't protect against resource exhaustion + [CVE-2026-46627] + * Fix sandbox bypass in deprecated internal wrappers [CVE-2026-48805] + * Fix sandbox bypass in the "column" filter under SourcePolicyInterface + [CVE-2026-48808] + * Fix sandbox __toString bypass via Traversable in join/replace filters + * Fix sandbox `__toString` bypass via the `in` and `not in` operators + [CVE-2026-48807] + * Fix sandbox __toString policy bypass via dynamic mapping keys + [CVE-2026-48806] + * Fix sandbox filter/tag/function allow-list bypass when sandbox state + changes between renders [CVE-2026-46636] + * Update CHANGELOG + * Prepare the 3.27.0 release + + [ Alexandre Daubois ] + * Fix sandbox bypass in object destructuring assignment [CVE-2026-46639] + * Fix unbounded memoisation of `IntlDateFormatter` / `NumberFormatter` + [CVE-2026-46629] + * Fix sandbox bypass: PHP code injection via {% use %} template name + [CVE-2026-46633] + * Fix sandbox bypass in the `{% sandbox %}` tag when including a preloaded + template [CVE-2026-46638] + * Fix sandbox bypass: PHP code injection via _self / import macro reference + [CVE-2026-46640] + * Fix sandbox bypass in the "column" filter [CVE-2026-46635] + + [ Nicolas Grekas ] + * Fix XSS by adjusting `is_safe` annotation on HTML-emitting filters + [CVE-2026-46637] + * Pre-escape HTML input on `inline_css` and `inky_to_html` filters + * [Profiler] Escape template and profile names in HtmlDumper [CVE-2026-47730] + + [ David Prévot ] + * Track debian/trixie branch + * Refresh patches + * Make phpab tolerant + * Update build for related path + + -- David Prévot Wed, 27 May 2026 23:21:18 +0200 + php-twig (3.20.0-2) unstable; urgency=medium * Restore autoload template building diff -Nru php-twig-3.20.0/debian/clean php-twig-3.27.0/debian/clean --- php-twig-3.20.0/debian/clean 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/clean 2026-05-27 21:16:58.000000000 +0000 @@ -4,9 +4,18 @@ debian/autoloaders/ debian/build-tmp/ debian/packages_to_build/ +DeepCopy/ +extra/*/.phpunit.cache/ extra/*/.phpunit.result.cache extra/*/autoload.php extra/twig-extra-bundle/var/ +PharIo/ +PHPUnit/ +Psr/ +SebastianBergmann/ src/autoload.php -Twig/ +src/Extra/ +staabm/ +Symfony/ +Twig vendor/ diff -Nru php-twig-3.20.0/debian/control php-twig-3.27.0/debian/control --- php-twig-3.20.0/debian/control 2025-02-19 06:20:15.000000000 +0000 +++ php-twig-3.27.0/debian/control 2026-05-27 21:14:40.000000000 +0000 @@ -30,7 +30,7 @@ re2c Homepage: https://twig.symfony.com Vcs-Browser: https://salsa.debian.org/php-team/pear/twig -Vcs-Git: https://salsa.debian.org/php-team/pear/twig.git +Vcs-Git: https://salsa.debian.org/php-team/pear/twig.git -b debian/trixie Rules-Requires-Root: no Package: php-twig diff -Nru php-twig-3.20.0/debian/gbp.conf php-twig-3.27.0/debian/gbp.conf --- php-twig-3.20.0/debian/gbp.conf 2024-11-08 08:42:18.000000000 +0000 +++ php-twig-3.27.0/debian/gbp.conf 2026-05-27 21:14:40.000000000 +0000 @@ -1,5 +1,5 @@ [DEFAULT] -debian-branch = debian/latest +debian-branch = debian/trixie pristine-tar = True filter = [ '.gitattributes' ] upstream-vcs-tag = v%(version%~%-)s diff -Nru php-twig-3.20.0/debian/patches/0002-Skip-failing-test-without-composer-install.patch php-twig-3.27.0/debian/patches/0002-Skip-failing-test-without-composer-install.patch --- php-twig-3.20.0/debian/patches/0002-Skip-failing-test-without-composer-install.patch 2025-02-15 14:52:01.000000000 +0000 +++ php-twig-3.27.0/debian/patches/0002-Skip-failing-test-without-composer-install.patch 2026-05-27 21:20:52.000000000 +0000 @@ -7,10 +7,10 @@ 1 file changed, 2 insertions(+) diff --git a/extra/twig-extra-bundle/Tests/IntegrationTest.php b/extra/twig-extra-bundle/Tests/IntegrationTest.php -index 04cbbee..c06eeee 100644 +index df62da2..7a72310 100644 --- a/extra/twig-extra-bundle/Tests/IntegrationTest.php +++ b/extra/twig-extra-bundle/Tests/IntegrationTest.php -@@ -8,6 +8,8 @@ class IntegrationTest extends KernelTestCase +@@ -17,6 +17,8 @@ class IntegrationTest extends KernelTestCase { public function testCommonMarkRendering() { diff -Nru php-twig-3.20.0/debian/patches/0004-Switch-to-static-data-provides-for-integration-tests.patch php-twig-3.27.0/debian/patches/0004-Switch-to-static-data-provides-for-integration-tests.patch --- php-twig-3.20.0/debian/patches/0004-Switch-to-static-data-provides-for-integration-tests.patch 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/patches/0004-Switch-to-static-data-provides-for-integration-tests.patch 2026-05-27 21:20:52.000000000 +0000 @@ -9,7 +9,7 @@ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Test/IntegrationTestCase.php b/src/Test/IntegrationTestCase.php -index f4a5dc7..7bc0cd2 100644 +index d995c6a..24abb8e 100644 --- a/src/Test/IntegrationTestCase.php +++ b/src/Test/IntegrationTestCase.php @@ -11,6 +11,9 @@ @@ -22,7 +22,7 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Error\Error; -@@ -114,6 +117,7 @@ abstract class IntegrationTestCase extends TestCase +@@ -122,6 +125,7 @@ abstract class IntegrationTestCase extends TestCase * * @return void */ @@ -30,7 +30,7 @@ public function testIntegration($file, $message, $condition, $templates, $exception, $outputs, $deprecation = '') { $this->doIntegrationTest($file, $message, $condition, $templates, $exception, $outputs, $deprecation); -@@ -126,17 +130,13 @@ abstract class IntegrationTestCase extends TestCase +@@ -134,17 +138,13 @@ abstract class IntegrationTestCase extends TestCase * * @return void */ @@ -50,7 +50,7 @@ { try { $fixturesDir = static::getFixturesDirectory(); -@@ -188,14 +188,14 @@ abstract class IntegrationTestCase extends TestCase +@@ -196,14 +196,14 @@ abstract class IntegrationTestCase extends TestCase return $tests; } diff -Nru php-twig-3.20.0/debian/patches/0005-PHPUnit-11.patch php-twig-3.27.0/debian/patches/0005-PHPUnit-11.patch --- php-twig-3.20.0/debian/patches/0005-PHPUnit-11.patch 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/patches/0005-PHPUnit-11.patch 2026-05-27 21:20:52.000000000 +0000 @@ -2,47 +2,50 @@ Date: Mon, 2 Sep 2024 16:34:53 +0200 Subject: PHPUnit 11 -Origin: upstream, https://github.com/twigphp/twig/commit/f2053bbea0fec2635681846c181c69ca2b22d410 +Origin: backport, https://github.com/twigphp/twig/commit/f2053bbea0fec2635681846c181c69ca2b22d410 Bug-Debian: https://bugs.debian.org/1039841 --- - .gitignore | 3 ++- - extra/cache-extra/composer.json | 3 --- - extra/cache-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/cssinliner-extra/composer.json | 3 --- - extra/cssinliner-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/html-extra/Tests/CvaTest.php | 9 +++---- - extra/html-extra/composer.json | 3 --- - extra/html-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/inky-extra/composer.json | 3 --- - extra/inky-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/intl-extra/composer.json | 3 --- - extra/intl-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/markdown-extra/Tests/FunctionalTest.php | 5 ++-- - extra/markdown-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/string-extra/composer.json | 3 --- - extra/string-extra/phpunit.xml.dist | 32 +++++++++++++++-------- - extra/twig-extra-bundle/composer.json | 1 - - extra/twig-extra-bundle/phpunit-bootstrap.php | 8 ++++++ - extra/twig-extra-bundle/phpunit.xml.dist | 32 +++++++++++++++-------- - tests/Cache/ChainTest.php | 5 ++-- + .gitignore | 3 +- + extra/cache-extra/composer.json | 3 -- + extra/cache-extra/phpunit.xml.dist | 32 +++++++++++++------ + extra/cssinliner-extra/composer.json | 3 -- + extra/cssinliner-extra/phpunit.xml.dist | 32 +++++++++++++------ + extra/html-extra/Tests/CvaTest.php | 9 ++---- + extra/html-extra/Tests/HtmlAttrMergeTest.php | 5 ++- + extra/html-extra/Tests/HtmlAttrTest.php | 5 ++- + extra/html-extra/composer.json | 3 -- + extra/html-extra/phpunit.xml.dist | 32 +++++++++++++------ + extra/inky-extra/composer.json | 3 -- + extra/inky-extra/phpunit.xml.dist | 32 +++++++++++++------ + extra/intl-extra/composer.json | 3 -- + extra/intl-extra/phpunit.xml.dist | 32 +++++++++++++------ + extra/markdown-extra/Tests/FunctionalTest.php | 5 ++- + extra/markdown-extra/phpunit.xml.dist | 32 +++++++++++++------ + extra/string-extra/composer.json | 3 -- + extra/string-extra/phpunit.xml.dist | 32 +++++++++++++------ + tests/Cache/ChainTest.php | 5 ++- tests/Cache/FilesystemTest.php | 4 +-- tests/Cache/ReadOnlyFilesystemTest.php | 4 +-- - tests/CustomExtensionTest.php | 5 ++-- - tests/ErrorTest.php | 5 ++-- - tests/ExpressionParserTest.php | 25 +++++------------- - tests/Extension/CoreTest.php | 37 ++++++++------------------- - tests/Extension/SandboxTest.php | 13 +++------- - tests/FileExtensionEscapingStrategyTest.php | 5 ++-- - tests/LexerTest.php | 13 +++------- - tests/Loader/FilesystemTest.php | 13 +++------- - tests/Node/NodeTest.php | 17 ++++-------- - tests/NodeVisitor/OptimizerTest.php | 5 ++-- - tests/ParserTest.php | 13 +++------- - tests/Runtime/EscaperRuntimeTest.php | 9 +++---- - tests/TemplateTest.php | 29 ++++++--------------- - tests/TokenParser/TypesTokenParserTest.php | 3 ++- - 36 files changed, 256 insertions(+), 249 deletions(-) - create mode 100644 extra/twig-extra-bundle/phpunit-bootstrap.php + tests/CustomExtensionTest.php | 2 ++ + tests/DeprecatedCallableInfoTest.php | 4 +-- + tests/ErrorTest.php | 9 ++---- + tests/ExpressionParserTest.php | 34 ++++++--------------- + tests/Extension/AttributeExtensionTest.php | 12 ++------ + tests/Extension/CoreTest.php | 34 ++++++--------------- + tests/Extension/EscaperTest.php | 6 ++-- + tests/Extension/SandboxTest.php | 44 +++++++++------------------ + tests/FileExtensionEscapingStrategyTest.php | 5 ++- + tests/LexerTest.php | 36 ++++++---------------- + tests/Loader/FilesystemTest.php | 13 +++----- + tests/Node/Expression/MacroReferenceTest.php | 4 +-- + tests/Node/NodeTest.php | 17 +++-------- + tests/NodeVisitor/OptimizerTest.php | 5 ++- + tests/ParserTest.php | 13 +++----- + tests/Runtime/EscaperRuntimeTest.php | 9 ++---- + tests/TemplateTest.php | 33 ++++++-------------- + tests/TokenParser/TypesTokenParserTest.php | 3 +- + tests/Util/CallableArgumentsExtractorTest.php | 4 +-- + 40 files changed, 259 insertions(+), 310 deletions(-) diff --git a/.gitignore b/.gitignore index b197246..a568c6d 100644 @@ -58,12 +61,12 @@ -.phpunit.result.cache +.phpunit.cache diff --git a/extra/cache-extra/composer.json b/extra/cache-extra/composer.json -index 4ae0621..38c7ffc 100644 +index 8b21310..ce285de 100644 --- a/extra/cache-extra/composer.json +++ b/extra/cache-extra/composer.json @@ -19,9 +19,6 @@ - "symfony/cache": "^5.4|^6.4|^7.0", - "twig/twig": "^3.19|^4.0" + "symfony/cache": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.21|^4.0" }, - "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" @@ -209,12 +212,58 @@ public function testAdditionalClasses(string|array $base, array|string $additionals, string $expected) { $cva = new Cva($base); +diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php +index d129a5a..3f6c71e 100644 +--- a/extra/html-extra/Tests/HtmlAttrMergeTest.php ++++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Extra\Html\Tests; + ++use PHPUnit\Framework\Attributes\DataProvider; + use PHPUnit\Framework\TestCase; + use Twig\Error\RuntimeError; + use Twig\Extra\Html\HtmlAttr\MergeableInterface; +@@ -18,9 +19,7 @@ use Twig\Extra\Html\HtmlExtension; + + class HtmlAttrMergeTest extends TestCase + { +- /** +- * @dataProvider htmlAttrProvider +- */ ++ #[DataProvider('htmlAttrProvider')] + public function testMerge(array $expected, array $inputs) + { + $result = HtmlExtension::htmlAttrMerge(...$inputs); +diff --git a/extra/html-extra/Tests/HtmlAttrTest.php b/extra/html-extra/Tests/HtmlAttrTest.php +index 41f704c..c87a688 100644 +--- a/extra/html-extra/Tests/HtmlAttrTest.php ++++ b/extra/html-extra/Tests/HtmlAttrTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Extra\Html\Tests; + ++use PHPUnit\Framework\Attributes\DataProvider; + use PHPUnit\Framework\TestCase; + use Twig\Environment; + use Twig\Error\RuntimeError; +@@ -21,9 +22,7 @@ use Twig\Loader\ArrayLoader; + + class HtmlAttrTest extends TestCase + { +- /** +- * @dataProvider htmlAttrProvider +- */ ++ #[DataProvider('htmlAttrProvider')] + public function testPrintingAttributes(string $expected, array $inputs) + { + $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ...$inputs); diff --git a/extra/html-extra/composer.json b/extra/html-extra/composer.json -index 55555a0..d31ca13 100644 +index db43e18..6cf6915 100644 --- a/extra/html-extra/composer.json +++ b/extra/html-extra/composer.json @@ -20,9 +20,6 @@ - "symfony/mime": "^5.4|^6.4|^7.0", + "symfony/mime": "^5.4|^6.4|^7.0|^8.0", "twig/twig": "^3.13|^4.0" }, - "require-dev": { @@ -330,12 +379,12 @@ + diff --git a/extra/intl-extra/composer.json b/extra/intl-extra/composer.json -index b728753..40a7235 100644 +index 21e3956..919c980 100644 --- a/extra/intl-extra/composer.json +++ b/extra/intl-extra/composer.json @@ -19,9 +19,6 @@ "twig/twig": "^3.13|^4.0", - "symfony/intl": "^5.4|^6.4|^7.0" + "symfony/intl": "^5.4|^6.4|^7.0|^8.0" }, - "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0" @@ -390,7 +439,7 @@ + diff --git a/extra/markdown-extra/Tests/FunctionalTest.php b/extra/markdown-extra/Tests/FunctionalTest.php -index 72b277e..40449b3 100644 +index 58fd729..ff7ea8b 100644 --- a/extra/markdown-extra/Tests/FunctionalTest.php +++ b/extra/markdown-extra/Tests/FunctionalTest.php @@ -11,6 +11,7 @@ @@ -459,7 +508,7 @@ + diff --git a/extra/string-extra/composer.json b/extra/string-extra/composer.json -index 6b366e1..ce60ee7 100644 +index 27a0f34..2affd7c 100644 --- a/extra/string-extra/composer.json +++ b/extra/string-extra/composer.json @@ -20,9 +20,6 @@ @@ -518,83 +567,11 @@ + + -diff --git a/extra/twig-extra-bundle/composer.json b/extra/twig-extra-bundle/composer.json -index 88ee810..b37e76e 100644 ---- a/extra/twig-extra-bundle/composer.json -+++ b/extra/twig-extra-bundle/composer.json -@@ -22,7 +22,6 @@ - }, - "require-dev": { - "league/commonmark": "^1.0|^2.0", -- "symfony/phpunit-bridge": "^6.4|^7.0", - "twig/cache-extra": "^3.0", - "twig/cssinliner-extra": "^3.0", - "twig/html-extra": "^3.0", -diff --git a/extra/twig-extra-bundle/phpunit-bootstrap.php b/extra/twig-extra-bundle/phpunit-bootstrap.php -new file mode 100644 -index 0000000..d896a46 ---- /dev/null -+++ b/extra/twig-extra-bundle/phpunit-bootstrap.php -@@ -0,0 +1,8 @@ -+ -- -- -- -- ./ -- -- -- ./Tests -- ./vendor -- -- -+ - - - -@@ -19,4 +22,13 @@ - ./Tests/ - - -+ -+ -+ ./ -+ -+ -+ ./Tests -+ ./vendor -+ -+ - diff --git a/tests/Cache/ChainTest.php b/tests/Cache/ChainTest.php -index 3120ab1..48cd1c1 100644 +index 4383e60..ad6114e 100644 --- a/tests/Cache/ChainTest.php +++ b/tests/Cache/ChainTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Cache; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Cache; * file that was distributed with this source code. */ @@ -602,7 +579,7 @@ use PHPUnit\Framework\TestCase; use Twig\Cache\ChainCache; use Twig\Cache\FilesystemCache; -@@ -203,9 +204,7 @@ class ChainTest extends TestCase +@@ -212,9 +213,7 @@ class ChainTest extends TestCase $this->assertSame(0, $this->cache->getTimestamp($this->key)); } @@ -614,10 +591,10 @@ { $cache = new ChainCache([]); diff --git a/tests/Cache/FilesystemTest.php b/tests/Cache/FilesystemTest.php -index e880562..ae4b7d0 100644 +index 85b1e97..74dd31b 100644 --- a/tests/Cache/FilesystemTest.php +++ b/tests/Cache/FilesystemTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Cache; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Cache; * file that was distributed with this source code. */ @@ -625,7 +602,7 @@ use PHPUnit\Framework\TestCase; use Twig\Cache\FilesystemCache; use Twig\Tests\FilesystemHelper; -@@ -162,9 +163,8 @@ class FilesystemTest extends TestCase +@@ -171,9 +172,8 @@ class FilesystemTest extends TestCase /** * Test file cache is tolerant towards trailing (back)slashes on the configured cache directory. @@ -637,10 +614,10 @@ { $cache = new FilesystemCache($input); diff --git a/tests/Cache/ReadOnlyFilesystemTest.php b/tests/Cache/ReadOnlyFilesystemTest.php -index 34bc14f..aa2eeab 100644 +index d67276b..9e77f43 100644 --- a/tests/Cache/ReadOnlyFilesystemTest.php +++ b/tests/Cache/ReadOnlyFilesystemTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Cache; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Cache; * file that was distributed with this source code. */ @@ -648,7 +625,7 @@ use PHPUnit\Framework\TestCase; use Twig\Cache\ReadOnlyFilesystemCache; use Twig\Tests\FilesystemHelper; -@@ -100,9 +101,8 @@ class ReadOnlyFilesystemTest extends TestCase +@@ -109,9 +110,8 @@ class ReadOnlyFilesystemTest extends TestCase /** * Test file cache is tolerant towards trailing (back)slashes on the configured cache directory. @@ -660,10 +637,10 @@ { $cache = new ReadOnlyFilesystemCache($input); diff --git a/tests/CustomExtensionTest.php b/tests/CustomExtensionTest.php -index 40be3b3..a38fe75 100644 +index 9f1fbd3..ba2b3a0 100644 --- a/tests/CustomExtensionTest.php +++ b/tests/CustomExtensionTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -671,30 +648,42 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Extension\ExtensionInterface; -@@ -18,9 +19,7 @@ use Twig\Loader\ArrayLoader; +@@ -32,6 +33,7 @@ class CustomExtensionTest extends TestCase + * + * @dataProvider provideInvalidExtensions + */ ++ #[DataProvider('provideInvalidExtensions')] + public function testGetInvalidOperators(ExtensionInterface $extension, $expectedExceptionMessage) + { + $env = new Environment(new ArrayLoader()); +diff --git a/tests/DeprecatedCallableInfoTest.php b/tests/DeprecatedCallableInfoTest.php +index 166eb9b..4a24827 100644 +--- a/tests/DeprecatedCallableInfoTest.php ++++ b/tests/DeprecatedCallableInfoTest.php +@@ -25,9 +25,7 @@ use Twig\DeprecatedCallableInfo; - class CustomExtensionTest extends TestCase + class DeprecatedCallableInfoTest extends TestCase { - /** -- * @dataProvider provideInvalidExtensions +- * @dataProvider provideTestsForTriggerDeprecation - */ -+ #[DataProvider('provideInvalidExtensions')] - public function testGetInvalidOperators(ExtensionInterface $extension, $expectedExceptionMessage) ++ #[DataProvider('provideTestsForTriggerDeprecation')] + public function testTriggerDeprecation($expected, DeprecatedCallableInfo $info) { - $env = new Environment(new ArrayLoader()); + $info->setType('function'); diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php -index b7da2d5..b21081b 100644 +index 1dc145f..37d8fca 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; - use Twig\Environment; - use Twig\Error\Error; -@@ -124,9 +125,7 @@ EOHTML, + use Twig\Attribute\YieldReady; + use Twig\Compiler; +@@ -141,9 +142,7 @@ EOHTML, } } @@ -705,11 +694,22 @@ public function testTwigExceptionAddsFileAndLine($templates, $name, $line) { $loader = new ArrayLoader($templates); +@@ -269,9 +268,7 @@ EOHTML, + } + } + +- /** +- * @dataProvider getErrorWithoutLineAndContextData +- */ ++ #[DataProvider('getErrorWithoutLineAndContextData')] + public function testErrorWithoutLineAndContext(LoaderInterface $loader, bool $debug, bool $addDebugInfo, bool $exceptionWithLineAndContext, int $errorLine) + { + $twig = new Environment($loader, ['debug' => $debug, 'cache' => false]); diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php -index d3887e9..b49d9e9 100644 +index 1bd32ac..56a6a81 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -717,7 +717,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Attribute\FirstClassTwigCallableReady; -@@ -38,9 +39,7 @@ class ExpressionParserTest extends TestCase +@@ -53,9 +54,7 @@ class ExpressionParserTest extends TestCase { use ExpectDeprecationTrait; @@ -728,7 +728,7 @@ public function testCanOnlyAssignToNames($template) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); -@@ -68,9 +67,7 @@ class ExpressionParserTest extends TestCase +@@ -83,9 +82,7 @@ class ExpressionParserTest extends TestCase ]; } @@ -739,7 +739,7 @@ public function testSequenceExpression($template, $expected) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); -@@ -81,9 +78,7 @@ class ExpressionParserTest extends TestCase +@@ -96,9 +93,7 @@ class ExpressionParserTest extends TestCase $this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode('0')->getNode('expr')); } @@ -750,18 +750,37 @@ public function testSequenceSyntaxError($template) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); -@@ -220,9 +215,7 @@ class ExpressionParserTest extends TestCase - $parser->parse($stream); +@@ -247,6 +242,7 @@ class ExpressionParserTest extends TestCase + /** + * @dataProvider getTestsForString + */ ++ #[DataProvider('getTestsForString')] + public function testStringExpression($template, $expected) + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]); +@@ -301,9 +297,7 @@ class ExpressionParserTest extends TestCase + ]; } - /** -- * @dataProvider getTestsForString +- * @dataProvider getTestsForNullSafeOperator - */ -+ #[DataProvider('getTestsForString')] - public function testStringExpression($template, $expected) ++ #[DataProvider('getTestsForNullSafeOperator')] + public function testNullSafeOperator($template, $data, $expected) { - $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]); -@@ -288,9 +281,7 @@ class ExpressionParserTest extends TestCase + $env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]); +@@ -373,9 +367,7 @@ class ExpressionParserTest extends TestCase + ]; + } + +- /** +- * @dataProvider getTestForInvalidNullSafeOperatorShortCircuiting +- */ ++ #[DataProvider('getTestForInvalidNullSafeOperatorShortCircuiting')] + public function testInvalidNullSafeOperatorShortCircuiting(string $template, array $data, string $expectedMessage) + { + $env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]); +@@ -411,9 +403,7 @@ class ExpressionParserTest extends TestCase $parser->parse($env->tokenize(new Source('{% macro foo("a") %}{% endmacro %}', 'index'))); } @@ -772,7 +791,7 @@ public function testMacroDefinitionDoesNotSupportNonConstantDefaultValues($template) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); -@@ -310,9 +301,7 @@ class ExpressionParserTest extends TestCase +@@ -433,9 +423,7 @@ class ExpressionParserTest extends TestCase ]; } @@ -783,30 +802,75 @@ public function testMacroDefinitionSupportsConstantDefaultValues($template) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); +@@ -713,9 +701,7 @@ class ExpressionParserTest extends TestCase + $this->expectNotToPerformAssertions(); + } + +- /** +- * @dataProvider getBindingPowerTests +- */ ++ #[DataProvider('getBindingPowerTests')] + public function testBindingPower(string $expression, string $expectedExpression, mixed $expectedResult, array $context = []) + { + $env = new Environment(new ArrayLoader([ +diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php +index 41b215c..6e24f56 100644 +--- a/tests/Extension/AttributeExtensionTest.php ++++ b/tests/Extension/AttributeExtensionTest.php +@@ -25,9 +25,7 @@ use Twig\TwigTest; + + class AttributeExtensionTest extends TestCase + { +- /** +- * @dataProvider provideFilters +- */ ++ #[DataProvider('provideFilters')] + public function testFilter(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); +@@ -54,9 +52,7 @@ class AttributeExtensionTest extends TestCase + yield 'pattern' => ['pattern_*_filter', 'patternFilter', []]; + } + +- /** +- * @dataProvider provideFunctions +- */ ++ #[DataProvider('provideFunctions')] + public function testFunction(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); +@@ -83,9 +79,7 @@ class AttributeExtensionTest extends TestCase + yield 'deprecated' => ['deprecated_function', 'deprecatedFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + } + +- /** +- * @dataProvider provideTests +- */ ++ #[DataProvider('provideTests')] + public function testTest(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php -index bbea3d5..4e83a17 100644 +index 2828d29..6ffadec 100644 --- a/tests/Extension/CoreTest.php +++ b/tests/Extension/CoreTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Extension; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Extension; * file that was distributed with this source code. */ +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; + use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Environment; - use Twig\Error\RuntimeError; -@@ -22,9 +23,7 @@ use Twig\Sandbox\SecurityPolicy; - - class CoreTest extends TestCase - { -- /** -- * @dataProvider provideCycleCases -- */ +@@ -37,6 +38,7 @@ class CoreTest extends TestCase + /** + * @dataProvider provideCycleCases + */ + #[DataProvider('provideCycleCases')] public function testCycleFunction($values, $position, $expected) { $this->assertSame($expected, CoreExtension::cycle($values, $position)); -@@ -45,9 +44,7 @@ class CoreTest extends TestCase +@@ -57,9 +59,7 @@ class CoreTest extends TestCase ]; } @@ -817,7 +881,7 @@ public function testCycleFunctionThrowRuntimeError($values, mixed $position = null) { $this->expectException(RuntimeError::class); -@@ -63,9 +60,7 @@ class CoreTest extends TestCase +@@ -75,9 +75,7 @@ class CoreTest extends TestCase ]; } @@ -828,7 +892,7 @@ public function testRandomFunction(array $expectedInArray, $value1, $value2 = null) { for ($i = 0; $i < 100; ++$i) { -@@ -163,9 +158,7 @@ class CoreTest extends TestCase +@@ -175,9 +173,7 @@ class CoreTest extends TestCase $this->assertEquals($output, 'éÄ'); } @@ -839,7 +903,7 @@ public function testTwigFirst($expected, $input) { $this->assertSame($expected, CoreExtension::first('UTF-8', $input)); -@@ -184,9 +177,7 @@ class CoreTest extends TestCase +@@ -196,9 +192,7 @@ class CoreTest extends TestCase ]; } @@ -850,7 +914,7 @@ public function testTwigLast($expected, $input) { $this->assertSame($expected, CoreExtension::last('UTF-8', $input)); -@@ -205,9 +196,7 @@ class CoreTest extends TestCase +@@ -217,9 +211,7 @@ class CoreTest extends TestCase ]; } @@ -861,7 +925,7 @@ public function testArrayKeysFilter(array $expected, $input) { $this->assertSame($expected, CoreExtension::keys($input)); -@@ -228,9 +217,7 @@ class CoreTest extends TestCase +@@ -240,9 +232,7 @@ class CoreTest extends TestCase ]; } @@ -872,7 +936,7 @@ public function testInFilter($expected, $value, $compare) { $this->assertSame($expected, CoreExtension::inFilter($value, $compare)); -@@ -256,9 +243,7 @@ class CoreTest extends TestCase +@@ -268,9 +258,7 @@ class CoreTest extends TestCase ]; } @@ -883,7 +947,7 @@ public function testSliceFilter($expected, $input, $start, $length = null, $preserveKeys = false) { $this->assertSame($expected, CoreExtension::slice('UTF-8', $input, $start, $length, $preserveKeys)); -@@ -285,9 +270,7 @@ class CoreTest extends TestCase +@@ -297,9 +285,7 @@ class CoreTest extends TestCase ]; } @@ -894,11 +958,39 @@ public function testCompare($expected, $a, $b) { $this->assertSame($expected, CoreExtension::compare($a, $b)); +diff --git a/tests/Extension/EscaperTest.php b/tests/Extension/EscaperTest.php +index 09aa4ca..8f0a835 100644 +--- a/tests/Extension/EscaperTest.php ++++ b/tests/Extension/EscaperTest.php +@@ -30,10 +30,9 @@ use Twig\RuntimeLoader\FactoryRuntimeLoader; + class EscaperTest extends TestCase + { + /** +- * @dataProvider provideCustomEscaperCases +- * + * @group legacy + */ ++ #[DataProvider('provideCustomEscaperCases')] + public function testCustomEscaper($expected, $string, $strategy) + { + $twig = new Environment(new ArrayLoader()); +@@ -52,10 +51,9 @@ class EscaperTest extends TestCase + } + + /** +- * @dataProvider provideCustomEscaperCases +- * + * @group legacy + */ ++ #[DataProvider('provideCustomEscaperCases')] + public function testCustomEscaperWithoutCallingSetEscaperRuntime($expected, $string, $strategy) + { + $twig = new Environment(new ArrayLoader()); diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php -index 3fb1f65..fd655f7 100644 +index ddb6ce7..9515f88 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Extension; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Extension; * file that was distributed with this source code. */ @@ -906,7 +998,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Environment; -@@ -76,9 +77,7 @@ class SandboxTest extends TestCase +@@ -96,9 +97,7 @@ class SandboxTest extends TestCase ]; } @@ -917,7 +1009,75 @@ public function testSandboxForCoreTags(string $tag, string $template) { $twig = $this->getEnvironment(true, [], self::$templates, []); -@@ -279,9 +278,7 @@ class SandboxTest extends TestCase +@@ -133,10 +132,9 @@ class SandboxTest extends TestCase + } + + /** +- * @dataProvider getSandboxedForExtendsAndUseTagsTests +- * + * @group legacy + */ ++ #[DataProvider('getSandboxedForExtendsAndUseTagsTests')] + public function testSandboxForExtendsAndUseTags(string $tag, string $template) + { + $this->expectDeprecation(\sprintf('Since twig/twig 3.12: The "%s" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).', $tag)); +@@ -152,10 +150,9 @@ class SandboxTest extends TestCase + } + + /** +- * @dataProvider getSandboxedForParserCallableFunctionsTests +- * + * @group legacy + */ ++ #[DataProvider('getSandboxedForParserCallableFunctionsTests')] + public function testSandboxForParserCallableFunctions(string $function, string $templateName, array $extraTemplates, array $allowedTags, array $allowedMethods, array $allowedProperties, array $context, string $expected) + { + $this->expectDeprecation(\sprintf('Since twig/twig 3.27: The "%s" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).', $function)); +@@ -213,9 +210,7 @@ class SandboxTest extends TestCase + ]; + } + +- /** +- * @dataProvider getAllowedParserCallableFunctionsTests +- */ ++ #[DataProvider('getAllowedParserCallableFunctionsTests')] + public function testSandboxWithAllowedParserCallableFunctions(string $templateName, array $extraTemplates, array $allowedTags, array $allowedMethods, array $allowedProperties, array $allowedFunctions, array $context, string $expected) + { + $twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], $allowedMethods, $allowedProperties, $allowedFunctions); +@@ -252,9 +247,7 @@ class SandboxTest extends TestCase + ]; + } + +- /** +- * @dataProvider getStrictSandboxRejectsGrandfatheredTagsTests +- */ ++ #[DataProvider('getStrictSandboxRejectsGrandfatheredTagsTests')] + public function testStrictSandboxRejectsGrandfatheredTags(string $tag, string $template) + { + $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], [], null, true); +@@ -271,9 +264,7 @@ class SandboxTest extends TestCase + yield ['use', '{% use "1_empty" %}']; + } + +- /** +- * @dataProvider getStrictSandboxRejectsGrandfatheredFunctionsTests +- */ ++ #[DataProvider('getStrictSandboxRejectsGrandfatheredFunctionsTests')] + public function testStrictSandboxRejectsGrandfatheredFunctions(string $function, string $templateName, array $extraTemplates, array $allowedTags, array $context) + { + $twig = $this->getEnvironment(true, [], $extraTemplates, $allowedTags, [], [], [], [], null, true); +@@ -388,9 +379,7 @@ class SandboxTest extends TestCase + } + } + +- /** +- * @dataProvider provideNonStringArrayAccessKeys +- */ ++ #[DataProvider('provideNonStringArrayAccessKeys')] + public function testSandboxNonStringKeyAccessDoesNotTriggerImplicitConversionDeprecation(string $template, string $expectedKey) + { + $loader = new ArrayLoader(['t' => $template]); +@@ -550,9 +539,7 @@ class SandboxTest extends TestCase } } @@ -927,23 +1087,61 @@ + #[DataProvider('getSandboxUnallowedToStringTests')] public function testSandboxUnallowedToString($template) { - $twig = $this->getEnvironment(true, [], ['index' => $template], [], ['upper', 'join', 'replace'], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject'], [], ['random']); -@@ -324,9 +321,7 @@ class SandboxTest extends TestCase - ]; + $twig = $this->getEnvironment(true, [], ['index' => $template], ['if', 'do', 'for', 'set'], ['upper', 'join', 'replace', 'format', 'split'], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject'], [], ['random', 'range', 'my_func']); +@@ -766,6 +753,7 @@ class SandboxTest extends TestCase + /** + * @dataProvider getSandboxAllowedToStringTests + */ ++ #[DataProvider('getSandboxAllowedToStringTests')] + public function testSandboxAllowedToString($template, $output) + { + $twig = $this->getEnvironment(true, [], ['index' => $template], ['set', 'do'], [], ['Twig\Tests\Extension\FooObject' => ['foo', 'getAnotherFooObject']]); +@@ -1190,9 +1178,7 @@ EOF + } } - /** -- * @dataProvider getSandboxAllowedToStringTests +- * @dataProvider getStringableTraversableBypassTemplates - */ -+ #[DataProvider('getSandboxAllowedToStringTests')] - public function testSandboxAllowedToString($template, $output) ++ #[DataProvider('getStringableTraversableBypassTemplates')] + public function testSandboxBlocksToStringInStringableTraversable(string $template) { - $twig = $this->getEnvironment(true, [], ['index' => $template], ['set'], [], ['Twig\Tests\Extension\FooObject' => ['foo', 'getAnotherFooObject']]); + $twig = $this->getEnvironment( +@@ -1221,9 +1207,8 @@ EOF + + /** + * @group legacy +- * +- * @dataProvider getStringableTraversableBypassTemplates + */ ++ #[DataProvider('getStringableTraversableBypassTemplates')] + public function testSourcePolicySandboxBlocksToStringInStringableTraversable(string $template) + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); +@@ -1276,9 +1261,7 @@ EOF + $this->assertSame('stringable-traversable', $twig->load('index')->render($params)); + } + +- /** +- * @dataProvider getCyclicTraversableTemplates +- */ ++ #[DataProvider('getCyclicTraversableTemplates')] + public function testSandboxHandlesCyclicTraversableWithoutStackOverflow(string $template) + { + // A self-referencing IteratorAggregate must not cause the sandbox policy +@@ -1527,6 +1510,7 @@ EOF + * + * @dataProvider provideSourcePolicyArrowBlockedTemplates + */ ++ #[DataProvider('provideSourcePolicyArrowBlockedTemplates')] + public function testSourcePolicyBlocksNonClosureCallableInArrow(string $template) + { + $this->expectDeprecation('Since twig/twig 3.27.0: The "Twig\Sandbox\SourcePolicyInterface" interface is deprecated with no replacement, do not pass an instance to "Twig\Extension\SandboxExtension".'); diff --git a/tests/FileExtensionEscapingStrategyTest.php b/tests/FileExtensionEscapingStrategyTest.php -index ed5ce06..411f9da 100644 +index 8b05810..5fa835c 100644 --- a/tests/FileExtensionEscapingStrategyTest.php +++ b/tests/FileExtensionEscapingStrategyTest.php -@@ -11,14 +11,13 @@ namespace Twig\Tests; +@@ -20,14 +20,13 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -961,10 +1159,10 @@ { $this->assertSame($strategy, FileExtensionEscapingStrategy::guess($filename)); diff --git a/tests/LexerTest.php b/tests/LexerTest.php -index 806b655..7ae9985 100644 +index 04f0770..a6962a4 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -972,7 +1170,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Environment; -@@ -181,9 +182,7 @@ class LexerTest extends TestCase +@@ -180,9 +181,7 @@ class LexerTest extends TestCase $this->assertEquals('922337203685477580700', $node->getValue()); } @@ -983,7 +1181,18 @@ public function testStringWithEscapedDelimiter(string $template, string $expected) { $lexer = new Lexer(new Environment(new ArrayLoader())); -@@ -484,9 +483,7 @@ bar +@@ -256,9 +255,8 @@ class LexerTest extends TestCase + + /** + * @group legacy +- * +- * @dataProvider getStringWithEscapedDelimiterProducingDeprecation + */ ++ #[DataProvider('getStringWithEscapedDelimiterProducingDeprecation')] + public function testStringWithEscapedDelimiterProducingDeprecation(string $template, string $expected, string $expectedDeprecation) + { + $this->expectDeprecation($expectedDeprecation); +@@ -533,9 +531,7 @@ bar $this->addToAssertionCount(1); } @@ -994,7 +1203,7 @@ public function testErrorsAtTheEndOfTheStream(string $template) { $lexer = new Lexer(new Environment(new ArrayLoader())); -@@ -507,9 +504,7 @@ bar +@@ -556,9 +552,7 @@ bar yield ['{{ ..']; } @@ -1005,11 +1214,66 @@ public function testStrings(string $expected) { $template = '{{ "'.$expected.'" }}'; +@@ -594,9 +588,7 @@ bar + $this->assertTrue($stream->isEOF()); + } + +- /** +- * @dataProvider getTemplateForInlineCommentsForVariable +- */ ++ #[DataProvider('getTemplateForInlineCommentsForVariable')] + public function testInlineCommentForVariable(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); +@@ -627,9 +619,7 @@ bar + }}']; + } + +- /** +- * @dataProvider getTemplateForInlineCommentsForBlock +- */ ++ #[DataProvider('getTemplateForInlineCommentsForBlock')] + public function testInlineCommentForBlock(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); +@@ -665,9 +655,7 @@ bar + %}me{% endif %}']; + } + +- /** +- * @dataProvider getTemplateForInlineCommentsForComment +- */ ++ #[DataProvider('getTemplateForInlineCommentsForComment')] + public function testInlineCommentForComment(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); +@@ -682,9 +670,7 @@ bar + #}']; + } + +- /** +- * @dataProvider getTemplateForUnclosedBracketInExpression +- */ ++ #[DataProvider('getTemplateForUnclosedBracketInExpression')] + public function testUnclosedBracketInExpression(string $template, string $bracket) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); +@@ -703,9 +689,7 @@ bar + yield ['{{ (([1]) + 3 }}', '(']; + } + +- /** +- * @dataProvider getTemplateForUnexpectedBracketInExpression +- */ ++ #[DataProvider('getTemplateForUnexpectedBracketInExpression')] + public function testUnexpectedBracketInExpression(string $template, string $bracket) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); diff --git a/tests/Loader/FilesystemTest.php b/tests/Loader/FilesystemTest.php -index c7315ea..62b0645 100644 +index f4c0b97..945072a 100644 --- a/tests/Loader/FilesystemTest.php +++ b/tests/Loader/FilesystemTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Loader; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Loader; * file that was distributed with this source code. */ @@ -1017,7 +1281,7 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Error\LoaderError; -@@ -26,9 +27,7 @@ class FilesystemTest extends TestCase +@@ -35,9 +36,7 @@ class FilesystemTest extends TestCase $this->assertEquals(realpath($path.'/errors/index.html'), realpath($loader->getSourceContext('errors/index.html')->getPath())); } @@ -1028,7 +1292,7 @@ public function testSecurity($template) { $loader = new FilesystemLoader([__DIR__.'/../Fixtures']); -@@ -71,9 +70,7 @@ class FilesystemTest extends TestCase +@@ -80,9 +79,7 @@ class FilesystemTest extends TestCase ]; } @@ -1039,7 +1303,7 @@ public function testPaths($basePath, $cacheKey, $rootPath) { $loader = new FilesystemLoader([$basePath.'/normal', $basePath.'/normal_bis'], $rootPath); -@@ -206,9 +203,7 @@ class FilesystemTest extends TestCase +@@ -215,9 +212,7 @@ class FilesystemTest extends TestCase ]; } @@ -1050,11 +1314,26 @@ public function testArrayInheritance(string $templateName) { $loader = new FilesystemLoader([]); +diff --git a/tests/Node/Expression/MacroReferenceTest.php b/tests/Node/Expression/MacroReferenceTest.php +index 817542c..5301774 100644 +--- a/tests/Node/Expression/MacroReferenceTest.php ++++ b/tests/Node/Expression/MacroReferenceTest.php +@@ -18,9 +18,7 @@ use Twig\Node\Expression\Variable\TemplateVariable; + + class MacroReferenceTest extends TestCase + { +- /** +- * @dataProvider provideInvalidMacroNames +- */ ++ #[DataProvider('provideInvalidMacroNames')] + public function testConstructorRejectsNonIdentifierName(string $name) + { + $this->expectException(\LogicException::class); diff --git a/tests/Node/NodeTest.php b/tests/Node/NodeTest.php -index 13ad334..a4d3f4a 100644 +index a71d936..b3b7aef 100644 --- a/tests/Node/NodeTest.php +++ b/tests/Node/NodeTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Node; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Node; * file that was distributed with this source code. */ @@ -1062,7 +1341,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Node\NameDeprecation; -@@ -75,9 +76,7 @@ EOF +@@ -81,9 +82,7 @@ EOF, (string) $node); $this->assertFalse($node->getAttribute('foo', false)); } @@ -1073,7 +1352,7 @@ public function testAttributeDeprecationWithoutAlternative() { $node = new NodeForTest([], ['foo' => false]); -@@ -87,9 +86,7 @@ EOF +@@ -93,9 +92,7 @@ EOF, (string) $node); $this->assertFalse($node->getAttribute('foo')); } @@ -1084,7 +1363,7 @@ public function testAttributeDeprecationWithAlternative() { $node = new NodeForTest([], ['foo' => false]); -@@ -107,9 +104,7 @@ EOF +@@ -113,9 +110,7 @@ EOF, (string) $node); $this->assertSame($foo, $node->getNode('foo', false)); } @@ -1095,7 +1374,7 @@ public function testNodeDeprecationWithoutAlternative() { $node = new NodeForTest(['foo' => $foo = new NodeForTest()]); -@@ -119,9 +114,7 @@ EOF +@@ -125,9 +120,7 @@ EOF, (string) $node); $this->assertSame($foo, $node->getNode('foo')); } @@ -1107,10 +1386,10 @@ { $node = new NodeForTest(['foo' => $foo = new NodeForTest()]); diff --git a/tests/NodeVisitor/OptimizerTest.php b/tests/NodeVisitor/OptimizerTest.php -index 5964b7b..a7ebfd7 100644 +index b333f56..c62efdf 100644 --- a/tests/NodeVisitor/OptimizerTest.php +++ b/tests/NodeVisitor/OptimizerTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\NodeVisitor; +@@ -20,6 +20,7 @@ namespace Twig\Tests\NodeVisitor; * file that was distributed with this source code. */ @@ -1118,7 +1397,7 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Loader\ArrayLoader; -@@ -78,9 +79,7 @@ class OptimizerTest extends TestCase +@@ -87,9 +88,7 @@ class OptimizerTest extends TestCase } } @@ -1130,10 +1409,10 @@ { $env = new Environment(new ArrayLoader(), ['cache' => false]); diff --git a/tests/ParserTest.php b/tests/ParserTest.php -index 1b222d0..5483791 100644 +index 37c468e..d96a587 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -1141,7 +1420,7 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Error\SyntaxError; -@@ -61,9 +62,7 @@ class ParserTest extends TestCase +@@ -71,9 +72,7 @@ class ParserTest extends TestCase $parser->parse($stream); } @@ -1152,7 +1431,7 @@ public function testFilterBodyNodes($input, $expected) { $parser = $this->getParser(); -@@ -91,9 +90,7 @@ class ParserTest extends TestCase +@@ -100,9 +99,7 @@ class ParserTest extends TestCase ]; } @@ -1163,7 +1442,7 @@ public function testFilterBodyNodesThrowsException($input) { $parser = $this->getParser(); -@@ -113,9 +110,7 @@ class ParserTest extends TestCase +@@ -121,9 +118,7 @@ class ParserTest extends TestCase ]; } @@ -1175,10 +1454,10 @@ { $parser = $this->getParser(); diff --git a/tests/Runtime/EscaperRuntimeTest.php b/tests/Runtime/EscaperRuntimeTest.php -index 706c007..86f2e2a 100644 +index 7d02249..5553825 100644 --- a/tests/Runtime/EscaperRuntimeTest.php +++ b/tests/Runtime/EscaperRuntimeTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Runtime; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Runtime; * file that was distributed with this source code. */ @@ -1186,7 +1465,7 @@ use PHPUnit\Framework\TestCase; use Twig\Error\RuntimeError; use Twig\Runtime\EscaperRuntime; -@@ -347,9 +348,7 @@ class EscaperRuntimeTest extends TestCase +@@ -383,9 +384,7 @@ class EscaperRuntimeTest extends TestCase (new EscaperRuntime())->escape('foo', 'bar'); } @@ -1197,7 +1476,7 @@ public function testCustomEscaper($expected, $string, $strategy, $charset) { $escaper = new EscaperRuntime(); -@@ -366,9 +365,7 @@ class EscaperRuntimeTest extends TestCase +@@ -402,9 +401,7 @@ class EscaperRuntimeTest extends TestCase ]; } @@ -1209,10 +1488,10 @@ { $obj = new Extension_TestClass(); diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php -index eb0e2db..afe2882 100644 +index 7102db9..612d70b 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -1220,7 +1499,7 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Error\RuntimeError; -@@ -34,9 +35,7 @@ class TemplateTest extends TestCase +@@ -43,9 +44,7 @@ class TemplateTest extends TestCase $template->displayBlock('foo', [], ['foo' => [new \stdClass(), 'foo']]); } @@ -1231,7 +1510,7 @@ public function testGetAttributeExceptions($template, $message) { $templates = ['index' => $template]; -@@ -84,9 +83,7 @@ class TemplateTest extends TestCase +@@ -93,9 +92,7 @@ class TemplateTest extends TestCase ]; } @@ -1242,7 +1521,7 @@ public function testGetAttributeWithSandbox($object, $item, $allowed) { $twig = new Environment(new ArrayLoader()); -@@ -123,9 +120,7 @@ class TemplateTest extends TestCase +@@ -132,9 +129,7 @@ class TemplateTest extends TestCase ]; } @@ -1253,7 +1532,18 @@ public function testRenderTemplateWithoutOutput(string $template) { $twig = new Environment(new ArrayLoader(['index' => $template])); -@@ -206,9 +201,7 @@ class TemplateTest extends TestCase +@@ -150,9 +145,7 @@ class TemplateTest extends TestCase + ]; + } + +- /** +- * @dataProvider getNullCoalesceWithImportedMacroData +- */ ++ #[DataProvider('getNullCoalesceWithImportedMacroData')] + public function testNullCoalesceWithImportedMacro(array $templates, string $expected) + { + $twig = new Environment(new ArrayLoader($templates)); +@@ -252,9 +245,7 @@ class TemplateTest extends TestCase $this->assertSame('EmptyString', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, null), 'null is treated as "" when accessing a sequence/mapping (equals PHP behavior)'); } @@ -1264,7 +1554,7 @@ public function testGetAttribute($defined, $value, $object, $item, $arguments, $type) { $twig = new Environment(new ArrayLoader()); -@@ -217,9 +210,7 @@ class TemplateTest extends TestCase +@@ -263,9 +254,7 @@ class TemplateTest extends TestCase $this->assertEquals($value, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); } @@ -1275,7 +1565,7 @@ public function testGetAttributeStrict($defined, $value, $object, $item, $arguments, $type, $exceptionMessage = null) { $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); -@@ -236,9 +227,7 @@ class TemplateTest extends TestCase +@@ -282,9 +271,7 @@ class TemplateTest extends TestCase } } @@ -1286,7 +1576,7 @@ public function testGetAttributeDefined($defined, $value, $object, $item, $arguments, $type) { $twig = new Environment(new ArrayLoader()); -@@ -247,9 +236,7 @@ class TemplateTest extends TestCase +@@ -293,9 +280,7 @@ class TemplateTest extends TestCase $this->assertEquals($defined, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); } @@ -1298,10 +1588,10 @@ { $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); diff --git a/tests/TokenParser/TypesTokenParserTest.php b/tests/TokenParser/TypesTokenParserTest.php -index 0acbb4d..428b059 100644 +index 49e0ac0..c285368 100644 --- a/tests/TokenParser/TypesTokenParserTest.php +++ b/tests/TokenParser/TypesTokenParserTest.php -@@ -2,6 +2,7 @@ +@@ -11,6 +11,7 @@ namespace Twig\Tests\TokenParser; @@ -1309,7 +1599,7 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Loader\ArrayLoader; -@@ -10,7 +11,7 @@ use Twig\Source; +@@ -19,7 +20,7 @@ use Twig\Source; class TypesTokenParserTest extends TestCase { @@ -1318,3 +1608,18 @@ public function testMappingParsing(string $template, array $expected): void { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); +diff --git a/tests/Util/CallableArgumentsExtractorTest.php b/tests/Util/CallableArgumentsExtractorTest.php +index f97bf57..aba5546 100644 +--- a/tests/Util/CallableArgumentsExtractorTest.php ++++ b/tests/Util/CallableArgumentsExtractorTest.php +@@ -95,9 +95,7 @@ class CallableArgumentsExtractorTest extends TestCase + $this->assertEquals(['arg1'], $this->getArguments('custom_static_function', __CLASS__.'::customStaticFunction', ['arg1' => 'arg1'])); + } + +- /** +- * @dataProvider getGetArgumentsConversionData +- */ ++ #[DataProvider('getGetArgumentsConversionData')] + public function testGetArgumentsConversion($arg1, $arg2) + { + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg1) => '';"), [$arg1 => null])); diff -Nru php-twig-3.20.0/debian/patches/0007-Twig-4-Add-PHPUnit-as-dev-dependency.patch php-twig-3.27.0/debian/patches/0007-Twig-4-Add-PHPUnit-as-dev-dependency.patch --- php-twig-3.20.0/debian/patches/0007-Twig-4-Add-PHPUnit-as-dev-dependency.patch 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/patches/0007-Twig-4-Add-PHPUnit-as-dev-dependency.patch 2026-05-27 21:20:52.000000000 +0000 @@ -4,39 +4,17 @@ Origin: backport, https://github.com/twigphp/twig/commit/c352a8d283bdf39e1b6bcc31e1a9be0bcfc82cb1 --- - composer.json | 4 ++-- - tests/DeprecatedCallableInfoTest.php | 5 ++--- - tests/LexerTest.php | 12 +++--------- - tests/Util/CallableArgumentsExtractorTest.php | 5 ++--- - 4 files changed, 9 insertions(+), 17 deletions(-) + tests/DeprecatedCallableInfoTest.php | 1 + + tests/Extension/AttributeExtensionTest.php | 1 + + tests/Node/Expression/MacroReferenceTest.php | 1 + + tests/Util/CallableArgumentsExtractorTest.php | 1 + + 4 files changed, 4 insertions(+) -diff --git a/composer.json b/composer.json -index 3662366..1962434 100644 ---- a/composer.json -+++ b/composer.json -@@ -5,7 +5,6 @@ - "keywords": ["templating"], - "homepage": "https://twig.symfony.com", - "license": "BSD-3-Clause", -- "minimum-stability": "dev", - "authors": [ - { - "name": "Fabien Potencier", -@@ -32,7 +31,8 @@ - "require-dev": { - "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0", - "psr/container": "^1.0|^2.0", -- "phpstan/phpstan": "^2.0" -+ "phpstan/phpstan": "^2.0", -+ "phpunit/phpunit": "^11.4" - }, - "autoload": { - "files": [ diff --git a/tests/DeprecatedCallableInfoTest.php b/tests/DeprecatedCallableInfoTest.php -index 454d826..f30cc2e 100644 +index 4a24827..4aa50f1 100644 --- a/tests/DeprecatedCallableInfoTest.php +++ b/tests/DeprecatedCallableInfoTest.php -@@ -11,14 +11,13 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -44,57 +22,35 @@ use PHPUnit\Framework\TestCase; use Twig\DeprecatedCallableInfo; - class DeprecatedCallableInfoTest extends TestCase - { -- /** -- * @dataProvider provideTestsForTriggerDeprecation -- */ -+ #[DataProvider('provideTestsForTriggerDeprecation')] - public function testTriggerDeprecation($expected, DeprecatedCallableInfo $info) - { - $info->setType('function'); -diff --git a/tests/LexerTest.php b/tests/LexerTest.php -index 7ae9985..4a8cb53 100644 ---- a/tests/LexerTest.php -+++ b/tests/LexerTest.php -@@ -540,9 +540,7 @@ bar - $this->assertTrue($stream->isEOF()); - } - -- /** -- * @dataProvider getTemplateForInlineCommentsForVariable -- */ -+ #[DataProvider('getTemplateForInlineCommentsForVariable')] - public function testInlineCommentForVariable(string $template) - { - $lexer = new Lexer(new Environment(new ArrayLoader())); -@@ -573,9 +571,7 @@ bar - }}']; - } - -- /** -- * @dataProvider getTemplateForInlineCommentsForBlock -- */ -+ #[DataProvider('getTemplateForInlineCommentsForBlock')] - public function testInlineCommentForBlock(string $template) - { - $lexer = new Lexer(new Environment(new ArrayLoader())); -@@ -611,9 +607,7 @@ bar - %}me{% endif %}']; - } - -- /** -- * @dataProvider getTemplateForInlineCommentsForComment -- */ -+ #[DataProvider('getTemplateForInlineCommentsForComment')] - public function testInlineCommentForComment(string $template) - { - $lexer = new Lexer(new Environment(new ArrayLoader())); +diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php +index 6e24f56..0e1a90c 100644 +--- a/tests/Extension/AttributeExtensionTest.php ++++ b/tests/Extension/AttributeExtensionTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Tests\Extension; + ++use PHPUnit\Framework\Attributes\DataProvider; + use PHPUnit\Framework\TestCase; + use Twig\DeprecatedCallableInfo; + use Twig\Error\RuntimeError; +diff --git a/tests/Node/Expression/MacroReferenceTest.php b/tests/Node/Expression/MacroReferenceTest.php +index 5301774..c007a16 100644 +--- a/tests/Node/Expression/MacroReferenceTest.php ++++ b/tests/Node/Expression/MacroReferenceTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Tests\Node\Expression; + ++use PHPUnit\Framework\Attributes\DataProvider; + use PHPUnit\Framework\TestCase; + use Twig\Node\Expression\ArrayExpression; + use Twig\Node\Expression\MacroReferenceExpression; diff --git a/tests/Util/CallableArgumentsExtractorTest.php b/tests/Util/CallableArgumentsExtractorTest.php -index a06e19a..25e91c6 100644 +index aba5546..e65bba9 100644 --- a/tests/Util/CallableArgumentsExtractorTest.php +++ b/tests/Util/CallableArgumentsExtractorTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests\Util; +@@ -20,6 +20,7 @@ namespace Twig\Tests\Util; * file that was distributed with this source code. */ @@ -102,14 +58,3 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Twig\Error\SyntaxError; -@@ -86,9 +87,7 @@ class CallableArgumentsExtractorTest extends TestCase - $this->assertEquals(['arg1'], $this->getArguments('custom_static_function', __CLASS__.'::customStaticFunction', ['arg1' => 'arg1'])); - } - -- /** -- * @dataProvider getGetArgumentsConversionData -- */ -+ #[DataProvider('getGetArgumentsConversionData')] - public function testGetArgumentsConversion($arg1, $arg2) - { - $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg1) => '';"), [$arg1 => null])); diff -Nru php-twig-3.20.0/debian/patches/0008-Compatibility-with-recent-PHPUnit-12.patch php-twig-3.27.0/debian/patches/0008-Compatibility-with-recent-PHPUnit-12.patch --- php-twig-3.20.0/debian/patches/0008-Compatibility-with-recent-PHPUnit-12.patch 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/patches/0008-Compatibility-with-recent-PHPUnit-12.patch 2026-05-27 21:20:52.000000000 +0000 @@ -3,16 +3,187 @@ Subject: Compatibility with recent PHPUnit (12) --- - tests/Extension/EscaperTest.php | 3 +++ - tests/Extension/SandboxTest.php | 1 + - tests/LexerTest.php | 3 +++ - 3 files changed, 7 insertions(+) + extra/cssinliner-extra/Tests/LegacyFunctionsTest.php | 2 ++ + extra/html-extra/Tests/LegacyFunctionsTest.php | 2 ++ + extra/inky-extra/Tests/LegacyFunctionsTest.php | 2 ++ + extra/markdown-extra/Tests/FunctionalTest.php | 4 +++- + extra/markdown-extra/Tests/LegacyFunctionsTest.php | 2 ++ + tests/EnvironmentTest.php | 7 ++----- + tests/ExpressionParserTest.php | 15 ++++++--------- + tests/Extension/EscaperTest.php | 1 + + tests/Extension/SandboxTest.php | 3 +++ + tests/Fixtures/tags/sandbox/array.legacy.test | 18 ------------------ + tests/LexerTest.php | 6 ++++++ + tests/NodeVisitor/OptimizerTest.php | 1 + + tests/TemplateTest.php | 6 +++--- + 13 files changed, 33 insertions(+), 36 deletions(-) + delete mode 100644 tests/Fixtures/tags/sandbox/array.legacy.test +diff --git a/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php b/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php +index d62e267..0b8bd7b 100644 +--- a/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php ++++ b/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Extra\CssInliner\Tests; + ++use PHPUnit\Framework\Attributes\IgnoreDeprecations; + use PHPUnit\Framework\TestCase; + use Twig\Extra\CssInliner\CssInlinerExtension; + +@@ -21,6 +22,7 @@ use function Twig\Extra\CssInliner\twig_inline_css; + */ + class LegacyFunctionsTest extends TestCase + { ++ #[IgnoreDeprecations] + public function testInlineCss() + { + $this->assertSame(CssInlinerExtension::inlineCss('

body

', 'p { color: red }'), twig_inline_css('

body

', 'p { color: red }')); +diff --git a/extra/html-extra/Tests/LegacyFunctionsTest.php b/extra/html-extra/Tests/LegacyFunctionsTest.php +index accf8af..757553c 100644 +--- a/extra/html-extra/Tests/LegacyFunctionsTest.php ++++ b/extra/html-extra/Tests/LegacyFunctionsTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Extra\Html\Tests; + ++use PHPUnit\Framework\Attributes\IgnoreDeprecations; + use PHPUnit\Framework\TestCase; + use Twig\Extra\Html\HtmlExtension; + +@@ -19,6 +20,7 @@ use Twig\Extra\Html\HtmlExtension; + */ + class LegacyFunctionsTest extends TestCase + { ++ #[IgnoreDeprecations] + public function testHtmlToMarkdown() + { + $this->assertSame(HtmlExtension::htmlClasses(['charset' => 'utf-8']), twig_html_classes(['charset' => 'utf-8'])); +diff --git a/extra/inky-extra/Tests/LegacyFunctionsTest.php b/extra/inky-extra/Tests/LegacyFunctionsTest.php +index 4810235..e83848c 100644 +--- a/extra/inky-extra/Tests/LegacyFunctionsTest.php ++++ b/extra/inky-extra/Tests/LegacyFunctionsTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Extra\Inky\Tests; + ++use PHPUnit\Framework\Attributes\IgnoreDeprecations; + use PHPUnit\Framework\TestCase; + use Twig\Extra\Inky\InkyExtension; + +@@ -21,6 +22,7 @@ use function Twig\Extra\Inky\twig_inky; + */ + class LegacyFunctionsTest extends TestCase + { ++ #[IgnoreDeprecations] + public function testInlineCss() + { + $this->assertSame(InkyExtension::inky('

Foo

'), twig_inky('

Foo

')); +diff --git a/extra/markdown-extra/Tests/FunctionalTest.php b/extra/markdown-extra/Tests/FunctionalTest.php +index ff7ea8b..438150d 100644 +--- a/extra/markdown-extra/Tests/FunctionalTest.php ++++ b/extra/markdown-extra/Tests/FunctionalTest.php +@@ -12,6 +12,7 @@ + namespace Twig\Extra\Markdown\Tests; + + use PHPUnit\Framework\Attributes\DataProvider; ++use PHPUnit\Framework\Attributes\IgnoreDeprecations; + use PHPUnit\Framework\TestCase; + use Twig\Environment; + use Twig\Extra\Markdown\DefaultMarkdown; +@@ -26,7 +27,8 @@ use Twig\RuntimeLoader\RuntimeLoaderInterface; + class FunctionalTest extends TestCase + { + #[DataProvider('getMarkdownTests')] +- public function testMarkdown(string $template, string $expected) ++ #[IgnoreDeprecations] ++ public function testMarkdown(string $template, string $expected): void + { + foreach ([LeagueMarkdown::class, ErusevMarkdown::class, /* MichelfMarkdown::class, */ DefaultMarkdown::class] as $class) { + $twig = new Environment(new ArrayLoader([ +diff --git a/extra/markdown-extra/Tests/LegacyFunctionsTest.php b/extra/markdown-extra/Tests/LegacyFunctionsTest.php +index 19a861d..c3a8708 100644 +--- a/extra/markdown-extra/Tests/LegacyFunctionsTest.php ++++ b/extra/markdown-extra/Tests/LegacyFunctionsTest.php +@@ -11,6 +11,7 @@ + + namespace Twig\Extra\Markdown\Tests; + ++use PHPUnit\Framework\Attributes\IgnoreDeprecations; + use PHPUnit\Framework\TestCase; + + use function Twig\Extra\Markdown\html_to_markdown; +@@ -22,6 +23,7 @@ use Twig\Extra\Markdown\MarkdownExtension; + */ + class LegacyFunctionsTest extends TestCase + { ++ #[IgnoreDeprecations] + public function testHtmlToMarkdown() + { + $this->assertSame(MarkdownExtension::htmlToMarkdown('

foo

'), html_to_markdown('

foo

')); +diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php +index 3c8243d..2a670f2 100644 +--- a/tests/EnvironmentTest.php ++++ b/tests/EnvironmentTest.php +@@ -460,11 +460,8 @@ class EnvironmentTest extends TestCase + $this->assertSame('dynamic', $parser->getTag()); + } + +- /** +- * @group legacy +- * +- * @requires PHP 8 +- */ ++ #[\PHPUnit\Framework\Attributes\RequiresPhp('8')] ++ #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testLegacyEchoingNode() + { + $loader = new ArrayLoader(['echo_bar' => 'A{% set v %}B{% test %}C{% endset %}D{% test %}E{{ v }}F{% set w %}{% test %}{% endset %}G{{ w }}H']); +diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php +index 56a6a81..48c5ed4 100644 +--- a/tests/ExpressionParserTest.php ++++ b/tests/ExpressionParserTest.php +@@ -591,9 +591,8 @@ class ExpressionParserTest extends TestCase + $this->expectNotToPerformAssertions(); + } + +- /** +- * @group legacy +- */ ++ #[\PHPUnit\Framework\Attributes\Group('legacy')] ++ #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function testNotReadyFunction() + { + $this->expectDeprecation('Since twig/twig 3.12: Twig node "Twig\Tests\NotReadyFunctionExpression" is not marked as ready for passing a "TwigFunction" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.'); +@@ -606,9 +605,8 @@ class ExpressionParserTest extends TestCase + $parser->parse($env->tokenize(new Source('{{ foo() }}', 'index'))); + } + +- /** +- * @group legacy +- */ ++ #[\PHPUnit\Framework\Attributes\Group('legacy')] ++ #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function testNotReadyFilter() + { + $this->expectDeprecation('Since twig/twig 3.12: Twig node "Twig\Tests\NotReadyFilterExpression" is not marked as ready for passing a "TwigFilter" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.'); +@@ -621,9 +619,8 @@ class ExpressionParserTest extends TestCase + $parser->parse($env->tokenize(new Source('{{ 1|foo }}', 'index'))); + } + +- /** +- * @group legacy +- */ ++ #[\PHPUnit\Framework\Attributes\Group('legacy')] ++ #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function testNotReadyTest() + { + $this->expectDeprecation('Since twig/twig 3.12: Twig node "Twig\Tests\NotReadyTestExpression" is not marked as ready for passing a "TwigTest" in the constructor instead of its name; please update your code and then add #[FirstClassTwigCallableReady] attribute to the constructor.'); diff --git a/tests/Extension/EscaperTest.php b/tests/Extension/EscaperTest.php -index 28df82d..7b59644 100644 +index 8f0a835..13da0e9 100644 --- a/tests/Extension/EscaperTest.php +++ b/tests/Extension/EscaperTest.php -@@ -11,6 +11,7 @@ namespace Twig\Tests; +@@ -20,6 +20,7 @@ namespace Twig\Tests; * file that was distributed with this source code. */ @@ -20,59 +191,115 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; use Twig\Extension\EscaperExtension; -@@ -24,6 +25,7 @@ class EscaperTest extends TestCase - * - * @group legacy - */ -+ #[DataProvider('provideCustomEscaperCases')] - public function testCustomEscaper($expected, $string, $strategy) - { - $twig = new Environment(new ArrayLoader()); -@@ -46,6 +48,7 @@ class EscaperTest extends TestCase - * - * @group legacy - */ -+ #[DataProvider('provideCustomEscaperCases')] - public function testCustomEscaperWithoutCallingSetEscaperRuntime($expected, $string, $strategy) - { - $twig = new Environment(new ArrayLoader()); diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php -index fd655f7..3dc75de 100644 +index 9515f88..dbaedff 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php -@@ -116,6 +116,7 @@ class SandboxTest extends TestCase - * +@@ -135,6 +135,8 @@ class SandboxTest extends TestCase * @group legacy */ -+ #[DataProvider('getSandboxedForExtendsAndUseTagsTests')] + #[DataProvider('getSandboxedForExtendsAndUseTagsTests')] ++ #[\PHPUnit\Framework\Attributes\Group('legacy')] ++ #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] public function testSandboxForExtendsAndUseTags(string $tag, string $template) { - $this->expectDeprecation(\sprintf('Since twig/twig 3.12: The "%s" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.', $tag)); + $this->expectDeprecation(\sprintf('Since twig/twig 3.12: The "%s" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).', $tag)); +@@ -438,6 +440,7 @@ class SandboxTest extends TestCase + /** + * @group legacy + */ ++ #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testIfSandBoxIsDisabledAfterSyntaxErrorLegacy() + { + $twig = $this->getEnvironment(false, [], self::$templates); +diff --git a/tests/Fixtures/tags/sandbox/array.legacy.test b/tests/Fixtures/tags/sandbox/array.legacy.test +deleted file mode 100644 +index df9d1e4..0000000 +--- a/tests/Fixtures/tags/sandbox/array.legacy.test ++++ /dev/null +@@ -1,18 +0,0 @@ +---TEST-- +-sandbox tag +---DEPRECATION-- +-Since twig/twig 3.15: The "sandbox" tag is deprecated in "index.twig" at line 2. +---TEMPLATE-- +-{%- sandbox %} +- {%- include "foo.twig" %} +-{%- endsandbox %} +---TEMPLATE(foo.twig)-- +-{{ [a][0] }} +-{{ dump([a][0]) }} +---DATA-- +-return ['a' => 'b'] +---CONFIG-- +-return ['autoescape' => false, 'debug' => true] +---EXPECT-- +-b +-string(1) "b" diff --git a/tests/LexerTest.php b/tests/LexerTest.php -index 4a8cb53..2f1a5a9 100644 +index a6962a4..8d4bef3 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php -@@ -259,6 +259,7 @@ class LexerTest extends TestCase - * - * @dataProvider getStringWithEscapedDelimiterProducingDeprecation - */ -+ #[DataProvider('getStringWithEscapedDelimiterProducingDeprecation')] - public function testStringWithEscapedDelimiterProducingDeprecation(string $template, string $expected, string $expectedDeprecation) - { - $this->expectDeprecation($expectedDeprecation); -@@ -625,6 +626,7 @@ bar - /** - * @dataProvider getTemplateForUnclosedBracketInExpression - */ -+ #[DataProvider('getTemplateForUnclosedBracketInExpression')] +@@ -670,6 +670,9 @@ bar + #}']; + } + ++ /** ++ * @dataProvider getTemplateForUnclosedBracketInExpression ++ */ + #[DataProvider('getTemplateForUnclosedBracketInExpression')] public function testUnclosedBracketInExpression(string $template, string $bracket) { - $lexer = new Lexer(new Environment(new ArrayLoader())); -@@ -646,6 +648,7 @@ bar - /** - * @dataProvider getTemplateForUnexpectedBracketInExpression - */ -+ #[DataProvider('getTemplateForUnexpectedBracketInExpression')] +@@ -689,6 +692,9 @@ bar + yield ['{{ (([1]) + 3 }}', '(']; + } + ++ /** ++ * @dataProvider getTemplateForUnexpectedBracketInExpression ++ */ + #[DataProvider('getTemplateForUnexpectedBracketInExpression')] public function testUnexpectedBracketInExpression(string $template, string $bracket) { - $lexer = new Lexer(new Environment(new ArrayLoader())); +diff --git a/tests/NodeVisitor/OptimizerTest.php b/tests/NodeVisitor/OptimizerTest.php +index c62efdf..cb22bb0 100644 +--- a/tests/NodeVisitor/OptimizerTest.php ++++ b/tests/NodeVisitor/OptimizerTest.php +@@ -65,6 +65,7 @@ class OptimizerTest extends TestCase + $this->assertTrue($node->getAttribute('output')); + } + ++ #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function testForVarOptimizer() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); +diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php +index 612d70b..c54ac6e 100644 +--- a/tests/TemplateTest.php ++++ b/tests/TemplateTest.php +@@ -246,7 +246,7 @@ class TemplateTest extends TestCase + } + + #[DataProvider('getGetAttributeTests')] +- public function testGetAttribute($defined, $value, $object, $item, $arguments, $type) ++ public function testGetAttribute($defined, $value, $object, $item, $arguments, $type, $exceptionMessage = null) + { + $twig = new Environment(new ArrayLoader()); + $template = new TemplateForTest($twig); +@@ -272,7 +272,7 @@ class TemplateTest extends TestCase + } + + #[DataProvider('getGetAttributeTests')] +- public function testGetAttributeDefined($defined, $value, $object, $item, $arguments, $type) ++ public function testGetAttributeDefined($defined, $value, $object, $item, $arguments, $type, $exceptionMessage = null) + { + $twig = new Environment(new ArrayLoader()); + $template = new TemplateForTest($twig); +@@ -281,7 +281,7 @@ class TemplateTest extends TestCase + } + + #[DataProvider('getGetAttributeTests')] +- public function testGetAttributeDefinedStrict($defined, $value, $object, $item, $arguments, $type) ++ public function testGetAttributeDefinedStrict($defined, $value, $object, $item, $arguments, $type, $exceptionMessage = null) + { + $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); + $template = new TemplateForTest($twig); diff -Nru php-twig-3.20.0/debian/patches/0009-Drop-redundant-require_once-in-tests.patch php-twig-3.27.0/debian/patches/0009-Drop-redundant-require_once-in-tests.patch --- php-twig-3.20.0/debian/patches/0009-Drop-redundant-require_once-in-tests.patch 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/debian/patches/0009-Drop-redundant-require_once-in-tests.patch 2026-05-27 21:20:52.000000000 +0000 @@ -0,0 +1,21 @@ +From: =?utf-8?q?David_Pr=C3=A9vot?= +Date: Thu, 21 May 2026 11:33:40 +0200 +Subject: Drop redundant require_once in tests + +--- + tests/Resources/LegacyCoreTest.php | 2 -- + 1 file changed, 2 deletions(-) + +diff --git a/tests/Resources/LegacyCoreTest.php b/tests/Resources/LegacyCoreTest.php +index 8eafca6..d4bd78d 100644 +--- a/tests/Resources/LegacyCoreTest.php ++++ b/tests/Resources/LegacyCoreTest.php +@@ -23,8 +23,6 @@ use Twig\Source; + use Twig\Template; + use Twig\TemplateWrapper; + +-require_once __DIR__.'/../../src/Resources/core.php'; +- + /** + * Tests that the deprecated wrappers in src/Resources/core.php still enforce + * sandbox checks. diff -Nru php-twig-3.20.0/debian/patches/series php-twig-3.27.0/debian/patches/series --- php-twig-3.20.0/debian/patches/series 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/patches/series 2026-05-27 21:20:52.000000000 +0000 @@ -6,3 +6,4 @@ 0006-Mark-getTests-fonction-as-static.patch 0007-Twig-4-Add-PHPUnit-as-dev-dependency.patch 0008-Compatibility-with-recent-PHPUnit-12.patch +0009-Drop-redundant-require_once-in-tests.patch diff -Nru php-twig-3.20.0/debian/rules php-twig-3.27.0/debian/rules --- php-twig-3.20.0/debian/rules 2025-02-19 05:49:38.000000000 +0000 +++ php-twig-3.27.0/debian/rules 2026-05-27 21:16:58.000000000 +0000 @@ -21,41 +21,44 @@ doc debian/build-tmp/php-twig-doc # php-twig phpabtpl --basedir src composer.json > debian/autoload.php.tpl + sed -i "s#'/usr/share/php/Symfony#__DIR__.'/../Symfony#" debian/autoload.php.tpl phpab \ --output src/autoload.php \ --template debian/autoload.php.tpl \ src # php-twig testsuite - mkdir --parents vendor Twig/Extra + mkdir --parents vendor phpabtpl \ --require psr/container \ --require symfony/phpunit-bridge \ --require erusev/parsedown \ --require league/commonmark \ --require league/html-to-markdown \ - --require twig/twig \ - --require-file ../extra/twig-extra-bundle/autoload.php \ - --require-file ../extra/cache-extra/autoload.php \ - --require-file ../extra/cssinliner-extra/autoload.php \ - --require-file ../extra/html-extra/autoload.php \ - --require-file ../extra/inky-extra/autoload.php \ - --require-file ../extra/intl-extra/autoload.php \ - --require-file ../extra/markdown-extra/autoload.php \ - --require-file ../extra/string-extra/autoload.php \ + --require-file ../Twig/autoload.php \ + --require-file ../Twig/Extra/TwigExtraBundle/autoload.php \ + --require-file ../Twig/Extra/Cache/autoload.php \ + --require-file ../Twig/Extra/CssInliner/autoload.php \ + --require-file ../Twig/Extra/Html/autoload.php \ + --require-file ../Twig/Extra/Inky/autoload.php \ + --require-file ../Twig/Extra/Intl/autoload.php \ + --require-file ../Twig/Extra/Markdown/autoload.php \ + --require-file ../Twig/Extra/String/autoload.php \ > debian/autoload.tests.php.tpl phpab --output vendor/autoload.php \ --template debian/autoload.tests.php.tpl \ tests extra/*/Tests # Mimic install path ln -s src Twig - ln -s ../../extra/cache-extra Twig/Extra/Cache - ln -s ../../extra/cssinliner-extra Twig/Extra/CssInliner - ln -s ../../extra/html-extra Twig/Extra/Html - ln -s ../../extra/inky-extra Twig/Extra/Inky - ln -s ../../extra/intl-extra Twig/Extra/Intl - ln -s ../../extra/markdown-extra Twig/Extra/Markdown - ln -s ../../extra/string-extra Twig/Extra/String - ln -s ../../extra/twig-extra-bundle Twig/Extra/TwigExtraBundle + mkdir --parents Twig/Extra + cp -a extra/cache-extra Twig/Extra/Cache + cp -a extra/cssinliner-extra Twig/Extra/CssInliner + cp -a extra/html-extra Twig/Extra/Html + cp -a extra/inky-extra Twig/Extra/Inky + cp -a extra/intl-extra Twig/Extra/Intl + cp -a extra/markdown-extra Twig/Extra/Markdown + cp -a extra/string-extra Twig/Extra/String + cp -a extra/twig-extra-bundle Twig/Extra/TwigExtraBundle + cp -a /usr/share/php/Symfony . # php-twig extra # Parts from the symfony package’s debian/rules. mkdir --parents debian/packages_to_build debian/autoloaders @@ -73,7 +76,7 @@ deb_pkg_name=php-$$(cat $$src_path/composer.json | jq -r '.name | tostring' | sed -r 's|/|-|'); \ if [ $$(grep -c -E -e "^Package: $$deb_pkg_name\$$" debian/control) -ne 1 ]; then \ echo "W: No Debian package '$$deb_pkg_name' defined in debian/control," 1>&2; \ - echo " therefor not considering Symfony $$src_path," 1>&2; \ + echo " therefor not considering Twig $$src_path," 1>&2; \ continue; \ fi; \ echo "# This file contains some variables sourced" > debian/packages_to_build/$$deb_pkg_name; \ @@ -82,8 +85,11 @@ echo "src_path='$$src_path'" >> debian/packages_to_build/$$deb_pkg_name; \ echo "twig $${deb_pkg_name#php-twig-} $$src_path/autoload.php" > debian/autoloaders/$$deb_pkg_name; \ phpabtpl --basedir $$src_path $$src_path/composer.json > debian/$$deb_pkg_name.autoload.php.tpl; \ + sed -i "s#'/usr/share/php/Twig#__DIR__.'/../..#" debian/$$deb_pkg_name.autoload.php.tpl; \ + sed -i "s#'/usr/share/php/Symfony#__DIR__.'/../../../Symfony#" debian/$$deb_pkg_name.autoload.php.tpl; \ phpab \ --blacklist '*\\tests\\*' \ + --tolerant \ --output $$src_path/autoload.php \ --template debian/$$deb_pkg_name.autoload.php.tpl \ $$src_path; \ @@ -91,6 +97,13 @@ done override_dh_auto_test: + # Mimic install path + cp -a /usr/share/php/DeepCopy . + cp -a /usr/share/php/PharIo . + cp -a /usr/share/php/PHPUnit . + cp -a /usr/share/php/Psr . + cp -a /usr/share/php/SebastianBergmann . + cp -a /usr/share/php/staabm . SYMFONY_DEPRECATIONS_HELPER=weak phpunit # testsuite for extra packages set -e; \ @@ -106,6 +119,7 @@ override_dh_install: dh_install --package=php-twig --package=php-twig-doc -X/Extra/ + rm -rf debian/php-twig/usr/share/php/Twig/Extra/ # -X/Extra/ is not enough # In debian/packages_to_build/ a file containing shell variables exists # for each package that should be build. Source one file after another # to make the shell variables available and run dh_install in order to diff -Nru php-twig-3.20.0/doc/_build/build.php php-twig-3.27.0/doc/_build/build.php --- php-twig-3.20.0/doc/_build/build.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/_build/build.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,6 +1,15 @@ #!/usr/bin/env php register('build-docs') ->addOption('disable-cache', null, InputOption::VALUE_NONE, 'Use this option to force a full regeneration of all doc contents') - ->setCode(function (InputInterface $input, OutputInterface $output) { + ->setCode(static function (InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); $io->text('Building all Twig docs...'); @@ -40,9 +49,9 @@ file_put_contents($htmlFilePath, str_replace('href="assets/', 'href="/assets/', $htmlContents)); } - $io->success(\sprintf('The Twig docs were successfully built at %s', realpath($outputDir))); + $io->success(sprintf('The Twig docs were successfully built at %s', realpath($outputDir))); } else { - $io->error(\sprintf("There were some errors while building the docs:\n\n%s\n", $result->getErrorTrace())); + $io->error(sprintf("There were some errors while building the docs:\n\n%s\n", $result->getErrorTrace())); $io->newLine(); $io->comment('Tip: you can add the -v, -vv or -vvv flags to this command to get debug information.'); diff -Nru php-twig-3.20.0/doc/advanced.rst php-twig-3.27.0/doc/advanced.rst --- php-twig-3.20.0/doc/advanced.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/advanced.rst 2026-05-27 13:05:51.000000000 +0000 @@ -216,6 +216,34 @@ // ... }, ['needs_context' => true, 'needs_environment' => true]); +Sandbox-aware Filters +~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 3.25 + + The ``needs_is_sandboxed`` option was added in Twig 3.25. + +If you want to know whether the current template is sandboxed in your +filter, set the ``needs_is_sandboxed`` option to ``true``; Twig will pass the +current sandbox state as a boolean to the filter call (as the first +argument, or after the charset, the environment, and the context if they +are also requested):: + + $filter = new \Twig\TwigFilter('rot13', function (bool $isSandboxed, $string) { + if ($isSandboxed) { + // adjust behavior when running in a sandboxed template + } + + return str_rot13($string); + }, ['needs_is_sandboxed' => true]); + +The sandbox state is resolved against the current template source, which +means it takes both the global sandbox state and any ``SourcePolicy`` into +account. + +The same ``needs_is_sandboxed`` option is also available on functions and +tests. + Automatic Escaping ~~~~~~~~~~~~~~~~~~ @@ -504,6 +532,7 @@ public function parse(\Twig\Token $token) { $parser = $this->parser; + $lineno = $token->getLine(); $stream = $parser->getStream(); $name = $stream->expect(\Twig\Token::NAME_TYPE)->getValue(); @@ -511,7 +540,7 @@ $value = $parser->getExpressionParser()->parseExpression(); $stream->expect(\Twig\Token::BLOCK_END_TYPE); - return new CustomSetNode($name, $value, $token->getLine()); + return new CustomSetNode($name, $value, $lineno); } public function getTag() @@ -546,6 +575,18 @@ Parsing expressions is done by calling the ``parseExpression()`` like we did for the ``set`` tag. +When encountering a syntax error during parsing, throw an exception:: + + throw new SyntaxError('Some error message.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + +For better error reporting to the user, follow these recommendations: + + * Use ``\Twig\Error\SyntaxError``; + + * **Always** pass the line number of the node and the source context; + + * End the exception message with a dot. + .. tip:: Reading the existing ``TokenParser`` classes is the best way to learn all @@ -590,7 +631,8 @@ ``\Twig\Node\ForNode`` for a usage example). * ``addDebugInfo()``: Adds the line of the original template file related to - the current node as a comment. + the current node as a comment. It's highly recommended to call this method + when implementing custom nodes. * ``indent()``: Indents the generated code (see ``\Twig\Node\BlockNode`` for a usage example). @@ -598,6 +640,10 @@ * ``outdent()``: Outdents the generated code (see ``\Twig\Node\BlockNode`` for a usage example). +For structural nodes, always call ``addDebugInfo()`` early on in the +compilation process to improve error reporting to the user in case the code +would throw an exception. + .. _creating_extensions: Creating an Extension @@ -656,11 +702,11 @@ public function getFunctions(); /** - * Returns a list of operators to add to the existing list. + * Returns a list of expression parsers to add to the existing list. * - * @return array First array of unary operators, second array of binary operators + * @return \Twig\ExpressionParser\ExpressionParserInterface[] */ - public function getOperators(); + public function getExpressionParsers(); } To keep your extension class clean and lean, inherit from the built-in @@ -775,26 +821,20 @@ Operators ~~~~~~~~~ -The ``getOperators()`` methods lets you add new operators. Here is how to add -the ``!``, ``||``, and ``&&`` operators:: +.. versionadded:: 3.21 - class CustomTwigExtension extends \Twig\Extension\AbstractExtension - { - public function getOperators() - { - return [ - [ - '!' => ['precedence' => 50, 'class' => \Twig\Node\Expression\Unary\NotUnary::class], - ], - [ - '||' => ['precedence' => 10, 'class' => \Twig\Node\Expression\Binary\OrBinary::class, 'associativity' => \Twig\ExpressionParser::OPERATOR_LEFT], - '&&' => ['precedence' => 15, 'class' => \Twig\Node\Expression\Binary\AndBinary::class, 'associativity' => \Twig\ExpressionParser::OPERATOR_LEFT], - ], - ]; - } + The ``getExpressionParsers()`` method was added in Twig 3.21. - // ... - } +.. deprecated:: 3.21 + + The ``getExpressionParsers()`` method replaces the now deprecated + ``getOperators()`` method. See the :doc:`deprecated ` page for + details on how to upgrade from ``getOperators()`` to + ``getExpressionParsers()``. + +The ``getExpressionParsers()`` method lets you add new operators. To implement +a new one, have a look at the default operators provided by +``Twig\Extension\CoreExtension``. Tests ~~~~~ @@ -813,6 +853,107 @@ // ... } +Using PHP Attributes to define Extensions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 3.21 + + The attribute classes were added in Twig 3.21. + +You can add the ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and ``#[AsTwigTest]`` +attributes to public methods of any class to define filters, functions, and tests. + +Create a class using these attributes:: + + use Twig\Attribute\AsTwigFilter; + use Twig\Attribute\AsTwigFunction; + use Twig\Attribute\AsTwigTest; + + class ProjectExtension + { + #[AsTwigFilter('rot13')] + public static function rot13(string $string): string + { + // ... + } + + #[AsTwigFunction('lipsum')] + public static function lipsum(int $count): string + { + // ... + } + + #[AsTwigTest('even')] + public static function isEven(int $number): bool + { + // ... + } + } + +Then register the ``Twig\Extension\AttributeExtension`` with the class name:: + + $twig = new \Twig\Environment($loader); + $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class)); + +If all the methods are static, you are done. The ``ProjectExtension`` class will +never be instantiated and the class attributes will be scanned only when a template +is compiled. + +Otherwise, if some methods are not static, you need to register the class as +a runtime extension using one of the runtime loaders:: + + use Twig\Attribute\AsTwigFunction; + + class ProjectExtension + { + // Inject hypothetical dependencies + public function __construct(private LipsumProvider $lipsumProvider) {} + + #[AsTwigFunction('lipsum')] + public function lipsum(int $count): string + { + return $this->lipsumProvider->lipsum($count); + } + } + + $twig = new \Twig\Environment($loader); + $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class); + $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([ + ProjectExtension::class => function () use ($lipsumProvider) { + return new ProjectExtension($lipsumProvider); + }, + ])); + +If you want to access the current environment instance in your filter or function, +add the ``Twig\Environment`` type to the first argument of the method:: + + class ProjectExtension + { + #[AsTwigFunction('lipsum')] + public function lipsum(\Twig\Environment $env, int $count): string + { + // ... + } + } + +``#[AsTwigFilter]`` and ``#[AsTwigFunction]`` support variadic arguments +automatically when applied to variadic methods:: + + class ProjectExtension + { + #[AsTwigFilter('thumbnail')] + public function thumbnail(string $file, mixed ...$options): string + { + // ... + } + } + +The attributes support other options used to configure the Twig Callables: + + * ``AsTwigFilter``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``preEscape``, ``preservesSafety``, ``deprecationInfo`` + * ``AsTwigFunction``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``deprecationInfo`` + * ``AsTwigTest``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``deprecationInfo`` + Definition vs Runtime ~~~~~~~~~~~~~~~~~~~~~ diff -Nru php-twig-3.20.0/doc/api.rst php-twig-3.27.0/doc/api.rst --- php-twig-3.20.0/doc/api.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/api.rst 2026-05-27 13:05:51.000000000 +0000 @@ -35,7 +35,7 @@ Notice that the second argument of the environment is an array of options. The ``cache`` option is a compilation cache directory, where Twig caches - the compiled templates to avoid the parsing phase for sub-sequent + the compiled templates to avoid the parsing phase for subsequent requests. It is very different from the cache you might want to add for the evaluated templates. For such a need, you can use any available PHP cache library. @@ -130,8 +130,8 @@ * ``autoescape`` *string* - Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, - ``url``, ``html_attr``, or a PHP callback that takes the template "filename" + Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, ``url``, + ``html_attr``, ``html_attr_relaxed``, or a PHP callback that takes the template "filename" and returns the escaping strategy to use -- the callback cannot be a function name to avoid collision with built-in escaping strategies); set it to ``false`` to disable auto-escaping. The ``name`` escaping strategy determines @@ -145,6 +145,17 @@ (default to ``-1`` -- all optimizations are enabled; set it to ``0`` to disable). +* ``use_yield`` *boolean* + + ``true``: forces templates to exclusively use ``yield`` instead of ``echo`` + (all extensions must be yield ready) + + ``false`` (default): allows templates to use a mix of ``yield`` and ``echo`` + calls to allow for a progressive migration. + + Switch to ``true`` when possible as this will be the only supported mode in + Twig 4.0. + Loaders ------- @@ -185,7 +196,7 @@ $loader->addPath($templateDir3); $loader->prependPath($templateDir4); -The filesystem loader also supports namespaced templates. This allows to group +The filesystem loader also supports namespaced templates. This allows you to group your templates under different namespaces which have their own template paths. When using the ``setPaths()``, ``addPath()``, and ``prependPath()`` methods, @@ -327,23 +338,23 @@ Twig comes bundled with the following extensions: -* *Twig\Extension\CoreExtension*: Defines all the core features of Twig. +* ``\Twig\Extension\CoreExtension``: Defines all the core features of Twig. -* *Twig\Extension\DebugExtension*: Defines the ``dump`` function to help debug +* ``\Twig\Extension\DebugExtension``: Defines the ``dump`` function to help debug template variables. -* *Twig\Extension\EscaperExtension*: Adds automatic output-escaping and the +* ``\Twig\Extension\EscaperExtension``: Adds automatic output-escaping and the possibility to escape/unescape blocks of code. -* *Twig\Extension\SandboxExtension*: Adds a sandbox mode to the default Twig +* ``\Twig\Extension\SandboxExtension``: Adds a sandbox mode to the default Twig environment, making it safe to evaluate untrusted code. -* *Twig\Extension\ProfilerExtension*: Enables the built-in Twig profiler. +* ``\Twig\Extension\ProfilerExtension``: Enables the built-in Twig profiler. -* *Twig\Extension\OptimizerExtension*: Optimizes the node tree before +* ``\Twig\Extension\OptimizerExtension``: Optimizes the node tree before compilation. -* *Twig\Extension\StringLoaderExtension*: Defines the ``template_from_string`` +* ``\Twig\Extension\StringLoaderExtension``: Defines the ``template_from_string`` function to allow loading templates from string in a template. The Core, Escaper, and Optimizer extensions are registered by default. diff -Nru php-twig-3.20.0/doc/coding_standards.rst php-twig-3.27.0/doc/coding_standards.rst --- php-twig-3.20.0/doc/coding_standards.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/coding_standards.rst 2026-05-27 13:05:51.000000000 +0000 @@ -11,12 +11,13 @@ standards: * Put exactly one space after the start of a delimiter (``{{``, ``{%``, - and ``{#``) and before the end of a delimiter (``}}``, ``%}``, and ``#}``): + and ``{#``) and before the end of a delimiter (``}}``, ``%}``, and ``#}``) + if the content is non empty: .. code-block:: twig {{ user }} - {# comment #} + {# comment #} {##} {% if user %}{% endif %} When using the whitespace control character, do not put any spaces between @@ -25,7 +26,7 @@ .. code-block:: twig {{- user -}} - {#- comment -#} + {#- comment -#} {#--#} {%- if user -%}{%- endif -%} * Put exactly one space before and after the following operators: diff -Nru php-twig-3.20.0/doc/deprecated.rst php-twig-3.27.0/doc/deprecated.rst --- php-twig-3.20.0/doc/deprecated.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/deprecated.rst 2026-05-27 13:05:51.000000000 +0000 @@ -72,7 +72,7 @@ deprecated. * The ``Twig\Node\Expression\NameExpression::isSimple()`` and - ``Twig\Node\Expression\NameExpression::isSpecial()`` methods are deprecated as + ``Twig\Node\Expression\NameExpression::isSpecial()`` methods are deprecated as of Twig 3.11 and will be removed in Twig 4.0. * The ``filter`` node of ``Twig\Node\Expression\FilterExpression`` is @@ -196,6 +196,27 @@ * The ``Twig\Node\Expression\ConditionalExpression`` class is deprecated as of Twig 3.17, use ``Twig\Node\Expression\Ternary\ConditionalTernary`` instead. + * The ``is_defined_test`` attribute is deprecated as of Twig 3.21, use + ``Twig\Node\Expression\SupportDefinedTestInterface`` instead. + +* Instantiating ``Twig\Node\Node`` directly is deprecated as of Twig 3.15. Use + ``EmptyNode`` or ``Nodes`` instead depending on the use case. The + ``Twig\Node\Node`` class will be abstract in Twig 4.0. + +* Not passing ``AbstractExpression`` arguments to the following ``Node`` class + constructors is deprecated as of Twig 3.15: + + * ``AbstractBinary`` + * ``AbstractUnary`` + * ``BlockReferenceExpression`` + * ``TestExpression`` + * ``DefinedTest`` + * ``FilterExpression`` + * ``RawFilter`` + * ``DefaultFilter`` + * ``InlinePrint`` + * ``NullCoalesceExpression`` + Node Visitors ------------- @@ -210,27 +231,44 @@ Parser ------ -* Passing a second argument to ``ExpressionParser::parseFilterExpressionRaw()`` - is deprecated as of Twig 3.12. - * The following methods from ``Twig\Parser`` are deprecated as of Twig 3.12: ``getBlockStack()``, ``hasBlock()``, ``getBlock()``, ``hasMacro()``, ``hasTraits()``, ``getParent()``. -* The ``Twig\ExpressionParser::parseHashExpression()`` method is deprecated, use - ``Twig\ExpressionParser::parseMappingExpression()`` instead. - -* The ``Twig\ExpressionParser::parseArrayExpression()`` method is deprecated, use - ``Twig\ExpressionParser::parseSequenceExpression()`` instead. - * Passing ``null`` to ``Twig\Parser::setParent()`` is deprecated as of Twig 3.12. -* The ``Twig\ExpressionParser::parseOnlyArguments()`` and - ``Twig\ExpressionParser::parseArguments()`` methods are deprecated, use - ``Twig\ExpressionParser::parseNamedArguments()`` instead. +* Passing a non-``AbstractExpression`` node to ``Twig\Parser::setParent()`` is + deprecated as of Twig 3.24; the method will require an ``AbstractExpression`` + instance in Twig 4.0. + +* Passing non-``AbstractExpression`` nodes to + ``Twig\Node\Expression\Binary\MatchesBinary`` constructor is deprecated as of + Twig 3.24; the constructor will require an ``AbstractExpression`` instance in Twig + 4.0. + +* The ``Twig\Parser::getExpressionParser()`` method is deprecated as of Twig + 3.21, use ``Twig\Parser::parseExpression()`` instead. + +* The ``Twig\ExpressionParser`` class is deprecated as of Twig 3.21: + + * ``parseExpression()``, use ``Parser::parseExpression()`` + * ``parsePrimaryExpression()``, use ``Parser::parseExpression()`` + * ``parseStringExpression()``, use ``Parser::parseExpression()`` + * ``parseHashExpression()``, use ``Parser::parseExpression()`` + * ``parseMappingExpression()``, use ``Parser::parseExpression()`` + * ``parseArrayExpression()``, use ``Parser::parseExpression()`` + * ``parseSequenceExpression()``, use ``Parser::parseExpression()`` + * ``parsePostfixExpression`` + * ``parseSubscriptExpression`` + * ``parseFilterExpression`` + * ``parseFilterExpressionRaw`` + * ``parseArguments()``, use ``Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()`` + * ``parseAssignmentExpression``, use ``AbstractTokenParser::parseAssignmentExpression`` + * ``parseMultitargetExpression`` + * ``parseOnlyArguments()``, use ``Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()`` -Lexer +Token ----- * Not passing a ``Source`` instance to ``Twig\TokenStream`` constructor is @@ -239,13 +277,19 @@ * The ``Token::getType()`` method is deprecated as of Twig 3.19, use ``Token::test()`` instead. +* The ``Token::ARROW_TYPE`` constant is deprecated as of Twig 3.21, the arrow + ``=>`` is now an operator (``Token::OPERATOR_TYPE``). + +* The ``Token::PUNCTUATION_TYPE`` with values ``(``, ``[``, ``|``, ``.``, + ``?``, or ``?:`` are now of the ``Token::OPERATOR_TYPE`` type. + Templates --------- +* The method ``Template::loadTemplate()`` is deprecated. * Passing ``Twig\Template`` instances to Twig public API is deprecated (like - in ``Environment::resolveTemplate()``, ``Environment::load()``, and - ``Template::loadTemplate()``); pass instances of ``Twig\TemplateWrapper`` - instead. + in ``Environment::resolveTemplate()`` and ``Environment::load()``); pass + instances of ``Twig\TemplateWrapper`` instead. Filters ------- @@ -258,7 +302,19 @@ * Having the ``extends`` and ``use`` tags allowed by default in a sandbox is deprecated as of Twig 3.12. You will need to explicitly allow them if needed - in 4.0. + in 4.0. To opt-in to the 4.0 behavior now (so the tags need to be + allow-listed or get rejected), enable strict mode on the security policy by + calling ``$policy->setStrict(true)``. + +* Having the ``parent``, ``block``, and ``attribute`` functions allowed by + default in a sandbox is deprecated as of Twig 3.27. You will need to + explicitly allow them if needed in 4.0. The same ``setStrict(true)`` toggle + on ``Twig\Sandbox\SecurityPolicy`` opts-in to the 4.0 behavior for these + functions too. + +* The ``Twig\Sandbox\SourcePolicyInterface`` interface is deprecated as of Twig + 3.27.0 with no replacement. Passing an instance to the + ``Twig\Extension\SandboxExtension`` constructor triggers a deprecation. * Deprecate the ``sandbox`` tag, use the ``sandboxed`` option of the ``include`` function instead: @@ -340,30 +396,11 @@ ``TwigFunction::getSafe()`` is deprecated as of Twig 3.16; return ``[]`` instead. -Node ----- - -* Instantiating ``Twig\Node\Node`` directly is deprecated as of Twig 3.15. Use - ``EmptyNode`` or ``Nodes`` instead depending on the use case. The - ``Twig\Node\Node`` class will be abstract in Twig 4.0. - -* Not passing ``AbstractExpression`` arguments to the following ``Node`` class - constructors is deprecated as of Twig 3.15: - - * ``AbstractBinary`` - * ``AbstractUnary`` - * ``BlockReferenceExpression`` - * ``TestExpression`` - * ``DefinedTest`` - * ``FilterExpression`` - * ``RawFilter`` - * ``DefaultFilter`` - * ``InlinePrint`` - * ``NullCoalesceExpression`` - Operators --------- +* An operator precedence must be part of the [0, 512] range as of Twig 3.21. + * The ``.`` operator allows accessing class constants as of Twig 3.15. This can be a BC break if you don't use UPPERCASE constant names. @@ -418,3 +455,55 @@ {# or #} {{ (not 1) * 2 }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* Using the ``|`` operator in an expression with ``+`` or ``-`` without explicit + parentheses to clarify precedence triggers a deprecation as of Twig 3.21 (in + Twig 4.0, ``|`` will have a higher precedence than ``+`` and ``-``). + + For example, the following expression will trigger a deprecation in Twig 3.21:: + + {{ -1|abs }} + + To avoid the deprecation, add parentheses to clarify the precedence:: + + {{ -(1|abs) }} {# this is equivalent to what Twig 3.x does without the parentheses #} + + {# or #} + + {{ (-1)|abs }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* The ``Twig\Extension\ExtensionInterface::getOperators()`` method is deprecated + as of Twig 3.21, use ``Twig\Extension\ExtensionInterface::getExpressionParsers()`` + instead: + + Before:: + + public function getOperators(): array { + return [ + 'not' => [ + 'precedence' => 10, + 'class' => NotUnary::class, + ], + ]; + } + + After:: + + public function getExpressionParsers(): array { + return [ + new UnaryOperatorExpressionParser(NotUnary::class, 'not', 10), + ]; + } + +* The ``Twig\OperatorPrecedenceChange`` class is deprecated as of Twig 3.21, + use ``Twig\ExpressionParser\PrecedenceChange`` instead. + +* Not implementing the ``getOperatorTokens()`` method in + ``Twig\ExpressionParser\ExpressionParserInterface`` implementations is + deprecated as of Twig 3.24. This method will be added to the interface in + Twig 4.0. It returns the operator token strings that the expression parser + handles (used by the Lexer and the parser registry). If your custom + expression parser extends ``Twig\ExpressionParser\AbstractExpressionParser``, + the default implementation returns ``[$this->getName(), ...$this->getAliases()]``. + Override it if your parser doesn't handle operator tokens (return ``[]``) or if + the operator tokens differ from the parser name. diff -Nru php-twig-3.20.0/doc/filters/escape.rst php-twig-3.27.0/doc/filters/escape.rst --- php-twig-3.20.0/doc/filters/escape.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/escape.rst 2026-05-27 13:05:51.000000000 +0000 @@ -39,11 +39,11 @@ The ``escape`` filter supports the following escaping strategies for HTML documents: -* ``html``: escapes a string for the **HTML body** context, +* ``html``: escapes a string for the **HTML body** context, or for HTML attributes values **inside quotes**. -* ``js``: escapes a string for the **JavaScript** context. This is intended for - use in JavaScript or JSON strings, and encodes values using backslash escape +* ``js``: escapes a string for the **JavaScript** context. This is intended for + use in JavaScript or JSON strings, and encodes values using backslash escape sequences. * ``css``: escapes a string for the **CSS** context. CSS escaping can be @@ -53,8 +53,19 @@ * ``url``: escapes a string for the **URI or parameter** contexts. This should not be used to escape an entire URI; only a subcomponent being inserted. -* ``html_attr``: escapes a string for the **HTML attribute** context, - **without quotes** around HTML attribute values. +* ``html_attr``: escapes a string when used as an **HTML attribute** name, and + also when used as the value of an HTML attribute **without quotes** + (e.g. ``data-attribute={{ some_value }}``). + +* ``html_attr_relaxed``: like ``html_attr``, but **does not** escape the ``@``, ``:``, + ``[`` and ``]`` characters. You may want to use this in combination with front-end + frameworks that use attribute names like ``v-bind:href`` or ``@click``. But, be + aware that in some processing contexts like XML, characters like the colon ``:`` + may have meaning like for XML namespace separation. + +.. versionadded:: 3.24 + + The ``html_attr_relaxed`` strategy has been added in 3.23. Note that doing contextual escaping in HTML documents is hard and choosing the right escaping strategy depends on a lot of factors. Please, read related @@ -96,23 +107,22 @@ .. tip:: - The ``html_attr`` escaping strategy can be useful when you need to - escape a **dynamic HTML attribute name**: + The ``html_attr`` escaping strategy can be useful when you need to escape a + **dynamic HTML attribute name**: .. code-block:: html+twig

- It can also be used for escaping a **dynamic HTML attribute value** - if it is not quoted, but this is **less performant**. - Instead, it is recommended to quote the HTML attribute value and use - the ``html`` escaping strategy: + It can also be used for escaping a **dynamic HTML attribute value** if it is + not quoted, but this is **less performant**. Instead, it is recommended to + quote the HTML attribute value and use the ``html`` escaping strategy: .. code-block:: html+twig

- {# is equivalent to, but is less performant #} + {# this is equivalent, but less performant #}

Custom Escapers diff -Nru php-twig-3.20.0/doc/filters/filter.rst php-twig-3.27.0/doc/filters/filter.rst --- php-twig-3.20.0/doc/filters/filter.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/filter.rst 2026-05-27 13:05:51.000000000 +0000 @@ -11,7 +11,7 @@ {{ sizes|filter(v => v > 38)|join(', ') }} {# output 40, 42 #} -Combined with the ``for`` tag, it allows to filter the items to iterate over: +Combined with the ``for`` tag, it allows you to filter the items to iterate over: .. code-block:: twig diff -Nru php-twig-3.20.0/doc/filters/format_datetime.rst php-twig-3.27.0/doc/filters/format_datetime.rst --- php-twig-3.20.0/doc/filters/format_datetime.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/format_datetime.rst 2026-05-27 13:05:51.000000000 +0000 @@ -8,6 +8,29 @@ {# Aug 7, 2019, 11:39:12 PM #} {{ '2019-08-07 23:39:12'|format_datetime() }} +.. note:: + + The ``format_datetime`` filter is part of the ``IntlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/intl-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Intl\IntlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new IntlExtension()); + + Format ------ diff -Nru php-twig-3.20.0/doc/filters/format_number.rst php-twig-3.27.0/doc/filters/format_number.rst --- php-twig-3.20.0/doc/filters/format_number.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/format_number.rst 2026-05-27 13:05:51.000000000 +0000 @@ -22,37 +22,37 @@ * ``grouping_used``: Specifies whether to use grouping separator for thousands:: {# 1,234,567.89 #} - {{ 1234567.89|format_number({grouping_used:true}, locale='en') }} + {{ 1234567.89|format_number({grouping_used:true}, locale: 'en') }} * ``decimal_always_shown``: Specifies whether to always show the decimal part, even if it's zero:: {# 123. #} - {{ 123|format_number({decimal_always_shown:true}, locale='en') }} + {{ 123|format_number({decimal_always_shown:true}, locale: 'en') }} * ``max_integer_digit``: * ``min_integer_digit``: * ``integer_digit``: Define constraints on the integer part:: {# 345.679 #} - {{ 12345.6789|format_number({max_integer_digit:3, min_integer_digit:2}, locale='en') }} + {{ 12345.6789|format_number({max_integer_digit:3, min_integer_digit:2}, locale: 'en') }} * ``max_fraction_digit``: * ``min_fraction_digit``: * ``fraction_digit``: Define constraints on the fraction part:: {# 123.46 #} - {{ 123.456789|format_number({max_fraction_digit:2, min_fraction_digit:1}, locale='en') }} + {{ 123.456789|format_number({max_fraction_digit:2, min_fraction_digit:1}, locale: 'en') }} * ``multiplier``: Multiplies the value before formatting:: {# 123,000 #} - {{ 123|format_number({multiplier:1000}, locale='en') }} + {{ 123|format_number({multiplier:1000}, locale: 'en') }} * ``grouping_size``: * ``secondary_grouping_size``: Set the size of the primary and secondary grouping separators:: {# 1,23,45,678 #} - {{ 12345678|format_number({grouping_size:3, secondary_grouping_size:2}, locale='en') }} + {{ 12345678|format_number({grouping_size:3, secondary_grouping_size:2}, locale: 'en') }} * ``rounding_mode``: * ``rounding_increment``: Control rounding behavior, here is a list of all rounding_mode available: @@ -60,14 +60,14 @@ * ``floor``: Floor rounding * ``down``: Rounding towards zero * ``up``: Rounding away from zero - * ``half_even``: Round halves to the nearest even integer - * ``half_up``: Round halves up - * ``half_down``: Round halves down + * ``halfeven``: Round halves to the nearest even integer + * ``halfup``: Round halves up + * ``halfdown``: Round halves down .. code-block:: twig {# 123.5 #} - {{ 123.456|format_number({rounding_mode:'ceiling', rounding_increment:0.05}, locale='en') }} + {{ 123.456|format_number({rounding_mode:'ceiling', rounding_increment:0.05}, locale: 'en') }} * ``format_width``: * ``padding_position``: Set width and padding for the formatted number, here is a list of all padding_position available: @@ -79,19 +79,19 @@ .. code-block:: twig {# 123 #} - {{ 123|format_number({format_width:10, padding_position:'before_suffix'}, locale='en') }} + {{ 123|format_number({format_width:10, padding_position:'before_suffix'}, locale: 'en') }} * ``significant_digits_used``: * ``min_significant_digits_used``: * ``max_significant_digits_used``: Control significant digits in formatting:: {# 123.4568 #} - {{ 123.456789|format_number({significant_digits_used:true, min_significant_digits_used:4, max_significant_digits_used:7}, locale='en') }} + {{ 123.456789|format_number({significant_digits_used:true, min_significant_digits_used:4, max_significant_digits_used:7}, locale: 'en') }} * ``lenient_parse``: If true, allows lenient parsing of the input:: {# 123 #} - {{ 123|format_number({lenient_parse:true}, locale='en') }} + {{ 123|format_number({lenient_parse:true}, locale: 'en') }} Besides plain numbers, the filter can also format numbers in various styles:: @@ -109,37 +109,37 @@ * ``decimal``:: {# 1,234.568 #} - {{ 1234.56789 | format_number(style='decimal', locale='en') }} + {{ 1234.56789 | format_number(style: 'decimal', locale: 'en') }} * ``currency``:: {# $1,234.56 #} - {{ 1234.56 | format_number(style='currency', locale='en') }} + {{ 1234.56 | format_number(style: 'currency', locale: 'en') }} * ``percent``:: {# 12% #} - {{ 0.1234 | format_number(style='percent', locale='en') }} + {{ 0.1234 | format_number(style: 'percent', locale: 'en') }} * ``scientific``:: {# 1.23456789e+3 #} - {{ 1234.56789 | format_number(style='scientific', locale='en') }} + {{ 1234.56789 | format_number(style: 'scientific', locale: 'en') }} * ``spellout``:: {# one thousand two hundred thirty-four point five six seven eight nine #} - {{ 1234.56789 | format_number(style='spellout', locale='en') }} + {{ 1234.56789 | format_number(style: 'spellout', locale: 'en') }} * ``ordinal``:: {# 1st #} - {{ 1 | format_number(style='ordinal', locale='en') }} + {{ 1 | format_number(style: 'ordinal', locale: 'en') }} * ``duration``:: {# 2:30:00 #} - {{ 9000 | format_number(style='duration', locale='en') }} + {{ 9000 | format_number(style: 'duration', locale: 'en') }} As a shortcut, you can use the ``format_*_number`` filters by replacing ``*`` with a style:: diff -Nru php-twig-3.20.0/doc/filters/html_attr_merge.rst php-twig-3.27.0/doc/filters/html_attr_merge.rst --- php-twig-3.20.0/doc/filters/html_attr_merge.rst 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/doc/filters/html_attr_merge.rst 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,141 @@ +``html_attr_merge`` +=================== + +.. _html_attr_merge: + +.. versionadded:: 3.24 + + The ``html_attr_merge`` filter was added in Twig 3.24. + +The ``html_attr_merge`` filter merges multiple mappings that represent +HTML attribute values. Such mappings contain the names of the HTML attributes +as keys, and the corresponding values represent the attributes' values. + +It is primarily designed for working with arrays that are passed to the +:ref:`html_attr` function. It closely resembles the :doc:`merge <../filters/merge>` +filter, but has different merge behavior for values that are iterables +themselves, as it will merge such values in turn. + +The filter returns a new merged array: + +.. code-block:: twig + + {% set base = {class: ['btn'], type: 'button'} %} + {% set variant = {class: ['btn-primary'], disabled: true} %} + + {% set merged = base|html_attr_merge(variant) %} + + {# merged is now: { + class: ['btn', 'btn-primary'], + type: 'button', + disabled: true + } #} + +The filter accepts multiple arrays as arguments and merges them from left to right: + +.. code-block:: twig + + {% set merged = base|html_attr_merge(variant1, variant2, variant3) %} + +A common use case is to build attribute mappings conditionally by merging multiple +parts based on conditions. To make this conditional merging more convenient, filter +arguments that are ``false``, ``null`` or empty arrays are ignored: + +.. code-block:: twig + + {% set button_attrs = { + type: 'button', + class: ['btn'] + }|html_attr_merge( + variant == 'primary' ? { class: ['btn-primary'] }, + variant == 'secondary' ? { class: ['btn-secondary'] }, + size == 'large' ? { class: ['btn-lg'] }, + size == 'small' ? { class: ['btn-sm'] }, + disabled ? { disabled: true, class: ['btn-disabled'] }, + loading ? { 'aria-busy': 'true', class: ['btn-loading'] }, + ) %} + + {# Example with variant='primary', size='large', disabled=false, loading=true: + + The false values (secondary variant, small size, disabled state) are ignored. + + button_attrs is: + { + type: 'button', + class: ['btn', 'btn-primary', 'btn-lg', 'btn-loading'], + 'aria-busy': 'true' + } + #} + +Merging Rules +------------- + +The filter follows these rules when merging attribute values: + +**Scalar values**: Later values override earlier ones. + +.. code-block:: twig + + {% set result = {id: 'old'}|html_attr_merge({id: 'new'}) %} + {# result: {id: 'new'} #} + +**Array values**: Arrays are merged like in PHP's ``array_merge`` function - numeric keys are +appended, non-numeric keys replace. + +.. code-block:: twig + + {# Numeric keys (appended): #} + {% set result = {class: ['btn']}|html_attr_merge({class: ['btn-primary']}) %} + {# result: {class: ['btn', 'btn-primary']} #} + + {# Non-numeric keys (replaced): #} + {% set result = {class: {base: 'btn', size: 'small'}}|html_attr_merge({class: {variant: 'primary', size: 'large'}}) %} + {# result: {class: {base: 'btn', size: 'large', variant: 'primary'}} #} + +.. note:: + + Remember, attribute mappings passed to or returned from this filter are regular + Twig mappings after all. If you want to completely replace an attribute value + that is an iterable with another value, you can use the :doc:`merge <../filters/merge>` + filter to do that. + +**``MergeableInterface`` implementations**: For advanced use cases, attribute values can be objects +that implement the ``MergeableInterface``. These objects can define their own, custom merge +behavior that takes precedence over the default rules. See the docblocks in that interface +for details. + +.. note:: + + The ``html_attr_merge`` filter is part of the ``HtmlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/html-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Html\HtmlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new HtmlExtension()); + +Arguments +--------- + +The filter accepts a variadic list of arguments to merge. Each argument can be: + +* A map of attributes +* ``false`` or ``null`` (ignored, useful for conditional merging) +* An empty string ``''`` (ignored, to support implicit else in ternary operators) + +.. seealso:: + + :ref:`html_attr`, + :doc:`html_attr_type` diff -Nru php-twig-3.20.0/doc/filters/html_attr_type.rst php-twig-3.27.0/doc/filters/html_attr_type.rst --- php-twig-3.20.0/doc/filters/html_attr_type.rst 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/doc/filters/html_attr_type.rst 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,123 @@ +``html_attr_type`` +================== + +.. _html_attr_type: + +.. versionadded:: 3.24 + + The ``html_attr_type`` filter was added in Twig 3.24. + +The ``html_attr_type`` filter converts arrays into specialized attribute value +objects that implement custom rendering logic. It is designed for use +with the :ref:`html_attr` function for attributes where +the attribute value follows special formatting rules. + +.. code-block:: html+twig + + + + {# Output: #} + +Available Types +--------------- + +Space-Separated Token List (``sst``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Used for attributes that expect space-separated values, like ``class`` or +``aria-labelledby``: + +.. code-block:: html+twig + + {% set classes = ['btn', 'btn-primary']|html_attr_type('sst') %} + + + + {# Output: #} + +This is the default type used when the :ref:`html_attr` function encounters an +array value (except for ``style`` attributes). + +Comma-Separated Token List (``cst``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Used for attributes that expect comma-separated values, like ``srcset`` or +``sizes``: + +.. code-block:: html+twig + + + + {# Output: #} + +Inline Style (``style``) +~~~~~~~~~~~~~~~~~~~~~~~~ + +Used for style attributes. Handles both maps (property - value pairs) and sequences (CSS declarations): + +.. code-block:: html+twig + + {# Associative array #} + {% set styles = {color: 'red', 'font-size': '14px'}|html_attr_type('style') %} + +

+ Styled content +
+ + {# Output:
Styled content
#} + + {# Numeric array #} + {% set styles = ['color: red', 'font-size: 14px']|html_attr_type('style') %} + +
+ Styled content +
+ + {# Output:
Styled content
#} + +The ``style`` type is automatically applied by the :ref:`html_attr` function when +it encounters an array value for the ``style`` attribute. + +.. note:: + + The ``html_attr_type`` filter is part of the ``HtmlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/html-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Html\HtmlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new HtmlExtension()); + +Arguments +--------- + +* ``value``: The sequence of attributes to convert +* ``type``: The attribute type. One of: + + * ``sst`` (default): Space-separated token list + * ``cst``: Comma-separated token list + * ``style``: Inline CSS styles + +.. seealso:: + + :ref:`html_attr`, + :ref:`html_attr_merge` diff -Nru php-twig-3.20.0/doc/filters/index.rst php-twig-3.27.0/doc/filters/index.rst --- php-twig-3.20.0/doc/filters/index.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/index.rst 2026-05-27 13:05:51.000000000 +0000 @@ -26,9 +26,12 @@ format_datetime format_number format_time + html_attr_merge + html_attr_type html_to_markdown inline_css inky_to_html + invoke join json_encode keys diff -Nru php-twig-3.20.0/doc/filters/inky_to_html.rst php-twig-3.27.0/doc/filters/inky_to_html.rst --- php-twig-3.20.0/doc/filters/inky_to_html.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/inky_to_html.rst 2026-05-27 13:05:51.000000000 +0000 @@ -2,7 +2,7 @@ ================ The ``inky_to_html`` filter processes an `inky email template -`_: +`_: .. code-block:: html+twig diff -Nru php-twig-3.20.0/doc/filters/number_format.rst php-twig-3.27.0/doc/filters/number_format.rst --- php-twig-3.20.0/doc/filters/number_format.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/number_format.rst 2026-05-27 13:05:51.000000000 +0000 @@ -15,15 +15,21 @@ {{ 9800.333|number_format(2, '.', ',') }} -To format negative numbers or math calculation, wrap the previous statement -with parentheses (needed because of Twig's :ref:`precedence of operators -`): +To format negative numbers, wrap the previous statement with parentheses (note +that as of Twig 3.21, not using parentheses is deprecated as the filter +operator will change precedence in Twig 4.0): .. code-block:: twig {{ -9800.333|number_format(2, '.', ',') }} {# outputs : -9 #} {{ (-9800.333)|number_format(2, '.', ',') }} {# outputs : -9,800.33 #} - {{ 1 + 0.2|number_format(2) }} {# outputs : 1.2 #} + +To format math calculation, wrap the previous statement with parentheses +(needed because of Twig's :ref:`precedence of operators -`): + +.. code-block:: twig + + {{ 1 + 0.2|number_format(2) }} {# outputs : 1.2 #} {{ (1 + 0.2)|number_format(2) }} {# outputs : 1.20 #} If no formatting options are provided then Twig will use the default formatting diff -Nru php-twig-3.20.0/doc/filters/reduce.rst php-twig-3.27.0/doc/filters/reduce.rst --- php-twig-3.20.0/doc/filters/reduce.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/reduce.rst 2026-05-27 13:05:51.000000000 +0000 @@ -10,14 +10,14 @@ {% set numbers = [1, 2, 3] %} - {{ numbers|reduce((carry, v, k) => carry + v * k) }} + {{ numbers|reduce((carry, value, key) => carry + value * key) }} {# output 8 #} The ``reduce`` filter takes an ``initial`` value as a second argument: .. code-block:: twig - {{ numbers|reduce((carry, v, k) => carry + v * k, 10) }} + {{ numbers|reduce((carry, value, key) => carry + value * key, 10) }} {# output 18 #} Note that the arrow function has access to the current context. @@ -27,9 +27,3 @@ * ``arrow``: The arrow function * ``initial``: The initial value - -.. note:: - - Internally, Twig uses the PHP `array_reduce`_ function. - -.. _`array_reduce`: https://www.php.net/array_reduce diff -Nru php-twig-3.20.0/doc/filters/replace.rst php-twig-3.27.0/doc/filters/replace.rst --- php-twig-3.20.0/doc/filters/replace.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/replace.rst 2026-05-27 13:05:51.000000000 +0000 @@ -6,6 +6,8 @@ .. code-block:: twig + {% set fruit = 'apples' %} + {{ "I like %this% and %that%."|replace({'%this%': fruit, '%that%': "oranges"}) }} {# if the "fruit" variable is set to "apples", #} {# it outputs "I like apples and oranges" #} diff -Nru php-twig-3.20.0/doc/filters/singular.rst php-twig-3.27.0/doc/filters/singular.rst --- php-twig-3.20.0/doc/filters/singular.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/singular.rst 2026-05-27 13:05:51.000000000 +0000 @@ -43,11 +43,11 @@ --------- * ``locale``: The locale of the original string (limited to languages supported by the from Symfony `inflector`_, part of the String component) -* ``all``: Whether to return all possible plurals as an array, default is ``false`` +* ``all``: Whether to return all possible singulars as an array, default is ``false`` .. note:: Internally, Twig uses the `singularize`_ method from the Symfony String component. -.. _`inflector`: -.. _`singularize`: +.. _`inflector`: https://symfony.com/doc/current/components/string.html#inflector +.. _`singularize`: https://symfony.com/doc/current/components/string.html#inflector diff -Nru php-twig-3.20.0/doc/filters/slug.rst php-twig-3.27.0/doc/filters/slug.rst --- php-twig-3.20.0/doc/filters/slug.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/slug.rst 2026-05-27 13:05:51.000000000 +0000 @@ -2,7 +2,7 @@ ======== The ``slug`` filter transforms a given string into another string that -only includes safe ASCII characters. +only includes safe ASCII characters. Here is an example: @@ -11,8 +11,8 @@ {{ 'Wôrķšƥáçè ~~sèťtïñğš~~'|slug }} Workspace-settings -The default separator between words is a dash (``-``), but you can -define a selector of your choice by passing it as an argument: +The default separator between words is a dash (``-``), but you can +define a separator of your choice by passing it as an argument: .. code-block:: twig @@ -27,8 +27,8 @@ {{ '...'|slug('-', 'ko') }} -The ``slug`` filter uses the method by the same name in Symfony's -`AsciiSlugger `_. +The ``slug`` filter uses the method by the same name in Symfony's +`AsciiSlugger `_. .. note:: diff -Nru php-twig-3.20.0/doc/filters/spaceless.rst php-twig-3.27.0/doc/filters/spaceless.rst --- php-twig-3.20.0/doc/filters/spaceless.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/spaceless.rst 2026-05-27 13:05:51.000000000 +0000 @@ -6,6 +6,16 @@ The ``spaceless`` filter is deprecated as of Twig 3.12. While not a full replacement, you can check the :ref:`whitespace control features `. +.. caution:: + + The ``spaceless`` filter is declared safe for the ``html`` context, so its + output is not auto-escaped. Its input is therefore pre-escaped by the + auto-escaper when needed. If you re-implement this filter in your own + code, declare it with both ``'pre_escape' => 'html'`` and + ``'is_safe' => ['html']``: declaring ``is_safe`` without ``pre_escape`` + is equivalent to piping user input through ``|raw`` and opens an XSS + vector. + Use the ``spaceless`` filter to remove whitespace *between HTML tags*, not whitespace within HTML tags or whitespace in plain text: diff -Nru php-twig-3.20.0/doc/filters/u.rst php-twig-3.27.0/doc/filters/u.rst --- php-twig-3.20.0/doc/filters/u.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/filters/u.rst 2026-05-27 13:05:51.000000000 +0000 @@ -31,7 +31,7 @@ {{ 'Lorem ipsum'|u.truncate(8, '...') }} Lorem... -The ``truncate`` method also accepts a third argument to preserve whole words: +By default, ``truncate`` cuts text at the exact length. Pass ``false`` as the third argument to preserve whole words: .. code-block:: twig diff -Nru php-twig-3.20.0/doc/functions/country_timezones.rst php-twig-3.27.0/doc/functions/country_timezones.rst --- php-twig-3.20.0/doc/functions/country_timezones.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/country_timezones.rst 2026-05-27 13:05:51.000000000 +0000 @@ -2,14 +2,14 @@ ===================== The ``country_timezones`` function returns the names of the timezones associated -with a given country its ISO-3166 code: +with a given country code (ISO-3166): .. code-block:: twig {# Europe/Paris #} {{ country_timezones('FR')|join(', ') }} -If the specified country were to be unknown, it will return an empty array +If the specified country is unknown, it will return an empty array. .. note:: diff -Nru php-twig-3.20.0/doc/functions/date.rst php-twig-3.27.0/doc/functions/date.rst --- php-twig-3.20.0/doc/functions/date.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/date.rst 2026-05-27 13:05:51.000000000 +0000 @@ -9,7 +9,7 @@ {# do something #} {% endif %} -The argument must be in one of PHP’s supported `date and time formats`_. +The argument must be in one of PHP's supported `date and time formats`_. You can pass a timezone as the second argument: diff -Nru php-twig-3.20.0/doc/functions/enum.rst php-twig-3.27.0/doc/functions/enum.rst --- php-twig-3.20.0/doc/functions/enum.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/enum.rst 2026-05-27 13:05:51.000000000 +0000 @@ -10,15 +10,25 @@ .. code-block:: twig {# display one specific case of a backed enum #} - {{ enum('App\\MyEnum').SomeCase.value }} + {{ enum('App\\CardSuite').Clubs.value }} {# "clubs" #} + + {# display one specific case of a backed enum, with a dynamic name #} + {% set case_name = 'Spades' %} + {{ enum('App\\CardSuite').(case_name).name }} {# "Spades" #} {# get all cases of an enum #} - {% for case in enum('App\\MyEnum').cases %} + {% for case in enum('App\\CardSuite').cases %} {{ case.value }} {% endfor %} + {# "clubs", "spades", "hearts", "diamonds" #} + + {# get a specific case of an enum by value #} + {% set card_suite = enum('App\\CardSuite').from('hearts') %} + {{ card_suite.name }} {# "Hearts" #} + {{ card_suite.value }} {# "hearts" #} {# call any methods of the enum class #} - {{ enum('App\\MyEnum').someMethod() }} + {{ enum('App\\CardSuite').someMethod() }} When using a string literal for the ``enum`` argument, it will be validated during compile time to be a valid enum name. diff -Nru php-twig-3.20.0/doc/functions/enum_cases.rst php-twig-3.27.0/doc/functions/enum_cases.rst --- php-twig-3.20.0/doc/functions/enum_cases.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/enum_cases.rst 2026-05-27 13:05:51.000000000 +0000 @@ -9,9 +9,10 @@ .. code-block:: twig - {% for case in enum_cases('App\\MyEnum') %} + {% for case in enum_cases('App\\CardSuite') %} {{ case.value }} {% endfor %} + {# "clubs", "spades", "hearts", "diamonds" #} When using a string literal for the ``enum`` argument, it will be validated during compile time to be a valid enum name. diff -Nru php-twig-3.20.0/doc/functions/html_attr.rst php-twig-3.27.0/doc/functions/html_attr.rst --- php-twig-3.20.0/doc/functions/html_attr.rst 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/doc/functions/html_attr.rst 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,202 @@ +``html_attr`` +============= + +.. _html_attr: + +.. versionadded:: 3.24 + + The ``html_attr`` function was added in Twig 3.24. + +The ``html_attr`` function renders HTML attributes from one or more mappings, +taking care of proper escaping. The mappings contain the names of HTML +attributes as keys, and the corresponding values represent the attributes' +values. + +.. note:: + + Attribute names are escaped using the ``html_attr_relaxed`` strategy. + +.. code-block:: html+twig + +
+ Content +
+ + {# Output:
Content
#} + +The function accepts multiple attribute maps. Internally, it uses +:ref:`html_attr_merge` to combine the arguments: + +.. code-block:: html+twig + + {% set base_attrs = {class: ['btn']} %} + {% set variant_attrs = {class: ['btn-primary'], disabled: true} %} + + + + {# Output: #} + +.. note:: + + To make best use of the special merge behavior of ``html_attr_merge`` and + to avoid confusion, you should consistently use iterables (mappings or sequences) + for attributes that can take multiple values, like ``class``, ``srcset`` or ``aria-describedby``. + + Use non-iterable values for attributes that contain a single value only, like + ``id`` or ``href``. + +Shorthand notation for mappings can be particularly helpful: + +.. code-block:: html+twig + + {% set id = 'user-123' %} + {% set href = '/profile' %} + + Profile + + {# Output: Profile #} + +``null`` and Boolean Attribute Values +------------------------------------- + +``null`` values always omit printing an attribute altogether. + +The boolean ``false`` value also omits the attribute altogether, with an +exception for ``aria-*`` attribute names, see below. + +.. code-block:: html+twig + + {# null omits the attribute entirely, and so does false for non-"aria-*" #} + + {# Output: #} + +``true`` will print the attribute with the empty value ``""``. This is XHTML compatible, +and in HTML 5 equivalent to using the short attribute notation without a value. An exception +is made for ``data-*`` and ``aria-*`` attributes, see below. + +.. code-block:: html+twig + + {# true becomes an empty string value #} + + {# Output: , which is equivalent to #} + +Array Values +------------ + +Attribute values that are iterables are automatically converted to space-separated +token lists of the values. Exceptions apply for ``data-*`` and ``style`` attributes, +described further below. + +.. code-block:: html+twig + +
+ Button +
+ + {# Output:
Button
#} + +.. note:: + + This is not bound to the ``class`` attribute name, but works for any attribute. + +You can use the :ref:`html_attr_type` filter to specify a different strategy for +concatenating values (e.g., comma-separated for ``srcset`` attributes). This would +also override the special behavior for ``data-*`` and ``style``. + +WAI-ARIA Attributes +------------------- + +To make it more convenient to work with the `WAI-ARIA type mapping for HTML +_`, boolean values for ``aria-*`` +attributes are converted to strings ``"true"`` and ``"false"``. + +.. code-block:: html+twig + + + + {# Output: #} + +Data Attributes +--------------- + +For ``data-*`` attributes, boolean ``true`` values will be converted to ``"true"``. +Values that are not scalars are automatically JSON-encoded. + +.. code-block:: html+twig + +
+ Content +
+ + {# Output:
Content
#} + +Style Attribute +---------------- + +The ``style`` attribute name has special handling when its value is iterable: + +.. code-block:: html+twig + + {# Non-numeric keys will be used as CSS properties and printed #} +
+ Styled text +
+ + {# Output:
Styled text
#} + + {# Numeric keys will be assumed to have values that are individual CSS declarations #} +
+ Styled text +
+ + {# Output:
Styled text
#} + + {# Merging style attributes #} +
+ Styled text +
+ + {# Output:
Styled text
#} + +.. warning:: + + No additional escaping specific to CSS is applied to key or values from this array. + Do not use it to pass untrusted, user-provided data, neither as key nor as value. + +``AttributeValueInterface`` Implementations +------------------------------------------- + +For advanced use cases, attribute values can be objects that implement the ``AttributeValueInterface``. +These objects can define their own conversion logic for the ``html_attr`` function that will take +precedence over all rules described here. See the docblocks in that interface for details. + +.. note:: + + The ``html_attr`` function is part of the ``HtmlExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/html-extra + + Then, on Symfony projects, install the ``twig/extra-bundle``: + + .. code-block:: bash + + $ composer require twig/extra-bundle + + Otherwise, add the extension explicitly on the Twig environment:: + + use Twig\Extra\Html\HtmlExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new HtmlExtension()); + +.. seealso:: + + :ref:`html_attr_merge`, + :ref:`html_attr_type` diff -Nru php-twig-3.20.0/doc/functions/html_cva.rst php-twig-3.27.0/doc/functions/html_cva.rst --- php-twig-3.20.0/doc/functions/html_cva.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/html_cva.rst 2026-05-27 13:05:51.000000000 +0000 @@ -5,7 +5,6 @@ The ``html_cva`` function was added in Twig 3.12. - `CVA (Class Variant Authority)`_ is a concept from the JavaScript world and used by the well-known `shadcn/ui`_ library. The CVA concept is used to render multiple variations of components, applying @@ -45,13 +44,13 @@ {# index.html.twig #} {{ include('alert.html.twig', {'color': 'blue', 'size': 'md'}) }} - // class="alert bg-blue text-md" + {# class="alert bg-blue text-md" #} {{ include('alert.html.twig', {'color': 'green', 'size': 'sm'}) }} - // class="alert bg-green text-sm" + {# class="alert bg-green text-sm" #} {{ include('alert.html.twig', {'color': 'red', 'class': 'flex items-center justify-center'}) }} - // class="alert bg-red flex items-center justify-center" + {# class="alert bg-red flex items-center justify-center" #} CVA and Tailwind CSS -------------------- @@ -61,18 +60,18 @@ ``tailwind_merge()`` filter from `tales-from-a-dev/twig-tailwind-extra`_ with the ``html_cva()`` function: -.. code-block:: terminal +.. code-block:: bash $ composer require tales-from-a-dev/twig-tailwind-extra .. code-block:: html+twig {% set alert = html_cva( - // ... + ... ) %}
- ... + ...
Compound Variants @@ -97,28 +96,28 @@ lg: 'text-lg', } }, - compoundVariants: [{ - // if color = red AND size = (md or lg), add the `font-bold` class + compound_variants: [{ + # if color = red AND size = (md or lg), add the `font-bold` class color: ['red'], size: ['md', 'lg'], - class: 'font-bold' + class: 'font-bold', }] ) %}
- ... + ...
{# index.html.twig #} {{ include('alert.html.twig', {color: 'red', size: 'lg'}) }} - // class="alert bg-red text-lg font-bold" + {# class="alert bg-red text-lg font-bold" #} {{ include('alert.html.twig', {color: 'green', size: 'sm'}) }} - // class="alert bg-green text-sm" + {# class="alert bg-green text-sm" #} {{ include('alert.html.twig', {color: 'red', size: 'md'}) }} - // class="alert bg-green text-md font-bold" + {# class="alert bg-green text-md font-bold" #} Default Variants ---------------- @@ -146,7 +145,7 @@ lg: 'rounded-lg', } }, - defaultVariant: { + default_variant: { rounded: 'md', } ) %} @@ -158,7 +157,7 @@ {# index.html.twig #} {{ include('alert.html.twig', {color: 'red', size: 'lg'}) }} - // class="alert bg-red text-lg rounded-md" + {# class="alert bg-red text-lg rounded-md" #} .. note:: diff -Nru php-twig-3.20.0/doc/functions/include.rst php-twig-3.27.0/doc/functions/include.rst --- php-twig-3.20.0/doc/functions/include.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/include.rst 2026-05-27 13:05:51.000000000 +0000 @@ -27,12 +27,12 @@ .. code-block:: twig {# only the name variable will be accessible #} - {{ include('template.html.twig', {name: 'Fabien'}, with_context = false) }} + {{ include('template.html.twig', {name: 'Fabien'}, with_context: false) }} .. code-block:: twig {# no variables will be accessible #} - {{ include('template.html.twig', with_context = false) }} + {{ include('template.html.twig', with_context: false) }} And if the expression evaluates to a ``\Twig\Template`` or a ``\Twig\TemplateWrapper`` instance, Twig will use it directly:: @@ -48,7 +48,7 @@ .. code-block:: twig - {{ include('sidebar.html.twig', ignore_missing = true) }} + {{ include('sidebar.html.twig', ignore_missing: true) }} You can also provide a list of templates that are checked for existence before inclusion. The first template that exists will be rendered: diff -Nru php-twig-3.20.0/doc/functions/index.rst php-twig-3.27.0/doc/functions/index.rst --- php-twig-3.20.0/doc/functions/index.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/index.rst 2026-05-27 13:05:51.000000000 +0000 @@ -12,6 +12,7 @@ dump enum enum_cases + html_attr html_classes html_cva include diff -Nru php-twig-3.20.0/doc/functions/random.rst php-twig-3.27.0/doc/functions/random.rst --- php-twig-3.20.0/doc/functions/random.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/random.rst 2026-05-27 13:05:51.000000000 +0000 @@ -10,6 +10,11 @@ * a random integer between the integer parameter (when negative) and 0 (inclusive). * a random integer between the first integer and the second integer parameter (inclusive). +.. caution:: + + The ``random`` function does not produce cryptographically secure random numbers. + Do not use them for purposes that require returned values to be unguessable. + .. code-block:: twig {{ random(['apple', 'orange', 'citrus']) }} {# example output: orange #} diff -Nru php-twig-3.20.0/doc/functions/template_from_string.rst php-twig-3.27.0/doc/functions/template_from_string.rst --- php-twig-3.20.0/doc/functions/template_from_string.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/functions/template_from_string.rst 2026-05-27 13:05:51.000000000 +0000 @@ -43,6 +43,16 @@ with the ``include`` function, you can use it with any tag or function that takes a template as an argument (like the ``embed`` or ``extends`` tags). +.. danger:: + + Never allow ``template_from_string`` in a sandboxed template. There is no + legitimate use case for letting untrusted template code build and render + arbitrary new templates on the fly, and doing so makes any sandbox policy + impossible to reason about. In particular, when sandboxing is driven by a + ``SourcePolicyInterface``, the template created by ``template_from_string`` + gets a synthesized name that is not possible to match, so the inner + template ends up running with no sandbox at all. + Arguments --------- diff -Nru php-twig-3.20.0/doc/index.rst php-twig-3.27.0/doc/index.rst --- php-twig-3.20.0/doc/index.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/index.rst 2026-05-27 13:05:51.000000000 +0000 @@ -14,6 +14,7 @@ deprecated recipes coding_standards + operators_precedence tags/index filters/index functions/index diff -Nru php-twig-3.20.0/doc/operators_precedence.rst php-twig-3.27.0/doc/operators_precedence.rst --- php-twig-3.20.0/doc/operators_precedence.rst 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/doc/operators_precedence.rst 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,204 @@ + ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| Precedence | Operator | Type | Associativity | Description | ++============+==================+=========+===============+===================================================================+ +| 512 | ``...`` | prefix | n/a | Spread operator | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| => 300 | ``|`` | infix | Left | Twig filter call | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``(`` | | | Twig function call | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``.``, ``?.`` | | | Get an attribute on a variable | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``[`` | | | Array access | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 500 | ``-`` | prefix | n/a | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``+`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 300 => 5 | ``??`` | infix | Right | Null coalescing operator (a ?? b) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 250 | ``=>`` | infix | Left | Arrow function (x => expr) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 200 | ``**`` | infix | Right | Exponentiation operator | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 100 | ``is`` | infix | Left | Twig tests | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``is not`` | | | Twig tests | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 60 | ``*`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``/`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``//`` | | | Floor division | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``%`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 50 => 70 | ``not`` | prefix | n/a | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 40 => 27 | ``~`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 30 | ``+`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``-`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 25 | ``..`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 20 | ``==`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``!=`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``<=>`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``<`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``>`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``>=`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``<=`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``not in`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``in`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``matches`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``starts with`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``ends with`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``has some`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``has every`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``===`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``!==`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 18 | ``b-and`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 17 | ``b-xor`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 16 | ``b-or`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 15 | ``and`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 12 | ``xor`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 10 | ``or`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 5 | ``?:``, ``? :`` | infix | Right | Elvis operator (a ?: b) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 0 | ``(`` | prefix | n/a | Explicit group expression (a) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?`` | infix | Left | Conditional operator (a ? b : c) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``=`` | | Right | Assignment operator | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``literal`` | prefix | n/a | A literal value (boolean, string, number, sequence, mapping, ...) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ + +When a precedence will change in 4.0, the new precedence is indicated by the arrow ``=>``. + +Here is the same table for Twig 4.0 with adjusted precedences: + ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| Precedence | Operator | Type | Associativity | Description | ++============+==================+=========+===============+===================================================================+ +| 512 | ``...`` | prefix | n/a | Spread operator | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``(`` | infix | Left | Twig function call | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``.``, ``?.`` | | | Get an attribute on a variable | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``[`` | | | Array access | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 500 | ``-`` | prefix | n/a | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``+`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 300 | ``|`` | infix | Left | Twig filter call | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 250 | ``=>`` | infix | Left | Arrow function (x => expr) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 200 | ``**`` | infix | Right | Exponentiation operator | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 100 | ``is`` | infix | Left | Twig tests | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``is not`` | | | Twig tests | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 70 | ``not`` | prefix | n/a | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 60 | ``*`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``/`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``//`` | | | Floor division | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``%`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 30 | ``+`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``-`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 27 | ``~`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 25 | ``..`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 20 | ``==`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``!=`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``<=>`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``<`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``>`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``>=`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``<=`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``not in`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``in`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``matches`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``starts with`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``ends with`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``has some`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``has every`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``===`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``!==`` | | | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 18 | ``b-and`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 17 | ``b-xor`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 16 | ``b-or`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 15 | ``and`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 12 | ``xor`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 10 | ``or`` | infix | Left | | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 5 | ``??`` | infix | Right | Null coalescing operator (a ?? b) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?:``, ``? :`` | | | Elvis operator (a ?: b) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 0 | ``(`` | prefix | n/a | Explicit group expression (a) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?`` | infix | Left | Conditional operator (a ? b : c) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``=`` | | Right | Assignment operator | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``literal`` | prefix | n/a | A literal value (boolean, string, number, sequence, mapping, ...) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ diff -Nru php-twig-3.20.0/doc/recipes.rst php-twig-3.27.0/doc/recipes.rst --- php-twig-3.20.0/doc/recipes.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/recipes.rst 2026-05-27 13:05:51.000000000 +0000 @@ -31,7 +31,7 @@ template names as keys and template contents as values (as done by ``\Twig\Util\TemplateDirIterator``). -However, this code won't find all deprecations (like using deprecated some Twig +However, this code won't find all deprecations (like using some deprecated Twig classes). To catch all notices, register a custom error handler like the one below:: @@ -271,13 +271,19 @@ The ``registerUndefinedTokenParserCallback()`` method was added in Twig 3.2. -When a function/filter/tag is not defined, Twig defaults to throw a +.. versionadded:: 3.22 + + The ``registerUndefinedTestCallback()`` method was added in Twig + 3.22. + +When a function/filter/test/tag is not defined, Twig defaults to throw a ``\Twig\Error\SyntaxError`` exception. However, it can also call a `callback`_ -(any valid PHP callable) which should return a function/filter/tag. +(any valid PHP callable) which should return a function/filter/test/tag. For tags, register callbacks with ``registerUndefinedTokenParserCallback()``. For filters, register callbacks with ``registerUndefinedFilterCallback()``. -For functions, use ``registerUndefinedFunctionCallback()``:: +For functions, use ``registerUndefinedFunctionCallback()``. +For tests, use ``registerUndefinedTestCallback()``:: // auto-register all native PHP functions as Twig functions // NEVER do this in a project as it's NOT secure @@ -289,7 +295,7 @@ return false; }); -If the callable is not able to return a valid function/filter/tag, it must +If the callable is not able to return a valid function/filter/test/tag, it must return ``false``. If you register more than one callback, Twig will call them in turn until one @@ -297,7 +303,7 @@ .. tip:: - As the resolution of functions/filters/tags is done during compilation, + As the resolution of functions/filters/tests/tags is done during compilation, there is no overhead when registering these callbacks. .. warning:: diff -Nru php-twig-3.20.0/doc/sandbox.rst php-twig-3.27.0/doc/sandbox.rst --- php-twig-3.20.0/doc/sandbox.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/sandbox.rst 2026-05-27 13:05:51.000000000 +0000 @@ -17,7 +17,7 @@ the ``SandboxExtension`` constructor. By default, Twig comes with one policy class: ``\Twig\Sandbox\SecurityPolicy``. -This class allows you to allow-list some tags, filters, functions, but also +This class allows you to allow-list some tags, filters, functions, and properties and methods on objects:: $tags = ['if']; @@ -31,11 +31,11 @@ $functions = ['range']; $policy = new \Twig\Sandbox\SecurityPolicy($tags, $filters, $methods, $properties, $functions); -With the previous configuration, the security policy will only allow usage of -the ``if`` tag, and the ``upper`` filter. Moreover, the templates will only be -able to call the ``getTitle()`` and ``getBody()`` methods on ``Article`` -objects, and the ``title`` and ``body`` public properties. Everything else -won't be allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception. +With the above configuration, the security policy will only allow usage of the +``if`` tag, and the ``upper`` filter. Moreover, the templates will only be able +to call the ``getTitle()`` and ``getBody()`` methods on ``Article`` objects, +and the ``title`` and ``body`` public properties. Everything else won't be +allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception. .. note:: @@ -48,9 +48,14 @@ .. caution:: - The ``extends`` and ``use`` tags are always allowed in a sandboxed - template. That behavior will change in 4.0 where these tags will need to be - explicitly allowed like any other tag. + The ``extends`` and ``use`` tags, as well as the ``parent``, ``block``, and + ``attribute`` functions are always allowed in a sandboxed template. That + behavior will change in 4.0 where they will need to be explicitly allowed + like any other tag or function. To opt-in to the 4.0 behavior now (so they + need to be allow-listed or get rejected), enable strict mode on the + security policy:: + + $policy->setStrict(true); Enabling the Sandbox -------------------- @@ -66,7 +71,43 @@ You can sandbox all templates by passing ``true`` as the second argument of the extension constructor:: - $sandbox = new \Twig\Extension\SandboxExtension($policy, true); + $twig->addExtension(new \Twig\Extension\SandboxExtension($policy, true)); + +Allowed Operations Apply Transitively to Their Arguments +-------------------------------------------------------- + +The method and property allow-lists only restrict attribute access written +explicitly in the template (``obj.foo`` and ``obj.foo()``). Once an object is +passed as an argument to an allowed tag, filter, function, or test, that +operation can interact with it in any way PHP allows, without going through +the sandbox allow-list. + +This is especially easy to miss for implicit calls made through PHP +interfaces. For example, allowing ``json_encode`` may expose public object +properties and call ``JsonSerializable::jsonSerialize()``; allowing sequence +operations such as ``for``, ``keys``, ``slice``, ``random``, or ``join`` may +call ``IteratorAggregate::getIterator()``, ``Iterator`` methods, or +``Countable::count()``; allowing ``cycle`` with an ``ArrayAccess`` value may +call ``offsetGet()``. None of these calls appear in the template source. + +Only allow operations whose behavior is safe for the objects you expose to +sandboxed templates. If this is not guaranteed, convert objects to plain +arrays or scalars before passing them in. + +Limiting Resource Usage +----------------------- + +The sandbox prevents untrusted templates from reaching code, data, methods, or +properties they shouldn't. It does **not** prevent a template from consuming +CPU, memory, or wall-clock time, even under the strictest allow-list. + +This is by design: any limit baked into Twig itself would be both arbitrary +and trivial to work around, since there are many ways a template can burn +resources (large ranges, nested loops, large string operations, recursive +macros, expensive filters, deeply nested includes, and so on). + +If you render untrusted templates, you should contain them at the process level +rather than at the template engine level. Accepting Callables Arguments ----------------------------- diff -Nru php-twig-3.20.0/doc/tags/block.rst php-twig-3.27.0/doc/tags/block.rst --- php-twig-3.20.0/doc/tags/block.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/tags/block.rst 2026-05-27 13:05:51.000000000 +0000 @@ -5,7 +5,7 @@ the same time. They are documented in detail in the documentation for the :doc:`extends<../tags/extends>` tag. -Block names must consist of alphanumeric characters, and underscores. The first char can't be a digit and dashes are not permitted. +Block names must consist of alphanumeric characters, and underscores. The first character can't be a digit and dashes are not permitted. .. seealso:: diff -Nru php-twig-3.20.0/doc/tags/cache.rst php-twig-3.27.0/doc/tags/cache.rst --- php-twig-3.20.0/doc/tags/cache.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/tags/cache.rst 2026-05-27 13:05:51.000000000 +0000 @@ -42,7 +42,7 @@ and ``post.updated_at`` returns a timestamp that represents the time where the blog post was last modified. -Using such a strategy for naming cache keys allows to avoid using a ``ttl``. +Using such a strategy for naming cache keys allows you to avoid using a ``ttl``. It's like using a "validation" strategy instead of an "expiration" strategy as we do for HTTP caches. diff -Nru php-twig-3.20.0/doc/tags/extends.rst php-twig-3.27.0/doc/tags/extends.rst --- php-twig-3.20.0/doc/tags/extends.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/tags/extends.rst 2026-05-27 13:05:51.000000000 +0000 @@ -23,12 +23,12 @@ {% endblock %} -
{% block content %}{% endblock %}
- + @@ -71,8 +71,8 @@ You can't define multiple ``block`` tags with the same name in the same template. This limitation exists because a block tag works in "both" -directions. That is, a block tag doesn't just provide a hole to fill - it also -defines the content that fills the hole in the *parent*. If there were two +directions. That is, a block tag doesn't just provide a slot to fill - it also +defines the content that fills the slot in the *parent*. If there were two similarly-named ``block`` tags in a template, that template's parent wouldn't know which one of the blocks' content to use. diff -Nru php-twig-3.20.0/doc/tags/index.rst php-twig-3.27.0/doc/tags/index.rst --- php-twig-3.20.0/doc/tags/index.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/tags/index.rst 2026-05-27 13:05:51.000000000 +0000 @@ -12,10 +12,10 @@ do embed extends - guard flush for from + guard if import include diff -Nru php-twig-3.20.0/doc/tags/set.rst php-twig-3.27.0/doc/tags/set.rst --- php-twig-3.20.0/doc/tags/set.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/tags/set.rst 2026-05-27 13:05:51.000000000 +0000 @@ -27,6 +27,16 @@ {% set user = {'name': 'Fabien'} %} {% set name = 'Fabien' ~ ' ' ~ 'Potencier' %} +.. tip:: + + To assign a value within an expression, use the :ref:`= operator + `: + + .. code-block:: twig + + {# use assignment within a larger expression #} + {{ (result = fetch_data()) ? result : 'default' }} + Several variables can be assigned in one block: .. code-block:: twig diff -Nru php-twig-3.20.0/doc/templates.rst php-twig-3.27.0/doc/templates.rst --- php-twig-3.20.0/doc/templates.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/templates.rst 2026-05-27 13:05:51.000000000 +0000 @@ -68,11 +68,10 @@ You might also be interested in: +* `Twig CS Fixer`_: a tool to check/fix your templates code style * `Twig Language Server`_: provides some language features like syntax highlighting, diagnostics, auto complete, ... - * `TwigQI`_: an extension which analyzes your templates for common bugs during compilation - * `TwigStan`_: a static analyzer for Twig templates powered by PHPStan Variables @@ -132,14 +131,33 @@ Setting Variables ~~~~~~~~~~~~~~~~~ -You can assign values to variables inside code blocks. Assignments use the -:doc:`set` tag: +You can assign values to variables inside code blocks using either the +:doc:`set` tag or the :ref:`= operator `: .. code-block:: twig {% set name = 'Fabien' %} {% set numbers = [1, 2] %} {% set map = {'city': 'Paris'} %} + {% set first, last = 'Fabien', 'Potencier' %} + + {# or #} + + {% do name = 'Fabien' %} + {% do numbers = [1, 2] %} + {% do map = {'city': 'Paris'} %} + {% do [first, last] = ['Fabien', 'Potencier'] %} + +The ``set`` tag can also be used to capture template content into +a variable: + + .. code-block:: html+twig + + {% set content %} + + {% endset %} + +See the :doc:`set` tag documentation for more details. Filters ------- @@ -186,28 +204,6 @@ {{ ('HELLO' ~ 'FABIEN')|lower }} - A common mistake is to forget using parentheses for filters on negative - numbers as a negative number in Twig is represented by the ``-`` operator - followed by a positive number. As the ``-`` operator has a lower precedence - than the filter operator, it can lead to confusion: - - .. code-block:: twig - - {{ -1|abs }} {# returns -1 #} - {{ -1**0 }} {# returns -1 #} - - {# as it is equivalent to #} - - {{ -(1|abs) }} - {{ -(1**0) }} - - For such cases, use parentheses to force the precedence: - - .. code-block:: twig - - {{ (-1)|abs }} {# returns 1 as expected #} - {{ (-1)**0 }} {# returns 1 as expected #} - Functions --------- @@ -365,7 +361,7 @@ }} As inline comments continue until the end of the current line, the following -code does not work as ``}}``would be part of the comment: +code does not work as ``}}`` would be part of the comment: .. code-block:: twig @@ -614,7 +610,7 @@ * ``42`` / ``42.23``: Integers and floating point numbers are created by writing the number down. If a dot is present the number is a float, - otherwise an integer. Underscores can be used as digits separator to + otherwise an integer. Underscores can be used as digits separator to improve readability (``-3_141.592_65`` is equivalent to ``-3141.59265``). * ``["first_name", "last_name"]``: Sequences are defined by a sequence of expressions @@ -643,6 +639,42 @@ {% set key = 'name' %} {(key): 'Fabien', (1 + 1): 2, ('ci' ~ 'ty'): 'city'} + Any expression is supported as a dynamic key. The result is coerced to + string, so objects implementing ``__toString()`` (PHP ``Stringable``) are + accepted: + + .. code-block:: twig + + {# attribute access #} + {(user.role): 'allowed'} + + {# method call #} + {(user.getRole()): 'allowed'} + + {# filter result #} + {(name|upper): 'Fabien'} + + {# function call #} + {(slug(title)): post} + + {# chained expression #} + {(user.email|lower): 'subscribed'} + + {# Stringable object (cast via __toString) #} + {(uuid): 'token'} + + .. versionadded:: 3.26.1 + + Support for arbitrary expressions as dynamic mapping keys + (attribute access, method calls, filter results, function calls, + and any ``Stringable`` object) was added in Twig 3.26.1. + + .. note:: + + Inside a sandbox, the ``__toString()`` coercion goes through the + ``SecurityPolicy`` method allowlist, the same way as ``{{ obj }}`` + or ``{{ obj|upper }}``. + * ``true`` / ``false``: ``true`` represents the true value, ``false`` represents the false value. @@ -703,14 +735,16 @@ ``4``. * ``//``: Divides two numbers and returns the floored integer result. ``{{ 20 - // 7 }}`` is ``2``, ``{{ -20 // 7 }}`` is ``-3`` (this is just syntactic + // 7 }}`` is ``2``, ``{{ -20 // 7 }}`` is ``-3`` (this is just syntactic sugar for the :doc:`round` filter). * ``*``: Multiplies the left operand with the right one. ``{{ 2 * 2 }}`` would return ``4``. * ``**``: Raises the left operand to the power of the right operand. ``{{ 2 ** - 3 }}`` would return ``8``. + 3 }}`` would return ``8``. Be careful as the ``**`` operator is right + associative, which means that ``{{ -1**0 }}`` is equivalent to ``{{ -(1**0) + }}`` and not ``{{ (-1)**0 }}``. .. _template_logic: @@ -743,6 +777,9 @@ The following mathematical comparison operators are supported in any expression: ``==``, ``!=``, ``<``, ``>``, ``>=``, and ``<=``. +In addition, the ``===`` and ``!==`` strict comparison operators are supported +(they are equivalent to the ``same as`` and ``not same as`` tests). + Spaceship Operator ~~~~~~~~~~~~~~~~~~ @@ -793,6 +830,21 @@ You can use this operator to perform a containment test on strings, sequences, mappings, or objects implementing the ``Traversable`` interface. +.. note:: + + For sequences, mappings, and ``Traversable`` objects, ``in`` uses a loose + comparison (similar to ``==``); use :doc:`same as ` for a + strict comparison. Like PHP's ``in_array()``, this can yield unexpected + results when the left operand is a boolean: + + .. code-block:: twig + + {# returns true because true == 'foo' under PHP loose comparison #} + {{ true in ['foo', 'bar'] }} + + Containment on strings only accepts string, integer, and float operands on + the left; other types always return ``false``. + To perform a negative test, use the ``not in`` operator: .. code-block:: twig @@ -883,7 +935,7 @@ .. _dot_operator: -* ``.``, ``[]``: Gets an attribute of a variable. +* ``.``, ``?.``, ``[]``: Gets an attribute of a variable. The (``.``) operator abstracts getting an attribute of a variable (methods, properties or constants of a PHP object, or items of a PHP array): @@ -892,11 +944,27 @@ {{ user.name }} - Twig supports a specific syntax via the ``[]`` operator for accessing items - on sequences and mappings, like in ``user['name']``: + The null-safe operator (``?.``) works like the dot operator but returns + ``null`` instead of throwing an exception when the left operand is ``null``. + If the operand is part of a chain, the rest of the chain is skipped: - After the ``.``, you can use any expression by wrapping it with parenthesis - ``()``. + .. code-block:: twig + + {{ user?.name }} + {# returns null if user is null, otherwise returns user.name #} + + {{ user?.address?.city }} + {# can be chained for safe navigation through potentially null values #} + + {{ user?.address.city }} + {# returns null if user is null, the rest of the chain is skipped (address.city is not evaluated) #} + + .. versionadded:: 3.23 + + The null-safe operator was added in Twig 3.23. + + After the ``.`` or ``?.``, you can use any expression by wrapping it with + parenthesis ``()``. One use case is when the attribute contains special characters (like ``-`` that would be interpreted as the minus operator): @@ -905,6 +973,7 @@ {# equivalent to the non-working user.first-name #} {{ user.('first-name') }} + {{ user?.('first-name') }} Another use case is when the attribute is "dynamic" (defined via a variable): @@ -912,6 +981,7 @@ {{ user.(name) }} {{ user.('get' ~ name) }} + {{ user?.(name) }} Before Twig 3.15, use the :doc:`attribute ` function instead for the two previous use cases. @@ -937,7 +1007,7 @@ To resolve ``user.name`` to a PHP call, Twig uses the following algorithm at runtime: - * check if ``user`` is a PHP array or a ArrayObject/ArrayAccess object and + * check if ``user`` is a PHP array or an ArrayObject/ArrayAccess object and ``name`` a valid element; * if not, and if ``user`` is a PHP object, check that ``name`` is a valid property; * if not, and if ``user`` is a PHP object, check that ``name`` is a class constant; @@ -947,6 +1017,12 @@ * if not, and if ``strict_variables`` is ``false``, return ``null``; * if not, throw an exception. + To resolve ``user?.name`` to a PHP call, Twig checks if ``user`` is + ``null`` first: + + * if ``user`` is ``null``, return ``null``; + * otherwise, use the same algorithm as for ``user.name``. + To resolve ``user['name']`` to a PHP call, Twig uses the following algorithm at runtime: @@ -957,7 +1033,7 @@ Twig supports a specific syntax via the ``()`` operator for calling methods on objects, like in ``user.name()``: - * check if ``user`` is a object and has the ``name()``, ``getName()``, + * check if ``user`` is an object and has the ``name()``, ``getName()``, ``isName()``, or ``hasName()`` method; * if not, and if ``strict_variables`` is ``false``, return ``null``; * if not, throw an exception. @@ -992,6 +1068,32 @@ Support for expanding the arguments of a function call was introduced in Twig 3.15. +.. _templates-assignment-operator: + +* ``=``: The assignment operator assigns a value to a variable within an + expression: + + .. code-block:: twig + + {# assign #} + {% do b = 1 + 3 %} + + {# assign and output the result #} + {{ b = 1 + 3 }} + + {# assignments can be chained #} + {% do a = b = 'foo' %} + + {# assignment can be used inside other expressions #} + {% do a = (b = 4) + 5 %} + + The assignment operator also supports :ref:`destructuring + `. + + .. versionadded:: 3.23 + + The ``=`` assignment operator was added in Twig 3.23. + * ``=>``: The arrow operator allows the creation of functions. A function is made of arguments (use parentheses for multiple arguments) and an arrow (``=>``) followed by an expression to execute. The expression has access to @@ -1032,36 +1134,10 @@ Understanding the precedence of these operators is crucial for writing correct and efficient Twig templates. -The operator precedence rules are as follows, with the lowest-precedence -operators listed first: +The operator precedence rules are as follows, with the highest-precedence +operators listed first. -============================= =================================== ===================================================== -Operator Score of precedence Description -============================= =================================== ===================================================== -``?:`` 0 Ternary operator, conditional statement -``or`` 10 Logical OR operation between two boolean expressions -``xor`` 12 Logical XOR operation between two boolean expressions -``and`` 15 Logical AND operation between two boolean expressions -``b-or`` 16 Bitwise OR operation on integers -``b-xor`` 17 Bitwise XOR operation on integers -``b-and`` 18 Bitwise AND operation on integers -``==``, ``!=``, ``<=>``, 20 Comparison operators -``<``, ``>``, ``>=``, -``<=``, ``not in``, ``in``, -``matches``, ``starts with``, -``ends with``, ``has some``, -``has every`` -``..`` 25 Range of values -``+``, ``-`` 30 Addition and subtraction on numbers -``~`` 40 String concatenation -``not`` 50 Negates a statement -``*``, ``/``, ``//``, ``%`` 60 Arithmetic operations on numbers -``is``, ``is not`` 100 Tests -``**`` 200 Raises a number to the power of another -``??`` 300 Default value when a variable is null -``+``, ``-`` 500 Unary operations on numbers -``|``,``[]``,``.`` - Filters, sequence, mapping, and attribute access -============================= =================================== ===================================================== +.. include:: operators_precedence.rst Without using any parentheses, the operator precedence rules are used to determine how to convert the code to PHP: @@ -1085,6 +1161,98 @@ {# use parenthesis to change precedence #} {{ (greeting ~ name)|lower }} {# hello fabien #} +.. _templates-destructuring: + +Destructuring +------------- + +.. versionadded:: 3.23 + + Destructuring was added in Twig 3.23. + +Destructuring allows you to extract values from sequences and assign them to +variables in a single operation using the ``=`` :ref:`assignment operator +`. + +Like in PHP, destructuring expressions return the right-hand side value, not +the extracted values: + +.. code-block:: twig + + {# returns the full user object, allowing chained access #} + {{ ({name} = user).email }} + +Sequence Destructuring +~~~~~~~~~~~~~~~~~~~~~~ + +Use square brackets on the left side of an assignment to destructure a +sequence: + +.. code-block:: twig + + {% do [first, last] = ['Fabien', 'Potencier'] %} + + {{ first }} {# Fabien #} + {{ last }} {# Potencier #} + +If there are more variables than values, the extra variables are set to +``null``: + +.. code-block:: twig + + {# extra will be null #} + {% do [first, last, extra] = ['Fabien', 'Potencier'] %} + +You can skip values by leaving a slot empty: + +.. code-block:: twig + + {# only assign the second value #} + {% do [, last] = ['Fabien', 'Potencier'] %} + +Object Destructuring +~~~~~~~~~~~~~~~~~~~~ + +Use curly braces on the left side of an assignment to destructure an object +or mapping by extracting values based on property/key names: + +.. code-block:: twig + + {% do {name, email} = user %} + + {{ name }} {# user.name #} + {{ email }} {# user.email #} + +You can rename variables during destructuring by using the ``key: variable`` +syntax, where the key is the property to extract and the variable is the name +to assign to: + +.. code-block:: twig + + {% do {name: userName, email: userEmail} = user %} + + {{ userName }} {# user.name #} + {{ userEmail }} {# user.email #} + +This is especially useful when you need to destructure multiple objects that +share the same property names: + +.. code-block:: twig + + {% do {data: product, error: productError} = loadProduct() %} + {% do {data: stock, error: stockError} = loadStock() %} + + {{ product }} {# loadProduct().data #} + {{ productError }} {# loadProduct().error #} + {{ stock }} {# loadStock().data #} + {{ stockError }} {# loadStock().error #} + +.. note:: + + Object destructuring uses the :ref:`dot operator ` to access + values, so ``{name} = user`` is equivalent to ``name = user.name`` or + ``name = user["name"]`` depending on the type of the variable. + .. _templates-whitespace-control: Whitespace Control @@ -1104,7 +1272,7 @@ * *Line whitespace trimming* via the ``~`` modifier: Removes all whitespace (excluding newlines). Using this modifier on the right disables the default - removal of the first newline inherited from PHP. + removal of the first newline mentioned above. The modifiers can be used on either side of the tags like in ``{%-`` or ``-%}`` and they consume all whitespace for that side of the tag. It is possible to use @@ -1151,6 +1319,7 @@ .. _`TwigStan`: https://github.com/twigstan/twigstan .. _`Twig pack`: https://marketplace.visualstudio.com/items?itemName=bajdzis.vscode-twig-pack .. _`Modern Twig`: https://marketplace.visualstudio.com/items?itemName=Stanislav.vscode-twig +.. _`Twig CS Fixer`: https://github.com/VincentLanglet/Twig-CS-Fixer .. _`Twig Language Server`: https://github.com/kaermorchen/twig-language-server/tree/master/packages/language-server .. _`Twiggy`: https://marketplace.visualstudio.com/items?itemName=moetelo.twiggy .. _`PHP spaceship operator documentation`: https://www.php.net/manual/en/language.operators.comparison.php diff -Nru php-twig-3.20.0/doc/tests/index.rst php-twig-3.27.0/doc/tests/index.rst --- php-twig-3.20.0/doc/tests/index.rst 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/doc/tests/index.rst 2026-05-27 13:05:51.000000000 +0000 @@ -10,6 +10,8 @@ empty even iterable + mapping null odd sameas + sequence diff -Nru php-twig-3.20.0/extra/cache-extra/.gitattributes php-twig-3.27.0/extra/cache-extra/.gitattributes --- php-twig-3.20.0/extra/cache-extra/.gitattributes 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cache-extra/.gitattributes 2026-05-27 13:05:51.000000000 +0000 @@ -1,2 +1,3 @@ /Tests export-ignore +/.git* export-ignore /phpunit.xml.dist export-ignore diff -Nru php-twig-3.20.0/extra/cache-extra/Tests/FunctionalTest.php php-twig-3.27.0/extra/cache-extra/Tests/FunctionalTest.php --- php-twig-3.20.0/extra/cache-extra/Tests/FunctionalTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cache-extra/Tests/FunctionalTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -29,7 +29,7 @@ $twig = $this->createEnvironment(['index' => '{% cache "city;v1" %}{{- city -}}{% endcache %}'], $cache); $this->assertSame('Paris', $twig->render('index', ['city' => 'Paris'])); - $value = $cache->get('city;v1', function () { throw new \RuntimeException('Key should be in the cache'); }); + $value = $cache->get('city;v1', static function () { throw new \RuntimeException('Key should be in the cache'); }); $this->assertSame('Paris', $value); } diff -Nru php-twig-3.20.0/extra/cache-extra/TokenParser/CacheTokenParser.php php-twig-3.27.0/extra/cache-extra/TokenParser/CacheTokenParser.php --- php-twig-3.20.0/extra/cache-extra/TokenParser/CacheTokenParser.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cache-extra/TokenParser/CacheTokenParser.php 2026-05-27 13:05:51.000000000 +0000 @@ -24,24 +24,23 @@ public function parse(Token $token): Node { $stream = $this->parser->getStream(); - $expressionParser = $this->parser->getExpressionParser(); - $key = $expressionParser->parseExpression(); + $key = $this->parser->parseExpression(); $ttl = null; $tags = null; while ($stream->test(Token::NAME_TYPE)) { $k = $stream->getCurrent()->getValue(); - if (!in_array($k, ['ttl', 'tags'])) { + if (!\in_array($k, ['ttl', 'tags'], true)) { throw new SyntaxError(\sprintf('Unknown "%s" configuration.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext()); } $stream->next(); - $stream->expect(Token::PUNCTUATION_TYPE, '('); + $stream->expect(Token::OPERATOR_TYPE, '('); $line = $stream->getCurrent()->getLine(); if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { throw new SyntaxError(\sprintf('The "%s" modifier takes exactly one argument (0 given).', $k), $line, $stream->getSourceContext()); } - $arg = $expressionParser->parseExpression(); + $arg = $this->parser->parseExpression(); if ($stream->test(Token::PUNCTUATION_TYPE, ',')) { throw new SyntaxError(\sprintf('The "%s" modifier takes exactly one argument (2 given).', $k), $line, $stream->getSourceContext()); } diff -Nru php-twig-3.20.0/extra/cache-extra/composer.json php-twig-3.27.0/extra/cache-extra/composer.json --- php-twig-3.20.0/extra/cache-extra/composer.json 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cache-extra/composer.json 2026-05-27 13:05:51.000000000 +0000 @@ -16,8 +16,8 @@ ], "require": { "php": ">=8.1.0", - "symfony/cache": "^5.4|^6.4|^7.0", - "twig/twig": "^3.19|^4.0" + "symfony/cache": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.21|^4.0" }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0" diff -Nru php-twig-3.20.0/extra/cssinliner-extra/.gitattributes php-twig-3.27.0/extra/cssinliner-extra/.gitattributes --- php-twig-3.20.0/extra/cssinliner-extra/.gitattributes 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cssinliner-extra/.gitattributes 2026-05-27 13:05:51.000000000 +0000 @@ -1,2 +1,3 @@ /Tests export-ignore +/.git* export-ignore /phpunit.xml.dist export-ignore diff -Nru php-twig-3.20.0/extra/cssinliner-extra/CssInlinerExtension.php php-twig-3.27.0/extra/cssinliner-extra/CssInlinerExtension.php --- php-twig-3.20.0/extra/cssinliner-extra/CssInlinerExtension.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cssinliner-extra/CssInlinerExtension.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,9 +1,9 @@ + * (c) Fabien Potencier * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -20,7 +20,7 @@ public function getFilters(): array { return [ - new TwigFilter('inline_css', [self::class, 'inlineCss'], ['is_safe' => ['all']]), + new TwigFilter('inline_css', [self::class, 'inlineCss'], ['is_safe' => ['html'], 'pre_escape' => 'html']), ]; } diff -Nru php-twig-3.20.0/extra/cssinliner-extra/Resources/functions.php php-twig-3.27.0/extra/cssinliner-extra/Resources/functions.php --- php-twig-3.20.0/extra/cssinliner-extra/Resources/functions.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/cssinliner-extra/Resources/functions.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,9 +1,9 @@ + * (c) Fabien Potencier * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. diff -Nru php-twig-3.20.0/extra/cssinliner-extra/Tests/FunctionalTest.php php-twig-3.27.0/extra/cssinliner-extra/Tests/FunctionalTest.php --- php-twig-3.20.0/extra/cssinliner-extra/Tests/FunctionalTest.php 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/extra/cssinliner-extra/Tests/FunctionalTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,47 @@ + "{% autoescape 'js' %}{% apply inline_css %}

x

{% endapply %}{% endautoescape %}", + ])); + $twig->addExtension(new CssInlinerExtension()); + + $output = $twig->render('index'); + + $this->assertStringNotContainsString('

', $output); + $this->assertStringNotContainsString('

', $output); + $this->assertMatchesRegularExpression('{\\\\u003[Cc]p\\\\u003[Ee]x\\\\u003[Cc]\\\\/p\\\\u003[Ee]}', $output); + } + + public function testInlineCssPreEscapesUnsafeInput() + { + $twig = new Environment(new ArrayLoader([ + 'index' => '{{ payload|inline_css }}', + ])); + $twig->addExtension(new CssInlinerExtension()); + + $output = $twig->render('index', ['payload' => '']); + + $this->assertStringNotContainsString(''], + ], + ]; + + // Variadic merging scenarios + yield 'scalar value overrides from left to right' => [ + 'id="final"', + [ + ['id' => 'first'], + ['id' => 'second'], + ['id' => 'final'], + ], + ]; + + yield 'variadic with mixed false and null values' => [ + 'id="test"', + [ + ['id' => 'test'], + null, + false, + null, + ], + ]; + + yield 'variadic with empty arrays' => [ + 'id="test"', + [ + [], + ['id' => 'test'], + [], + ], + ]; + + yield 'variadic with empty string values' => [ + 'id="test"', + [ + '', + ['id' => 'test'], + '', + ], + ]; + + // AttributeValueInterface + yield 'AttributeValueInterface with string value' => [ + 'custom="custom-value"', + [ + ['custom' => new AttributeValueStub('custom-value')], + ], + ]; + + yield 'AttributeValueInterface with null value omits attribute' => [ + '', + [ + ['custom' => new AttributeValueStub(null)], + ], + ]; + + yield 'AttributeValueInterface wins over special case handling for style and data-*' => [ + 'style="some style" data-custom="not JSON"', + [ + ['style' => new AttributeValueStub('some style'), 'data-custom' => new AttributeValueStub('not JSON')], + ], + ]; + + // Edge cases + yield 'numeric attribute value' => [ + 'tabindex="0"', + [ + ['tabindex' => 0], + ], + ]; + + yield 'zero is not treated as falsy' => [ + 'data-count="0"', + [ + ['data-count' => 0], + ], + ]; + + // Scalar and object merging in rendering + yield 'string replaces object in rendering' => [ + 'value="new-string"', + [ + ['value' => new \stdClass()], + ['value' => 'new-string'], + ], + ]; + + yield 'object replaces string in rendering uses __toString if available' => [ + 'value="stringable-object"', + [ + ['value' => 'old-string'], + ['value' => new StringableStub('stringable-object')], + ], + ]; + } + + public function testIterableObjectCastedToArray() + { + /* + This test case demonstrates how objects could e. g. implement helper logic + to construct more complex attribute combinations and sets, and be passed as + one argument to html_attr as well. + */ + $object = new class implements \IteratorAggregate { + public function getIterator(): \Traversable + { + return new \ArrayIterator([ + 'data-controller' => new SeparatedTokenList(['dropdown', 'tooltip']), + 'data-action' => new SeparatedTokenList(['click->dropdown#toggle', 'mouseover->tooltip#show']), + ]); + } + }; + + $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object); + + self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', $result); + } + + public function testDataAttributeWithNonJsonEncodableValueThrowsRuntimeError() + { + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage('The "data-bad" attribute value cannot be JSON encoded.'); + + HtmlExtension::htmlAttr( + new Environment(new ArrayLoader()), + ['data-bad' => [\INF]] // INF cannot be JSON-encoded + ); + } + + public function testNonStringableObjectAsAttributeValueThrowsRuntimeError() + { + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage('The "title" attribute value should be a scalar, an iterable, or an object implementing "Stringable"'); + + HtmlExtension::htmlAttr( + new Environment(new ArrayLoader()), + ['title' => new \stdClass()] + ); + } +} + +class StringableStub implements \Stringable +{ + public function __construct(private readonly string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +} + +class AttributeValueStub implements AttributeValueInterface +{ + public function __construct(private readonly ?string $value) + { + } + + public function getValue(): ?string + { + return $this->value; + } +} diff -Nru php-twig-3.20.0/extra/html-extra/composer.json php-twig-3.27.0/extra/html-extra/composer.json --- php-twig-3.20.0/extra/html-extra/composer.json 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/html-extra/composer.json 2026-05-27 13:05:51.000000000 +0000 @@ -17,7 +17,7 @@ "require": { "php": ">=8.1.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/mime": "^5.4|^6.4|^7.0", + "symfony/mime": "^5.4|^6.4|^7.0|^8.0", "twig/twig": "^3.13|^4.0" }, "require-dev": { diff -Nru php-twig-3.20.0/extra/inky-extra/.gitattributes php-twig-3.27.0/extra/inky-extra/.gitattributes --- php-twig-3.20.0/extra/inky-extra/.gitattributes 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/inky-extra/.gitattributes 2026-05-27 13:05:51.000000000 +0000 @@ -1,2 +1,3 @@ /Tests export-ignore +/.git* export-ignore /phpunit.xml.dist export-ignore diff -Nru php-twig-3.20.0/extra/inky-extra/InkyExtension.php php-twig-3.27.0/extra/inky-extra/InkyExtension.php --- php-twig-3.20.0/extra/inky-extra/InkyExtension.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/inky-extra/InkyExtension.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,9 +1,9 @@ + * (c) Fabien Potencier * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -20,7 +20,7 @@ public function getFilters(): array { return [ - new TwigFilter('inky_to_html', [self::class, 'inky'], ['is_safe' => ['html']]), + new TwigFilter('inky_to_html', [self::class, 'inky'], ['is_safe' => ['html'], 'pre_escape' => 'html']), ]; } diff -Nru php-twig-3.20.0/extra/inky-extra/Resources/functions.php php-twig-3.27.0/extra/inky-extra/Resources/functions.php --- php-twig-3.20.0/extra/inky-extra/Resources/functions.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/extra/inky-extra/Resources/functions.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,9 +1,9 @@ + * (c) Fabien Potencier * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. diff -Nru php-twig-3.20.0/extra/inky-extra/Tests/FunctionalTest.php php-twig-3.27.0/extra/inky-extra/Tests/FunctionalTest.php --- php-twig-3.20.0/extra/inky-extra/Tests/FunctionalTest.php 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/extra/inky-extra/Tests/FunctionalTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,33 @@ + '{{ payload|inky_to_html }}', + ])); + $twig->addExtension(new InkyExtension()); + + $output = $twig->render('index', ['payload' => '']); + + $this->assertStringNotContainsString(''] +--EXPECT-- +1. <script> alert(1) </script> + +2. <script> alert(1) </script> + +3. diff -Nru php-twig-3.20.0/tests/Fixtures/operators/contat_vs_add_sub.legacy.test php-twig-3.27.0/tests/Fixtures/operators/contat_vs_add_sub.legacy.test --- php-twig-3.20.0/tests/Fixtures/operators/contat_vs_add_sub.legacy.test 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/operators/contat_vs_add_sub.legacy.test 2026-05-27 13:05:51.000000000 +0000 @@ -1,8 +1,8 @@ --TEST-- +/- will have a higher precedence over ~ in Twig 4.0 --DEPRECATION-- -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 2. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 3. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 2. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 3. --TEMPLATE-- {{ '42' ~ 1 + 41 }} {{ '42' ~ 43 - 1 }} diff -Nru php-twig-3.20.0/tests/Fixtures/operators/minus_vs_pipe.legacy.test php-twig-3.27.0/tests/Fixtures/operators/minus_vs_pipe.legacy.test --- php-twig-3.20.0/tests/Fixtures/operators/minus_vs_pipe.legacy.test 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/operators/minus_vs_pipe.legacy.test 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,10 @@ +--TEST-- +| will have a higher precedence over + and - in Twig 4.0 +--DEPRECATION-- +Since twig/twig 3.21: As the "|" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 2. +--TEMPLATE-- +{{ -1|abs }} +--DATA-- +return [] +--EXPECT-- +-1 diff -Nru php-twig-3.20.0/tests/Fixtures/operators/not_precedence.legacy.test php-twig-3.27.0/tests/Fixtures/operators/not_precedence.legacy.test --- php-twig-3.20.0/tests/Fixtures/operators/not_precedence.legacy.test 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/operators/not_precedence.legacy.test 2026-05-27 13:05:51.000000000 +0000 @@ -1,7 +1,7 @@ --TEST-- *, /, //, and % will have a higher precedence over not in Twig 4.0 --DEPRECATION-- -Since twig/twig 3.15: Add explicit parentheses around the "not" unary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 2. +Since twig/twig 3.15: As the "not" prefix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 2. --TEMPLATE-- {{ not 1 * 2 }} --DATA-- diff -Nru php-twig-3.20.0/tests/Fixtures/operators/not_precedence.test php-twig-3.27.0/tests/Fixtures/operators/not_precedence.test --- php-twig-3.20.0/tests/Fixtures/operators/not_precedence.test 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/operators/not_precedence.test 2026-05-27 13:05:51.000000000 +0000 @@ -2,7 +2,7 @@ *, /, //, and % will have a higher precedence over not in Twig 4.0 --TEMPLATE-- {{ (not 1) * 2 }} -{{ (not 1 * 2) }} +{{ not (1 * 2) }} --DATA-- return [] --EXPECT-- diff -Nru php-twig-3.20.0/tests/Fixtures/regression/4701-block-inheritance-issue.test php-twig-3.27.0/tests/Fixtures/regression/4701-block-inheritance-issue.test --- php-twig-3.20.0/tests/Fixtures/regression/4701-block-inheritance-issue.test 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/regression/4701-block-inheritance-issue.test 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,21 @@ +--TEST-- +#4701 Accessing arrays with stringable objects as key +--TEMPLATE-- +{% set hash = { + 'foo': 'FOO', + 'bar': 'BAR', +} %} + +{{ hash[key] }} +--DATA-- +class MyObj { + public function __toString() { + return 'foo'; + } +} + +return [ + 'key' => new MyObj(), +]; +--EXPECT-- +FOO diff -Nru php-twig-3.20.0/tests/Fixtures/regression/markup_test.test php-twig-3.27.0/tests/Fixtures/regression/markup_test.test --- php-twig-3.20.0/tests/Fixtures/regression/markup_test.test 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/regression/markup_test.test 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,18 @@ +--TEST-- +Twig outputs 0 nodes correctly +--TEMPLATE-- +{{ empty|trim ? 'KO' : 'ok' }} +{{ spaces|trim ? 'KO' : 'ok' }} +{% if empty %}KO{% else %}ok{% endif %} + +{% if spaces|trim %}KO{% else %}ok{% endif %} + +{% set bar %} {% endset %}{{ bar|trim ? 'KO' : 'ok' }} +--DATA-- +return ['spaces' => new Twig\Markup(' ', 'UTF-8'), 'empty' => new Twig\Markup('', 'UTF-8')] +--EXPECT-- +ok +ok +ok +ok +ok diff -Nru php-twig-3.20.0/tests/Fixtures/tags/guard/basic.test php-twig-3.27.0/tests/Fixtures/tags/guard/basic.test --- php-twig-3.20.0/tests/Fixtures/tags/guard/basic.test 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/tags/guard/basic.test 2026-05-27 13:05:51.000000000 +0000 @@ -13,9 +13,26 @@ {% else %} NEVER {% endguard %} + +{% guard test foobar %} + NEVER + {{ 'a'|foobar }} +{% else -%} + The foobar test doesn't exist +{% endguard %} + +{% guard test divisible by -%} + The divisible by function does exist +{% else %} + NEVER +{% endguard %} --DATA-- return [] --EXPECT-- The foobar filter doesn't exist The constant function does exist + +The foobar test doesn't exist + +The divisible by function does exist diff -Nru php-twig-3.20.0/tests/Fixtures/tags/guard/throwing_handler.test php-twig-3.27.0/tests/Fixtures/tags/guard/throwing_handler.test --- php-twig-3.20.0/tests/Fixtures/tags/guard/throwing_handler.test 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/tags/guard/throwing_handler.test 2026-05-27 13:05:51.000000000 +0000 @@ -14,9 +14,27 @@ {% else -%} The throwing_undefined_function function doesn't exist {% endguard %} + +{% guard test throwing_undefined_test -%} + NEVER + {% if 'a' is throwing_undefined_test('b') %}{% endif %} +{% else -%} + The throwing_undefined_test test doesn't exist +{% endguard %} + +{% guard test throwing_undefined_two words_test -%} + NEVER + {% if 'a' is throwing_undefined_test words_test('b') %}{% endif %} +{% else -%} + The throwing_undefined_two words_test test doesn't exist +{% endguard %} --DATA-- return [] --EXPECT-- The throwing_undefined_filter filter doesn't exist The throwing_undefined_function function doesn't exist + +The throwing_undefined_test test doesn't exist + +The throwing_undefined_two words_test test doesn't exist diff -Nru php-twig-3.20.0/tests/Fixtures/tests/null_coalesce.legacy.test php-twig-3.27.0/tests/Fixtures/tests/null_coalesce.legacy.test --- php-twig-3.20.0/tests/Fixtures/tests/null_coalesce.legacy.test 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Fixtures/tests/null_coalesce.legacy.test 2026-05-27 13:05:51.000000000 +0000 @@ -1,16 +1,16 @@ --TEST-- Twig supports the ?? operator --DEPRECATION-- -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 4. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 5. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 6. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 7. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 10. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 9. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 11. -Since twig/twig 3.15: Add explicit parentheses around the "??" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 16. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 15. -Since twig/twig 3.15: Add explicit parentheses around the "~" binary operator to avoid behavior change in the next major version as its precedence will change in "index.twig" at line 17. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 4. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 5. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 6. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 7. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 10. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 9. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 11. +Since twig/twig 3.15: As the "??" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 16. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 15. +Since twig/twig 3.15: As the "~" infix operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "index.twig" at line 17. --TEMPLATE-- {{ nope ?? nada ?? 'OK' -}} {# no deprecation as the operators have the same precedence #} diff -Nru php-twig-3.20.0/tests/IntegrationTest.php php-twig-3.27.0/tests/IntegrationTest.php --- php-twig-3.20.0/tests/IntegrationTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/IntegrationTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ ''); + } + + return false; + }, + ]; + } + + protected function getUndefinedFilterCallbacks(): array { return [ static function (string $name) { @@ -210,7 +240,7 @@ new TwigFilter('*_path', [$this, 'dynamic_path']), new TwigFilter('*_foo_*_bar', [$this, 'dynamic_foo']), new TwigFilter('not', [$this, 'notFilter']), - new TwigFilter('anon_foo', function ($name) { return '*'.$name.'*'; }), + new TwigFilter('anon_foo', static function ($name) { return '*'.$name.'*'; }), ]; } @@ -224,8 +254,8 @@ new TwigFunction('static_call_array', ['Twig\Tests\TwigTestExtension', 'staticCall']), 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'; }, ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1', 'not_deprecated_function')]), + new TwigFunction('anon_foo', static function ($name) { return '*'.$name.'*'; }), + new TwigFunction('deprecated_function', static function () { return 'foo'; }, ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1', 'not_deprecated_function')]), ]; } @@ -307,7 +337,7 @@ public function is_multi_word($value) { - return false !== strpos($value, ' '); + return str_contains($value, ' '); } public function __call($method, $arguments) diff -Nru php-twig-3.20.0/tests/LexerTest.php php-twig-3.27.0/tests/LexerTest.php --- php-twig-3.20.0/tests/LexerTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/LexerTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '}')); } - public function testSpreadOperator() - { - $template = '{{ { a: "a", ...{ b: "b" } } }}'; - - $this->assertEquals(1, $this->countToken($template, Token::SPREAD_TYPE, '...')); - // sanity check on lexing after spread - $this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '{')); - $this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '}')); - } - protected function countToken($template, $type, $value = null) { $lexer = new Lexer(new Environment(new ArrayLoader())); @@ -421,6 +420,56 @@ $this->addToAssertionCount(1); } + public function testFilterAndAttributeNamedAfterOperator() + { + // Ensure that filters/attributes aren't mistaken for operators when their names conflict + // (see https://github.com/twigphp/Twig/issues/4767) + $template = '{{ \'foo\'|and }}' + .'{{ \'bar\' | and }}' + .'{{ foo.and }}' + .'{{ bar . and }}' + .'{{ foo and bar }}'; + + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + foreach (['foo', 'bar'] as $value) { + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, $value); + $stream->expect(Token::OPERATOR_TYPE, '|'); + $stream->expect(Token::NAME_TYPE, 'and'); + $stream->expect(Token::VAR_END_TYPE); + } + foreach (['foo', 'bar'] as $value) { + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::NAME_TYPE, $value); + $stream->expect(Token::OPERATOR_TYPE, '.'); + $stream->expect(Token::NAME_TYPE, 'and'); + $stream->expect(Token::VAR_END_TYPE); + } + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::NAME_TYPE, 'foo'); + $stream->expect(Token::OPERATOR_TYPE, 'and'); + $stream->expect(Token::NAME_TYPE, 'bar'); + $stream->expect(Token::VAR_END_TYPE); + + // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above + // can be executed without throwing any exceptions + } + + public function testLiteralIsNotAnOperator() + { + // "literal" is the name of the LiteralExpressionParser but should not be treated as an operator token + $template = '{{ literal }}'; + + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::NAME_TYPE, 'literal'); + $stream->expect(Token::VAR_END_TYPE); + + $this->addToAssertionCount(1); + } + public function testUnterminatedVariable() { $template = ' diff -Nru php-twig-3.20.0/tests/Loader/ArrayTest.php php-twig-3.27.0/tests/Loader/ArrayTest.php --- php-twig-3.20.0/tests/Loader/ArrayTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Loader/ArrayTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertFalse($node->hasNode('variables')); + $this->assertEquals('foo.twig', $node->getAttribute('name')); + $this->assertEquals(0, $node->getAttribute('index')); + $this->assertFalse($node->getAttribute('only')); + $this->assertFalse($node->getAttribute('ignore_missing')); + + $vars = new ArrayExpression([new ConstantExpression('foo', 1), new ConstantExpression(true, 1)], 1); + $node = new EmbedNode('bar.twig', 1, $vars, true, false, 1); + $this->assertEquals($vars, $node->getNode('variables')); + $this->assertTrue($node->getAttribute('only')); + $this->assertEquals('bar.twig', $node->getAttribute('name')); + $this->assertEquals(1, $node->getAttribute('index')); + } + + public static function provideTests(): iterable + { + $tests = []; + + $node = new EmbedNode('foo.twig', 0, null, false, false, 1); + $tests[] = [$node, <<<'EOF' +// line 1 +yield from $this->load("foo.twig", 1, 0)->unwrap()->yield($context); +EOF + ]; + + $node = new EmbedNode('foo.twig', 1, null, false, false, 1); + $tests[] = [$node, <<<'EOF' +// line 1 +yield from $this->load("foo.twig", 1, 1)->unwrap()->yield($context); +EOF + ]; + + $vars = new ArrayExpression([new ConstantExpression('foo', 1), new ConstantExpression(true, 1)], 1); + $node = new EmbedNode('foo.twig', 0, $vars, false, false, 1); + $tests[] = [$node, <<<'EOF' +// line 1 +yield from $this->load("foo.twig", 1, 0)->unwrap()->yield(CoreExtension::merge($context, ["foo" => true])); +EOF + ]; + + $node = new EmbedNode('foo.twig', 0, $vars, true, false, 1); + $tests[] = [$node, <<<'EOF' +// line 1 +yield from $this->load("foo.twig", 1, 0)->unwrap()->yield(CoreExtension::toArray(["foo" => true])); +EOF + ]; + + $node = new EmbedNode('foo.twig', 2, $vars, true, true, 1); + $tests[] = [$node, <<load("foo.twig", 1, 2); + \$_v0->getParent(\$context); +; +} catch (LoaderError \$e) { + // ignore missing template + \$_v0 = null; +} +if (\$_v0) { + yield from \$_v0->unwrap()->yield(CoreExtension::toArray(["foo" => true])); +} +EOF + ]; + + return $tests; + } +} diff -Nru php-twig-3.20.0/tests/Node/Expression/ArrayTest.php php-twig-3.27.0/tests/Node/Expression/ArrayTest.php --- php-twig-3.20.0/tests/Node/Expression/ArrayTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/ArrayTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ setAccessible(true); return $m->invokeArgs($call, $args); } diff -Nru php-twig-3.20.0/tests/Node/Expression/ConditionalTest.php php-twig-3.27.0/tests/Node/Expression/ConditionalTest.php --- php-twig-3.20.0/tests/Node/Expression/ConditionalTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/ConditionalTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ env->getFilter(\'anonymous\')->getCallable()("foo")']; + // needs sandbox + $node = self::createFilter($environment, $string, 'bar_sandbox'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_sandbox($this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source), "abc")', $environment]; + + // needs charset, environment, context, and sandbox + $node = self::createFilter($environment, $string, 'bar_all'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_all($this->env->getCharset(), $this->env, $context, $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source), "abc")', $environment]; + // needs environment $node = self::createFilter($environment, $string, 'bar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_dummy($this->env, "abc")', $environment]; @@ -165,10 +182,12 @@ protected static function createEnvironment(): Environment { $env = new Environment(new ArrayLoader()); - $env->addFilter(new TwigFilter('anonymous', function () {})); + $env->addFilter(new TwigFilter('anonymous', static function () {})); $env->addFilter(new TwigFilter('bar', 'Twig\Tests\Node\Expression\twig_tests_filter_dummy', ['needs_environment' => true])); $env->addFilter(new TwigFilter('bar_closure', \Closure::fromCallable(twig_tests_filter_dummy::class), ['needs_environment' => true])); $env->addFilter(new TwigFilter('barbar', 'Twig\Tests\Node\Expression\twig_tests_filter_barbar', ['needs_context' => true, 'is_variadic' => true])); + $env->addFilter(new TwigFilter('bar_sandbox', 'Twig\Tests\Node\Expression\twig_tests_filter_sandbox', ['needs_is_sandboxed' => true])); + $env->addFilter(new TwigFilter('bar_all', 'Twig\Tests\Node\Expression\twig_tests_filter_all', ['needs_charset' => true, 'needs_environment' => true, 'needs_context' => true, 'needs_is_sandboxed' => true])); $env->addFilter(new TwigFilter('magic_static', __NAMESPACE__.'\ChildMagicCallStub::magicStaticCall')); if (\PHP_VERSION_ID >= 80111) { $env->addExtension(new FilterTestExtension()); @@ -208,6 +227,14 @@ { } +function twig_tests_filter_sandbox(bool $isSandboxed, $string) +{ +} + +function twig_tests_filter_all(string $charset, Environment $env, array $context, bool $isSandboxed, $string) +{ +} + class ChildMagicCallStub extends ParentMagicCallStub { public static function identifier() diff -Nru php-twig-3.20.0/tests/Node/Expression/FilterTestExtension.php php-twig-3.27.0/tests/Node/Expression/FilterTestExtension.php --- php-twig-3.20.0/tests/Node/Expression/FilterTestExtension.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/FilterTestExtension.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ addFunction(new TwigFunction('anonymous', function () {})); + $env->addFunction(new TwigFunction('anonymous', static function () {})); $env->addFunction(new TwigFunction('foo', 'Twig\Tests\Node\Expression\twig_tests_function_dummy', [])); $env->addFunction(new TwigFunction('foo_closure', \Closure::fromCallable(twig_tests_function_dummy::class), [])); $env->addFunction(new TwigFunction('bar', 'Twig\Tests\Node\Expression\twig_tests_function_dummy', ['needs_environment' => true])); diff -Nru php-twig-3.20.0/tests/Node/Expression/GetAttrTest.php php-twig-3.27.0/tests/Node/Expression/GetAttrTest.php --- php-twig-3.20.0/tests/Node/Expression/GetAttrTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/GetAttrTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertEquals($attr, $node->getNode('attribute')); $this->assertEquals($args, $node->getNode('arguments')); $this->assertEquals(Template::ARRAY_CALL, $node->getAttribute('type')); + $this->assertFalse($node->getAttribute('null_safe')); } public static function provideTests(): iterable @@ -41,10 +51,27 @@ $expr = new ContextVariable('foo', 1); $attr = new ConstantExpression('bar', 1); + $attr2 = new ConstantExpression('baz', 1); + $attr3 = new ConstantExpression('qux', 1); + $attr4 = new ConstantExpression('corge', 1); $args = new ArrayExpression([], 1); + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1); $tests[] = [$node, \sprintf('%s%s, "bar", [], "any", false, false, false, 1)', self::createAttributeGetter(), self::createVariableGetter('foo', 1))]; + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); + $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1))', null, true]; + + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); + $node = new GetAttrExpression($node, $attr2, $args, Template::METHOD_CALL, 1); + $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1), "baz", [], "method", false, false, false, 1))', null, true]; + + $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); + $node = new GetAttrExpression($node, $attr2, $args, Template::ANY_CALL, 1); + $node = new GetAttrExpression($node, $attr3, $args, Template::METHOD_CALL, 1, true); + $node = new GetAttrExpression($node, $attr4, $args, Template::ANY_CALL, 1); + $tests[] = [$node, '((null === ($_v0 = ((null === ($_v1 = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v1, "bar", [], "any", false, false, false, 1), "baz", [], "any", false, false, false, 1)))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v0, "qux", [], "method", false, false, false, 1), "corge", [], "any", false, false, false, 1))', null]; + $node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1); $tests[] = [$node, '(($_v%s = // line 1'."\n". '($context["foo"] ?? null)) && is_array($_v%s) || $_v%s instanceof ArrayAccess ? ($_v%s["bar"] ?? null) : null)', null, true, ]; diff -Nru php-twig-3.20.0/tests/Node/Expression/MacroReferenceTest.php php-twig-3.27.0/tests/Node/Expression/MacroReferenceTest.php --- php-twig-3.20.0/tests/Node/Expression/MacroReferenceTest.php 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/MacroReferenceTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,41 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name)); + + new MacroReferenceExpression(new TemplateVariable('foo', 1), $name, new ArrayExpression([], 1), 1); + } + + public static function provideInvalidMacroNames(): iterable + { + yield 'empty' => ['']; + yield 'starts with digit' => ['1foo']; + yield 'contains space' => ['foo bar']; + yield 'contains semicolon' => ['foo;bar']; + yield 'PHP injection payload' => ['macro_foo + 1; trigger_error("BAD") //']; + yield 'contains NUL byte' => ["foo\x00bar"]; + } +} diff -Nru php-twig-3.20.0/tests/Node/Expression/NullCoalesceTest.php php-twig-3.27.0/tests/Node/Expression/NullCoalesceTest.php --- php-twig-3.20.0/tests/Node/Expression/NullCoalesceTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/NullCoalesceTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ addTest(new TwigTest('anonymous', function () {})); + $env->addTest(new TwigTest('anonymous', static function () {})); $env->addTest(new TwigTest('barbar', 'Twig\Tests\Node\Expression\twig_tests_test_barbar', ['is_variadic' => true, 'need_context' => true])); return $env; diff -Nru php-twig-3.20.0/tests/Node/Expression/Unary/NegTest.php php-twig-3.27.0/tests/Node/Expression/Unary/NegTest.php --- php-twig-3.20.0/tests/Node/Expression/Unary/NegTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/Expression/Unary/NegTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ [$node, "// line 1\n$compiled"]; $node = new ContextVariable($special, 1); - $node->setAttribute('is_defined_test', true); + $node->enableDefinedTest(); yield $special.'_defined_test' => [$node, "// line 1\ntrue"]; } @@ -59,13 +68,13 @@ // is defined test $node = new ContextVariable('foo', 1); - $node->setAttribute('is_defined_test', true); + $node->enableDefinedTest(); yield 'is_defined_test_strict' => [$node, "// line 1\narray_key_exists(\"foo\", \$context)", $envStrict]; yield 'is_defined_test_non_strict' => [$node, "// line 1\narray_key_exists(\"foo\", \$context)", $env]; // is defined test // always defined $node = new ContextVariable('foo', 1); - $node->setAttribute('is_defined_test', true); + $node->enableDefinedTest(); $node->setAttribute('always_defined', true); yield 'is_defined_test_always_defined_strict' => [$node, "// line 1\ntrue", $envStrict]; yield 'is_defined_test_always_defined_non_strict' => [$node, "// line 1\ntrue", $env]; diff -Nru php-twig-3.20.0/tests/Node/ForTest.php php-twig-3.27.0/tests/Node/ForTest.php --- php-twig-3.20.0/tests/Node/ForTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/ForTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ macros["macro"] = \$this->loadTemplate("foo.twig", null, 1)->unwrap(); +\$macros["macro"] = \$this->macros["macro"] = \$this->load("foo.twig", 1)->unwrap(); EOF ]; diff -Nru php-twig-3.20.0/tests/Node/IncludeTest.php php-twig-3.27.0/tests/Node/IncludeTest.php --- php-twig-3.20.0/tests/Node/IncludeTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/IncludeTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ loadTemplate("foo.twig", null, 1)->unwrap()->yield($context); +yield from $this->load("foo.twig", 1)->unwrap()->yield($context); EOF ]; @@ -55,7 +64,7 @@ $node = new IncludeNode($expr, null, false, false, 1); $tests[] = [$node, <<<'EOF' // line 1 -yield from $this->loadTemplate(((true) ? ("foo") : ("foo")), null, 1)->unwrap()->yield($context); +yield from $this->load(((true) ? ("foo") : ("foo")), 1)->unwrap()->yield($context); EOF ]; @@ -64,14 +73,14 @@ $node = new IncludeNode($expr, $vars, false, false, 1); $tests[] = [$node, <<<'EOF' // line 1 -yield from $this->loadTemplate("foo.twig", null, 1)->unwrap()->yield(CoreExtension::merge($context, ["foo" => true])); +yield from $this->load("foo.twig", 1)->unwrap()->yield(CoreExtension::merge($context, ["foo" => true])); EOF ]; $node = new IncludeNode($expr, $vars, true, false, 1); $tests[] = [$node, <<<'EOF' // line 1 -yield from $this->loadTemplate("foo.twig", null, 1)->unwrap()->yield(CoreExtension::toArray(["foo" => true])); +yield from $this->load("foo.twig", 1)->unwrap()->yield(CoreExtension::toArray(["foo" => true])); EOF ]; @@ -79,7 +88,7 @@ $tests[] = [$node, <<loadTemplate("foo.twig", null, 1); + \$_v%s = \$this->load("foo.twig", 1); } catch (LoaderError \$e) { // ignore missing template \$_v%s = null; @@ -87,8 +96,7 @@ if (\$_v%s) { yield from \$_v%s->unwrap()->yield(CoreExtension::toArray(["foo" => true])); } -EOF - , null, true]; +EOF, null, true]; return $tests; } diff -Nru php-twig-3.20.0/tests/Node/MacroTest.php php-twig-3.27.0/tests/Node/MacroTest.php --- php-twig-3.20.0/tests/Node/MacroTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/MacroTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ env->getCharset()); } -EOF - , new Environment(new ArrayLoader(), ['use_yield' => true]), +EOF, new Environment(new ArrayLoader(), ['use_yield' => true]), ]; yield 'with use_yield = false' => [$node, <<env->getCharset()); } -EOF - , new Environment(new ArrayLoader(), ['use_yield' => false]), +EOF, new Environment(new ArrayLoader(), ['use_yield' => false]), ]; } } diff -Nru php-twig-3.20.0/tests/Node/ModuleTest.php php-twig-3.27.0/tests/Node/ModuleTest.php --- php-twig-3.20.0/tests/Node/ModuleTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/ModuleTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertEquals($source->getName(), $node->getTemplateName()); } + public function testUseTagTemplateNameDoesNotInjectPhpInCompiledOutput() + { + $evilName = "evil' . print('BAD-EOL') . '.twig"; + $loader = new ArrayLoader([ + $evilName => '{% block existing %}ok{% endblock %}', + 'main.twig' => "{% use \"$evilName\" with absent_block as alias %}", + ]); + $twig = new Environment($loader); + + ob_start(); + $message = null; + try { + $twig->load('main.twig'); + } catch (RuntimeError $e) { + $message = $e->getMessage(); + } + $stdout = ob_get_clean(); + + $this->assertSame('', $stdout, 'No code from the template name must execute when the trait is loaded.'); + $this->assertNotNull($message, 'A RuntimeError must be raised for the missing block.'); + $this->assertStringContainsString($evilName, $message, 'The error message must contain the literal template name.'); + } + public static function provideTests(): iterable { $twig = new Environment(new ArrayLoader(['foo.twig' => '{{ foo }}'])); @@ -128,8 +161,7 @@ return new Source("", "foo.twig", ""); } } -EOF - , $twig, true]; +EOF, $twig, true]; $import = new ImportNode(new ConstantExpression('foo.twig', 1), new AssignTemplateVariable(new TemplateVariable('macro', 2), true), 2); @@ -183,9 +215,9 @@ { \$macros = \$this->macros; // line 2 - \$macros["macro"] = \$this->macros["macro"] = \$this->loadTemplate("foo.twig", "foo.twig", 2)->unwrap(); + \$macros["macro"] = \$this->macros["macro"] = \$this->load("foo.twig", 2)->unwrap(); // line 1 - \$this->parent = \$this->loadTemplate("layout.twig", "foo.twig", 1); + \$this->parent = \$this->load("layout.twig", 1); yield from \$this->parent->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks)); } @@ -218,8 +250,7 @@ return new Source("", "foo.twig", ""); } } -EOF - , $twig, true]; +EOF, $twig, true]; $set = new SetNode(false, new Nodes([new AssignContextVariable('foo', 4)]), new Nodes([new ConstantExpression('foo', 4)]), 4); $body = new BodyNode([$set]); @@ -271,7 +302,7 @@ protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper { // line 2 - return \$this->loadTemplate(((true) ? ("foo") : ("foo")), "foo.twig", 2); + return \$this->load(((true) ? ("foo") : ("foo")), 2); } protected function doDisplay(array \$context, array \$blocks = []): iterable @@ -312,8 +343,7 @@ return new Source("{{ foo }}", "foo.twig", ""); } } -EOF - , $twig, true]; +EOF, $twig, true]; return $tests; } diff -Nru php-twig-3.20.0/tests/Node/NodeTest.php php-twig-3.27.0/tests/Node/NodeTest.php --- php-twig-3.20.0/tests/Node/NodeTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/NodeTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ function () { return '1'; }], 1); + $node = new NodeForTest([], ['value' => static function () { return '1'; }], 1); $this->assertEquals(<<assertEquals(<<env->getCharset()); -EOF - , new Environment(new ArrayLoader(), ['use_yield' => true]), +EOF, new Environment(new ArrayLoader(), ['use_yield' => true]), ]; $tests[] = [$node, <<<'EOF' @@ -68,8 +76,7 @@ yield "foo"; yield from []; })())) ? '' : new Markup($tmp, $this->env->getCharset()); -EOF - , new Environment(new ArrayLoader(), ['use_yield' => false]), +EOF, new Environment(new ArrayLoader(), ['use_yield' => false]), ]; $names = new Nodes([new AssignContextVariable('foo', 1)], 1); diff -Nru php-twig-3.20.0/tests/Node/TextTest.php php-twig-3.27.0/tests/Node/TextTest.php --- php-twig-3.20.0/tests/Node/TextTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Node/TextTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ getAttribute('name')) { + if (NameExpression::class === $n::class && $target === $n->getAttribute('name')) { $this->assertTrue($n->getAttribute('always_defined')); } else { $this->checkForVarConfiguration($n, $target); diff -Nru php-twig-3.20.0/tests/NodeVisitor/SandboxTest.php php-twig-3.27.0/tests/NodeVisitor/SandboxTest.php --- php-twig-3.20.0/tests/NodeVisitor/SandboxTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/NodeVisitor/SandboxTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertNotInstanceOf(CheckToStringNode::class, $node->getNode('body')->getNode(0)->getNode('expr')); $this->assertSame("// line 1\nyield from (\$context[\"foo\"] ?? null);\n", $env->compile($node->getNode('body'))); } + + public function testCustomNodeImplementingCoercesChildrenToStringInterfaceIsWrapped() + { + $env = new Environment(new ArrayLoader()); + $custom = new CustomCoercingExpression(new ContextVariable('foo', 1), new ContextVariable('bar', 1), 1); + // wrap inside a PrintNode so it lives in a module; the wrapping must happen on the + // custom node itself regardless of the print context + $node = new ModuleNode(new BodyNode([new PrintNode($custom, 1)]), null, new EmptyNode(), new EmptyNode(), new EmptyNode(), new EmptyNode(), new Source('foo', 'foo')); + $traverser = new NodeTraverser($env, [new SandboxNodeVisitor($env)]); + $node = $traverser->traverse($node); + + $custom = $node->getNode('body')->getNode(0)->getNode('expr'); + $this->assertInstanceOf(CheckToStringNode::class, $custom->getNode('left')); + $this->assertInstanceOf(CheckToStringNode::class, $custom->getNode('right')); + } + + public function testCustomNonExpressionNodeImplementingCoercesChildrenToStringInterfaceIsWrapped() + { + $env = new Environment(new ArrayLoader()); + $custom = new CustomCoercingNode(['expr' => new ContextVariable('foo', 1)], [], 1); + $node = new ModuleNode(new BodyNode([$custom]), null, new EmptyNode(), new EmptyNode(), new EmptyNode(), new EmptyNode(), new Source('foo', 'foo')); + $traverser = new NodeTraverser($env, [new SandboxNodeVisitor($env)]); + $node = $traverser->traverse($node); + + $custom = $node->getNode('body')->getNode(0); + $this->assertInstanceOf(CheckToStringNode::class, $custom->getNode('expr')); + } + + public function testSelfIsNeverWrapped() + { + $env = new Environment(new ArrayLoader()); + $self = new ContextVariable('_self', 1); + $custom = new CustomCoercingNode(['expr' => $self], [], 1); + $node = new ModuleNode(new BodyNode([$custom]), null, new EmptyNode(), new EmptyNode(), new EmptyNode(), new EmptyNode(), new Source('foo', 'foo')); + $traverser = new NodeTraverser($env, [new SandboxNodeVisitor($env)]); + $node = $traverser->traverse($node); + + $this->assertNotInstanceOf(CheckToStringNode::class, $node->getNode('body')->getNode(0)->getNode('expr')); + } +} + +class CustomCoercingExpression extends AbstractExpression implements CoercesChildrenToStringInterface +{ + public function __construct(AbstractExpression $left, AbstractExpression $right, int $lineno) + { + parent::__construct(['left' => $left, 'right' => $right], [], $lineno); + } + + public function getStringCoercedChildNames(): array + { + return ['left', 'right']; + } +} + +class CustomCoercingNode extends Node implements CoercesChildrenToStringInterface +{ + public function getStringCoercedChildNames(): array + { + return ['expr']; + } } diff -Nru php-twig-3.20.0/tests/ParserTest.php php-twig-3.27.0/tests/ParserTest.php --- php-twig-3.20.0/tests/ParserTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/ParserTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); - $m->setAccessible(true); $this->assertEquals($expected, $m->invoke($parser, $input)); } @@ -99,7 +108,6 @@ $parser = $this->getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); - $m->setAccessible(true); $this->expectException(SyntaxError::class); $m->invoke($parser, $input); @@ -121,7 +129,6 @@ $parser = $this->getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); - $m->setAccessible(true); $this->assertNull($m->invoke($parser, new TextNode(\chr(0xEF).\chr(0xBB).\chr(0xBF).$emptyNode, 1))); } @@ -156,7 +163,6 @@ ], new Source('', ''))); $p = new \ReflectionProperty($parser, 'parent'); - $p->setAccessible(true); $this->assertNull($p->getValue($parser)); } @@ -173,8 +179,7 @@ {% macro foo() %} {{ foo }} {% endmacro %} -EOF - , 'index'))); +EOF, 'index'))); // The getVarName() must not depend on the template loaders, // If this test does not throw any exception, that's good. @@ -201,13 +206,26 @@ $this->assertTrue($argumentNodes->getNode(3)->getAttribute('value')); } + public function testEmbeddedTemplatesHaveSequentialIndices(): void + { + $template = new Source('{% embed "first" %}{% endembed %}{% embed "second" %}{% endembed %}', 'index'); + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize($template); + + $embeds = $this->getParser() + ->parse($stream) + ->getAttribute('embedded_templates'); + + $this->assertSame(1, $embeds->getNode(0)->getAttribute('index')); + $this->assertSame(2, $embeds->getNode(1)->getAttribute('index')); + } + protected function getParser() { $parser = new Parser(new Environment(new ArrayLoader())); - $parser->setParent(new EmptyNode()); + $parser->setParent(new ConstantExpression('base.html', 1)); $p = new \ReflectionProperty($parser, 'stream'); - $p->setAccessible(true); $p->setValue($parser, new TokenStream([], new Source('', ''))); return $parser; diff -Nru php-twig-3.20.0/tests/Profiler/Dumper/BlackfireTest.php php-twig-3.27.0/tests/Profiler/Dumper/BlackfireTest.php --- php-twig-3.20.0/tests/Profiler/Dumper/BlackfireTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Profiler/Dumper/BlackfireTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ embedded.twig//2 %d %d %d embedded.twig==>included.twig//2 %d %d %d index.twig==>index.twig::macro(foo)//1 %d %d %d -EOF - , $dumper->dump($this->getProfile())); +EOF, $dumper->dump($this->getProfile())); } } diff -Nru php-twig-3.20.0/tests/Profiler/Dumper/HtmlTest.php php-twig-3.27.0/tests/Profiler/Dumper/HtmlTest.php --- php-twig-3.20.0/tests/Profiler/Dumper/HtmlTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Profiler/Dumper/HtmlTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ embedded.twig └ included.twig -EOF - , $dumper->dump($this->getProfile())); +EOF, $dumper->dump($this->getProfile())); + } + + public function testDumpEscapesTemplateAndProfileNames() + { + $root = new Profile('main'); + $child = new Profile('', Profile::TEMPLATE); + $grandchild = new Profile('', Profile::MACRO, ''); + + (new \ReflectionProperty($child, 'profiles'))->setValue($child, [$grandchild]); + (new \ReflectionProperty($root, 'profiles'))->setValue($root, [$child]); + + $output = (new HtmlDumper())->dump($root); + + $this->assertStringNotContainsString('', $output); + $this->assertStringNotContainsString('', $output); + $this->assertStringNotContainsString('', $output); + $this->assertStringContainsString('<img src=x onerror=alert(1)>', $output); + $this->assertStringContainsString('<img src=x onerror=alert(2)>', $output); + $this->assertStringContainsString('<img src=x onerror=alert(3)>', $output); + } + + public function testDumpEscapesRootProfileName() + { + $root = new Profile('template-name', Profile::ROOT, ''); + + $output = (new HtmlDumper())->dump($root); + + $this->assertStringNotContainsString('', $output); + $this->assertStringContainsString('<img src=x onerror=alert(1)>', $output); } } diff -Nru php-twig-3.20.0/tests/Profiler/Dumper/ProfilerTestCase.php php-twig-3.27.0/tests/Profiler/Dumper/ProfilerTestCase.php --- php-twig-3.20.0/tests/Profiler/Dumper/ProfilerTestCase.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Profiler/Dumper/ProfilerTestCase.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ setAccessible(true); $p->setValue($profile, $subProfiles); return $profile; @@ -83,18 +91,15 @@ $profile = new Profile($templateName, $type, $name); $p = new \ReflectionProperty($profile, 'profiles'); - $p->setAccessible(true); $p->setValue($profile, $subProfiles); $starts = new \ReflectionProperty($profile, 'starts'); - $starts->setAccessible(true); $starts->setValue($profile, [ 'wt' => 0, 'mu' => 0, 'pmu' => 0, ]); $ends = new \ReflectionProperty($profile, 'ends'); - $ends->setAccessible(true); $ends->setValue($profile, [ 'wt' => $duration, 'mu' => 0, diff -Nru php-twig-3.20.0/tests/Profiler/Dumper/TextTest.php php-twig-3.27.0/tests/Profiler/Dumper/TextTest.php --- php-twig-3.20.0/tests/Profiler/Dumper/TextTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Profiler/Dumper/TextTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ dump($this->getProfile())); +EOF, $dumper->dump($this->getProfile())); } } diff -Nru php-twig-3.20.0/tests/Profiler/ProfileTest.php php-twig-3.27.0/tests/Profiler/ProfileTest.php --- php-twig-3.20.0/tests/Profiler/ProfileTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Profiler/ProfileTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertEquals($profile1->getName(), $profile3->getName()); } + public function testUnserializeDoesNotInstantiateArbitraryClasses() + { + $payload = serialize([ + 'template', + 'name', + Profile::ROOT, + [], + [], + [new ProfileTestProbe()], + ]); + + $profile = new Profile(); + $profile->unserialize($payload); + + $this->assertFalse(ProfileTestProbe::$wakeupCalled, 'Magic unserialize methods must not be called on arbitrary classes'); + } + public function testReset() { $profile = new Profile(); @@ -122,3 +148,13 @@ $this->assertEquals(0, $profile->getDuration()); } } + +class ProfileTestProbe +{ + public static bool $wakeupCalled = false; + + public function __unserialize(array $data): void + { + self::$wakeupCalled = true; + } +} diff -Nru php-twig-3.20.0/tests/Resources/LegacyCoreTest.php php-twig-3.27.0/tests/Resources/LegacyCoreTest.php --- php-twig-3.20.0/tests/Resources/LegacyCoreTest.php 1970-01-01 00:00:00.000000000 +0000 +++ php-twig-3.27.0/tests/Resources/LegacyCoreTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -0,0 +1,297 @@ +createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + + $template->callLegacySort(['b', 'a'], 'strnatcasecmp'); + } + + public function testTwigSortFilterRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $sandboxedTemplate = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + $trustedTemplate = new LegacyCoreTestTemplate($env, 'trusted.twig'); + + // Trusted template: non-Closure callable is allowed (only a deprecation is triggered). + $this->assertSame(['a', 'b'], array_values($trustedTemplate->callLegacySort(['b', 'a'], 'strnatcasecmp'))); + + // Sandboxed template: non-Closure callable is rejected thanks to Source recovery. + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $sandboxedTemplate->callLegacySort(['b', 'a'], 'strnatcasecmp'); + } + + public function testTwigArrayFilterRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + iterator_to_array($template->callLegacyArrayFilter(['a', 'b'], 'is_string')); + } + + public function testTwigArrayMapRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArrayMap(['a', 'b'], 'strtoupper'); + } + + public function testTwigArrayColumnEnforcesSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(SecurityNotAllowedPropertyError::class); + $template->callLegacyArrayColumn([new LegacyColumnObject()], 'bar'); + } + + public function testTwigArrayReduceRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArrayReduce([1, 2], 'intval'); + } + + public function testTwigArraySomeEnforcesGlobalSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArraySome(['a', 'b'], 'is_string'); + } + + public function testTwigArraySomeRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArraySome(['a', 'b'], 'is_string'); + } + + public function testTwigArrayEveryEnforcesGlobalSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArrayEvery(['a', 'b'], 'is_string'); + } + + public function testTwigArrayEveryRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyArrayEvery(['a', 'b'], 'is_string'); + } + + public function testTwigCheckArrowInSandboxEnforcesGlobalSandbox() + { + $env = $this->createSandboxedEnvironment(true); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyCheckArrowInSandbox('strlen', 'test', 'filter'); + } + + public function testTwigCheckArrowInSandboxRecoversSourceForSourcePolicy() + { + $env = $this->createSandboxedEnvironment(false, new class implements SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return 'sandboxed.twig' === $source->getName(); + } + }); + + $template = new LegacyCoreTestTemplate($env, 'sandboxed.twig'); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessageMatches('/must be a Closure in sandbox mode/'); + $template->callLegacyCheckArrowInSandbox('strlen', 'test', 'filter'); + } + + public function testTwigCheckArrowInSandboxIsNoopWhenNotSandboxed() + { + $env = $this->createSandboxedEnvironment(false); + $template = new LegacyCoreTestTemplate($env, 'index.twig'); + + $template->callLegacyCheckArrowInSandbox('strlen', 'test', 'filter'); + $this->expectNotToPerformAssertions(); + } + + private function createSandboxedEnvironment(bool $globallySandboxed, ?SourcePolicyInterface $sourcePolicy = null): Environment + { + $env = new Environment(new ArrayLoader([]), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new SandboxExtension(new SecurityPolicy(), $globallySandboxed, $sourcePolicy)); + + return $env; + } +} + +class LegacyCoreTestTemplate extends Template +{ + public function __construct(Environment $env, private string $name) + { + parent::__construct($env); + } + + public function callLegacySort($array, $arrow) + { + return twig_sort_filter($this->env, $array, $arrow); + } + + public function callLegacyArrayFilter($array, $arrow) + { + return twig_array_filter($this->env, $array, $arrow); + } + + public function callLegacyArrayMap($array, $arrow) + { + return twig_array_map($this->env, $array, $arrow); + } + + public function callLegacyArrayReduce($array, $arrow, $initial = null) + { + return twig_array_reduce($this->env, $array, $arrow, $initial); + } + + public function callLegacyArrayColumn($array, $name, $index = null) + { + return twig_array_column($this->env, $array, $name, $index); + } + + public function callLegacyArraySome($array, $arrow) + { + return twig_array_some($this->env, $array, $arrow); + } + + public function callLegacyArrayEvery($array, $arrow) + { + return twig_array_every($this->env, $array, $arrow); + } + + public function callLegacyCheckArrowInSandbox($arrow, $thing, $type) + { + twig_check_arrow_in_sandbox($this->env, $arrow, $thing, $type); + } + + public function getTemplateName(): string + { + return $this->name; + } + + public function getDebugInfo(): array + { + return []; + } + + public function getSourceContext(): Source + { + return new Source('', $this->name); + } + + protected function doGetParent(array $context): bool|string|Template|TemplateWrapper + { + return false; + } + + protected function doDisplay(array $context, array $blocks = []): iterable + { + return []; + } +} + +class LegacyColumnObject +{ + public $bar = 'bar'; +} diff -Nru php-twig-3.20.0/tests/Runtime/EscaperRuntimeTest.php php-twig-3.27.0/tests/Runtime/EscaperRuntimeTest.php --- php-twig-3.20.0/tests/Runtime/EscaperRuntimeTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/Runtime/EscaperRuntimeTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ htmlAttrSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key); + } + } + public function testJavascriptEscapingConvertsSpecialChars() { foreach ($this->jsSpecialChars as $key => $value) { @@ -319,6 +335,26 @@ } } } + } + + public function testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges() + { + $immune = [',', '.', '-', '_', ':', '@', '[', ']']; // Exceptions to escaping ranges + for ($chr = 0; $chr < 0xFF; ++$chr) { + if ($chr >= 0x30 && $chr <= 0x39 + || $chr >= 0x41 && $chr <= 0x5A + || $chr >= 0x61 && $chr <= 0x7A) { + $literal = $this->codepointToUtf8($chr); + $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed')); + } else { + $literal = $this->codepointToUtf8($chr); + if (\in_array($literal, $immune)) { + $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed')); + } else { + $this->assertNotEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'), "$literal should be escaped!"); + } + } + } } public function testCssEscapingEscapesOwaspRecommendedRanges() diff -Nru php-twig-3.20.0/tests/TemplateTest.php php-twig-3.27.0/tests/TemplateTest.php --- php-twig-3.20.0/tests/TemplateTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/TemplateTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ assertSame($expected, trim($twig->render('index.twig'))); + } + + public static function getNullCoalesceWithImportedMacroData(): array + { + return [ + 'from import' => [ + [ + 'index.twig' => '{% from "helper.twig" import foo %}{{ foo("bar") ?? "" }}', + 'helper.twig' => '{% macro foo(param) %}{{ param }}{% endmacro %}', + ], + 'bar', + ], + 'from import with undefined macro falls back' => [ + [ + 'index.twig' => '{% from "helper.twig" import foo, nonexistent %}{{ nonexistent("bar") ?? "fallback" }}', + 'helper.twig' => '{% macro foo(param) %}{{ param }}{% endmacro %}', + ], + 'fallback', + ], + 'from import used multiple times' => [ + [ + 'index.twig' => '{% from "helper.twig" import foo %}{{ foo("a") ?? "" }}-{{ foo("b") ?? "" }}', + 'helper.twig' => '{% macro foo(param) %}{{ param }}{% endmacro %}', + ], + 'a-b', + ], + ]; + } + public function testRenderBlockWithUndefinedBlock() { $twig = new Environment(new ArrayLoader()); @@ -194,7 +240,7 @@ } $this->assertSame('FloatButString', $array['1.5']); $this->assertSame('IntegerButStringWithLeadingZeros', $array['01']); - $this->assertSame('EmptyString', $array[null]); + $this->assertSame('EmptyString', $array['']); $this->assertSame('Zero', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, false), 'false is treated as 0 when accessing a sequence/mapping (equals PHP behavior)'); $this->assertSame('One', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, true), 'true is treated as 1 when accessing a sequence/mapping (equals PHP behavior)'); @@ -395,8 +441,8 @@ ]); // test for Closure::__invoke() - $tests[] = [true, 'closure called', fn (): string => 'closure called', '__invoke', [], $anyType]; - $tests[] = [true, 'closure called', fn (): string => 'closure called', '__invoke', [], $methodType]; + $tests[] = [true, 'closure called', static fn (): string => 'closure called', '__invoke', [], $anyType]; + $tests[] = [true, 'closure called', static fn (): string => 'closure called', '__invoke', [], $methodType]; // tests when input is not an array or object $tests = array_merge($tests, [ @@ -558,7 +604,7 @@ { public $defined = 'defined'; public $zero = 0; - public $null = null; + public $null; public $bar = true; public $foo = true; public $baz = 'baz'; diff -Nru php-twig-3.20.0/tests/TemplateWrapperTest.php php-twig-3.27.0/tests/TemplateWrapperTest.php --- php-twig-3.20.0/tests/TemplateWrapperTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/TemplateWrapperTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ expectNotToPerformAssertions(); $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); - $env->registerUndefinedFunctionCallback(fn ($name) => throw new SyntaxError('boom.')); + $env->registerUndefinedFunctionCallback(static fn ($name) => throw new SyntaxError('boom.')); (new Parser($env))->parse($env->tokenize(new Source('{% guard function boom %}{% endguard %}', ''))); } } diff -Nru php-twig-3.20.0/tests/TokenParser/TypesTokenParserTest.php php-twig-3.27.0/tests/TokenParser/TypesTokenParserTest.php --- php-twig-3.20.0/tests/TokenParser/TypesTokenParserTest.php 2025-02-13 08:34:43.000000000 +0000 +++ php-twig-3.27.0/tests/TokenParser/TypesTokenParserTest.php 2026-05-27 13:05:51.000000000 +0000 @@ -1,5 +1,14 @@ output -perl -p -i -e 's/^([A-Za-z]+)\: (.+)$/export DRUPAL_\1=\2/' output -source output -#echo '$config["system.logging"]["error_level"] = "verbose";' >> web/sites/default/settings.php - -wget https://get.symfony.com/cli/installer -O - | bash -export PATH="$HOME/.symfony5/bin:$PATH" -symfony server:start -d --no-tls - -curl -LsS -o blackfire-player.phar https://get.blackfire.io/blackfire-player-v1.31.0.phar -chmod +x blackfire-player.phar -cat > drupal-tests.bkf <