diff --git a/.gitattributes b/.gitattributes index 06bc3671340..c07b0dfb56e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,10 @@ +/.github/ export-ignore +/bin/ export-ignore /doc/ export-ignore /extra/ export-ignore /tests/ export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore /phpunit.xml.dist export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..5ace4600a1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f23f9a475..188e4bfda08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,6 @@ name: "CI" on: pull_request: push: - branches: - - '3.x' env: SYMFONY_PHPUNIT_DISABLE_RESULT_CACHE: 1 @@ -18,21 +16,17 @@ jobs: runs-on: 'ubuntu-latest' - continue-on-error: ${{ matrix.experimental }} - strategy: matrix: php-version: - - '7.2.5' - - '7.3' - - '7.4' - - '8.0' - '8.1' - experimental: [false] + - '8.2' + - '8.3' + - '8.4' steps: - name: "Checkout code" - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 @@ -46,6 +40,11 @@ jobs: - run: composer install + - name: "Switch use_yield to true on PHP ${{ matrix.php-version }}" + if: "matrix.php-version == '8.2'" + run: | + sed -i -e "s/'use_yield' => false/'use_yield' => true/" src/Environment.php + - name: "Install PHPUnit" run: vendor/bin/simple-phpunit install @@ -59,7 +58,7 @@ jobs: needs: - 'tests' - name: "${{ matrix.extension }} with PHP ${{ matrix.php-version }}" + name: "${{ matrix.extension }} PHP ${{ matrix.php-version }}" runs-on: 'ubuntu-latest' @@ -68,25 +67,23 @@ jobs: strategy: matrix: php-version: - - '7.2.5' - - '7.3' - - '7.4' - - '8.0' - '8.1' + - '8.2' + - '8.3' + - '8.4' extension: - - 'extra/cache-extra' - - 'extra/cssinliner-extra' - - 'extra/html-extra' - - 'extra/inky-extra' - - 'extra/intl-extra' - - 'extra/markdown-extra' - - 'extra/string-extra' - - 'extra/twig-extra-bundle' - experimental: [false] + - 'cache-extra' + - 'cssinliner-extra' + - 'html-extra' + - 'inky-extra' + - 'intl-extra' + - 'markdown-extra' + - 'string-extra' + - 'twig-extra-bundle' steps: - name: "Checkout code" - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 @@ -98,7 +95,8 @@ jobs: - name: "Add PHPUnit matcher" run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - run: composer install + - name: "Composer install Twig" + run: composer install - name: "Install PHPUnit" run: vendor/bin/simple-phpunit install @@ -106,44 +104,77 @@ jobs: - name: "PHPUnit version" run: vendor/bin/simple-phpunit --version - - name: "Composer install" - working-directory: ${{ matrix.extension}} + - name: "Prevent installing symfony/translation-contract 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'" + + - name: "Composer install ${{ matrix.extension }}" + working-directory: extra/${{ matrix.extension }} run: composer install - - name: "Run tests" - working-directory: ${{ matrix.extension}} + - name: "Switch use_yield to true" + if: "matrix.php-version == '8.2'" + run: | + sed -i -e "s/'use_yield' => false/'use_yield' => true/" extra/${{ matrix.extension }}/vendor/twig/twig/src/Environment.php + + - name: "Run tests for ${{ matrix.extension }}" + working-directory: extra/${{ matrix.extension }} run: ../../vendor/bin/simple-phpunit -# -# Drupal does not support Twig 3 now! -# -# integration-tests: -# needs: -# - 'tests' -# -# name: "Integration tests with PHP ${{ matrix.php-version }}" -# -# runs-on: 'ubuntu-20.04' -# -# continue-on-error: true -# -# strategy: -# matrix: -# php-version: -# - '7.3' -# -# steps: -# - name: "Checkout code" -# uses: actions/checkout@v2 -# -# - name: "Install PHP with extensions" -# uses: shivammathur/setup-php@2 -# with: -# coverage: "none" -# extensions: "gd, pdo_sqlite" -# php-version: ${{ matrix.php-version }} -# ini-values: memory_limit=-1 -# tools: composer:v2 -# -# - run: bash ./tests/drupal_test.sh -# shell: "bash" + 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" + + phpstan: + name: "PHPStan" + + runs-on: 'ubuntu-latest' + + strategy: + matrix: + php-version: + - '8.4' + + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - name: "Install PHP with extensions" + uses: shivammathur/setup-php@v2 + with: + coverage: "none" + php-version: ${{ matrix.php-version }} + ini-values: memory_limit=-1 + + - run: composer install + + - name: "Run tests" + run: vendor/bin/phpstan diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index ee83b588749..8e6d5011810 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -3,9 +3,6 @@ name: "Documentation" on: pull_request: push: - branches: - - '2.x' - - '3.x' permissions: contents: read @@ -18,22 +15,22 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: "Set-up PHP" uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none tools: "composer:v2" - name: Get composer cache directory id: composercache working-directory: doc/_build - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -54,7 +51,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: "Run DOCtor-RST" uses: docker://oskarstark/doctor-rst diff --git a/.github/workflows/fabbot.yml b/.github/workflows/fabbot.yml new file mode 100644 index 00000000000..7aa4bc682d6 --- /dev/null +++ b/.github/workflows/fabbot.yml @@ -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 --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b07ac7fcabd..b57df306ac6 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -13,7 +13,16 @@ 'heredoc_to_nowdoc' => false, 'ordered_imports' => true, 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], - 'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'all'], + 'header_comment' => [ + 'header' => <<setRiskyAllowed(true) ->setFinder((new PhpCsFixer\Finder())->in(__DIR__)) diff --git a/CHANGELOG b/CHANGELOG index 923eb8aca93..7dccdc4bc57 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,325 @@ -# 3.4.4 (2022-XX-XX) +# 3.22.1 (2025-XX-XX) * n/a +# 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 + * Add configuration for Commonmark + * Fix wrong array index + * Bump minimum PHP version to 8.1 + * Add support for registering callbacks for undefined functions, filters or token parsers in the IntegrationTestCase + * Use correct line number for `ForElseNode` + * Fix timezone conversion on strings + +# 3.19.0 (2025-01-28) + + * Fix a security issue where escaping was missing when using `??` + * Deprecate `Token::getType()`, use `Token::test()` instead + * Add `Token::toEnglish()` + * Add `ForElseNode` + * Deprecate `Twig\ExpressionParser::parseOnlyArguments()` and + `Twig\ExpressionParser::parseArguments()` (use + `Twig\ExpressionParser::parseNamedArguments()` instead) + * Fix `constant()` behavior when used with `??` + * Add the `invoke` filter + * Make `{}` optional for the `types` tag + * Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes + * Ignore static properties when using the dot operator + +# 3.18.0 (2024-12-29) + + * 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()`) + +# 3.17.1 (2024-12-12) + + * 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) + + * Fix ArrayAccess with objects as keys + * Support underscores in number literals + * Deprecate `ConditionalExpression` and `NullCoalesceExpression` (use `ConditionalTernary` and `NullCoalesceBinary` instead) + +# 3.16.0 (2024-11-29) + + * Deprecate `InlinePrint` + * Fix having macro variables starting with an underscore + * Deprecate not passing a `Source` instance to `TokenStream` + * Deprecate returning `null` from `TwigFilter::getSafe()` and `TwigFunction::getSafe()`, return `[]` instead + +# 3.15.0 (2024-11-17) + + * [BC BREAK] Add support for accessing class constants with the dot operator; + this can be a BC break if you don't use UPPERCASE constant names + * Add Spanish inflector support for the `plural` and `singular` filters in the String extension + * Deprecate `TempNameExpression` in favor of `LocalVariable` + * Deprecate `NameExpression` in favor of `ContextVariable` + * Deprecate `AssignNameExpression` in favor of `AssignContextVariable` + * Remove `MacroAutoImportNodeVisitor` + * Deprecate `MethodCallExpression` in favor of `MacroReferenceExpression` + * Fix support for the "is defined" test on `_self.xxx` (auto-imported) macros + * Fix support for the "is defined" test on inherited macros + * Add named arguments support for the dot operator arguments (`foo.bar(some: arg)`) + * Add named arguments support for macros + * Add a new `guard` tag that allows to test if some Twig callables are available at compilation time + * Allow arrow functions everywhere + * Deprecate passing a string or an array to Twig callable arguments accepting arrow functions (pass a `\Closure`) + * Add support for triggering deprecations for future operator precedence changes + * Deprecate using the `not` unary operator in an expression with ``*``, ``/``, ``//``, or ``%`` without using explicit parentheses to clarify precedence + * Deprecate using the `??` binary operator without explicit parentheses + * Deprecate using the `~` binary operator in an expression with `+` or `-` without using parentheses to clarify precedence + * Deprecate not passing `AbstractExpression` args to most constructor arguments for classes extending `AbstractExpression` + * Fix `power` expressions with a negative number in parenthesis (`(-1) ** 2`) + * Deprecate instantiating `Node` directly. Use `EmptyNode` or `Nodes` instead. + * Add support for inline comments + * Add `Profile::getStartTime()` and `Profile::getEndTime()` + * Fix "ignore missing" when used on an "embed" tag + * Fix the possibility to override an aliased block (via use) + * Add template cache hot reload + * Allow Twig callable argument names to be free-form (snake-case or camelCase) independently of the PHP callable signature + They were automatically converted to snake-cased before + * Deprecate the `attribute` function; use the `.` notation and wrap the name with parenthesis instead + * Add support for argument unpackaging + * Add JSON support for the file extension escaping strategy + * Support Markup instances (and any other \Stringable) as dynamic mapping keys + * Deprecate the `sandbox` tag + * Improve the way one can deprecate a Twig callable (use `deprecation_info` instead of the other callable options) + * Add the `enum` function + * Add support for logical `xor` operator + +# 3.14.2 (2024-11-07) + + * Fix an infinite recursion in the sandbox code + +# 3.14.1 (2024-11-06) + + * [BC BREAK] Fix a security issue in the sandbox mode allowing an attacker to call attributes on Array-like objects + They are now checked via the property policy + * Fix a security issue in the sandbox mode allowing an attacker to be able to call `toString()` + under some circumstances on an object even if the `__toString()` method is not allowed by the security policy + +# 3.14.0 (2024-09-09) + + * Fix a security issue when an included sandboxed template has been loaded before without the sandbox context + * Add the possibility to reset globals via `Environment::resetGlobals()` + * Deprecate `Environment::mergeGlobals()` + +# 3.13.0 (2024-09-07) + + * Add the `types` tag (experimental) + * Deprecate the `Twig\Test\NodeTestCase::getTests()` data provider, override `provideTests()` instead. + * Mark `Twig\Test\NodeTestCase::getEnvironment()` as final, override `createEnvironment()` instead. + * Deprecate `Twig\Test\NodeTestCase::getVariableGetter()`, call `createVariableGetter()` instead. + * Deprecate `Twig\Test\NodeTestCase::getAttributeGetter()`, call `createAttributeGetter()` instead. + * Deprecate not overriding `Twig\Test\IntegrationTestCase::getFixturesDirectory()`, this method will be abstract in 4.0 + * Marked `Twig\Test\IntegrationTestCase::getTests()` and `getLegacyTests()` as final + +# 3.12.0 (2024-08-29) + + * Deprecate the fact that the `extends` and `use` tags are always allowed in a sandboxed template. + This behavior will change in 4.0 where these tags will need to be explicitly allowed like any other tag. + * Deprecate the "tag" constructor argument of the "Twig\Node\Node" class as the tag is now automatically set by the Parser when needed + * Fix precedence of two-word tests when the first word is a valid test + * Deprecate the `spaceless` filter + * Deprecate some internal methods from `Parser`: `getBlockStack()`, `hasBlock()`, `getBlock()`, `hasMacro()`, `hasTraits()`, `getParent()` + * Deprecate passing `null` to `Twig\Parser::setParent()` + * Update `Node::__toString()` to include the node tag if set + * Add support for integers in methods of `Twig\Node\Node` that take a Node name + * Deprecate not passing a `BodyNode` instance as the body of a `ModuleNode` or `MacroNode` constructor + * Deprecate returning "null" from "TokenParserInterface::parse()". + * Deprecate `OptimizerNodeVisitor::OPTIMIZE_TEXT_NODES` + * Fix performance regression when `use_yield` is `false` (which is the default) + * Improve compatibility when `use_yield` is `false` (as extensions still using `echo` will work as is) + * Accept colons (`:`) in addition to equals (`=`) to separate argument names and values in named arguments + * Add the `html_cva` function (in the HTML extra package) + * Add support for named arguments to the `block` and `attribute` functions + * Throw a SyntaxError exception at compile time when a Twig callable has not the minimum number of required arguments + * Add a `CallableArgumentsExtractor` class + * Deprecate passing a name to `FunctionExpression`, `FilterExpression`, and `TestExpression`; + pass a `TwigFunction`, `TwigFilter`, or `TestFilter` instead + * Deprecate all Twig callable attributes on `FunctionExpression`, `FilterExpression`, and `TestExpression` + * Deprecate the `filter` node of `FilterExpression` + * Add the notion of Twig callables (functions, filters, and tests) + * Bump minimum PHP version to 8.0 + * Fix integration tests when a test has more than one data/expect section and deprecations + * Add the `enum_cases` function + +# 3.11.2 (2024-11-06) + + * [BC BREAK] Fix a security issue in the sandbox mode allowing an attacker to call attributes on Array-like objects + They are now checked via the property policy + * Fix a security issue in the sandbox mode allowing an attacker to be able to call `toString()` + under some circumstances on an object even if the `__toString()` method is not allowed by the security policy + +# 3.11.1 (2024-09-10) + + * Fix a security issue when an included sandboxed template has been loaded before without the sandbox context + +# 3.11.0 (2024-08-08) + + * Deprecate `OptimizerNodeVisitor::OPTIMIZE_RAW_FILTER` + * Add `Twig\Cache\ChainCache` and `Twig\Cache\ReadOnlyFilesystemCache` + * Add the possibility to deprecate attributes and nodes on `Node` + * Add the possibility to add a package and a version to the `deprecated` tag + * Add the possibility to add a package for filter/function/test deprecations + * Mark `ConstantExpression` as being `@final` + * Add the `find` filter + * Fix optimizer mode validation in `OptimizerNodeVisitor` + * Add the possibility to yield from a generator in `PrintNode` + * Add the `shuffle` filter + * Add the `singular` and `plural` filters in `StringExtension` + * Deprecate the second argument of `Twig\Node\Expression\CallExpression::compileArguments()` + * Deprecate `Twig\ExpressionParser\parseHashExpression()` in favor of + `Twig\ExpressionParser::parseMappingExpression()` + * Deprecate `Twig\ExpressionParser\parseArrayExpression()` in favor of + `Twig\ExpressionParser::parseSequenceExpression()` + * Add `sequence` and `mapping` tests + * Deprecate `Twig\Node\Expression\NameExpression::isSimple()` and + `Twig\Node\Expression\NameExpression::isSpecial()` + +# 3.10.3 (2024-05-16) + + * Fix missing ; in generated code + +# 3.10.2 (2024-05-14) + + * Fix support for the deprecated escaper signature + +# 3.10.1 (2024-05-12) + + * Fix BC break on escaper extension + * Fix constant return type + +# 3.10.0 (2024-05-11) + + * Make `CoreExtension::formatDate`, `CoreExtension::convertDate`, and + `CoreExtension::formatNumber` part of the public API + * Add `needs_charset` option for filters and functions + * Extract the escaping logic from the `EscaperExtension` class to a new + `EscaperRuntime` class. + + The following methods from ``Twig\\Extension\\EscaperExtension`` are + deprecated: ``setEscaper()``, ``getEscapers()``, ``setSafeClasses``, + ``addSafeClasses()``. Use the same methods on the + ``Twig\\Runtime\\EscaperRuntime`` class instead. + * Fix capturing output from extensions that still use echo + * Fix a PHP warning in the Lexer on malformed templates + * Fix blocks not available under some circumstances + * Synchronize source context in templates when setting a Node on a Node + +# 3.9.3 (2024-04-18) + + * Add missing `twig_escape_filter_is_safe` deprecated function + * Fix yield usage with CaptureNode + * Add missing unwrap call when using a TemplateWrapper instance internally + * Ensure Lexer is initialized early on + +# 3.9.2 (2024-04-17) + + * Fix usage of display_end hook + +# 3.9.1 (2024-04-17) + + * Fix missing `$blocks` variable in `CaptureNode` + +# 3.9.0 (2024-04-16) + + * Add support for PHP 8.4 + * Deprecate AbstractNodeVisitor + * Deprecate passing Template to Environment::resolveTemplate(), Environment::load(), and Template::loadTemplate() + * Add a new "yield" mode for output generation; + Node implementations that use "echo" or "print" should use "yield" instead; + all Node implementations should be flagged with `#[YieldReady]` once they've been made ready for "yield"; + the "use_yield" Environment option can be turned on when all nodes have been made `#[YieldReady]`; + "yield" will be the only strategy supported in the next major version + * Add return type for Symfony 7 compatibility + * Fix premature loop exit in Security Policy lookup of allowed methods/properties + * Deprecate all internal extension functions in favor of methods on the extension classes + * Mark all extension functions as @internal + * Add SourcePolicyInterface to selectively enable the Sandbox based on a template's Source + * Throw a proper Twig exception when using cycle on an empty array + +# 3.8.0 (2023-11-21) + + * Catch errors thrown during template rendering + * Fix IntlExtension::formatDateTime use of date formatter prototype + * Fix premature loop exit in Security Policy lookup of allowed methods/properties + * Remove NumberFormatter::TYPE_CURRENCY (deprecated in PHP 8.3) + * Restore return type annotations + * Allow Symfony 7 packages to be installed + * Deprecate `twig_test_iterable` function. Use the native `is_iterable` instead. + +# 3.7.1 (2023-08-28) + + * Fix some phpdocs + +# 3.7.0 (2023-07-26) + + * Add support for the ...spread operator on arrays and hashes + +# 3.6.1 (2023-06-08) + + * Suppress some native return type deprecation messages + +# 3.6.0 (2023-05-03) + + * Allow psr/container 2.0 + * Add the new PHP 8.0 IntlDateFormatter::RELATIVE_* constants for date formatting + * Make the Lexer initialize itself lazily + +# 3.5.1 (2023-02-08) + + * Arrow functions passed to the "reduce" filter now accept the current key as a third argument + * Restores the leniency of the matches twig comparison + * Fix error messages in sandboxed mode for "has some" and "has every" + +# 3.5.0 (2022-12-27) + + * Make Twig\ExpressionParser non-internal + * Add "has some" and "has every" operators + * Add Compile::reset() + * Throw a better runtime error when the "matches" regexp is not valid + * Add "twig *_names" intl functions + * Fix optimizing closures callbacks + * Add a better exception when getting an undefined constant via `constant` + * Fix `if` nodes when outside of a block and with an empty body + # 3.4.3 (2022-09-28) * Fix a security issue on filesystem loader (possibility to load a template outside a configured directory) @@ -145,7 +463,7 @@ * removed Parser::isReservedMacroName() * removed SanboxedPrintNode * removed Node::setTemplateName() - * made classes maked as "@final" final + * made classes marked as "@final" final * removed InitRuntimeInterface, ExistsLoaderInterface, and SourceContextLoaderInterface * removed the "spaceless" tag * removed Twig\Environment::getBaseTemplateClass() and Twig\Environment::setBaseTemplateClass() diff --git a/LICENSE b/LICENSE index 8711927f6d9..fd8234e511b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2009-2022 by the Twig Team. +Copyright (c) 2009-present by the Twig Team. All rights reserved. diff --git a/README.rst b/README.rst index fbe7e9a9f83..7bf8c673ed0 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ Sponsors .. raw:: html - + Blackfire.io diff --git a/bin/generate_operators_precedence.php b/bin/generate_operators_precedence.php new file mode 100644 index 00000000000..185a0147ed6 --- /dev/null +++ b/bin/generate_operators_precedence.php @@ -0,0 +1,97 @@ +getExpressionParsers() as $expressionParser) { + $expressionParsers[] = $expressionParser; + $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, 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; + } + fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", + (!$previous || $previousPrecedence !== $precedence ? $precedence : '').($expressionParser->getPrecedenceChange() ? ' => '.$expressionParser->getPrecedenceChange()->getNewPrecedence() : ''), + '``'.$expressionParser->getName().'``', + !$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, 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; + } + fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", + !$previous || $previousPrecedence !== $precedence ? $precedence : '', + '``'.$expressionParser->getName().'``', + !$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 --git a/composer.json b/composer.json index 33e46405c93..366236637c3 100644 --- a/composer.json +++ b/composer.json @@ -24,15 +24,23 @@ } ], "require": { - "php": ">=7.2.5", + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.3", "symfony/polyfill-ctype": "^1.8" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0", - "psr/container": "^1.0" + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0", + "psr/container": "^1.0|^2.0", + "phpstan/phpstan": "^2.0" }, "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], "psr-4" : { "Twig\\" : "src/" } @@ -41,10 +49,5 @@ "psr-4" : { "Twig\\Tests\\" : "tests/" } - }, - "extra": { - "branch-alias": { - "dev-master": "3.4-dev" - } } } diff --git a/doc/_build/build.php b/doc/_build/build.php index b93ef3bc8b6..25950f78977 100755 --- a/doc/_build/build.php +++ b/doc/_build/build.php @@ -1,6 +1,15 @@ #!/usr/bin/env php addGlobal('text', new Text()); @@ -175,6 +175,17 @@ The ``\Twig\TwigFilter`` class takes an array of options as its last argument:: $filter = new \Twig\TwigFilter('rot13', 'str_rot13', $options); +Charset-aware Filters +~~~~~~~~~~~~~~~~~~~~~ + +If you want to access the default charset in your filter, set the +``needs_charset`` option to ``true``; Twig will pass the default charset as the +first argument to the filter call:: + + $filter = new \Twig\TwigFilter('rot13', function (string $charset, $string) { + return str_rot13($string); + }, ['needs_charset' => true]); + Environment-aware Filters ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -260,23 +271,59 @@ A dynamic filter can define more than one dynamic parts:: The filter receives all dynamic part values before the normal filter arguments, but after the environment and the context. For instance, a call to -``'foo'|a_path_b()`` will result in the following arguments to be passed to the -filter: ``('a', 'b', 'foo')``. +``'Paris'|a_path_b()`` will result in the following arguments to be passed to the +filter: ``('a', 'b', 'Paris')``. Deprecated Filters ~~~~~~~~~~~~~~~~~~ -You can mark a filter as being deprecated by setting the ``deprecated`` option -to ``true``. You can also give an alternative filter that replaces the -deprecated one when that makes sense:: +.. versionadded:: 3.15 + + The ``deprecation_info`` option was added in Twig 3.15. + +You can mark a filter as being deprecated by setting the ``deprecation_info`` +option:: $filter = new \Twig\TwigFilter('obsolete', function () { // ... - }, ['deprecated' => true, 'alternative' => 'new_one']); + }, ['deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.11', 'new_one')]); + +The ``DeprecatedCallableInfo`` constructor takes the following parameters: + +* The Composer package name that defines the filter; +* The version when the filter was deprecated. + +Optionally, you can also provide the following parameters about an alternative: + +* The package name that contains the alternative filter; +* The alternative filter name that replaces the deprecated one; +* The package version that added the alternative filter. When a filter is deprecated, Twig emits a deprecation notice when compiling a template using it. See :ref:`deprecation-notices` for more information. +.. note:: + + Before Twig 3.15, you can mark a filter as being deprecated by setting the + ``deprecated`` option to ``true``. You can also give an alternative filter + that replaces the deprecated one when that makes sense:: + + $filter = new \Twig\TwigFilter('obsolete', function () { + // ... + }, ['deprecated' => true, 'alternative' => 'new_one']); + + .. versionadded:: 3.11 + + The ``deprecating_package`` option was added in Twig 3.11. + + You can also set the ``deprecating_package`` option to specify the package + that is deprecating the filter, and ``deprecated`` can be set to the + package version when the filter was deprecated:: + + $filter = new \Twig\TwigFilter('obsolete', function () { + // ... + }, ['deprecated' => '1.1', 'deprecating_package' => 'twig/some-package']); + Functions --------- @@ -327,11 +374,11 @@ compilation. This is useful if your test can be compiled into PHP primitives. This is used by many of the tests built into Twig:: namespace App; - + use Twig\Environment; use Twig\Node\Expression\TestExpression; use Twig\TwigTest; - + $twig = new Environment($loader); $test = new TwigTest( 'odd', @@ -402,7 +449,7 @@ Most of the time though, a tag is not needed: * If your tag does not output anything, but only exists because of a side effect, create a **function** that returns nothing and call it via the - :doc:`filter ` tag. + :doc:`do ` tag. For instance, if you want to create a tag that logs text, create a ``log`` function instead and call it via the :doc:`do ` tag: @@ -445,18 +492,19 @@ Add a tag by calling the ``addTokenParser`` method on the ``\Twig\Environment`` instance:: $twig = new \Twig\Environment($loader); - $twig->addTokenParser(new Project_Set_TokenParser()); + $twig->addTokenParser(new CustomSetTokenParser()); Defining a Token Parser ~~~~~~~~~~~~~~~~~~~~~~~ Now, let's see the actual code of this class:: - class Project_Set_TokenParser extends \Twig\TokenParser\AbstractTokenParser + class CustomSetTokenParser extends \Twig\TokenParser\AbstractTokenParser { public function parse(\Twig\Token $token) { $parser = $this->parser; + $lineno = $token->getLine(); $stream = $parser->getStream(); $name = $stream->expect(\Twig\Token::NAME_TYPE)->getValue(); @@ -464,7 +512,7 @@ Now, let's see the actual code of this class:: $value = $parser->getExpressionParser()->parseExpression(); $stream->expect(\Twig\Token::BLOCK_END_TYPE); - return new Project_Set_Node($name, $value, $token->getLine(), $this->getTag()); + return new CustomSetNode($name, $value, $lineno); } public function getTag() @@ -477,7 +525,7 @@ The ``getTag()`` method must return the tag we want to parse, here ``set``. The ``parse()`` method is invoked whenever the parser encounters a ``set`` tag. It should return a ``\Twig\Node\Node`` instance that represents the node (the -``Project_Set_Node`` calls creating is explained in the next section). +``CustomSetNode`` calls creating is explained in the next section). The parsing process is simplified thanks to a bunch of methods you can call from the token stream (``$this->parser->getStream()``): @@ -499,6 +547,18 @@ from the token stream (``$this->parser->getStream()``): 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 @@ -507,13 +567,13 @@ the ``set`` tag. Defining a Node ~~~~~~~~~~~~~~~ -The ``Project_Set_Node`` class itself is quite short:: +The ``CustomSetNode`` class itself is quite short:: - class Project_Set_Node extends \Twig\Node\Node + class CustomSetNode extends \Twig\Node\Node { - public function __construct($name, \Twig\Node\Expression\AbstractExpression $value, $line, $tag = null) + public function __construct($name, \Twig\Node\Expression\AbstractExpression $value, $line) { - parent::__construct(['value' => $value], ['name' => $name], $line, $tag); + parent::__construct(['value' => $value], ['name' => $name], $line); } public function compile(\Twig\Compiler $compiler) @@ -543,7 +603,8 @@ developer generate beautiful and readable PHP code: ``\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). @@ -551,6 +612,10 @@ developer generate beautiful and readable PHP code: * ``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 @@ -620,7 +685,7 @@ To keep your extension class clean and lean, inherit from the built-in ``\Twig\Extension\AbstractExtension`` class instead of implementing the interface as it provides empty implementations for all methods:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { } @@ -633,7 +698,7 @@ You can register an extension by using the ``addExtension()`` method on your main ``Environment`` object:: $twig = new \Twig\Environment($loader); - $twig->addExtension(new Project_Twig_Extension()); + $twig->addExtension(new CustomTwigExtension()); .. tip:: @@ -645,7 +710,7 @@ Globals Global variables can be registered in an extension via the ``getGlobals()`` method:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension implements \Twig\Extension\GlobalsInterface + class CustomTwigExtension extends \Twig\Extension\AbstractExtension implements \Twig\Extension\GlobalsInterface { public function getGlobals(): array { @@ -657,13 +722,23 @@ method:: // ... } +.. caution:: + + Globals are fetched once from extensions and then cached for the lifetime + of the Twig environment. It means that globals should not be used to store + values that can change during the lifetime of the Twig environment. For + instance, if you're using an application server like RoadRunner or + FrankenPHP, you should not store values related to the current context (like + the HTTP request). If you do so, don't forget to reset the cache between + requests by calling ``Environment::resetGlobals()``. + Functions ~~~~~~~~~ Functions can be registered in an extension via the ``getFunctions()`` method:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getFunctions() { @@ -682,7 +757,7 @@ To add a filter to an extension, you need to override the ``getFilters()`` method. This method must return an array of filters to add to the Twig environment:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getFilters() { @@ -701,61 +776,145 @@ Adding a tag in an extension can be done by overriding the ``getTokenParsers()`` method. This method must return an array of tags to add to the Twig environment:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getTokenParsers() { - return [new Project_Set_TokenParser()]; + return [new CustomSetTokenParser()]; } // ... } In the above code, we have added a single new tag, defined by the -``Project_Set_TokenParser`` class. The ``Project_Set_TokenParser`` class is +``CustomSetTokenParser`` class. The ``CustomSetTokenParser`` class is responsible for parsing the tag and compiling it to PHP. Operators ~~~~~~~~~ -The ``getOperators()`` methods lets you add new operators. Here is how to add -the ``!``, ``||``, and ``&&`` operators:: +The ``getOperators()`` method lets you add new operators. To implement a new +one, have a look at the default operators provided by +``Twig\Extension\CoreExtension``. + +Tests +~~~~~ + +The ``getTests()`` method lets you add new test functions:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { - public function getOperators() + public function getTests() { 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], - ], + new \Twig\TwigTest('even', 'twig_test_even'), ]; } // ... } -Tests -~~~~~ +Using PHP Attributes to define Extensions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``getTests()`` method lets you add new test functions:: +.. 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:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + use Twig\Attribute\AsTwigFilter; + use Twig\Attribute\AsTwigFunction; + use Twig\Attribute\AsTwigTest; + + class ProjectExtension { - public function getTests() + #[AsTwigFilter('rot13')] + public static function rot13(string $string): string { - return [ - new \Twig\TwigTest('even', 'twig_test_even'), - ]; + // ... } - // ... + #[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 ~~~~~~~~~~~~~~~~~~~~~ @@ -773,7 +932,7 @@ any valid PHP callable: The simplest way to use methods is to define them on the extension itself:: - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { private $rot13Provider; @@ -811,7 +970,7 @@ must be autoload-able):: // implement the logic to create an instance of $class // and inject its dependencies // most of the time, it means using your dependency injection container - if ('Project_Twig_RuntimeExtension' === $class) { + if ('CustomTwigRuntime' === $class) { return new $class(new Rot13Provider()); } else { // ... @@ -827,9 +986,9 @@ must be autoload-able):: (``\Twig\RuntimeLoader\ContainerRuntimeLoader``). It is now possible to move the runtime logic to a new -``Project_Twig_RuntimeExtension`` class and use it directly in the extension:: +``CustomTwigRuntime`` class and use it directly in the extension:: - class Project_Twig_RuntimeExtension + class CustomTwigRuntime { private $rot13Provider; @@ -844,18 +1003,26 @@ It is now possible to move the runtime logic to a new } } - class Project_Twig_Extension extends \Twig\Extension\AbstractExtension + class CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getFunctions() { return [ - new \Twig\TwigFunction('rot13', ['Project_Twig_RuntimeExtension', 'rot13']), + new \Twig\TwigFunction('rot13', ['CustomTwigRuntime', 'rot13']), // or - new \Twig\TwigFunction('rot13', 'Project_Twig_RuntimeExtension::rot13'), + new \Twig\TwigFunction('rot13', 'CustomTwigRuntime::rot13'), ]; } } +.. note:: + + The extension class should implement the ``Twig\Extension\LastModifiedExtensionInterface`` + interface to invalidate the template cache when the runtime class is modified. + The ``AbstractExtension`` class implements this interface and tracks the + runtime class if its name is the same as the extension class but ends with + ``Runtime`` instead of ``Extension``. + Testing an Extension -------------------- @@ -867,27 +1034,29 @@ structure in your test directory:: Fixtures/ filters/ - foo.test - bar.test + lower.test + upper.test functions/ - foo.test - bar.test + date.test + format.test tags/ - foo.test - bar.test + for.test + if.test IntegrationTest.php The ``IntegrationTest.php`` file should look like this:: + namespace Project\Tests; + use Twig\Test\IntegrationTestCase; - class Project_Tests_IntegrationTest extends IntegrationTestCase + class IntegrationTest extends IntegrationTestCase { public function getExtensions() { return [ - new Project_Twig_Extension1(), - new Project_Twig_Extension2(), + new CustomTwigExtension1(), + new CustomTwigExtension2(), ]; } diff --git a/doc/api.rst b/doc/api.rst index 3186e293d26..24b1baea743 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -40,15 +40,18 @@ templates from a database or other resources. the evaluated templates. For such a need, you can use any available PHP cache library. -Rendering Templates -------------------- +Loading Templates +----------------- -To load a template from a Twig environment, call the ``load()`` method which +To load a template, call the ``load()`` method on a Twig environment which returns a ``\Twig\TemplateWrapper`` instance:: - $template = $twig->load('index.html'); + $template = $twig->load('index.html.twig'); -To render the template with some variables, call the ``render()`` method:: +Rendering Templates +------------------- + +To render a template with some variables, call the ``render()`` method:: echo $template->render(['the' => 'variables', 'go' => 'here']); @@ -56,15 +59,32 @@ To render the template with some variables, call the ``render()`` method:: The ``display()`` method is a shortcut to output the rendered template. -You can also load and render the template in one fell swoop:: +You can also load and render the template directly via the Environment:: - echo $twig->render('index.html', ['the' => 'variables', 'go' => 'here']); + echo $twig->render('index.html.twig', ['the' => 'variables', 'go' => 'here']); If a template defines blocks, they can be rendered individually via the ``renderBlock()`` call:: echo $template->renderBlock('block_name', ['the' => 'variables', 'go' => 'here']); +Streaming Templates +------------------- + +.. versionadded:: 3.18 + +To stream a template, call the ``stream()`` method:: + + $template->stream(['the' => 'variables', 'go' => 'here']); + +To stream a specific template block, call the ``streamBlock()`` method:: + + $template->streamBlock('block_name', ['the' => 'variables', 'go' => 'here']); + +.. note:: + + The ``stream()`` and ``streamBlock()`` methods return an iterable. + .. _environment_options: Environment Options @@ -99,6 +119,8 @@ The following options are available: the ``auto_reload`` option, it will be determined automatically based on the ``debug`` value. +.. _environment_options_strict_variables: + * ``strict_variables`` *boolean* If set to ``false``, Twig will silently ignore invalid @@ -123,6 +145,17 @@ The following options are available: (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 ------- @@ -175,7 +208,7 @@ methods act on the "main" namespace):: Namespaced templates can be accessed via the special ``@namespace_name/template_path`` notation:: - $twig->render('@admin/index.html', []); + $twig->render('@admin/index.html.twig', []); ``\Twig\Loader\FilesystemLoader`` supports absolute and relative paths. Using relative paths is preferred as it makes the cache keys independent of the project root @@ -196,11 +229,11 @@ the directory might be different from the one used on production servers):: array of strings bound to template names:: $loader = new \Twig\Loader\ArrayLoader([ - 'index.html' => 'Hello {{ name }}!', + 'index.html.twig' => 'Hello {{ name }}!', ]); $twig = new \Twig\Environment($loader); - echo $twig->render('index.html', ['name' => 'Fabien']); + echo $twig->render('index.html.twig', ['name' => 'Fabien']); This loader is very useful for unit testing. It can also be used for small projects where storing all templates in a single PHP file might make sense. @@ -219,11 +252,11 @@ projects where storing all templates in a single PHP file might make sense. ``\Twig\Loader\ChainLoader`` delegates the loading of templates to other loaders:: $loader1 = new \Twig\Loader\ArrayLoader([ - 'base.html' => '{% block content %}{% endblock %}', + 'base.html.twig' => '{% block content %}{% endblock %}', ]); $loader2 = new \Twig\Loader\ArrayLoader([ - 'index.html' => '{% extends "base.html" %}{% block content %}Hello {{ name }}{% endblock %}', - 'base.html' => 'Will never be loaded', + 'index.html.twig' => '{% extends "base.html.twig" %}{% block content %}Hello {{ name }}{% endblock %}', + 'base.html.twig' => 'Will never be loaded', ]); $loader = new \Twig\Loader\ChainLoader([$loader1, $loader2]); @@ -231,8 +264,8 @@ projects where storing all templates in a single PHP file might make sense. $twig = new \Twig\Environment($loader); When looking for a template, Twig tries each loader in turn and returns as soon -as the template is found. When rendering the ``index.html`` template from the -above example, Twig will load it with ``$loader2`` but the ``base.html`` +as the template is found. When rendering the ``index.html.twig`` template from the +above example, Twig will load it with ``$loader2`` but the ``base.html.twig`` template will be loaded from ``$loader1``. .. note:: @@ -305,23 +338,23 @@ extension via the ``addExtension()`` method:: 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. @@ -396,14 +429,14 @@ The escaping rules are implemented as follows: .. code-block:: html+twig - {{ foo ? "Twig
" : "
Twig" }} {# won't be escaped #} + {{ any_value ? "Twig
" : "
Twig" }} {# won't be escaped #} {% set text = "Twig
" %} {{ true ? text : "
Twig" }} {# will be escaped #} {{ false ? text : "
Twig" }} {# won't be escaped #} {% set text = "Twig
" %} - {{ foo ? text|raw : "
Twig" }} {# won't be escaped #} + {{ any_value ? text|raw : "
Twig" }} {# won't be escaped #} * Objects with a ``__toString`` method are converted to strings and escaped. You can mark some classes and/or interfaces as being safe for some @@ -411,17 +444,17 @@ The escaping rules are implemented as follows: .. code-block:: twig - // mark object of class Foo as safe for the HTML strategy - $escaper->addSafeClass('Foo', ['html']); + // mark objects of class "HtmlGenerator" as safe for the HTML strategy + $escaper->addSafeClass('HtmlGenerator', ['html']); - // mark object of interface Foo as safe for the HTML strategy - $escaper->addSafeClass('FooInterface', ['html']); + // mark objects of interface "HtmlGeneratorInterface" as safe for the HTML strategy + $escaper->addSafeClass('HtmlGeneratorInterface', ['html']); - // mark object of class Foo as safe for the HTML and JS strategies - $escaper->addSafeClass('Foo', ['html', 'js']); + // mark objects of class "HtmlGenerator" as safe for the HTML and JS strategies + $escaper->addSafeClass('HtmlGenerator', ['html', 'js']); - // mark object of class Foo as safe for all strategies - $escaper->addSafeClass('Foo', ['all']); + // mark objects of class "HtmlGenerator" as safe for all strategies + $escaper->addSafeClass('HtmlGenerator', ['all']); * Escaping is applied before printing, after any other filter is applied: @@ -429,7 +462,7 @@ The escaping rules are implemented as follows: {{ var|upper }} {# is equivalent to {{ var|upper|escape }} #} -* The `raw` filter should only be used at the end of the filter chain: +* The ``raw`` filter should only be used at the end of the filter chain: .. code-block:: twig @@ -454,54 +487,15 @@ The escaping rules are implemented as follows: Note that autoescaping has some limitations as escaping is applied on expressions after evaluation. For instance, when working with - concatenation, ``{{ foo|raw ~ bar }}`` won't give the expected result as - escaping is applied on the result of the concatenation, not on the + concatenation, ``{{ value|raw ~ other }}`` won't give the expected result + as escaping is applied on the result of the concatenation, not on the individual variables (so, the ``raw`` filter won't have any effect here). Sandbox Extension ~~~~~~~~~~~~~~~~~ -The ``sandbox`` extension can be used to evaluate untrusted code. Access to -unsafe attributes and methods is prohibited. The sandbox security is managed -by a policy instance. By default, Twig comes with one policy class: -``\Twig\Sandbox\SecurityPolicy``. This class allows you to white-list some -tags, filters, properties, and methods:: - - $tags = ['if']; - $filters = ['upper']; - $methods = [ - 'Article' => ['getTitle', 'getBody'], - ]; - $properties = [ - 'Article' => ['title', 'body'], - ]; - $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. - -The policy object is the first argument of the sandbox constructor:: - - $sandbox = new \Twig\Extension\SandboxExtension($policy); - $twig->addExtension($sandbox); - -By default, the sandbox mode is disabled and should be enabled when including -untrusted template code by using the ``sandbox`` tag: - -.. code-block:: twig - - {% sandbox %} - {% include 'user.html' %} - {% endsandbox %} - -You can sandbox all templates by passing ``true`` as the second argument of -the extension constructor:: - - $sandbox = new \Twig\Extension\SandboxExtension($policy, true); +The ``sandbox`` extension can be used to evaluate untrusted code. Read more +about it in the :doc:`sandbox` chapter. Profiler Extension ~~~~~~~~~~~~~~~~~~ @@ -558,12 +552,6 @@ Twig supports the following optimizations: * ``\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_FOR``, optimizes the ``for`` tag by removing the ``loop`` variable creation whenever possible. -* ``\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_RAW_FILTER``, removes the ``raw`` - filter whenever possible. - -* ``\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_VAR_ACCESS``, simplifies the creation - and access of variables in the compiled templates whenever possible. - Exceptions ---------- diff --git a/doc/coding_standards.rst b/doc/coding_standards.rst index 721b0f13aaf..5b10ef74a75 100644 --- a/doc/coding_standards.rst +++ b/doc/coding_standards.rst @@ -1,28 +1,35 @@ Coding Standards ================ +.. note:: + + The `Twig CS fixer tool `_ + uses the coding standards described in this document to automatically fix + your templates. + When writing Twig templates, we recommend you to follow these official coding standards: -* Put one (and only one) space after the start of a delimiter (``{{``, ``{%``, - and ``{#``) and before the end of a delimiter (``}}``, ``%}``, and ``#}``): +* Put exactly one space after the start of a delimiter (``{{``, ``{%``, + and ``{#``) and before the end of a delimiter (``}}``, ``%}``, and ``#}``) + if the content is non empty: .. code-block:: twig - {{ foo }} - {# comment #} - {% if foo %}{% endif %} + {{ user }} + {# comment #} {##} + {% if user %}{% endif %} When using the whitespace control character, do not put any spaces between it and the delimiter: .. code-block:: twig - {{- foo -}} - {#- comment -#} - {%- if foo -%}{%- endif -%} + {{- user -}} + {#- comment -#} {#--#} + {%- if user -%}{%- endif -%} -* Put one (and only one) space before and after the following operators: +* Put exactly one space before and after the following operators: comparison operators (``==``, ``!=``, ``<``, ``>``, ``>=``, ``<=``), math operators (``+``, ``-``, ``/``, ``*``, ``%``, ``//``, ``**``), logic operators (``not``, ``and``, ``or``), ``~``, ``is``, ``in``, and the ternary @@ -30,17 +37,17 @@ standards: .. code-block:: twig - {{ 1 + 2 }} - {{ foo ~ bar }} - {{ true ? true : false }} + {{ 1 + 2 }} + {{ first_name ~ ' ' ~ last_name }} + {{ is_correct ? true : false }} -* Put one (and only one) space after the ``:`` sign in hashes and ``,`` in - arrays and hashes: +* Put exactly one space after the ``:`` sign in mappings and ``,`` in sequences + and mappings: .. code-block:: twig - {{ [1, 2, 3] }} - {{ {'foo': 'bar'} }} + [1, 2, 3] + {'name': 'Fabien'} * Do not put any spaces after an opening parenthesis and before a closing parenthesis in expressions: @@ -53,15 +60,15 @@ standards: .. code-block:: twig - {{ 'foo' }} - {{ "foo" }} + {{ 'Twig' }} + {{ "Twig" }} * Do not put any spaces before and after the following operators: ``|``, ``.``, ``..``, ``[]``: .. code-block:: twig - {{ foo|upper|lower }} + {{ name|upper|lower }} {{ user.name }} {{ user[name] }} {% for i in 1..12 %}{% endfor %} @@ -71,31 +78,58 @@ standards: .. code-block:: twig - {{ foo|default('foo') }} - {{ range(1..10) }} + {{ name|default('Fabien') }} + {{ range(1..10) }} -* Do not put any spaces before and after the opening and the closing of arrays - and hashes: +* Do not put any spaces before and after the opening and the closing of + sequences and mappings: .. code-block:: twig - {{ [1, 2, 3] }} - {{ {'foo': 'bar'} }} + [1, 2, 3] + {'name': 'Fabien'} -* Use lower cased and underscored variable names: +* Put exactly one space before and after ``=`` in macro argument declarations: .. code-block:: twig - {% set foo = 'foo' %} - {% set foo_bar = 'foo' %} + {% macro html_input(class = "input") %} + +* Put exactly one space after the ``:`` sign when using named arguments: + + .. code-block:: twig + + {{ html_input(class: "input") }} + +* Use snake case for all variable names (provided by the application and + created in templates), function/filter/test names, argument names and named + arguments: + + .. code-block:: twig + + {% set name = 'Fabien' %} + {% set first_name = 'Fabien' %} + + {{ 'Fabien Potencier'|to_lower_case }} + {{ generate_random_number() }} + + {% macro html_input(class_name) %} + + {{ html_input(class_name: 'pwd') }} * Indent your code inside tags (use the same indentation as the one used for the target language of the rendered template): .. code-block:: twig - {% block foo %} - {% if true %} - true - {% endif %} - {% endblock %} + {% block content %} + {% if true %} + true + {% endif %} + {% endblock %} + +* Use ``:`` instead of ``=`` to separate argument names and values: + + .. code-block:: twig + + {{ data|convert_encoding(from: 'iso-2022-jp', to: 'UTF-8') }} diff --git a/doc/deprecated.rst b/doc/deprecated.rst index ac22338e184..3348c59cb28 100644 --- a/doc/deprecated.rst +++ b/doc/deprecated.rst @@ -4,3 +4,475 @@ Deprecated Features This document lists deprecated features in Twig 3.x. Deprecated features are kept for backward compatibility and removed in the next major release (a feature that was deprecated in Twig 3.x is removed in Twig 4.0). + +Functions +--------- + +* The ``twig_test_iterable`` function is deprecated; use the native PHP + ``is_iterable`` function instead. + +* The ``attribute`` function is deprecated as of Twig 3.15. Use the ``.`` + operator instead and wrap the name with parenthesis: + + .. code-block:: twig + + {# before #} + {{ attribute(object, method) }} + {{ attribute(object, method, arguments) }} + {{ attribute(array, item) }} + + {# after #} + {{ object.(method) }} + {{ object.(method)(arguments) }} + {{ array[item] }} + + Note that it won't be removed in 4.0 to allow a smoother upgrade path. + +Extensions +---------- + +* All functions defined in Twig extensions are marked as internal as of Twig + 3.9.0, and will be removed in Twig 4.0. They have been replaced by internal + methods on their respective extension classes. + + If you were using the ``twig_escape_filter()`` function in your code, use + ``$env->getRuntime(EscaperRuntime::class)->escape()`` instead. + +* The following methods from ``Twig\Extension\EscaperExtension`` are + deprecated: ``setEscaper()``, ``getEscapers()``, ``setSafeClasses``, + ``addSafeClasses()``. Use the same methods on the + ``Twig\Runtime\EscaperRuntime`` class instead: + + Before: + ``$twig->getExtension(EscaperExtension::class)->METHOD();`` + + After: + ``$twig->getRuntime(EscaperRuntime::class)->METHOD();`` + +Nodes +----- + +* The "tag" constructor parameter of the ``Twig\Node\Node`` class is deprecated + as of Twig 3.12 as the tag is now automatically set by the Parser when + needed. + +* The following ``Twig\Node\Node`` methods will take a string or an integer + (instead of just a string) in Twig 4.0 for their "name" argument: + ``getNode()``, ``hasNode()``, ``setNode()``, ``removeNode()``, and + ``deprecateNode()``. + +* Not passing a ``BodyNode`` instance as the body of a ``ModuleNode`` or + ``MacroNode`` constructor is deprecated as of Twig 3.12. + +* Returning ``null`` from ``TokenParserInterface::parse()`` is deprecated as of + Twig 3.12 (as forbidden by the interface). + +* The second argument of the + ``Twig\Node\Expression\CallExpression::compileArguments()`` method is + deprecated. + +* The ``Twig\Node\Expression\NameExpression::isSimple()`` and + ``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 + deprecated as of Twig 3.12 and will be removed in 4.0. Use the ``filter`` + attribute instead to get the filter: + + Before: + ``$node->getNode('filter')->getAttribute('value')`` + + After: + ``$node->getAttribute('twig_callable')->getName()`` + +* Passing a name to ``Twig\Node\Expression\FunctionExpression``, + ``Twig\Node\Expression\FilterExpression``, and + ``Twig\Node\Expression\TestExpression`` is deprecated as of Twig 3.12. + As of Twig 4.0, you need to pass a ``TwigFunction``, ``TwigFilter``, or + ``TestFilter`` instead. + + Let's take a ``FunctionExpression`` as an example. + + If you have a node that extends ``FunctionExpression`` and if you don't + override the constructor, you don't need to do anything. But if you override + the constructor, then you need to change the type hint of the name and mark + the constructor with the ``Twig\Attribute\FirstClassTwigCallableReady`` attribute. + + Before:: + + class NotReadyFunctionExpression extends FunctionExpression + { + public function __construct(string $function, Node $arguments, int $lineno) + { + parent::__construct($function, $arguments, $lineno); + } + } + + class NotReadyFilterExpression extends FilterExpression + { + public function __construct(Node $node, ConstantExpression $filter, Node $arguments, int $lineno) + { + parent::__construct($node, $filter, $arguments, $lineno); + } + } + + class NotReadyTestExpression extends TestExpression + { + public function __construct(Node $node, string $test, ?Node $arguments, int $lineno) + { + parent::__construct($node, $test, $arguments, $lineno); + } + } + + After:: + + class ReadyFunctionExpression extends FunctionExpression + { + #[FirstClassTwigCallableReady] + public function __construct(TwigFunction|string $function, Node $arguments, int $lineno) + { + parent::__construct($function, $arguments, $lineno); + } + } + + class ReadyFilterExpression extends FilterExpression + { + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigFilter|ConstantExpression $filter, Node $arguments, int $lineno) + { + parent::__construct($node, $filter, $arguments, $lineno); + } + } + + class ReadyTestExpression extends TestExpression + { + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigTest|string $test, ?Node $arguments, int $lineno) + { + parent::__construct($node, $test, $arguments, $lineno); + } + } + +* The following ``Twig\Node\Expression\FunctionExpression`` attributes are + deprecated as of Twig 3.12: ``needs_charset``, ``needs_environment``, + ``needs_context``, ``arguments``, ``callable``, ``is_variadic``, + and ``dynamic_name``. + +* The following ``Twig\Node\Expression\FilterExpression`` attributes are + deprecated as of Twig 3.12: ``needs_charset``, ``needs_environment``, + ``needs_context``, ``arguments``, ``callable``, ``is_variadic``, + and ``dynamic_name``. + +* The following ``Twig\Node\Expression\TestExpression`` attributes are + deprecated as of Twig 3.12: ``arguments``, ``callable``, ``is_variadic``, + and ``dynamic_name``. + +* The ``MethodCallExpression`` class is deprecated as of Twig 3.15, use + ``MacroReferenceExpression`` instead. + +* The ``Twig\Node\Expression\TempNameExpression`` class is deprecated as of + Twig 3.15; use ``Twig\Node\Expression\Variable\LocalVariable`` instead. + +* The ``Twig\Node\Expression\NameExpression`` class is deprecated as of Twig + 3.15; use ``Twig\Node\Expression\Variable\ContextVariable`` instead. + +* The ``Twig\Node\Expression\AssignNameExpression`` class is deprecated as of + Twig 3.15; use ``Twig\Node\Expression\Variable\AssignContextVariable`` + instead. + +* Node implementations that use ``echo`` or ``print`` should use ``yield`` + instead; all Node implementations should use the + ``#[\Twig\Attribute\YieldReady]`` attribute on their class once they've been + made ready for ``yield``; the ``use_yield`` Environment option can be turned + on when all nodes use the ``#[\Twig\Attribute\YieldReady]`` attribute. + + * The ``Twig\Node\InlinePrint`` class is deprecated as of Twig 3.16 with no + replacement. + + * The ``Twig\Node\Expression\NullCoalesceExpression`` class is deprecated as + of Twig 3.17, use ``Twig\Node\Expression\Binary\NullCoalesceBinary`` + instead. + + * 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 +------------- + +* The ``Twig\NodeVisitor\AbstractNodeVisitor`` class is deprecated, implement the + ``Twig\NodeVisitor\NodeVisitorInterface`` interface instead. + +* The ``Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_RAW_FILTER`` and the + ``Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_TEXT_NODES`` options are + deprecated as of Twig 3.12 and will be removed in Twig 4.0; they don't do + anything anymore. + +Parser +------ + +* The following methods from ``Twig\Parser`` are deprecated as of Twig 3.12: + ``getBlockStack()``, ``hasBlock()``, ``getBlock()``, ``hasMacro()``, + ``hasTraits()``, ``getParent()``. + +* Passing ``null`` to ``Twig\Parser::setParent()`` is deprecated as of Twig + 3.12. + +* 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()`` + +Token +----- + +* Not passing a ``Source`` instance to ``Twig\TokenStream`` constructor is + deprecated as of Twig 3.16. + +* 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()`` and ``Environment::load()``); pass + instances of ``Twig\TemplateWrapper`` instead. + +Filters +------- + +* The ``spaceless`` filter is deprecated as of Twig 3.12 and will be removed in + Twig 4.0. + +Sandbox +------- + +* 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. + +* Deprecate the ``sandbox`` tag, use the ``sandboxed`` option of the + ``include`` function instead: + + Before:: + + {% sandbox %} + {% include 'user_defined.html.twig' %} + {% endsandbox %} + + After:: + + {{ include('user_defined.html.twig', sandboxed: true) }} + +Testing Utilities +----------------- + +* Implementing the data provider method ``Twig\Test\NodeTestCase::getTests()`` + is deprecated as of Twig 3.13. Instead, implement the static data provider + ``provideTests()``. + +* In order to make their functionality available for static data providers, the + helper methods ``getVariableGetter()`` and ``getAttributeGetter()`` on + ``Twig\Test\NodeTestCase`` have been deprecated. Call the new methods + ``createVariableGetter()`` and ``createAttributeGetter()`` instead. + +* The method ``Twig\Test\NodeTestCase::getEnvironment()`` is considered final + as of Twig 3.13. If you want to override how the Twig environment is + constructed, override ``createEnvironment()`` instead. + +* The method ``getFixturesDir()`` on ``Twig\Test\IntegrationTestCase`` is + deprecated, implement the new static method ``getFixturesDirectory()`` + instead, which will be abstract in 4.0. + +* The data providers ``getTests()`` and ``getLegacyTests()`` on + ``Twig\Test\IntegrationTestCase`` are considered final as of Twig 3.13. + +Environment +----------- + +* The ``Twig\Environment::mergeGlobals()`` method is deprecated as of Twig 3.14 + and will be removed in Twig 4.0: + + Before:: + + $context = $twig->mergeGlobals($context); + + After:: + + $context += $twig->getGlobals(); + +Functions/Filters/Tests +----------------------- + +* The ``deprecated``, ``deprecating_package``, ``alternative`` options on Twig + functions/filters/Tests are deprecated as of Twig 3.15, and will be removed + in Twig 4.0. Use the ``deprecation_info`` option instead: + + Before:: + + $twig->addFunction(new TwigFunction('upper', 'upper', [ + 'deprecated' => '3.12', 'deprecating_package' => 'twig/twig', + ])); + + After:: + + $twig->addFunction(new TwigFunction('upper', 'upper', [ + 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12'), + ])); + +* For variadic arguments, use snake-case for the argument name to ease the + transition to 4.0. + +* Passing a ``string`` or an ``array`` to Twig callable arguments accepting + arrow functions is deprecated as of Twig 3.15; these arguments will have a + ``\Closure`` type hint in 4.0. + +* Returning ``null`` from ``TwigFilter::getSafe()`` and + ``TwigFunction::getSafe()`` is deprecated as of Twig 3.16; return ``[]`` + instead. + +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. + +* Using ``~`` in an expression with the ``+`` or ``-`` operators without using + parentheses to clarify precedence triggers a deprecation as of Twig 3.15 (in + Twig 4.0, ``+`` / ``-`` will have a higher precedence than ``~``). + + For example, the following expression will trigger a deprecation in Twig 3.15:: + + {{ '42' ~ 1 + 41 }} + + To avoid the deprecation, wrap the concatenation in parentheses to clarify + the precedence:: + + {{ ('42' ~ 1) + 41 }} {# this is equivalent to what Twig 3.x does without the parentheses #} + + {# or #} + + {{ '42' ~ (1 + 41) }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* Using ``??`` without explicit parentheses to clarify precedence triggers a + deprecation as of Twig 3.15 (in Twig 4.0, ``??`` will have the lowest + precedence). + + For example, the following expression will trigger a deprecation in Twig 3.15:: + + {{ 'notnull' ?? 'foo' ~ '_bar' }} + + To avoid the deprecation, wrap the ``??`` expression in parentheses to clarify + the precedence:: + + {{ ('notnull' ?? 'foo') ~ '_bar' }} {# this is equivalent to what Twig 3.x does without the parentheses #} + + {# or #} + + {{ 'notnull' ?? ('foo' ~ '_bar') }} {# this is equivalent to what Twig 4.x will do without the parentheses #} + +* Using the ``not`` unary operator in an expression with ``*``, ``/``, ``//``, + or ``%`` operators without explicit parentheses to clarify precedence + triggers a deprecation as of Twig 3.15 (in Twig 4.0, ``not`` will have a + higher precedence than ``*``, ``/``, ``//``, and ``%``). + + For example, the following expression will trigger a deprecation in Twig 3.15:: + + {{ not 1 * 2 }} + + To avoid the deprecation, wrap the concatenation in parentheses to clarify + the precedence:: + + {{ (not 1 * 2) }} {# this is equivalent to what Twig 3.x does without the parentheses #} + + {# 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. diff --git a/doc/filters/batch.rst b/doc/filters/batch.rst index 18a227feb39..adb2948c6a3 100644 --- a/doc/filters/batch.rst +++ b/doc/filters/batch.rst @@ -12,8 +12,8 @@ missing items: {% for row in items|batch(3, 'No item') %} - {% for column in row %} - + {% for index, column in row %} + {% endfor %} {% endfor %} @@ -25,14 +25,47 @@ The above example will be rendered as:
{{ column }}{{ index }} - {{ column }}
- - - + + + - - - + + + + +
abc0 - a1 - b2 - c
dNo itemNo item3 - d4 - No item5 - No item
+ +If you choose to set the third parameter ``preserve_keys`` to ``false``, the keys will be reset in each loop. + +.. code-block:: html+twig + + {% set items = ['a', 'b', 'c', 'd'] %} + + + {% for row in items|batch(3, 'No item', false) %} + + {% for index, column in row %} + + {% endfor %} + + {% endfor %} +
{{ index }} - {{ column }}
+ +The above example will be rendered as: + +.. code-block:: html+twig + + + + + + + + + + +
0 - a1 - b2 - c
0 - d1 - No item2 - No item
@@ -41,4 +74,4 @@ Arguments * ``size``: The size of the batch; fractional numbers will be rounded up * ``fill``: Used to fill in missing items -* ``preserve_keys``: Whether to preserve keys or not +* ``preserve_keys``: Whether to preserve keys or not (defaults to ``true``) diff --git a/doc/filters/country_name.rst b/doc/filters/country_name.rst index 434b0bda7a1..a30184de9da 100644 --- a/doc/filters/country_name.rst +++ b/doc/filters/country_name.rst @@ -1,8 +1,7 @@ ``country_name`` ================ -The ``country_name`` filter returns the country name given its ISO-3166 -two-letter code: +The ``country_name`` filter returns the country name given its ISO-3166 code: .. code-block:: twig @@ -16,6 +15,9 @@ By default, the filter uses the current locale. You can pass it explicitly: {# États-Unis #} {{ 'US'|country_name('fr') }} + {# 美國 #} + {{ 'US'|country_name('zh_Hant_HK') }} + .. note:: The ``country_name`` filter is part of the ``IntlExtension`` which is not @@ -41,4 +43,6 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/currency_name.rst b/doc/filters/currency_name.rst index a35c499988d..498dc9423d7 100644 --- a/doc/filters/currency_name.rst +++ b/doc/filters/currency_name.rst @@ -1,8 +1,7 @@ ``currency_name`` ================= -The ``currency_name`` filter returns the currency name given its three-letter -code: +The ``currency_name`` filter returns the currency name given its ISO 4217 code: .. code-block:: twig @@ -44,4 +43,6 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/currency_symbol.rst b/doc/filters/currency_symbol.rst index 84a048ed52c..80843fba023 100644 --- a/doc/filters/currency_symbol.rst +++ b/doc/filters/currency_symbol.rst @@ -1,7 +1,7 @@ ``currency_symbol`` =================== -The ``currency_symbol`` filter returns the currency symbol given its three-letter +The ``currency_symbol`` filter returns the currency symbol given its ISO 4217 code: .. code-block:: twig @@ -44,4 +44,6 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/data_uri.rst b/doc/filters/data_uri.rst index e008266b346..a68deb0f285 100644 --- a/doc/filters/data_uri.rst +++ b/doc/filters/data_uri.rst @@ -11,13 +11,13 @@ The ``data_uri`` filter generates a URL using the data scheme as defined in {{ source('path_to_image')|data_uri }} {# force the mime type, disable the guessing of the mime type #} - {{ image_data|data_uri(mime="image/svg") }} + {{ image_data|data_uri(mime: "image/svg") }} {# also works with plain text #} - {{ 'foobar'|data_uri(mime="text/html") }} + {{ 'foobar'|data_uri(mime: "text/html") }} {# add some extra parameters #} - {{ 'foobar'|data_uri(mime="text/html", parameters={charset: "ascii"}) }} + {{ 'foobar'|data_uri(mime: "text/html", parameters: {charset: "ascii"}) }} .. note:: @@ -50,6 +50,6 @@ Arguments --------- * ``mime``: The mime type -* ``parameters``: An array of parameters +* ``parameters``: A mapping of parameters .. _RFC 2397: https://tools.ietf.org/html/rfc2397 diff --git a/doc/filters/date.rst b/doc/filters/date.rst index 70470f0f4d2..7ac9b8750a3 100644 --- a/doc/filters/date.rst +++ b/doc/filters/date.rst @@ -47,7 +47,7 @@ Timezone By default, the date is displayed by applying the default timezone (the one specified in php.ini or declared in Twig -- see below), but you can override -it by explicitly specifying a timezone: +it by explicitly specifying a supported `timezone`_: .. code-block:: twig @@ -68,7 +68,7 @@ The default timezone can also be set globally by calling ``setTimezone()``:: Arguments --------- -* ``format``: The date format +* ``format``: The date format (default format is ``F j, Y H:i``, which will render as ``January 11, 2024 15:17``) * ``timezone``: The date timezone .. _`strtotime`: https://www.php.net/strtotime @@ -76,3 +76,4 @@ Arguments .. _`DateInterval`: https://www.php.net/DateInterval .. _`date`: https://www.php.net/date .. _`DateInterval::format`: https://www.php.net/DateInterval.format +.. _`timezone`: https://www.php.net/manual/en/timezones.php diff --git a/doc/filters/default.rst b/doc/filters/default.rst index 2376fe7a6d9..35495389049 100644 --- a/doc/filters/default.rst +++ b/doc/filters/default.rst @@ -8,9 +8,9 @@ undefined or empty, otherwise the value of the variable: {{ var|default('var is not defined') }} - {{ var.foo|default('foo item on var is not defined') }} + {{ user.name|default('name item on user is not defined') }} - {{ var['foo']|default('foo item on var is not defined') }} + {{ user['name']|default('name item on user is not defined') }} {{ ''|default('passed var is empty') }} @@ -20,16 +20,17 @@ undefined: .. code-block:: twig - {{ var.method(foo|default('foo'))|default('foo') }} + {{ user.value(name|default('username'))|default('not defined') }} -Using the ``default`` filter on a boolean variable might trigger unexpected behavior, as -``false`` is treated as an empty value. Consider using ``??`` instead: +Using the ``default`` filter on a boolean variable might trigger unexpected +behavior, as ``false`` is treated as an empty value. Consider using ``??`` +instead: .. code-block:: twig - {% set foo = false %} - {{ foo|default(true) }} {# true #} - {{ foo ?? true }} {# false #} + {% set value = false %} + {{ value|default(true) }} {# true #} + {{ value ?? true }} {# false #} .. note:: diff --git a/doc/filters/escape.rst b/doc/filters/escape.rst index e8b735db46b..6da700794fe 100644 --- a/doc/filters/escape.rst +++ b/doc/filters/escape.rst @@ -39,9 +39,12 @@ And here is how to escape variables included in JavaScript code: 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. +* ``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 applied to any string being inserted into CSS and escapes everything except @@ -50,7 +53,9 @@ documents: * ``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. +* ``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 }}``). 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 @@ -90,18 +95,57 @@ to learn more about this topic. {{ var|escape(strategy)|raw }} {# won't be double-escaped #} {% endautoescape %} +.. tip:: + + 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: + + .. code-block:: html+twig + +

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

+ Custom Escapers --------------- +.. versionadded:: 3.10 + + The ``EscaperRuntime`` class has been added in 3.10. On previous versions, + you can define custom escapers by calling the ``setEscaper()`` method on + the escaper extension instance. The first argument is the escaper strategy + (to be used in the ``escape`` call) and the second one must be a valid PHP + callable:: + + use Twig\Extension\EscaperExtension; + + $twig = new \Twig\Environment($loader); + $twig->getExtension(EscaperExtension::class)->setEscaper('csv', 'csv_escaper'); + + When called by Twig, the callable receives the Twig environment instance, + the string to escape, and the charset. + You can define custom escapers by calling the ``setEscaper()`` method on the -escaper extension instance. The first argument is the escaper name (to be -used in the ``escape`` call) and the second one must be a valid PHP callable:: +escaper runtime instance. It accepts two arguments: the strategy name and a PHP +callable that accepts a string to escape and the charset:: + + use Twig\Runtime\EscaperRuntime; $twig = new \Twig\Environment($loader); - $twig->getExtension(\Twig\Extension\EscaperExtension::class)->setEscaper('csv', 'csv_escaper'); + $escaper = fn ($string, $charset) => $string; + $twig->getRuntime(EscaperRuntime::class)->setEscaper('identity', $escaper); -When called by Twig, the callable receives the Twig environment instance, the -string to escape, and the charset. + # Usage in a template: + # {{ 'Twig'|escape('identity') }} .. note:: diff --git a/doc/filters/find.rst b/doc/filters/find.rst new file mode 100644 index 00000000000..f11b68e36c4 --- /dev/null +++ b/doc/filters/find.rst @@ -0,0 +1,57 @@ +``find`` +======== + +.. versionadded:: 3.11 + + The ``find`` filter was added in Twig 3.11. + +The ``find`` filter returns the first element of a sequence matching an arrow +function. The arrow function receives the value of the sequence: + +.. code-block:: twig + + {% set sizes = [34, 36, 38, 40, 42] %} + + {{ sizes|find(v => v > 38) }} + {# output 40 #} + +It also works with mappings: + +.. code-block:: twig + + {% set sizes = { + xxs: 32, + xs: 34, + s: 36, + m: 38, + l: 40, + xl: 42, + } %} + + {{ sizes|find(v => v > 38) }} + + {# output 40 #} + +The arrow function also receives the key as a second argument: + +.. code-block:: twig + + {{ sizes|find((v, k) => 's' not in k) }} + + {# output 38 #} + +Note that the arrow function has access to the current context: + +.. code-block:: twig + + {% set my_size = 39 %} + + {{ sizes|find(v => v >= my_size) }} + + {# output 40 #} + +Arguments +--------- + +* ``array``: The sequence or mapping +* ``arrow``: The arrow function diff --git a/doc/filters/first.rst b/doc/filters/first.rst index e0cc7cb1c9e..0d9ba9c4957 100644 --- a/doc/filters/first.rst +++ b/doc/filters/first.rst @@ -9,7 +9,7 @@ a string: {{ [1, 2, 3, 4]|first }} {# outputs 1 #} - {{ { a: 1, b: 2, c: 3, d: 4 }|first }} + {{ {a: 1, b: 2, c: 3, d: 4}|first }} {# outputs 1 #} {{ '1234'|first }} diff --git a/doc/filters/format.rst b/doc/filters/format.rst index 68551a3dda3..d7a27f68ab4 100644 --- a/doc/filters/format.rst +++ b/doc/filters/format.rst @@ -6,10 +6,10 @@ The ``format`` filter formats a given string by replacing the placeholders .. code-block:: twig - {{ "I like %s and %s."|format(foo, "bar") }} + {% set fruit = 'apples' %} + {{ "I like %s and %s."|format(fruit, "oranges") }} - {# outputs I like foo and bar - if the foo parameter equals to the foo string. #} + {# outputs I like apples and oranges #} .. seealso:: diff --git a/doc/filters/format_currency.rst b/doc/filters/format_currency.rst index 8b649bf5d94..eff465af48c 100644 --- a/doc/filters/format_currency.rst +++ b/doc/filters/format_currency.rst @@ -20,32 +20,86 @@ You can pass attributes to tweak the output: The list of supported options: -* ``grouping_used``; -* ``decimal_always_shown``; -* ``max_integer_digit``; -* ``min_integer_digit``; -* ``integer_digit``; -* ``max_fraction_digit``; -* ``min_fraction_digit``; -* ``fraction_digit``; -* ``multiplier``; -* ``grouping_size``; -* ``rounding_mode``; -* ``rounding_increment``; -* ``format_width``; -* ``padding_position``; -* ``secondary_grouping_size``; -* ``significant_digits_used``; -* ``min_significant_digits_used``; -* ``max_significant_digits_used``; -* ``lenient_parse``. - -By default, the filter uses the current locale. You can pass it explicitly: +* ``grouping_used``: Specifies whether to use grouping separator for thousands:: -.. code-block:: twig + {# €1,234,567.89 #} + {{ 1234567.89 | format_currency('EUR', {grouping_used:true}, 'en') }} + +* ``decimal_always_shown``: Specifies whether to always show the decimal part, even if it's zero:: + + {# €123.00 #} + {{ 123 | format_currency('EUR', {decimal_always_shown:true}, 'en') }} + +* ``max_integer_digit``: +* ``min_integer_digit``: +* ``integer_digit``: Define constraints on the integer part:: + + {# €345.68 #} + {{ 12345.6789 | format_currency('EUR', {max_integer_digit:3, min_integer_digit:2}, 'en') }} + +* ``max_fraction_digit``: +* ``min_fraction_digit``: +* ``fraction_digit``: Define constraints on the fraction part:: + + {# €123.46 #} + {{ 123.456789 | format_currency('EUR', {max_fraction_digit:2, min_fraction_digit:1}, 'en') }} + +* ``multiplier``: Multiplies the value before formatting:: + + {# €123,000.00 #} + {{ 123 | format_currency('EUR', {multiplier:1000}, 'en') }} + +* ``grouping_size``: +* ``secondary_grouping_size``: Set the size of the primary and secondary grouping separators:: + + {# €1,23,45,678.00 #} + {{ 12345678 | format_currency('EUR', {grouping_size:3, secondary_grouping_size:2}, 'en') }} + +* ``rounding_mode``: +* ``rounding_increment``: Control rounding behavior, here is a list of all rounding_mode available: + + * ``ceil``: Ceiling rounding + * ``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 + + .. code-block:: twig + + {# €123.50 #} + {{ 123.456 | format_currency('EUR', {rounding_mode:'ceiling', rounding_increment:0.05}, 'en') }} + +* ``format_width``: +* ``padding_position``: Set width and padding for the formatted number, here is a list of all padding_position available: + + * ``before_prefix``: Pad before the currency symbol + * ``after_prefix``: Pad after the currency symbol + * ``before_suffix``: Pad before the suffix (currency symbol) + * ``after_suffix``: Pad after the suffix (currency symbol) + + .. code-block:: twig + + {# €123.00 #} + {{ 123 | format_currency('EUR', {format_width:10, padding_position:'before_suffix'}, 'en') }} + +* ``significant_digits_used``: +* ``min_significant_digits_used``: +* ``max_significant_digits_used``: Control significant digits in formatting:: + + {# €123.4568 #} + {{ 123.456789 | format_currency('EUR', {significant_digits_used:true, min_significant_digits_used:4, max_significant_digits_used:7}, 'en') }} + +* ``lenient_parse``: If true, allows lenient parsing of the input:: + + {# €123.00 #} + {{ 123 | format_currency('EUR', {lenient_parse:true}, 'en') }} + +By default, the filter uses the current locale. You can pass it explicitly:: {# 1.000.000,00 € #} - {{ '1000000'|format_currency('EUR', locale='de') }} + {{ '1000000'|format_currency('EUR', locale: 'de') }} .. note:: @@ -72,6 +126,13 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``currency``: The currency +* ``currency``: The currency (ISO 4217 code) * ``attrs``: A map of attributes -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. note:: + + Internally, Twig uses the PHP `NumberFormatter::formatCurrency`_ function. + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 +.. _`NumberFormatter::formatCurrency`: https://www.php.net/manual/en/numberformatter.formatcurrency.php diff --git a/doc/filters/format_date.rst b/doc/filters/format_date.rst index c4a900a4360..ab0d609df50 100644 --- a/doc/filters/format_date.rst +++ b/doc/filters/format_date.rst @@ -29,8 +29,10 @@ the :doc:`format_datetime` filter, but without the time. Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ * ``dateFormat``: The date format * ``pattern``: A date time pattern * ``timezone``: The date timezone -* ``calendar``: The calendar (Gregorian by default) +* ``calendar``: The calendar ("gregorian" by default) + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/format_datetime.rst b/doc/filters/format_datetime.rst index 9fed54f8ec9..9b27af28e6d 100644 --- a/doc/filters/format_datetime.rst +++ b/doc/filters/format_datetime.rst @@ -8,6 +8,29 @@ The ``format_datetime`` filter formats a date time: {# 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 ------ @@ -16,23 +39,29 @@ You can tweak the output for the date part and the time part: .. code-block:: twig {# 23:39 #} - {{ '2019-08-07 23:39:12'|format_datetime('none', 'short', locale='fr') }} + {{ '2019-08-07 23:39:12'|format_datetime('none', 'short', locale: 'fr') }} {# 07/08/2019 #} - {{ '2019-08-07 23:39:12'|format_datetime('short', 'none', locale='fr') }} + {{ '2019-08-07 23:39:12'|format_datetime('short', 'none', locale: 'fr') }} {# mercredi 7 août 2019 23:39:12 UTC #} - {{ '2019-08-07 23:39:12'|format_datetime('full', 'full', locale='fr') }} + {{ '2019-08-07 23:39:12'|format_datetime('full', 'full', locale: 'fr') }} Supported values are: ``none``, ``short``, ``medium``, ``long``, and ``full``. +.. versionadded:: 3.6 + + ``relative_short``, ``relative_medium``, ``relative_long``, and ``relative_full`` are also supported when running on + PHP 8.0 and superior or when using a polyfill that define the ``IntlDateFormatter::RELATIVE_*`` constants and + associated behavior. + For greater flexibility, you can even define your own pattern (see the `ICU user guide`_ for supported patterns). .. code-block:: twig {# 11 oclock PM, GMT #} - {{ '2019-08-07 23:39:12'|format_datetime(pattern="hh 'oclock' a, zzzz") }} + {{ '2019-08-07 23:39:12'|format_datetime(pattern: "hh 'oclock' a, zzzz") }} Locale ------ @@ -42,7 +71,7 @@ By default, the filter uses the current locale. You can pass it explicitly: .. code-block:: twig {# 7 août 2019 23:39:12 #} - {{ '2019-08-07 23:39:12'|format_datetime(locale='fr') }} + {{ '2019-08-07 23:39:12'|format_datetime(locale: 'fr') }} Timezone -------- @@ -53,14 +82,14 @@ it by explicitly specifying a timezone: .. code-block:: twig - {{ datetime|format_datetime(locale='en', timezone='Pacific/Midway') }} + {{ datetime|format_datetime(locale: 'en', timezone: 'Pacific/Midway') }} If the date is already a DateTime object, and if you want to keep its current timezone, pass ``false`` as the timezone value: .. code-block:: twig - {{ datetime|format_datetime(locale='en', timezone=false) }} + {{ datetime|format_datetime(locale: 'en', timezone: false) }} The default timezone can also be set globally by calling ``setTimezone()``:: @@ -92,11 +121,12 @@ The default timezone can also be set globally by calling ``setTimezone()``:: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ * ``dateFormat``: The date format * ``timeFormat``: The time format * ``pattern``: A date time pattern * ``timezone``: The date timezone name -* ``calendar``: The calendar (Gregorian by default) +* ``calendar``: The calendar ("gregorian" by default) .. _ICU user guide: https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/format_number.rst b/doc/filters/format_number.rst index a1c2804ab4b..0b51a00d852 100644 --- a/doc/filters/format_number.rst +++ b/doc/filters/format_number.rst @@ -19,53 +19,130 @@ You can pass attributes to tweak the output: The list of supported options: -* ``grouping_used``; -* ``decimal_always_shown``; -* ``max_integer_digit``; -* ``min_integer_digit``; -* ``integer_digit``; -* ``max_fraction_digit``; -* ``min_fraction_digit``; -* ``fraction_digit``; -* ``multiplier``; -* ``grouping_size``; -* ``rounding_mode``; -* ``rounding_increment``; -* ``format_width``; -* ``padding_position``; -* ``secondary_grouping_size``; -* ``significant_digits_used``; -* ``min_significant_digits_used``; -* ``max_significant_digits_used``; -* ``lenient_parse``. - -Besides plain numbers, the filter can also format numbers in various styles: +* ``grouping_used``: Specifies whether to use grouping separator for thousands:: -.. code-block:: twig + {# 1,234,567.89 #} + {{ 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') }} + +* ``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') }} + +* ``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') }} + +* ``multiplier``: Multiplies the value before formatting:: + + {# 123,000 #} + {{ 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') }} + +* ``rounding_mode``: +* ``rounding_increment``: Control rounding behavior, here is a list of all rounding_mode available: + * ``ceil``: Ceiling rounding + * ``floor``: Floor rounding + * ``down``: Rounding towards zero + * ``up``: Rounding away from zero + * ``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') }} + +* ``format_width``: +* ``padding_position``: Set width and padding for the formatted number, here is a list of all padding_position available: + * ``before_prefix``: Pad before the currency symbol + * ``after_prefix``: Pad after the currency symbol + * ``before_suffix``: Pad before the suffix (currency symbol) + * ``after_suffix``: Pad after the suffix (currency symbol) + + .. code-block:: twig + + {# 123 #} + {{ 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') }} + +* ``lenient_parse``: If true, allows lenient parsing of the input:: + + {# 123 #} + {{ 123|format_number({lenient_parse:true}, locale: 'en') }} + +Besides plain numbers, the filter can also format numbers in various styles:: {# 1,234% #} - {{ '12.345'|format_number(style='percent') }} + {{ '12.345'|format_number(style: 'percent') }} {# twelve point three four five #} - {{ '12.345'|format_number(style='spellout') }} + {{ '12.345'|format_number(style: 'spellout') }} {# 12 sec. #} {{ '12'|format_duration_number }} The list of supported styles: -* ``decimal``; -* ``currency``; -* ``percent``; -* ``scientific``; -* ``spellout``; -* ``ordinal``; -* ``duration``. +* ``decimal``:: -As a shortcut, you can use the ``format_*_number`` filters by replacing `*` with -a style: + {# 1,234.568 #} + {{ 1234.56789 | format_number(style: 'decimal', locale: 'en') }} -.. code-block:: twig +* ``currency``:: + + {# $1,234.56 #} + {{ 1234.56 | format_number(style: 'currency', locale: 'en') }} + +* ``percent``:: + + {# 12% #} + {{ 0.1234 | format_number(style: 'percent', locale: 'en') }} + +* ``scientific``:: + + {# 1.23456789e+3 #} + {{ 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') }} + +* ``ordinal``:: + + {# 1st #} + {{ 1 | format_number(style: 'ordinal', locale: 'en') }} + +* ``duration``:: + + {# 2:30:00 #} + {{ 9000 | format_number(style: 'duration', locale: 'en') }} + +As a shortcut, you can use the ``format_*_number`` filters by replacing ``*`` +with a style:: {# 1,234% #} {{ '12.345'|format_percent_number }} @@ -73,32 +150,28 @@ a style: {# twelve point three four five #} {{ '12.345'|format_spellout_number }} -You can pass attributes to tweak the output: - -.. code-block:: twig +You can pass attributes to tweak the output:: {# 12.3% #} {{ '0.12345'|format_percent_number({rounding_mode: 'floor', fraction_digit: 1}) }} -By default, the filter uses the current locale. You can pass it explicitly: - -.. code-block:: twig +By default, the filter uses the current locale. You can pass it explicitly:: {# 12,345 #} - {{ '12.345'|format_number(locale='fr') }} + {{ '12.345'|format_number(locale: 'fr') }} .. note:: The ``format_number`` filter is part of the ``IntlExtension`` which is not installed by default. Install it first: - .. code-block:: bash + .. code-block:: sh $ composer require twig/intl-extra Then, on Symfony projects, install the ``twig/extra-bundle``: - .. code-block:: bash + .. code-block:: sh $ composer require twig/extra-bundle @@ -112,6 +185,8 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ * ``attrs``: A map of attributes * ``style``: The style of the number output + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/format_time.rst b/doc/filters/format_time.rst index 417b8a9c62b..8709a6bcf4e 100644 --- a/doc/filters/format_time.rst +++ b/doc/filters/format_time.rst @@ -29,8 +29,10 @@ the :doc:`format_datetime` filter, but without the date. Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ * ``timeFormat``: The time format * ``pattern``: A date time pattern * ``timezone``: The date timezone -* ``calendar``: The calendar (Gregorian by default) +* ``calendar``: The calendar ("gregorian" by default) + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/index.rst b/doc/filters/index.rst index eea2383e505..475b35c804d 100644 --- a/doc/filters/index.rst +++ b/doc/filters/index.rst @@ -18,6 +18,7 @@ Filters default escape filter + find first format format_currency @@ -41,11 +42,14 @@ Filters merge nl2br number_format + plural raw reduce replace reverse round + shuffle + singular slice slug sort diff --git a/doc/filters/inky_to_html.rst b/doc/filters/inky_to_html.rst index 563baba363a..839e41935e8 100644 --- a/doc/filters/inky_to_html.rst +++ b/doc/filters/inky_to_html.rst @@ -2,7 +2,7 @@ ================ The ``inky_to_html`` filter processes an `inky email template -`_: +`_: .. code-block:: html+twig diff --git a/doc/filters/inline_css.rst b/doc/filters/inline_css.rst index 44b142626d8..15f2eb2b439 100644 --- a/doc/filters/inline_css.rst +++ b/doc/filters/inline_css.rst @@ -1,7 +1,7 @@ ``inline_css`` ============== -The ``inline_css`` filter inline CSS styles in HTML documents: +The ``inline_css`` filter inlines CSS styles in HTML documents: .. code-block:: html+twig diff --git a/doc/filters/invoke.rst b/doc/filters/invoke.rst new file mode 100644 index 00000000000..b8e1a4936b6 --- /dev/null +++ b/doc/filters/invoke.rst @@ -0,0 +1,16 @@ +``invoke`` +========== + +.. versionadded:: 3.19 + + The ``invoke`` filter has been added in Twig 3.19. + +The ``invoke`` filter invokes an arrow function with the given arguments: + +.. code-block:: twig + + {% set person = { first: "Bob", last: "Smith" } %} + {% set func = p => "#{p.first} #{p.last}" %} + + {{ func|invoke(person) }} + {# outputs Bob Smith #} diff --git a/doc/filters/keys.rst b/doc/filters/keys.rst index 58609471720..bcd638d8c08 100644 --- a/doc/filters/keys.rst +++ b/doc/filters/keys.rst @@ -1,11 +1,23 @@ ``keys`` ======== -The ``keys`` filter returns the keys of an array. It is useful when you want to -iterate over the keys of an array: +The ``keys`` filter returns the keys of a sequence or a mapping. It is useful +when you want to iterate over the keys of a sequence or a mapping: .. code-block:: twig - {% for key in array|keys %} - ... + {% for key in ['a', 'b', 'c', 'd']|keys %} + {{ key }} {% endfor %} + {# outputs: 0 1 2 3 #} + + {% for key in {a: 'a_value', b: 'b_value'}|keys %} + {{ key }} + {% endfor %} + {# outputs: a b #} + +.. note:: + + Internally, Twig uses the PHP `array_keys`_ function. + +.. _`array_keys`: https://www.php.net/array_keys diff --git a/doc/filters/language_name.rst b/doc/filters/language_name.rst index 55c2439207a..ccffd20eb04 100644 --- a/doc/filters/language_name.rst +++ b/doc/filters/language_name.rst @@ -1,8 +1,8 @@ ``language_name`` ================= -The ``language_name`` filter returns the language name given its two-letter -code: +The ``language_name`` filter returns the language name based on its ISO 639-1 +code, ISO 639-2 code, or other specific localized code: .. code-block:: twig @@ -44,4 +44,6 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/last.rst b/doc/filters/last.rst index d7ac6a533be..865e57cc117 100644 --- a/doc/filters/last.rst +++ b/doc/filters/last.rst @@ -9,7 +9,7 @@ a string: {{ [1, 2, 3, 4]|last }} {# outputs 4 #} - {{ { a: 1, b: 2, c: 3, d: 4 }|last }} + {{ {a: 1, b: 2, c: 3, d: 4}|last }} {# outputs 4 #} {{ '1234'|last }} diff --git a/doc/filters/length.rst b/doc/filters/length.rst index d36712233cd..a9dfae423ca 100644 --- a/doc/filters/length.rst +++ b/doc/filters/length.rst @@ -12,8 +12,12 @@ it will return the length of the string provided by that method. For objects that implement the ``Traversable`` interface, ``length`` will use the return value of the ``iterator_count()`` method. +For strings, `mb_strlen()`_ is used. + .. code-block:: twig {% if users|length > 10 %} ... {% endif %} + +.. _mb_strlen(): https://www.php.net/manual/function.mb-strlen.php diff --git a/doc/filters/locale_name.rst b/doc/filters/locale_name.rst index c6d34cbc019..9e0df073854 100644 --- a/doc/filters/locale_name.rst +++ b/doc/filters/locale_name.rst @@ -1,8 +1,7 @@ ``locale_name`` =============== -The ``locale_name`` filter returns the locale name given its two-letter -code: +The ``locale_name`` filter returns the locale name given its code: .. code-block:: twig @@ -44,4 +43,6 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/lower.rst b/doc/filters/lower.rst index c0a0e0cddf4..1dff6039e10 100644 --- a/doc/filters/lower.rst +++ b/doc/filters/lower.rst @@ -8,3 +8,9 @@ The ``lower`` filter converts a value to lowercase: {{ 'WELCOME'|lower }} {# outputs 'welcome' #} + +.. note:: + + Internally, Twig uses the PHP `mb_strtolower`_ function. + +.. _`mb_strtolower`: https://www.php.net/manual/fr/function.mb-strtolower.php diff --git a/doc/filters/markdown_to_html.rst b/doc/filters/markdown_to_html.rst index 8e3fede00f4..886cda4d40a 100644 --- a/doc/filters/markdown_to_html.rst +++ b/doc/filters/markdown_to_html.rst @@ -7,7 +7,7 @@ The ``markdown_to_html`` filter converts a block of Markdown to HTML: {% apply markdown_to_html %} Title - ====== + ===== Hello! {% endapply %} @@ -19,7 +19,7 @@ removed consistently before conversion: {% apply markdown_to_html %} Title - ====== + ===== Hello! {% endapply %} diff --git a/doc/filters/merge.rst b/doc/filters/merge.rst index 40146b5d7e5..d0b302c8f60 100644 --- a/doc/filters/merge.rst +++ b/doc/filters/merge.rst @@ -1,7 +1,9 @@ ``merge`` ========= -The ``merge`` filter merges an array with another array: +The ``merge`` filter merges sequences and mappings: + +For sequences, new values are added at the end of the existing ones: .. code-block:: twig @@ -11,35 +13,31 @@ The ``merge`` filter merges an array with another array: {# values now contains [1, 2, 'apple', 'orange'] #} -New values are added at the end of the existing ones. - -The ``merge`` filter also works on hashes: +For mappings, the merging process occurs on the keys; if the key does not +already exist, it is added but if the key already exists, its value is +overridden: .. code-block:: twig - {% set items = { 'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'unknown' } %} + {% set items = {'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'unknown'} %} {% set items = items|merge({ 'peugeot': 'car', 'renault': 'car' }) %} - {# items now contains { 'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'car', 'renault': 'car' } #} - -For hashes, the merging process occurs on the keys: if the key does not -already exist, it is added but if the key already exists, its value is -overridden. + {# items now contains {'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'car', 'renault': 'car'} #} .. tip:: - If you want to ensure that some values are defined in an array (by given + If you want to ensure that some values are defined in a mapping (by given default values), reverse the two elements in the call: .. code-block:: twig - {% set items = { 'apple': 'fruit', 'orange': 'fruit' } %} + {% set items = {'apple': 'fruit', 'orange': 'fruit'} %} + + {% set items = {'apple': 'unknown'}|merge(items) %} - {% set items = { 'apple': 'unknown' }|merge(items) %} + {# items now contains {'apple': 'fruit', 'orange': 'fruit'} #} - {# items now contains { 'apple': 'fruit', 'orange': 'fruit' } #} - .. note:: Internally, Twig uses the PHP `array_merge`_ function. It supports diff --git a/doc/filters/number_format.rst b/doc/filters/number_format.rst index 047249d6718..4f68596fbe1 100644 --- a/doc/filters/number_format.rst +++ b/doc/filters/number_format.rst @@ -15,15 +15,21 @@ separator using the additional arguments: {{ 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 --git a/doc/filters/plural.rst b/doc/filters/plural.rst new file mode 100644 index 00000000000..7703452196e --- /dev/null +++ b/doc/filters/plural.rst @@ -0,0 +1,53 @@ +``plural`` +========== + +.. versionadded:: 3.11 + + The ``plural`` filter was added in Twig 3.11. + +The ``plural`` filter transforms a given noun in its singular form into its +plural version: + +.. code-block:: twig + + {# English (en) rules are used by default #} + {{ 'animal'|plural() }} + animals + + {{ 'animal'|plural('fr') }} + animaux + +.. note:: + + The ``plural`` filter is part of the ``StringExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/string-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\String\StringExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new StringExtension()); + +Arguments +--------- + +* ``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`` + +.. note:: + + Internally, Twig uses the `pluralize`_ method from the Symfony String component. + +.. _`inflector`: https://symfony.com/doc/current/components/string.html#inflector +.. _`pluralize`: https://symfony.com/doc/current/components/string.html#inflector diff --git a/doc/filters/reduce.rst b/doc/filters/reduce.rst index 7df4646c745..ac8089fd88d 100644 --- a/doc/filters/reduce.rst +++ b/doc/filters/reduce.rst @@ -4,21 +4,21 @@ The ``reduce`` filter iteratively reduces a sequence or a mapping to a single value using an arrow function, so as to reduce it to a single value. The arrow function receives the return value of the previous iteration and the current -value of the sequence or mapping: +value and key of the sequence or mapping: .. code-block:: twig {% set numbers = [1, 2, 3] %} - {{ numbers|reduce((carry, v) => carry + v) }} - {# output 6 #} + {{ 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) => carry + v, 10) }} - {# output 16 #} + {{ numbers|reduce((carry, value, key) => carry + value * key, 10) }} + {# output 18 #} Note that the arrow function has access to the current context. diff --git a/doc/filters/replace.rst b/doc/filters/replace.rst index 0c38b73bfe4..8053ae1eb39 100644 --- a/doc/filters/replace.rst +++ b/doc/filters/replace.rst @@ -6,6 +6,8 @@ format is free-form): .. 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" #} @@ -17,7 +19,7 @@ format is free-form): Arguments --------- -* ``from``: The placeholder values as a hash +* ``from``: The placeholder values as a mapping .. seealso:: diff --git a/doc/filters/shuffle.rst b/doc/filters/shuffle.rst new file mode 100644 index 00000000000..9ade4f01130 --- /dev/null +++ b/doc/filters/shuffle.rst @@ -0,0 +1,92 @@ +``shuffle`` +=========== + +.. versionadded:: 3.11 + + The ``shuffle`` filter was added in Twig 3.11. + +The ``shuffle`` filter shuffles a sequence, a mapping, or a string: + +.. code-block:: twig + + {% for user in users|shuffle %} + ... + {% endfor %} + +.. caution:: + + The shuffled array does not preserve keys. So if the input had not + sequential keys but indexed keys (using the user id for instance), it is + not the case anymore after shuffling it. + +Example 1: + +.. code-block:: html+twig + + {% set items = [ + 'a', + 'b', + 'c', + ] %} + +

    + {% for item in items|shuffle %} +
  • {{ item }}
  • + {% endfor %} +
+ +The above example will be rendered as: + +.. code-block:: html + +
    +
  • a
  • +
  • c
  • +
  • b
  • +
+ +The result can also be: "a, b, c" or "b, a, c" or "b, c, a" or "c, a, b" or +"c, b, a". + +Example 2: + +.. code-block:: html+twig + + {% set items = { + 'a': 'd', + 'b': 'e', + 'c': 'f', + } %} + +
    + {% for index, item in items|shuffle %} +
  • {{ index }} - {{ item }}
  • + {% endfor %} +
+ +The above example will be rendered as: + +.. code-block:: html + +
    +
  • 0 - d
  • +
  • 1 - f
  • +
  • 2 - e
  • +
+ +The result can also be: "d, e, f" or "e, d, f" or "e, f, d" or "f, d, e" or +"f, e, d". + +.. code-block:: html+twig + + {% set string = 'ghi' %} + +

{{ string|shuffle }}

+ +The above example will be rendered as: + +.. code-block:: html + +

gih

+ +The result can also be: "ghi" or "hgi" or "hig" or "igh" or "ihg". diff --git a/doc/filters/singular.rst b/doc/filters/singular.rst new file mode 100644 index 00000000000..9a9e03ff19b --- /dev/null +++ b/doc/filters/singular.rst @@ -0,0 +1,53 @@ +``singular`` +============ + +.. versionadded:: 3.11 + + The ``singular`` filter was added in Twig 3.11. + +The ``singular`` filter transforms a given noun in its plural form into its +singular version: + +.. code-block:: twig + + {# English (en) rules are used by default #} + {{ 'partitions'|singular() }} + partition + + {{ 'partitions'|singular('fr') }} + partition + +.. note:: + + The ``singular`` filter is part of the ``StringExtension`` which is not + installed by default. Install it first: + + .. code-block:: bash + + $ composer require twig/string-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\String\StringExtension; + + $twig = new \Twig\Environment(...); + $twig->addExtension(new StringExtension()); + +Arguments +--------- + +* ``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`` + +.. note:: + + Internally, Twig uses the `singularize`_ method from the Symfony String component. + +.. _`inflector`: +.. _`singularize`: diff --git a/doc/filters/slice.rst b/doc/filters/slice.rst index ae83b57a063..47af2b923e1 100644 --- a/doc/filters/slice.rst +++ b/doc/filters/slice.rst @@ -21,7 +21,7 @@ You can use any valid expression for both the start and the length: {# ... #} {% endfor %} -As syntactic sugar, you can also use the ``[]`` notation: +As syntactic sugar, you can also use the ``[]`` operator: .. code-block:: twig @@ -37,6 +37,9 @@ As syntactic sugar, you can also use the ``[]`` notation: {# you can omit the last argument -- which will select everything till the end #} {{ '12345'[2:] }} {# will display "345" #} + {# you can use a negative value -- for example to remove characters at the end #} + {{ '12345'[:-2] }} {# will display "123" #} + The ``slice`` filter works as the `array_slice`_ PHP function for arrays and `mb_substr`_ for strings with a fallback to `substr`_. @@ -51,6 +54,28 @@ negative then the sequence will stop that many elements from the end of the variable. If it is omitted, then the sequence will have everything from offset up until the end of the variable. +The argument ``preserve_keys`` is used to reset the index during the loop. + +.. code-block:: twig + + {% for key, value in [1, 2, 3, 4, 5]|slice(1, 2, true) %} + {{ key }} - {{ value }} + {% endfor %} + + {# output + 1 - 2 + 2 - 3 + #} + + {% for key, value in [1, 2, 3, 4, 5]|slice(1, 2) %} + {{ key }} - {{ value }} + {% endfor %} + + {# output + 0 - 2 + 1 - 3 + #} + .. note:: It also works with objects implementing the `Traversable`_ interface. @@ -60,7 +85,7 @@ Arguments * ``start``: The start of the slice * ``length``: The size of the slice -* ``preserve_keys``: Whether to preserve key or not (when the input is an array) +* ``preserve_keys``: Whether to preserve key or not (when the input is an array), by default the value is ``false``. .. _`Traversable`: https://www.php.net/manual/en/class.traversable.php .. _`array_slice`: https://www.php.net/array_slice diff --git a/doc/filters/slug.rst b/doc/filters/slug.rst index 773a42fac26..f5b91b2daf6 100644 --- a/doc/filters/slug.rst +++ b/doc/filters/slug.rst @@ -56,4 +56,6 @@ Arguments --------- * ``separator``: The separator that is used to join words (defaults to ``-``) -* ``locale``: The locale of the original string (if none is specified, it will be automatically detected) +* ``locale``: The locale code of the original string as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/sort.rst b/doc/filters/sort.rst index 1816c35e6fc..4afd60e7d8a 100644 --- a/doc/filters/sort.rst +++ b/doc/filters/sort.rst @@ -1,7 +1,7 @@ ``sort`` ======== -The ``sort`` filter sorts an array: +The ``sort`` filter sorts sequences and mappings: .. code-block:: twig @@ -15,14 +15,14 @@ The ``sort`` filter sorts an array: association. It supports Traversable objects by transforming those to arrays. -You can pass an arrow function to sort the array: +You can pass an arrow function to configure the sorting: .. code-block:: html+twig {% set fruits = [ - { name: 'Apples', quantity: 5 }, - { name: 'Oranges', quantity: 2 }, - { name: 'Grapes', quantity: 4 }, + {name: 'Apples', quantity: 5}, + {name: 'Oranges', quantity: 2}, + {name: 'Grapes', quantity: 4}, ] %} {% for fruit in fruits|sort((a, b) => a.quantity <=> b.quantity)|column('name') %} diff --git a/doc/filters/spaceless.rst b/doc/filters/spaceless.rst index 9a213c370b1..7fab7fb2602 100644 --- a/doc/filters/spaceless.rst +++ b/doc/filters/spaceless.rst @@ -1,6 +1,11 @@ ``spaceless`` ============= +.. warning:: + + The ``spaceless`` filter is deprecated as of Twig 3.12. While not a full + replacement, you can check the :ref:`whitespace control features `. + Use the ``spaceless`` filter to remove whitespace *between HTML tags*, not whitespace within HTML tags or whitespace in plain text: diff --git a/doc/filters/split.rst b/doc/filters/split.rst index 386ae3043d0..ba0c0044894 100644 --- a/doc/filters/split.rst +++ b/doc/filters/split.rst @@ -6,12 +6,12 @@ of strings: .. code-block:: twig - {% set foo = "one,two,three"|split(',') %} - {# foo contains ['one', 'two', 'three'] #} + {% set items = "one,two,three"|split(',') %} + {# items contains ['one', 'two', 'three'] #} You can also pass a ``limit`` argument: -* If ``limit`` is positive, the returned array will contain a maximum of +* If ``limit`` is positive, the returned sequence will contain a maximum of limit elements with the last element containing the rest of string; * If ``limit`` is negative, all components except the last -limit are @@ -21,19 +21,19 @@ You can also pass a ``limit`` argument: .. code-block:: twig - {% set foo = "one,two,three,four,five"|split(',', 3) %} - {# foo contains ['one', 'two', 'three,four,five'] #} + {% set items = "one,two,three,four,five"|split(',', 3) %} + {# items contains ['one', 'two', 'three,four,five'] #} If the ``delimiter`` is an empty string, then value will be split by equal chunks. Length is set by the ``limit`` argument (one character by default). .. code-block:: twig - {% set foo = "123"|split('') %} - {# foo contains ['1', '2', '3'] #} + {% set items = "123"|split('') %} + {# items contains ['1', '2', '3'] #} - {% set bar = "aabbcc"|split('', 2) %} - {# bar contains ['aa', 'bb', 'cc'] #} + {% set items = "aabbcc"|split('', 2) %} + {# items contains ['aa', 'bb', 'cc'] #} .. note:: diff --git a/doc/filters/striptags.rst b/doc/filters/striptags.rst index d5f542b3d8b..64a9c8156fe 100644 --- a/doc/filters/striptags.rst +++ b/doc/filters/striptags.rst @@ -1,7 +1,7 @@ ``striptags`` ============= -The ``striptags`` filter strips SGML/XML tags and replace adjacent whitespace +The ``striptags`` filter strips SGML/XML tags and replaces adjacent whitespace characters by one space: .. code-block:: twig diff --git a/doc/filters/timezone_name.rst b/doc/filters/timezone_name.rst index dfb22818bad..26c6f8917ed 100644 --- a/doc/filters/timezone_name.rst +++ b/doc/filters/timezone_name.rst @@ -1,7 +1,7 @@ ``timezone_name`` ================= -The ``timezone_name`` filter returns the timezone name given a timezone identifier: +The ``timezone_name`` filter returns the timezone name given its ISO 8601 timezone identifier: .. code-block:: twig @@ -43,4 +43,6 @@ By default, the filter uses the current locale. You can pass it explicitly: Arguments --------- -* ``locale``: The locale +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/filters/trim.rst b/doc/filters/trim.rst index a3d36ca0345..ba01cbe8588 100644 --- a/doc/filters/trim.rst +++ b/doc/filters/trim.rst @@ -14,7 +14,7 @@ and end of a string: {# outputs ' I like Twig' #} - {{ ' I like Twig. '|trim(side='left') }} + {{ ' I like Twig. '|trim(side: 'left') }} {# outputs 'I like Twig. ' #} @@ -31,8 +31,9 @@ Arguments * ``character_mask``: The characters to strip -* ``side``: The default is to strip from the left and the right (`both`) sides, but `left` - and `right` will strip from either the left side or right side only +* ``side``: The default is to strip from the left and the right (``both``) + sides, but ``left`` and ``right`` will strip from either the left side or + right side only .. _`trim`: https://www.php.net/trim .. _`ltrim`: https://www.php.net/ltrim diff --git a/doc/filters/u.rst b/doc/filters/u.rst index 20bb0d5cfe8..f27cb1812c2 100644 --- a/doc/filters/u.rst +++ b/doc/filters/u.rst @@ -18,6 +18,9 @@ Wrapping a text to a given number of characters: Twig = <3 +Here, ``u`` is the filter and ``wordwrap(5)`` is a method called on the result +of the filter; it's equivalent to ``(text|u).wordwrap(5)``. + Truncating a string: .. code-block:: twig @@ -56,14 +59,6 @@ You can also chain methods: TWIG = <3 -For large strings manipulation, use the ``apply`` tag: - -.. code-block:: twig - - {% apply u.wordwrap(5) %} - Some large amount of text... - {% endapply %} - .. note:: The ``u`` filter is part of the ``StringExtension`` which is not installed diff --git a/doc/filters/upper.rst b/doc/filters/upper.rst index 01c9fbb0b53..2ca7cbeb55d 100644 --- a/doc/filters/upper.rst +++ b/doc/filters/upper.rst @@ -8,3 +8,9 @@ The ``upper`` filter converts a value to uppercase: {{ 'welcome'|upper }} {# outputs 'WELCOME' #} + +.. note:: + + Internally, Twig uses the PHP `mb_strtoupper`_ function. + +.. _`mb_strtoupper`: https://www.php.net/mb_strtoupper diff --git a/doc/filters/url_encode.rst b/doc/filters/url_encode.rst index c5919be016b..c43011fc366 100644 --- a/doc/filters/url_encode.rst +++ b/doc/filters/url_encode.rst @@ -1,8 +1,8 @@ ``url_encode`` ============== -The ``url_encode`` filter percent encodes a given string as URL segment -or an array as query string: +The ``url_encode`` filter percent encodes a given string as URL segment or a +mapping as query string: .. code-block:: twig @@ -12,8 +12,8 @@ or an array as query string: {{ "string with spaces"|url_encode }} {# outputs "string%20with%20spaces" #} - {{ {'param': 'value', 'foo': 'bar'}|url_encode }} - {# outputs "param=value&foo=bar" #} + {{ {'name': 'Fabien', 'city': 'Paris'}|url_encode }} + {# outputs "name=Fabien&city=Paris" #} .. note:: diff --git a/doc/functions/attribute.rst b/doc/functions/attribute.rst index e9d9a842ef3..6a0d5ada9ec 100644 --- a/doc/functions/attribute.rst +++ b/doc/functions/attribute.rst @@ -1,14 +1,32 @@ ``attribute`` ============= -The ``attribute`` function can be used to access a "dynamic" attribute of a -variable: +.. warning:: + + The ``attribute`` function is deprecated as of Twig 3.15. Use the + :ref:`dot operator ` that now accepts any expression + when wrapped with parenthesis. + + Note that this function will still be available in Twig 4.0 to allow a + smoother upgrade path. + +The ``attribute`` function lets you access an attribute, method, or property of +an object or array when the name of that attribute, method, or property is stored +in a variable or dynamically generated with an expression: .. code-block:: twig - {{ attribute(object, method) }} - {{ attribute(object, method, arguments) }} - {{ attribute(array, item) }} + {# method_name is a variable that stores the method to call #} + {{ attribute(object, method_name) }} + + {# you can also pass arguments when calling a method #} + {{ attribute(object, method_name, arguments) }} + + {# the method/property name can be the result of evaluating an expression #} + {{ attribute(object, 'some_property_' ~ user.type) }} + + {# in addition to objects, this function works with plain arrays as well #} + {{ attribute(array, item_name) }} In addition, the ``defined`` test can check for the existence of a dynamic attribute: @@ -20,4 +38,11 @@ attribute: .. note:: The resolution algorithm is the same as the one used for the ``.`` - notation, except that the item can be any valid expression. + operator. + +Arguments +--------- + +* ``variable``: The variable +* ``attribute``: The attribute name +* ``arguments``: An array of arguments to pass to the call diff --git a/doc/functions/block.rst b/doc/functions/block.rst index 117e160f584..acd13649283 100644 --- a/doc/functions/block.rst +++ b/doc/functions/block.rst @@ -1,7 +1,7 @@ ``block`` ========= -When a template uses inheritance and if you want to print a block multiple +When a template uses inheritance and if you want to render a block multiple times, use the ``block`` function: .. code-block:: html+twig @@ -17,7 +17,7 @@ template: .. code-block:: twig - {{ block("title", "common_blocks.twig") }} + {{ block("title", "common_blocks.html.twig") }} Use the ``defined`` test to check if a block exists in the context of the current template: @@ -28,10 +28,16 @@ current template: ... {% endif %} - {% if block("footer", "common_blocks.twig") is defined %} + {% if block("footer", "common_blocks.html.twig") is defined %} ... {% endif %} +Arguments +--------- + +* ``name``: The block name +* ``template``: The template where to look for the block + .. seealso:: :doc:`extends<../tags/extends>`, :doc:`parent<../functions/parent>` diff --git a/doc/functions/country_names.rst b/doc/functions/country_names.rst new file mode 100644 index 00000000000..f65b265edc9 --- /dev/null +++ b/doc/functions/country_names.rst @@ -0,0 +1,49 @@ +``country_names`` +================= + +.. versionadded:: 3.5 + + The ``country_names`` function was added in Twig 3.5. + +The ``country_names`` function returns the names of the countries: + +.. code-block:: twig + + {# Afghanistan, Åland Islands, ... #} + {{ country_names()|join(', ') }} + +By default, the function uses the current locale. You can pass it explicitly: + +.. code-block:: twig + + {# Afghanistan, Afrique du Sud, ... #} + {{ country_names('fr')|join(', ') }} + +.. note:: + + The ``country_names`` function 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()); + +Arguments +--------- + +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/functions/country_timezones.rst b/doc/functions/country_timezones.rst index ecbbc1c9941..370a4befff3 100644 --- a/doc/functions/country_timezones.rst +++ b/doc/functions/country_timezones.rst @@ -2,13 +2,15 @@ ===================== The ``country_timezones`` function returns the names of the timezones associated -with a given country code: +with a given country its ISO-3166 code: .. code-block:: twig {# Europe/Paris #} {{ country_timezones('FR')|join(', ') }} +If the specified country were to be unknown, it will return an empty array + .. note:: The ``country_timezones`` function is part of the ``IntlExtension`` which is not @@ -30,3 +32,8 @@ with a given country code: $twig = new \Twig\Environment(...); $twig->addExtension(new IntlExtension()); + +Arguments +--------- + +* ``country``: The country code \ No newline at end of file diff --git a/doc/functions/currency_names.rst b/doc/functions/currency_names.rst new file mode 100644 index 00000000000..9113aa08866 --- /dev/null +++ b/doc/functions/currency_names.rst @@ -0,0 +1,49 @@ +``currency_names`` +================== + +.. versionadded:: 3.5 + + The ``currency_names`` function was added in Twig 3.5. + +The ``currency_names`` function returns the names of the currencies: + +.. code-block:: twig + + {# Afghan Afghani, Afghan Afghani (1927–2002), ... #} + {{ currency_names()|join(', ') }} + +By default, the function uses the current locale. You can pass it explicitly: + +.. code-block:: twig + + {# afghani (1927–2002), afghani afghan, ... #} + {{ currency_names('fr')|join(', ') }} + +.. note:: + + The ``currency_names`` function 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()); + +Arguments +--------- + +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/functions/cycle.rst b/doc/functions/cycle.rst index 84cff6a1d5a..8b159e1e526 100644 --- a/doc/functions/cycle.rst +++ b/doc/functions/cycle.rst @@ -1,7 +1,7 @@ ``cycle`` ========= -The ``cycle`` function cycles on an array of values: +The ``cycle`` function cycles on a sequence: .. code-block:: twig @@ -11,8 +11,21 @@ The ``cycle`` function cycles on an array of values: {% for year in start_year..end_year %} {{ cycle(['odd', 'even'], loop.index0) }} {% endfor %} + + {# outputs -The array can contain any number of values: + odd + even + odd + even + odd + even + + #} + +The ``cycle`` function takes two arguments: the ``sequence`` to cycle through and the ``position`` in the sequence. + +The ``sequence`` must be non-empty and can contain any number of values: .. code-block:: twig @@ -21,8 +34,25 @@ The array can contain any number of values: {% for i in 0..10 %} {{ cycle(fruits, i) }} {% endfor %} + + {# outputs + + apple + orange + citrus + apple + orange + citrus + apple + orange + citrus + apple + orange + + #} Arguments --------- -* ``position``: The cycle position +* ``values``: The sequence to cycle on +* ``position``: The position in the sequence diff --git a/doc/functions/date.rst b/doc/functions/date.rst index 16e1d48740e..3c6b6316bae 100644 --- a/doc/functions/date.rst +++ b/doc/functions/date.rst @@ -9,7 +9,7 @@ Converts an argument to a date to allow date comparison: {# 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 --git a/doc/functions/enum.rst b/doc/functions/enum.rst new file mode 100644 index 00000000000..a49f25c2c64 --- /dev/null +++ b/doc/functions/enum.rst @@ -0,0 +1,34 @@ +``enum`` +======== + +.. versionadded:: 3.15 + + The ``enum`` function was added in Twig 3.15. + +``enum`` gives access to enums: + +.. code-block:: twig + + {# display one specific case of a backed enum #} + {{ enum('App\\CardSuite').Clubs.value }} {# "clubs" #} + + {# get all cases of an enum #} + {% 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\\CardSuite').someMethod() }} + +When using a string literal for the ``enum`` argument, it will be validated during compile time to be a valid enum name. + +Arguments +--------- + +* ``enum``: The FQCN of the enum diff --git a/doc/functions/enum_cases.rst b/doc/functions/enum_cases.rst new file mode 100644 index 00000000000..480c022d611 --- /dev/null +++ b/doc/functions/enum_cases.rst @@ -0,0 +1,22 @@ +``enum_cases`` +============== + +.. versionadded:: 3.12 + + The ``enum_cases`` function was added in Twig 3.12. + +``enum_cases`` returns the list of cases for a given enum: + +.. code-block:: twig + + {% 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. + +Arguments +--------- + +* ``enum``: The FQCN of the enum diff --git a/doc/functions/html_cva.rst b/doc/functions/html_cva.rst new file mode 100644 index 00000000000..61c094ea50b --- /dev/null +++ b/doc/functions/html_cva.rst @@ -0,0 +1,189 @@ +``html_cva`` +============ + +.. versionadded:: 3.12 + + 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 +a set of conditions and recipes to dynamically compose CSS class strings (color, size, etc.), +to create highly reusable and customizable templates. + +The concept of CVA is powered by a ``html_cva()`` Twig +function where you define ``base`` classes that should always be present and then different +``variants`` and the corresponding classes: + +.. code-block:: html+twig + + {# templates/alert.html.twig #} + {% set alert = html_cva( + base: 'alert', + variants: { + color: { + blue: 'bg-blue', + red: 'bg-red', + green: 'bg-green', + }, + size: { + sm: 'text-sm', + md: 'text-md', + lg: 'text-lg', + } + } + ) %} + +
+ ... +
+ +Then use the ``color`` and ``size`` variants to select the needed classes: + +.. code-block:: twig + + {# index.html.twig #} + {{ include('alert.html.twig', {'color': 'blue', 'size': 'md'}) }} + {# class="alert bg-blue text-md" #} + + {{ include('alert.html.twig', {'color': 'green', 'size': '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" #} + +CVA and Tailwind CSS +-------------------- + +CVA work perfectly with Tailwind CSS. The only drawback is that you can have class conflicts. +To "merge" conflicting classes together and keep only the ones you need, use the +``tailwind_merge()`` filter from `tales-from-a-dev/twig-tailwind-extra`_ +with the ``html_cva()`` function: + +.. code-block:: bash + + $ composer require tales-from-a-dev/twig-tailwind-extra + +.. code-block:: html+twig + + {% set alert = html_cva( + ... + ) %} + +
+ ... +
+ +Compound Variants +----------------- + +You can define compound variants. A compound variant is a variant that applies +when multiple other variant conditions are met: + +.. code-block:: html+twig + + {% set alert = html_cva( + base: 'alert', + variants: { + color: { + blue: 'bg-blue', + red: 'bg-red', + green: 'bg-green', + }, + size: { + sm: 'text-sm', + md: 'text-md', + lg: 'text-lg', + } + }, + compound_variants: [{ + # if color = red AND size = (md or lg), add the `font-bold` class + color: ['red'], + size: ['md', 'lg'], + class: 'font-bold', + }] + ) %} + +
+ ... +
+ + {# index.html.twig #} + + {{ include('alert.html.twig', {color: 'red', size: 'lg'}) }} + {# class="alert bg-red text-lg font-bold" #} + + {{ include('alert.html.twig', {color: 'green', size: 'sm'}) }} + {# class="alert bg-green text-sm" #} + + {{ include('alert.html.twig', {color: 'red', size: 'md'}) }} + {# class="alert bg-green text-md font-bold" #} + +Default Variants +---------------- + +If no variants match, you can define a default set of classes to apply: + +.. code-block:: html+twig + + {% set alert = html_cva( + base: 'alert', + variants: { + color: { + blue: 'bg-blue', + red: 'bg-red', + green: 'bg-green', + }, + size: { + sm: 'text-sm', + md: 'text-md', + lg: 'text-lg', + }, + rounded: { + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + } + }, + default_variant: { + rounded: 'md', + } + ) %} + +
+ ... +
+ + {# index.html.twig #} + + {{ include('alert.html.twig', {color: 'red', size: 'lg'}) }} + {# class="alert bg-red text-lg rounded-md" #} + +.. note:: + + The ``html_cva`` 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()); + +This function works best when used with `TwigComponent`_. + +.. _`CVA (Class Variant Authority)`: https://cva.style/docs/getting-started/variants +.. _`shadcn/ui`: https://ui.shadcn.com +.. _`tales-from-a-dev/twig-tailwind-extra`: https://github.com/tales-from-a-dev/twig-tailwind-extra +.. _`TwigComponent`: https://symfony.com/bundles/ux-twig-component/current/index.html diff --git a/doc/functions/include.rst b/doc/functions/include.rst index f49971a8fed..3700018e5aa 100644 --- a/doc/functions/include.rst +++ b/doc/functions/include.rst @@ -5,7 +5,7 @@ The ``include`` function returns the rendered content of a template: .. code-block:: twig - {{ include('template.html') }} + {{ include('template.html.twig') }} {{ include(some_var) }} Included templates have access to the variables of the active context. @@ -18,54 +18,54 @@ additional variables: .. code-block:: twig - {# template.html will have access to the variables from the current context and the additional ones provided #} - {{ include('template.html', {foo: 'bar'}) }} + {# template.html.twig will have access to the variables from the current context and the additional ones provided #} + {{ include('template.html.twig', {name: 'Fabien'}) }} You can disable access to the context by setting ``with_context`` to ``false``: .. code-block:: twig - {# only the foo variable will be accessible #} - {{ include('template.html', {foo: 'bar'}, with_context = false) }} + {# only the name variable will be accessible #} + {{ include('template.html.twig', {name: 'Fabien'}, with_context: false) }} .. code-block:: twig {# no variables will be accessible #} - {{ include('template.html', 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:: // {{ include(template) }} - $template = $twig->load('some_template.twig'); + $template = $twig->load('some_template.html.twig'); - $twig->display('template.twig', ['template' => $template]); + $twig->display('template.html.twig', ['template' => $template]); When you set the ``ignore_missing`` flag, Twig will return an empty string if the template does not exist: .. code-block:: twig - {{ include('sidebar.html', 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: .. code-block:: twig - {{ include(['page_detailed.html', 'page.html']) }} + {{ include(['page_detailed.html.twig', 'page.html.twig']) }} If ``ignore_missing`` is set, it will fall back to rendering nothing if none of the templates exist, otherwise it will throw an exception. When including a template created by an end user, you should consider -sandboxing it: +:doc:`sandboxing<../sandbox>` it: .. code-block:: twig - {{ include('page.html', sandboxed = true) }} + {{ include('page.html.twig', sandboxed: true) }} Arguments --------- diff --git a/doc/functions/index.rst b/doc/functions/index.rst index 97465ed0395..557f6938a30 100644 --- a/doc/functions/index.rst +++ b/doc/functions/index.rst @@ -10,7 +10,10 @@ Functions cycle date dump + enum + enum_cases html_classes + html_cva include max min @@ -19,4 +22,10 @@ Functions range source country_timezones + country_names + currency_names + language_names + locale_names + script_names + timezone_names template_from_string diff --git a/doc/functions/language_names.rst b/doc/functions/language_names.rst new file mode 100644 index 00000000000..145a4955722 --- /dev/null +++ b/doc/functions/language_names.rst @@ -0,0 +1,49 @@ +``language_names`` +================== + +.. versionadded:: 3.5 + + The ``language_names`` function was added in Twig 3.5. + +The ``language_names`` function returns the names of the languages: + +.. code-block:: twig + + {# Abkhazian, Achinese, ... #} + {{ language_names()|join(', ') }} + +By default, the function uses the current locale. You can pass it explicitly: + +.. code-block:: twig + + {# abkhaze, aceh, ... #} + {{ language_names('fr')|join(', ') }} + +.. note:: + + The ``language_names`` function 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()); + +Arguments +--------- + +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/functions/locale_names.rst b/doc/functions/locale_names.rst new file mode 100644 index 00000000000..b8597f98079 --- /dev/null +++ b/doc/functions/locale_names.rst @@ -0,0 +1,49 @@ +``locale_names`` +================ + +.. versionadded:: 3.5 + + The ``locale_names`` function was added in Twig 3.5. + +The ``locale_names`` function returns the names of the locales: + +.. code-block:: twig + + {# Afrikaans, Afrikaans (Namibia), ... #} + {{ locale_names()|join(', ') }} + +By default, the function uses the current locale. You can pass it explicitly: + +.. code-block:: twig + + {# afrikaans, afrikaans (Afrique du Sud), ... #} + {{ locale_names('fr')|join(', ') }} + +.. note:: + + The ``locale_names`` function 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()); + +Arguments +--------- + +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/functions/parent.rst b/doc/functions/parent.rst index 158bac78b1e..c9acc8733cf 100644 --- a/doc/functions/parent.rst +++ b/doc/functions/parent.rst @@ -6,7 +6,7 @@ parent block when overriding a block by using the ``parent`` function: .. code-block:: html+twig - {% extends "base.html" %} + {% extends "base.html.twig" %} {% block sidebar %}

Table Of Contents

@@ -15,7 +15,7 @@ parent block when overriding a block by using the ``parent`` function: {% endblock %} The ``parent()`` call will return the content of the ``sidebar`` block as -defined in the ``base.html`` template. +defined in the ``base.html.twig`` template. .. seealso:: diff --git a/doc/functions/random.rst b/doc/functions/random.rst index aac2986c387..3ebf40cb140 100644 --- a/doc/functions/random.rst +++ b/doc/functions/random.rst @@ -10,6 +10,11 @@ parameter type: * 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 --git a/doc/functions/script_names.rst b/doc/functions/script_names.rst new file mode 100644 index 00000000000..cddf9310ad3 --- /dev/null +++ b/doc/functions/script_names.rst @@ -0,0 +1,49 @@ +``script_names`` +================ + +.. versionadded:: 3.5 + + The ``script_names`` function was added in Twig 3.5. + +The ``script_names`` function returns the names of the scripts: + +.. code-block:: twig + + {# Adlam, Afaka, ... #} + {{ script_names()|join(', ') }} + +By default, the function uses the current locale. You can pass it explicitly: + +.. code-block:: twig + + {# Adlam, Afaka, ... #} + {{ script_names('fr')|join(', ') }} + +.. note:: + + The ``script_names`` function 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()); + +Arguments +--------- + +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/functions/source.rst b/doc/functions/source.rst index 080e2befe57..077ba91a402 100644 --- a/doc/functions/source.rst +++ b/doc/functions/source.rst @@ -5,7 +5,7 @@ The ``source`` function returns the content of a template without rendering it: .. code-block:: twig - {{ source('template.html') }} + {{ source('template.html.twig') }} {{ source(some_var) }} When you set the ``ignore_missing`` flag, Twig will return an empty string if @@ -13,7 +13,7 @@ the template does not exist: .. code-block:: twig - {{ source('template.html', ignore_missing = true) }} + {{ source('template.html.twig', ignore_missing = true) }} The function uses the same template loaders as the ones used to include templates. So, if you are using the filesystem loader, the templates are looked diff --git a/doc/functions/timezone_names.rst b/doc/functions/timezone_names.rst new file mode 100644 index 00000000000..98c5871ea22 --- /dev/null +++ b/doc/functions/timezone_names.rst @@ -0,0 +1,49 @@ +``timezone_names`` +================== + +.. versionadded:: 3.5 + + The ``timezone_names`` function was added in Twig 3.5. + +The ``timezone_names`` function returns the names of the timezones: + +.. code-block:: twig + + {# Acre Time (Eirunepe), Acre Time (Rio Branco), ... #} + {{ timezone_names()|join(', ') }} + +By default, the function uses the current locale. You can pass it explicitly: + +.. code-block:: twig + + {# heure : Antarctique (Casey), heure : Canada (Montreal), ... #} + {{ timezone_names('fr')|join(', ') }} + +.. note:: + + The ``timezone_names`` function 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()); + +Arguments +--------- + +* ``locale``: The locale code as defined in `RFC 5646`_ + +.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 diff --git a/doc/index.rst b/doc/index.rst index 358bd738ed0..8fc8db977a8 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -9,6 +9,7 @@ Twig templates api advanced + sandbox internals deprecated recipes diff --git a/doc/internals.rst b/doc/internals.rst index ccbb202f372..07ff85534de 100644 --- a/doc/internals.rst +++ b/doc/internals.rst @@ -30,7 +30,7 @@ The Lexer The lexer tokenizes a template source code into a token stream (each token is an instance of ``\Twig\Token``, and the stream is an instance of -``\Twig\TokenStream``). The default lexer recognizes 13 different token types: +``\Twig\TokenStream``). The default lexer recognizes 15 different token types: * ``\Twig\Token::BLOCK_START_TYPE``, ``\Twig\Token::BLOCK_END_TYPE``: Delimiters for blocks (``{% %}``) * ``\Twig\Token::VAR_START_TYPE``, ``\Twig\Token::VAR_END_TYPE``: Delimiters for variables (``{{ }}``) @@ -39,6 +39,8 @@ an instance of ``\Twig\Token``, and the stream is an instance of * ``\Twig\Token::NUMBER_TYPE``: A number in an expression; * ``\Twig\Token::STRING_TYPE``: A string in an expression; * ``\Twig\Token::OPERATOR_TYPE``: An operator; +* ``\Twig\Token::ARROW_TYPE``: An arrow function operator (``=>``); +* ``\Twig\Token::SPREAD_TYPE``: A spread operator (``...``); * ``\Twig\Token::PUNCTUATION_TYPE``: A punctuation sign; * ``\Twig\Token::INTERPOLATION_START_TYPE``, ``\Twig\Token::INTERPOLATION_END_TYPE``: Delimiters for string interpolation; * ``\Twig\Token::EOF_TYPE``: Ends of template. @@ -122,11 +124,14 @@ using):: /* Hello {{ name }} */ class __TwigTemplate_1121b6f109fe93ebe8c6e22e3712bceb extends Template { - protected function doDisplay(array $context, array $blocks = []) + protected function doDisplay(array $context, array $blocks = []): iterable { + $macros = $this->macros; // line 1 - echo "Hello "; - echo twig_escape_filter($this->env, (isset($context["name"]) ? $context["name"] : null), "html", null, true); + yield "Hello "; + // line 2 + yield $this->env->getRuntime('Twig\Runtime\EscaperRuntime')->escape((isset($context["name"]) || array_key_exists("name", $context) ? $context["name"] : (function () { throw new RuntimeError('Variable "name" does not exist.', 2, $this->source); })()), "html", null, true); + return; yield ''; } // some more code diff --git a/doc/intro.rst b/doc/intro.rst index 8914507e4f0..13d13aa0e27 100644 --- a/doc/intro.rst +++ b/doc/intro.rst @@ -30,7 +30,7 @@ Slim, Yii, Laravel, and Codeigniter — just to name a few. Prerequisites ------------- -Twig 3.x needs at least **PHP 7.2.5** to run. +Twig 3.x needs at least **PHP 8.1.0** to run. Installation ------------ @@ -69,6 +69,6 @@ filesystem loader:: 'cache' => '/path/to/compilation_cache', ]); - echo $twig->render('index.html', ['name' => 'Fabien']); + echo $twig->render('index.html.twig', ['name' => 'Fabien']); .. _`SymfonyCasts Twig Tutorial`: https://symfonycasts.com/screencast/twig diff --git a/doc/operators_precedence.rst b/doc/operators_precedence.rst new file mode 100644 index 00000000000..472e39529f6 --- /dev/null +++ b/doc/operators_precedence.rst @@ -0,0 +1,196 @@ + ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 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) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?:`` | | | Elvis operator (a ?: b) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 0 | ``(`` | prefix | n/a | Explicit group expression (a) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``literal`` | | | A literal value (boolean, string, number, sequence, mapping, ...) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?`` | infix | Left | Conditional operator (a ? b : c) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ + +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) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?:`` | | | Elvis operator (a ?: b) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| 0 | ``(`` | prefix | n/a | Explicit group expression (a) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``literal`` | | | A literal value (boolean, string, number, sequence, mapping, ...) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ +| | ``?`` | infix | Left | Conditional operator (a ? b : c) | ++------------+------------------+---------+---------------+-------------------------------------------------------------------+ diff --git a/doc/recipes.rst b/doc/recipes.rst index 8c1caa8058a..2864fdab7e3 100644 --- a/doc/recipes.rst +++ b/doc/recipes.rst @@ -66,7 +66,7 @@ the request is made via Ajax and choose the layout accordingly: .. code-block:: twig - {% extends request.ajax ? "base_ajax.html" : "base.html" %} + {% extends request.ajax ? "base_ajax.html.twig" : "base.html.twig" %} {% block content %} This is the content to be displayed. @@ -80,9 +80,9 @@ instance, the name can depend on the value of a variable: .. code-block:: twig - {% include var ~ '_foo.html' %} + {% include var ~ '_foo.html.twig' %} -If ``var`` evaluates to ``index``, the ``index_foo.html`` template will be +If ``var`` evaluates to ``index``, the ``index_foo.html.twig`` template will be rendered. As a matter of fact, the template name can be any valid expression, such as @@ -90,7 +90,7 @@ the following: .. code-block:: twig - {% include var|default('index') ~ '_foo.html' %} + {% include var|default('index') ~ '_foo.html.twig' %} Overriding a Template that also extends itself ---------------------------------------------- @@ -108,13 +108,13 @@ But how do you combine both: *replace* a template that also extends itself (aka a template in a directory further in the list)? Let's say that your templates are loaded from both ``.../templates/mysite`` -and ``.../templates/default`` in this order. The ``page.twig`` template, +and ``.../templates/default`` in this order. The ``page.html.twig`` template, stored in ``.../templates/default`` reads as follows: .. code-block:: twig - {# page.twig #} - {% extends "layout.twig" %} + {# page.html.twig #} + {% extends "layout.html.twig" %} {% block content %} {% endblock %} @@ -125,8 +125,8 @@ might be tempted to write the following: .. code-block:: twig - {# page.twig in .../templates/mysite #} - {% extends "page.twig" %} {# from .../templates/default #} + {# page.html.twig in .../templates/mysite #} + {% extends "page.html.twig" %} {# from .../templates/default #} However, this will not work as Twig will always load the template from ``.../templates/mysite``. @@ -141,8 +141,8 @@ parent's full, unambiguous template path in the extends tag: .. code-block:: twig - {# page.twig in .../templates/mysite #} - {% extends "default/page.twig" %} {# from .../templates #} + {# page.html.twig in .../templates/mysite #} + {% extends "default/page.html.twig" %} {# from .../templates #} .. note:: @@ -271,13 +271,19 @@ Defining undefined Functions, Filters, and Tags on the Fly 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 @@ For functions, use ``registerUndefinedFunctionCallback()``:: 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,9 +303,17 @@ does not return ``false``. .. 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:: + + As parsing a tag is specific to each tag (the syntax is free form), the + ``registerUndefinedTokenParserCallback()`` cannot be used to define a + default implementation for all unknown tags. It's mainly useful to override + the default exception or to register on the fly TokenParser instances for + specific known tags. + Validating the Template Syntax ------------------------------ @@ -385,15 +399,15 @@ First, let's create a temporary in-memory SQLite3 database to work with:: $dbh->exec('CREATE TABLE templates (name STRING, source STRING, last_modified INTEGER)'); $base = '{% block content %}{% endblock %}'; $index = ' - {% extends "base.twig" %} + {% extends "base.html.twig" %} {% block content %}Hello {{ name }}{% endblock %} '; $now = time(); - $dbh->prepare('INSERT INTO templates (name, source, last_modified) VALUES (?, ?, ?)')->execute(['base.twig', $base, $now]); - $dbh->prepare('INSERT INTO templates (name, source, last_modified) VALUES (?, ?, ?)')->execute(['index.twig', $index, $now]); + $dbh->prepare('INSERT INTO templates (name, source, last_modified) VALUES (?, ?, ?)')->execute(['base.html.twig', $base, $now]); + $dbh->prepare('INSERT INTO templates (name, source, last_modified) VALUES (?, ?, ?)')->execute(['index.html.twig', $index, $now]); We have created a simple ``templates`` table that hosts two templates: -``base.twig`` and ``index.twig``. +``base.html.twig`` and ``index.html.twig``. Now, let's define a loader able to use this database:: @@ -448,7 +462,7 @@ Finally, here is an example on how you can use it:: $loader = new DatabaseTwigLoader($dbh); $twig = new \Twig\Environment($loader); - echo $twig->render('index.twig', ['name' => 'Fabien']); + echo $twig->render('index.html.twig', ['name' => 'Fabien']); Using different Template Sources -------------------------------- @@ -466,15 +480,15 @@ logical name, and not the path from the filesystem:: $loader1 = new DatabaseTwigLoader($dbh); $loader2 = new \Twig\Loader\ArrayLoader([ - 'base.twig' => '{% block content %}{% endblock %}', + 'base.html.twig' => '{% block content %}{% endblock %}', ]); $loader = new \Twig\Loader\ChainLoader([$loader1, $loader2]); $twig = new \Twig\Environment($loader); - echo $twig->render('index.twig', ['name' => 'Fabien']); + echo $twig->render('index.html.twig', ['name' => 'Fabien']); -Now that the ``base.twig`` templates is defined in an array loader, you can +Now that the ``base.html.twig`` templates is defined in an array loader, you can remove it from the database, and everything else will still work as before. Loading a Template from a String @@ -516,7 +530,7 @@ include in your templates: ``interpolateProvider`` service, for instance at the module initialization time: - .. code-block:: javascript + .. code-block:: javascript angular.module('myApp', []).config(function($interpolateProvider) { $interpolateProvider.startSymbol('{[').endSymbol(']}'); @@ -528,4 +542,15 @@ include in your templates: 'tag_variable' => ['{[', ']}'], ])); +Marking a Node as being safe +---------------------------- + +When using the escaper extension, you might want to mark some nodes as being +safe to avoid any escaping. You can do so by wrapping your expression with a +``RawFilter`` node:: + + use Twig\Node\Expression\Filter\RawFilter; + + $safeExpr = new RawFilter(new YourSafeNode()); + .. _callback: https://www.php.net/manual/en/function.is-callable.php diff --git a/doc/sandbox.rst b/doc/sandbox.rst new file mode 100644 index 00000000000..f2ba9e9d118 --- /dev/null +++ b/doc/sandbox.rst @@ -0,0 +1,104 @@ +Twig Sandbox +============ + +The ``sandbox`` extension can be used to evaluate untrusted code. + +Registering the Sandbox +----------------------- + +Register the ``SandboxExtension`` extension via the ``addExtension()`` method:: + + $twig->addExtension(new \Twig\Extension\SandboxExtension($policy)); + +Configuring the Sandbox Policy +------------------------------ + +The sandbox security is managed by a policy instance, which must be passed to +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, and +properties and methods on objects:: + + $tags = ['if']; + $filters = ['upper']; + $methods = [ + 'Article' => ['getTitle', 'getBody'], + ]; + $properties = [ + 'Article' => ['title', 'body'], + ]; + $functions = ['range']; + $policy = new \Twig\Sandbox\SecurityPolicy($tags, $filters, $methods, $properties, $functions); + +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:: + + As of Twig 3.14.1 (and on Twig 3.11.2), if the ``Article`` class implements + the ``ArrayAccess`` interface, the templates will only be able to access + the ``title`` and ``body`` attributes. + + Note that native array-like classes (like ``ArrayObject``) are always + allowed, you don't need to configure them. + +.. 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. + +Enabling the Sandbox +-------------------- + +By default, the sandbox mode is disabled and should be enabled when including +untrusted template code by using the ``sandboxed`` option of the ``include`` +function: + +.. code-block:: twig + + {{ include('user.html.twig', sandboxed: true) }} + +You can sandbox all templates by passing ``true`` as the second argument of +the extension constructor:: + + $twig->addExtension(new \Twig\Extension\SandboxExtension($policy, true)); + +Accepting Callables Arguments +----------------------------- + +The Twig sandbox allows you to configure which functions, filters, tests and +dot operations are allowed. Many of these calls can accept arguments. As these +arguments are not validated by the sandbox, you must be very careful. + +For instance, accepting a PHP ``callable`` as an argument is dangerous as it +allows end user to call any PHP function (by passing a ``string``) or any +static methods (by passing an ``array``). For instance, it would accept any PHP +built-in functions like ``system()`` or ``exec()``:: + + $twig->addFilter(new \Twig\TwigFilter('custom', function (callable $callable) { + // ... + $callable(); + // ... + })); + +To avoid this security issue, don't type-hint such arguments with ``callable`` +but use ``\Closure`` instead (not using a type-hint would also be problematic). +This restricts the allowed callables to PHP closures only, which is enough to +accept Twig arrow functions:: + + $twig->addFilter(new \Twig\TwigFilter('custom', function (\Closure $callable) { + // ... + $callable(); + // ... + })); + + {{ people|custom(p => p.username|join(', ') }} + +Any PHP callable can easily be converted to a closure by using the `first-class callable syntax`_. + +.. _`first-class callable syntax`: https://www.php.net/manual/en/functions.first_class_callable_syntax.php diff --git a/doc/tags/autoescape.rst b/doc/tags/autoescape.rst index 8c621d3a849..2708009c624 100644 --- a/doc/tags/autoescape.rst +++ b/doc/tags/autoescape.rst @@ -41,7 +41,8 @@ Functions returning template data (like :doc:`macros` and .. note:: Twig is smart enough to not escape an already escaped value by the - :doc:`escape<../filters/escape>` filter. + :doc:`escape<../filters/escape>` filter when the automatic escaping + strategy is the same as the one applied by the escape filter. .. note:: diff --git a/doc/tags/deprecated.rst b/doc/tags/deprecated.rst index d2dbe7f2bf7..da5a0b7b92b 100644 --- a/doc/tags/deprecated.rst +++ b/doc/tags/deprecated.rst @@ -6,9 +6,9 @@ PHP function) where the ``deprecated`` tag is used in a template: .. code-block:: twig - {# base.twig #} - {% deprecated 'The "base.twig" template is deprecated, use "layout.twig" instead.' %} - {% extends 'layout.twig' %} + {# base.html.twig #} + {% deprecated 'The "base.html.twig" template is deprecated, use "layout.html.twig" instead.' %} + {% extends 'layout.html.twig' %} You can also deprecate a macro in the following way: @@ -23,6 +23,17 @@ You can also deprecate a macro in the following way: Note that by default, the deprecation notices are silenced and never displayed nor logged. See :ref:`deprecation-notices` to learn how to handle them. +.. versionadded:: 3.11 + + The ``package`` and ``version`` options were added in Twig 3.11. + +You can optionally add the package and the version that introduced the deprecation: + +.. code-block:: twig + + {% deprecated 'The "base.html.twig" template is deprecated, use "layout.html.twig" instead.' package='twig/twig' %} + {% deprecated 'The "base.html.twig" template is deprecated, use "layout.html.twig" instead.' package='twig/twig' version='3.11' %} + .. note:: Don't use the ``deprecated`` tag to deprecate a ``block`` as the diff --git a/doc/tags/embed.rst b/doc/tags/embed.rst index 42e33b1955a..17013b9b045 100644 --- a/doc/tags/embed.rst +++ b/doc/tags/embed.rst @@ -11,8 +11,8 @@ Think of an embedded template as a "micro layout skeleton". .. code-block:: twig - {% embed "teasers_skeleton.twig" %} - {# These blocks are defined in "teasers_skeleton.twig" #} + {% embed "teasers_skeleton.html.twig" %} + {# These blocks are defined in "teasers_skeleton.html.twig" #} {# and we override them right here: #} {% block left_teaser %} Some content for the left teaser box @@ -47,7 +47,7 @@ named "content": │ │ └─────────────────────────────────────┘ -Some pages ("foo" and "bar") share the same content structure - +Some pages ("page_1" and "page_2") share the same content structure - two vertically stacked boxes: .. code-block:: text @@ -65,7 +65,7 @@ two vertically stacked boxes: │ │ └─────────────────────────────────────┘ -While other pages ("boom" and "baz") share a different content structure - +While other pages ("page_3" and "page_4") share a different content structure - two boxes side by side: .. code-block:: text @@ -86,9 +86,9 @@ two boxes side by side: Without the ``embed`` tag, you have two ways to design your templates: * Create two "intermediate" base templates that extend the master layout - template: one with vertically stacked boxes to be used by the "foo" and - "bar" pages and another one with side-by-side boxes for the "boom" and - "baz" pages. + template: one with vertically stacked boxes to be used by the "page_1" and + "page_2" pages and another one with side-by-side boxes for the "page_3" and + "page_4" pages. * Embed the markup for the top/bottom and left/right boxes into each page template directly. @@ -111,14 +111,14 @@ code can live in a single base template, and the two different content structure let's call them "micro layouts" go into separate templates which are embedded as necessary: -Page template ``foo.twig``: +Page template ``page_1.html.twig``: .. code-block:: twig - {% extends "layout_skeleton.twig" %} + {% extends "layout_skeleton.html.twig" %} {% block content %} - {% embed "vertical_boxes_skeleton.twig" %} + {% embed "vertical_boxes_skeleton.html.twig" %} {% block top %} Some content for the top box {% endblock %} @@ -129,7 +129,7 @@ Page template ``foo.twig``: {% endembed %} {% endblock %} -And here is the code for ``vertical_boxes_skeleton.twig``: +And here is the code for ``vertical_boxes_skeleton.html.twig``: .. code-block:: html+twig @@ -145,18 +145,18 @@ And here is the code for ``vertical_boxes_skeleton.twig``: {% endblock %} -The goal of the ``vertical_boxes_skeleton.twig`` template being to factor +The goal of the ``vertical_boxes_skeleton.html.twig`` template being to factor out the HTML markup for the boxes. The ``embed`` tag takes the exact same arguments as the ``include`` tag: .. code-block:: twig - {% embed "base" with {'foo': 'bar'} %} + {% embed "base" with {'name': 'Fabien'} %} ... {% endembed %} - {% embed "base" with {'foo': 'bar'} only %} + {% embed "base" with {'name': 'Fabien'} only %} ... {% endembed %} diff --git a/doc/tags/extends.rst b/doc/tags/extends.rst index 7f1c1e8c1b8..5dc6ba0dd1a 100644 --- a/doc/tags/extends.rst +++ b/doc/tags/extends.rst @@ -9,7 +9,7 @@ The ``extends`` tag can be used to extend a template from another one. one extends tag called per rendering. However, Twig supports horizontal :doc:`reuse`. -Let's define a base template, ``base.html``, which defines a simple HTML +Let's define a base template, ``base.html.twig``, which defines a simple HTML skeleton document: .. code-block:: html+twig @@ -26,7 +26,7 @@ skeleton document:
{% block content %}{% endblock %}
@@ -45,7 +45,7 @@ A child template might look like this: .. code-block:: html+twig - {% extends "base.html" %} + {% extends "base.html.twig" %} {% block title %}Index{% endblock %} {% block head %} @@ -156,16 +156,16 @@ instance, Twig will use it as the parent template:: // {% extends layout %} - $layout = $twig->load('some_layout_template.twig'); + $layout = $twig->load('some_layout_template.html.twig'); - $twig->display('template.twig', ['layout' => $layout]); + $twig->display('template.html.twig', ['layout' => $layout]); You can also provide a list of templates that are checked for existence. The first template that exists will be used as a parent: .. code-block:: twig - {% extends ['layout.html', 'base_layout.html'] %} + {% extends ['layout.html.twig', 'base_layout.html.twig'] %} Conditional Inheritance ----------------------- @@ -175,10 +175,10 @@ possible to make the inheritance mechanism conditional: .. code-block:: twig - {% extends standalone ? "minimum.html" : "base.html" %} + {% extends standalone ? "minimum.html.twig" : "base.html.twig" %} -In this example, the template will extend the "minimum.html" layout template -if the ``standalone`` variable evaluates to ``true``, and "base.html" +In this example, the template will extend the "minimum.html.twig" layout template +if the ``standalone`` variable evaluates to ``true``, and "base.html.twig" otherwise. How do blocks work? @@ -192,7 +192,7 @@ importantly, how it does not work: .. code-block:: html+twig - {# base.twig #} + {# base.html.twig #} {% for post in posts %} {% block post %}

{{ post.title }}

@@ -206,8 +206,8 @@ to make it overridable by a child template: .. code-block:: html+twig - {# child.twig #} - {% extends "base.twig" %} + {# child.html.twig #} + {% extends "base.html.twig" %} {% block post %}
diff --git a/doc/tags/for.rst b/doc/tags/for.rst index 4517f590724..1e78db63c19 100644 --- a/doc/tags/for.rst +++ b/doc/tags/for.rst @@ -1,8 +1,8 @@ ``for`` ======= -Loop over each item in a sequence. For example, to display a list of users -provided in a variable called ``users``: +Loop over each item in a sequence or a mapping. For example, to display a list +of users provided in a variable called ``users``: .. code-block:: html+twig @@ -15,8 +15,8 @@ provided in a variable called ``users``: .. note:: - A sequence can be either an array or an object implementing the - ``Traversable`` interface. + A sequence or a mapping can be either an array or an object implementing + the ``Traversable`` interface. If you do need to iterate over a sequence of numbers, you can use the ``..`` operator: @@ -45,13 +45,13 @@ The ``..`` operator can take any expression at both sides: * {{ letter }} {% endfor %} -.. tip: +.. tip:: If you need a step different from 1, you can use the ``range`` function instead. -The `loop` variable -------------------- +The ``loop`` variable +--------------------- Inside of a ``for`` loop block you can access some special variables: @@ -80,8 +80,8 @@ Variable Description ``loop.last`` variables are only available for PHP arrays, or objects that implement the ``Countable`` interface. -The `else` Clause ------------------ +The ``else`` Clause +------------------- If no iteration took place because the sequence was empty, you can render a replacement block by using ``else``: @@ -139,3 +139,18 @@ the :doc:`slice <../filters/slice>` filter:
  • {{ user.username|e }}
  • {% endfor %} + +Iterating over a String +----------------------- + +To iterate over the characters of a string, use the +:doc:`split <../filters/split>` filter: + +.. code-block:: html+twig + +

    Characters

    +
      + {% for char in "諺 / ことわざ"|split('') -%} +
    • {{ char }}
    • + {%- endfor %} +
    diff --git a/doc/tags/guard.rst b/doc/tags/guard.rst new file mode 100644 index 00000000000..c655bcf9080 --- /dev/null +++ b/doc/tags/guard.rst @@ -0,0 +1,28 @@ +``guard`` +========= + +.. versionadded:: 3.15 + + The ``guard`` tag was added in Twig 3.15. + +The ``guard`` statement checks if some Twig callables are available at +**compilation time** to bypass code compilation that would otherwise fail. + +.. code-block:: twig + + {% guard function importmap %} + {{ importmap('app') }} + {% endguard %} + +The first argument is the Twig callable to test: ``filter``, ``function``, or +``test``. The second argument is the Twig callable name you want to test. + +You can also generate different code if the callable does not exist: + +.. code-block:: twig + + {% guard function importmap %} + {{ importmap('app') }} + {% else %} + {# the importmap function doesn't exist, generate fallback code #} + {% endguard %} diff --git a/doc/tags/if.rst b/doc/tags/if.rst index 2d7475227c1..8a29af5da11 100644 --- a/doc/tags/if.rst +++ b/doc/tags/if.rst @@ -12,7 +12,7 @@ In the simplest form you can use it to test if an expression evaluates to

    Our website is in maintenance mode. Please, come back later.

    {% endif %} -You can also test if an array is not empty: +You can also test if a sequence or a mapping is not empty: .. code-block:: html+twig @@ -26,8 +26,7 @@ You can also test if an array is not empty: .. note:: - If you want to test if the variable is defined, use ``if users is - defined`` instead. + If you want to test if the variable is defined, use ``if users is defined`` instead. You can also use ``not`` to check for values that evaluate to ``false``: @@ -72,8 +71,10 @@ use more complex ``expressions`` there too: INF (Infinity) true whitespace-only string true string "0" or '0' false - empty array false + empty sequence false + empty mapping false null false - non-empty array true + non-empty sequence true + non-empty mapping true object true ====================== ==================== diff --git a/doc/tags/include.rst b/doc/tags/include.rst index 93fb0371b8e..3d2b2e0892b 100644 --- a/doc/tags/include.rst +++ b/doc/tags/include.rst @@ -6,9 +6,9 @@ of that file: .. code-block:: twig - {% include 'header.html' %} + {% include 'header.html.twig' %} Body - {% include 'footer.html' %} + {% include 'footer.html.twig' %} .. note:: @@ -25,17 +25,17 @@ of that file: {# Store a rendered template in a variable #} {% set content %} - {% include 'template.html' %} + {% include 'template.html.twig' %} {% endset %} {# vs #} - {% set content = include('template.html') %} + {% set content = include('template.html.twig') %} {# Apply filter on a rendered template #} {% apply upper %} - {% include 'template.html' %} + {% include 'template.html.twig' %} {% endapply %} {# vs #} - {{ include('template.html')|upper }} + {{ include('template.html.twig')|upper }} * The ``include`` function does not impose any specific order for arguments thanks to :ref:`named arguments `. @@ -49,45 +49,45 @@ You can add additional variables by passing them after the ``with`` keyword: .. code-block:: twig - {# template.html will have access to the variables from the current context and the additional ones provided #} - {% include 'template.html' with {'foo': 'bar'} %} + {# template.html.twig will have access to the variables from the current context and the additional ones provided #} + {% include 'template.html.twig' with {'name': 'Fabien'} %} - {% set vars = {'foo': 'bar'} %} - {% include 'template.html' with vars %} + {% set vars = {'name': 'Fabien'} %} + {% include 'template.html.twig' with vars %} You can disable access to the context by appending the ``only`` keyword: .. code-block:: twig - {# only the foo variable will be accessible #} - {% include 'template.html' with {'foo': 'bar'} only %} + {# only the name variable will be accessible #} + {% include 'template.html.twig' with {'name': 'Fabien'} only %} .. code-block:: twig {# no variables will be accessible #} - {% include 'template.html' only %} + {% include 'template.html.twig' only %} .. tip:: When including a template created by an end user, you should consider - sandboxing it. More information in the :doc:`Twig for Developers<../api>` - chapter and in the :doc:`sandbox<../tags/sandbox>` tag documentation. + sandboxing it. More information in the :doc:`Twig Sandbox<../sandbox>` + chapter. The template name can be any valid Twig expression: .. code-block:: twig {% include some_var %} - {% include ajax ? 'ajax.html' : 'not_ajax.html' %} + {% include ajax ? 'ajax.html.twig' : 'not_ajax.html.twig' %} And if the expression evaluates to a ``\Twig\Template`` or a ``\Twig\TemplateWrapper`` instance, Twig will use it directly:: // {% include template %} - $template = $twig->load('some_template.twig'); + $template = $twig->load('some_template.html.twig'); - $twig->display('template.twig', ['template' => $template]); + $twig->display('template.html.twig', ['template' => $template]); You can mark an include with ``ignore missing`` in which case Twig will ignore the statement if the template to be included does not exist. It has to be @@ -95,16 +95,16 @@ placed just after the template name. Here some valid examples: .. code-block:: twig - {% include 'sidebar.html' ignore missing %} - {% include 'sidebar.html' ignore missing with {'foo': 'bar'} %} - {% include 'sidebar.html' ignore missing only %} + {% include 'sidebar.html.twig' ignore missing %} + {% include 'sidebar.html.twig' ignore missing with {'name': 'Fabien'} %} + {% include 'sidebar.html.twig' ignore missing only %} You can also provide a list of templates that are checked for existence before inclusion. The first template that exists will be included: .. code-block:: twig - {% include ['page_detailed.html', 'page.html'] %} + {% include ['page_detailed.html.twig', 'page.html.twig'] %} If ``ignore missing`` is given, it will fall back to rendering nothing if none of the templates exist, otherwise it will throw an exception. diff --git a/doc/tags/index.rst b/doc/tags/index.rst index b3c10408071..2c456630963 100644 --- a/doc/tags/index.rst +++ b/doc/tags/index.rst @@ -12,6 +12,7 @@ Tags do embed extends + guard flush for from @@ -21,6 +22,7 @@ Tags macro sandbox set + types use verbatim with diff --git a/doc/tags/macro.rst b/doc/tags/macro.rst index 42fc460cae8..ea173d5a877 100644 --- a/doc/tags/macro.rst +++ b/doc/tags/macro.rst @@ -7,7 +7,7 @@ are useful to reuse template fragments to not repeat yourself. Macros are defined in regular templates. Imagine having a generic helper template that define how to render HTML forms -via macros (called ``forms.html``): +via macros (called ``forms.twig``): .. code-block:: html+twig @@ -49,11 +49,11 @@ tag: .. code-block:: twig - {% import "forms.html" as forms %} + {% import "forms.html.twig" as forms %} -The above ``import`` call imports the ``forms.html`` file (which can contain -only macros, or a template and some macros), and import the macros as items of -the ``forms`` local variable. +The above ``import`` call imports the ``forms.html.twig`` file (which can contain +only macros, or a template and some macros), and import the macros as +attributes of the ``forms`` local variable. The macros can then be called at will in the *current* template: @@ -61,17 +61,32 @@ The macros can then be called at will in the *current* template:

    {{ forms.input('username') }}

    {{ forms.input('password', null, 'password') }}

    + {# You can also use named arguments #} +

    {{ forms.input(name: 'password', type: 'password') }}

    Alternatively you can import names from the template into the current namespace via the ``from`` tag: .. code-block:: html+twig - {% from 'forms.html' import input as input_field, textarea %} + {% from 'forms.html.twig' import input as input_field, textarea %}

    {{ input_field('password', '', 'password') }}

    +

    {{ input_field(name: 'password', type: 'password') }}

    {{ textarea('comment') }}

    +.. caution:: + + As macros imported via ``from`` are called like functions, be careful that + they shadow existing functions: + + .. code-block:: twig + + {% from 'forms.html.twig' import input as include %} + + {# include refers to the macro and not to the built-in "include" function #} + {{ include() }} + .. tip:: When macro usages and definitions are in the same template, you don't need to @@ -101,11 +116,7 @@ Imported macros are not available in the body of ``embed`` tags, you need to explicitly re-import macros inside the tag. When calling ``import`` or ``from`` from a ``block`` tag, the imported macros -are only defined in the current block and they override macros defined at the -template level with the same names. - -When calling ``import`` or ``from`` from a ``macro`` tag, the imported macros -are only defined in the current macro and they override macros defined at the +are only defined in the current block and they shadow macros defined at the template level with the same names. Checking if a Macro is defined @@ -115,9 +126,9 @@ You can check if a macro is defined via the ``defined`` test: .. code-block:: twig - {% import "macros.twig" as macros %} + {% import "macros.html.twig" as macros %} - {% from "macros.twig" import hello %} + {% from "macros.html.twig" import hello %} {% if macros.hello is defined -%} OK diff --git a/doc/tags/sandbox.rst b/doc/tags/sandbox.rst index b331fdb8e69..b9b9a8dd6c6 100644 --- a/doc/tags/sandbox.rst +++ b/doc/tags/sandbox.rst @@ -1,13 +1,18 @@ ``sandbox`` =========== +.. warning:: + + The ``sandbox`` tag is deprecated as of Twig 3.15. + Use the ``sandboxed`` option of the ``include`` function instead. + The ``sandbox`` tag can be used to enable the sandboxing mode for an included template, when sandboxing is not enabled globally for the Twig environment: .. code-block:: twig {% sandbox %} - {% include 'user.html' %} + {% include 'user.html.twig' %} {% endsandbox %} .. warning:: diff --git a/doc/tags/set.rst b/doc/tags/set.rst index 7a3a784f503..0ebf7ad5d65 100644 --- a/doc/tags/set.rst +++ b/doc/tags/set.rst @@ -4,45 +4,45 @@ Inside code blocks you can also assign values to variables. Assignments use the ``set`` tag and can have multiple targets. -Here is how you can assign the ``bar`` value to the ``foo`` variable: +Here is how you can assign the ``Fabien`` value to the ``name`` variable: .. code-block:: twig - {% set foo = 'bar' %} + {% set name = 'Fabien' %} -After the ``set`` call, the ``foo`` variable is available in the template like +After the ``set`` call, the ``name`` variable is available in the template like any other ones: .. code-block:: twig - {# displays bar #} - {{ foo }} + {# displays Fabien #} + {{ name }} The assigned value can be any valid :ref:`Twig expression `: .. code-block:: twig - {% set foo = [1, 2] %} - {% set foo = {'foo': 'bar'} %} - {% set foo = 'foo' ~ 'bar' %} + {% set numbers = [1, 2] %} + {% set user = {'name': 'Fabien'} %} + {% set name = 'Fabien' ~ ' ' ~ 'Potencier' %} Several variables can be assigned in one block: .. code-block:: twig - {% set foo, bar = 'foo', 'bar' %} + {% set first, last = 'Fabien', 'Potencier' %} {# is equivalent to #} - {% set foo = 'foo' %} - {% set bar = 'bar' %} + {% set first = 'Fabien' %} + {% set last = 'Potencier' %} -The ``set`` tag can also be used to 'capture' chunks of text: +The ``set`` tag can also be used to "capture" chunks of text: .. code-block:: html+twig - {% set foo %} + {% set content %} @@ -60,19 +60,19 @@ The ``set`` tag can also be used to 'capture' chunks of text: .. code-block:: twig - {% for item in list %} - {% set foo = item %} + {% for item in items %} + {% set value = item %} {% endfor %} - {# foo is NOT available #} + {# value is NOT available #} If you want to access the variable, just declare it before the loop: .. code-block:: twig - {% set foo = "" %} - {% for item in list %} - {% set foo = item %} + {% set value = "" %} + {% for item in items %} + {% set value = item %} {% endfor %} - {# foo is available #} + {# value is available #} diff --git a/doc/tags/types.rst b/doc/tags/types.rst new file mode 100644 index 00000000000..c3a175392cf --- /dev/null +++ b/doc/tags/types.rst @@ -0,0 +1,62 @@ +``types`` +========= + +.. versionadded:: 3.13 + + The ``types`` tag was added in Twig 3.13. This tag is **experimental** and + can change based on usage and feedback. + +Use the ``types`` tag to declare the type of a variable: + +.. code-block:: twig + + {% types is_correct: 'boolean' %} + {% types score: 'number' %} + +Or multiple variables: + +.. code-block:: twig + + {% types + is_correct: 'boolean', + score: 'number', + %} + +You can also enclose types with ``{}``: + +.. code-block:: twig + + {% types { + is_correct: 'boolean', + score: 'number', + } %} + +Declare optional variables by adding a ``?`` suffix: + +.. code-block:: twig + + {% types { + is_correct: 'boolean', + score?: 'number', + } %} + +By default, this tag does not affect the template compilation or runtime behavior. + +Its purpose is to enable designers and developers to document and specify the +context's available and/or required variables. While Twig itself does not +validate variables or their types, this tag enables extensions to do this. + +Additionally, :ref:`Twig extensions ` can analyze these +tags to perform compile-time and runtime analysis of templates. + +.. note:: + + The types declared in a template are local to that template and must not be + propagated to included templates. This is because a template can be + included from multiple different places, each potentially having different + variable types. + +.. note:: + + The syntax for and contents of type strings are intentionally left out of + scope. diff --git a/doc/tags/use.rst b/doc/tags/use.rst index 2aca6a01fb4..52b80c43a56 100644 --- a/doc/tags/use.rst +++ b/doc/tags/use.rst @@ -14,7 +14,7 @@ debug: .. code-block:: twig - {% extends "base.html" %} + {% extends "base.html.twig" %} {% block title %}{% endblock %} {% block content %}{% endblock %} @@ -24,20 +24,19 @@ but without the associated complexity: .. code-block:: twig - {% extends "base.html" %} + {% extends "base.html.twig" %} - {% use "blocks.html" %} + {% use "blocks.html.twig" %} {% block title %}{% endblock %} {% block content %}{% endblock %} The ``use`` statement tells Twig to import the blocks defined in -``blocks.html`` into the current template (it's like macros, but for blocks): +``blocks.html.twig`` into the current template (it's like macros, but for blocks): .. code-block:: twig - {# blocks.html #} - + {# blocks.html.twig #} {% block sidebar %}{% endblock %} In this example, the ``use`` statement imports the ``sidebar`` block into the @@ -46,7 +45,7 @@ imported blocks are not outputted automatically): .. code-block:: twig - {% extends "base.html" %} + {% extends "base.html.twig" %} {% block sidebar %}{% endblock %} {% block title %}{% endblock %} @@ -64,14 +63,14 @@ imported blocks are not outputted automatically): passed to the template, the template reference cannot be an expression. The main template can also override any imported block. If the template -already defines the ``sidebar`` block, then the one defined in ``blocks.html`` +already defines the ``sidebar`` block, then the one defined in ``blocks.html.twig`` is ignored. To avoid name conflicts, you can rename imported blocks: .. code-block:: twig - {% extends "base.html" %} + {% extends "base.html.twig" %} - {% use "blocks.html" with sidebar as base_sidebar, title as base_title %} + {% use "blocks.html.twig" with sidebar as base_sidebar, title as base_title %} {% block sidebar %}{% endblock %} {% block title %}{% endblock %} @@ -83,9 +82,9 @@ template: .. code-block:: twig - {% extends "base.html" %} + {% extends "base.html.twig" %} - {% use "blocks.html" %} + {% use "blocks.html.twig" %} {% block sidebar %} {{ parent() }} @@ -95,7 +94,7 @@ template: {% block content %}{% endblock %} In this example, ``parent()`` will correctly call the ``sidebar`` block from -the ``blocks.html`` template. +the ``blocks.html.twig`` template. .. tip:: @@ -103,9 +102,9 @@ the ``blocks.html`` template. .. code-block:: twig - {% extends "base.html" %} + {% extends "base.html.twig" %} - {% use "blocks.html" with sidebar as parent_sidebar %} + {% use "blocks.html.twig" with sidebar as parent_sidebar %} {% block sidebar %} {{ block('parent_sidebar') }} diff --git a/doc/tags/with.rst b/doc/tags/with.rst index 107432f6fc8..7c5e8cf3a82 100644 --- a/doc/tags/with.rst +++ b/doc/tags/with.rst @@ -7,24 +7,24 @@ scope are not visible outside of the scope: .. code-block:: twig {% with %} - {% set foo = 42 %} - {{ foo }} {# foo is 42 here #} + {% set value = 42 %} + {{ value }} {# value is 42 here #} {% endwith %} - foo is not visible here any longer + value is not visible here any longer Instead of defining variables at the beginning of the scope, you can pass a -hash of variables you want to define in the ``with`` tag; the previous example -is equivalent to the following one: +mapping of variables you want to define in the ``with`` tag; the previous +example is equivalent to the following one: .. code-block:: twig - {% with { foo: 42 } %} - {{ foo }} {# foo is 42 here #} + {% with {value: 42} %} + {{ value }} {# value is 42 here #} {% endwith %} - foo is not visible here any longer + value is not visible here any longer - {# it works with any expression that resolves to a hash #} - {% set vars = { foo: 42 } %} + {# it works with any expression that resolves to a mapping #} + {% set vars = {value: 42} %} {% with vars %} ... {% endwith %} @@ -34,8 +34,8 @@ disable this behavior by appending the ``only`` keyword: .. code-block:: twig - {% set bar = 'bar' %} - {% with { foo: 42 } only %} - {# only foo is defined #} - {# bar is not defined #} + {% set zero = 0 %} + {% with {value: 42} only %} + {# only value is defined #} + {# zero is not defined #} {% endwith %} diff --git a/doc/templates.rst b/doc/templates.rst index acf81a382f0..ad281e4e6c0 100644 --- a/doc/templates.rst +++ b/doc/templates.rst @@ -41,14 +41,19 @@ There are two kinds of delimiters: ``{% ... %}`` and ``{{ ... }}``. The first one is used to execute statements such as for-loops, the latter outputs the result of an expression. -IDEs Integration ----------------- +.. tip:: + + To experiment with Twig, you can use the `Twig Playground + `_. + +Third-party Integrations +------------------------ Many IDEs support syntax highlighting and auto-completion for Twig: * *Textmate* via the `Twig bundle`_ -* *Vim* via the `Jinja syntax plugin`_ or the `vim-twig plugin`_ -* *Netbeans* via the `Twig syntax plugin`_ (until 7.1, native as of 7.2) +* *Vim* via the `vim-twig plugin`_ +* *Netbeans* (native as of 7.2) * *PhpStorm* (native as of 2.1) * *Eclipse* via the `Twig plugin`_ * *Sublime Text* via the `Twig bundle`_ @@ -59,25 +64,45 @@ Many IDEs support syntax highlighting and auto-completion for Twig: * *Notepad++* via the `Notepad++ Twig Highlighter`_ * *Emacs* via `web-mode.el`_ * *Atom* via the `PHP-twig for atom`_ -* *Visual Studio Code* via the `Twig pack`_ +* *Visual Studio Code* via the `Twig pack`_, `Modern Twig`_ or `Twiggy`_ -Also, `TwigFiddle`_ is an online service that allows you to execute Twig templates -from a browser; it supports all versions of Twig. +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 --------- -The application passes variables to the templates for manipulation in the -template. Variables may have attributes or elements you can access, too. The -visual representation of a variable depends heavily on the application providing -it. - -Use a dot (``.``) to access attributes of a variable (methods or properties of a -PHP object, or items of a PHP array): +Twig templates have access to variables provided by the PHP application and +variables created in templates via the :doc:`set ` tag. These +variables can be manipulated and displayed in the template. + +Twig tries to abstract PHP types as much as possible and works with a few basic +types, supported by ``filters``, ``functions``, and ``tests`` among others: + +=================== =============================== +Twig Type PHP Type +=================== =============================== +string A string or a Stringable object +number An integer or a float +boolean ``true`` or ``false`` +null ``null`` +iterable (mapping) An array +iterable (sequence) An array +iterable (object) An iterable object +object An object +=================== =============================== + +The ``iterable`` and ``object`` types expose attributes you can access via the +dot (``.``) operator: .. code-block:: twig - {{ foo.bar }} + {{ user.name }} .. note:: @@ -85,43 +110,14 @@ PHP object, or items of a PHP array): variable but the print statement. When accessing variables inside tags, don't put the braces around them. -.. sidebar:: Implementation - - For convenience's sake ``foo.bar`` does the following things on the PHP - layer: - - * check if ``foo`` is an array and ``bar`` a valid element; - * if not, and if ``foo`` is an object, check that ``bar`` is a valid property; - * if not, and if ``foo`` is an object, check that ``bar`` is a valid method - (even if ``bar`` is the constructor - use ``__construct()`` instead); - * if not, and if ``foo`` is an object, check that ``getBar`` is a valid method; - * if not, and if ``foo`` is an object, check that ``isBar`` is a valid method; - * if not, and if ``foo`` is an object, check that ``hasBar`` is a valid method; - * if not, return a ``null`` value. - - Twig also supports a specific syntax for accessing items on PHP arrays, - ``foo['bar']``: - - * check if ``foo`` is an array and ``bar`` a valid element; - * if not, return a ``null`` value. - -If a variable or attribute does not exist, you will receive a ``null`` value -when the ``strict_variables`` option is set to ``false``; alternatively, if ``strict_variables`` -is set, Twig will throw an error (see :ref:`environment options`). - -.. note:: +If a variable or attribute does not exist, the behavior depends on the +``strict_variables`` option value (see :ref:`environment options +`): - If you want to access a dynamic attribute of a variable, use the - :doc:`attribute` function instead. +* When ``false``, it returns ``null``; +* When ``true``, it throws an exception. - The ``attribute`` function is also useful when the attribute contains - special characters (like ``-`` that would be interpreted as the minus - operator): - - .. code-block:: twig - - {# equivalent to the non-working foo.data-foo #} - {{ attribute(foo, 'data-foo') }} +Learn more about the :ref:`dot operator `. Global Variables ~~~~~~~~~~~~~~~~ @@ -140,16 +136,16 @@ You can assign values to variables inside code blocks. Assignments use the .. code-block:: twig - {% set foo = 'foo' %} - {% set foo = [1, 2] %} - {% set foo = {'foo': 'bar'} %} + {% set name = 'Fabien' %} + {% set numbers = [1, 2] %} + {% set map = {'city': 'Paris'} %} Filters ------- -Variables can be modified by **filters**. Filters are separated from the -variable by a pipe symbol (``|``). Multiple filters can be chained. The output -of one filter is applied to the next. +Variables and expressions can be modified by **filters**. Filters are separated +from the variable by a pipe symbol (``|``). Multiple filters can be chained. +The output of one filter is applied to the next. The following example removes all HTML tags from the ``name`` and title-cases it: @@ -177,6 +173,18 @@ To apply a filter on a section of code, wrap it with the Go to the :doc:`filters` page to learn more about built-in filters. +.. warning:: + + As the ``filter`` operator has the highest :ref:`precedence + `, use parentheses when filtering more "complex" + expressions: + + .. code-block:: twig + + {{ (1..5)|join(', ') }} + + {{ ('HELLO' ~ 'FABIEN')|lower }} + Functions --------- @@ -200,9 +208,22 @@ built-in functions. Named Arguments --------------- +Named arguments are supported everywhere you can pass arguments: functions, +filters, tests, macros, and dot operator arguments. + +.. versionadded:: 3.15 + + Named arguments for macros and dot operator arguments were added in Twig + 3.15. + +.. versionadded:: 3.12 + + Twig supports both ``=`` and ``:`` as separators between argument names and + values, but support for ``:`` was introduced in Twig 3.12. + .. code-block:: twig - {% for i in range(low=1, high=10, step=2) %} + {% for i in range(low: 1, high: 10, step: 2) %} {{ i }}, {% endfor %} @@ -215,7 +236,7 @@ the values you pass as arguments: {# versus #} - {{ data|convert_encoding(from='iso-2022-jp', to='UTF-8') }} + {{ data|convert_encoding(from: 'iso-2022-jp', to: 'UTF-8') }} Named arguments also allow you to skip some arguments for which you don't want to change the default value: @@ -226,19 +247,19 @@ to change the default value: {{ "now"|date(null, "Europe/Paris") }} {# or skip the format value by using a named argument for the time zone #} - {{ "now"|date(timezone="Europe/Paris") }} + {{ "now"|date(timezone: "Europe/Paris") }} You can also use both positional and named arguments in one call, in which case positional arguments must always come before named arguments: .. code-block:: twig - {{ "now"|date('d/m/Y H:i', timezone="Europe/Paris") }} + {{ "now"|date('d/m/Y H:i', timezone: "Europe/Paris") }} .. tip:: - Each function and filter documentation page has a section where the names - of all arguments are listed when supported. + Each function, filter, and test documentation page has a section where the + names of all supported arguments are listed. Control Structure ----------------- @@ -277,9 +298,9 @@ Go to the :doc:`tags` page to learn more about the built-in tags. Comments -------- -To comment-out part of a line in a template, use the comment syntax ``{# ... -#}``. This is useful for debugging or to add information for other template -designers or yourself: +To comment-out part of a template, use the comment syntax ``{# ... #}``. This +is useful for debugging or to add information for other template designers or +yourself: .. code-block:: twig @@ -289,6 +310,44 @@ designers or yourself: {% endfor %} #} +.. versionadded:: 3.15 + + Inline comments were added in Twig 3.15. + +If you want to add comments inside a block, variable, or comment, use an inline +comment. They start with ``#`` and continue to the end of the line: + +.. code-block:: twig + + {{ + # this is an inline comment + "Hello World"|upper + # this is an inline comment + }} + + {{ + { + # this is an inline comment + fruit: 'apple', # this is an inline comment + color: 'red', # this is an inline comment + }|join(', ') + }} + +Inline comments can also be on the same line as the expression: + +.. code-block:: twig + + {{ + "Hello World"|upper # this is an inline comment + }} + +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-block:: twig + + {{ "Hello World"|upper # this is an inline comment }} + Including other Templates ------------------------- @@ -297,7 +356,7 @@ and return the rendered content of that template into the current one: .. code-block:: twig - {{ include('sidebar.html') }} + {{ include('sidebar.html.twig') }} By default, included templates have access to the same context as the template which includes them. This means that any variable defined in the main template @@ -306,10 +365,10 @@ will be available in the included template too: .. code-block:: twig {% for box in boxes %} - {{ include('render_box.html') }} + {{ include('render_box.html.twig') }} {% endfor %} -The included template ``render_box.html`` is able to access the ``box`` variable. +The included template ``render_box.html.twig`` is able to access the ``box`` variable. The name of the template depends on the template loader. For instance, the ``\Twig\Loader\FilesystemLoader`` allows you to access other templates by giving the @@ -317,7 +376,7 @@ filename. You can access templates in subdirectories with a slash: .. code-block:: twig - {{ include('sections/articles/sidebar.html') }} + {{ include('sections/articles/sidebar.html.twig') }} This behavior depends on the application embedding Twig. @@ -331,7 +390,7 @@ override. It's easier to understand the concept by starting with an example. -Let's define a base template, ``base.html``, which defines an HTML skeleton +Let's define a base template, ``base.html.twig``, which defines an HTML skeleton document that might be used for a two-column page: .. code-block:: html+twig @@ -348,7 +407,7 @@ document that might be used for a two-column page:
    {% block content %}{% endblock %}
    @@ -363,7 +422,7 @@ A child template might look like this: .. code-block:: html+twig - {% extends "base.html" %} + {% extends "base.html.twig" %} {% block title %}Index{% endblock %} {% block head %} @@ -436,7 +495,7 @@ Escaping works by using the :doc:`escape` or ``e`` filter: {{ user.username|e }} By default, the ``escape`` filter uses the ``html`` strategy, but depending on -the escaping context, you might want to explicitly use an other strategy: +the escaping context, you might want to explicitly use another strategy: .. code-block:: twig @@ -500,25 +559,6 @@ Expressions Twig allows expressions everywhere. -.. note:: - - The operator precedence is as follows, with the lowest-precedence operators - listed first: ``?:`` (ternary operator), ``b-and``, ``b-xor``, ``b-or``, - ``or``, ``and``, ``==``, ``!=``, ``<=>``, ``<``, ``>``, ``>=``, ``<=``, - ``in``, ``matches``, ``starts with``, ``ends with``, ``..``, ``+``, ``-``, - ``~``, ``*``, ``/``, ``//``, ``%``, ``is`` (tests), ``**``, ``??``, ``|`` - (filters), ``[]``, and ``.``: - - .. code-block:: twig - - {% set greeting = 'Hello ' %} - {% set name = 'Fabien' %} - - {{ greeting ~ name|lower }} {# Hello fabien #} - - {# use parenthesis to change precedence #} - {{ (greeting ~ name)|lower }} {# hello fabien #} - Literals ~~~~~~~~ @@ -529,40 +569,56 @@ exist: * ``"Hello World"``: Everything between two double or single quotes is a string. They are useful whenever you need a string in the template (for example as arguments to function calls, filters or just to extend or include - a template). A string can contain a delimiter if it is preceded by a - backslash (``\``) -- like in ``'It\'s good'``. If the string contains a - backslash (e.g. ``'c:\Program Files'``) escape it by doubling it - (e.g. ``'c:\\Program Files'``). + a template). + + Note that certain characters require escaping: + * ``\f``: Form feed + * ``\n``: New line + * ``\r``: Carriage return + * ``\t``: Horizontal tab + * ``\v``: Vertical tab + * ``\x``: Hexadecimal escape sequence + * ``\0`` to ``\377``: Octal escape sequences representing characters + * ``\``: Backslash + + When using single-quoted strings, the single quote character (``'``) needs to be escaped with a backslash (``\'``). + When using double-quoted strings, the double quote character (``"``) needs to be escaped with a backslash (``\"``). + + For example, a single quoted string can contain a delimiter if it is preceded by a + backslash (``\``) -- like in ``'It\'s good'``. If the string contains a + backslash (e.g. ``'c:\Program Files'``) escape it by doubling it + (e.g. ``'c:\\Program Files'``). * ``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. + otherwise an integer. Underscores can be used as digits separator to + improve readability (``-3_141.592_65`` is equivalent to ``-3141.59265``). -* ``["foo", "bar"]``: Arrays are defined by a sequence of expressions +* ``["first_name", "last_name"]``: Sequences are defined by a sequence of expressions separated by a comma (``,``) and wrapped with squared brackets (``[]``). -* ``{"foo": "bar"}``: Hashes are defined by a list of keys and values +* ``{"name": "Fabien"}``: Mappings are defined by a list of keys and values separated by a comma (``,``) and wrapped with curly braces (``{}``): .. code-block:: twig {# keys as string #} - { 'foo': 'foo', 'bar': 'bar' } + {'name': 'Fabien', 'city': 'Paris'} - {# keys as names (equivalent to the previous hash) #} - { foo: 'foo', bar: 'bar' } + {# keys as names (equivalent to the previous mapping) #} + {name: 'Fabien', city: 'Paris'} {# keys as integer #} - { 2: 'foo', 4: 'bar' } + {2: 'Twig', 4: 'Symfony'} {# keys can be omitted if it is the same as the variable name #} - { foo } + {Paris} {# is equivalent to the following #} - { 'foo': foo } + {'Paris': Paris} {# keys as expressions (the expression must be enclosed into parentheses) #} - {% set foo = 'foo' %} - { (foo): 'foo', (1 + 1): 'bar', (foo ~ 'b'): 'baz' } + {% set key = 'name' %} + {(key): 'Fabien', (1 + 1): 2, ('ci' ~ 'ty'): 'city'} * ``true`` / ``false``: ``true`` represents the true value, ``false`` represents the false value. @@ -570,11 +626,11 @@ exist: * ``null``: ``null`` represents no specific value. This is the value returned when a variable does not exist. ``none`` is an alias for ``null``. -Arrays and hashes can be nested: +Sequences and mappings can be nested: .. code-block:: twig - {% set foo = [1, {"foo": "bar"}] %} + {% set complex = [1, {"name": "Fabien"}] %} .. tip:: @@ -582,6 +638,30 @@ Arrays and hashes can be nested: but :ref:`string interpolation ` is only supported in double-quoted strings. +.. _templates-string-interpolation: + +String Interpolation +~~~~~~~~~~~~~~~~~~~~ + +String interpolation (``#{expression}``) allows any valid expression to appear +within a *double-quoted string*. The result of evaluating that expression is +inserted into the string: + +.. code-block:: twig + + {{ "first #{middle} last" }} + {{ "first #{1 + 2} last" }} + +.. tip:: + + String interpolations can be ignored by escaping them with a backslash + (``\``): + + .. code-block:: twig + + {# outputs first #{1 + 2} last #} + {{ "first \#{1 + 2} last" }} + Math ~~~~ @@ -600,14 +680,16 @@ Twig allows you to do math in templates; the following operators are supported: ``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: @@ -618,6 +700,8 @@ You can combine multiple expressions with the following operators: * ``and``: Returns true if the left and the right operands are both true. +* ``xor``: Returns true if **either** the left or the right operand is true, but not both. + * ``or``: Returns true if the left or the right operand is true. * ``not``: Negates a statement. @@ -635,34 +719,42 @@ You can combine multiple expressions with the following operators: Comparisons ~~~~~~~~~~~ -The following comparison operators are supported in any expression: ``==``, -``!=``, ``<``, ``>``, ``>=``, and ``<=``. +The following mathematical comparison operators are supported in any +expression: ``==``, ``!=``, ``<``, ``>``, ``>=``, and ``<=``. -Check if a string ``starts with`` or ``ends with`` another string: +Spaceship Operator +~~~~~~~~~~~~~~~~~~ -.. code-block:: twig +The spaceship operator (``<=>``) is used for comparing two expressions. It +returns ``-1``, ``0`` or ``1`` when the first operand is respectively less +than, equal to, or greater than the second operand. - {% if 'Fabien' starts with 'F' %} - {% endif %} +.. note:: - {% if 'Fabien' ends with 'n' %} - {% endif %} + Read more about in the `PHP spaceship operator documentation`_. -Check that a string contains another string via the containment operator (see -next section). +Iterable Operators +~~~~~~~~~~~~~~~~~~ -.. note:: +Check that an iterable ``has every`` or ``has some`` of its elements return +``true`` using an arrow function. The arrow function receives the value of the +iterable as its argument: - For complex string comparisons, the ``matches`` operator allows you to use - `regular expressions`_: +.. code-block:: twig - .. code-block:: twig + {% set sizes = [34, 36, 38, 40, 42] %} - {% if phone matches '/^[\\d\\.]+$/' %} - {% endif %} + {% set hasOnlyOver38 = sizes has every v => v > 38 %} + {# hasOnlyOver38 is false #} -Containment Operator -~~~~~~~~~~~~~~~~~~~~ + {% set hasOver38 = sizes has some v => v > 38 %} + {# hasOver38 is true #} + +For an empty iterable, ``has every`` returns ``true`` and ``has some`` returns +``false``. + +Containment Operators +~~~~~~~~~~~~~~~~~~~~~ The ``in`` operator performs containment test. It returns ``true`` if the left operand is contained in the right: @@ -677,8 +769,8 @@ operand is contained in the right: .. tip:: - You can use this filter to perform a containment test on strings, arrays, - or objects implementing the ``Traversable`` interface. + You can use this operator to perform a containment test on strings, + sequences, mappings, or objects implementing the ``Traversable`` interface. To perform a negative test, use the ``not in`` operator: @@ -689,6 +781,27 @@ To perform a negative test, use the ``not in`` operator: {# is equivalent to #} {% if not (1 in [1, 2, 3]) %} +The ``starts with`` and ``ends with`` operators are used to check if a string +starts or ends with a given substring: + +.. code-block:: twig + + {% if 'Fabien' starts with 'F' %} + {% endif %} + + {% if 'Fabien' ends with 'n' %} + {% endif %} + +.. note:: + + For complex string comparisons, the ``matches`` operator allows you to use + `regular expressions`_: + + .. code-block:: twig + + {% if phone matches '/^[\\d\\.]+$/' %} + {% endif %} + Test Operator ~~~~~~~~~~~~~ @@ -731,52 +844,196 @@ The following operators don't fit into any of the other categories: .. code-block:: twig - {{ 1..5 }} + {% for i in 1..5 %}{{ i }}{% endfor %} - {# equivalent to #} - {{ range(1, 5) }} + {# is equivalent to #} + {% for i in range(1, 5) %}{{ i }}{% endfor %} Note that you must use parentheses when combining it with the filter operator due to the :ref:`operator precedence rules `: .. code-block:: twig - (1..5)|join(', ') + {{ (1..5)|join(', ') }} * ``~``: Converts all operands into strings and concatenates them. ``{{ "Hello " ~ name ~ "!" }}`` would return (assuming ``name`` is ``'John'``) ``Hello John!``. +.. _dot_operator: + * ``.``, ``[]``: 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): + + .. code-block:: twig + + {{ user.name }} + + After the ``.``, 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): + + .. code-block:: twig + + {# equivalent to the non-working user.first-name #} + {{ user.('first-name') }} + + Another use case is when the attribute is "dynamic" (defined via a variable): + + .. code-block:: twig + + {{ user.(name) }} + {{ user.('get' ~ name) }} + + Before Twig 3.15, use the :doc:`attribute ` function + instead for the two previous use cases. + + Twig supports a specific syntax via the ``[]`` operator for accessing items + on sequences and mappings: + + .. code-block:: twig + + {{ user['name'] }} + + When calling a method, you can pass arguments using the ``()`` operator: + + .. code-block:: twig + + {{ html.generate_input() }} + {{ html.generate_input('pwd', 'password') }} + {# or using named arguments #} + {{ html.generate_input(name: 'pwd', type: 'password') }} + + .. sidebar:: PHP Implementation + + 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 + ``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; + * if not, and if ``user`` is a PHP object, check the following methods and + call the first valid one: ``name()``, ``getName()``, ``isName()``, or + ``hasName()``; + * if not, and if ``strict_variables`` is ``false``, return ``null``; + * if not, throw an exception. + + To resolve ``user['name']`` to a PHP call, Twig uses the following algorithm + at runtime: + + * check if ``user`` is an array and ``name`` a valid element; + * if not, and if ``strict_variables`` is ``false``, return ``null``; + * if not, throw an exception. + + 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()``, + ``isName()``, or ``hasName()`` method; + * if not, and if ``strict_variables`` is ``false``, return ``null``; + * if not, throw an exception. + * ``?:``: The ternary operator: .. code-block:: twig - {{ foo ? 'yes' : 'no' }} - {{ foo ?: 'no' }} is the same as {{ foo ? foo : 'no' }} - {{ foo ? 'yes' }} is the same as {{ foo ? 'yes' : '' }} + {{ result ? 'yes' : 'no' }} + {{ result ?: 'no' }} is the same as {{ result ? result : 'no' }} + {{ result ? 'yes' }} is the same as {{ result ? 'yes' : '' }} * ``??``: The null-coalescing operator: .. code-block:: twig - {# returns the value of foo if it is defined and not null, 'no' otherwise #} - {{ foo ?? 'no' }} + {# returns the value of result if it is defined and not null, 'no' otherwise #} + {{ result ?? 'no' }} -.. _templates-string-interpolation: +* ``...``: The spread operator can be used to expand sequences or mappings or + to expand the arguments of a function call: -String Interpolation -~~~~~~~~~~~~~~~~~~~~ + .. code-block:: twig -String interpolation (``#{expression}``) allows any valid expression to appear -within a *double-quoted string*. The result of evaluating that expression is -inserted into the string: + {% set numbers = [1, 2, ...moreNumbers] %} + {% set ratings = {'q1': 10, 'q2': 5, ...moreRatings} %} + + {{ 'Hello %s %s!'|format(...['Fabien', 'Potencier']) }} + + .. versionadded:: 3.15 + + Support for expanding the arguments of a function call was introduced in + Twig 3.15. + +* ``=>``: 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 + all passed arguments. Arrow functions are supported as arguments for filters, + functions, tests, macros, and method calls. + + For instance, the built-in ``map``, ``reduce``, ``sort``, ``filter``, and + ``find`` filters accept arrow functions as arguments: + + .. code-block:: twig + + {{ people|map(p => p.first_name)|join(', ') }} + + Arrow functions can be stored in variables: + + .. code-block:: twig + + {% set first_name_fn = (p) => p.first_name %} + + {{ people|map(first_name_fn)|join(', ') }} + + .. versionadded:: 3.15 + + Arrow function support for functions, macros, and method calls was added in + Twig 3.15 (filters and tests were already supported). + + Arrow functions can be called using the :doc:`invoke ` + filter. + + .. versionadded:: 3.19 + + The ``invoke`` filter has been added in Twig 3.19. + +Operators +~~~~~~~~~ + +Twig uses operators to perform various operations within templates. +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. + +.. include:: operators_precedence.rst + +Without using any parentheses, the operator precedence rules are used to +determine how to convert the code to PHP: + +.. code-block:: twig + + {{ 6 b-and 2 or 6 b-and 16 }} + + {# it is converted to the following PHP code: (6 & 2) || (6 & 16) #} + +Change the default precedence by explicitly grouping expressions with +parentheses: .. code-block:: twig - {{ "foo #{bar} baz" }} - {{ "foo #{1 + 2} baz" }} + {% set greeting = 'Hello ' %} + {% set name = 'Fabien' %} + + {{ greeting ~ name|lower }} {# Hello fabien #} + + {# use parenthesis to change precedence #} + {{ (greeting ~ name)|lower }} {# hello fabien #} .. _templates-whitespace-control: @@ -824,38 +1081,27 @@ the modifiers on one side of a tag or on both sides: {{~ value }} {# outputs '
  • \nno spaces
  • ' #} -.. tip:: - - In addition to the whitespace modifiers, Twig also has a ``spaceless`` filter - that removes whitespace **between HTML tags**: - - .. code-block:: html+twig - - {% apply spaceless %} -
    - foo bar -
    - {% endapply %} - - {# output will be
    foo bar
    #} - Extensions ---------- Twig can be extended. If you want to create your own extensions, read the :ref:`Creating an Extension ` chapter. -.. _`Twig bundle`: https://github.com/Anomareh/PHP-Twig.tmbundle -.. _`Jinja syntax plugin`: http://jinja.pocoo.org/docs/integration/#vim -.. _`vim-twig plugin`: https://github.com/lumiliet/vim-twig -.. _`Twig syntax plugin`: http://plugins.netbeans.org/plugin/37069/php-twig -.. _`Twig plugin`: https://github.com/pulse00/Twig-Eclipse-Plugin -.. _`Twig language definition`: https://github.com/gabrielcorpse/gedit-twig-template-language -.. _`Twig syntax mode`: https://github.com/bobthecow/Twig-HTML.mode -.. _`other Twig syntax mode`: https://github.com/muxx/Twig-HTML.mode -.. _`Notepad++ Twig Highlighter`: https://github.com/Banane9/notepadplusplus-twig -.. _`web-mode.el`: http://web-mode.org/ -.. _`regular expressions`: https://www.php.net/manual/en/pcre.pattern.php -.. _`PHP-twig for atom`: https://github.com/reesef/php-twig -.. _`TwigFiddle`: https://twigfiddle.com/ -.. _`Twig pack`: https://marketplace.visualstudio.com/items?itemName=bajdzis.vscode-twig-pack +.. _`Twig bundle`: https://github.com/uhnomoli/PHP-Twig.tmbundle +.. _`vim-twig plugin`: https://github.com/lumiliet/vim-twig +.. _`Twig plugin`: https://github.com/pulse00/Twig-Eclipse-Plugin +.. _`Twig language definition`: https://github.com/gabrielcorpse/gedit-twig-template-language +.. _`Twig syntax mode`: https://github.com/bobthecow/Twig-HTML.mode +.. _`other Twig syntax mode`: https://github.com/muxx/Twig-HTML.mode +.. _`Notepad++ Twig Highlighter`: https://github.com/Banane9/notepadplusplus-twig +.. _`web-mode.el`: https://web-mode.org/ +.. _`regular expressions`: https://www.php.net/manual/en/pcre.pattern.php +.. _`PHP-twig for atom`: https://github.com/reesef/php-twig +.. _`TwigQI`: https://github.com/alisqi/TwigQI +.. _`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 --git a/doc/tests/defined.rst b/doc/tests/defined.rst index 234a28988a0..6e642d11607 100644 --- a/doc/tests/defined.rst +++ b/doc/tests/defined.rst @@ -7,16 +7,16 @@ useful if you use the ``strict_variables`` option: .. code-block:: twig {# defined works with variable names #} - {% if foo is defined %} + {% if user is defined %} ... {% endif %} {# and attributes on variables names #} - {% if foo.bar is defined %} + {% if user.name is defined %} ... {% endif %} - {% if foo['bar'] is defined %} + {% if user['name'] is defined %} ... {% endif %} @@ -25,6 +25,6 @@ method calls, be sure that they are all defined first: .. code-block:: twig - {% if var is defined and foo.method(var) is defined %} + {% if var is defined and user.name(var) is defined %} ... {% endif %} diff --git a/doc/tests/empty.rst b/doc/tests/empty.rst index 0233eca48f5..0b45f1d0e31 100644 --- a/doc/tests/empty.rst +++ b/doc/tests/empty.rst @@ -1,8 +1,8 @@ ``empty`` ========= -``empty`` checks if a variable is an empty string, an empty array, an empty -hash, exactly ``false``, or exactly ``null``. +``empty`` checks if a variable is an empty string, an empty sequence, an empty +mapping, exactly ``false``, or exactly ``null``. For objects that implement the ``Countable`` interface, ``empty`` will check the return value of the ``count()`` method. @@ -12,7 +12,7 @@ it will check if an empty string is returned. .. code-block:: twig - {% if foo is empty %} + {% if user is empty %} ... {% endif %} diff --git a/doc/tests/iterable.rst b/doc/tests/iterable.rst index 4ebfe9d8a50..8c83efcd89d 100644 --- a/doc/tests/iterable.rst +++ b/doc/tests/iterable.rst @@ -5,7 +5,7 @@ .. code-block:: twig - {# evaluates to true if the foo variable is iterable #} + {# evaluates to true if the users variable is iterable #} {% if users is iterable %} {% for user in users %} Hello {{ user }}! diff --git a/doc/tests/mapping.rst b/doc/tests/mapping.rst new file mode 100644 index 00000000000..ab90cd6c16e --- /dev/null +++ b/doc/tests/mapping.rst @@ -0,0 +1,14 @@ +``mapping`` +=========== + +``mapping`` checks if a variable is a mapping: + +.. code-block:: twig + + {% set users = {alice: "Alice Dupond", bob: "Bob Smith"} %} + {# evaluates to true if the users variable is a mapping #} + {% if users is mapping %} + {% for key, user in users %} + {{ key }}: {{ user }}; + {% endfor %} + {% endif %} diff --git a/doc/tests/sameas.rst b/doc/tests/sameas.rst index c09297114bb..1854f0c81d6 100644 --- a/doc/tests/sameas.rst +++ b/doc/tests/sameas.rst @@ -6,6 +6,6 @@ This is equivalent to ``===`` in PHP: .. code-block:: twig - {% if foo.attribute is same as(false) %} - the foo attribute really is the 'false' PHP value + {% if user.name is same as(false) %} + the user attribute is the 'false' PHP value {% endif %} diff --git a/doc/tests/sequence.rst b/doc/tests/sequence.rst new file mode 100644 index 00000000000..0ae47a38705 --- /dev/null +++ b/doc/tests/sequence.rst @@ -0,0 +1,14 @@ +``sequence`` +============ + +``sequence`` checks if a variable is a sequence: + +.. code-block:: twig + + {% set users = ["Alice", "Bob"] %} + {# evaluates to true if the users variable is a sequence #} + {% if users is sequence %} + {% for user in users %} + Hello {{ user }}! + {% endfor %} + {% endif %} diff --git a/extra/cache-extra/CacheExtension.php b/extra/cache-extra/CacheExtension.php index 5cf849dc634..3898b6e0f50 100644 --- a/extra/cache-extra/CacheExtension.php +++ b/extra/cache-extra/CacheExtension.php @@ -16,7 +16,7 @@ final class CacheExtension extends AbstractExtension { - public function getTokenParsers() + public function getTokenParsers(): array { return [ new CacheTokenParser(), diff --git a/extra/cache-extra/LICENSE b/extra/cache-extra/LICENSE index efb17f98e7d..99c6bdf356e 100644 --- a/extra/cache-extra/LICENSE +++ b/extra/cache-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 Fabien Potencier +Copyright (c) 2021-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/cache-extra/Node/CacheNode.php b/extra/cache-extra/Node/CacheNode.php index f79873aa9a5..7308b6bc9fc 100644 --- a/extra/cache-extra/Node/CacheNode.php +++ b/extra/cache-extra/Node/CacheNode.php @@ -12,13 +12,17 @@ namespace Twig\Extra\Cache\Node; use Twig\Compiler; +use Twig\Node\CaptureNode; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; -class CacheNode extends Node +class CacheNode extends AbstractExpression { - public function __construct(AbstractExpression $key, ?AbstractExpression $ttl, ?AbstractExpression $tags, Node $body, int $lineno, string $tag) + public function __construct(AbstractExpression $key, ?AbstractExpression $ttl, ?AbstractExpression $tags, Node $body, int $lineno) { + $body = new CaptureNode($body, $lineno); + $body->setAttribute('raw', true); + $nodes = ['key' => $key, 'body' => $body]; if (null !== $ttl) { $nodes['ttl'] = $ttl; @@ -27,16 +31,16 @@ public function __construct(AbstractExpression $key, ?AbstractExpression $ttl, ? $nodes['tags'] = $tags; } - parent::__construct($nodes, [], $lineno, $tag); + parent::__construct($nodes, [], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) - ->write('$cached = $this->env->getRuntime(\'Twig\Extra\Cache\CacheRuntime\')->getCache()->get(') + ->raw('$this->env->getRuntime(\'Twig\Extra\Cache\CacheRuntime\')->getCache()->get(') ->subcompile($this->getNode('key')) - ->raw(", function (\Symfony\Contracts\Cache\ItemInterface \$item) use (\$context, \$macros) {\n") + ->raw(", function (\Symfony\Contracts\Cache\ItemInterface \$item) use (\$context, \$macros, \$blocks) {\n") ->indent() ; @@ -57,13 +61,11 @@ public function compile(Compiler $compiler): void } $compiler - ->write("ob_start(function () { return ''; });\n") + ->write('return ') ->subcompile($this->getNode('body')) - ->write("\n") - ->write("return ob_get_clean();\n") + ->raw(";\n") ->outdent() - ->write("});\n") - ->write("echo '' === \$cached ? '' : new Markup(\$cached, \$this->env->getCharset());\n") + ->write("})\n") ; } } diff --git a/extra/cache-extra/Tests/Fixtures/cache_complex.test b/extra/cache-extra/Tests/Fixtures/cache_complex.test new file mode 100644 index 00000000000..206865088b1 --- /dev/null +++ b/extra/cache-extra/Tests/Fixtures/cache_complex.test @@ -0,0 +1,15 @@ +--TEST-- +"cache" tag +--TEMPLATE-- +{% cache 'test_%s_%s'|format(10, 10000) ttl(36000) %} + {% set content %} + ok + {% endset %} + {% apply upper %} + {{ content }} + {% endapply %} +{% endcache %} +--DATA-- +return [] +--EXPECT-- +OK diff --git a/extra/cache-extra/Tests/Fixtures/cache_with_blocks.test b/extra/cache-extra/Tests/Fixtures/cache_with_blocks.test new file mode 100644 index 00000000000..79721d7fca8 --- /dev/null +++ b/extra/cache-extra/Tests/Fixtures/cache_with_blocks.test @@ -0,0 +1,15 @@ +--TEST-- +"cache" tag +--TEMPLATE-- +{% extends "layout.twig" %} +{% block bar %} + {% cache "foo" %} + {%- block content %}FOO{% endblock %} + {% endcache %} +{% endblock %} +--TEMPLATE(layout.twig)-- +{% block content %}{% endblock %} +--DATA-- +return [] +--EXPECT-- +FOO diff --git a/extra/cache-extra/Tests/FunctionalTest.php b/extra/cache-extra/Tests/FunctionalTest.php index 111026a89d9..a91858c9175 100644 --- a/extra/cache-extra/Tests/FunctionalTest.php +++ b/extra/cache-extra/Tests/FunctionalTest.php @@ -65,7 +65,7 @@ public function testTagsTooManyArgs() $twig->render('index'); } - private function createEnvironment(array $templates, ArrayAdapter $cache = null): Environment + private function createEnvironment(array $templates, ?ArrayAdapter $cache = null): Environment { $twig = new Environment(new ArrayLoader($templates)); $cache = $cache ?? new ArrayAdapter(); @@ -78,11 +78,9 @@ public function __construct(CacheInterface $cache) $this->cache = $cache; } - public function load($class) + public function load(string $class): ?object { - if (CacheRuntime::class === $class) { - return new CacheRuntime($this->cache); - } + return CacheRuntime::class === $class ? new CacheRuntime($this->cache) : null; } }); diff --git a/extra/cache-extra/Tests/IntegrationTest.php b/extra/cache-extra/Tests/IntegrationTest.php index 8e216aaa51e..ab72d6442da 100644 --- a/extra/cache-extra/Tests/IntegrationTest.php +++ b/extra/cache-extra/Tests/IntegrationTest.php @@ -29,18 +29,16 @@ public function getExtensions() protected function getRuntimeLoaders() { return [ - new class() implements RuntimeLoaderInterface { - public function load($class) + new class implements RuntimeLoaderInterface { + public function load(string $class): ?object { - if (CacheRuntime::class === $class) { - return new CacheRuntime(new ArrayAdapter()); - } + return CacheRuntime::class === $class ? new CacheRuntime(new ArrayAdapter()) : null; } }, ]; } - public function getFixturesDir() + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } diff --git a/extra/cache-extra/TokenParser/CacheTokenParser.php b/extra/cache-extra/TokenParser/CacheTokenParser.php index cb50b72d369..e57a8b3fd62 100644 --- a/extra/cache-extra/TokenParser/CacheTokenParser.php +++ b/extra/cache-extra/TokenParser/CacheTokenParser.php @@ -13,7 +13,9 @@ use Twig\Error\SyntaxError; use Twig\Extra\Cache\Node\CacheNode; +use Twig\Node\Expression\Filter\RawFilter; use Twig\Node\Node; +use Twig\Node\PrintNode; use Twig\Token; use Twig\TokenParser\AbstractTokenParser; @@ -22,31 +24,32 @@ class CacheTokenParser extends AbstractTokenParser 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'], true)) { + throw new SyntaxError(\sprintf('Unknown "%s" configuration.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + $stream->next(); - $args = $expressionParser->parseArguments(); + $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 = $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()); + } + $stream->expect(Token::PUNCTUATION_TYPE, ')'); - switch ($k) { - case 'ttl': - if (1 !== \count($args)) { - throw new SyntaxError(sprintf('The "ttl" modifier takes exactly one argument (%d given).', \count($args)), $stream->getCurrent()->getLine(), $stream->getSourceContext()); - } - $ttl = $args->getNode(0); - break; - case 'tags': - if (1 !== \count($args)) { - throw new SyntaxError(sprintf('The "tags" modifier takes exactly one argument (%d given).', \count($args)), $stream->getCurrent()->getLine(), $stream->getSourceContext()); - } - $tags = $args->getNode(0); - break; - default: - throw new SyntaxError(sprintf('Unknown "%s" configuration.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext()); + if ('ttl' === $k) { + $ttl = $arg; + } elseif ('tags' === $k) { + $tags = $arg; } } @@ -54,7 +57,9 @@ public function parse(Token $token): Node $body = $this->parser->subparse([$this, 'decideCacheEnd'], true); $stream->expect(Token::BLOCK_END_TYPE); - return new CacheNode($key, $ttl, $tags, $body, $token->getLine(), $this->getTag()); + $body = new CacheNode($key, $ttl, $tags, $body, $token->getLine()); + + return new PrintNode(new RawFilter($body), $token->getLine()); } public function decideCacheEnd(Token $token): bool diff --git a/extra/cache-extra/composer.json b/extra/cache-extra/composer.json index 462b0f48b30..8b21310eed9 100644 --- a/extra/cache-extra/composer.json +++ b/extra/cache-extra/composer.json @@ -15,22 +15,17 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/cache": "^5.0|^6.0", - "twig/twig": "^2.4|^3.0" + "php": ">=8.1.0", + "symfony/cache": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.21|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\Cache\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/cache-extra/phpunit.xml.dist b/extra/cache-extra/phpunit.xml.dist index fb5f43192e6..0cfdd72967c 100644 --- a/extra/cache-extra/phpunit.xml.dist +++ b/extra/cache-extra/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + ./Tests/ + + diff --git a/extra/cssinliner-extra/CssInlinerExtension.php b/extra/cssinliner-extra/CssInlinerExtension.php index 4d8b75a5623..0447b4df037 100644 --- a/extra/cssinliner-extra/CssInlinerExtension.php +++ b/extra/cssinliner-extra/CssInlinerExtension.php @@ -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. @@ -17,20 +17,23 @@ class CssInlinerExtension extends AbstractExtension { - public function getFilters() + public function getFilters(): array { return [ - new TwigFilter('inline_css', 'Twig\\Extra\\CssInliner\\twig_inline_css', ['is_safe' => ['all']]), + new TwigFilter('inline_css', [self::class, 'inlineCss'], ['is_safe' => ['all']]), ]; } -} -function twig_inline_css(string $body, string ...$css): string -{ - static $inliner; - if (null === $inliner) { - $inliner = new CssToInlineStyles(); - } + /** + * @internal + */ + public static function inlineCss(string $body, string ...$css): string + { + static $inliner; + if (null === $inliner) { + $inliner = new CssToInlineStyles(); + } - return $inliner->convert($body, implode("\n", $css)); + return $inliner->convert($body, implode("\n", $css)); + } } diff --git a/extra/cssinliner-extra/LICENSE b/extra/cssinliner-extra/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/cssinliner-extra/LICENSE +++ b/extra/cssinliner-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/cssinliner-extra/Resources/functions.php b/extra/cssinliner-extra/Resources/functions.php new file mode 100644 index 00000000000..d2bcecafda7 --- /dev/null +++ b/extra/cssinliner-extra/Resources/functions.php @@ -0,0 +1,24 @@ + - -

    Great!

    - +{% apply inline_css %} +

    Great!

    {% endapply %} -{% apply inline_css(source('css'))|spaceless %} - -

    Great!

    - +{% apply inline_css(source('css')) %} +

    Great!

    {% endapply %} -{% apply inline_css(source('css'), source('more_css'))|spaceless %} - -

    Great!

    - +{% apply inline_css(source('css'), source('more_css')) %} +

    Great!

    {% endapply %} -{% apply inline_css(source('css') ~ source('more_css'))|spaceless %} - -

    Great!

    - +{% apply inline_css(source('css') ~ source('more_css')) %} +

    Great!

    {% endapply %} -{{ include('html')|inline_css(source('css') ~ source('more_css'))|spaceless }} +{{ include('html')|inline_css(source('css') ~ source('more_css')) }} --TEMPLATE(html)-- - -

    Great!

    - +

    Great!

    --TEMPLATE(css)-- p { color: red } --TEMPLATE(more_css)-- @@ -42,12 +31,20 @@ p { color: blue } --DATA-- return [] --EXPECT-- -

    Great!

    + + + +

    Great!

    + -

    Great!

    + +

    Great!

    -

    Great!

    + +

    Great!

    -

    Great!

    + +

    Great!

    -

    Great!

    + +

    Great!

    diff --git a/extra/cssinliner-extra/Tests/IntegrationTest.php b/extra/cssinliner-extra/Tests/IntegrationTest.php index 5ab6ec9b4ab..7004b5e99ac 100644 --- a/extra/cssinliner-extra/Tests/IntegrationTest.php +++ b/extra/cssinliner-extra/Tests/IntegrationTest.php @@ -23,7 +23,7 @@ public function getExtensions() ]; } - public function getFixturesDir() + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } diff --git a/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php b/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php new file mode 100644 index 00000000000..d62e267fdff --- /dev/null +++ b/extra/cssinliner-extra/Tests/LegacyFunctionsTest.php @@ -0,0 +1,28 @@ +assertSame(CssInlinerExtension::inlineCss('

    body

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

    body

    ', 'p { color: red }')); + } +} diff --git a/extra/cssinliner-extra/composer.json b/extra/cssinliner-extra/composer.json index b1c15249b7a..f8cce58e03d 100644 --- a/extra/cssinliner-extra/composer.json +++ b/extra/cssinliner-extra/composer.json @@ -15,22 +15,19 @@ } ], "require": { - "php": ">=7.1.3", + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", "tijsverkoyen/css-to-inline-styles": "^2.0", - "twig/twig": "^2.7|^3.0" + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { + "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\CssInliner\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/cssinliner-extra/phpunit.xml.dist b/extra/cssinliner-extra/phpunit.xml.dist index 78ead58f5cd..de3fc99ea67 100644 --- a/extra/cssinliner-extra/phpunit.xml.dist +++ b/extra/cssinliner-extra/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + ./Tests/ + + diff --git a/extra/html-extra/Cva.php b/extra/html-extra/Cva.php new file mode 100644 index 00000000000..9355565ce75 --- /dev/null +++ b/extra/html-extra/Cva.php @@ -0,0 +1,129 @@ + + */ +final class Cva +{ + /** + * @var list + */ + private array $base; + + /** + * @param string|list $base The base classes to apply to the component + */ + public function __construct( + string|array $base = [], + /** + * The variants to apply based on recipes. + * + * Format: [variantCategory => [variantName => classes]] + * + * Example: + * 'colors' => [ + * 'primary' => 'bleu-8000', + * 'danger' => 'red-800 text-bold', + * ], + * 'size' => [...], + * + * @var array>> + */ + private array $variants = [], + + /** + * The compound variants to apply based on recipes. + * + * Format: [variantsCategory => ['variantName', 'variantName'], class: classes] + * + * Example: + * [ + * 'colors' => ['primary'], + * 'size' => ['small'], + * 'class' => 'text-red-500', + * ], + * [ + * 'size' => ['large'], + * 'class' => 'font-weight-500', + * ] + * + * @var array>> + */ + private array $compoundVariants = [], + + /** + * The default variants to apply if specific recipes aren't provided. + * + * Format: [variantCategory => variantName] + * + * Example: + * 'colors' => 'primary', + * + * @var array + */ + private array $defaultVariants = [], + ) { + $this->base = (array) $base; + } + + public function apply(array $recipes, ?string ...$additionalClasses): string + { + $classes = $this->base; + + // Resolve recipes against variants + foreach ($recipes as $recipeName => $recipeValue) { + if (\is_bool($recipeValue)) { + $recipeValue = $recipeValue ? 'true' : 'false'; + } + $recipeClasses = $this->variants[$recipeName][$recipeValue] ?? []; + $classes = [...$classes, ...(array) $recipeClasses]; + } + + // Resolve compound variants + foreach ($this->compoundVariants as $compound) { + $compoundClasses = $this->resolveCompoundVariant($compound, $recipes) ?? []; + $classes = [...$classes, ...$compoundClasses]; + } + + // Apply default variants if specific recipes aren't provided + foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) { + if (!isset($recipes[$defaultVariantName])) { + $variantClasses = $this->variants[$defaultVariantName][$defaultVariantValue] ?? []; + $classes = [...$classes, ...(array) $variantClasses]; + } + } + $classes = [...$classes, ...array_values($additionalClasses)]; + + $classes = implode(' ', array_filter($classes, 'is_string')); + $classes = preg_split('#\s+#', $classes, -1, \PREG_SPLIT_NO_EMPTY) ?: []; + + return implode(' ', array_unique($classes)); + } + + private function resolveCompoundVariant(array $compound, array $recipes): array + { + foreach ($compound as $compoundName => $compoundValues) { + if ('class' === $compoundName) { + continue; + } + if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues, true)) { + return []; + } + } + + return (array) ($compound['class'] ?? []); + } +} diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index ed740b47187..cba22427ba1 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -9,9 +9,12 @@ * file that was distributed with this source code. */ -namespace Twig\Extra\Html { +namespace Twig\Extra\Html; + use Symfony\Component\Mime\MimeTypes; +use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; +use Twig\Markup; use Twig\TwigFilter; use Twig\TwigFunction; @@ -19,7 +22,7 @@ final class HtmlExtension extends AbstractExtension { private $mimeTypes; - public function __construct(MimeTypes $mimeTypes = null) + public function __construct(?MimeTypes $mimeTypes = null) { $this->mimeTypes = $mimeTypes; } @@ -34,7 +37,8 @@ public function getFilters(): array public function getFunctions(): array { return [ - new TwigFunction('html_classes', 'twig_html_classes'), + new TwigFunction('html_classes', [self::class, 'htmlClasses']), + new TwigFunction('html_cva', [self::class, 'htmlCva']), ]; } @@ -45,8 +49,10 @@ public function getFunctions(): array * be done before calling this filter. * * @return string The generated data URI + * + * @internal */ - public function dataUri(string $data, string $mime = null, array $parameters = []): string + public function dataUri(string $data, ?string $mime = null, array $parameters = []): string { $repr = 'data:'; @@ -71,7 +77,7 @@ public function dataUri(string $data, string $mime = null, array $parameters = [ $repr .= ';'.$key.'='.rawurlencode($value); } - if (0 === strpos($mime, 'text/')) { + if (str_starts_with($mime, 'text/')) { $repr .= ','.rawurlencode($data); } else { $repr .= ';base64,'.base64_encode($data); @@ -79,33 +85,44 @@ public function dataUri(string $data, string $mime = null, array $parameters = [ return $repr; } -} -} -namespace { -use Twig\Error\RuntimeError; - -function twig_html_classes(...$args): string -{ - $classes = []; - foreach ($args as $i => $arg) { - if (\is_string($arg)) { - $classes[] = $arg; - } elseif (\is_array($arg)) { - foreach ($arg as $class => $condition) { - if (!\is_string($class)) { - throw new RuntimeError(sprintf('The html_classes function argument %d (key %d) should be a string, got "%s".', $i, $class, \gettype($class))); - } - if (!$condition) { - continue; + /** + * @internal + */ + public static function htmlClasses(...$args): string + { + $classes = []; + foreach ($args as $i => $arg) { + if (\is_string($arg) || $arg instanceof Markup) { + $classes[] = (string) $arg; + } elseif (\is_array($arg)) { + foreach ($arg as $class => $condition) { + if (!\is_string($class)) { + throw new RuntimeError(\sprintf('The "html_classes" function argument %d (key %d) should be a string, got "%s".', $i, $class, get_debug_type($class))); + } + if (!$condition) { + continue; + } + $classes[] = $class; } - $classes[] = $class; + } else { + throw new RuntimeError(\sprintf('The "html_classes" function argument %d should be either a string or an array, got "%s".', $i, get_debug_type($arg))); } - } else { - throw new RuntimeError(sprintf('The html_classes function argument %d should be either a string or an array, got "%s".', $i, \gettype($arg))); } + + return implode(' ', array_unique(array_filter($classes, static function ($v) { return '' !== $v; }))); } - return implode(' ', array_unique($classes)); -} + /** + * @param string|list $base + * @param array>> $variants + * @param array>> $compoundVariants + * @param array $defaultVariant + * + * @internal + */ + public static function htmlCva(array|string $base = [], array $variants = [], array $compoundVariants = [], array $defaultVariant = []): Cva + { + return new Cva($base, $variants, $compoundVariants, $defaultVariant); + } } diff --git a/extra/html-extra/LICENSE b/extra/html-extra/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/html-extra/LICENSE +++ b/extra/html-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/html-extra/README.md b/extra/html-extra/README.md index e2c46b08e4a..9cd51fe2884 100644 --- a/extra/html-extra/README.md +++ b/extra/html-extra/README.md @@ -9,5 +9,8 @@ This package is a Twig extension that provides the following: * [`html_classes`][2] function: returns a string by conditionally joining class names together. + * [`html_cva`][3] function: returns a `Cva` object to handle class variants. + [1]: https://twig.symfony.com/data_uri [2]: https://twig.symfony.com/html_classes +[3]: https://twig.symfony.com/html_cva diff --git a/extra/html-extra/Resources/functions.php b/extra/html-extra/Resources/functions.php new file mode 100644 index 00000000000..ca18af1d344 --- /dev/null +++ b/extra/html-extra/Resources/functions.php @@ -0,0 +1,24 @@ +assertEquals($expected, $recipeClass->apply($recipes)); + } + + public function testApply() + { + $recipe = new Cva('font-semibold border rounded', [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], [ + [ + 'colors' => ['primary'], + 'sizes' => ['sm'], + 'class' => 'text-red-500', + ], + ]); + + $this->assertEquals('font-semibold border rounded text-primary text-sm text-red-500', $recipe->apply(['colors' => 'primary', 'sizes' => 'sm'])); + } + + public function testApplyWithNullString() + { + $recipe = new Cva('font-semibold border rounded', [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], [ + [ + 'colors' => ['primary'], + 'sizes' => ['sm'], + 'class' => 'text-red-500', + ], + ]); + + $this->assertEquals('font-semibold border rounded text-primary text-sm text-red-500 flex justify-center', $recipe->apply(['colors' => 'primary', 'sizes' => 'sm'], 'flex', null, 'justify-center')); + } + + public static function recipeProvider(): iterable + { + yield 'base null' => [ + ['variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ]], + ['colors' => 'primary', 'sizes' => 'sm'], + 'text-primary text-sm', + ]; + + yield 'base empty' => [ + [ + 'base' => '', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ]], + ['colors' => 'primary', 'sizes' => 'sm'], + 'text-primary text-sm', + ]; + + yield 'base array' => [ + [ + 'base' => ['font-semibold', 'border', 'rounded'], + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ]], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm', + ]; + + yield 'no recipes match' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + ], + ['colors' => 'red', 'sizes' => 'test'], + 'font-semibold border rounded', + ]; + + yield 'simple variants' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm', + ]; + + yield 'simple variants as array' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => ['text-primary', 'uppercase'], + 'secondary' => ['text-secondary', 'uppercase'], + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary uppercase text-sm', + ]; + + yield 'simple variants with custom' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + ], + ['colors' => 'secondary', 'sizes' => 'md'], + 'font-semibold border rounded text-secondary text-md', + ]; + + yield 'compound variants' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => 'primary', + 'sizes' => ['sm'], + 'class' => 'text-red-100', + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm text-red-100', + ]; + + yield 'compound variants with true' => [ + [ + 'base' => 'button', + 'variants' => [ + 'colors' => [ + 'blue' => 'btn-blue', + 'red' => 'btn-red', + ], + 'disabled' => [ + 'true' => 'disabled', + ], + ], + 'compounds' => [ + [ + 'colors' => 'blue', + 'disabled' => ['true'], + 'class' => 'font-bold', + ], + ], + ], + ['colors' => 'blue', 'disabled' => 'true'], + 'button btn-blue disabled font-bold', + ]; + + yield 'compound variants as array' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['primary'], + 'sizes' => ['sm'], + 'class' => ['text-red-900', 'bold'], + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm text-red-900 bold', + ]; + + yield 'multiple compound variants' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['primary'], + 'sizes' => ['sm'], + 'class' => 'text-red-300', + ], + [ + 'colors' => ['primary'], + 'sizes' => ['md'], + 'class' => 'text-blue-300', + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm text-red-300', + ]; + + yield 'compound with multiple variants' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['primary', 'secondary'], + 'sizes' => ['sm'], + 'class' => 'text-red-800', + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm text-red-800', + ]; + + yield 'compound doesn\'t match' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['danger', 'secondary'], + 'sizes' => ['sm'], + 'class' => 'text-red-500', + ], + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm', + ]; + + yield 'default variables' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + 'rounded' => [ + 'sm' => 'rounded-sm', + 'md' => 'rounded-md', + 'lg' => 'rounded-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['danger', 'secondary'], + 'sizes' => 'sm', + 'class' => 'text-red-500', + ], + ], + 'defaultVariants' => [ + 'colors' => 'primary', + 'sizes' => 'sm', + 'rounded' => 'md', + ], + ], + ['colors' => 'primary', 'sizes' => 'sm'], + 'font-semibold border rounded text-primary text-sm rounded-md', + ]; + + yield 'default variables all overwrite' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + 'rounded' => [ + 'sm' => 'rounded-sm', + 'md' => 'rounded-md', + 'lg' => 'rounded-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['danger', 'secondary'], + 'sizes' => ['sm'], + 'class' => 'text-red-500', + ], + ], + 'defaultVariants' => [ + 'colors' => 'primary', + 'sizes' => 'sm', + 'rounded' => 'md', + ], + ], + ['colors' => 'primary', 'sizes' => 'sm', 'rounded' => 'lg'], + 'font-semibold border rounded text-primary text-sm rounded-lg', + ]; + + yield 'default variables without matching variants' => [ + [ + 'base' => 'font-semibold border rounded', + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'md' => 'text-md', + 'lg' => 'text-lg', + ], + 'rounded' => [ + 'sm' => 'rounded-sm', + 'md' => 'rounded-md', + 'lg' => 'rounded-lg', + ], + ], + 'compounds' => [ + [ + 'colors' => ['danger', 'secondary'], + 'sizes' => ['sm'], + 'class' => 'text-red-500', + ], + ], + 'defaultVariants' => [ + 'colors' => 'primary', + 'sizes' => 'sm', + 'rounded' => 'md', + ], + ], + [], + 'font-semibold border rounded text-primary text-sm rounded-md', + ]; + + yield 'default variables with boolean' => [ + [ + 'base' => 'button', + 'variants' => [ + 'colors' => [ + 'blue' => 'btn-blue', + 'red' => 'btn-red', + ], + 'disabled' => [ + 'true' => 'disabled', + 'false' => 'opacity-100', + ], + ], + 'defaultVariants' => [ + 'colors' => 'blue', + 'disabled' => 'false', + ], + ], + [], + 'button btn-blue opacity-100', + ]; + + yield 'boolean string variants true / true' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'true' => 'disable', + ], + ], + ], + ['colors' => 'primary', 'disabled' => true], + 'text-primary disable', + ]; + + yield 'boolean string variants true / false' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'true' => 'disable', + ], + ], + ], + ['colors' => 'primary', 'disabled' => false], + 'text-primary', + ]; + + yield 'boolean string variants false / true' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'false' => 'disable', + ], + ], + ], + ['colors' => 'primary', 'disabled' => true], + 'text-primary', + ]; + + yield 'boolean string variants false / false' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'false' => 'disable', + ], + ], + ], + ['colors' => 'primary', 'disabled' => false], + 'text-primary disable', + ]; + + yield 'boolean string variants missing' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'true' => 'disable', + ], + ], + ], + ['colors' => 'primary'], + 'text-primary', + ]; + + yield 'boolean list variants true' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'true' => ['disable', 'opacity-50'], + ], + ], + ], + ['colors' => 'primary', 'disabled' => true], + 'text-primary disable opacity-50', + ]; + + yield 'boolean list variants false' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'true' => ['disable', 'opacity-50'], + ], + ], + ], + ['colors' => 'primary', 'disabled' => false], + 'text-primary', + ]; + + yield 'boolean list variants missing' => [ + [ + 'variants' => [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary', + ], + 'disabled' => [ + 'true' => ['disable', 'opacity-50'], + ], + ], + ], + ['colors' => 'primary'], + 'text-primary', + ]; + } + + /** + * @dataProvider provideAdditionalClassesCases + */ + public function testAdditionalClasses(string|array $base, array|string $additionals, string $expected) + { + $cva = new Cva($base); + if (!$additionals) { + $this->assertEquals($expected, $cva->apply([])); + } else { + $this->assertEquals($expected, $cva->apply([], ...(array) $additionals)); + } + } + + public static function provideAdditionalClassesCases(): iterable + { + yield 'additionals_are_optional' => [ + '', + 'foo', + 'foo', + ]; + + yield 'additional_are_used' => [ + '', + 'foo', + 'foo', + ]; + + yield 'additionals_are_used' => [ + '', + ['foo', 'bar'], + 'foo bar', + ]; + + yield 'additionals_preserve_order' => [ + ['foo'], + ['bar', 'foo'], + 'foo bar', + ]; + + yield 'additional_are_deduplicated' => [ + '', + ['bar', 'bar'], + 'bar', + ]; + } +} diff --git a/extra/html-extra/Tests/Fixtures/data_uri.test b/extra/html-extra/Tests/Fixtures/data_uri.test index 65ec863f6e7..070e713e074 100644 --- a/extra/html-extra/Tests/Fixtures/data_uri.test +++ b/extra/html-extra/Tests/Fixtures/data_uri.test @@ -1,7 +1,7 @@ --TEST-- "data_uri" filter --TEMPLATE-- -{{ 'foobar#'|data_uri(parameters={charset: "utf-8", foo: "\$bar"}) }} +{{ 'foobar#'|data_uri(parameters={charset: "utf-8", foo: "$bar"}) }} {{ 'foobar'|data_uri(mime="text/html", parameters={charset: "ascii"}) }} --DATA-- diff --git a/extra/html-extra/Tests/Fixtures/html_classes.test b/extra/html-extra/Tests/Fixtures/html_classes.test index 8a5304cf63d..7266c1c448c 100644 --- a/extra/html-extra/Tests/Fixtures/html_classes.test +++ b/extra/html-extra/Tests/Fixtures/html_classes.test @@ -1,12 +1,20 @@ --TEST-- "html_classes" function --TEMPLATE-- -{{ html_classes('a', {'b': true, 'c': false}, 'd') }} +{{ html_classes('a', {'b': true, 'c': false}, 'd', false ? 'e', true ? 'f', '0') }} {% set class_a = 'a' %} -{% set class_b = 'b' %} -{{ html_classes(class_a, {(class_b): true})}} +{%- set class_b -%} +b +{%- endset -%} +{{ html_classes(class_a) }} +{{ html_classes(class_b) }} +{{ html_classes({ (class_a): true }) }} +{{ html_classes({ (class_b): true }) }} --DATA-- return [] --EXPECT-- -a b d -a b +a b d f 0 +a +b +a +b diff --git a/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_arg.test b/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_arg.test index 85faed6247c..21ca373f818 100644 --- a/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_arg.test +++ b/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_arg.test @@ -5,4 +5,4 @@ --DATA-- return [] --EXCEPTION-- -Twig\Error\RuntimeError: The html_classes function argument 0 should be either a string or an array, got "boolean" in "index.twig" at line 2. +Twig\Error\RuntimeError: The "html_classes" function argument 0 should be either a string or an array, got "bool" in "index.twig" at line 2. diff --git a/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_key.test b/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_key.test index b74748c58f5..708cb255bbc 100644 --- a/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_key.test +++ b/extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_key.test @@ -5,4 +5,4 @@ --DATA-- return [] --EXCEPTION-- -Twig\Error\RuntimeError: The html_classes function argument 0 (key 0) should be a string, got "integer" in "index.twig" at line 2. +Twig\Error\RuntimeError: The "html_classes" function argument 0 (key 0) should be a string, got "int" in "index.twig" at line 2. diff --git a/extra/html-extra/Tests/Fixtures/html_cva.test b/extra/html-extra/Tests/Fixtures/html_cva.test new file mode 100644 index 00000000000..71e16036adb --- /dev/null +++ b/extra/html-extra/Tests/Fixtures/html_cva.test @@ -0,0 +1,37 @@ +--TEST-- +"html_cva" function +--TEMPLATE-- +{% set alert = html_cva( + ['alert'], + { + color: { + blue: 'alert-blue', + red: 'alert-red', + green: 'alert-green', + yellow: 'alert-yellow', + }, + size: { + sm: 'alert-sm', + md: 'alert-md', + lg: 'alert-lg', + }, + rounded: { + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + } + }, + [{ + color: ['red'], + size: ['lg'], + class: 'font-semibold' + }], + { + rounded: 'md' + } +) %} +{{ alert.apply({color: 'blue', size: 'sm'}) }} +--DATA-- +return [] +--EXPECT-- +alert alert-blue alert-sm rounded-md diff --git a/extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test b/extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test new file mode 100644 index 00000000000..49b842fac1a --- /dev/null +++ b/extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test @@ -0,0 +1,19 @@ +--TEST-- +pass Cva object to template +--TEMPLATE-- +{{ alert.apply({colors: 'primary', sizes: 'sm'}) }} +--DATA-- +return [ + 'alert' => new Twig\Extra\Html\Cva('font-semibold border rounded', [ + 'colors' => [ + 'primary' => 'text-primary', + 'secondary' => 'text-secondary' + ], + 'sizes' => [ + 'sm' => 'text-sm', + 'lg' => 'text-lg' + ] + ]) +]; +--EXPECT-- +font-semibold border rounded text-primary text-sm diff --git a/extra/html-extra/Tests/IntegrationTest.php b/extra/html-extra/Tests/IntegrationTest.php index 8f464c152e0..8e2f94e38b9 100644 --- a/extra/html-extra/Tests/IntegrationTest.php +++ b/extra/html-extra/Tests/IntegrationTest.php @@ -23,7 +23,7 @@ public function getExtensions() ]; } - public function getFixturesDir() + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } diff --git a/extra/html-extra/Tests/LegacyFunctionsTest.php b/extra/html-extra/Tests/LegacyFunctionsTest.php new file mode 100644 index 00000000000..accf8afb39c --- /dev/null +++ b/extra/html-extra/Tests/LegacyFunctionsTest.php @@ -0,0 +1,26 @@ +assertSame(HtmlExtension::htmlClasses(['charset' => 'utf-8']), twig_html_classes(['charset' => 'utf-8'])); + } +} diff --git a/extra/html-extra/composer.json b/extra/html-extra/composer.json index 97d87f7e1b1..db43e18bbdf 100644 --- a/extra/html-extra/composer.json +++ b/extra/html-extra/composer.json @@ -15,22 +15,19 @@ } ], "require": { - "php": ">=7.1.3", - "symfony/mime": "^4.4|^5.0|^6.0", - "twig/twig": "^2.7|^3.0" + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/mime": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { + "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\Html\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/html-extra/phpunit.xml.dist b/extra/html-extra/phpunit.xml.dist index 83a13c728a4..1b088ff266e 100644 --- a/extra/html-extra/phpunit.xml.dist +++ b/extra/html-extra/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + ./Tests/ + + diff --git a/extra/inky-extra/InkyExtension.php b/extra/inky-extra/InkyExtension.php index 1ee2b515660..b8ac22da9e9 100644 --- a/extra/inky-extra/InkyExtension.php +++ b/extra/inky-extra/InkyExtension.php @@ -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. @@ -17,15 +17,18 @@ class InkyExtension extends AbstractExtension { - public function getFilters() + public function getFilters(): array { return [ - new TwigFilter('inky_to_html', 'Twig\\Extra\\Inky\\twig_inky', ['is_safe' => ['html']]), + new TwigFilter('inky_to_html', [self::class, 'inky'], ['is_safe' => ['html']]), ]; } -} -function twig_inky(string $body): string -{ - return false === ($html = Pinky\transformString($body)->saveHTML()) ? '' : $html; + /** + * @internal + */ + public static function inky(string $body): string + { + return false === ($html = Pinky\transformString($body)->saveHTML()) ? '' : $html; + } } diff --git a/extra/inky-extra/LICENSE b/extra/inky-extra/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/inky-extra/LICENSE +++ b/extra/inky-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/inky-extra/Resources/functions.php b/extra/inky-extra/Resources/functions.php new file mode 100644 index 00000000000..9ebe920454b --- /dev/null +++ b/extra/inky-extra/Resources/functions.php @@ -0,0 +1,24 @@ +assertSame(InkyExtension::inky('

    Foo

    '), twig_inky('

    Foo

    ')); + } +} diff --git a/extra/inky-extra/composer.json b/extra/inky-extra/composer.json index abd7d399aa6..3d6ed29b892 100644 --- a/extra/inky-extra/composer.json +++ b/extra/inky-extra/composer.json @@ -15,22 +15,19 @@ } ], "require": { - "php": ">=7.1.3", + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", "lorenzo/pinky": "^1.0.5", - "twig/twig": "^2.7|^3.0" + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { + "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\Inky\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/inky-extra/phpunit.xml.dist b/extra/inky-extra/phpunit.xml.dist index 1a317bf68d0..7f759dd3d5c 100644 --- a/extra/inky-extra/phpunit.xml.dist +++ b/extra/inky-extra/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + ./Tests/ + + diff --git a/extra/intl-extra/IntlExtension.php b/extra/intl-extra/IntlExtension.php index 1fce0c7888d..43fd1c66e55 100644 --- a/extra/intl-extra/IntlExtension.php +++ b/extra/intl-extra/IntlExtension.php @@ -16,16 +16,47 @@ use Symfony\Component\Intl\Exception\MissingResourceException; use Symfony\Component\Intl\Languages; use Symfony\Component\Intl\Locales; +use Symfony\Component\Intl\Scripts; use Symfony\Component\Intl\Timezones; use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; +use Twig\Extension\CoreExtension; use Twig\TwigFilter; use Twig\TwigFunction; final class IntlExtension extends AbstractExtension { - private const DATE_FORMATS = [ + private static function availableDateFormats(): array + { + static $formats = null; + + if (null !== $formats) { + return $formats; + } + + $formats = [ + 'none' => \IntlDateFormatter::NONE, + 'short' => \IntlDateFormatter::SHORT, + 'medium' => \IntlDateFormatter::MEDIUM, + 'long' => \IntlDateFormatter::LONG, + 'full' => \IntlDateFormatter::FULL, + ]; + + // Assuming that each `RELATIVE_*` constant are defined when one of them is. + if (\defined('IntlDateFormatter::RELATIVE_FULL')) { + $formats = array_merge($formats, [ + 'relative_short' => \IntlDateFormatter::RELATIVE_SHORT, + 'relative_medium' => \IntlDateFormatter::RELATIVE_MEDIUM, + 'relative_long' => \IntlDateFormatter::RELATIVE_LONG, + 'relative_full' => \IntlDateFormatter::RELATIVE_FULL, + ]); + } + + return $formats; + } + + private const TIME_FORMATS = [ 'none' => \IntlDateFormatter::NONE, 'short' => \IntlDateFormatter::SHORT, 'medium' => \IntlDateFormatter::MEDIUM, @@ -37,7 +68,6 @@ final class IntlExtension extends AbstractExtension 'int32' => \NumberFormatter::TYPE_INT32, 'int64' => \NumberFormatter::TYPE_INT64, 'double' => \NumberFormatter::TYPE_DOUBLE, - 'currency' => \NumberFormatter::TYPE_CURRENCY, ]; private const NUMBER_STYLES = [ 'decimal' => \NumberFormatter::DECIMAL, @@ -120,13 +150,13 @@ final class IntlExtension extends AbstractExtension private $dateFormatterPrototype; private $numberFormatterPrototype; - public function __construct(\IntlDateFormatter $dateFormatterPrototype = null, \NumberFormatter $numberFormatterPrototype = null) + public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null) { $this->dateFormatterPrototype = $dateFormatterPrototype; $this->numberFormatterPrototype = $numberFormatterPrototype; } - public function getFilters() + public function getFilters(): array { return [ // internationalized names @@ -147,15 +177,21 @@ public function getFilters() ]; } - public function getFunctions() + public function getFunctions(): array { return [ // internationalized names new TwigFunction('country_timezones', [$this, 'getCountryTimezones']), + new TwigFunction('language_names', [$this, 'getLanguageNames']), + new TwigFunction('script_names', [$this, 'getScriptNames']), + new TwigFunction('country_names', [$this, 'getCountryNames']), + new TwigFunction('locale_names', [$this, 'getLocaleNames']), + new TwigFunction('currency_names', [$this, 'getCurrencyNames']), + new TwigFunction('timezone_names', [$this, 'getTimezoneNames']), ]; } - public function getCountryName(?string $country, string $locale = null): string + public function getCountryName(?string $country, ?string $locale = null): string { if (null === $country) { return ''; @@ -168,7 +204,7 @@ public function getCountryName(?string $country, string $locale = null): string } } - public function getCurrencyName(?string $currency, string $locale = null): string + public function getCurrencyName(?string $currency, ?string $locale = null): string { if (null === $currency) { return ''; @@ -181,7 +217,7 @@ public function getCurrencyName(?string $currency, string $locale = null): strin } } - public function getCurrencySymbol(?string $currency, string $locale = null): string + public function getCurrencySymbol(?string $currency, ?string $locale = null): string { if (null === $currency) { return ''; @@ -194,7 +230,7 @@ public function getCurrencySymbol(?string $currency, string $locale = null): str } } - public function getLanguageName(?string $language, string $locale = null): string + public function getLanguageName(?string $language, ?string $locale = null): string { if (null === $language) { return ''; @@ -207,7 +243,7 @@ public function getLanguageName(?string $language, string $locale = null): strin } } - public function getLocaleName(?string $data, string $locale = null): string + public function getLocaleName(?string $data, ?string $locale = null): string { if (null === $data) { return ''; @@ -220,7 +256,7 @@ public function getLocaleName(?string $data, string $locale = null): string } } - public function getTimezoneName(?string $timezone, string $locale = null): string + public function getTimezoneName(?string $timezone, ?string $locale = null): string { if (null === $timezone) { return ''; @@ -242,7 +278,61 @@ public function getCountryTimezones(string $country): array } } - public function formatCurrency($amount, string $currency, array $attrs = [], string $locale = null): string + public function getLanguageNames(?string $locale = null): array + { + try { + return Languages::getNames($locale); + } catch (MissingResourceException $exception) { + return []; + } + } + + public function getScriptNames(?string $locale = null): array + { + try { + return Scripts::getNames($locale); + } catch (MissingResourceException $exception) { + return []; + } + } + + public function getCountryNames(?string $locale = null): array + { + try { + return Countries::getNames($locale); + } catch (MissingResourceException $exception) { + return []; + } + } + + public function getLocaleNames(?string $locale = null): array + { + try { + return Locales::getNames($locale); + } catch (MissingResourceException $exception) { + return []; + } + } + + public function getCurrencyNames(?string $locale = null): array + { + try { + return Currencies::getNames($locale); + } catch (MissingResourceException $exception) { + return []; + } + } + + public function getTimezoneNames(?string $locale = null): array + { + try { + return Timezones::getNames($locale); + } catch (MissingResourceException $exception) { + return []; + } + } + + public function formatCurrency($amount, string $currency, array $attrs = [], ?string $locale = null): string { $formatter = $this->createNumberFormatter($locale, 'currency', $attrs); @@ -253,10 +343,10 @@ public function formatCurrency($amount, string $currency, array $attrs = [], str return $ret; } - public function formatNumber($number, array $attrs = [], string $style = 'decimal', string $type = 'default', string $locale = null): string + public function formatNumber($number, array $attrs = [], string $style = 'decimal', string $type = 'default', ?string $locale = null): string { if (!isset(self::NUMBER_TYPES[$type])) { - throw new RuntimeError(sprintf('The type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys(self::NUMBER_TYPES)))); + throw new RuntimeError(\sprintf('The type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys(self::NUMBER_TYPES)))); } $formatter = $this->createNumberFormatter($locale, $style, $attrs); @@ -268,7 +358,7 @@ public function formatNumber($number, array $attrs = [], string $style = 'decima return $ret; } - public function formatNumberStyle(string $style, $number, array $attrs = [], string $type = 'default', string $locale = null): string + public function formatNumberStyle(string $style, $number, array $attrs = [], string $type = 'default', ?string $locale = null): string { return $this->formatNumber($number, $attrs, $style, $type, $locale); } @@ -277,10 +367,17 @@ public function formatNumberStyle(string $style, $number, array $attrs = [], str * @param \DateTimeInterface|string|null $date A date or null to use the current time * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ - public function formatDateTime(Environment $env, $date, ?string $dateFormat = 'medium', ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string + public function formatDateTime(Environment $env, $date, ?string $dateFormat = 'medium', ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string { - $date = \twig_date_converter($env, $date, $timezone); - $formatter = $this->createDateFormatter($locale, $dateFormat, $timeFormat, $pattern, $date->getTimezone(), $calendar); + $date = $env->getExtension(CoreExtension::class)->convertDate($date, $timezone); + + $formatterTimezone = $timezone; + if (null === $formatterTimezone || false === $formatterTimezone) { + $formatterTimezone = $date->getTimezone(); + } elseif (\is_string($formatterTimezone)) { + $formatterTimezone = new \DateTimeZone($timezone); + } + $formatter = $this->createDateFormatter($locale, $dateFormat, $timeFormat, $pattern, $formatterTimezone, $calendar); if (false === $ret = $formatter->format($date)) { throw new RuntimeError('Unable to format the given date.'); @@ -293,7 +390,7 @@ public function formatDateTime(Environment $env, $date, ?string $dateFormat = 'm * @param \DateTimeInterface|string|null $date A date or null to use the current time * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ - public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string + public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string { return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale); } @@ -302,39 +399,46 @@ public function formatDate(Environment $env, $date, ?string $dateFormat = 'mediu * @param \DateTimeInterface|string|null $date A date or null to use the current time * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged */ - public function formatTime(Environment $env, $date, ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string + public function formatTime(Environment $env, $date, ?string $timeFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', ?string $locale = null): string { return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale); } - private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, \DateTimeZone $timezone, string $calendar): \IntlDateFormatter + private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, string $calendar): \IntlDateFormatter { - if (null !== $dateFormat && !isset(self::DATE_FORMATS[$dateFormat])) { - throw new RuntimeError(sprintf('The date format "%s" does not exist, known formats are: "%s".', $dateFormat, implode('", "', array_keys(self::DATE_FORMATS)))); + $dateFormats = self::availableDateFormats(); + + if (null !== $dateFormat && !isset($dateFormats[$dateFormat])) { + throw new RuntimeError(\sprintf('The date format "%s" does not exist, known formats are: "%s".', $dateFormat, implode('", "', array_keys($dateFormats)))); } - if (null !== $timeFormat && !isset(self::DATE_FORMATS[$timeFormat])) { - throw new RuntimeError(sprintf('The time format "%s" does not exist, known formats are: "%s".', $timeFormat, implode('", "', array_keys(self::DATE_FORMATS)))); + if (null !== $timeFormat && !isset(self::TIME_FORMATS[$timeFormat])) { + throw new RuntimeError(\sprintf('The time format "%s" does not exist, known formats are: "%s".', $timeFormat, implode('", "', array_keys(self::TIME_FORMATS)))); } if (null === $locale) { - $locale = \Locale::getDefault(); + if ($this->dateFormatterPrototype) { + $locale = $this->dateFormatterPrototype->getLocale(); + } + $locale = $locale ?: \Locale::getDefault(); } $calendar = 'gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL; - $dateFormatValue = self::DATE_FORMATS[$dateFormat] ?? null; - $timeFormatValue = self::DATE_FORMATS[$timeFormat] ?? null; + $dateFormatValue = $dateFormats[$dateFormat] ?? null; + $timeFormatValue = self::TIME_FORMATS[$timeFormat] ?? null; if ($this->dateFormatterPrototype) { $dateFormatValue = $dateFormatValue ?: $this->dateFormatterPrototype->getDateType(); $timeFormatValue = $timeFormatValue ?: $this->dateFormatterPrototype->getTimeType(); - $timezone = $timezone ?: $this->dateFormatterPrototype->getTimeType(); + $timezone = $timezone ?: $this->dateFormatterPrototype->getTimeZone()->toDateTimeZone(); $calendar = $calendar ?: $this->dateFormatterPrototype->getCalendar(); $pattern = $pattern ?: $this->dateFormatterPrototype->getPattern(); } - $hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezone->getName().'|'.$calendar.'|'.$pattern; + $timezoneName = $timezone ? $timezone->getName() : '(none)'; + + $hash = $locale.'|'.$dateFormatValue.'|'.$timeFormatValue.'|'.$timezoneName.'|'.$calendar.'|'.$pattern; if (!isset($this->dateFormatters[$hash])) { $this->dateFormatters[$hash] = new \IntlDateFormatter($locale, $dateFormatValue, $timeFormatValue, $timezone, $calendar, $pattern); @@ -346,7 +450,7 @@ private function createDateFormatter(?string $locale, ?string $dateFormat, ?stri private function createNumberFormatter(?string $locale, string $style, array $attrs = []): \NumberFormatter { if (!isset(self::NUMBER_STYLES[$style])) { - throw new RuntimeError(sprintf('The style "%s" does not exist, known styles are: "%s".', $style, implode('", "', array_keys(self::NUMBER_STYLES)))); + throw new RuntimeError(\sprintf('The style "%s" does not exist, known styles are: "%s".', $style, implode('", "', array_keys(self::NUMBER_STYLES)))); } if (null === $locale) { @@ -388,18 +492,18 @@ private function createNumberFormatter(?string $locale, string $style, array $at foreach ($attrs as $name => $value) { if (!isset(self::NUMBER_ATTRIBUTES[$name])) { - throw new RuntimeError(sprintf('The number formatter attribute "%s" does not exist, known attributes are: "%s".', $name, implode('", "', array_keys(self::NUMBER_ATTRIBUTES)))); + throw new RuntimeError(\sprintf('The number formatter attribute "%s" does not exist, known attributes are: "%s".', $name, implode('", "', array_keys(self::NUMBER_ATTRIBUTES)))); } if ('rounding_mode' === $name) { if (!isset(self::NUMBER_ROUNDING_ATTRIBUTES[$value])) { - throw new RuntimeError(sprintf('The number formatter rounding mode "%s" does not exist, known modes are: "%s".', $value, implode('", "', array_keys(self::NUMBER_ROUNDING_ATTRIBUTES)))); + throw new RuntimeError(\sprintf('The number formatter rounding mode "%s" does not exist, known modes are: "%s".', $value, implode('", "', array_keys(self::NUMBER_ROUNDING_ATTRIBUTES)))); } $value = self::NUMBER_ROUNDING_ATTRIBUTES[$value]; } elseif ('padding_position' === $name) { if (!isset(self::NUMBER_PADDING_ATTRIBUTES[$value])) { - throw new RuntimeError(sprintf('The number formatter padding position "%s" does not exist, known positions are: "%s".', $value, implode('", "', array_keys(self::NUMBER_PADDING_ATTRIBUTES)))); + throw new RuntimeError(\sprintf('The number formatter padding position "%s" does not exist, known positions are: "%s".', $value, implode('", "', array_keys(self::NUMBER_PADDING_ATTRIBUTES)))); } $value = self::NUMBER_PADDING_ATTRIBUTES[$value]; diff --git a/extra/intl-extra/LICENSE b/extra/intl-extra/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/intl-extra/LICENSE +++ b/extra/intl-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/intl-extra/Tests/Fixtures/country_names.test b/extra/intl-extra/Tests/Fixtures/country_names.test new file mode 100644 index 00000000000..f3cb079741d --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/country_names.test @@ -0,0 +1,16 @@ +--TEST-- +"country_names" function +--TEMPLATE-- +{{ country_names('UNKNOWN')|length }} +{{ country_names()|length }} +{{ country_names('fr')|length }} +{{ country_names()['BE'] }} +{{ country_names('fr')['BE'] }} +--DATA-- +return []; +--EXPECT-- +0 +249 +249 +Belgium +Belgique diff --git a/extra/intl-extra/Tests/Fixtures/country_timezones.test b/extra/intl-extra/Tests/Fixtures/country_timezones.test index 3c81440c11e..04af954ab56 100644 --- a/extra/intl-extra/Tests/Fixtures/country_timezones.test +++ b/extra/intl-extra/Tests/Fixtures/country_timezones.test @@ -3,10 +3,10 @@ --TEMPLATE-- {{ country_timezones('UNKNOWN')|length }} {{ country_timezones('FR')|join(', ') }} -{{ country_timezones('US')|join(', ') }} +{{ country_timezones('US')[0:2]|join(', ') }} --DATA-- return []; --EXPECT-- 0 Europe/Paris -America/Adak, America/Anchorage, America/Boise, America/Chicago, America/Denver, America/Detroit, America/Indiana/Knox, America/Indiana/Marengo, America/Indiana/Petersburg, America/Indiana/Tell_City, America/Indiana/Vevay, America/Indiana/Vincennes, America/Indiana/Winamac, America/Indianapolis, America/Juneau, America/Kentucky/Monticello, America/Los_Angeles, America/Louisville, America/Menominee, America/Metlakatla, America/New_York, America/Nome, America/North_Dakota/Beulah, America/North_Dakota/Center, America/North_Dakota/New_Salem, America/Phoenix, America/Sitka, America/Yakutat, Pacific/Honolulu +America/Adak, America/Anchorage diff --git a/extra/intl-extra/Tests/Fixtures/currency_names.test b/extra/intl-extra/Tests/Fixtures/currency_names.test new file mode 100644 index 00000000000..dc3e9d819ff --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/currency_names.test @@ -0,0 +1,16 @@ +--TEST-- +"currency_names" function +--TEMPLATE-- +{{ currency_names('UNKNOWN')|length }} +{{ currency_names()|length }} +{{ currency_names('fr')|length }} +{{ currency_names()['USD'] }} +{{ currency_names('fr')['USD'] }} +--DATA-- +return []; +--EXPECT-- +0 +294 +294 +US Dollar +dollar des États-Unis diff --git a/extra/intl-extra/Tests/Fixtures/format_date.test b/extra/intl-extra/Tests/Fixtures/format_date.test index 457f345d9d2..08cd873d417 100644 --- a/extra/intl-extra/Tests/Fixtures/format_date.test +++ b/extra/intl-extra/Tests/Fixtures/format_date.test @@ -1,5 +1,7 @@ --TEST-- "format_date" filter +--CONDITION-- +version_compare(Symfony\Component\Intl\Intl::getIcuVersion(), '72.1', '<') --TEMPLATE-- {{ '2019-08-07 23:39:12'|format_datetime() }} {{ '2019-08-07 23:39:12'|format_datetime(locale='fr') }} @@ -15,10 +17,10 @@ return []; --EXPECT-- Aug 7, 2019, 11:39:12 PM -7 août 2019 à 23:39:12 +7 août 2019, 23:39:12 23:39 07/08/2019 -mercredi 7 août 2019 à 23:39:12 Temps universel coordonné +mercredi 7 août 2019 à 23:39:12 temps universel coordonné 11 oclock PM, Coordinated Universal Time Aug 7, 2019 diff --git a/extra/intl-extra/Tests/Fixtures/format_date_ICU72.test b/extra/intl-extra/Tests/Fixtures/format_date_ICU72.test new file mode 100644 index 00000000000..ea427605df2 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/format_date_ICU72.test @@ -0,0 +1,28 @@ +--TEST-- +"format_date" filter +--CONDITION-- +version_compare(Symfony\Component\Intl\Intl::getIcuVersion(), '72.1', '>=') +--TEMPLATE-- +{{ '2019-08-07 23:39:12'|format_datetime() }} +{{ '2019-08-07 23:39:12'|format_datetime(locale='fr') }} +{{ '2019-08-07 23:39:12'|format_datetime('none', 'short', locale='fr') }} +{{ '2019-08-07 23:39:12'|format_datetime('short', 'none', locale='fr') }} +{{ '2019-08-07 23:39:12'|format_datetime('full', 'full', locale='fr') }} +{{ '2019-08-07 23:39:12'|format_datetime(pattern="hh 'oclock' a, zzzz") }} + +{{ '2019-08-07 23:39:12'|format_date }} +{{ '2019-08-07 23:39:12'|format_date(locale='fr') }} +{{ '2019-08-07 23:39:12'|format_time }} +--DATA-- +return []; +--EXPECT-- +Aug 7, 2019, 11:39:12 PM +7 août 2019, 23:39:12 +23:39 +07/08/2019 +mercredi 7 août 2019 à 23:39:12 temps universel coordonné +11 oclock PM, Coordinated Universal Time + +Aug 7, 2019 +7 août 2019 +11:39:12 PM diff --git a/extra/intl-extra/Tests/Fixtures/format_date_php8.test b/extra/intl-extra/Tests/Fixtures/format_date_php8.test new file mode 100644 index 00000000000..5d694e52ae1 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/format_date_php8.test @@ -0,0 +1,12 @@ +--TEST-- +"format_date" filter +--CONDITION-- +PHP_VERSION_ID >= 80000 && version_compare(Symfony\Component\Intl\Intl::getIcuVersion(), '72.1', '<') +--TEMPLATE-- +{{ 'today 23:39:12'|format_datetime('relative_short', 'none', locale='fr') }} +{{ 'today 23:39:12'|format_datetime('relative_full', 'full', locale='fr') }} +--DATA-- +return []; +--EXPECT-- +aujourd’hui +aujourd’hui à 23:39:12 temps universel coordonné diff --git a/extra/intl-extra/Tests/Fixtures/format_date_php8_ICU72.test b/extra/intl-extra/Tests/Fixtures/format_date_php8_ICU72.test new file mode 100644 index 00000000000..3162ae54d93 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/format_date_php8_ICU72.test @@ -0,0 +1,12 @@ +--TEST-- +"format_date" filter +--CONDITION-- +PHP_VERSION_ID >= 80000 && version_compare(Symfony\Component\Intl\Intl::getIcuVersion(), '72.1', '>=') +--TEMPLATE-- +{{ 'today 23:39:12'|format_datetime('relative_short', 'none', locale='fr') }} +{{ 'today 23:39:12'|format_datetime('relative_full', 'full', locale='fr') }} +--DATA-- +return []; +--EXPECT-- +aujourd’hui +aujourd’hui, 23:39:12 temps universel coordonné diff --git a/extra/intl-extra/Tests/Fixtures/language_names.test b/extra/intl-extra/Tests/Fixtures/language_names.test new file mode 100644 index 00000000000..ce5a95f49a3 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/language_names.test @@ -0,0 +1,16 @@ +--TEST-- +"language_names" function +--TEMPLATE-- +{{ language_names('UNKNOWN')|length }} +{{ language_names()|length > 600 ? 'ok' : 'ko' }} +{{ language_names('fr')|length > 600 ? 'ok' : 'ko' }} +{{ language_names()['fr'] }} +{{ language_names('fr')['fr'] }} +--DATA-- +return []; +--EXPECT-- +0 +ok +ok +French +français diff --git a/extra/intl-extra/Tests/Fixtures/locale_names.test b/extra/intl-extra/Tests/Fixtures/locale_names.test new file mode 100644 index 00000000000..202e43aae45 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/locale_names.test @@ -0,0 +1,16 @@ +--TEST-- +"locale_names" function +--TEMPLATE-- +{{ locale_names('UNKNOWN')|length }} +{{ locale_names()|length > 600 ? 'ok' : 'ko' }} +{{ locale_names('fr')|length > 600 ? 'ok' : 'ko' }} +{{ locale_names()['fr'] }} +{{ locale_names('fr')['fr'] }} +--DATA-- +return []; +--EXPECT-- +0 +ok +ok +French +français diff --git a/extra/intl-extra/Tests/Fixtures/script_names.test b/extra/intl-extra/Tests/Fixtures/script_names.test new file mode 100644 index 00000000000..76d7ddb1735 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/script_names.test @@ -0,0 +1,16 @@ +--TEST-- +"script_names" function +--TEMPLATE-- +{{ script_names('UNKNOWN')|length }} +{{ script_names()|length }} +{{ script_names('fr')|length }} +{{ script_names()['Marc'] }} +{{ script_names('fr')['Marc'] }} +--DATA-- +return []; +--EXPECT-- +0 +208 +208 +Marchen +Marchen diff --git a/extra/intl-extra/Tests/Fixtures/timezone_names.test b/extra/intl-extra/Tests/Fixtures/timezone_names.test new file mode 100644 index 00000000000..c8ee51c6b00 --- /dev/null +++ b/extra/intl-extra/Tests/Fixtures/timezone_names.test @@ -0,0 +1,16 @@ +--TEST-- +"timezone_names" function +--TEMPLATE-- +{{ timezone_names('UNKNOWN')|length }} +{{ timezone_names()|length > 400 ? 'ok' : 'ko' }} +{{ timezone_names('fr')|length > 400 ? 'ok' : 'ko' }} +{{ timezone_names()['Europe/Paris'] }} +{{ timezone_names('fr')['Europe/Paris'] }} +--DATA-- +return []; +--EXPECT-- +0 +ok +ok +Central European Time (Paris) +heure d’Europe centrale (Paris) diff --git a/extra/intl-extra/Tests/IntegrationTest.php b/extra/intl-extra/Tests/IntegrationTest.php index 7b191bacd01..fa22b570801 100644 --- a/extra/intl-extra/Tests/IntegrationTest.php +++ b/extra/intl-extra/Tests/IntegrationTest.php @@ -23,7 +23,7 @@ public function getExtensions() ]; } - public function getFixturesDir() + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } diff --git a/extra/intl-extra/Tests/IntlExtensionTest.php b/extra/intl-extra/Tests/IntlExtensionTest.php index f2637620693..91aa9e84f01 100644 --- a/extra/intl-extra/Tests/IntlExtensionTest.php +++ b/extra/intl-extra/Tests/IntlExtensionTest.php @@ -12,17 +12,88 @@ namespace Twig\Extra\Intl\Tests; use PHPUnit\Framework\TestCase; +use Twig\Environment; +use Twig\Extension\CoreExtension; use Twig\Extra\Intl\IntlExtension; +use Twig\Loader\ArrayLoader; class IntlExtensionTest extends TestCase { + public function testFormatterWithoutProto() + { + $ext = new IntlExtension(); + $env = new Environment(new ArrayLoader()); + + $this->assertSame('12.346', $ext->formatNumber('12.3456')); + $this->assertStringStartsWith( + 'Feb 20, 2020, 1:37:00', + $ext->formatDateTime($env, new \DateTime('2020-02-20T13:37:00+00:00')) + ); + } + + public function testFormatterWithoutProtoFallsBackToCoreExtensionTimezone() + { + $ext = new IntlExtension(); + $env = new Environment(new ArrayLoader()); + // EET is always +2 without changes for daylight saving time + // so it has a fixed difference to UTC + $env->getExtension(CoreExtension::class)->setTimezone('EET'); + + $this->assertStringStartsWith( + 'Feb 20, 2020, 3:37:00', + $ext->formatDateTime($env, new \DateTime('2020-02-20T13:37:00+00:00', new \DateTimeZone('UTC'))) + ); + } + + public function testFormatterWithoutProtoSkipTimezoneConverter() + { + $ext = new IntlExtension(); + $env = new Environment(new ArrayLoader()); + // EET is always +2 without changes for daylight saving time + // so it has a fixed difference to UTC + $env->getExtension(CoreExtension::class)->setTimezone('EET'); + + $this->assertStringStartsWith( + 'Feb 20, 2020, 1:37:00', + $ext->formatDateTime($env, new \DateTime('2020-02-20T13:37:00+00:00', new \DateTimeZone('UTC')), 'medium', 'medium', '', false) + ); + } + public function testFormatterProto() { - $dateFormatterProto = new \IntlDateFormatter('fr', \IntlDateFormatter::FULL, \IntlDateFormatter::FULL); + $dateFormatterProto = new \IntlDateFormatter('fr', \IntlDateFormatter::FULL, \IntlDateFormatter::FULL, new \DateTimeZone('Europe/Paris')); $numberFormatterProto = new \NumberFormatter('fr', \NumberFormatter::DECIMAL); $numberFormatterProto->setTextAttribute(\NumberFormatter::POSITIVE_PREFIX, '++'); $numberFormatterProto->setAttribute(\NumberFormatter::FRACTION_DIGITS, 1); $ext = new IntlExtension($dateFormatterProto, $numberFormatterProto); + $env = new Environment(new ArrayLoader()); + $this->assertSame('++12,3', $ext->formatNumber('12.3456')); + $this->assertContains( + $ext->formatDateTime($env, new \DateTime('2020-02-20T13:37:00+00:00', new \DateTimeZone('Europe/Paris'))), + [ + 'jeudi 20 février 2020 à 13:37:00 heure normale d’Europe centrale', + 'jeudi 20 février 2020 à 13:37:00 temps universel coordonné', + ] + ); + } + + public function testFormatterOverridenProto() + { + $dateFormatterProto = new \IntlDateFormatter('fr', \IntlDateFormatter::FULL, \IntlDateFormatter::FULL, new \DateTimeZone('Europe/Paris')); + $numberFormatterProto = new \NumberFormatter('fr', \NumberFormatter::DECIMAL); + $numberFormatterProto->setTextAttribute(\NumberFormatter::POSITIVE_PREFIX, '++'); + $numberFormatterProto->setAttribute(\NumberFormatter::FRACTION_DIGITS, 1); + $ext = new IntlExtension($dateFormatterProto, $numberFormatterProto); + $env = new Environment(new ArrayLoader()); + + $this->assertSame( + 'twelve point three', + $ext->formatNumber('12.3456', [], 'spellout', 'default', 'en_US') + ); + $this->assertSame( + '2020-02-20 13:37:00', + $ext->formatDateTime($env, new \DateTime('2020-02-20T13:37:00+00:00'), 'short', 'short', 'yyyy-MM-dd HH:mm:ss', 'UTC', 'gregorian', 'en_US') + ); } } diff --git a/extra/intl-extra/composer.json b/extra/intl-extra/composer.json index abd60d38606..21e3956b30f 100644 --- a/extra/intl-extra/composer.json +++ b/extra/intl-extra/composer.json @@ -15,22 +15,17 @@ } ], "require": { - "php": ">=7.1.3", - "twig/twig": "^2.7|^3.0", - "symfony/intl": "^4.4|^5.0|^6.0" + "php": ">=8.1.0", + "twig/twig": "^3.13|^4.0", + "symfony/intl": "^5.4|^6.4|^7.0|^8.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\Intl\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/intl-extra/phpunit.xml.dist b/extra/intl-extra/phpunit.xml.dist index be06f3797a5..d33987d1649 100644 --- a/extra/intl-extra/phpunit.xml.dist +++ b/extra/intl-extra/phpunit.xml.dist @@ -1,31 +1,21 @@ - - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + + ./Tests/ + + diff --git a/extra/markdown-extra/DefaultMarkdown.php b/extra/markdown-extra/DefaultMarkdown.php index 6650a661a53..a20993d45da 100644 --- a/extra/markdown-extra/DefaultMarkdown.php +++ b/extra/markdown-extra/DefaultMarkdown.php @@ -13,7 +13,6 @@ use League\CommonMark\CommonMarkConverter; use Michelf\MarkdownExtra; -use Parsedown; class DefaultMarkdown implements MarkdownInterface { @@ -25,7 +24,7 @@ public function __construct() $this->converter = new LeagueMarkdown(); } elseif (class_exists(MarkdownExtra::class)) { $this->converter = new MichelfMarkdown(); - } elseif (class_exists(Parsedown::class)) { + } elseif (class_exists(\Parsedown::class)) { $this->converter = new ErusevMarkdown(); } else { throw new \LogicException('You cannot use the "markdown_to_html" filter as no Markdown library is available; try running "composer require league/commonmark".'); diff --git a/extra/markdown-extra/ErusevMarkdown.php b/extra/markdown-extra/ErusevMarkdown.php index f4f7e1c48fb..923cf0eebcd 100644 --- a/extra/markdown-extra/ErusevMarkdown.php +++ b/extra/markdown-extra/ErusevMarkdown.php @@ -11,15 +11,13 @@ namespace Twig\Extra\Markdown; -use Parsedown; - class ErusevMarkdown implements MarkdownInterface { private $converter; - public function __construct(Parsedown $converter = null) + public function __construct(?\Parsedown $converter = null) { - $this->converter = $converter ?: new Parsedown(); + $this->converter = $converter ?: new \Parsedown(); } public function convert(string $body): string diff --git a/extra/markdown-extra/LICENSE b/extra/markdown-extra/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/markdown-extra/LICENSE +++ b/extra/markdown-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/markdown-extra/LeagueMarkdown.php b/extra/markdown-extra/LeagueMarkdown.php index 2390901c01e..edd2bfd6c01 100644 --- a/extra/markdown-extra/LeagueMarkdown.php +++ b/extra/markdown-extra/LeagueMarkdown.php @@ -12,13 +12,14 @@ namespace Twig\Extra\Markdown; use League\CommonMark\CommonMarkConverter; +use League\CommonMark\MarkdownConverter; class LeagueMarkdown implements MarkdownInterface { private $converter; private $legacySupport; - public function __construct(CommonMarkConverter $converter = null) + public function __construct(?MarkdownConverter $converter = null) { $this->converter = $converter ?: new CommonMarkConverter(); $this->legacySupport = !method_exists($this->converter, 'convert'); diff --git a/extra/markdown-extra/MarkdownExtension.php b/extra/markdown-extra/MarkdownExtension.php index 8f249ce6d86..7bc737a29f9 100644 --- a/extra/markdown-extra/MarkdownExtension.php +++ b/extra/markdown-extra/MarkdownExtension.php @@ -17,32 +17,35 @@ final class MarkdownExtension extends AbstractExtension { - public function getFilters() + public function getFilters(): array { return [ new TwigFilter('markdown_to_html', ['Twig\\Extra\\Markdown\\MarkdownRuntime', 'convert'], ['is_safe' => ['all']]), - new TwigFilter('html_to_markdown', 'Twig\\Extra\\Markdown\\twig_html_to_markdown', ['is_safe' => ['all']]), + new TwigFilter('html_to_markdown', [self::class, 'htmlToMarkdown'], ['is_safe' => ['all']]), ]; } -} -function twig_html_to_markdown(string $body, array $options = []): string -{ - static $converters; + /** + * @internal + */ + public static function htmlToMarkdown(string $body, array $options = []): string + { + static $converters; - if (!class_exists(HtmlConverter::class)) { - throw new \LogicException('You cannot use the "html_to_markdown" filter as league/html-to-markdown is not installed; try running "composer require league/html-to-markdown".'); - } + if (!class_exists(HtmlConverter::class)) { + throw new \LogicException('You cannot use the "html_to_markdown" filter as league/html-to-markdown is not installed; try running "composer require league/html-to-markdown".'); + } - $options = $options + [ - 'hard_break' => true, - 'strip_tags' => true, - 'remove_nodes' => 'head style', - ]; + $options += [ + 'hard_break' => true, + 'strip_tags' => true, + 'remove_nodes' => 'head style', + ]; - if (!isset($converters[$key = serialize($options)])) { - $converters[$key] = new HtmlConverter($options); - } + if (!isset($converters[$key = serialize($options)])) { + $converters[$key] = new HtmlConverter($options); + } - return $converters[$key]->convert($body); + return $converters[$key]->convert($body); + } } diff --git a/extra/markdown-extra/MichelfMarkdown.php b/extra/markdown-extra/MichelfMarkdown.php index 2660a7f0440..0acc3a3a41d 100644 --- a/extra/markdown-extra/MichelfMarkdown.php +++ b/extra/markdown-extra/MichelfMarkdown.php @@ -17,7 +17,7 @@ class MichelfMarkdown implements MarkdownInterface { private $converter; - public function __construct(MarkdownExtra $converter = null) + public function __construct(?MarkdownExtra $converter = null) { if (null === $converter) { $converter = new MarkdownExtra(); diff --git a/extra/markdown-extra/Resources/functions.php b/extra/markdown-extra/Resources/functions.php new file mode 100644 index 00000000000..cf498364df0 --- /dev/null +++ b/extra/markdown-extra/Resources/functions.php @@ -0,0 +1,24 @@ + $template, 'html' => <<addExtension(new MarkdownExtension()); $twig->addRuntimeLoader(new class($class) implements RuntimeLoaderInterface { @@ -48,18 +48,16 @@ public function __construct(string $class) $this->class = $class; } - public function load($c) + public function load(string $c): ?object { - if (MarkdownRuntime::class === $c) { - return new $c(new $this->class()); - } + return MarkdownRuntime::class === $c ? new $c(new $this->class()) : null; } }); $this->assertMatchesRegularExpression('{'.$expected.'}m', trim($twig->render('index'))); } } - public function getMarkdownTests() + public static function getMarkdownTests() { return [ [<<Hello\n+

    Great!

    "], + , "

    Hello

    \n+

    Great!

    "], [<<Hello\n+

    Great!

    "], + , "

    Hello

    \n+

    Great!

    "], ["{{ include('html')|markdown_to_html }}", "

    Hello

    \n+

    Great!

    "], ]; } diff --git a/extra/markdown-extra/Tests/IntegrationTest.php b/extra/markdown-extra/Tests/IntegrationTest.php index 7474ec7693c..7db95c9190f 100644 --- a/extra/markdown-extra/Tests/IntegrationTest.php +++ b/extra/markdown-extra/Tests/IntegrationTest.php @@ -23,7 +23,7 @@ public function getExtensions() ]; } - public function getFixturesDir() + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } diff --git a/extra/markdown-extra/Tests/LegacyFunctionsTest.php b/extra/markdown-extra/Tests/LegacyFunctionsTest.php new file mode 100644 index 00000000000..19a861de3ef --- /dev/null +++ b/extra/markdown-extra/Tests/LegacyFunctionsTest.php @@ -0,0 +1,29 @@ +assertSame(MarkdownExtension::htmlToMarkdown('

    foo

    '), html_to_markdown('

    foo

    ')); + } +} diff --git a/extra/markdown-extra/composer.json b/extra/markdown-extra/composer.json index 45382b62f7a..703b25c20fb 100644 --- a/extra/markdown-extra/composer.json +++ b/extra/markdown-extra/composer.json @@ -15,25 +15,22 @@ } ], "require": { - "php": ">=7.1.3", - "twig/twig": "^2.7|^3.0" + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0", - "erusev/parsedown": "^1.7", - "league/commonmark": "^1.0|^2.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "erusev/parsedown": "dev-master as 1.x-dev", + "league/commonmark": "^2.7", "league/html-to-markdown": "^4.8|^5.0", "michelf/php-markdown": "^1.8|^2.0" }, "autoload": { + "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\Markdown\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/markdown-extra/phpunit.xml.dist b/extra/markdown-extra/phpunit.xml.dist index cc7b8577ddc..a40846ed435 100644 --- a/extra/markdown-extra/phpunit.xml.dist +++ b/extra/markdown-extra/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + ./Tests/ + + diff --git a/extra/string-extra/LICENSE b/extra/string-extra/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/string-extra/LICENSE +++ b/extra/string-extra/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/string-extra/README.md b/extra/string-extra/README.md index 6687723bfae..c5a8ca4b9d9 100644 --- a/extra/string-extra/README.md +++ b/extra/string-extra/README.md @@ -2,15 +2,20 @@ String Extension ================ This package is a Twig extension that provides integration with the Symfony -String component. +String component. It provides the following filters: -It provides a [`u`][1] filter that wraps a text in a `UnicodeString` -object to give access to [methods of the class][2]. + * [`u`][1]: Wraps a text in a `UnicodeString` object to give access to +[methods of the class][2]. -It also provides a [`slug`][3] filter which is simply a wrapper for the -[`AsciiSlugger`][4]'s `slug` method. + * [`slug`][3]: Wraps the [`AsciiSlugger`][4]'s `slug` method. + + * [`singular`][5] and [`plural`][6]: Wraps the [`Inflector`][7] `singularize` + and `pluralize` methods. [1]: https://twig.symfony.com/u [2]: https://symfony.com/doc/current/components/string.html [3]: https://twig.symfony.com/slug [4]: https://symfony.com/doc/current/components/string.html#slugger +[5]: https://twig.symfony.com/singular +[6]: https://twig.symfony.com/plural +[7]: https://symfony.com/doc/current/components/string.html#inflector diff --git a/extra/string-extra/StringExtension.php b/extra/string-extra/StringExtension.php index 7b5d0049204..3ee7b7f2e65 100644 --- a/extra/string-extra/StringExtension.php +++ b/extra/string-extra/StringExtension.php @@ -12,26 +12,36 @@ namespace Twig\Extra\String; use Symfony\Component\String\AbstractUnicodeString; +use Symfony\Component\String\Inflector\EnglishInflector; +use Symfony\Component\String\Inflector\FrenchInflector; +use Symfony\Component\String\Inflector\InflectorInterface; +use Symfony\Component\String\Inflector\SpanishInflector; use Symfony\Component\String\Slugger\AsciiSlugger; use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\String\UnicodeString; +use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; final class StringExtension extends AbstractExtension { private $slugger; + private $englishInflector; + private $spanishInflector; + private $frenchInflector; - public function __construct(SluggerInterface $slugger = null) + public function __construct(?SluggerInterface $slugger = null) { $this->slugger = $slugger ?: new AsciiSlugger(); } - public function getFilters() + public function getFilters(): array { return [ new TwigFilter('u', [$this, 'createUnicodeString']), new TwigFilter('slug', [$this, 'createSlug']), + new TwigFilter('plural', [$this, 'plural']), + new TwigFilter('singular', [$this, 'singular']), ]; } @@ -44,4 +54,46 @@ public function createSlug(string $string, string $separator = '-', ?string $loc { return $this->slugger->slug($string, $separator, $locale); } + + /** + * @return array|string + */ + public function plural(string $value, string $locale = 'en', bool $all = false) + { + if ($all) { + return $this->getInflector($locale)->pluralize($value); + } + + return $this->getInflector($locale)->pluralize($value)[0]; + } + + /** + * @return array|string + */ + public function singular(string $value, string $locale = 'en', bool $all = false) + { + if ($all) { + return $this->getInflector($locale)->singularize($value); + } + + return $this->getInflector($locale)->singularize($value)[0]; + } + + private function getInflector(string $locale): InflectorInterface + { + switch ($locale) { + case 'en': + return $this->englishInflector ?? $this->englishInflector = new EnglishInflector(); + case 'es': + if (!class_exists(SpanishInflector::class)) { + throw new RuntimeError('SpanishInflector is not available.'); + } + + return $this->spanishInflector ?? $this->spanishInflector = new SpanishInflector(); + case 'fr': + return $this->frenchInflector ?? $this->frenchInflector = new FrenchInflector(); + default: + throw new \InvalidArgumentException(\sprintf('Locale "%s" is not supported.', $locale)); + } + } } diff --git a/extra/string-extra/Tests/Fixtures/plural-invalid-language.test b/extra/string-extra/Tests/Fixtures/plural-invalid-language.test new file mode 100755 index 00000000000..9e3851daac4 --- /dev/null +++ b/extra/string-extra/Tests/Fixtures/plural-invalid-language.test @@ -0,0 +1,10 @@ +--TEST-- +"plural" filter +--TEMPLATE-- +{{ 'partition'|plural('it') }} + +--DATA-- +return [] + +--EXCEPTION-- +Twig\Error\RuntimeError: An exception has been thrown during the rendering of a template ("Locale "it" is not supported.") in "index.twig" at line 2. \ No newline at end of file diff --git a/extra/string-extra/Tests/Fixtures/plural.test b/extra/string-extra/Tests/Fixtures/plural.test new file mode 100755 index 00000000000..b561e2ad0d6 --- /dev/null +++ b/extra/string-extra/Tests/Fixtures/plural.test @@ -0,0 +1,15 @@ +--TEST-- +"plural" filter +--TEMPLATE-- +{{ 'partition'|plural('fr') }} +{{ 'partition'|plural('fr', all=true)|join(',') }} +{{ 'person'|plural('fr') }} +{{ 'person'|plural('en', all=true)|join(',') }} + +--DATA-- +return [] +--EXPECT-- +partitions +partitions +persons +persons,people diff --git a/extra/string-extra/Tests/Fixtures/plural_es.test b/extra/string-extra/Tests/Fixtures/plural_es.test new file mode 100755 index 00000000000..55f82191f75 --- /dev/null +++ b/extra/string-extra/Tests/Fixtures/plural_es.test @@ -0,0 +1,13 @@ +--TEST-- +"plural" filter +--CONDITION-- +class_exists('Symfony\Component\String\Inflector\SpanishInflector') +--TEMPLATE-- +{{ 'avión'|plural('es') }} +{{ 'avión'|plural('es', all=true)|join(',') }} + +--DATA-- +return [] +--EXPECT-- +aviones +aviones diff --git a/extra/string-extra/Tests/Fixtures/singular-invalid-language.test b/extra/string-extra/Tests/Fixtures/singular-invalid-language.test new file mode 100755 index 00000000000..deaa6fbffb5 --- /dev/null +++ b/extra/string-extra/Tests/Fixtures/singular-invalid-language.test @@ -0,0 +1,10 @@ +--TEST-- +"singular" filter +--TEMPLATE-- +{{ 'partitions'|singular('it') }} + +--DATA-- +return [] + +--EXCEPTION-- +Twig\Error\RuntimeError: An exception has been thrown during the rendering of a template ("Locale "it" is not supported.") in "index.twig" at line 2. \ No newline at end of file diff --git a/extra/string-extra/Tests/Fixtures/singular.test b/extra/string-extra/Tests/Fixtures/singular.test new file mode 100755 index 00000000000..01e03db66a6 --- /dev/null +++ b/extra/string-extra/Tests/Fixtures/singular.test @@ -0,0 +1,19 @@ +--TEST-- +"singular" filter +--TEMPLATE-- +{{ 'partitions'|singular('fr') }} +{{ 'partitions'|singular('fr', all=true)|join(',') }} +{{ 'persons'|singular('fr') }} +{{ 'persons'|singular('en', all=true)|join(',') }} +{{ 'people'|singular('en') }} +{{ 'people'|singular('en', all=true)|join(',') }} + +--DATA-- +return [] +--EXPECT-- +partition +partition +person +person +person +person diff --git a/extra/string-extra/Tests/Fixtures/singular_es.test b/extra/string-extra/Tests/Fixtures/singular_es.test new file mode 100755 index 00000000000..9bc42a343a0 --- /dev/null +++ b/extra/string-extra/Tests/Fixtures/singular_es.test @@ -0,0 +1,13 @@ +--TEST-- +"singular" filter +--CONDITION-- +class_exists('Symfony\Component\String\Inflector\SpanishInflector') +--TEMPLATE-- +{{ 'personas'|singular('es') }} +{{ 'personas'|singular('es', all=true)|join(',') }} + +--DATA-- +return [] +--EXPECT-- +persona +persona diff --git a/extra/string-extra/Tests/IntegrationTest.php b/extra/string-extra/Tests/IntegrationTest.php index 032c9a9d9a2..ddf6abfe509 100644 --- a/extra/string-extra/Tests/IntegrationTest.php +++ b/extra/string-extra/Tests/IntegrationTest.php @@ -23,7 +23,7 @@ public function getExtensions() ]; } - public function getFixturesDir() + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } diff --git a/extra/string-extra/composer.json b/extra/string-extra/composer.json index e422f7c0c5f..27a0f346af9 100644 --- a/extra/string-extra/composer.json +++ b/extra/string-extra/composer.json @@ -15,23 +15,18 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/string": "^5.0|^6.0", + "php": ">=8.1.0", + "symfony/string": "^5.4|^6.4|^7.0|^8.0", "symfony/translation-contracts": "^1.1|^2|^3", - "twig/twig": "^2.7|^3.0" + "twig/twig": "^3.13|^4.0" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0" + "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\String\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/string-extra/phpunit.xml.dist b/extra/string-extra/phpunit.xml.dist index 930cc0ab1f3..aa15cb642c7 100644 --- a/extra/string-extra/phpunit.xml.dist +++ b/extra/string-extra/phpunit.xml.dist @@ -1,30 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + ./Tests/ + + diff --git a/extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php b/extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php index 3f3cca62a35..fdd65820b0f 100644 --- a/extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php +++ b/extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php @@ -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. @@ -14,23 +14,38 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; -use Twig\Environment; -class MissingExtensionSuggestorPass implements CompilerPassInterface -{ - public function process(ContainerBuilder $container) +if (!method_exists(ContainerBuilder::class, 'getAutoconfiguredAttributes')) { + class MissingExtensionSuggestorPass implements CompilerPassInterface { - if ($container->getParameter('kernel.debug')) { + public function process(ContainerBuilder $container): void + { + if (!$container->getParameter('kernel.debug')) { + return; + } $twigDefinition = $container->getDefinition('twig'); $twigDefinition ->addMethodCall('registerUndefinedFilterCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestFilter']]) ->addMethodCall('registerUndefinedFunctionCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestFunction']]) + ->addMethodCall('registerUndefinedTokenParserCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestTag']]) ; - - // this method was added in Twig 3.2 - if (method_exists(Environment::class, 'registerUndefinedTokenParserCallback')) { - $twigDefinition->addMethodCall('registerUndefinedTokenParserCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestTag']]); + } + } +} else { + class MissingExtensionSuggestorPass implements CompilerPassInterface + { + /** @return void */ + public function process(ContainerBuilder $container) + { + if (!$container->getParameter('kernel.debug')) { + return; } + $twigDefinition = $container->getDefinition('twig'); + $twigDefinition + ->addMethodCall('registerUndefinedFilterCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestFilter']]) + ->addMethodCall('registerUndefinedFunctionCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestFunction']]) + ->addMethodCall('registerUndefinedTokenParserCallback', [[new Reference('twig.missing_extension_suggestor'), 'suggestTag']]) + ; } } } diff --git a/extra/twig-extra-bundle/DependencyInjection/Configuration.php b/extra/twig-extra-bundle/DependencyInjection/Configuration.php index 447e6ac76fa..8b6f33c58e7 100644 --- a/extra/twig-extra-bundle/DependencyInjection/Configuration.php +++ b/extra/twig-extra-bundle/DependencyInjection/Configuration.php @@ -11,16 +11,14 @@ namespace Twig\Extra\TwigExtraBundle\DependencyInjection; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Twig\Extra\TwigExtraBundle\Extensions; class Configuration implements ConfigurationInterface { - /** - * @return TreeBuilder - */ - public function getConfigTreeBuilder() + public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('twig_extra'); $rootNode = $treeBuilder->getRootNode(); @@ -35,6 +33,67 @@ public function getConfigTreeBuilder() ; } + $this->addCommonMarkConfiguration($rootNode); + return $treeBuilder; } + + /** + * Full configuration from {@link https://commonmark.thephpleague.com/2.7/configuration}. + */ + private function addCommonMarkConfiguration(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('commonmark') + ->ignoreExtraKeys(false) + ->children() + ->arrayNode('renderer') + ->info('Array of options for rendering HTML.') + ->children() + ->scalarNode('block_separator')->end() + ->scalarNode('inner_separator')->end() + ->scalarNode('soft_break')->end() + ->end() + ->end() + ->enumNode('html_input') + ->info('How to handle HTML input.') + ->values(['strip', 'allow', 'escape']) + ->end() + ->booleanNode('allow_unsafe_links') + ->info('Remove risky link and image URLs by setting this to false.') + ->defaultTrue() + ->end() + ->integerNode('max_nesting_level') + ->info('The maximum nesting level for blocks.') + ->defaultValue(\PHP_INT_MAX) + ->end() + ->integerNode('max_delimiters_per_line') + ->info('The maximum number of strong/emphasis delimiters per line.') + ->defaultValue(\PHP_INT_MAX) + ->end() + ->arrayNode('slug_normalizer') + ->info('Array of options for configuring how URL-safe slugs are created.') + ->children() + ->variableNode('instance')->end() + ->integerNode('max_length')->defaultValue(255)->end() + ->variableNode('unique')->end() + ->end() + ->end() + ->arrayNode('commonmark') + ->info('Array of options for configuring the CommonMark core extension.') + ->children() + ->booleanNode('enable_em')->defaultTrue()->end() + ->booleanNode('enable_strong')->defaultTrue()->end() + ->booleanNode('use_asterisk')->defaultTrue()->end() + ->booleanNode('use_underscore')->defaultTrue()->end() + ->arrayNode('unordered_list_markers') + ->scalarPrototype()->end() + ->defaultValue([['-', '*', '+']])->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } } diff --git a/extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php b/extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php index 0f57d71f36a..7c5a7c7db16 100644 --- a/extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php +++ b/extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php @@ -14,16 +14,40 @@ use League\CommonMark\CommonMarkConverter; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Twig\Extra\TwigExtraBundle\Extensions; +if (!method_exists(ContainerBuilder::class, 'getAutoconfiguredAttributes')) { + /** @internal */ + trait TwigExtraExtensionTrait + { + public function load(array $configs, ContainerBuilder $container): void + { + $this->doLoad($configs, $container); + } + } +} else { + /** @internal */ + trait TwigExtraExtensionTrait + { + /** @return void */ + public function load(array $configs, ContainerBuilder $container) + { + $this->doLoad($configs, $container); + } + } + +} + /** * @author Fabien Potencier */ class TwigExtraExtension extends Extension { - public function load(array $configs, ContainerBuilder $container) + use TwigExtraExtensionTrait; + + private function doLoad(array $configs, ContainerBuilder $container): void { $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')); $configuration = $this->getConfiguration($configs, $container); @@ -37,8 +61,14 @@ public function load(array $configs, ContainerBuilder $container) if ($this->isConfigEnabled($container, $config[$extension])) { $loader->load($extension.'.php'); - if ('markdown' === $extension && \class_exists(CommonMarkConverter::class)) { + if ('markdown' === $extension && class_exists(CommonMarkConverter::class)) { $loader->load('markdown_league.php'); + + if ($container->hasDefinition('twig.markdown.league_common_mark_converter_factory')) { + $container + ->getDefinition('twig.markdown.league_common_mark_converter_factory') + ->setArgument('$config', $config['commonmark'] ?? []); + } } } } diff --git a/extra/twig-extra-bundle/Extensions.php b/extra/twig-extra-bundle/Extensions.php index e542604e1fa..306de75a358 100644 --- a/extra/twig-extra-bundle/Extensions.php +++ b/extra/twig-extra-bundle/Extensions.php @@ -99,7 +99,7 @@ public static function getClasses(): array public static function getFilter(string $name): array { foreach (self::EXTENSIONS as $extension) { - if (\in_array($name, $extension['filters'])) { + if (\in_array($name, $extension['filters'], true)) { return [$extension['class_name'], $extension['package']]; } } @@ -110,7 +110,7 @@ public static function getFilter(string $name): array public static function getFunction(string $name): array { foreach (self::EXTENSIONS as $extension) { - if (\in_array($name, $extension['functions'])) { + if (\in_array($name, $extension['functions'], true)) { return [$extension['class_name'], $extension['package']]; } } @@ -121,7 +121,7 @@ public static function getFunction(string $name): array public static function getTag(string $name): array { foreach (self::EXTENSIONS as $extension) { - if (\in_array($name, $extension['tags'])) { + if (\in_array($name, $extension['tags'], true)) { return [$extension['class_name'], $extension['package']]; } } diff --git a/extra/twig-extra-bundle/LICENSE b/extra/twig-extra-bundle/LICENSE index 9c907a46a62..f37c76b591d 100644 --- a/extra/twig-extra-bundle/LICENSE +++ b/extra/twig-extra-bundle/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019-2022 Fabien Potencier +Copyright (c) 2019-present Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/extra/twig-extra-bundle/LeagueCommonMarkConverterFactory.php b/extra/twig-extra-bundle/LeagueCommonMarkConverterFactory.php index a2b90a25f58..a484d8b6b09 100644 --- a/extra/twig-extra-bundle/LeagueCommonMarkConverterFactory.php +++ b/extra/twig-extra-bundle/LeagueCommonMarkConverterFactory.php @@ -20,18 +20,20 @@ final class LeagueCommonMarkConverterFactory { private $extensions; + private $config; /** * @param ExtensionInterface[] $extensions */ - public function __construct(iterable $extensions) + public function __construct(iterable $extensions, array $config = []) { $this->extensions = $extensions; + $this->config = $config; } public function __invoke(): CommonMarkConverter { - $converter = new CommonMarkConverter(); + $converter = new CommonMarkConverter($this->config); foreach ($this->extensions as $extension) { $converter->getEnvironment()->addExtension($extension); diff --git a/extra/twig-extra-bundle/MissingExtensionSuggestor.php b/extra/twig-extra-bundle/MissingExtensionSuggestor.php index 683d3d6c0f3..0f8e1d5fda0 100644 --- a/extra/twig-extra-bundle/MissingExtensionSuggestor.php +++ b/extra/twig-extra-bundle/MissingExtensionSuggestor.php @@ -18,7 +18,7 @@ final class MissingExtensionSuggestor public function suggestFilter(string $name): bool { if ($filter = Extensions::getFilter($name)) { - throw new SyntaxError(sprintf('The "%s" filter is part of the %s, which is not installed/enabled; try running "composer require %s".', $name, $filter[0], $filter[1])); + throw new SyntaxError(\sprintf('The "%s" filter is part of the %s, which is not installed/enabled; try running "composer require %s".', $name, $filter[0], $filter[1])); } return false; @@ -27,7 +27,7 @@ public function suggestFilter(string $name): bool public function suggestFunction(string $name): bool { if ($function = Extensions::getFunction($name)) { - throw new SyntaxError(sprintf('The "%s" function is part of the %s, which is not installed/enabled; try running "composer require %s".', $name, $function[0], $function[1])); + throw new SyntaxError(\sprintf('The "%s" function is part of the %s, which is not installed/enabled; try running "composer require %s".', $name, $function[0], $function[1])); } return false; @@ -36,7 +36,7 @@ public function suggestFunction(string $name): bool public function suggestTag(string $name): bool { if ($function = Extensions::getTag($name)) { - throw new SyntaxError(sprintf('The "%s" tag is part of the %s, which is not installed/enabled; try running "composer require %s".', $name, $function[0], $function[1])); + throw new SyntaxError(\sprintf('The "%s" tag is part of the %s, which is not installed/enabled; try running "composer require %s".', $name, $function[0], $function[1])); } return false; diff --git a/extra/twig-extra-bundle/Tests/DependencyInjection/TwigExtraExtensionTest.php b/extra/twig-extra-bundle/Tests/DependencyInjection/TwigExtraExtensionTest.php index 355b794d2b1..ce263ac2c38 100644 --- a/extra/twig-extra-bundle/Tests/DependencyInjection/TwigExtraExtensionTest.php +++ b/extra/twig-extra-bundle/Tests/DependencyInjection/TwigExtraExtensionTest.php @@ -26,7 +26,29 @@ public function testDefaultConfiguration() 'kernel.debug' => false, ])); $container->registerExtension(new TwigExtraExtension()); - $container->loadFromExtension('twig_extra'); + $container->loadFromExtension('twig_extra', [ + 'commonmark' => [ + 'extra_key' => true, + 'renderer' => [ + 'block_separator' => "\n", + 'inner_separator' => "\n", + 'soft_break' => "\n", + ], + 'commonmark' => [ + 'enable_em' => true, + 'enable_strong' => true, + 'use_asterisk' => true, + 'use_underscore' => true, + 'unordered_list_markers' => ['-', '*', '+'], + ], + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + 'max_nesting_level' => \PHP_INT_MAX, + 'slug_normalizer' => [ + 'max_length' => 255, + ], + ], + ]); $container->getCompilerPassConfig()->setOptimizationPasses([]); $container->getCompilerPassConfig()->setRemovingPasses([]); $container->getCompilerPassConfig()->setAfterRemovingPasses([]); diff --git a/extra/twig-extra-bundle/Tests/Fixture/Kernel.php b/extra/twig-extra-bundle/Tests/Fixture/Kernel.php index cbc9c4bd850..8986f8ecd92 100644 --- a/extra/twig-extra-bundle/Tests/Fixture/Kernel.php +++ b/extra/twig-extra-bundle/Tests/Fixture/Kernel.php @@ -1,14 +1,24 @@ loadFromExtension('framework', [ + $config = [ 'secret' => 'S3CRET', 'test' => true, - ]); - + 'router' => ['utf8' => true], + 'http_method_override' => false, + 'php_errors' => [ + 'log' => true, + ], + ]; + + // the "handle_all_throwables" option was introduced in FrameworkBundle 6.2 (and so was the NotificationAssertionsTrait) + if (trait_exists(NotificationAssertionsTrait::class)) { + $config['handle_all_throwables'] = true; + } + + $c->loadFromExtension('framework', $config); $c->loadFromExtension('twig', [ 'default_path' => __DIR__.'/views', ]); diff --git a/extra/twig-extra-bundle/Tests/IntegrationTest.php b/extra/twig-extra-bundle/Tests/IntegrationTest.php index 04cbbeed5a9..df62da2da38 100644 --- a/extra/twig-extra-bundle/Tests/IntegrationTest.php +++ b/extra/twig-extra-bundle/Tests/IntegrationTest.php @@ -1,5 +1,14 @@ addCompilerPass(new MissingExtensionSuggestorPass()); + $container->addCompilerPass(new MissingExtensionSuggestorPass()); + } + } +} else { + class TwigExtraBundle extends Bundle + { + /** @return void */ + public function build(ContainerBuilder $container) + { + parent::build($container); + + $container->addCompilerPass(new MissingExtensionSuggestorPass()); + } } } diff --git a/extra/twig-extra-bundle/composer.json b/extra/twig-extra-bundle/composer.json index a946ecc3441..35421f84a7c 100644 --- a/extra/twig-extra-bundle/composer.json +++ b/extra/twig-extra-bundle/composer.json @@ -15,31 +15,26 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/framework-bundle": "^4.4|^5.0|^6.0", - "symfony/twig-bundle": "^4.4|^5.0|^6.0", - "twig/twig": "^2.7|^3.0" + "php": ">=8.1.0", + "symfony/framework-bundle": "^5.4|^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0", + "twig/twig": "^3.2|^4.0" }, "require-dev": { - "league/commonmark": "^1.0|^2.0", - "symfony/phpunit-bridge": "^4.4.9|^5.0.9|^6.0", + "league/commonmark": "^2.7", + "symfony/phpunit-bridge": "^6.4|^7.0", "twig/cache-extra": "^3.0", - "twig/cssinliner-extra": "^2.12|^3.0", - "twig/html-extra": "^2.12|^3.0", - "twig/inky-extra": "^2.12|^3.0", - "twig/intl-extra": "^2.12|^3.0", - "twig/markdown-extra": "^2.12|^3.0", - "twig/string-extra": "^2.12|^3.0" + "twig/cssinliner-extra": "^3.0", + "twig/html-extra": "^3.0", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.0", + "twig/markdown-extra": "^3.0", + "twig/string-extra": "^3.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\TwigExtraBundle\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } } } diff --git a/extra/twig-extra-bundle/phpunit.xml.dist b/extra/twig-extra-bundle/phpunit.xml.dist index 41534858a1f..c8d88d89c48 100644 --- a/extra/twig-extra-bundle/phpunit.xml.dist +++ b/extra/twig-extra-bundle/phpunit.xml.dist @@ -1,32 +1,22 @@ - - - - - - - - - - - ./Tests/ - - - - - - ./ - - ./Tests - ./vendor - - - + + + + ./ + + + ./Tests + ./vendor + + + + + + + + + + ./Tests/ + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000000..131ed97b4ff --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,25 @@ +parameters: + ignoreErrors: + - # The method is dynamically generated by the CheckSecurityNode + message: '#^Call to an undefined method Twig\\Template\:\:checkSecurity\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Extension/CoreExtension.php + + - # 2 parameters will be required + message: '#^Method Twig\\Node\\IncludeNode\:\:addGetTemplate\(\) invoked with 2 parameters, 1 required\.$#' + identifier: arguments.count + count: 1 + path: src/Node/IncludeNode.php + + - # int|string will be supported in 4.x + message: '#^PHPDoc tag @param for parameter $name with type int|string is not subtype of native type string\.$#' + identifier: parameter.phpDocType + count: 5 + path: src/Node/Node.php + + - # Adding 0 to the string representation of a number is valid and what we want here + message: '#^Binary operation "\+" between 0 and string results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: src/Lexer.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000000..6d94e410929 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 3 + paths: + - src + excludePaths: + - src/Test diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9af92f4639d..0caf8dad519 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ - + + ./tests/ @@ -7,7 +8,6 @@ - diff --git a/src/AbstractTwigCallable.php b/src/AbstractTwigCallable.php new file mode 100644 index 00000000000..804f336cbe3 --- /dev/null +++ b/src/AbstractTwigCallable.php @@ -0,0 +1,187 @@ + + */ +abstract class AbstractTwigCallable implements TwigCallableInterface +{ + protected $options; + + private $name; + private $dynamicName; + private $callable; + private $arguments; + + public function __construct(string $name, $callable = null, array $options = []) + { + $this->name = $this->dynamicName = $name; + $this->callable = $callable; + $this->arguments = []; + $this->options = array_merge([ + 'needs_environment' => false, + 'needs_context' => false, + 'needs_charset' => false, + 'is_variadic' => false, + 'deprecation_info' => null, + 'deprecated' => false, + 'deprecating_package' => '', + 'alternative' => null, + ], $options); + + if ($this->options['deprecation_info'] && !$this->options['deprecation_info'] instanceof DeprecatedCallableInfo) { + throw new \LogicException(\sprintf('The "deprecation_info" option must be an instance of "%s".', DeprecatedCallableInfo::class)); + } + + if ($this->options['deprecated']) { + if ($this->options['deprecation_info']) { + throw new \LogicException('When setting the "deprecation_info" option, you need to remove the obsolete deprecated options.'); + } + + trigger_deprecation('twig/twig', '3.15', 'Using the "deprecated", "deprecating_package", and "alternative" options is deprecated, pass a "deprecation_info" one instead.'); + + $this->options['deprecation_info'] = new DeprecatedCallableInfo( + $this->options['deprecating_package'], + $this->options['deprecated'], + null, + $this->options['alternative'], + ); + } + + if ($this->options['deprecation_info']) { + $this->options['deprecation_info']->setName($name); + $this->options['deprecation_info']->setType($this->getType()); + } + } + + public function __toString(): string + { + return \sprintf('%s(%s)', static::class, $this->name); + } + + public function getName(): string + { + return $this->name; + } + + public function getDynamicName(): string + { + return $this->dynamicName; + } + + /** + * @return callable|array{class-string, string}|null + */ + public function getCallable() + { + return $this->callable; + } + + public function getNodeClass(): string + { + return $this->options['node_class']; + } + + public function needsCharset(): bool + { + return $this->options['needs_charset']; + } + + public function needsEnvironment(): bool + { + return $this->options['needs_environment']; + } + + public function needsContext(): bool + { + return $this->options['needs_context']; + } + + /** + * @return static + */ + public function withDynamicArguments(string $name, string $dynamicName, array $arguments): self + { + $new = clone $this; + $new->name = $name; + $new->dynamicName = $dynamicName; + $new->arguments = $arguments; + + return $new; + } + + /** + * @deprecated since Twig 3.12, use withDynamicArguments() instead + */ + public function setArguments(array $arguments): void + { + trigger_deprecation('twig/twig', '3.12', 'The "%s::setArguments()" method is deprecated, use "%s::withDynamicArguments()" instead.', static::class, static::class); + + $this->arguments = $arguments; + } + + public function getArguments(): array + { + return $this->arguments; + } + + public function isVariadic(): bool + { + return $this->options['is_variadic']; + } + + public function isDeprecated(): bool + { + return (bool) $this->options['deprecation_info']; + } + + public function triggerDeprecation(?string $file = null, ?int $line = null): void + { + $this->options['deprecation_info']->triggerDeprecation($file, $line); + } + + /** + * @deprecated since Twig 3.15 + */ + public function getDeprecatingPackage(): string + { + trigger_deprecation('twig/twig', '3.15', 'The "%s" method is deprecated, use "%s::triggerDeprecation()" instead.', __METHOD__, static::class); + + return $this->options['deprecating_package']; + } + + /** + * @deprecated since Twig 3.15 + */ + public function getDeprecatedVersion(): string + { + trigger_deprecation('twig/twig', '3.15', 'The "%s" method is deprecated, use "%s::triggerDeprecation()" instead.', __METHOD__, static::class); + + return \is_bool($this->options['deprecated']) ? '' : $this->options['deprecated']; + } + + /** + * @deprecated since Twig 3.15 + */ + public function getAlternative(): ?string + { + trigger_deprecation('twig/twig', '3.15', 'The "%s" method is deprecated, use "%s::triggerDeprecation()" instead.', __METHOD__, static::class); + + return $this->options['alternative']; + } + + public function getMinimalNumberOfRequiredArguments(): int + { + return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + \count($this->arguments); + } +} diff --git a/src/Attribute/AsTwigFilter.php b/src/Attribute/AsTwigFilter.php new file mode 100644 index 00000000000..09f8f01de49 --- /dev/null +++ b/src/Attribute/AsTwigFilter.php @@ -0,0 +1,56 @@ + + */ +final class ChainCache implements CacheInterface, RemovableCacheInterface +{ + /** + * @param iterable $caches The ordered list of caches used to store and fetch cached items + */ + public function __construct( + private iterable $caches, + ) { + } + + public function generateKey(string $name, string $className): string + { + return $className.'#'.$name; + } + + public function write(string $key, string $content): void + { + $splitKey = $this->splitKey($key); + + foreach ($this->caches as $cache) { + $cache->write($cache->generateKey(...$splitKey), $content); + } + } + + public function load(string $key): void + { + [$name, $className] = $this->splitKey($key); + + foreach ($this->caches as $cache) { + $cache->load($cache->generateKey($name, $className)); + + if (class_exists($className, false)) { + break; + } + } + } + + public function getTimestamp(string $key): int + { + $splitKey = $this->splitKey($key); + + foreach ($this->caches as $cache) { + if (0 < $timestamp = $cache->getTimestamp($cache->generateKey(...$splitKey))) { + return $timestamp; + } + } + + return 0; + } + + public function remove(string $name, string $cls): void + { + foreach ($this->caches as $cache) { + if ($cache instanceof RemovableCacheInterface) { + $cache->remove($name, $cls); + } + } + } + + /** + * @return string[] + */ + private function splitKey(string $key): array + { + return array_reverse(explode('#', $key, 2)); + } +} diff --git a/src/Cache/FilesystemCache.php b/src/Cache/FilesystemCache.php index e075563aef6..5840585e3e9 100644 --- a/src/Cache/FilesystemCache.php +++ b/src/Cache/FilesystemCache.php @@ -16,7 +16,7 @@ * * @author Andrew Tch */ -class FilesystemCache implements CacheInterface +class FilesystemCache implements CacheInterface, RemovableCacheInterface { public const FORCE_BYTECODE_INVALIDATION = 1; @@ -50,11 +50,11 @@ public function write(string $key, string $content): void if (false === @mkdir($dir, 0777, true)) { clearstatcache(true, $dir); if (!is_dir($dir)) { - throw new \RuntimeException(sprintf('Unable to create the cache directory (%s).', $dir)); + throw new \RuntimeException(\sprintf('Unable to create the cache directory (%s).', $dir)); } } } elseif (!is_writable($dir)) { - throw new \RuntimeException(sprintf('Unable to write in the cache directory (%s).', $dir)); + throw new \RuntimeException(\sprintf('Unable to write in the cache directory (%s).', $dir)); } $tmpFile = tempnam($dir, basename($key)); @@ -63,7 +63,7 @@ public function write(string $key, string $content): void if (self::FORCE_BYTECODE_INVALIDATION == ($this->options & self::FORCE_BYTECODE_INVALIDATION)) { // Compile cached file into bytecode cache - if (\function_exists('opcache_invalidate') && filter_var(ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) { + if (\function_exists('opcache_invalidate') && filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)) { @opcache_invalidate($key, true); } elseif (\function_exists('apc_compile_file')) { apc_compile_file($key); @@ -73,7 +73,15 @@ public function write(string $key, string $content): void return; } - throw new \RuntimeException(sprintf('Failed to write cache file "%s".', $key)); + throw new \RuntimeException(\sprintf('Failed to write cache file "%s".', $key)); + } + + public function remove(string $name, string $cls): void + { + $key = $this->generateKey($name, $cls); + if (!@unlink($key) && file_exists($key)) { + throw new \RuntimeException(\sprintf('Failed to delete cache file "%s".', $key)); + } } public function getTimestamp(string $key): int diff --git a/src/Cache/NullCache.php b/src/Cache/NullCache.php index 8d20d59d8b3..1ae21692800 100644 --- a/src/Cache/NullCache.php +++ b/src/Cache/NullCache.php @@ -16,7 +16,7 @@ * * @author Fabien Potencier */ -final class NullCache implements CacheInterface +final class NullCache implements CacheInterface, RemovableCacheInterface { public function generateKey(string $name, string $className): string { @@ -35,4 +35,8 @@ public function getTimestamp(string $key): int { return 0; } + + public function remove(string $name, string $cls): void + { + } } diff --git a/src/Cache/ReadOnlyFilesystemCache.php b/src/Cache/ReadOnlyFilesystemCache.php new file mode 100644 index 00000000000..3ba6514c950 --- /dev/null +++ b/src/Cache/ReadOnlyFilesystemCache.php @@ -0,0 +1,25 @@ + + */ +class ReadOnlyFilesystemCache extends FilesystemCache +{ + public function write(string $key, string $content): void + { + // Do nothing with the content, it's a read-only filesystem. + } +} diff --git a/src/Cache/RemovableCacheInterface.php b/src/Cache/RemovableCacheInterface.php new file mode 100644 index 00000000000..05da569136b --- /dev/null +++ b/src/Cache/RemovableCacheInterface.php @@ -0,0 +1,20 @@ + + */ +interface RemovableCacheInterface +{ + public function remove(string $name, string $cls): void; +} diff --git a/src/Compiler.php b/src/Compiler.php index 95e1f183b25..6f62c091978 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -22,15 +22,16 @@ class Compiler private $lastLine; private $source; private $indentation; - private $env; private $debugInfo = []; private $sourceOffset; private $sourceLine; private $varNameSalt = 0; + private $didUseEcho = false; + private $didUseEchoStack = []; - public function __construct(Environment $env) - { - $this->env = $env; + public function __construct( + private Environment $env, + ) { } public function getEnvironment(): Environment @@ -46,7 +47,7 @@ public function getSource(): string /** * @return $this */ - public function compile(Node $node, int $indentation = 0) + public function reset(int $indentation = 0) { $this->lastLine = null; $this->source = ''; @@ -57,23 +58,54 @@ public function compile(Node $node, int $indentation = 0) $this->indentation = $indentation; $this->varNameSalt = 0; - $node->compile($this); - return $this; } + /** + * @return $this + */ + public function compile(Node $node, int $indentation = 0) + { + $this->reset($indentation); + $this->didUseEchoStack[] = $this->didUseEcho; + + try { + $this->didUseEcho = false; + $node->compile($this); + + if ($this->didUseEcho) { + trigger_deprecation('twig/twig', '3.9', 'Using "%s" is deprecated, use "yield" instead in "%s", then flag the class with #[\Twig\Attribute\YieldReady].', $this->didUseEcho, $node::class); + } + + return $this; + } finally { + $this->didUseEcho = array_pop($this->didUseEchoStack); + } + } + /** * @return $this */ public function subcompile(Node $node, bool $raw = true) { - if (false === $raw) { + if (!$raw) { $this->source .= str_repeat(' ', $this->indentation * 4); } - $node->compile($this); + $this->didUseEchoStack[] = $this->didUseEcho; - return $this; + try { + $this->didUseEcho = false; + $node->compile($this); + + if ($this->didUseEcho) { + trigger_deprecation('twig/twig', '3.9', 'Using "%s" is deprecated, use "yield" instead in "%s", then flag the class with #[\Twig\Attribute\YieldReady].', $this->didUseEcho, $node::class); + } + + return $this; + } finally { + $this->didUseEcho = array_pop($this->didUseEchoStack); + } } /** @@ -83,6 +115,7 @@ public function subcompile(Node $node, bool $raw = true) */ public function raw(string $string) { + $this->checkForEcho($string); $this->source .= $string; return $this; @@ -96,6 +129,7 @@ public function raw(string $string) public function write(...$strings) { foreach ($strings as $string) { + $this->checkForEcho($string); $this->source .= str_repeat(' ', $this->indentation * 4).$string; } @@ -109,7 +143,7 @@ public function write(...$strings) */ public function string(string $value) { - $this->source .= sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); + $this->source .= \sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); return $this; } @@ -136,7 +170,7 @@ public function repr($value) } elseif (\is_bool($value)) { $this->raw($value ? 'true' : 'false'); } elseif (\is_array($value)) { - $this->raw('array('); + $this->raw('['); $first = true; foreach ($value as $key => $v) { if (!$first) { @@ -147,7 +181,7 @@ public function repr($value) $this->raw(' => '); $this->repr($v); } - $this->raw(')'); + $this->raw(']'); } else { $this->string($value); } @@ -161,7 +195,7 @@ public function repr($value) public function addDebugInfo(Node $node) { if ($node->getTemplateLine() != $this->lastLine) { - $this->write(sprintf("// line %d\n", $node->getTemplateLine())); + $this->write(\sprintf("// line %d\n", $node->getTemplateLine())); $this->sourceLine += substr_count($this->source, "\n", $this->sourceOffset); $this->sourceOffset = \strlen($this->source); @@ -209,6 +243,15 @@ public function outdent(int $step = 1) public function getVarName(): string { - return sprintf('__internal_compile_%d', $this->varNameSalt++); + return \sprintf('_v%d', $this->varNameSalt++); + } + + private function checkForEcho(string $string): void + { + if ($this->didUseEcho) { + return; + } + + $this->didUseEcho = preg_match('/^\s*+(echo|print)\b/', $string, $m) ? $m[1] : false; } } diff --git a/src/DeprecatedCallableInfo.php b/src/DeprecatedCallableInfo.php new file mode 100644 index 00000000000..2db9f3d28af --- /dev/null +++ b/src/DeprecatedCallableInfo.php @@ -0,0 +1,67 @@ + + */ +final class DeprecatedCallableInfo +{ + private string $type; + private string $name; + + public function __construct( + private string $package, + private string $version, + private ?string $altName = null, + private ?string $altPackage = null, + private ?string $altVersion = null, + ) { + } + + public function setType(string $type): void + { + $this->type = $type; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function triggerDeprecation(?string $file = null, ?int $line = null): void + { + $message = \sprintf('Twig %s "%s" is deprecated', ucfirst($this->type), $this->name); + + if ($this->altName) { + $message .= \sprintf('; use "%s"', $this->altName); + if ($this->altPackage) { + $message .= \sprintf(' from the "%s" package', $this->altPackage); + } + if ($this->altVersion) { + $message .= \sprintf(' (available since version %s)', $this->altVersion); + } + $message .= ' instead'; + } + + if ($file) { + $message .= \sprintf(' in %s', $file); + if ($line) { + $message .= \sprintf(' at line %d', $line); + } + } + + $message .= '.'; + + trigger_deprecation($this->package, $this->version, $message); + } +} diff --git a/src/Environment.php b/src/Environment.php index 12db4bb2375..a8c7678ebfb 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -14,22 +14,25 @@ use Twig\Cache\CacheInterface; use Twig\Cache\FilesystemCache; use Twig\Cache\NullCache; +use Twig\Cache\RemovableCacheInterface; use Twig\Error\Error; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\ExpressionParsers; use Twig\Extension\CoreExtension; use Twig\Extension\EscaperExtension; use Twig\Extension\ExtensionInterface; use Twig\Extension\OptimizerExtension; +use Twig\Extension\YieldNotReadyExtension; use Twig\Loader\ArrayLoader; use Twig\Loader\ChainLoader; use Twig\Loader\LoaderInterface; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\NodeVisitor\NodeVisitorInterface; +use Twig\Runtime\EscaperRuntime; +use Twig\RuntimeLoader\FactoryRuntimeLoader; use Twig\RuntimeLoader\RuntimeLoaderInterface; use Twig\TokenParser\TokenParserInterface; @@ -40,11 +43,11 @@ */ class Environment { - public const VERSION = '3.4.4-DEV'; - public const VERSION_ID = 30404; + public const VERSION = '3.22.1-DEV'; + public const VERSION_ID = 32201; public const MAJOR_VERSION = 3; - public const MINOR_VERSION = 4; - public const RELEASE_VERSION = 4; + public const MINOR_VERSION = 22; + public const RELEASE_VERSION = 1; public const EXTRA_VERSION = 'DEV'; private $charset; @@ -60,12 +63,15 @@ class Environment private $resolvedGlobals; private $loadedTemplates; private $strictVariables; - private $templateClassPrefix = '__TwigTemplate_'; private $originalCache; private $extensionSet; private $runtimeLoaders = []; private $runtimes = []; private $optionsHash; + /** @var bool */ + private $useYield; + private $defaultRuntimeLoader; + private array $hotCache = []; /** * Constructor. @@ -97,8 +103,12 @@ class Environment * * optimizations: A flag that indicates which optimizations to apply * (default to -1 which means that all optimizations are enabled; * set it to 0 to disable). + * + * * use_yield: 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 */ - public function __construct(LoaderInterface $loader, $options = []) + public function __construct(LoaderInterface $loader, array $options = []) { $this->setLoader($loader); @@ -110,22 +120,42 @@ public function __construct(LoaderInterface $loader, $options = []) 'cache' => false, 'auto_reload' => null, 'optimizations' => -1, + 'use_yield' => false, ], $options); + $this->useYield = (bool) $options['use_yield']; $this->debug = (bool) $options['debug']; $this->setCharset($options['charset'] ?? 'UTF-8'); $this->autoReload = null === $options['auto_reload'] ? $this->debug : (bool) $options['auto_reload']; $this->strictVariables = (bool) $options['strict_variables']; $this->setCache($options['cache']); $this->extensionSet = new ExtensionSet(); + $this->defaultRuntimeLoader = new FactoryRuntimeLoader([ + EscaperRuntime::class => function () { return new EscaperRuntime($this->charset); }, + ]); $this->addExtension(new CoreExtension()); - $this->addExtension(new EscaperExtension($options['autoescape'])); + $escaperExt = new EscaperExtension($options['autoescape']); + $escaperExt->setEnvironment($this, false); + $this->addExtension($escaperExt); + if (\PHP_VERSION_ID >= 80000) { + $this->addExtension(new YieldNotReadyExtension($this->useYield)); + } $this->addExtension(new OptimizerExtension($options['optimizations'])); } + /** + * @internal + */ + public function useYield(): bool + { + return $this->useYield; + } + /** * Enables debugging mode. + * + * @return void */ public function enableDebug() { @@ -135,6 +165,8 @@ public function enableDebug() /** * Disables debugging mode. + * + * @return void */ public function disableDebug() { @@ -154,6 +186,8 @@ public function isDebug() /** * Enables the auto_reload option. + * + * @return void */ public function enableAutoReload() { @@ -162,6 +196,8 @@ public function enableAutoReload() /** * Disables the auto_reload option. + * + * @return void */ public function disableAutoReload() { @@ -180,6 +216,8 @@ public function isAutoReload() /** * Enables the strict_variables option. + * + * @return void */ public function enableStrictVariables() { @@ -189,6 +227,8 @@ public function enableStrictVariables() /** * Disables the strict_variables option. + * + * @return void */ public function disableStrictVariables() { @@ -206,6 +246,18 @@ public function isStrictVariables() return $this->strictVariables; } + public function removeCache(string $name): void + { + $cls = $this->getTemplateClass($name); + $this->hotCache[$name] = $cls.'_'.bin2hex(random_bytes(16)); + + if ($this->cache instanceof RemovableCacheInterface) { + $this->cache->remove($name, $cls); + } else { + throw new \LogicException(\sprintf('The "%s" cache class does not support removing template cache as it does not implement the "RemovableCacheInterface" interface.', \get_class($this->cache))); + } + } + /** * Gets the current cache implementation. * @@ -226,6 +278,8 @@ public function getCache($original = true) * @param CacheInterface|string|false $cache A Twig\Cache\CacheInterface implementation, * an absolute path to the compiled templates, * or false to disable cache + * + * @return void */ public function setCache($cache) { @@ -249,7 +303,6 @@ public function setCache($cache) * * * The cache key for the given template; * * The currently enabled extensions; - * * Whether the Twig C extension is available or not; * * PHP version; * * Twig version; * * Options with what environment was created. @@ -259,11 +312,11 @@ public function setCache($cache) * * @internal */ - public function getTemplateClass(string $name, int $index = null): string + public function getTemplateClass(string $name, ?int $index = null): string { - $key = $this->getLoader()->getCacheKey($name).$this->optionsHash; + $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash; - return $this->templateClassPrefix.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index); + return '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index); } /** @@ -308,6 +361,11 @@ public function load($name): TemplateWrapper if ($name instanceof TemplateWrapper) { return $name; } + if ($name instanceof Template) { + trigger_deprecation('twig/twig', '3.9', 'Passing a "%s" instance to "%s" is deprecated.', self::class, __METHOD__); + + return $name; + } return new TemplateWrapper($this, $this->loadTemplate($this->getTemplateClass($name), $name)); } @@ -318,8 +376,8 @@ public function load($name): TemplateWrapper * This method is for internal use only and should never be called * directly. * - * @param string $name The template name - * @param int $index The index if it is an embedded template + * @param string $name The template name + * @param int|null $index The index if it is an embedded template * * @throws LoaderError When the template cannot be found * @throws RuntimeError When a previously generated cache is corrupted @@ -327,7 +385,7 @@ public function load($name): TemplateWrapper * * @internal */ - public function loadTemplate(string $cls, string $name, int $index = null): Template + public function loadTemplate(string $cls, string $name, ?int $index = null): Template { $mainCls = $cls; if (null !== $index) { @@ -345,12 +403,13 @@ public function loadTemplate(string $cls, string $name, int $index = null): Temp $this->cache->load($key); } - $source = null; if (!class_exists($cls, false)) { $source = $this->getLoader()->getSourceContext($name); $content = $this->compileSource($source); - $this->cache->write($key, $content); - $this->cache->load($key); + if (!isset($this->hotCache[$name])) { + $this->cache->write($key, $content); + $this->cache->load($key); + } if (!class_exists($mainCls, false)) { /* Last line of defense if either $this->bcWriteCacheFile was used, @@ -362,7 +421,7 @@ public function loadTemplate(string $cls, string $name, int $index = null): Temp } if (!class_exists($cls, false)) { - throw new RuntimeError(sprintf('Failed to load Twig template "%s", index "%s": cache might be corrupted.', $name, $index), -1, $source); + throw new RuntimeError(\sprintf('Failed to load Twig template "%s", index "%s": cache might be corrupted.', $name, $index), -1, $source); } } } @@ -377,19 +436,19 @@ public function loadTemplate(string $cls, string $name, int $index = null): Temp * * This method should not be used as a generic way to load templates. * - * @param string $template The template source - * @param string $name An optional name of the template to be used in error messages + * @param string $template The template source + * @param string|null $name An optional name of the template to be used in error messages * * @throws LoaderError When the template cannot be found * @throws SyntaxError When an error occurred during compilation */ - public function createTemplate(string $template, string $name = null): TemplateWrapper + public function createTemplate(string $template, ?string $name = null): TemplateWrapper { $hash = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $template, false); if (null !== $name) { - $name = sprintf('%s (string template %s)', $name, $hash); + $name = \sprintf('%s (string template %s)', $name, $hash); } else { - $name = sprintf('__string_template__%s', $hash); + $name = \sprintf('__string_template__%s', $hash); } $loader = new ChainLoader([ @@ -422,10 +481,10 @@ public function isTemplateFresh(string $name, int $time): bool /** * Tries to load a template consecutively from an array. * - * Similar to load() but it also accepts instances of \Twig\Template and - * \Twig\TemplateWrapper, and an array of templates where each is tried to be loaded. + * Similar to load() but it also accepts instances of \Twig\TemplateWrapper + * and an array of templates where each is tried to be loaded. * - * @param string|TemplateWrapper|array $names A template or an array of templates to try consecutively + * @param string|TemplateWrapper|array $names A template or an array of templates to try consecutively * * @throws LoaderError When none of the templates can be found * @throws SyntaxError When an error occurred during compilation @@ -439,7 +498,9 @@ public function resolveTemplate($names): TemplateWrapper $count = \count($names); foreach ($names as $name) { if ($name instanceof Template) { - return $name; + trigger_deprecation('twig/twig', '3.9', 'Passing a "%s" instance to "%s" is deprecated.', Template::class, __METHOD__); + + return new TemplateWrapper($this, $name); } if ($name instanceof TemplateWrapper) { return $name; @@ -452,9 +513,12 @@ public function resolveTemplate($names): TemplateWrapper return $this->load($name); } - throw new LoaderError(sprintf('Unable to find one of the following templates: "%s".', implode('", "', $names))); + throw new LoaderError(\sprintf('Unable to find one of the following templates: "%s".', implode('", "', $names))); } + /** + * @return void + */ public function setLexer(Lexer $lexer) { $this->lexer = $lexer; @@ -472,6 +536,9 @@ public function tokenize(Source $source): TokenStream return $this->lexer->tokenize($source); } + /** + * @return void + */ public function setParser(Parser $parser) { $this->parser = $parser; @@ -491,6 +558,9 @@ public function parse(TokenStream $stream): ModuleNode return $this->parser->parse($stream); } + /** + * @return void + */ public function setCompiler(Compiler $compiler) { $this->compiler = $compiler; @@ -521,10 +591,13 @@ public function compileSource(Source $source): string $e->setSourceContext($source); throw $e; } catch (\Exception $e) { - throw new SyntaxError(sprintf('An exception has been thrown during the compilation of a template ("%s").', $e->getMessage()), -1, $source, $e); + throw new SyntaxError(\sprintf('An exception has been thrown during the compilation of a template ("%s").', $e->getMessage()), -1, $source, $e); } } + /** + * @return void + */ public function setLoader(LoaderInterface $loader) { $this->loader = $loader; @@ -535,9 +608,12 @@ public function getLoader(): LoaderInterface return $this->loader; } + /** + * @return void + */ public function setCharset(string $charset) { - if ('UTF8' === $charset = null === $charset ? null : strtoupper($charset)) { + if ('UTF8' === $charset = strtoupper($charset ?: '')) { // iconv on Windows requires "UTF-8" instead of "UTF8" $charset = 'UTF-8'; } @@ -555,6 +631,9 @@ public function hasExtension(string $class): bool return $this->extensionSet->hasExtension($class); } + /** + * @return void + */ public function addRuntimeLoader(RuntimeLoaderInterface $loader) { $this->runtimeLoaders[] = $loader; @@ -595,9 +674,16 @@ public function getRuntime(string $class) } } - throw new RuntimeError(sprintf('Unable to load the "%s" runtime.', $class)); + if (null !== $runtime = $this->defaultRuntimeLoader->load($class)) { + return $this->runtimes[$class] = $runtime; + } + + throw new RuntimeError(\sprintf('Unable to load the "%s" runtime.', $class)); } + /** + * @return void + */ public function addExtension(ExtensionInterface $extension) { $this->extensionSet->addExtension($extension); @@ -606,6 +692,8 @@ public function addExtension(ExtensionInterface $extension) /** * @param ExtensionInterface[] $extensions An array of extensions + * + * @return void */ public function setExtensions(array $extensions) { @@ -621,6 +709,9 @@ public function getExtensions(): array return $this->extensionSet->getExtensions(); } + /** + * @return void + */ public function addTokenParser(TokenParserInterface $parser) { $this->extensionSet->addTokenParser($parser); @@ -644,11 +735,17 @@ public function getTokenParser(string $name): ?TokenParserInterface return $this->extensionSet->getTokenParser($name); } + /** + * @param callable(string): (TokenParserInterface|false) $callable + */ public function registerUndefinedTokenParserCallback(callable $callable): void { $this->extensionSet->registerUndefinedTokenParserCallback($callable); } + /** + * @return void + */ public function addNodeVisitor(NodeVisitorInterface $visitor) { $this->extensionSet->addNodeVisitor($visitor); @@ -664,6 +761,9 @@ public function getNodeVisitors(): array return $this->extensionSet->getNodeVisitors(); } + /** + * @return void + */ public function addFilter(TwigFilter $filter) { $this->extensionSet->addFilter($filter); @@ -677,6 +777,9 @@ public function getFilter(string $name): ?TwigFilter return $this->extensionSet->getFilter($name); } + /** + * @param callable(string): (TwigFilter|false) $callable + */ public function registerUndefinedFilterCallback(callable $callable): void { $this->extensionSet->registerUndefinedFilterCallback($callable); @@ -698,6 +801,9 @@ public function getFilters(): array return $this->extensionSet->getFilters(); } + /** + * @return void + */ public function addTest(TwigTest $test) { $this->extensionSet->addTest($test); @@ -721,6 +827,17 @@ public function getTest(string $name): ?TwigTest return $this->extensionSet->getTest($name); } + /** + * @param callable(string): (TwigTest|false) $callable + */ + public function registerUndefinedTestCallback(callable $callable): void + { + $this->extensionSet->registerUndefinedTestCallback($callable); + } + + /** + * @return void + */ public function addFunction(TwigFunction $function) { $this->extensionSet->addFunction($function); @@ -734,6 +851,9 @@ public function getFunction(string $name): ?TwigFunction return $this->extensionSet->getFunction($name); } + /** + * @param callable(string): (TwigFunction|false) $callable + */ public function registerUndefinedFunctionCallback(callable $callable): void { $this->extensionSet->registerUndefinedFunctionCallback($callable); @@ -762,11 +882,13 @@ public function getFunctions(): array * but after, you can only update existing globals. * * @param mixed $value The global value + * + * @return void */ public function addGlobal(string $name, $value) { if ($this->extensionSet->isInitialized() && !\array_key_exists($name, $this->getGlobals())) { - throw new \LogicException(sprintf('Unable to add global "%s" as the runtime or the extensions have already been initialized.', $name)); + throw new \LogicException(\sprintf('Unable to add global "%s" as the runtime or the extensions have already been initialized.', $name)); } if (null !== $this->resolvedGlobals) { @@ -777,8 +899,6 @@ public function addGlobal(string $name, $value) } /** - * @internal - * * @return array */ public function getGlobals(): array @@ -794,37 +914,28 @@ public function getGlobals(): array return array_merge($this->extensionSet->getGlobals(), $this->globals); } - public function mergeGlobals(array $context): array + public function resetGlobals(): void { - // we don't use array_merge as the context being generally - // bigger than globals, this code is faster. - foreach ($this->getGlobals() as $key => $value) { - if (!\array_key_exists($key, $context)) { - $context[$key] = $value; - } - } - - return $context; + $this->resolvedGlobals = null; + $this->extensionSet->resetGlobals(); } /** - * @internal - * - * @return array}> + * @deprecated since Twig 3.14 */ - public function getUnaryOperators(): array + public function mergeGlobals(array $context): array { - return $this->extensionSet->getUnaryOperators(); + trigger_deprecation('twig/twig', '3.14', 'The "%s" method is deprecated.', __METHOD__); + + return $context + $this->getGlobals(); } /** * @internal - * - * @return array, associativity: ExpressionParser::OPERATOR_*}> */ - public function getBinaryOperators(): array + public function getExpressionParsers(): ExpressionParsers { - return $this->extensionSet->getBinaryOperators(); + return $this->extensionSet->getExpressionParsers(); } private function updateOptionsHash(): void @@ -836,6 +947,7 @@ private function updateOptionsHash(): void self::VERSION, (int) $this->debug, (int) $this->strictVariables, + $this->useYield ? '1' : '0', ]); } } diff --git a/src/Error/Error.php b/src/Error/Error.php index a68be65f203..97ed2df9913 100644 --- a/src/Error/Error.php +++ b/src/Error/Error.php @@ -29,20 +29,17 @@ * Whenever possible, you must set these information (original template name * and line number) yourself by passing them to the constructor. If some or all * these information are not available from where you throw the exception, then - * this class will guess them automatically (when the line number is set to -1 - * and/or the name is set to null). As this is a costly operation, this - * can be disabled by passing false for both the name and the line number - * when creating a new instance of this class. + * this class will guess them automatically. * * @author Fabien Potencier */ class Error extends \Exception { private $lineno; - private $name; private $rawMessage; - private $sourcePath; - private $sourceCode; + private ?Source $source; + private string $phpFile; + private int $phpLine; /** * Constructor. @@ -53,20 +50,14 @@ class Error extends \Exception * @param int $lineno The template line where the error occurred * @param Source|null $source The source context where the error occurred */ - public function __construct(string $message, int $lineno = -1, Source $source = null, \Exception $previous = null) + public function __construct(string $message, int $lineno = -1, ?Source $source = null, ?\Throwable $previous = null) { parent::__construct('', 0, $previous); - if (null === $source) { - $name = null; - } else { - $name = $source->getName(); - $this->sourceCode = $source->getCode(); - $this->sourcePath = $source->getPath(); - } - + $this->phpFile = $this->getFile(); + $this->phpLine = $this->getLine(); $this->lineno = $lineno; - $this->name = $name; + $this->source = $source; $this->rawMessage = $message; $this->updateRepr(); } @@ -84,30 +75,26 @@ public function getTemplateLine(): int public function setTemplateLine(int $lineno): void { $this->lineno = $lineno; - $this->updateRepr(); } public function getSourceContext(): ?Source { - return $this->name ? new Source($this->sourceCode, $this->name, $this->sourcePath) : null; + return $this->source; } - public function setSourceContext(Source $source = null): void + public function setSourceContext(?Source $source = null): void { - if (null === $source) { - $this->sourceCode = $this->name = $this->sourcePath = null; - } else { - $this->sourceCode = $source->getCode(); - $this->name = $source->getName(); - $this->sourcePath = $source->getPath(); - } - + $this->source = $source; $this->updateRepr(); } public function guess(): void { + if ($this->lineno > -1) { + return; + } + $this->guessTemplateInfo(); $this->updateRepr(); } @@ -120,80 +107,49 @@ public function appendMessage($rawMessage): void private function updateRepr(): void { - $this->message = $this->rawMessage; - - if ($this->sourcePath && $this->lineno > 0) { - $this->file = $this->sourcePath; - $this->line = $this->lineno; - - return; - } - - $dot = false; - if ('.' === substr($this->message, -1)) { - $this->message = substr($this->message, 0, -1); - $dot = true; - } - - $questionMark = false; - if ('?' === substr($this->message, -1)) { - $this->message = substr($this->message, 0, -1); - $questionMark = true; - } - - if ($this->name) { - if (\is_string($this->name) || (\is_object($this->name) && method_exists($this->name, '__toString'))) { - $name = sprintf('"%s"', $this->name); + if ($this->source && $this->source->getPath()) { + // we only update the file and the line together + $this->file = $this->source->getPath(); + if ($this->lineno > 0) { + $this->line = $this->lineno; } else { - $name = json_encode($this->name); + $this->line = -1; } - $this->message .= sprintf(' in %s', $name); } - if ($this->lineno && $this->lineno >= 0) { - $this->message .= sprintf(' at line %d', $this->lineno); + $this->message = $this->rawMessage; + $last = substr($this->message, -1); + if ($punctuation = '.' === $last || '?' === $last ? $last : '') { + $this->message = substr($this->message, 0, -1); } - - if ($dot) { - $this->message .= '.'; + if ($this->source && $this->source->getName()) { + $this->message .= \sprintf(' in "%s"', $this->source->getName()); } - - if ($questionMark) { - $this->message .= '?'; + if ($this->lineno > 0) { + $this->message .= \sprintf(' at line %d', $this->lineno); + } + if ($punctuation) { + $this->message .= $punctuation; } } private function guessTemplateInfo(): void { - $template = null; - $templateClass = null; + // $this->source is never null here (see guess() usage in Template) + $this->lineno = 0; + $template = null; $backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT); foreach ($backtrace as $trace) { - if (isset($trace['object']) && $trace['object'] instanceof Template) { - $currentClass = \get_class($trace['object']); - $isEmbedContainer = null === $templateClass ? false : 0 === strpos($templateClass, $currentClass); - if (null === $this->name || ($this->name == $trace['object']->getTemplateName() && !$isEmbedContainer)) { - $template = $trace['object']; - $templateClass = \get_class($trace['object']); - } - } - } - - // update template name - if (null !== $template && null === $this->name) { - $this->name = $template->getTemplateName(); - } + if (isset($trace['object']) && $trace['object'] instanceof Template && $this->source->getName() === $trace['object']->getTemplateName()) { + $template = $trace['object']; - // update template path if any - if (null !== $template && null === $this->sourcePath) { - $src = $template->getSourceContext(); - $this->sourceCode = $src->getCode(); - $this->sourcePath = $src->getPath(); + break; + } } - if (null === $template || $this->lineno > -1) { - return; + if (null === $template) { + return; // Impossible to guess the info as the template was not found in the backtrace } $r = new \ReflectionObject($template); @@ -206,8 +162,7 @@ private function guessTemplateInfo(): void while ($e = array_pop($exceptions)) { $traces = $e->getTrace(); - array_unshift($traces, ['file' => $e->getFile(), 'line' => $e->getLine()]); - + array_unshift($traces, ['file' => $e instanceof self ? $e->phpFile : $e->getFile(), 'line' => $e instanceof self ? $e->phpLine : $e->getLine()]); while ($trace = array_shift($traces)) { if (!isset($trace['file']) || !isset($trace['line']) || $file != $trace['file']) { continue; diff --git a/src/Error/SyntaxError.php b/src/Error/SyntaxError.php index 726b3309e5b..841b653f552 100644 --- a/src/Error/SyntaxError.php +++ b/src/Error/SyntaxError.php @@ -30,7 +30,7 @@ public function addSuggestions(string $name, array $items): void $alternatives = []; foreach ($items as $item) { $lev = levenshtein($name, $item); - if ($lev <= \strlen($name) / 3 || false !== strpos($item, $name)) { + if ($lev <= \strlen($name) / 3 || str_contains($item, $name)) { $alternatives[$item] = $lev; } } @@ -41,6 +41,6 @@ public function addSuggestions(string $name, array $items): void asort($alternatives); - $this->appendMessage(sprintf(' Did you mean "%s"?', implode('", "', array_keys($alternatives)))); + $this->appendMessage(\sprintf(' Did you mean "%s"?', implode('", "', array_keys($alternatives)))); } } diff --git a/src/ExpressionParser.php b/src/ExpressionParser.php index 4e3eb30e31d..3ba94d076fa 100644 --- a/src/ExpressionParser.php +++ b/src/ExpressionParser.php @@ -13,25 +13,18 @@ namespace Twig; use Twig\Error\SyntaxError; -use Twig\Node\Expression\AbstractExpression; +use Twig\ExpressionParser\Infix\DotExpressionParser; +use Twig\ExpressionParser\Infix\FilterExpressionParser; +use Twig\ExpressionParser\Infix\SquareBracketExpressionParser; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\ArrowFunctionExpression; -use Twig\Node\Expression\AssignNameExpression; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Binary\ConcatBinary; -use Twig\Node\Expression\BlockReferenceExpression; -use Twig\Node\Expression\ConditionalExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\GetAttrExpression; -use Twig\Node\Expression\MethodCallExpression; -use Twig\Node\Expression\NameExpression; -use Twig\Node\Expression\ParentExpression; -use Twig\Node\Expression\TestExpression; -use Twig\Node\Expression\Unary\AbstractUnary; use Twig\Node\Expression\Unary\NegUnary; -use Twig\Node\Expression\Unary\NotUnary; use Twig\Node\Expression\Unary\PosUnary; +use Twig\Node\Expression\Unary\SpreadUnary; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; +use Twig\Node\Nodes; /** * Parses expressions. @@ -42,363 +35,108 @@ * @see https://en.wikipedia.org/wiki/Operator-precedence_parser * * @author Fabien Potencier + * + * @deprecated since Twig 3.21 */ class ExpressionParser { + /** + * @deprecated since Twig 3.21 + */ public const OPERATOR_LEFT = 1; + /** + * @deprecated since Twig 3.21 + */ public const OPERATOR_RIGHT = 2; - private $parser; - private $env; - /** @var array}> */ - private $unaryOperators; - /** @var array, associativity: self::OPERATOR_*}> */ - private $binaryOperators; - - public function __construct(Parser $parser, Environment $env) - { - $this->parser = $parser; - $this->env = $env; - $this->unaryOperators = $env->getUnaryOperators(); - $this->binaryOperators = $env->getBinaryOperators(); + public function __construct( + private Parser $parser, + private Environment $env, + ) { + trigger_deprecation('twig/twig', '3.21', 'Class "%s" is deprecated, use "Parser::parseExpression()" instead.', __CLASS__); } - public function parseExpression($precedence = 0, $allowArrow = false) + public function parseExpression($precedence = 0) { - if ($allowArrow && $arrow = $this->parseArrow()) { - return $arrow; + if (\func_num_args() > 1) { + trigger_deprecation('twig/twig', '3.15', 'Passing a second argument ($allowArrow) to "%s()" is deprecated.', __METHOD__); } - $expr = $this->getPrimary(); - $token = $this->parser->getCurrentToken(); - while ($this->isBinary($token) && $this->binaryOperators[$token->getValue()]['precedence'] >= $precedence) { - $op = $this->binaryOperators[$token->getValue()]; - $this->parser->getStream()->next(); + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated, use "Parser::parseExpression()" instead.', __METHOD__); - if ('is not' === $token->getValue()) { - $expr = $this->parseNotTestExpression($expr); - } elseif ('is' === $token->getValue()) { - $expr = $this->parseTestExpression($expr); - } elseif (isset($op['callable'])) { - $expr = $op['callable']($this->parser, $expr); - } else { - $expr1 = $this->parseExpression(self::OPERATOR_LEFT === $op['associativity'] ? $op['precedence'] + 1 : $op['precedence']); - $class = $op['class']; - $expr = new $class($expr, $expr1, $token->getLine()); - } - - $token = $this->parser->getCurrentToken(); - } - - if (0 === $precedence) { - return $this->parseConditionalExpression($expr); - } - - return $expr; + return $this->parser->parseExpression((int) $precedence); } /** - * @return ArrowFunctionExpression|null + * @deprecated since Twig 3.21 */ - private function parseArrow() - { - $stream = $this->parser->getStream(); - - // short array syntax (one argument, no parentheses)? - if ($stream->look(1)->test(/* Token::ARROW_TYPE */ 12)) { - $line = $stream->getCurrent()->getLine(); - $token = $stream->expect(/* Token::NAME_TYPE */ 5); - $names = [new AssignNameExpression($token->getValue(), $token->getLine())]; - $stream->expect(/* Token::ARROW_TYPE */ 12); - - return new ArrowFunctionExpression($this->parseExpression(0), new Node($names), $line); - } - - // first, determine if we are parsing an arrow function by finding => (long form) - $i = 0; - if (!$stream->look($i)->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - return null; - } - ++$i; - while (true) { - // variable name - ++$i; - if (!$stream->look($i)->test(/* Token::PUNCTUATION_TYPE */ 9, ',')) { - break; - } - ++$i; - } - if (!$stream->look($i)->test(/* Token::PUNCTUATION_TYPE */ 9, ')')) { - return null; - } - ++$i; - if (!$stream->look($i)->test(/* Token::ARROW_TYPE */ 12)) { - return null; - } - - // yes, let's parse it properly - $token = $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, '('); - $line = $token->getLine(); - - $names = []; - while (true) { - $token = $stream->expect(/* Token::NAME_TYPE */ 5); - $names[] = new AssignNameExpression($token->getValue(), $token->getLine()); - - if (!$stream->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ',')) { - break; - } - } - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ')'); - $stream->expect(/* Token::ARROW_TYPE */ 12); - - return new ArrowFunctionExpression($this->parseExpression(0), new Node($names), $line); - } - - private function getPrimary(): AbstractExpression - { - $token = $this->parser->getCurrentToken(); - - if ($this->isUnary($token)) { - $operator = $this->unaryOperators[$token->getValue()]; - $this->parser->getStream()->next(); - $expr = $this->parseExpression($operator['precedence']); - $class = $operator['class']; - - return $this->parsePostfixExpression(new $class($expr, $token->getLine())); - } elseif ($token->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - $this->parser->getStream()->next(); - $expr = $this->parseExpression(); - $this->parser->getStream()->expect(/* Token::PUNCTUATION_TYPE */ 9, ')', 'An opened parenthesis is not properly closed'); - - return $this->parsePostfixExpression($expr); - } - - return $this->parsePrimaryExpression(); - } - - private function parseConditionalExpression($expr): AbstractExpression - { - while ($this->parser->getStream()->nextIf(/* Token::PUNCTUATION_TYPE */ 9, '?')) { - if (!$this->parser->getStream()->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ':')) { - $expr2 = $this->parseExpression(); - if ($this->parser->getStream()->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ':')) { - $expr3 = $this->parseExpression(); - } else { - $expr3 = new ConstantExpression('', $this->parser->getCurrentToken()->getLine()); - } - } else { - $expr2 = $expr; - $expr3 = $this->parseExpression(); - } - - $expr = new ConditionalExpression($expr, $expr2, $expr3, $this->parser->getCurrentToken()->getLine()); - } - - return $expr; - } - - private function isUnary(Token $token): bool - { - return $token->test(/* Token::OPERATOR_TYPE */ 8) && isset($this->unaryOperators[$token->getValue()]); - } - - private function isBinary(Token $token): bool - { - return $token->test(/* Token::OPERATOR_TYPE */ 8) && isset($this->binaryOperators[$token->getValue()]); - } - public function parsePrimaryExpression() { - $token = $this->parser->getCurrentToken(); - switch ($token->getType()) { - case /* Token::NAME_TYPE */ 5: - $this->parser->getStream()->next(); - switch ($token->getValue()) { - case 'true': - case 'TRUE': - $node = new ConstantExpression(true, $token->getLine()); - break; - - case 'false': - case 'FALSE': - $node = new ConstantExpression(false, $token->getLine()); - break; - - case 'none': - case 'NONE': - case 'null': - case 'NULL': - $node = new ConstantExpression(null, $token->getLine()); - break; - - default: - if ('(' === $this->parser->getCurrentToken()->getValue()) { - $node = $this->getFunctionNode($token->getValue(), $token->getLine()); - } else { - $node = new NameExpression($token->getValue(), $token->getLine()); - } - } - break; - - case /* Token::NUMBER_TYPE */ 6: - $this->parser->getStream()->next(); - $node = new ConstantExpression($token->getValue(), $token->getLine()); - break; + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - case /* Token::STRING_TYPE */ 7: - case /* Token::INTERPOLATION_START_TYPE */ 10: - $node = $this->parseStringExpression(); - break; - - case /* Token::OPERATOR_TYPE */ 8: - if (preg_match(Lexer::REGEX_NAME, $token->getValue(), $matches) && $matches[0] == $token->getValue()) { - // in this context, string operators are variable names - $this->parser->getStream()->next(); - $node = new NameExpression($token->getValue(), $token->getLine()); - break; - } - - if (isset($this->unaryOperators[$token->getValue()])) { - $class = $this->unaryOperators[$token->getValue()]['class']; - if (!\in_array($class, [NegUnary::class, PosUnary::class])) { - throw new SyntaxError(sprintf('Unexpected unary operator "%s".', $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()); - } - - $this->parser->getStream()->next(); - $expr = $this->parsePrimaryExpression(); - - $node = new $class($expr, $token->getLine()); - break; - } - - // no break - default: - if ($token->test(/* Token::PUNCTUATION_TYPE */ 9, '[')) { - $node = $this->parseArrayExpression(); - } elseif ($token->test(/* Token::PUNCTUATION_TYPE */ 9, '{')) { - $node = $this->parseHashExpression(); - } elseif ($token->test(/* Token::OPERATOR_TYPE */ 8, '=') && ('==' === $this->parser->getStream()->look(-1)->getValue() || '!=' === $this->parser->getStream()->look(-1)->getValue())) { - throw new SyntaxError(sprintf('Unexpected operator of value "%s". Did you try to use "===" or "!==" for strict comparison? Use "is same as(value)" instead.', $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()); - } else { - throw new SyntaxError(sprintf('Unexpected token "%s" of value "%s".', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $this->parser->getStream()->getSourceContext()); - } - } - - return $this->parsePostfixExpression($node); + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.21 + */ public function parseStringExpression() { - $stream = $this->parser->getStream(); - - $nodes = []; - // a string cannot be followed by another string in a single expression - $nextCanBeString = true; - while (true) { - if ($nextCanBeString && $token = $stream->nextIf(/* Token::STRING_TYPE */ 7)) { - $nodes[] = new ConstantExpression($token->getValue(), $token->getLine()); - $nextCanBeString = false; - } elseif ($stream->nextIf(/* Token::INTERPOLATION_START_TYPE */ 10)) { - $nodes[] = $this->parseExpression(); - $stream->expect(/* Token::INTERPOLATION_END_TYPE */ 11); - $nextCanBeString = true; - } else { - break; - } - } + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - $expr = array_shift($nodes); - foreach ($nodes as $node) { - $expr = new ConcatBinary($expr, $node, $node->getTemplateLine()); - } - - return $expr; + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.11, use parseExpression() instead + */ public function parseArrayExpression() { - $stream = $this->parser->getStream(); - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, '[', 'An array element was expected'); - - $node = new ArrayExpression([], $stream->getCurrent()->getLine()); - $first = true; - while (!$stream->test(/* Token::PUNCTUATION_TYPE */ 9, ']')) { - if (!$first) { - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ',', 'An array element must be followed by a comma'); + trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); - // trailing ,? - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, ']')) { - break; - } - } - $first = false; + return $this->parseExpression(); + } - $node->addElement($this->parseExpression()); - } - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ']', 'An opened array is not properly closed'); + /** + * @deprecated since Twig 3.21 + */ + public function parseSequenceExpression() + { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - return $node; + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.11, use parseExpression() instead + */ public function parseHashExpression() { - $stream = $this->parser->getStream(); - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, '{', 'A hash element was expected'); - - $node = new ArrayExpression([], $stream->getCurrent()->getLine()); - $first = true; - while (!$stream->test(/* Token::PUNCTUATION_TYPE */ 9, '}')) { - if (!$first) { - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ',', 'A hash value must be followed by a comma'); - - // trailing ,? - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, '}')) { - break; - } - } - $first = false; - - // a hash key can be: - // - // * a number -- 12 - // * a string -- 'a' - // * a name, which is equivalent to a string -- a - // * an expression, which must be enclosed in parentheses -- (1 + 2) - if ($token = $stream->nextIf(/* Token::NAME_TYPE */ 5)) { - $key = new ConstantExpression($token->getValue(), $token->getLine()); - - // {a} is a shortcut for {a:a} - if ($stream->test(Token::PUNCTUATION_TYPE, [',', '}'])) { - $value = new NameExpression($key->getAttribute('value'), $key->getTemplateLine()); - $node->addElement($value, $key); - continue; - } - } elseif (($token = $stream->nextIf(/* Token::STRING_TYPE */ 7)) || $token = $stream->nextIf(/* Token::NUMBER_TYPE */ 6)) { - $key = new ConstantExpression($token->getValue(), $token->getLine()); - } elseif ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - $key = $this->parseExpression(); - } else { - $current = $stream->getCurrent(); - - throw new SyntaxError(sprintf('A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s".', Token::typeToEnglish($current->getType()), $current->getValue()), $current->getLine(), $stream->getSourceContext()); - } + trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ':', 'A hash key must be followed by a colon (:)'); - $value = $this->parseExpression(); + return $this->parseExpression(); + } - $node->addElement($value, $key); - } - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, '}', 'An opened hash is not properly closed'); + /** + * @deprecated since Twig 3.21 + */ + public function parseMappingExpression() + { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - return $node; + return $this->parseExpression(); } + /** + * @deprecated since Twig 3.21 + */ public function parsePostfixExpression($node) { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); + while (true) { $token = $this->parser->getCurrentToken(); - if (/* Token::PUNCTUATION_TYPE */ 9 == $token->getType()) { + if ($token->test(Token::PUNCTUATION_TYPE)) { if ('.' == $token->getValue() || '[' == $token->getValue()) { $node = $this->parseSubscriptExpression($node); } elseif ('|' == $token->getValue()) { @@ -414,159 +152,49 @@ public function parsePostfixExpression($node) return $node; } - public function getFunctionNode($name, $line) - { - switch ($name) { - case 'parent': - $this->parseArguments(); - if (!\count($this->parser->getBlockStack())) { - throw new SyntaxError('Calling "parent" outside a block is forbidden.', $line, $this->parser->getStream()->getSourceContext()); - } - - if (!$this->parser->getParent() && !$this->parser->hasTraits()) { - throw new SyntaxError('Calling "parent" on a template that does not extend nor "use" another template is forbidden.', $line, $this->parser->getStream()->getSourceContext()); - } - - return new ParentExpression($this->parser->peekBlockStack(), $line); - case 'block': - $args = $this->parseArguments(); - if (\count($args) < 1) { - throw new SyntaxError('The "block" function takes one argument (the block name).', $line, $this->parser->getStream()->getSourceContext()); - } - - return new BlockReferenceExpression($args->getNode(0), \count($args) > 1 ? $args->getNode(1) : null, $line); - case 'attribute': - $args = $this->parseArguments(); - if (\count($args) < 2) { - throw new SyntaxError('The "attribute" function takes at least two arguments (the variable and the attributes).', $line, $this->parser->getStream()->getSourceContext()); - } - - return new GetAttrExpression($args->getNode(0), $args->getNode(1), \count($args) > 2 ? $args->getNode(2) : null, Template::ANY_CALL, $line); - default: - if (null !== $alias = $this->parser->getImportedSymbol('function', $name)) { - $arguments = new ArrayExpression([], $line); - foreach ($this->parseArguments() as $n) { - $arguments->addElement($n); - } - - $node = new MethodCallExpression($alias['node'], $alias['name'], $arguments, $line); - $node->setAttribute('safe', true); - - return $node; - } - - $args = $this->parseArguments(true); - $class = $this->getFunctionNodeClass($name, $line); - - return new $class($name, $args, $line); - } - } - + /** + * @deprecated since Twig 3.21 + */ public function parseSubscriptExpression($node) { - $stream = $this->parser->getStream(); - $token = $stream->next(); - $lineno = $token->getLine(); - $arguments = new ArrayExpression([], $lineno); - $type = Template::ANY_CALL; - if ('.' == $token->getValue()) { - $token = $stream->next(); - if ( - /* Token::NAME_TYPE */ 5 == $token->getType() - || - /* Token::NUMBER_TYPE */ 6 == $token->getType() - || - (/* Token::OPERATOR_TYPE */ 8 == $token->getType() && preg_match(Lexer::REGEX_NAME, $token->getValue())) - ) { - $arg = new ConstantExpression($token->getValue(), $lineno); - - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - $type = Template::METHOD_CALL; - foreach ($this->parseArguments() as $n) { - $arguments->addElement($n); - } - } - } else { - throw new SyntaxError(sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), Token::typeToEnglish($token->getType())), $lineno, $stream->getSourceContext()); - } + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - if ($node instanceof NameExpression && null !== $this->parser->getImportedSymbol('template', $node->getAttribute('name'))) { - if (!$arg instanceof ConstantExpression) { - throw new SyntaxError(sprintf('Dynamic macro names are not supported (called on "%s").', $node->getAttribute('name')), $token->getLine(), $stream->getSourceContext()); - } - - $name = $arg->getAttribute('value'); - - $node = new MethodCallExpression($node, 'macro_'.$name, $arguments, $lineno); - $node->setAttribute('safe', true); - - return $node; - } - } else { - $type = Template::ARRAY_CALL; - - // slice? - $slice = false; - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, ':')) { - $slice = true; - $arg = new ConstantExpression(0, $token->getLine()); - } else { - $arg = $this->parseExpression(); - } + $parsers = new \ReflectionProperty($this->parser, 'parsers'); - if ($stream->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ':')) { - $slice = true; - } - - if ($slice) { - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, ']')) { - $length = new ConstantExpression(null, $token->getLine()); - } else { - $length = $this->parseExpression(); - } - - $class = $this->getFilterNodeClass('slice', $token->getLine()); - $arguments = new Node([$arg, $length]); - $filter = new $class($node, new ConstantExpression('slice', $token->getLine()), $arguments, $token->getLine()); - - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ']'); - - return $filter; - } - - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ']'); + if ('.' === $this->parser->getStream()->next()->getValue()) { + return $parsers->getValue($this->parser)->getByClass(DotExpressionParser::class)->parse($this->parser, $node, $this->parser->getCurrentToken()); } - return new GetAttrExpression($node, $arg, $arguments, $type, $lineno); + return $parsers->getValue($this->parser)->getByClass(SquareBracketExpressionParser::class)->parse($this->parser, $node, $this->parser->getCurrentToken()); } + /** + * @deprecated since Twig 3.21 + */ public function parseFilterExpression($node) { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); + $this->parser->getStream()->next(); return $this->parseFilterExpressionRaw($node); } - public function parseFilterExpressionRaw($node, $tag = null) + /** + * @deprecated since Twig 3.21 + */ + public function parseFilterExpressionRaw($node) { - while (true) { - $token = $this->parser->getStream()->expect(/* Token::NAME_TYPE */ 5); - - $name = new ConstantExpression($token->getValue(), $token->getLine()); - if (!$this->parser->getStream()->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - $arguments = new Node(); - } else { - $arguments = $this->parseArguments(true, false, true); - } - - $class = $this->getFilterNodeClass($name->getAttribute('value'), $token->getLine()); + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); - $node = new $class($node, $name, $arguments, $token->getLine(), $tag); + $parsers = new \ReflectionProperty($this->parser, 'parsers'); - if (!$this->parser->getStream()->test(/* Token::PUNCTUATION_TYPE */ 9, '|')) { + $op = $parsers->getValue($this->parser)->getByClass(FilterExpressionParser::class); + while (true) { + $node = $op->parse($this->parser, $node, $this->parser->getCurrentToken()); + if (!$this->parser->getStream()->test(Token::OPERATOR_TYPE, '|')) { break; } - $this->parser->getStream()->next(); } @@ -576,51 +204,72 @@ public function parseFilterExpressionRaw($node, $tag = null) /** * Parses arguments. * - * @param bool $namedArguments Whether to allow named arguments or not - * @param bool $definition Whether we are parsing arguments for a function definition - * * @return Node * * @throws SyntaxError + * + * @deprecated since Twig 3.19 Use Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments() instead */ - public function parseArguments($namedArguments = false, $definition = false, $allowArrow = false) + public function parseArguments() { + trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated, use "Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()" instead.', __METHOD__)); + + $parsePrimaryExpression = new \ReflectionMethod($this->parser, 'parsePrimaryExpression'); + + $namedArguments = false; + $definition = false; + if (\func_num_args() > 1) { + $definition = func_get_arg(1); + } + if (\func_num_args() > 0) { + trigger_deprecation('twig/twig', '3.15', 'Passing arguments to "%s()" is deprecated.', __METHOD__); + $namedArguments = func_get_arg(0); + } + $args = []; $stream = $this->parser->getStream(); - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, '(', 'A list of arguments must begin with an opening parenthesis'); - while (!$stream->test(/* Token::PUNCTUATION_TYPE */ 9, ')')) { - if (!empty($args)) { - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ',', 'Arguments must be separated by a comma'); + $stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + $hasSpread = false; + while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { + if ($args) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); // if the comma above was a trailing comma, early exit the argument parse loop - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, ')')) { + if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { break; } } if ($definition) { - $token = $stream->expect(/* Token::NAME_TYPE */ 5, null, 'An argument must be a name'); - $value = new NameExpression($token->getValue(), $this->parser->getCurrentToken()->getLine()); + $token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name'); + $value = new ContextVariable($token->getValue(), $this->parser->getCurrentToken()->getLine()); } else { - $value = $this->parseExpression(0, $allowArrow); + if ($stream->nextIf(Token::SPREAD_TYPE)) { + $hasSpread = true; + $value = new SpreadUnary($this->parseExpression(), $stream->getCurrent()->getLine()); + } elseif ($hasSpread) { + throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } else { + $value = $this->parseExpression(); + } } $name = null; - if ($namedArguments && $token = $stream->nextIf(/* Token::OPERATOR_TYPE */ 8, '=')) { - if (!$value instanceof NameExpression) { - throw new SyntaxError(sprintf('A parameter name must be a string, "%s" given.', \get_class($value)), $token->getLine(), $stream->getSourceContext()); + if ($namedArguments && (($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) || (!$definition && $token = $stream->nextIf(Token::PUNCTUATION_TYPE, ':')))) { + if (!$value instanceof ContextVariable) { + throw new SyntaxError(\sprintf('A parameter name must be a string, "%s" given.', $value::class), $token->getLine(), $stream->getSourceContext()); } $name = $value->getAttribute('name'); if ($definition) { - $value = $this->parsePrimaryExpression(); + $value = $parsePrimaryExpression->invoke($this->parser); if (!$this->checkConstantExpression($value)) { - throw new SyntaxError('A default value for an argument must be a constant (a boolean, a string, a number, or an array).', $token->getLine(), $stream->getSourceContext()); + throw new SyntaxError('A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping).', $token->getLine(), $stream->getSourceContext()); } } else { - $value = $this->parseExpression(0, $allowArrow); + $value = $this->parseExpression(); } } @@ -628,6 +277,7 @@ public function parseArguments($namedArguments = false, $definition = false, $al if (null === $name) { $name = $value->getAttribute('name'); $value = new ConstantExpression(null, $this->parser->getCurrentToken()->getLine()); + $value->setAttribute('is_implicit', true); } $args[$name] = $value; } else { @@ -638,176 +288,58 @@ public function parseArguments($namedArguments = false, $definition = false, $al } } } - $stream->expect(/* Token::PUNCTUATION_TYPE */ 9, ')', 'A list of arguments must be closed by a parenthesis'); + $stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); - return new Node($args); + return new Nodes($args); } + /** + * @deprecated since Twig 3.21, use "AbstractTokenParser::parseAssignmentExpression()" instead + */ public function parseAssignmentExpression() { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated, use "AbstractTokenParser::parseAssignmentExpression()" instead.', __METHOD__); + $stream = $this->parser->getStream(); $targets = []; while (true) { $token = $this->parser->getCurrentToken(); - if ($stream->test(/* Token::OPERATOR_TYPE */ 8) && preg_match(Lexer::REGEX_NAME, $token->getValue())) { + if ($stream->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) { // in this context, string operators are variable names $this->parser->getStream()->next(); } else { - $stream->expect(/* Token::NAME_TYPE */ 5, null, 'Only variables can be assigned to'); + $stream->expect(Token::NAME_TYPE, null, 'Only variables can be assigned to'); } - $value = $token->getValue(); - if (\in_array(strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), ['true', 'false', 'none', 'null'])) { - throw new SyntaxError(sprintf('You cannot assign a value to "%s".', $value), $token->getLine(), $stream->getSourceContext()); - } - $targets[] = new AssignNameExpression($value, $token->getLine()); + $targets[] = new AssignContextVariable($token->getValue(), $token->getLine()); - if (!$stream->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ',')) { + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { break; } } - return new Node($targets); + return new Nodes($targets); } + /** + * @deprecated since Twig 3.21 + */ public function parseMultitargetExpression() { + trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); + $targets = []; while (true) { $targets[] = $this->parseExpression(); - if (!$this->parser->getStream()->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ',')) { + if (!$this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ',')) { break; } } - return new Node($targets); - } - - private function parseNotTestExpression(Node $node): NotUnary - { - return new NotUnary($this->parseTestExpression($node), $this->parser->getCurrentToken()->getLine()); - } - - private function parseTestExpression(Node $node): TestExpression - { - $stream = $this->parser->getStream(); - list($name, $test) = $this->getTest($node->getTemplateLine()); - - $class = $this->getTestNodeClass($test); - $arguments = null; - if ($stream->test(/* Token::PUNCTUATION_TYPE */ 9, '(')) { - $arguments = $this->parseArguments(true); - } elseif ($test->hasOneMandatoryArgument()) { - $arguments = new Node([0 => $this->parsePrimaryExpression()]); - } - - if ('defined' === $name && $node instanceof NameExpression && null !== $alias = $this->parser->getImportedSymbol('function', $node->getAttribute('name'))) { - $node = new MethodCallExpression($alias['node'], $alias['name'], new ArrayExpression([], $node->getTemplateLine()), $node->getTemplateLine()); - $node->setAttribute('safe', true); - } - - return new $class($node, $name, $arguments, $this->parser->getCurrentToken()->getLine()); - } - - private function getTest(int $line): array - { - $stream = $this->parser->getStream(); - $name = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); - - if ($test = $this->env->getTest($name)) { - return [$name, $test]; - } - - if ($stream->test(/* Token::NAME_TYPE */ 5)) { - // try 2-words tests - $name = $name.' '.$this->parser->getCurrentToken()->getValue(); - - if ($test = $this->env->getTest($name)) { - $stream->next(); - - return [$name, $test]; - } - } - - $e = new SyntaxError(sprintf('Unknown "%s" test.', $name), $line, $stream->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getTests())); - - throw $e; - } - - private function getTestNodeClass(TwigTest $test): string - { - if ($test->isDeprecated()) { - $stream = $this->parser->getStream(); - $message = sprintf('Twig Test "%s" is deprecated', $test->getName()); - - if ($test->getDeprecatedVersion()) { - $message .= sprintf(' since version %s', $test->getDeprecatedVersion()); - } - if ($test->getAlternative()) { - $message .= sprintf('. Use "%s" instead', $test->getAlternative()); - } - $src = $stream->getSourceContext(); - $message .= sprintf(' in %s at line %d.', $src->getPath() ?: $src->getName(), $stream->getCurrent()->getLine()); - - @trigger_error($message, \E_USER_DEPRECATED); - } - - return $test->getNodeClass(); - } - - private function getFunctionNodeClass(string $name, int $line): string - { - if (!$function = $this->env->getFunction($name)) { - $e = new SyntaxError(sprintf('Unknown "%s" function.', $name), $line, $this->parser->getStream()->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getFunctions())); - - throw $e; - } - - if ($function->isDeprecated()) { - $message = sprintf('Twig Function "%s" is deprecated', $function->getName()); - if ($function->getDeprecatedVersion()) { - $message .= sprintf(' since version %s', $function->getDeprecatedVersion()); - } - if ($function->getAlternative()) { - $message .= sprintf('. Use "%s" instead', $function->getAlternative()); - } - $src = $this->parser->getStream()->getSourceContext(); - $message .= sprintf(' in %s at line %d.', $src->getPath() ?: $src->getName(), $line); - - @trigger_error($message, \E_USER_DEPRECATED); - } - - return $function->getNodeClass(); - } - - private function getFilterNodeClass(string $name, int $line): string - { - if (!$filter = $this->env->getFilter($name)) { - $e = new SyntaxError(sprintf('Unknown "%s" filter.', $name), $line, $this->parser->getStream()->getSourceContext()); - $e->addSuggestions($name, array_keys($this->env->getFilters())); - - throw $e; - } - - if ($filter->isDeprecated()) { - $message = sprintf('Twig Filter "%s" is deprecated', $filter->getName()); - if ($filter->getDeprecatedVersion()) { - $message .= sprintf(' since version %s', $filter->getDeprecatedVersion()); - } - if ($filter->getAlternative()) { - $message .= sprintf('. Use "%s" instead', $filter->getAlternative()); - } - $src = $this->parser->getStream()->getSourceContext(); - $message .= sprintf(' in %s at line %d.', $src->getPath() ?: $src->getName(), $line); - - @trigger_error($message, \E_USER_DEPRECATED); - } - - return $filter->getNodeClass(); + return new Nodes($targets); } // checks that the node only contains "constant" elements + // to be removed in 4.0 private function checkConstantExpression(Node $node): bool { if (!($node instanceof ConstantExpression || $node instanceof ArrayExpression @@ -824,4 +356,14 @@ private function checkConstantExpression(Node $node): bool return true; } + + /** + * @deprecated since Twig 3.19 Use Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments() instead + */ + public function parseOnlyArguments() + { + trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated, use "Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()" instead.', __METHOD__)); + + return $this->parseArguments(); + } } diff --git a/src/ExpressionParser/AbstractExpressionParser.php b/src/ExpressionParser/AbstractExpressionParser.php new file mode 100644 index 00000000000..bc05bfa051e --- /dev/null +++ b/src/ExpressionParser/AbstractExpressionParser.php @@ -0,0 +1,30 @@ +value, $this->getName()); + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return null; + } + + public function getAliases(): array + { + return []; + } +} diff --git a/src/ExpressionParser/ExpressionParserDescriptionInterface.php b/src/ExpressionParser/ExpressionParserDescriptionInterface.php new file mode 100644 index 00000000000..686f8a59f1e --- /dev/null +++ b/src/ExpressionParser/ExpressionParserDescriptionInterface.php @@ -0,0 +1,17 @@ + + */ + public function getAliases(): array; +} diff --git a/src/ExpressionParser/ExpressionParserType.php b/src/ExpressionParser/ExpressionParserType.php new file mode 100644 index 00000000000..8c21a8d7633 --- /dev/null +++ b/src/ExpressionParser/ExpressionParserType.php @@ -0,0 +1,33 @@ + + * + * @internal + */ +final class ExpressionParsers implements \IteratorAggregate +{ + /** + * @var array, array> + */ + private array $parsersByName = []; + + /** + * @var array, ExpressionParserInterface> + */ + private array $parsersByClass = []; + + /** + * @var \WeakMap>|null + */ + private ?\WeakMap $precedenceChanges = null; + + /** + * @param array $parsers + */ + public function __construct(array $parsers = []) + { + $this->add($parsers); + } + + /** + * @param array $parsers + * + * @return $this + */ + public function add(array $parsers): static + { + foreach ($parsers as $parser) { + if ($parser->getPrecedence() > 512 || $parser->getPrecedence() < 0) { + trigger_deprecation('twig/twig', '3.21', 'Precedence for "%s" must be between 0 and 512, got %d.', $parser->getName(), $parser->getPrecedence()); + // throw new \InvalidArgumentException(\sprintf('Precedence for "%s" must be between 0 and 512, got %d.', $parser->getName(), $parser->getPrecedence())); + } + $interface = $parser instanceof PrefixExpressionParserInterface ? PrefixExpressionParserInterface::class : InfixExpressionParserInterface::class; + $this->parsersByName[$interface][$parser->getName()] = $parser; + $this->parsersByClass[$parser::class] = $parser; + foreach ($parser->getAliases() as $alias) { + $this->parsersByName[$interface][$alias] = $parser; + } + } + + return $this; + } + + /** + * @template T of ExpressionParserInterface + * + * @param class-string $class + * + * @return T|null + */ + public function getByClass(string $class): ?ExpressionParserInterface + { + return $this->parsersByClass[$class] ?? null; + } + + /** + * @template T of ExpressionParserInterface + * + * @param class-string $interface + * + * @return T|null + */ + public function getByName(string $interface, string $name): ?ExpressionParserInterface + { + return $this->parsersByName[$interface][$name] ?? null; + } + + public function getIterator(): \Traversable + { + foreach ($this->parsersByName as $parsers) { + // we don't yield the keys + yield from $parsers; + } + } + + /** + * @internal + * + * @return \WeakMap> + */ + public function getPrecedenceChanges(): \WeakMap + { + if (null === $this->precedenceChanges) { + $this->precedenceChanges = new \WeakMap(); + foreach ($this as $ep) { + if (!$ep->getPrecedenceChange()) { + continue; + } + $min = min($ep->getPrecedenceChange()->getNewPrecedence(), $ep->getPrecedence()); + $max = max($ep->getPrecedenceChange()->getNewPrecedence(), $ep->getPrecedence()); + foreach ($this as $e) { + if ($e->getPrecedence() > $min && $e->getPrecedence() < $max) { + if (!isset($this->precedenceChanges[$e])) { + $this->precedenceChanges[$e] = []; + } + $this->precedenceChanges[$e][] = $ep; + } + } + } + } + + return $this->precedenceChanges; + } +} diff --git a/src/ExpressionParser/Infix/ArgumentsTrait.php b/src/ExpressionParser/Infix/ArgumentsTrait.php new file mode 100644 index 00000000000..1c2ae49dd3a --- /dev/null +++ b/src/ExpressionParser/Infix/ArgumentsTrait.php @@ -0,0 +1,79 @@ +parseNamedArguments($parser, $parseOpenParenthesis) as $k => $n) { + $arguments->addElement($n, new LocalVariable($k, $line)); + } + + return $arguments; + } + + private function parseNamedArguments(Parser $parser, bool $parseOpenParenthesis = true): Nodes + { + $args = []; + $stream = $parser->getStream(); + if ($parseOpenParenthesis) { + $stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + } + $hasSpread = false; + while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { + if ($args) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); + + // if the comma above was a trailing comma, early exit the argument parse loop + if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { + break; + } + } + + $value = $parser->parseExpression(); + if ($value instanceof SpreadUnary) { + $hasSpread = true; + } elseif ($hasSpread) { + throw new SyntaxError('Normal arguments must be placed before argument unpacking.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + + $name = null; + if (($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) || ($token = $stream->nextIf(Token::PUNCTUATION_TYPE, ':'))) { + if (!$value instanceof ContextVariable) { + throw new SyntaxError(\sprintf('A parameter name must be a string, "%s" given.', $value::class), $token->getLine(), $stream->getSourceContext()); + } + $name = $value->getAttribute('name'); + $value = $parser->parseExpression(); + } + + if (null === $name) { + $args[] = $value; + } else { + $args[$name] = $value; + } + } + $stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); + + return new Nodes($args); + } +} diff --git a/src/ExpressionParser/Infix/ArrowExpressionParser.php b/src/ExpressionParser/Infix/ArrowExpressionParser.php new file mode 100644 index 00000000000..c8630da41e7 --- /dev/null +++ b/src/ExpressionParser/Infix/ArrowExpressionParser.php @@ -0,0 +1,53 @@ +parseExpression(), $expr, $token->getLine()); + } + + public function getName(): string + { + return '=>'; + } + + public function getDescription(): string + { + return 'Arrow function (x => expr)'; + } + + public function getPrecedence(): int + { + return 250; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php b/src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php new file mode 100644 index 00000000000..4c66da73bc1 --- /dev/null +++ b/src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php @@ -0,0 +1,80 @@ + */ + private string $nodeClass, + private string $name, + private int $precedence, + private InfixAssociativity $associativity = InfixAssociativity::Left, + private ?PrecedenceChange $precedenceChange = null, + private ?string $description = null, + private array $aliases = [], + ) { + } + + /** + * @return AbstractBinary + */ + public function parse(Parser $parser, AbstractExpression $left, Token $token): AbstractExpression + { + $right = $parser->parseExpression(InfixAssociativity::Left === $this->getAssociativity() ? $this->getPrecedence() + 1 : $this->getPrecedence()); + + return new ($this->nodeClass)($left, $right, $token->getLine()); + } + + public function getAssociativity(): InfixAssociativity + { + return $this->associativity; + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description ?? ''; + } + + public function getPrecedence(): int + { + return $this->precedence; + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return $this->precedenceChange; + } + + public function getAliases(): array + { + return $this->aliases; + } +} diff --git a/src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php b/src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php new file mode 100644 index 00000000000..9707c0a04bd --- /dev/null +++ b/src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php @@ -0,0 +1,62 @@ +parseExpression($this->getPrecedence()); + if ($parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ':')) { + // Ternary operator (expr ? expr2 : expr3) + $else = $parser->parseExpression($this->getPrecedence()); + } else { + // Ternary without else (expr ? expr2) + $else = new ConstantExpression('', $token->getLine()); + } + + return new ConditionalTernary($left, $then, $else, $token->getLine()); + } + + public function getName(): string + { + return '?'; + } + + public function getDescription(): string + { + return 'Conditional operator (a ? b : c)'; + } + + public function getPrecedence(): int + { + return 0; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/DotExpressionParser.php b/src/ExpressionParser/Infix/DotExpressionParser.php new file mode 100644 index 00000000000..7d1cf505827 --- /dev/null +++ b/src/ExpressionParser/Infix/DotExpressionParser.php @@ -0,0 +1,99 @@ +getStream(); + $token = $stream->getCurrent(); + $lineno = $token->getLine(); + $arguments = new ArrayExpression([], $lineno); + $type = Template::ANY_CALL; + + if ($stream->nextIf(Token::OPERATOR_TYPE, '(')) { + $attribute = $parser->parseExpression(); + $stream->expect(Token::PUNCTUATION_TYPE, ')'); + } else { + $token = $stream->next(); + if ( + $token->test(Token::NAME_TYPE) + || $token->test(Token::NUMBER_TYPE) + || ($token->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) + ) { + $attribute = new ConstantExpression($token->getValue(), $token->getLine()); + } else { + throw new SyntaxError(\sprintf('Expected name or number, got value "%s" of type %s.', $token->getValue(), $token->toEnglish()), $token->getLine(), $stream->getSourceContext()); + } + } + + if ($stream->test(Token::OPERATOR_TYPE, '(')) { + $type = Template::METHOD_CALL; + $arguments = $this->parseCallableArguments($parser, $token->getLine()); + } + + if ( + $expr instanceof NameExpression + && ( + null !== $parser->getImportedSymbol('template', $expr->getAttribute('name')) + || '_self' === $expr->getAttribute('name') && $attribute instanceof ConstantExpression + ) + ) { + return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $expr->getTemplateLine()); + } + + return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno); + } + + public function getName(): string + { + return '.'; + } + + public function getDescription(): string + { + return 'Get an attribute on a variable'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/FilterExpressionParser.php b/src/ExpressionParser/Infix/FilterExpressionParser.php new file mode 100644 index 00000000000..0bbe6b40969 --- /dev/null +++ b/src/ExpressionParser/Infix/FilterExpressionParser.php @@ -0,0 +1,85 @@ +getStream(); + $token = $stream->expect(Token::NAME_TYPE); + $line = $token->getLine(); + + if (!$stream->test(Token::OPERATOR_TYPE, '(')) { + $arguments = new EmptyNode(); + } else { + $arguments = $this->parseNamedArguments($parser); + } + + $filter = $parser->getFilter($token->getValue(), $line); + + $ready = true; + if (!isset($this->readyNodes[$class = $filter->getNodeClass()])) { + $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); + } + + if (!$ready = $this->readyNodes[$class]) { + trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" 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.', $class); + } + + return new $class($expr, $ready ? $filter : new ConstantExpression($filter->getName(), $line), $arguments, $line); + } + + public function getName(): string + { + return '|'; + } + + public function getDescription(): string + { + return 'Twig filter call'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return new PrecedenceChange('twig/twig', '3.21', 300); + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/FunctionExpressionParser.php b/src/ExpressionParser/Infix/FunctionExpressionParser.php new file mode 100644 index 00000000000..e9cd7751793 --- /dev/null +++ b/src/ExpressionParser/Infix/FunctionExpressionParser.php @@ -0,0 +1,90 @@ +getLine(); + if (!$expr instanceof NameExpression) { + throw new SyntaxError('Function name must be an identifier.', $line, $parser->getStream()->getSourceContext()); + } + + $name = $expr->getAttribute('name'); + + if (null !== $alias = $parser->getImportedSymbol('function', $name)) { + return new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], $this->parseCallableArguments($parser, $line, false), $line); + } + + $args = $this->parseNamedArguments($parser, false); + + $function = $parser->getFunction($name, $line); + + if ($function->getParserCallable()) { + $fakeNode = new EmptyNode($line); + $fakeNode->setSourceContext($parser->getStream()->getSourceContext()); + + return ($function->getParserCallable())($parser, $fakeNode, $args, $line); + } + + if (!isset($this->readyNodes[$class = $function->getNodeClass()])) { + $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); + } + + if (!$ready = $this->readyNodes[$class]) { + trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" 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.', $class); + } + + return new $class($ready ? $function : $function->getName(), $args, $line); + } + + public function getName(): string + { + return '('; + } + + public function getDescription(): string + { + return 'Twig function call'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/IsExpressionParser.php b/src/ExpressionParser/Infix/IsExpressionParser.php new file mode 100644 index 00000000000..88d54f70a7b --- /dev/null +++ b/src/ExpressionParser/Infix/IsExpressionParser.php @@ -0,0 +1,84 @@ +getStream(); + $test = $parser->getTest($token->getLine()); + + $arguments = null; + if ($stream->test(Token::OPERATOR_TYPE, '(')) { + $arguments = $this->parseNamedArguments($parser); + } elseif ($test->hasOneMandatoryArgument()) { + $arguments = new Nodes([0 => $parser->parseExpression($this->getPrecedence())]); + } + + if ('defined' === $test->getName() && $expr instanceof NameExpression && null !== $alias = $parser->getImportedSymbol('function', $expr->getAttribute('name'))) { + $expr = new MacroReferenceExpression($alias['node']->getNode('var'), $alias['name'], new ArrayExpression([], $expr->getTemplateLine()), $expr->getTemplateLine()); + } + + $ready = $test instanceof TwigTest; + if (!isset($this->readyNodes[$class = $test->getNodeClass()])) { + $this->readyNodes[$class] = (bool) (new \ReflectionClass($class))->getConstructor()->getAttributes(FirstClassTwigCallableReady::class); + } + + if (!$ready = $this->readyNodes[$class]) { + trigger_deprecation('twig/twig', '3.12', 'Twig node "%s" 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.', $class); + } + + return new $class($expr, $ready ? $test : $test->getName(), $arguments, $stream->getCurrent()->getLine()); + } + + public function getPrecedence(): int + { + return 100; + } + + public function getName(): string + { + return 'is'; + } + + public function getDescription(): string + { + return 'Twig tests'; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/Infix/IsNotExpressionParser.php b/src/ExpressionParser/Infix/IsNotExpressionParser.php new file mode 100644 index 00000000000..1e1085aa835 --- /dev/null +++ b/src/ExpressionParser/Infix/IsNotExpressionParser.php @@ -0,0 +1,33 @@ +getLine()); + } + + public function getName(): string + { + return 'is not'; + } +} diff --git a/src/ExpressionParser/Infix/SquareBracketExpressionParser.php b/src/ExpressionParser/Infix/SquareBracketExpressionParser.php new file mode 100644 index 00000000000..c47c91dee36 --- /dev/null +++ b/src/ExpressionParser/Infix/SquareBracketExpressionParser.php @@ -0,0 +1,91 @@ +getStream(); + $lineno = $token->getLine(); + $arguments = new ArrayExpression([], $lineno); + + // slice? + $slice = false; + if ($stream->test(Token::PUNCTUATION_TYPE, ':')) { + $slice = true; + $attribute = new ConstantExpression(0, $token->getLine()); + } else { + $attribute = $parser->parseExpression(); + } + + if ($stream->nextIf(Token::PUNCTUATION_TYPE, ':')) { + $slice = true; + } + + if ($slice) { + if ($stream->test(Token::PUNCTUATION_TYPE, ']')) { + $length = new ConstantExpression(null, $token->getLine()); + } else { + $length = $parser->parseExpression(); + } + + $filter = $parser->getFilter('slice', $token->getLine()); + $arguments = new Nodes([$attribute, $length]); + $filter = new ($filter->getNodeClass())($expr, $filter, $arguments, $token->getLine()); + + $stream->expect(Token::PUNCTUATION_TYPE, ']'); + + return $filter; + } + + $stream->expect(Token::PUNCTUATION_TYPE, ']'); + + return new GetAttrExpression($expr, $attribute, $arguments, Template::ARRAY_CALL, $lineno); + } + + public function getName(): string + { + return '['; + } + + public function getDescription(): string + { + return 'Array access'; + } + + public function getPrecedence(): int + { + return 512; + } + + public function getAssociativity(): InfixAssociativity + { + return InfixAssociativity::Left; + } +} diff --git a/src/ExpressionParser/InfixAssociativity.php b/src/ExpressionParser/InfixAssociativity.php new file mode 100644 index 00000000000..3aeccce4565 --- /dev/null +++ b/src/ExpressionParser/InfixAssociativity.php @@ -0,0 +1,18 @@ + + */ +class PrecedenceChange +{ + public function __construct( + private string $package, + private string $version, + private int $newPrecedence, + ) { + } + + public function getPackage(): string + { + return $this->package; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getNewPrecedence(): int + { + return $this->newPrecedence; + } +} diff --git a/src/ExpressionParser/Prefix/GroupingExpressionParser.php b/src/ExpressionParser/Prefix/GroupingExpressionParser.php new file mode 100644 index 00000000000..5c6608da401 --- /dev/null +++ b/src/ExpressionParser/Prefix/GroupingExpressionParser.php @@ -0,0 +1,78 @@ +getStream(); + $expr = $parser->parseExpression($this->getPrecedence()); + + if ($stream->nextIf(Token::PUNCTUATION_TYPE, ')')) { + if (!$stream->test(Token::OPERATOR_TYPE, '=>')) { + return $expr->setExplicitParentheses(); + } + + return new ListExpression([$expr], $token->getLine()); + } + + // determine if we are parsing an arrow function arguments + if (!$stream->test(Token::PUNCTUATION_TYPE, ',')) { + $stream->expect(Token::PUNCTUATION_TYPE, ')', 'An opened parenthesis is not properly closed'); + } + + $names = [$expr]; + while (true) { + if ($stream->nextIf(Token::PUNCTUATION_TYPE, ')')) { + break; + } + $stream->expect(Token::PUNCTUATION_TYPE, ','); + $token = $stream->expect(Token::NAME_TYPE); + $names[] = new ContextVariable($token->getValue(), $token->getLine()); + } + + if (!$stream->test(Token::OPERATOR_TYPE, '=>')) { + throw new SyntaxError('A list of variables must be followed by an arrow.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + + return new ListExpression($names, $token->getLine()); + } + + public function getName(): string + { + return '('; + } + + public function getDescription(): string + { + return 'Explicit group expression (a)'; + } + + public function getPrecedence(): int + { + return 0; + } +} diff --git a/src/ExpressionParser/Prefix/LiteralExpressionParser.php b/src/ExpressionParser/Prefix/LiteralExpressionParser.php new file mode 100644 index 00000000000..d98c9adf1f9 --- /dev/null +++ b/src/ExpressionParser/Prefix/LiteralExpressionParser.php @@ -0,0 +1,244 @@ +getStream(); + switch (true) { + case $token->test(Token::NAME_TYPE): + $stream->next(); + switch ($token->getValue()) { + case 'true': + case 'TRUE': + $this->type = 'constant'; + + return new ConstantExpression(true, $token->getLine()); + + case 'false': + case 'FALSE': + $this->type = 'constant'; + + return new ConstantExpression(false, $token->getLine()); + + case 'none': + case 'NONE': + case 'null': + case 'NULL': + $this->type = 'constant'; + + return new ConstantExpression(null, $token->getLine()); + + default: + $this->type = 'variable'; + + return new ContextVariable($token->getValue(), $token->getLine()); + } + + // no break + case $token->test(Token::NUMBER_TYPE): + $stream->next(); + $this->type = 'constant'; + + return new ConstantExpression($token->getValue(), $token->getLine()); + + case $token->test(Token::STRING_TYPE): + case $token->test(Token::INTERPOLATION_START_TYPE): + $this->type = 'string'; + + return $this->parseStringExpression($parser); + + case $token->test(Token::PUNCTUATION_TYPE): + // In 4.0, we should always return the node or throw an error for default + if ($node = match ($token->getValue()) { + '{' => $this->parseMappingExpression($parser), + default => null, + }) { + return $node; + } + + // no break + case $token->test(Token::OPERATOR_TYPE): + if ('[' === $token->getValue()) { + return $this->parseSequenceExpression($parser); + } + + if (preg_match(Lexer::REGEX_NAME, $token->getValue(), $matches) && $matches[0] == $token->getValue()) { + // in this context, string operators are variable names + $stream->next(); + $this->type = 'variable'; + + return new ContextVariable($token->getValue(), $token->getLine()); + } + + if ('=' === $token->getValue() && ('==' === $stream->look(-1)->getValue() || '!=' === $stream->look(-1)->getValue())) { + throw new SyntaxError(\sprintf('Unexpected operator of value "%s". Did you try to use "===" or "!==" for strict comparison? Use "is same as(value)" instead.', $token->getValue()), $token->getLine(), $stream->getSourceContext()); + } + + // no break + default: + throw new SyntaxError(\sprintf('Unexpected token "%s" of value "%s".', $token->toEnglish(), $token->getValue()), $token->getLine(), $stream->getSourceContext()); + } + } + + public function getName(): string + { + return $this->type; + } + + public function getDescription(): string + { + return 'A literal value (boolean, string, number, sequence, mapping, ...)'; + } + + public function getPrecedence(): int + { + // not used + return 0; + } + + private function parseStringExpression(Parser $parser) + { + $stream = $parser->getStream(); + + $nodes = []; + // a string cannot be followed by another string in a single expression + $nextCanBeString = true; + while (true) { + if ($nextCanBeString && $token = $stream->nextIf(Token::STRING_TYPE)) { + $nodes[] = new ConstantExpression($token->getValue(), $token->getLine()); + $nextCanBeString = false; + } elseif ($stream->nextIf(Token::INTERPOLATION_START_TYPE)) { + $nodes[] = $parser->parseExpression(); + $stream->expect(Token::INTERPOLATION_END_TYPE); + $nextCanBeString = true; + } else { + break; + } + } + + $expr = array_shift($nodes); + foreach ($nodes as $node) { + $expr = new ConcatBinary($expr, $node, $node->getTemplateLine()); + } + + return $expr; + } + + private function parseSequenceExpression(Parser $parser) + { + $this->type = 'sequence'; + + $stream = $parser->getStream(); + $stream->expect(Token::OPERATOR_TYPE, '[', 'A sequence element was expected'); + + $node = new ArrayExpression([], $stream->getCurrent()->getLine()); + $first = true; + while (!$stream->test(Token::PUNCTUATION_TYPE, ']')) { + if (!$first) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A sequence element must be followed by a comma'); + + // trailing ,? + if ($stream->test(Token::PUNCTUATION_TYPE, ']')) { + break; + } + } + $first = false; + + $node->addElement($parser->parseExpression()); + } + $stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened sequence is not properly closed'); + + return $node; + } + + private function parseMappingExpression(Parser $parser) + { + $this->type = 'mapping'; + + $stream = $parser->getStream(); + $stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected'); + + $node = new ArrayExpression([], $stream->getCurrent()->getLine()); + $first = true; + while (!$stream->test(Token::PUNCTUATION_TYPE, '}')) { + if (!$first) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A mapping value must be followed by a comma'); + + // trailing ,? + if ($stream->test(Token::PUNCTUATION_TYPE, '}')) { + break; + } + } + $first = false; + + if ($stream->test(Token::OPERATOR_TYPE, '...')) { + $node->addElement($parser->parseExpression()); + + continue; + } + + // a mapping key can be: + // + // * a number -- 12 + // * a string -- 'a' + // * a name, which is equivalent to a string -- a + // * an expression, which must be enclosed in parentheses -- (1 + 2) + if ($token = $stream->nextIf(Token::NAME_TYPE)) { + $key = new ConstantExpression($token->getValue(), $token->getLine()); + + // {a} is a shortcut for {a:a} + if ($stream->test(Token::PUNCTUATION_TYPE, [',', '}'])) { + $value = new ContextVariable($key->getAttribute('value'), $key->getTemplateLine()); + $node->addElement($value, $key); + continue; + } + } elseif (($token = $stream->nextIf(Token::STRING_TYPE)) || $token = $stream->nextIf(Token::NUMBER_TYPE)) { + $key = new ConstantExpression($token->getValue(), $token->getLine()); + } elseif ($stream->test(Token::OPERATOR_TYPE, '(')) { + $key = $parser->parseExpression(); + } else { + $current = $stream->getCurrent(); + + throw new SyntaxError(\sprintf('A mapping key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "%s" of value "%s".', $current->toEnglish(), $current->getValue()), $current->getLine(), $stream->getSourceContext()); + } + + $stream->expect(Token::PUNCTUATION_TYPE, ':', 'A mapping key must be followed by a colon (:)'); + $value = $parser->parseExpression(); + + $node->addElement($value, $key); + } + $stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed'); + + return $node; + } +} diff --git a/src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php b/src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php new file mode 100644 index 00000000000..35468940a14 --- /dev/null +++ b/src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php @@ -0,0 +1,71 @@ + */ + private string $nodeClass, + private string $name, + private int $precedence, + private ?PrecedenceChange $precedenceChange = null, + private ?string $description = null, + private array $aliases = [], + ) { + } + + /** + * @return AbstractUnary + */ + public function parse(Parser $parser, Token $token): AbstractExpression + { + return new ($this->nodeClass)($parser->parseExpression($this->precedence), $token->getLine()); + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): string + { + return $this->description ?? ''; + } + + public function getPrecedence(): int + { + return $this->precedence; + } + + public function getPrecedenceChange(): ?PrecedenceChange + { + return $this->precedenceChange; + } + + public function getAliases(): array + { + return $this->aliases; + } +} diff --git a/src/ExpressionParser/PrefixExpressionParserInterface.php b/src/ExpressionParser/PrefixExpressionParserInterface.php new file mode 100644 index 00000000000..587997c51a2 --- /dev/null +++ b/src/ExpressionParser/PrefixExpressionParserInterface.php @@ -0,0 +1,21 @@ +getFileName(); + if (!is_file($filename)) { + return 0; + } + + $lastModified = filemtime($filename); + + // Track modifications of the runtime class if it exists and follows the naming convention + if (str_ends_with($filename, 'Extension.php') && is_file($filename = substr($filename, 0, -13).'Runtime.php')) { + $lastModified = max($lastModified, filemtime($filename)); + } + + return $lastModified; + } } diff --git a/src/Extension/AttributeExtension.php b/src/Extension/AttributeExtension.php new file mode 100644 index 00000000000..74fcbb85706 --- /dev/null +++ b/src/Extension/AttributeExtension.php @@ -0,0 +1,173 @@ + + */ +final class AttributeExtension extends AbstractExtension +{ + private array $filters; + private array $functions; + private array $tests; + + /** + * Use a runtime class using PHP attributes to define filters, functions, and tests. + * + * @param class-string $class + */ + public function __construct(private string $class) + { + } + + /** + * @return class-string + */ + public function getClass(): string + { + return $this->class; + } + + public function getFilters(): array + { + if (!isset($this->filters)) { + $this->initFromAttributes(); + } + + return $this->filters; + } + + public function getFunctions(): array + { + if (!isset($this->functions)) { + $this->initFromAttributes(); + } + + return $this->functions; + } + + public function getTests(): array + { + if (!isset($this->tests)) { + $this->initFromAttributes(); + } + + return $this->tests; + } + + public function getLastModified(): int + { + return max( + filemtime(__FILE__), + is_file($filename = (new \ReflectionClass($this->getClass()))->getFileName()) ? filemtime($filename) : 0, + ); + } + + private function initFromAttributes(): void + { + $filters = $functions = $tests = []; + $reflectionClass = new \ReflectionClass($this->getClass()); + foreach ($reflectionClass->getMethods() as $method) { + foreach ($method->getAttributes(AsTwigFilter::class) as $reflectionAttribute) { + /** @var AsTwigFilter $attribute */ + $attribute = $reflectionAttribute->newInstance(); + + $callable = new TwigFilter($attribute->name, [$reflectionClass->name, $method->getName()], [ + 'needs_context' => $attribute->needsContext ?? false, + 'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method), + 'needs_charset' => $attribute->needsCharset ?? false, + 'is_variadic' => $method->isVariadic(), + 'is_safe' => $attribute->isSafe, + 'is_safe_callback' => $attribute->isSafeCallback, + 'pre_escape' => $attribute->preEscape, + 'preserves_safety' => $attribute->preservesSafety, + 'deprecation_info' => $attribute->deprecationInfo, + ]); + + if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) { + throw new \LogicException(\sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigFilter, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters())); + } + + $filters[$attribute->name] = $callable; + } + + foreach ($method->getAttributes(AsTwigFunction::class) as $reflectionAttribute) { + /** @var AsTwigFunction $attribute */ + $attribute = $reflectionAttribute->newInstance(); + + $callable = new TwigFunction($attribute->name, [$reflectionClass->name, $method->getName()], [ + 'needs_context' => $attribute->needsContext ?? false, + 'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method), + 'needs_charset' => $attribute->needsCharset ?? false, + 'is_variadic' => $method->isVariadic(), + 'is_safe' => $attribute->isSafe, + 'is_safe_callback' => $attribute->isSafeCallback, + 'deprecation_info' => $attribute->deprecationInfo, + ]); + + if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) { + throw new \LogicException(\sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigFunction, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters())); + } + + $functions[$attribute->name] = $callable; + } + + foreach ($method->getAttributes(AsTwigTest::class) as $reflectionAttribute) { + /** @var AsTwigTest $attribute */ + $attribute = $reflectionAttribute->newInstance(); + + $callable = new TwigTest($attribute->name, [$reflectionClass->name, $method->getName()], [ + 'needs_context' => $attribute->needsContext ?? false, + 'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method), + 'needs_charset' => $attribute->needsCharset ?? false, + 'is_variadic' => $method->isVariadic(), + 'deprecation_info' => $attribute->deprecationInfo, + ]); + + if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) { + throw new \LogicException(\sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigTest, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters())); + } + + $tests[$attribute->name] = $callable; + } + } + + // Assign all at the end to avoid inconsistent state in case of exception + $this->filters = array_values($filters); + $this->functions = array_values($functions); + $this->tests = array_values($tests); + } + + /** + * Detect if the first argument of the method is the environment. + */ + private function needsEnvironment(\ReflectionFunctionAbstract $function): bool + { + if (!$parameters = $function->getParameters()) { + return false; + } + + return $parameters[0]->getType() instanceof \ReflectionNamedType + && Environment::class === $parameters[0]->getType()->getName() + && !$parameters[0]->isVariadic(); + } +} diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index b7798585987..fcfbcb11656 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -9,8 +9,29 @@ * file that was distributed with this source code. */ -namespace Twig\Extension { -use Twig\ExpressionParser; +namespace Twig\Extension; + +use Twig\DeprecatedCallableInfo; +use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Error\RuntimeError; +use Twig\Error\SyntaxError; +use Twig\ExpressionParser\Infix\ArrowExpressionParser; +use Twig\ExpressionParser\Infix\BinaryOperatorExpressionParser; +use Twig\ExpressionParser\Infix\ConditionalTernaryExpressionParser; +use Twig\ExpressionParser\Infix\DotExpressionParser; +use Twig\ExpressionParser\Infix\FilterExpressionParser; +use Twig\ExpressionParser\Infix\FunctionExpressionParser; +use Twig\ExpressionParser\Infix\IsExpressionParser; +use Twig\ExpressionParser\Infix\IsNotExpressionParser; +use Twig\ExpressionParser\Infix\SquareBracketExpressionParser; +use Twig\ExpressionParser\InfixAssociativity; +use Twig\ExpressionParser\PrecedenceChange; +use Twig\ExpressionParser\Prefix\GroupingExpressionParser; +use Twig\ExpressionParser\Prefix\LiteralExpressionParser; +use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; +use Twig\Markup; +use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\Binary\AddBinary; use Twig\Node\Expression\Binary\AndBinary; use Twig\Node\Expression\Binary\BitwiseAndBinary; @@ -18,11 +39,14 @@ use Twig\Node\Expression\Binary\BitwiseXorBinary; use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\Binary\DivBinary; +use Twig\Node\Expression\Binary\ElvisBinary; use Twig\Node\Expression\Binary\EndsWithBinary; use Twig\Node\Expression\Binary\EqualBinary; use Twig\Node\Expression\Binary\FloorDivBinary; use Twig\Node\Expression\Binary\GreaterBinary; use Twig\Node\Expression\Binary\GreaterEqualBinary; +use Twig\Node\Expression\Binary\HasEveryBinary; +use Twig\Node\Expression\Binary\HasSomeBinary; use Twig\Node\Expression\Binary\InBinary; use Twig\Node\Expression\Binary\LessBinary; use Twig\Node\Expression\Binary\LessEqualBinary; @@ -31,14 +55,20 @@ use Twig\Node\Expression\Binary\MulBinary; use Twig\Node\Expression\Binary\NotEqualBinary; use Twig\Node\Expression\Binary\NotInBinary; +use Twig\Node\Expression\Binary\NullCoalesceBinary; use Twig\Node\Expression\Binary\OrBinary; use Twig\Node\Expression\Binary\PowerBinary; use Twig\Node\Expression\Binary\RangeBinary; use Twig\Node\Expression\Binary\SpaceshipBinary; use Twig\Node\Expression\Binary\StartsWithBinary; use Twig\Node\Expression\Binary\SubBinary; +use Twig\Node\Expression\Binary\XorBinary; +use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\Expression\Filter\DefaultFilter; -use Twig\Node\Expression\NullCoalesceExpression; +use Twig\Node\Expression\FunctionNode\EnumCasesFunction; +use Twig\Node\Expression\FunctionNode\EnumFunction; +use Twig\Node\Expression\GetAttrExpression; +use Twig\Node\Expression\ParentExpression; use Twig\Node\Expression\Test\ConstantTest; use Twig\Node\Expression\Test\DefinedTest; use Twig\Node\Expression\Test\DivisiblebyTest; @@ -46,10 +76,18 @@ use Twig\Node\Expression\Test\NullTest; use Twig\Node\Expression\Test\OddTest; use Twig\Node\Expression\Test\SameasTest; +use Twig\Node\Expression\Test\TrueTest; use Twig\Node\Expression\Unary\NegUnary; use Twig\Node\Expression\Unary\NotUnary; use Twig\Node\Expression\Unary\PosUnary; -use Twig\NodeVisitor\MacroAutoImportNodeVisitor; +use Twig\Node\Expression\Unary\SpreadUnary; +use Twig\Node\Node; +use Twig\Parser; +use Twig\Sandbox\SecurityNotAllowedMethodError; +use Twig\Sandbox\SecurityNotAllowedPropertyError; +use Twig\Source; +use Twig\Template; +use Twig\TemplateWrapper; use Twig\TokenParser\ApplyTokenParser; use Twig\TokenParser\BlockTokenParser; use Twig\TokenParser\DeprecatedTokenParser; @@ -59,19 +97,38 @@ use Twig\TokenParser\FlushTokenParser; use Twig\TokenParser\ForTokenParser; use Twig\TokenParser\FromTokenParser; +use Twig\TokenParser\GuardTokenParser; use Twig\TokenParser\IfTokenParser; use Twig\TokenParser\ImportTokenParser; use Twig\TokenParser\IncludeTokenParser; use Twig\TokenParser\MacroTokenParser; use Twig\TokenParser\SetTokenParser; +use Twig\TokenParser\TypesTokenParser; use Twig\TokenParser\UseTokenParser; use Twig\TokenParser\WithTokenParser; use Twig\TwigFilter; use Twig\TwigFunction; use Twig\TwigTest; +use Twig\Util\CallableArgumentsExtractor; final class CoreExtension extends AbstractExtension { + public const ARRAY_LIKE_CLASSES = [ + 'ArrayIterator', + 'ArrayObject', + 'CachingIterator', + 'RecursiveArrayIterator', + 'RecursiveCachingIterator', + 'SplDoublyLinkedList', + 'SplFixedArray', + 'SplObjectStorage', + 'SplQueue', + 'SplStack', + 'WeakMap', + ]; + + private const DEFAULT_TRIM_CHARS = " \t\n\r\0\x0B"; + private $dateFormats = ['F j, Y H:i', '%d days']; private $numberFormat = [0, '.', ',']; private $timezone = null; @@ -79,8 +136,8 @@ final class CoreExtension extends AbstractExtension /** * Sets the default format to be used by the date filter. * - * @param string $format The default date format string - * @param string $dateIntervalFormat The default date interval format string + * @param string|null $format The default date format string + * @param string|null $dateIntervalFormat The default date interval format string */ public function setDateFormat($format = null, $dateIntervalFormat = null) { @@ -163,11 +220,13 @@ public function getTokenParsers(): array new ImportTokenParser(), new FromTokenParser(), new SetTokenParser(), + new TypesTokenParser(), new FlushTokenParser(), new DoTokenParser(), new EmbedTokenParser(), new WithTokenParser(), new DeprecatedTokenParser(), + new GuardTokenParser(), ]; } @@ -175,65 +234,73 @@ public function getFilters(): array { return [ // formatting filters - new TwigFilter('date', 'twig_date_format_filter', ['needs_environment' => true]), - new TwigFilter('date_modify', 'twig_date_modify_filter', ['needs_environment' => true]), - new TwigFilter('format', 'twig_sprintf'), - new TwigFilter('replace', 'twig_replace_filter'), - new TwigFilter('number_format', 'twig_number_format_filter', ['needs_environment' => true]), + new TwigFilter('date', [$this, 'formatDate']), + new TwigFilter('date_modify', [$this, 'modifyDate']), + new TwigFilter('format', [self::class, 'sprintf']), + new TwigFilter('replace', [self::class, 'replace']), + new TwigFilter('number_format', [$this, 'formatNumber']), new TwigFilter('abs', 'abs'), - new TwigFilter('round', 'twig_round'), + new TwigFilter('round', [self::class, 'round']), // encoding - new TwigFilter('url_encode', 'twig_urlencode_filter'), + new TwigFilter('url_encode', [self::class, 'urlencode']), new TwigFilter('json_encode', 'json_encode'), - new TwigFilter('convert_encoding', 'twig_convert_encoding'), + new TwigFilter('convert_encoding', [self::class, 'convertEncoding']), // string filters - new TwigFilter('title', 'twig_title_string_filter', ['needs_environment' => true]), - new TwigFilter('capitalize', 'twig_capitalize_string_filter', ['needs_environment' => true]), - new TwigFilter('upper', 'twig_upper_filter', ['needs_environment' => true]), - new TwigFilter('lower', 'twig_lower_filter', ['needs_environment' => true]), - new TwigFilter('striptags', 'twig_striptags'), - new TwigFilter('trim', 'twig_trim_filter'), - new TwigFilter('nl2br', 'twig_nl2br', ['pre_escape' => 'html', 'is_safe' => ['html']]), - new TwigFilter('spaceless', 'twig_spaceless', ['is_safe' => ['html']]), + new TwigFilter('title', [self::class, 'titleCase'], ['needs_charset' => true]), + new TwigFilter('capitalize', [self::class, 'capitalize'], ['needs_charset' => true]), + new TwigFilter('upper', [self::class, 'upper'], ['needs_charset' => true]), + new TwigFilter('lower', [self::class, 'lower'], ['needs_charset' => true]), + new TwigFilter('striptags', [self::class, 'striptags']), + new TwigFilter('trim', [self::class, 'trim']), + new TwigFilter('nl2br', [self::class, 'nl2br'], ['pre_escape' => 'html', 'is_safe' => ['html']]), + new TwigFilter('spaceless', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12')]), // array helpers - new TwigFilter('join', 'twig_join_filter'), - new TwigFilter('split', 'twig_split_filter', ['needs_environment' => true]), - new TwigFilter('sort', 'twig_sort_filter', ['needs_environment' => true]), - new TwigFilter('merge', 'twig_array_merge'), - new TwigFilter('batch', 'twig_array_batch'), - new TwigFilter('column', 'twig_array_column'), - new TwigFilter('filter', 'twig_array_filter', ['needs_environment' => true]), - new TwigFilter('map', 'twig_array_map', ['needs_environment' => true]), - new TwigFilter('reduce', 'twig_array_reduce', ['needs_environment' => true]), + new TwigFilter('join', [self::class, 'join']), + new TwigFilter('split', [self::class, 'split'], ['needs_charset' => true]), + new TwigFilter('sort', [self::class, 'sort'], ['needs_environment' => true]), + new TwigFilter('merge', [self::class, 'merge']), + new TwigFilter('batch', [self::class, 'batch']), + new TwigFilter('column', [self::class, 'column']), + new TwigFilter('filter', [self::class, 'filter'], ['needs_environment' => true]), + new TwigFilter('map', [self::class, 'map'], ['needs_environment' => true]), + new TwigFilter('reduce', [self::class, 'reduce'], ['needs_environment' => true]), + new TwigFilter('find', [self::class, 'find'], ['needs_environment' => true]), // string/array filters - new TwigFilter('reverse', 'twig_reverse_filter', ['needs_environment' => true]), - new TwigFilter('length', 'twig_length_filter', ['needs_environment' => true]), - new TwigFilter('slice', 'twig_slice', ['needs_environment' => true]), - new TwigFilter('first', 'twig_first', ['needs_environment' => true]), - new TwigFilter('last', 'twig_last', ['needs_environment' => true]), + new TwigFilter('reverse', [self::class, 'reverse'], ['needs_charset' => true]), + new TwigFilter('shuffle', [self::class, 'shuffle'], ['needs_charset' => true]), + new TwigFilter('length', [self::class, 'length'], ['needs_charset' => true]), + new TwigFilter('slice', [self::class, 'slice'], ['needs_charset' => true]), + new TwigFilter('first', [self::class, 'first'], ['needs_charset' => true]), + new TwigFilter('last', [self::class, 'last'], ['needs_charset' => true]), // iteration and runtime - new TwigFilter('default', '_twig_default_filter', ['node_class' => DefaultFilter::class]), - new TwigFilter('keys', 'twig_get_array_keys_filter'), + new TwigFilter('default', [self::class, 'default'], ['node_class' => DefaultFilter::class]), + new TwigFilter('keys', [self::class, 'keys']), + new TwigFilter('invoke', [self::class, 'invoke']), ]; } public function getFunctions(): array { return [ + new TwigFunction('parent', null, ['parser_callable' => [self::class, 'parseParentFunction']]), + new TwigFunction('block', null, ['parser_callable' => [self::class, 'parseBlockFunction']]), + new TwigFunction('attribute', null, ['parser_callable' => [self::class, 'parseAttributeFunction']]), new TwigFunction('max', 'max'), new TwigFunction('min', 'min'), new TwigFunction('range', 'range'), - new TwigFunction('constant', 'twig_constant'), - new TwigFunction('cycle', 'twig_cycle'), - new TwigFunction('random', 'twig_random', ['needs_environment' => true]), - new TwigFunction('date', 'twig_date_converter', ['needs_environment' => true]), - new TwigFunction('include', 'twig_include', ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all']]), - new TwigFunction('source', 'twig_source', ['needs_environment' => true, 'is_safe' => ['all']]), + new TwigFunction('constant', [self::class, 'constant']), + new TwigFunction('cycle', [self::class, 'cycle']), + new TwigFunction('random', [self::class, 'random'], ['needs_charset' => true]), + new TwigFunction('date', [$this, 'convertDate']), + new TwigFunction('include', [self::class, 'include'], ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all']]), + new TwigFunction('source', [self::class, 'source'], ['needs_environment' => true, 'is_safe' => ['all']]), + new TwigFunction('enum_cases', [self::class, 'enumCases'], ['node_class' => EnumCasesFunction::class]), + new TwigFunction('enum', [self::class, 'enum'], ['node_class' => EnumFunction::class]), ]; } @@ -248,244 +315,303 @@ public function getTests(): array new TwigTest('null', null, ['node_class' => NullTest::class]), new TwigTest('divisible by', null, ['node_class' => DivisiblebyTest::class, 'one_mandatory_argument' => true]), new TwigTest('constant', null, ['node_class' => ConstantTest::class]), - new TwigTest('empty', 'twig_test_empty'), - new TwigTest('iterable', 'twig_test_iterable'), + new TwigTest('empty', [self::class, 'testEmpty']), + new TwigTest('iterable', 'is_iterable'), + new TwigTest('sequence', [self::class, 'testSequence']), + new TwigTest('mapping', [self::class, 'testMapping']), + new TwigTest('true', null, ['node_class' => TrueTest::class]), ]; } public function getNodeVisitors(): array { - return [new MacroAutoImportNodeVisitor()]; + return []; } - public function getOperators(): array + public function getExpressionParsers(): array { return [ - [ - 'not' => ['precedence' => 50, 'class' => NotUnary::class], - '-' => ['precedence' => 500, 'class' => NegUnary::class], - '+' => ['precedence' => 500, 'class' => PosUnary::class], - ], - [ - 'or' => ['precedence' => 10, 'class' => OrBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'and' => ['precedence' => 15, 'class' => AndBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-or' => ['precedence' => 16, 'class' => BitwiseOrBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-xor' => ['precedence' => 17, 'class' => BitwiseXorBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'b-and' => ['precedence' => 18, 'class' => BitwiseAndBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '==' => ['precedence' => 20, 'class' => EqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '!=' => ['precedence' => 20, 'class' => NotEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<=>' => ['precedence' => 20, 'class' => SpaceshipBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<' => ['precedence' => 20, 'class' => LessBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '>' => ['precedence' => 20, 'class' => GreaterBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '>=' => ['precedence' => 20, 'class' => GreaterEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '<=' => ['precedence' => 20, 'class' => LessEqualBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'not in' => ['precedence' => 20, 'class' => NotInBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'in' => ['precedence' => 20, 'class' => InBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'matches' => ['precedence' => 20, 'class' => MatchesBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'starts with' => ['precedence' => 20, 'class' => StartsWithBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'ends with' => ['precedence' => 20, 'class' => EndsWithBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '..' => ['precedence' => 25, 'class' => RangeBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '+' => ['precedence' => 30, 'class' => AddBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '-' => ['precedence' => 30, 'class' => SubBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '~' => ['precedence' => 40, 'class' => ConcatBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '*' => ['precedence' => 60, 'class' => MulBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '/' => ['precedence' => 60, 'class' => DivBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '//' => ['precedence' => 60, 'class' => FloorDivBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '%' => ['precedence' => 60, 'class' => ModBinary::class, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'is' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT], - 'is not' => ['precedence' => 100, 'associativity' => ExpressionParser::OPERATOR_LEFT], - '**' => ['precedence' => 200, 'class' => PowerBinary::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - '??' => ['precedence' => 300, 'class' => NullCoalesceExpression::class, 'associativity' => ExpressionParser::OPERATOR_RIGHT], - ], + // unary operators + new UnaryOperatorExpressionParser(NotUnary::class, 'not', 50, new PrecedenceChange('twig/twig', '3.15', 70)), + new UnaryOperatorExpressionParser(SpreadUnary::class, '...', 512, description: 'Spread operator'), + new UnaryOperatorExpressionParser(NegUnary::class, '-', 500), + new UnaryOperatorExpressionParser(PosUnary::class, '+', 500), + + // binary operators + new BinaryOperatorExpressionParser(ElvisBinary::class, '?:', 5, InfixAssociativity::Right, description: 'Elvis operator (a ?: b)', aliases: ['? :']), + new BinaryOperatorExpressionParser(NullCoalesceBinary::class, '??', 300, InfixAssociativity::Right, new PrecedenceChange('twig/twig', '3.15', 5), description: 'Null coalescing operator (a ?? b)'), + new BinaryOperatorExpressionParser(OrBinary::class, 'or', 10), + new BinaryOperatorExpressionParser(XorBinary::class, 'xor', 12), + new BinaryOperatorExpressionParser(AndBinary::class, 'and', 15), + new BinaryOperatorExpressionParser(BitwiseOrBinary::class, 'b-or', 16), + new BinaryOperatorExpressionParser(BitwiseXorBinary::class, 'b-xor', 17), + new BinaryOperatorExpressionParser(BitwiseAndBinary::class, 'b-and', 18), + new BinaryOperatorExpressionParser(EqualBinary::class, '==', 20), + new BinaryOperatorExpressionParser(NotEqualBinary::class, '!=', 20), + new BinaryOperatorExpressionParser(SpaceshipBinary::class, '<=>', 20), + new BinaryOperatorExpressionParser(LessBinary::class, '<', 20), + new BinaryOperatorExpressionParser(GreaterBinary::class, '>', 20), + new BinaryOperatorExpressionParser(GreaterEqualBinary::class, '>=', 20), + new BinaryOperatorExpressionParser(LessEqualBinary::class, '<=', 20), + new BinaryOperatorExpressionParser(NotInBinary::class, 'not in', 20), + new BinaryOperatorExpressionParser(InBinary::class, 'in', 20), + new BinaryOperatorExpressionParser(MatchesBinary::class, 'matches', 20), + new BinaryOperatorExpressionParser(StartsWithBinary::class, 'starts with', 20), + new BinaryOperatorExpressionParser(EndsWithBinary::class, 'ends with', 20), + new BinaryOperatorExpressionParser(HasSomeBinary::class, 'has some', 20), + new BinaryOperatorExpressionParser(HasEveryBinary::class, 'has every', 20), + new BinaryOperatorExpressionParser(RangeBinary::class, '..', 25), + new BinaryOperatorExpressionParser(AddBinary::class, '+', 30), + new BinaryOperatorExpressionParser(SubBinary::class, '-', 30), + new BinaryOperatorExpressionParser(ConcatBinary::class, '~', 40, precedenceChange: new PrecedenceChange('twig/twig', '3.15', 27)), + new BinaryOperatorExpressionParser(MulBinary::class, '*', 60), + new BinaryOperatorExpressionParser(DivBinary::class, '/', 60), + new BinaryOperatorExpressionParser(FloorDivBinary::class, '//', 60, description: 'Floor division'), + new BinaryOperatorExpressionParser(ModBinary::class, '%', 60), + new BinaryOperatorExpressionParser(PowerBinary::class, '**', 200, InfixAssociativity::Right, description: 'Exponentiation operator'), + + // ternary operator + new ConditionalTernaryExpressionParser(), + + // Twig callables + new IsExpressionParser(), + new IsNotExpressionParser(), + new FilterExpressionParser(), + new FunctionExpressionParser(), + + // get attribute operators + new DotExpressionParser(), + new SquareBracketExpressionParser(), + + // group expression + new GroupingExpressionParser(), + + // arrow function + new ArrowExpressionParser(), + + // all literals + new LiteralExpressionParser(), ]; } -} -} -namespace { - use Twig\Environment; - use Twig\Error\LoaderError; - use Twig\Error\RuntimeError; - use Twig\Extension\CoreExtension; - use Twig\Extension\SandboxExtension; - use Twig\Markup; - use Twig\Source; - use Twig\Template; - use Twig\TemplateWrapper; - -/** - * Cycles over a value. - * - * @param \ArrayAccess|array $values - * @param int $position The cycle position - * - * @return string The next value in the cycle - */ -function twig_cycle($values, $position) -{ - if (!\is_array($values) && !$values instanceof \ArrayAccess) { - return $values; - } + /** + * Cycles over a sequence. + * + * @param array|\ArrayAccess $values A non-empty sequence of values + * @param int<0, max> $position The position of the value to return in the cycle + * + * @return mixed The value at the given position in the sequence, wrapping around as needed + * + * @internal + */ + public static function cycle($values, $position): mixed + { + if (!\is_array($values)) { + if (!$values instanceof \ArrayAccess) { + throw new RuntimeError('The "cycle" function expects an array or "ArrayAccess" as first argument.'); + } - return $values[$position % \count($values)]; -} + if (!is_countable($values)) { + // To be uncommented in 4.0 + // throw new RuntimeError('The "cycle" function expects a countable sequence as first argument.'); -/** - * Returns a random value depending on the supplied parameter type: - * - a random item from a \Traversable or array - * - a random character from a string - * - a random integer between 0 and the integer parameter. - * - * @param \Traversable|array|int|float|string $values The values to pick a random item from - * @param int|null $max Maximum value used when $values is an int - * - * @throws RuntimeError when $values is an empty array (does not apply to an empty string which is returned as is) - * - * @return mixed A random value from the given sequence - */ -function twig_random(Environment $env, $values = null, $max = null) -{ - if (null === $values) { - return null === $max ? mt_rand() : mt_rand(0, (int) $max); - } + trigger_deprecation('twig/twig', '3.12', 'Passing a non-countable sequence of values to "%s()" is deprecated.', __METHOD__); - if (\is_int($values) || \is_float($values)) { - if (null === $max) { - if ($values < 0) { - $max = 0; - $min = $values; - } else { - $max = $values; - $min = 0; + return $values; } - } else { - $min = $values; - $max = $max; + + $values = self::toArray($values, false); + } + + if (!$count = \count($values)) { + throw new RuntimeError('The "cycle" function expects a non-empty sequence.'); } - return mt_rand((int) $min, (int) $max); + return $values[$position % $count]; } - if (\is_string($values)) { - if ('' === $values) { - return ''; + /** + * Returns a random value depending on the supplied parameter type: + * - a random item from a \Traversable or array + * - a random character from a string + * - a random integer between 0 and the integer parameter. + * + * @param \Traversable|array|int|float|string $values The values to pick a random item from + * @param int|null $max Maximum value used when $values is an int + * + * @return mixed A random value from the given sequence + * + * @throws RuntimeError when $values is an empty array (does not apply to an empty string which is returned as is) + * + * @internal + */ + public static function random(string $charset, $values = null, $max = null) + { + if (null === $values) { + return null === $max ? mt_rand() : mt_rand(0, (int) $max); } - $charset = $env->getCharset(); + if (\is_int($values) || \is_float($values)) { + if (null === $max) { + if ($values < 0) { + $max = 0; + $min = $values; + } else { + $max = $values; + $min = 0; + } + } else { + $min = $values; + } - if ('UTF-8' !== $charset) { - $values = twig_convert_encoding($values, 'UTF-8', $charset); + return mt_rand((int) $min, (int) $max); } - // unicode version of str_split() - // split at all positions, but not after the start and not before the end - $values = preg_split('/(? $value) { - $values[$i] = twig_convert_encoding($value, $charset, 'UTF-8'); + if ('UTF-8' !== $charset) { + foreach ($values as $i => $value) { + $values[$i] = self::convertEncoding($value, $charset, 'UTF-8'); + } } } - } - if (!twig_test_iterable($values)) { - return $values; + if (!is_iterable($values)) { + return $values; + } + + $values = self::toArray($values); + + if (0 === \count($values)) { + throw new RuntimeError('The "random" function cannot pick from an empty sequence or mapping.'); + } + + return $values[array_rand($values, 1)]; } - $values = twig_to_array($values); + /** + * Formats a date. + * + * {{ post.published_at|date("m/d/Y") }} + * + * @param \DateTimeInterface|\DateInterval|string|int|null $date A date, a timestamp or null to use the current time + * @param string|null $format The target format, null to use the default + * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged + */ + public function formatDate($date, $format = null, $timezone = null): string + { + if (null === $format) { + $formats = $this->getDateFormat(); + $format = $date instanceof \DateInterval ? $formats[1] : $formats[0]; + } + + if ($date instanceof \DateInterval) { + return $date->format($format); + } - if (0 === \count($values)) { - throw new RuntimeError('The random function cannot pick from an empty array.'); + return $this->convertDate($date, $timezone)->format($format); } - return $values[array_rand($values, 1)]; -} + /** + * Returns a new date object modified. + * + * {{ post.published_at|date_modify("-1day")|date("m/d/Y") }} + * + * @param \DateTimeInterface|string|int|null $date A date, a timestamp or null to use the current time + * @param string $modifier A modifier string + * + * @return \DateTime|\DateTimeImmutable + * + * @internal + */ + public function modifyDate($date, $modifier) + { + return $this->convertDate($date, false)->modify($modifier); + } -/** - * Converts a date to the given format. - * - * {{ post.published_at|date("m/d/Y") }} - * - * @param \DateTimeInterface|\DateInterval|string $date A date - * @param string|null $format The target format, null to use the default - * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged - * - * @return string The formatted date - */ -function twig_date_format_filter(Environment $env, $date, $format = null, $timezone = null) -{ - if (null === $format) { - $formats = $env->getExtension(CoreExtension::class)->getDateFormat(); - $format = $date instanceof \DateInterval ? $formats[1] : $formats[0]; + /** + * Returns a formatted string. + * + * @param string|null $format + * + * @internal + */ + public static function sprintf($format, ...$values): string + { + return \sprintf($format ?? '', ...$values); } - if ($date instanceof \DateInterval) { - return $date->format($format); + /** + * @internal + */ + public static function dateConverter(Environment $env, $date, $format = null, $timezone = null): string + { + return $env->getExtension(self::class)->formatDate($date, $format, $timezone); } - return twig_date_converter($env, $date, $timezone)->format($format); -} + /** + * Converts an input to a \DateTime instance. + * + * {% if date(user.created_at) < date('+2days') %} + * {# do something #} + * {% endif %} + * + * @param \DateTimeInterface|string|int|null $date A date, a timestamp or null to use the current time + * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged + * + * @return \DateTime|\DateTimeImmutable + */ + public function convertDate($date = null, $timezone = null) + { + // determine the timezone + if (false !== $timezone) { + if (null === $timezone) { + $timezone = $this->getTimezone(); + } elseif (!$timezone instanceof \DateTimeZone) { + $timezone = new \DateTimeZone($timezone); + } + } -/** - * Returns a new date object modified. - * - * {{ post.published_at|date_modify("-1day")|date("m/d/Y") }} - * - * @param \DateTimeInterface|string $date A date - * @param string $modifier A modifier string - * - * @return \DateTimeInterface - */ -function twig_date_modify_filter(Environment $env, $date, $modifier) -{ - $date = twig_date_converter($env, $date, false); + // immutable dates + if ($date instanceof \DateTimeImmutable) { + return false !== $timezone ? $date->setTimezone($timezone) : $date; + } - return $date->modify($modifier); -} + if ($date instanceof \DateTime) { + $date = clone $date; + if (false !== $timezone) { + $date->setTimezone($timezone); + } -/** - * Returns a formatted string. - * - * @param string|null $format - * @param ...$values - * - * @return string - */ -function twig_sprintf($format, ...$values) -{ - return sprintf($format ?? '', ...$values); -} + return $date; + } -/** - * Converts an input to a \DateTime instance. - * - * {% if date(user.created_at) < date('+2days') %} - * {# do something #} - * {% endif %} - * - * @param \DateTimeInterface|string|null $date A date or null to use the current time - * @param \DateTimeZone|string|false|null $timezone The target timezone, null to use the default, false to leave unchanged - * - * @return \DateTimeInterface - */ -function twig_date_converter(Environment $env, $date = null, $timezone = null) -{ - // determine the timezone - if (false !== $timezone) { - if (null === $timezone) { - $timezone = $env->getExtension(CoreExtension::class)->getTimezone(); - } elseif (!$timezone instanceof \DateTimeZone) { - $timezone = new \DateTimeZone($timezone); + if (null === $date || 'now' === $date) { + if (null === $date) { + $date = 'now'; + } + + return new \DateTime($date, false !== $timezone ? $timezone : $this->getTimezone()); } - } - // immutable dates - if ($date instanceof \DateTimeImmutable) { - return false !== $timezone ? $date->setTimezone($timezone) : $date; - } + $asString = (string) $date; + if (ctype_digit($asString) || ('' !== $asString && '-' === $asString[0] && ctype_digit(substr($asString, 1)))) { + $date = new \DateTime('@'.$date); + } else { + $date = new \DateTime($date); + } - if ($date instanceof \DateTimeInterface) { - $date = clone $date; if (false !== $timezone) { $date->setTimezone($timezone); } @@ -493,1203 +619,1575 @@ function twig_date_converter(Environment $env, $date = null, $timezone = null) return $date; } - if (null === $date || 'now' === $date) { - if (null === $date) { - $date = 'now'; + /** + * Replaces strings within a string. + * + * @param string|null $str String to replace in + * @param array|\Traversable $from Replace values + * + * @internal + */ + public static function replace($str, $from): string + { + if (!is_iterable($from)) { + throw new RuntimeError(\sprintf('The "replace" filter expects a sequence or a mapping, got "%s".', get_debug_type($from))); } - return new \DateTime($date, false !== $timezone ? $timezone : $env->getExtension(CoreExtension::class)->getTimezone()); + return strtr($str ?? '', self::toArray($from)); } - $asString = (string) $date; - if (ctype_digit($asString) || (!empty($asString) && '-' === $asString[0] && ctype_digit(substr($asString, 1)))) { - $date = new \DateTime('@'.$date); - } else { - $date = new \DateTime($date, $env->getExtension(CoreExtension::class)->getTimezone()); - } + /** + * Rounds a number. + * + * @param int|float|string|null $value The value to round + * @param int|float $precision The rounding precision + * @param 'common'|'ceil'|'floor' $method The method to use for rounding + * + * @return float The rounded number + * + * @internal + */ + public static function round($value, $precision = 0, $method = 'common') + { + $value = (float) $value; - if (false !== $timezone) { - $date->setTimezone($timezone); - } + if ('common' === $method) { + return round($value, $precision); + } - return $date; -} + if ('ceil' !== $method && 'floor' !== $method) { + throw new RuntimeError('The "round" filter only supports the "common", "ceil", and "floor" methods.'); + } -/** - * Replaces strings within a string. - * - * @param string|null $str String to replace in - * @param array|\Traversable $from Replace values - * - * @return string - */ -function twig_replace_filter($str, $from) -{ - if (!twig_test_iterable($from)) { - throw new RuntimeError(sprintf('The "replace" filter expects an array or "Traversable" as replace values, got "%s".', \is_object($from) ? \get_class($from) : \gettype($from))); + return $method($value * 10 ** $precision) / 10 ** $precision; } - return strtr($str ?? '', twig_to_array($from)); -} + /** + * Formats a number. + * + * All of the formatting options can be left null, in that case the defaults will + * be used. Supplying any of the parameters will override the defaults set in the + * environment object. + * + * @param mixed $number A float/int/string of the number to format + * @param int|null $decimal the number of decimal points to display + * @param string|null $decimalPoint the character(s) to use for the decimal point + * @param string|null $thousandSep the character(s) to use for the thousands separator + */ + public function formatNumber($number, $decimal = null, $decimalPoint = null, $thousandSep = null): string + { + $defaults = $this->getNumberFormat(); + if (null === $decimal) { + $decimal = $defaults[0]; + } -/** - * Rounds a number. - * - * @param int|float|string|null $value The value to round - * @param int|float $precision The rounding precision - * @param string $method The method to use for rounding - * - * @return int|float The rounded number - */ -function twig_round($value, $precision = 0, $method = 'common') -{ - $value = (float) $value; + if (null === $decimalPoint) { + $decimalPoint = $defaults[1]; + } - if ('common' === $method) { - return round($value, $precision); - } + if (null === $thousandSep) { + $thousandSep = $defaults[2]; + } - if ('ceil' !== $method && 'floor' !== $method) { - throw new RuntimeError('The round filter only supports the "common", "ceil", and "floor" methods.'); + return number_format((float) $number, $decimal, $decimalPoint, $thousandSep); } - return $method($value * 10 ** $precision) / 10 ** $precision; -} + /** + * URL encodes (RFC 3986) a string as a path segment or an array as a query string. + * + * @param string|array|null $url A URL or an array of query parameters + * + * @internal + */ + public static function urlencode($url): string + { + if (\is_array($url)) { + return http_build_query($url, '', '&', \PHP_QUERY_RFC3986); + } -/** - * Number format filter. - * - * All of the formatting options can be left null, in that case the defaults will - * be used. Supplying any of the parameters will override the defaults set in the - * environment object. - * - * @param mixed $number A float/int/string of the number to format - * @param int $decimal the number of decimal points to display - * @param string $decimalPoint the character(s) to use for the decimal point - * @param string $thousandSep the character(s) to use for the thousands separator - * - * @return string The formatted number - */ -function twig_number_format_filter(Environment $env, $number, $decimal = null, $decimalPoint = null, $thousandSep = null) -{ - $defaults = $env->getExtension(CoreExtension::class)->getNumberFormat(); - if (null === $decimal) { - $decimal = $defaults[0]; + return rawurlencode($url ?? ''); } - if (null === $decimalPoint) { - $decimalPoint = $defaults[1]; - } + /** + * Merges any number of arrays or Traversable objects. + * + * {% set items = { 'apple': 'fruit', 'orange': 'fruit' } %} + * + * {% set items = items|merge({ 'peugeot': 'car' }, { 'banana': 'fruit' }) %} + * + * {# items now contains { 'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'car', 'banana': 'fruit' } #} + * + * @param array|\Traversable ...$arrays Any number of arrays or Traversable objects to merge + * + * @internal + */ + public static function merge(...$arrays): array + { + $result = []; + + foreach ($arrays as $argNumber => $array) { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "merge" filter expects a sequence or a mapping, got "%s" for argument %d.', get_debug_type($array), $argNumber + 1)); + } + + $result = array_merge($result, self::toArray($array)); + } - if (null === $thousandSep) { - $thousandSep = $defaults[2]; + return $result; } - return number_format((float) $number, $decimal, $decimalPoint, $thousandSep); -} + /** + * Slices a variable. + * + * @param mixed $item A variable + * @param int $start Start of the slice + * @param int $length Size of the slice + * @param bool $preserveKeys Whether to preserve key or not (when the input is an array) + * + * @return mixed The sliced variable + * + * @internal + */ + public static function slice(string $charset, $item, $start, $length = null, $preserveKeys = false) + { + if ($item instanceof \Traversable) { + while ($item instanceof \IteratorAggregate) { + $item = $item->getIterator(); + } -/** - * URL encodes (RFC 3986) a string as a path segment or an array as a query string. - * - * @param string|array|null $url A URL or an array of query parameters - * - * @return string The URL encoded value - */ -function twig_urlencode_filter($url) -{ - if (\is_array($url)) { - return http_build_query($url, '', '&', \PHP_QUERY_RFC3986); + if ($start >= 0 && $length >= 0 && $item instanceof \Iterator) { + try { + return iterator_to_array(new \LimitIterator($item, $start, $length ?? -1), $preserveKeys); + } catch (\OutOfBoundsException $e) { + return []; + } + } + + $item = iterator_to_array($item, $preserveKeys); + } + + if (\is_array($item)) { + return \array_slice($item, $start, $length, $preserveKeys); + } + + return mb_substr((string) $item, $start, $length, $charset); } - return rawurlencode($url ?? ''); -} + /** + * Returns the first element of the item. + * + * @param mixed $item A variable + * + * @return mixed The first element of the item + * + * @internal + */ + public static function first(string $charset, $item) + { + $elements = self::slice($charset, $item, 0, 1, false); -/** - * Merges an array with another one. - * - * {% set items = { 'apple': 'fruit', 'orange': 'fruit' } %} - * - * {% set items = items|merge({ 'peugeot': 'car' }) %} - * - * {# items now contains { 'apple': 'fruit', 'orange': 'fruit', 'peugeot': 'car' } #} - * - * @param array|\Traversable $arr1 An array - * @param array|\Traversable $arr2 An array - * - * @return array The merged array - */ -function twig_array_merge($arr1, $arr2) -{ - if (!twig_test_iterable($arr1)) { - throw new RuntimeError(sprintf('The merge filter only works with arrays or "Traversable", got "%s" as first argument.', \gettype($arr1))); + return \is_string($elements) ? $elements : current($elements); } - if (!twig_test_iterable($arr2)) { - throw new RuntimeError(sprintf('The merge filter only works with arrays or "Traversable", got "%s" as second argument.', \gettype($arr2))); + /** + * Returns the last element of the item. + * + * @param mixed $item A variable + * + * @return mixed The last element of the item + * + * @internal + */ + public static function last(string $charset, $item) + { + $elements = self::slice($charset, $item, -1, 1, false); + + return \is_string($elements) ? $elements : current($elements); } - return array_merge(twig_to_array($arr1), twig_to_array($arr2)); -} + /** + * Joins the values to a string. + * + * The separators between elements are empty strings per default, you can define them with the optional parameters. + * + * {{ [1, 2, 3]|join(', ', ' and ') }} + * {# returns 1, 2 and 3 #} + * + * {{ [1, 2, 3]|join('|') }} + * {# returns 1|2|3 #} + * + * {{ [1, 2, 3]|join }} + * {# returns 123 #} + * + * @param iterable|array|string|float|int|bool|null $value An array + * @param string $glue The separator + * @param string|null $and The separator for the last pair + * + * @internal + */ + public static function join($value, $glue = '', $and = null): string + { + if (!is_iterable($value)) { + $value = (array) $value; + } -/** - * Slices a variable. - * - * @param mixed $item A variable - * @param int $start Start of the slice - * @param int $length Size of the slice - * @param bool $preserveKeys Whether to preserve key or not (when the input is an array) - * - * @return mixed The sliced variable - */ -function twig_slice(Environment $env, $item, $start, $length = null, $preserveKeys = false) -{ - if ($item instanceof \Traversable) { - while ($item instanceof \IteratorAggregate) { - $item = $item->getIterator(); + $value = self::toArray($value, false); + + if (0 === \count($value)) { + return ''; } - if ($start >= 0 && $length >= 0 && $item instanceof \Iterator) { - try { - return iterator_to_array(new \LimitIterator($item, $start, null === $length ? -1 : $length), $preserveKeys); - } catch (\OutOfBoundsException $e) { - return []; - } + if (null === $and || $and === $glue) { + return implode($glue, $value); } - $item = iterator_to_array($item, $preserveKeys); - } + if (1 === \count($value)) { + return $value[0]; + } - if (\is_array($item)) { - return \array_slice($item, $start, $length, $preserveKeys); + return implode($glue, \array_slice($value, 0, -1)).$and.$value[\count($value) - 1]; } - return (string) mb_substr((string) $item, $start, $length, $env->getCharset()); -} + /** + * Splits the string into an array. + * + * {{ "one,two,three"|split(',') }} + * {# returns [one, two, three] #} + * + * {{ "one,two,three,four,five"|split(',', 3) }} + * {# returns [one, two, "three,four,five"] #} + * + * {{ "123"|split('') }} + * {# returns [1, 2, 3] #} + * + * {{ "aabbcc"|split('', 2) }} + * {# returns [aa, bb, cc] #} + * + * @param string|null $value A string + * @param string $delimiter The delimiter + * @param int|null $limit The limit + * + * @internal + */ + public static function split(string $charset, $value, $delimiter, $limit = null): array + { + $value = $value ?? ''; -/** - * Returns the first element of the item. - * - * @param mixed $item A variable - * - * @return mixed The first element of the item - */ -function twig_first(Environment $env, $item) -{ - $elements = twig_slice($env, $item, 0, 1, false); + if ('' !== $delimiter) { + return null === $limit ? explode($delimiter, $value) : explode($delimiter, $value, $limit); + } - return \is_string($elements) ? $elements : current($elements); -} + if ($limit <= 1) { + return preg_split('/(?getIterator(); + } - if (1 === \count($value)) { - return $value[0]; - } + $keys = []; + if ($array instanceof \Iterator) { + $array->rewind(); + while ($array->valid()) { + $keys[] = $array->key(); + $array->next(); + } - return implode($glue, \array_slice($value, 0, -1)).$and.$value[\count($value) - 1]; -} + return $keys; + } -/** - * Splits the string into an array. - * - * {{ "one,two,three"|split(',') }} - * {# returns [one, two, three] #} - * - * {{ "one,two,three,four,five"|split(',', 3) }} - * {# returns [one, two, "three,four,five"] #} - * - * {{ "123"|split('') }} - * {# returns [1, 2, 3] #} - * - * {{ "aabbcc"|split('', 2) }} - * {# returns [aa, bb, cc] #} - * - * @param string|null $value A string - * @param string $delimiter The delimiter - * @param int $limit The limit - * - * @return array The split string as an array - */ -function twig_split_filter(Environment $env, $value, $delimiter, $limit = null) -{ - $value = $value ?? ''; + foreach ($array as $key => $item) { + $keys[] = $key; + } - if (\strlen($delimiter) > 0) { - return null === $limit ? explode($delimiter, $value) : explode($delimiter, $value, $limit); + return $keys; + } + + if (!\is_array($array)) { + return []; + } + + return array_keys($array); } - if ($limit <= 1) { - return preg_split('/(?getCharset()); - if ($length < $limit) { - return [$value]; + /** + * Reverses a variable. + * + * @param array|\Traversable|string|null $item An array, a \Traversable instance, or a string + * @param bool $preserveKeys Whether to preserve key or not + * + * @return mixed The reversed input + * + * @internal + */ + public static function reverse(string $charset, $item, $preserveKeys = false) + { + if ($item instanceof \Traversable) { + return array_reverse(iterator_to_array($item), $preserveKeys); + } + + if (\is_array($item)) { + return array_reverse($item, $preserveKeys); + } + + $string = (string) $item; + + if ('UTF-8' !== $charset) { + $string = self::convertEncoding($string, 'UTF-8', $charset); + } + + preg_match_all('/./us', $string, $matches); + + $string = implode('', array_reverse($matches[0])); + + if ('UTF-8' !== $charset) { + $string = self::convertEncoding($string, $charset, 'UTF-8'); + } + + return $string; } - $r = []; - for ($i = 0; $i < $length; $i += $limit) { - $r[] = mb_substr($value, $i, $limit, $env->getCharset()); + /** + * Shuffles an array, a \Traversable instance, or a string. + * The function does not preserve keys. + * + * @param array|\Traversable|string|null $item + * + * @internal + */ + public static function shuffle(string $charset, $item) + { + if (\is_string($item)) { + if ('UTF-8' !== $charset) { + $item = self::convertEncoding($item, 'UTF-8', $charset); + } + + $item = preg_split('/(?getIterator(); + if (\is_string($compare)) { + if (\is_string($value) || \is_int($value) || \is_float($value)) { + return '' === $value || str_contains($compare, (string) $value); + } + + return false; } - $keys = []; - if ($array instanceof \Iterator) { - $array->rewind(); - while ($array->valid()) { - $keys[] = $array->key(); - $array->next(); + if (!is_iterable($compare)) { + return false; + } + + if (\is_object($value) || \is_resource($value)) { + if (!\is_array($compare)) { + foreach ($compare as $item) { + if ($item === $value) { + return true; + } + } + + return false; } - return $keys; + return \in_array($value, $compare, true); } - foreach ($array as $key => $item) { - $keys[] = $key; + foreach ($compare as $item) { + if (0 === self::compare($value, $item)) { + return true; + } } - return $keys; + return false; } - if (!\is_array($array)) { - return []; - } + /** + * Compares two values using a more strict version of the PHP non-strict comparison operator. + * + * @see https://wiki.php.net/rfc/string_to_number_comparison + * @see https://wiki.php.net/rfc/trailing_whitespace_numerics + * + * @internal + */ + public static function compare($a, $b) + { + // int <=> string + if (\is_int($a) && \is_string($b)) { + $bTrim = trim($b, " \t\n\r\v\f"); + if (!is_numeric($bTrim)) { + return (string) $a <=> $b; + } + if ((int) $bTrim == $bTrim) { + return $a <=> (int) $bTrim; + } else { + return (float) $a <=> (float) $bTrim; + } + } + if (\is_string($a) && \is_int($b)) { + $aTrim = trim($a, " \t\n\r\v\f"); + if (!is_numeric($aTrim)) { + return $a <=> (string) $b; + } + if ((int) $aTrim == $aTrim) { + return (int) $aTrim <=> $b; + } else { + return (float) $aTrim <=> (float) $b; + } + } - return array_keys($array); -} + // float <=> string + if (\is_float($a) && \is_string($b)) { + if (is_nan($a)) { + return 1; + } + $bTrim = trim($b, " \t\n\r\v\f"); + if (!is_numeric($bTrim)) { + return (string) $a <=> $b; + } -/** - * Reverses a variable. - * - * @param array|\Traversable|string|null $item An array, a \Traversable instance, or a string - * @param bool $preserveKeys Whether to preserve key or not - * - * @return mixed The reversed input - */ -function twig_reverse_filter(Environment $env, $item, $preserveKeys = false) -{ - if ($item instanceof \Traversable) { - return array_reverse(iterator_to_array($item), $preserveKeys); + return $a <=> (float) $bTrim; + } + if (\is_string($a) && \is_float($b)) { + if (is_nan($b)) { + return 1; + } + $aTrim = trim($a, " \t\n\r\v\f"); + if (!is_numeric($aTrim)) { + return $a <=> (string) $b; + } + + return (float) $aTrim <=> $b; + } + + // fallback to <=> + return $a <=> $b; } - if (\is_array($item)) { - return array_reverse($item, $preserveKeys); + /** + * @throws RuntimeError When an invalid pattern is used + * + * @internal + */ + public static function matches(string $regexp, ?string $str): int + { + set_error_handler(function ($t, $m) use ($regexp) { + throw new RuntimeError(\sprintf('Regexp "%s" passed to "matches" is not valid', $regexp).substr($m, 12)); + }); + try { + return preg_match($regexp, $str ?? ''); + } finally { + restore_error_handler(); + } } - $string = (string) $item; + /** + * Returns a trimmed string. + * + * @param string|\Stringable|null $string + * @param string|null $characterMask + * @param string $side left, right, or both + * + * @throws RuntimeError When an invalid trimming side is used + * + * @internal + */ + public static function trim($string, $characterMask = null, $side = 'both'): string|\Stringable + { + if (null === $characterMask) { + $characterMask = self::DEFAULT_TRIM_CHARS; + } - $charset = $env->getCharset(); + $trimmed = match ($side) { + 'both' => trim($string ?? '', $characterMask), + 'left' => ltrim($string ?? '', $characterMask), + 'right' => rtrim($string ?? '', $characterMask), + default => throw new RuntimeError('Trimming side must be "left", "right" or "both".'), + }; - if ('UTF-8' !== $charset) { - $string = twig_convert_encoding($string, 'UTF-8', $charset); + // trimming a safe string with the default character mask always returns a safe string (independently of the context) + return $string instanceof Markup && self::DEFAULT_TRIM_CHARS === $characterMask ? new Markup($trimmed, $string->getCharset()) : $trimmed; } - preg_match_all('/./us', $string, $matches); - - $string = implode('', array_reverse($matches[0])); + /** + * Inserts HTML line breaks before all newlines in a string. + * + * @param string|null $string + * + * @internal + */ + public static function nl2br($string): string + { + return nl2br($string ?? ''); + } - if ('UTF-8' !== $charset) { - $string = twig_convert_encoding($string, $charset, 'UTF-8'); + /** + * Removes whitespaces between HTML tags. + * + * @param string|null $content + * + * @internal + */ + public static function spaceless($content): string + { + return trim(preg_replace('/>\s+<', $content ?? '')); } - return $string; -} + /** + * @param string|null $string + * @param string $to + * @param string $from + * + * @internal + */ + public static function convertEncoding($string, $to, $from): string + { + if (!\function_exists('iconv')) { + throw new RuntimeError('Unable to convert encoding: required function iconv() does not exist. You should install ext-iconv or symfony/polyfill-iconv.'); + } -/** - * Sorts an array. - * - * @param array|\Traversable $array - * - * @return array - */ -function twig_sort_filter(Environment $env, $array, $arrow = null) -{ - if ($array instanceof \Traversable) { - $array = iterator_to_array($array); - } elseif (!\is_array($array)) { - throw new RuntimeError(sprintf('The sort filter only works with arrays or "Traversable", got "%s".', \gettype($array))); + return iconv($from, $to, $string ?? ''); } - if (null !== $arrow) { - twig_check_arrow_in_sandbox($env, $arrow, 'sort', 'filter'); + /** + * Returns the length of a variable. + * + * @param mixed $thing A variable + * + * @internal + */ + public static function length(string $charset, $thing): int + { + if (null === $thing) { + return 0; + } - uasort($array, $arrow); - } else { - asort($array); - } + if (\is_scalar($thing)) { + return mb_strlen($thing, $charset); + } - return $array; -} + if ($thing instanceof \Countable || \is_array($thing) || $thing instanceof \SimpleXMLElement) { + return \count($thing); + } -/** - * @internal - */ -function twig_in_filter($value, $compare) -{ - if ($value instanceof Markup) { - $value = (string) $value; + if ($thing instanceof \Traversable) { + return iterator_count($thing); + } + + if ($thing instanceof \Stringable) { + return mb_strlen((string) $thing, $charset); + } + + return 1; } - if ($compare instanceof Markup) { - $compare = (string) $compare; + + /** + * Converts a string to uppercase. + * + * @param string|null $string A string + * + * @internal + */ + public static function upper(string $charset, $string): string + { + return mb_strtoupper($string ?? '', $charset); } - if (\is_string($compare)) { - if (\is_string($value) || \is_int($value) || \is_float($value)) { - return '' === $value || false !== strpos($compare, (string) $value); - } + /** + * Converts a string to lowercase. + * + * @param string|null $string A string + * + * @internal + */ + public static function lower(string $charset, $string): string + { + return mb_strtolower($string ?? '', $charset); + } - return false; + /** + * Strips HTML and PHP tags from a string. + * + * @param string|null $string + * @param string[]|string|null $allowable_tags + * + * @internal + */ + public static function striptags($string, $allowable_tags = null): string + { + return strip_tags($string ?? '', $allowable_tags); } - if (!is_iterable($compare)) { - return false; + /** + * Returns a titlecased string. + * + * @param string|null $string A string + * + * @internal + */ + public static function titleCase(string $charset, $string): string + { + return mb_convert_case($string ?? '', \MB_CASE_TITLE, $charset); } - if (\is_object($value) || \is_resource($value)) { - if (!\is_array($compare)) { - foreach ($compare as $item) { - if ($item === $value) { - return true; + /** + * Returns a capitalized string. + * + * @param string|null $string A string + * + * @internal + */ + public static function capitalize(string $charset, $string): string + { + return mb_strtoupper(mb_substr($string ?? '', 0, 1, $charset), $charset).mb_strtolower(mb_substr($string ?? '', 1, null, $charset), $charset); + } + + /** + * @internal + * + * to be removed in 4.0 + */ + public static function callMacro(Template $template, string $method, array $args, int $lineno, array $context, Source $source) + { + if (!method_exists($template, $method)) { + $parent = $template; + while ($parent = $parent->getParent($context)) { + if (method_exists($parent, $method)) { + return $parent->$method(...$args); } } - return false; + throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($method, \strlen('macro_')), $template->getTemplateName()), $lineno, $source); } - return \in_array($value, $compare, true); + return $template->$method(...$args); } - foreach ($compare as $item) { - if (0 === twig_compare($value, $item)) { - return true; + /** + * @template TSequence + * + * @param TSequence $seq + * + * @return ($seq is iterable ? TSequence : array{}) + * + * @internal + */ + public static function ensureTraversable($seq) + { + if (is_iterable($seq)) { + return $seq; } + + return []; } - return false; -} + /** + * @internal + */ + public static function toArray($seq, $preserveKeys = true) + { + if ($seq instanceof \Traversable) { + return iterator_to_array($seq, $preserveKeys); + } -/** - * Compares two values using a more strict version of the PHP non-strict comparison operator. - * - * @see https://wiki.php.net/rfc/string_to_number_comparison - * @see https://wiki.php.net/rfc/trailing_whitespace_numerics - * - * @internal - */ -function twig_compare($a, $b) -{ - // int <=> string - if (\is_int($a) && \is_string($b)) { - $bTrim = trim($b, " \t\n\r\v\f"); - if (!is_numeric($bTrim)) { - return (string) $a <=> $b; - } - if ((int) $bTrim == $bTrim) { - return $a <=> (int) $bTrim; - } else { - return (float) $a <=> (float) $bTrim; + if (!\is_array($seq)) { + return $seq; } + + return $preserveKeys ? $seq : array_values($seq); } - if (\is_string($a) && \is_int($b)) { - $aTrim = trim($a, " \t\n\r\v\f"); - if (!is_numeric($aTrim)) { - return $a <=> (string) $b; - } - if ((int) $aTrim == $aTrim) { - return (int) $aTrim <=> $b; - } else { - return (float) $aTrim <=> (float) $b; + + /** + * Checks if a variable is empty. + * + * {# evaluates to true if the foo variable is null, false, or the empty string #} + * {% if foo is empty %} + * {# ... #} + * {% endif %} + * + * @param mixed $value A variable + * + * @internal + */ + public static function testEmpty($value): bool + { + if ($value instanceof \Countable) { + return 0 === \count($value); } - } - // float <=> string - if (\is_float($a) && \is_string($b)) { - if (is_nan($a)) { - return 1; + if ($value instanceof \Traversable) { + return !iterator_count($value); } - $bTrim = trim($b, " \t\n\r\v\f"); - if (!is_numeric($bTrim)) { - return (string) $a <=> $b; + + if ($value instanceof \Stringable) { + return '' === (string) $value; } - return $a <=> (float) $bTrim; + return '' === $value || false === $value || null === $value || [] === $value; } - if (\is_string($a) && \is_float($b)) { - if (is_nan($b)) { - return 1; + + /** + * Checks if a variable is a sequence. + * + * {# evaluates to true if the foo variable is a sequence #} + * {% if foo is sequence %} + * {# ... #} + * {% endif %} + * + * @internal + */ + public static function testSequence($value): bool + { + if ($value instanceof \ArrayObject) { + $value = $value->getArrayCopy(); } - $aTrim = trim($a, " \t\n\r\v\f"); - if (!is_numeric($aTrim)) { - return $a <=> (string) $b; + + if ($value instanceof \Traversable) { + $value = iterator_to_array($value); } - return (float) $aTrim <=> $b; + return \is_array($value) && array_is_list($value); } - // fallback to <=> - return $a <=> $b; -} + /** + * Checks if a variable is a mapping. + * + * {# evaluates to true if the foo variable is a mapping #} + * {% if foo is mapping %} + * {# ... #} + * {% endif %} + * + * @internal + */ + public static function testMapping($value): bool + { + if ($value instanceof \ArrayObject) { + $value = $value->getArrayCopy(); + } -/** - * Returns a trimmed string. - * - * @param string|null $string - * @param string|null $characterMask - * @param string $side - * - * @return string - * - * @throws RuntimeError When an invalid trimming side is used (not a string or not 'left', 'right', or 'both') - */ -function twig_trim_filter($string, $characterMask = null, $side = 'both') -{ - if (null === $characterMask) { - $characterMask = " \t\n\r\0\x0B"; - } + if ($value instanceof \Traversable) { + $value = iterator_to_array($value); + } - switch ($side) { - case 'both': - return trim($string ?? '', $characterMask); - case 'left': - return ltrim($string ?? '', $characterMask); - case 'right': - return rtrim($string ?? '', $characterMask); - default: - throw new RuntimeError('Trimming side must be "left", "right" or "both".'); + return (\is_array($value) && !array_is_list($value)) || \is_object($value); } -} -/** - * Inserts HTML line breaks before all newlines in a string. - * - * @param string|null $string - * - * @return string - */ -function twig_nl2br($string) -{ - return nl2br($string ?? ''); -} - -/** - * Removes whitespaces between HTML tags. - * - * @param string|null $string - * - * @return string - */ -function twig_spaceless($content) -{ - return trim(preg_replace('/>\s+<', $content ?? '')); -} + /** + * Renders a template. + * + * @param array $context + * @param string|array|TemplateWrapper $template The template to render or an array of templates to try consecutively + * @param array $variables The variables to pass to the template + * @param bool $withContext + * @param bool $ignoreMissing Whether to ignore missing templates or not + * @param bool $sandboxed Whether to sandbox the template or not + * + * @internal + */ + public static function include(Environment $env, $context, $template, $variables = [], $withContext = true, $ignoreMissing = false, $sandboxed = false): string + { + $alreadySandboxed = false; + $sandbox = null; + if ($withContext) { + $variables = array_merge($context, $variables); + } -/** - * @param string|null $string - * @param string $to - * @param string $from - * - * @return string - */ -function twig_convert_encoding($string, $to, $from) -{ - if (!\function_exists('iconv')) { - throw new RuntimeError('Unable to convert encoding: required function iconv() does not exist. You should install ext-iconv or symfony/polyfill-iconv.'); - } + if ($isSandboxed = $sandboxed && $env->hasExtension(SandboxExtension::class)) { + $sandbox = $env->getExtension(SandboxExtension::class); + if (!$alreadySandboxed = $sandbox->isSandboxed()) { + $sandbox->enableSandbox(); + } + } - return iconv($from, $to, $string ?? ''); -} + try { + $loaded = null; + try { + $loaded = $env->resolveTemplate($template); + } catch (LoaderError $e) { + if (!$ignoreMissing) { + throw $e; + } -/** - * Returns the length of a variable. - * - * @param mixed $thing A variable - * - * @return int The length of the value - */ -function twig_length_filter(Environment $env, $thing) -{ - if (null === $thing) { - return 0; - } + return ''; + } - if (is_scalar($thing)) { - return mb_strlen($thing, $env->getCharset()); - } + if ($isSandboxed) { + $loaded->unwrap()->checkSecurity(); + } - if ($thing instanceof \Countable || \is_array($thing) || $thing instanceof \SimpleXMLElement) { - return \count($thing); + return $loaded->render($variables); + } finally { + if ($isSandboxed && !$alreadySandboxed) { + $sandbox->disableSandbox(); + } + } } - if ($thing instanceof \Traversable) { - return iterator_count($thing); - } + /** + * Returns a template content without rendering it. + * + * @param string $name The template name + * @param bool $ignoreMissing Whether to ignore missing templates or not + * + * @internal + */ + public static function source(Environment $env, $name, $ignoreMissing = false): string + { + $loader = $env->getLoader(); + try { + return $loader->getSourceContext($name)->getCode(); + } catch (LoaderError $e) { + if (!$ignoreMissing) { + throw $e; + } - if (method_exists($thing, '__toString') && !$thing instanceof \Countable) { - return mb_strlen((string) $thing, $env->getCharset()); + return ''; + } } - return 1; -} + /** + * Returns the list of cases of the enum. + * + * @template T of \UnitEnum + * + * @param class-string $enum + * + * @return list + * + * @internal + */ + public static function enumCases(string $enum): array + { + if (!enum_exists($enum)) { + throw new RuntimeError(\sprintf('Enum "%s" does not exist.', $enum)); + } -/** - * Converts a string to uppercase. - * - * @param string|null $string A string - * - * @return string The uppercased string - */ -function twig_upper_filter(Environment $env, $string) -{ - return mb_strtoupper($string ?? '', $env->getCharset()); -} + return $enum::cases(); + } -/** - * Converts a string to lowercase. - * - * @param string|null $string A string - * - * @return string The lowercased string - */ -function twig_lower_filter(Environment $env, $string) -{ - return mb_strtolower($string ?? '', $env->getCharset()); -} + /** + * Provides the ability to access enums by their class names. + * + * @template T of \UnitEnum + * + * @param class-string $enum + * + * @return T + * + * @internal + */ + public static function enum(string $enum): \UnitEnum + { + if (!enum_exists($enum)) { + throw new RuntimeError(\sprintf('"%s" is not an enum.', $enum)); + } -/** - * Strips HTML and PHP tags from a string. - * - * @param string|null $string - * @param string[]|string|null $string - * - * @return string - */ -function twig_striptags($string, $allowable_tags = null) -{ - return strip_tags($string ?? '', $allowable_tags); -} + if (!$cases = $enum::cases()) { + throw new RuntimeError(\sprintf('"%s" is an empty enum.', $enum)); + } -/** - * Returns a titlecased string. - * - * @param string|null $string A string - * - * @return string The titlecased string - */ -function twig_title_string_filter(Environment $env, $string) -{ - if (null !== $charset = $env->getCharset()) { - return mb_convert_case($string ?? '', \MB_CASE_TITLE, $charset); + return $cases[0]; } - return ucwords(strtolower($string ?? '')); -} + /** + * Provides the ability to get constants from instances as well as class/global constants. + * + * @param string $constant The name of the constant + * @param object|null $object The object to get the constant from + * @param bool $checkDefined Whether to check if the constant is defined or not + * + * @return mixed Class constants can return many types like scalars, arrays, and + * objects depending on the PHP version (\BackedEnum, \UnitEnum, etc.) + * When $checkDefined is true, returns true when the constant is defined, false otherwise + * + * @internal + */ + public static function constant($constant, $object = null, bool $checkDefined = false) + { + if (null !== $object) { + if ('class' === $constant) { + return $checkDefined ? true : $object::class; + } -/** - * Returns a capitalized string. - * - * @param string|null $string A string - * - * @return string The capitalized string - */ -function twig_capitalize_string_filter(Environment $env, $string) -{ - $charset = $env->getCharset(); + $constant = $object::class.'::'.$constant; + } - return mb_strtoupper(mb_substr($string ?? '', 0, 1, $charset), $charset).mb_strtolower(mb_substr($string ?? '', 1, null, $charset), $charset); -} + if (!\defined($constant)) { + if ($checkDefined) { + return false; + } -/** - * @internal - */ -function twig_call_macro(Template $template, string $method, array $args, int $lineno, array $context, Source $source) -{ - if (!method_exists($template, $method)) { - $parent = $template; - while ($parent = $parent->getParent($context)) { - if (method_exists($parent, $method)) { - return $parent->$method(...$args); + if ('::class' === strtolower(substr($constant, -7))) { + throw new RuntimeError(\sprintf('You cannot use the Twig function "constant" to access "%s". You could provide an object and call constant("class", $object) or use the class name directly as a string.', $constant)); } + + throw new RuntimeError(\sprintf('Constant "%s" is undefined.', $constant)); } - throw new RuntimeError(sprintf('Macro "%s" is not defined in template "%s".', substr($method, \strlen('macro_')), $template->getTemplateName()), $lineno, $source); + return $checkDefined ? true : \constant($constant); } - return $template->$method(...$args); -} + /** + * Batches item. + * + * @param array $items An array of items + * @param int $size The size of the batch + * @param mixed $fill A value used to fill missing items + * + * @internal + */ + public static function batch($items, $size, $fill = null, $preserveKeys = true): array + { + if (!is_iterable($items)) { + throw new RuntimeError(\sprintf('The "batch" filter expects a sequence or a mapping, got "%s".', get_debug_type($items))); + } -/** - * @internal - */ -function twig_ensure_traversable($seq) -{ - if ($seq instanceof \Traversable || \is_array($seq)) { - return $seq; - } + $size = (int) ceil($size); - return []; -} + $result = array_chunk(self::toArray($items, $preserveKeys), $size, $preserveKeys); -/** - * @internal - */ -function twig_to_array($seq, $preserveKeys = true) -{ - if ($seq instanceof \Traversable) { - return iterator_to_array($seq, $preserveKeys); - } + if (null !== $fill && $result) { + $last = \count($result) - 1; + if ($fillCount = $size - \count($result[$last])) { + for ($i = 0; $i < $fillCount; ++$i) { + $result[$last][] = $fill; + } + } + } - if (!\is_array($seq)) { - return $seq; + return $result; } - return $preserveKeys ? $seq : array_values($seq); -} - -/** - * Checks if a variable is empty. - * - * {# evaluates to true if the foo variable is null, false, or the empty string #} - * {% if foo is empty %} - * {# ... #} - * {% endif %} - * - * @param mixed $value A variable - * - * @return bool true if the value is empty, false otherwise - */ -function twig_test_empty($value) -{ - if ($value instanceof \Countable) { - return 0 === \count($value); - } + /** + * Returns the attribute value for a given array/object. + * + * @param mixed $object The object or array from where to get the item + * @param mixed $item The item to get from the array or object + * @param array $arguments An array of arguments to pass if the item is an object method + * @param string $type The type of attribute (@see \Twig\Template constants) + * @param bool $isDefinedTest Whether this is only a defined check + * @param bool $ignoreStrictCheck Whether to ignore the strict attribute check or not + * @param int $lineno The template line where the attribute was called + * + * @return mixed The attribute value, or a Boolean when $isDefinedTest is true, or null when the attribute is not set and $ignoreStrictCheck is true + * + * @throws RuntimeError if the attribute does not exist and Twig is running in strict mode and $isDefinedTest is false + * + * @internal + */ + public static function getAttribute(Environment $env, Source $source, $object, $item, array $arguments = [], $type = Template::ANY_CALL, $isDefinedTest = false, $ignoreStrictCheck = false, $sandboxed = false, int $lineno = -1) + { + $propertyNotAllowedError = null; - if ($value instanceof \Traversable) { - return !iterator_count($value); - } + // array + if (Template::METHOD_CALL !== $type) { + $arrayItem = \is_bool($item) || \is_float($item) ? (int) $item : $item; - if (\is_object($value) && method_exists($value, '__toString')) { - return '' === (string) $value; - } + if ($sandboxed && $object instanceof \ArrayAccess && !\in_array($object::class, self::ARRAY_LIKE_CLASSES, true)) { + try { + $env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $arrayItem, $lineno, $source); + } catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) { + goto methodCheck; + } + } - return '' === $value || false === $value || null === $value || [] === $value; -} + if (match (true) { + \is_array($object) => \array_key_exists($arrayItem = (string) $arrayItem, $object), + $object instanceof \ArrayAccess => $object->offsetExists($arrayItem), + default => false, + }) { + if ($isDefinedTest) { + return true; + } -/** - * Checks if a variable is traversable. - * - * {# evaluates to true if the foo variable is an array or a traversable object #} - * {% if foo is iterable %} - * {# ... #} - * {% endif %} - * - * @param mixed $value A variable - * - * @return bool true if the value is traversable - */ -function twig_test_iterable($value) -{ - return $value instanceof \Traversable || \is_array($value); -} + return $object[$arrayItem]; + } -/** - * Renders a template. - * - * @param array $context - * @param string|array $template The template to render or an array of templates to try consecutively - * @param array $variables The variables to pass to the template - * @param bool $withContext - * @param bool $ignoreMissing Whether to ignore missing templates or not - * @param bool $sandboxed Whether to sandbox the template or not - * - * @return string The rendered template - */ -function twig_include(Environment $env, $context, $template, $variables = [], $withContext = true, $ignoreMissing = false, $sandboxed = false) -{ - $alreadySandboxed = false; - $sandbox = null; - if ($withContext) { - $variables = array_merge($context, $variables); - } + if (Template::ARRAY_CALL === $type || !\is_object($object)) { + if ($isDefinedTest) { + return false; + } - if ($isSandboxed = $sandboxed && $env->hasExtension(SandboxExtension::class)) { - $sandbox = $env->getExtension(SandboxExtension::class); - if (!$alreadySandboxed = $sandbox->isSandboxed()) { - $sandbox->enableSandbox(); - } + if ($ignoreStrictCheck || !$env->isStrictVariables()) { + return; + } + + if ($object instanceof \ArrayAccess) { + if (\is_object($arrayItem) || \is_array($arrayItem)) { + $message = \sprintf('Key of type "%s" does not exist in ArrayAccess-able object of class "%s".', get_debug_type($arrayItem), get_debug_type($object)); + } else { + $message = \sprintf('Key "%s" does not exist in ArrayAccess-able object of class "%s".', $arrayItem, get_debug_type($object)); + } + } elseif (\is_object($object)) { + $message = \sprintf('Impossible to access a key "%s" on an object of class "%s" that does not implement ArrayAccess interface.', $item, get_debug_type($object)); + } elseif (\is_array($object)) { + if (!$object) { + $message = \sprintf('Key "%s" does not exist as the sequence/mapping is empty.', $arrayItem); + } else { + $message = \sprintf('Key "%s" for sequence/mapping with keys "%s" does not exist.', $arrayItem, implode(', ', array_keys($object))); + } + } elseif (Template::ARRAY_CALL === $type) { + if (null === $object) { + $message = \sprintf('Impossible to access a key ("%s") on a null variable.', $item); + } else { + $message = \sprintf('Impossible to access a key ("%s") on a %s variable ("%s").', $item, get_debug_type($object), $object); + } + } elseif (null === $object) { + $message = \sprintf('Impossible to access an attribute ("%s") on a null variable.', $item); + } else { + $message = \sprintf('Impossible to access an attribute ("%s") on a %s variable ("%s").', $item, get_debug_type($object), $object); + } - foreach ((\is_array($template) ? $template : [$template]) as $name) { - // if a Template instance is passed, it might have been instantiated outside of a sandbox, check security - if ($name instanceof TemplateWrapper || $name instanceof Template) { - $name->unwrap()->checkSecurity(); + throw new RuntimeError($message, $lineno, $source); } } - } - try { - $loaded = null; - try { - $loaded = $env->resolveTemplate($template); - } catch (LoaderError $e) { - if (!$ignoreMissing) { - throw $e; + $item = (string) $item; + + if (!\is_object($object)) { + if ($isDefinedTest) { + return false; } - } - return $loaded ? $loaded->render($variables) : ''; - } finally { - if ($isSandboxed && !$alreadySandboxed) { - $sandbox->disableSandbox(); - } - } -} + if ($ignoreStrictCheck || !$env->isStrictVariables()) { + return; + } -/** - * Returns a template content without rendering it. - * - * @param string $name The template name - * @param bool $ignoreMissing Whether to ignore missing templates or not - * - * @return string The template source - */ -function twig_source(Environment $env, $name, $ignoreMissing = false) -{ - $loader = $env->getLoader(); - try { - return $loader->getSourceContext($name)->getCode(); - } catch (LoaderError $e) { - if (!$ignoreMissing) { - throw $e; + if (null === $object) { + $message = \sprintf('Impossible to invoke a method ("%s") on a null variable.', $item); + } elseif (\is_array($object)) { + $message = \sprintf('Impossible to invoke a method ("%s") on a sequence/mapping.', $item); + } else { + $message = \sprintf('Impossible to invoke a method ("%s") on a %s variable ("%s").', $item, get_debug_type($object), $object); + } + + throw new RuntimeError($message, $lineno, $source); } - } -} -/** - * Provides the ability to get constants from instances as well as class/global constants. - * - * @param string $constant The name of the constant - * @param object|null $object The object to get the constant from - * - * @return string - */ -function twig_constant($constant, $object = null) -{ - if (null !== $object) { - if ('class' === $constant) { - return \get_class($object); + if ($object instanceof Template) { + throw new RuntimeError('Accessing \Twig\Template attributes is forbidden.', $lineno, $source); } - $constant = \get_class($object).'::'.$constant; - } + // object property + if (Template::METHOD_CALL !== $type) { + if ($sandboxed) { + try { + $env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $item, $lineno, $source); + } catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) { + goto methodCheck; + } + } - return \constant($constant); -} + static $propertyCheckers = []; -/** - * Checks if a constant exists. - * - * @param string $constant The name of the constant - * @param object|null $object The object to get the constant from - * - * @return bool - */ -function twig_constant_is_defined($constant, $object = null) -{ - if (null !== $object) { - if ('class' === $constant) { - return true; - } + if ($object instanceof \Closure && '__invoke' === $item) { + return $isDefinedTest ? true : $object(); + } - $constant = \get_class($object).'::'.$constant; - } + if (isset($object->$item) + || ($propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item))($object, $item) + ) { + if ($isDefinedTest) { + return true; + } - return \defined($constant); -} + return $object->$item; + } -/** - * Batches item. - * - * @param array $items An array of items - * @param int $size The size of the batch - * @param mixed $fill A value used to fill missing items - * - * @return array - */ -function twig_array_batch($items, $size, $fill = null, $preserveKeys = true) -{ - if (!twig_test_iterable($items)) { - throw new RuntimeError(sprintf('The "batch" filter expects an array or "Traversable", got "%s".', \is_object($items) ? \get_class($items) : \gettype($items))); - } + if ($object instanceof \DateTimeInterface && \in_array($item, ['date', 'timezone', 'timezone_type'], true)) { + if ($isDefinedTest) { + return true; + } - $size = ceil($size); + return ((array) $object)[$item]; + } - $result = array_chunk(twig_to_array($items, $preserveKeys), $size, $preserveKeys); + if (\defined($object::class.'::'.$item)) { + if ($isDefinedTest) { + return true; + } - if (null !== $fill && $result) { - $last = \count($result) - 1; - if ($fillCount = $size - \count($result[$last])) { - for ($i = 0; $i < $fillCount; ++$i) { - $result[$last][] = $fill; + return \constant($object::class.'::'.$item); } } - } - return $result; -} + methodCheck: -/** - * Returns the attribute value for a given array/object. - * - * @param mixed $object The object or array from where to get the item - * @param mixed $item The item to get from the array or object - * @param array $arguments An array of arguments to pass if the item is an object method - * @param string $type The type of attribute (@see \Twig\Template constants) - * @param bool $isDefinedTest Whether this is only a defined check - * @param bool $ignoreStrictCheck Whether to ignore the strict attribute check or not - * @param int $lineno The template line where the attribute was called - * - * @return mixed The attribute value, or a Boolean when $isDefinedTest is true, or null when the attribute is not set and $ignoreStrictCheck is true - * - * @throws RuntimeError if the attribute does not exist and Twig is running in strict mode and $isDefinedTest is false - * - * @internal - */ -function twig_get_attribute(Environment $env, Source $source, $object, $item, array $arguments = [], $type = /* Template::ANY_CALL */ 'any', $isDefinedTest = false, $ignoreStrictCheck = false, $sandboxed = false, int $lineno = -1) -{ - // array - if (/* Template::METHOD_CALL */ 'method' !== $type) { - $arrayItem = \is_bool($item) || \is_float($item) ? (int) $item : $item; + static $cache = []; - if (((\is_array($object) || $object instanceof \ArrayObject) && (isset($object[$arrayItem]) || \array_key_exists($arrayItem, (array) $object))) - || ($object instanceof ArrayAccess && isset($object[$arrayItem])) - ) { - if ($isDefinedTest) { - return true; + $class = $object::class; + + // object method + // precedence: getXxx() > isXxx() > hasXxx() + if (!isset($cache[$class])) { + $methods = get_class_methods($object); + if ($object instanceof \Closure) { + $methods[] = '__invoke'; } + sort($methods); + $lcMethods = array_map('strtolower', $methods); + $classCache = []; + foreach ($methods as $i => $method) { + $classCache[$method] = $method; + $classCache[$lcName = $lcMethods[$i]] = $method; + + if ('g' === $lcName[0] && str_starts_with($lcName, 'get')) { + $name = substr($method, 3); + $lcName = substr($lcName, 3); + } elseif ('i' === $lcName[0] && str_starts_with($lcName, 'is')) { + $name = substr($method, 2); + $lcName = substr($lcName, 2); + } elseif ('h' === $lcName[0] && str_starts_with($lcName, 'has')) { + $name = substr($method, 3); + $lcName = substr($lcName, 3); + if (\in_array('is'.$lcName, $lcMethods, true)) { + continue; + } + } else { + continue; + } - return $object[$arrayItem]; + // skip get() and is() methods (in which case, $name is empty) + if ($name) { + if (!isset($classCache[$name])) { + $classCache[$name] = $method; + } + + if (!isset($classCache[$lcName])) { + $classCache[$lcName] = $method; + } + } + } + $cache[$class] = $classCache; } - if (/* Template::ARRAY_CALL */ 'array' === $type || !\is_object($object)) { + $call = false; + if (isset($cache[$class][$item])) { + $method = $cache[$class][$item]; + } elseif (isset($cache[$class][$lcItem = strtolower($item)])) { + $method = $cache[$class][$lcItem]; + } elseif (isset($cache[$class]['__call'])) { + $method = $item; + $call = true; + } else { if ($isDefinedTest) { return false; } + if ($propertyNotAllowedError) { + throw $propertyNotAllowedError; + } + if ($ignoreStrictCheck || !$env->isStrictVariables()) { return; } - if ($object instanceof ArrayAccess) { - $message = sprintf('Key "%s" in object with ArrayAccess of class "%s" does not exist.', $arrayItem, \get_class($object)); - } elseif (\is_object($object)) { - $message = sprintf('Impossible to access a key "%s" on an object of class "%s" that does not implement ArrayAccess interface.', $item, \get_class($object)); - } elseif (\is_array($object)) { - if (empty($object)) { - $message = sprintf('Key "%s" does not exist as the array is empty.', $arrayItem); - } else { - $message = sprintf('Key "%s" for array with keys "%s" does not exist.', $arrayItem, implode(', ', array_keys($object))); + throw new RuntimeError(\sprintf('Neither the property "%1$s" nor one of the methods "%1$s()", "get%1$s()"/"is%1$s()"/"has%1$s()" or "__call()" exist and have public access in class "%2$s".', $item, $class), $lineno, $source); + } + + if ($sandboxed) { + try { + $env->getExtension(SandboxExtension::class)->checkMethodAllowed($object, $method, $lineno, $source); + } catch (SecurityNotAllowedMethodError $e) { + if ($isDefinedTest) { + return false; } - } elseif (/* Template::ARRAY_CALL */ 'array' === $type) { - if (null === $object) { - $message = sprintf('Impossible to access a key ("%s") on a null variable.', $item); - } else { - $message = sprintf('Impossible to access a key ("%s") on a %s variable ("%s").', $item, \gettype($object), $object); + + if ($propertyNotAllowedError) { + throw $propertyNotAllowedError; } - } elseif (null === $object) { - $message = sprintf('Impossible to access an attribute ("%s") on a null variable.', $item); - } else { - $message = sprintf('Impossible to access an attribute ("%s") on a %s variable ("%s").', $item, \gettype($object), $object); - } - throw new RuntimeError($message, $lineno, $source); + throw $e; + } } - } - if (!\is_object($object)) { if ($isDefinedTest) { - return false; + return true; } - if ($ignoreStrictCheck || !$env->isStrictVariables()) { - return; + // Some objects throw exceptions when they have __call, and the method we try + // to call is not supported. If ignoreStrictCheck is true, we should return null. + try { + $ret = $object->$method(...$arguments); + } catch (\BadMethodCallException $e) { + if ($call && ($ignoreStrictCheck || !$env->isStrictVariables())) { + return; + } + throw $e; } - if (null === $object) { - $message = sprintf('Impossible to invoke a method ("%s") on a null variable.', $item); - } elseif (\is_array($object)) { - $message = sprintf('Impossible to invoke a method ("%s") on an array.', $item); - } else { - $message = sprintf('Impossible to invoke a method ("%s") on a %s variable ("%s").', $item, \gettype($object), $object); + return $ret; + } + + /** + * Returns the values from a single column in the input array. + * + *
    +     *  {% set items = [{ 'fruit' : 'apple'}, {'fruit' : 'orange' }] %}
    +     *
    +     *  {% set fruits = items|column('fruit') %}
    +     *
    +     *  {# fruits now contains ['apple', 'orange'] #}
    +     * 
    + * + * @param array|\Traversable $array An array + * @param int|string $name The column name + * @param int|string|null $index The column to use as the index/keys for the returned array + * + * @return array The array of values + * + * @internal + */ + public static function column($array, $name, $index = null): array + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "column" filter expects a sequence or a mapping, got "%s".', get_debug_type($array))); } - throw new RuntimeError($message, $lineno, $source); - } + if ($array instanceof \Traversable) { + $array = iterator_to_array($array); + } - if ($object instanceof Template) { - throw new RuntimeError('Accessing \Twig\Template attributes is forbidden.', $lineno, $source); + return array_column($array, $name, $index); } - // object property - if (/* Template::METHOD_CALL */ 'method' !== $type) { - if (isset($object->$item) || \array_key_exists((string) $item, (array) $object)) { - if ($isDefinedTest) { - return true; - } + /** + * @param \Closure $arrow + * + * @internal + */ + public static function filter(Environment $env, $array, $arrow) + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "filter" filter expects a sequence/mapping or "Traversable", got "%s".', get_debug_type($array))); + } - if ($sandboxed) { - $env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $item, $lineno, $source); - } + self::checkArrow($env, $arrow, 'filter', 'filter'); - return $object->$item; + if (\is_array($array)) { + return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH); } - } - static $cache = []; + // the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator + return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow); + } - $class = \get_class($object); + /** + * @param \Closure $arrow + * + * @internal + */ + public static function find(Environment $env, $array, $arrow) + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "find" filter expects a sequence or a mapping, got "%s".', get_debug_type($array))); + } - // object method - // precedence: getXxx() > isXxx() > hasXxx() - if (!isset($cache[$class])) { - $methods = get_class_methods($object); - sort($methods); - $lcMethods = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, $methods); - $classCache = []; - foreach ($methods as $i => $method) { - $classCache[$method] = $method; - $classCache[$lcName = $lcMethods[$i]] = $method; + self::checkArrow($env, $arrow, 'find', 'filter'); - if ('g' === $lcName[0] && 0 === strpos($lcName, 'get')) { - $name = substr($method, 3); - $lcName = substr($lcName, 3); - } elseif ('i' === $lcName[0] && 0 === strpos($lcName, 'is')) { - $name = substr($method, 2); - $lcName = substr($lcName, 2); - } elseif ('h' === $lcName[0] && 0 === strpos($lcName, 'has')) { - $name = substr($method, 3); - $lcName = substr($lcName, 3); - if (\in_array('is'.$lcName, $lcMethods)) { - continue; - } - } else { - continue; + foreach ($array as $k => $v) { + if ($arrow($v, $k)) { + return $v; } + } - // skip get() and is() methods (in which case, $name is empty) - if ($name) { - if (!isset($classCache[$name])) { - $classCache[$name] = $method; - } + return null; + } - if (!isset($classCache[$lcName])) { - $classCache[$lcName] = $method; - } - } + /** + * @param \Closure $arrow + * + * @internal + */ + public static function map(Environment $env, $array, $arrow) + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "map" filter expects a sequence or a mapping, got "%s".', get_debug_type($array))); + } + + self::checkArrow($env, $arrow, 'map', 'filter'); + + $r = []; + foreach ($array as $k => $v) { + $r[$k] = $arrow($v, $k); } - $cache[$class] = $classCache; + + return $r; } - $call = false; - if (isset($cache[$class][$item])) { - $method = $cache[$class][$item]; - } elseif (isset($cache[$class][$lcItem = strtr($item, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')])) { - $method = $cache[$class][$lcItem]; - } elseif (isset($cache[$class]['__call'])) { - $method = $item; - $call = true; - } else { - if ($isDefinedTest) { - return false; + /** + * @param \Closure $arrow + * + * @internal + */ + public static function reduce(Environment $env, $array, $arrow, $initial = null) + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "reduce" filter expects a sequence or a mapping, got "%s".', get_debug_type($array))); } - if ($ignoreStrictCheck || !$env->isStrictVariables()) { - return; + self::checkArrow($env, $arrow, 'reduce', 'filter'); + + $accumulator = $initial; + foreach ($array as $key => $value) { + $accumulator = $arrow($accumulator, $value, $key); } - throw new RuntimeError(sprintf('Neither the property "%1$s" nor one of the methods "%1$s()", "get%1$s()"/"is%1$s()"/"has%1$s()" or "__call()" exist and have public access in class "%2$s".', $item, $class), $lineno, $source); + return $accumulator; } - if ($isDefinedTest) { - return true; + /** + * @param \Closure $arrow + * + * @internal + */ + public static function arraySome(Environment $env, $array, $arrow) + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "has some" test expects a sequence or a mapping, got "%s".', get_debug_type($array))); + } + + self::checkArrow($env, $arrow, 'has some', 'operator'); + + foreach ($array as $k => $v) { + if ($arrow($v, $k)) { + return true; + } + } + + return false; } - if ($sandboxed) { - $env->getExtension(SandboxExtension::class)->checkMethodAllowed($object, $method, $lineno, $source); + /** + * @param \Closure $arrow + * + * @internal + */ + public static function arrayEvery(Environment $env, $array, $arrow) + { + if (!is_iterable($array)) { + throw new RuntimeError(\sprintf('The "has every" test expects a sequence or a mapping, got "%s".', get_debug_type($array))); + } + + self::checkArrow($env, $arrow, 'has every', 'operator'); + + foreach ($array as $k => $v) { + if (!$arrow($v, $k)) { + return false; + } + } + + return true; } - // Some objects throw exceptions when they have __call, and the method we try - // to call is not supported. If ignoreStrictCheck is true, we should return null. - try { - $ret = $object->$method(...$arguments); - } catch (\BadMethodCallException $e) { - if ($call && ($ignoreStrictCheck || !$env->isStrictVariables())) { + /** + * @internal + */ + public static function checkArrow(Environment $env, $arrow, $thing, $type) + { + if ($arrow instanceof \Closure) { return; } - throw $e; + + if ($env->hasExtension(SandboxExtension::class) && $env->getExtension(SandboxExtension::class)->isSandboxed()) { + throw new RuntimeError(\sprintf('The callable passed to the "%s" %s must be a Closure in sandbox mode.', $thing, $type)); + } + + trigger_deprecation('twig/twig', '3.15', 'Passing a callable that is not a PHP \Closure as an argument to the "%s" %s is deprecated.', $thing, $type); } - return $ret; -} + /** + * @internal to be removed in Twig 4 + */ + public static function captureOutput(iterable $body): string + { + $level = ob_get_level(); + ob_start(); -/** - * Returns the values from a single column in the input array. - * - *
    - *  {% set items = [{ 'fruit' : 'apple'}, {'fruit' : 'orange' }] %}
    - *
    - *  {% set fruits = items|column('fruit') %}
    - *
    - *  {# fruits now contains ['apple', 'orange'] #}
    - * 
    - * - * @param array|Traversable $array An array - * @param mixed $name The column name - * @param mixed $index The column to use as the index/keys for the returned array - * - * @return array The array of values - */ -function twig_array_column($array, $name, $index = null): array -{ - if ($array instanceof Traversable) { - $array = iterator_to_array($array); - } elseif (!\is_array($array)) { - throw new RuntimeError(sprintf('The column filter only works with arrays or "Traversable", got "%s" as first argument.', \gettype($array))); + try { + foreach ($body as $data) { + echo $data; + } + } catch (\Throwable $e) { + while (ob_get_level() > $level) { + ob_end_clean(); + } + + throw $e; + } + + return ob_get_clean(); } - return array_column($array, $name, $index); -} + /** + * @internal + */ + public static function parseParentFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression + { + if (!$blockName = $parser->peekBlockStack()) { + throw new SyntaxError('Calling the "parent" function outside of a block is forbidden.', $line, $parser->getStream()->getSourceContext()); + } -function twig_array_filter(Environment $env, $array, $arrow) -{ - if (!twig_test_iterable($array)) { - throw new RuntimeError(sprintf('The "filter" filter expects an array or "Traversable", got "%s".', \is_object($array) ? \get_class($array) : \gettype($array))); + if (!$parser->hasInheritance()) { + throw new SyntaxError('Calling the "parent" function on a template that does not call "extends" or "use" is forbidden.', $line, $parser->getStream()->getSourceContext()); + } + + return new ParentExpression($blockName, $line); } - twig_check_arrow_in_sandbox($env, $arrow, 'filter', 'filter'); + /** + * @internal + */ + public static function parseBlockFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression + { + $fakeFunction = new TwigFunction('block', fn ($name, $template = null) => null); + $args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args); - if (\is_array($array)) { - return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH); + return new BlockReferenceExpression($args[0], $args[1] ?? null, $line); } - // the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator - return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow); -} + /** + * @internal + */ + public static function parseAttributeFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression + { + $fakeFunction = new TwigFunction('attribute', fn ($variable, $attribute, $arguments = null) => null); + $args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args); -function twig_array_map(Environment $env, $array, $arrow) -{ - twig_check_arrow_in_sandbox($env, $arrow, 'map', 'filter'); + /* + Deprecation to uncomment sometimes during the lifetime of the 4.x branch + $src = $parser->getStream()->getSourceContext(); + $dep = new DeprecatedCallableInfo('twig/twig', '3.15', 'The "attribute" function is deprecated, use the "." notation instead.'); + $dep->setName('attribute'); + $dep->setType('function'); + $dep->triggerDeprecation($src->getPath() ?: $src->getName(), $line); + */ - $r = []; - foreach ($array as $k => $v) { - $r[$k] = $arrow($v, $k); + return new GetAttrExpression($args[0], $args[1], $args[2] ?? null, Template::ANY_CALL, $line); } - return $r; -} + private static function getPropertyChecker(string $class, string $property): \Closure + { + static $classReflectors = []; -function twig_array_reduce(Environment $env, $array, $arrow, $initial = null) -{ - twig_check_arrow_in_sandbox($env, $arrow, 'reduce', 'filter'); + $class = $classReflectors[$class] ??= new \ReflectionClass($class); + + if (!$class->hasProperty($property)) { + static $propertyExists; - if (!\is_array($array)) { - if (!$array instanceof \Traversable) { - throw new RuntimeError(sprintf('The "reduce" filter only works with arrays or "Traversable", got "%s" as first argument.', \gettype($array))); + return $propertyExists ??= \Closure::fromCallable('property_exists'); } - $array = iterator_to_array($array); - } + $property = $class->getProperty($property); - return array_reduce($array, $arrow, $initial); -} + if (!$property->isPublic() || $property->isStatic()) { + static $false; -function twig_check_arrow_in_sandbox(Environment $env, $arrow, $thing, $type) -{ - if (!$arrow instanceof Closure && $env->hasExtension('\Twig\Extension\SandboxExtension') && $env->getExtension('\Twig\Extension\SandboxExtension')->isSandboxed()) { - throw new RuntimeError(sprintf('The callable passed to the "%s" %s must be a Closure in sandbox mode.', $thing, $type)); + return $false ??= static fn () => false; + } + + return static fn ($object) => $property->isInitialized($object); } } -} diff --git a/src/Extension/DebugExtension.php b/src/Extension/DebugExtension.php index bfb23d7bd4f..dac21c31797 100644 --- a/src/Extension/DebugExtension.php +++ b/src/Extension/DebugExtension.php @@ -9,7 +9,11 @@ * file that was distributed with this source code. */ -namespace Twig\Extension { +namespace Twig\Extension; + +use Twig\Environment; +use Twig\Template; +use Twig\TemplateWrapper; use Twig\TwigFunction; final class DebugExtension extends AbstractExtension @@ -18,47 +22,41 @@ public function getFunctions(): array { // dump is safe if var_dump is overridden by xdebug $isDumpOutputHtmlSafe = \extension_loaded('xdebug') - // false means that it was not set (and the default is on) or it explicitly enabled - && (false === ini_get('xdebug.overload_var_dump') || ini_get('xdebug.overload_var_dump')) - // false means that it was not set (and the default is on) or it explicitly enabled - // xdebug.overload_var_dump produces HTML only when html_errors is also enabled - && (false === ini_get('html_errors') || ini_get('html_errors')) + // Xdebug overloads var_dump in develop mode when html_errors is enabled + && str_contains(\ini_get('xdebug.mode'), 'develop') + && (false === \ini_get('html_errors') || \ini_get('html_errors')) || 'cli' === \PHP_SAPI ; return [ - new TwigFunction('dump', 'twig_var_dump', ['is_safe' => $isDumpOutputHtmlSafe ? ['html'] : [], 'needs_context' => true, 'needs_environment' => true, 'is_variadic' => true]), + new TwigFunction('dump', [self::class, 'dump'], ['is_safe' => $isDumpOutputHtmlSafe ? ['html'] : [], 'needs_context' => true, 'needs_environment' => true, 'is_variadic' => true]), ]; } -} -} -namespace { -use Twig\Environment; -use Twig\Template; -use Twig\TemplateWrapper; - -function twig_var_dump(Environment $env, $context, ...$vars) -{ - if (!$env->isDebug()) { - return; - } + /** + * @internal + */ + public static function dump(Environment $env, $context, ...$vars) + { + if (!$env->isDebug()) { + return; + } - ob_start(); + ob_start(); - if (!$vars) { - $vars = []; - foreach ($context as $key => $value) { - if (!$value instanceof Template && !$value instanceof TemplateWrapper) { - $vars[$key] = $value; + if (!$vars) { + $vars = []; + foreach ($context as $key => $value) { + if (!$value instanceof Template && !$value instanceof TemplateWrapper) { + $vars[$key] = $value; + } } + + var_dump($vars); + } else { + var_dump(...$vars); } - var_dump($vars); - } else { - var_dump(...$vars); + return ob_get_clean(); } - - return ob_get_clean(); -} } diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index 9d2251dc6e1..c5625fa6a41 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -9,22 +9,24 @@ * file that was distributed with this source code. */ -namespace Twig\Extension { +namespace Twig\Extension; + +use Twig\Environment; use Twig\FileExtensionEscapingStrategy; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\Filter\RawFilter; +use Twig\Node\Node; use Twig\NodeVisitor\EscaperNodeVisitor; +use Twig\Runtime\EscaperRuntime; use Twig\TokenParser\AutoEscapeTokenParser; use Twig\TwigFilter; final class EscaperExtension extends AbstractExtension { - private $defaultStrategy; + private $environment; private $escapers = []; - - /** @internal */ - public $safeClasses = []; - - /** @internal */ - public $safeLookup = []; + private $escaper; + private $defaultStrategy; /** * @param string|false|callable $defaultStrategy An escaping strategy @@ -49,19 +51,53 @@ public function getNodeVisitors(): array public function getFilters(): array { return [ - new TwigFilter('escape', 'twig_escape_filter', ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), - new TwigFilter('e', 'twig_escape_filter', ['needs_environment' => true, 'is_safe_callback' => 'twig_escape_filter_is_safe']), - new TwigFilter('raw', 'twig_raw_filter', ['is_safe' => ['all']]), + new TwigFilter('escape', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]), + new TwigFilter('e', [EscaperRuntime::class, 'escape'], ['is_safe_callback' => [self::class, 'escapeFilterIsSafe']]), + new TwigFilter('raw', null, ['is_safe' => ['all'], 'node_class' => RawFilter::class]), ]; } + public function getLastModified(): int + { + return max( + parent::getLastModified(), + filemtime((new \ReflectionClass(EscaperRuntime::class))->getFileName()), + ); + } + + /** + * @deprecated since Twig 3.10 + */ + public function setEnvironment(Environment $environment): void + { + $triggerDeprecation = \func_num_args() > 1 ? func_get_arg(1) : true; + if ($triggerDeprecation) { + trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated and not needed if you are using methods from "Twig\Runtime\EscaperRuntime".', __METHOD__); + } + + $this->environment = $environment; + $this->escaper = $environment->getRuntime(EscaperRuntime::class); + } + + /** + * @return void + * + * @deprecated since Twig 3.10 + */ + public function setEscaperRuntime(EscaperRuntime $escaper) + { + trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated and not needed if you are using methods from "Twig\Runtime\EscaperRuntime".', __METHOD__); + + $this->escaper = $escaper; + } + /** * Sets the default strategy to use when not defined by the user. * * The strategy can be a valid PHP callback that takes the template * name as an argument and returns the strategy to use. * - * @param string|false|callable $defaultStrategy An escaping strategy + * @param string|false|callable(string $templateName): string $defaultStrategy An escaping strategy */ public function setDefaultStrategy($defaultStrategy): void { @@ -93,324 +129,90 @@ public function getDefaultStrategy(string $name) /** * Defines a new escaper to be used via the escape filter. * - * @param string $strategy The strategy name that should be used as a strategy in the escape call - * @param callable $callable A valid PHP callable + * @param string $strategy The strategy name that should be used as a strategy in the escape call + * @param callable(Environment, string, string): string $callable A valid PHP callable + * + * @return void + * + * @deprecated since Twig 3.10 */ public function setEscaper($strategy, callable $callable) { + trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::setEscaper()" method instead (be warned that Environment is not passed anymore to the callable).', __METHOD__); + + if (!isset($this->environment)) { + throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__)); + } + $this->escapers[$strategy] = $callable; + $callable = function ($string, $charset) use ($callable) { + return $callable($this->environment, $string, $charset); + }; + + $this->escaper->setEscaper($strategy, $callable); } /** * Gets all defined escapers. * - * @return callable[] An array of escapers + * @return array An array of escapers + * + * @deprecated since Twig 3.10 */ public function getEscapers() { + trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::getEscaper()" method instead.', __METHOD__); + return $this->escapers; } + /** + * @return void + * + * @deprecated since Twig 3.10 + */ public function setSafeClasses(array $safeClasses = []) { - $this->safeClasses = []; - $this->safeLookup = []; - foreach ($safeClasses as $class => $strategies) { - $this->addSafeClass($class, $strategies); - } - } + trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::setSafeClasses()" method instead.', __METHOD__); - public function addSafeClass(string $class, array $strategies) - { - $class = ltrim($class, '\\'); - if (!isset($this->safeClasses[$class])) { - $this->safeClasses[$class] = []; + if (!isset($this->escaper)) { + throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__)); } - $this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies); - foreach ($strategies as $strategy) { - $this->safeLookup[$strategy][$class] = true; - } + $this->escaper->setSafeClasses($safeClasses); } -} -} - -namespace { -use Twig\Environment; -use Twig\Error\RuntimeError; -use Twig\Extension\EscaperExtension; -use Twig\Markup; -use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Node; - -/** - * Marks a variable as being safe. - * - * @param string $string A PHP variable - */ -function twig_raw_filter($string) -{ - return $string; -} -/** - * Escapes a string. - * - * @param mixed $string The value to be escaped - * @param string $strategy The escaping strategy - * @param string $charset The charset - * @param bool $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false) - * - * @return string - */ -function twig_escape_filter(Environment $env, $string, $strategy = 'html', $charset = null, $autoescape = false) -{ - if ($autoescape && $string instanceof Markup) { - return $string; - } - - if (!\is_string($string)) { - if (\is_object($string) && method_exists($string, '__toString')) { - if ($autoescape) { - $c = \get_class($string); - $ext = $env->getExtension(EscaperExtension::class); - if (!isset($ext->safeClasses[$c])) { - $ext->safeClasses[$c] = []; - foreach (class_parents($string) + class_implements($string) as $class) { - if (isset($ext->safeClasses[$class])) { - $ext->safeClasses[$c] = array_unique(array_merge($ext->safeClasses[$c], $ext->safeClasses[$class])); - foreach ($ext->safeClasses[$class] as $s) { - $ext->safeLookup[$s][$c] = true; - } - } - } - } - if (isset($ext->safeLookup[$strategy][$c]) || isset($ext->safeLookup['all'][$c])) { - return (string) $string; - } - } + /** + * @return void + * + * @deprecated since Twig 3.10 + */ + public function addSafeClass(string $class, array $strategies) + { + trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::addSafeClass()" method instead.', __METHOD__); - $string = (string) $string; - } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'])) { - return $string; + if (!isset($this->escaper)) { + throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__)); } - } - if ('' === $string) { - return ''; + $this->escaper->addSafeClass($class, $strategies); } - if (null === $charset) { - $charset = $env->getCharset(); - } - - switch ($strategy) { - case 'html': - // see https://www.php.net/htmlspecialchars - - // Using a static variable to avoid initializing the array - // each time the function is called. Moving the declaration on the - // top of the function slow downs other escaping strategies. - static $htmlspecialcharsCharsets = [ - 'ISO-8859-1' => true, 'ISO8859-1' => true, - 'ISO-8859-15' => true, 'ISO8859-15' => true, - 'utf-8' => true, 'UTF-8' => true, - 'CP866' => true, 'IBM866' => true, '866' => true, - 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, - '1251' => true, - 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, - 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, - 'BIG5' => true, '950' => true, - 'GB2312' => true, '936' => true, - 'BIG5-HKSCS' => true, - 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, - 'EUC-JP' => true, 'EUCJP' => true, - 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, - ]; - - if (isset($htmlspecialcharsCharsets[$charset])) { - return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset); - } - - if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { - // cache the lowercase variant for future iterations - $htmlspecialcharsCharsets[$charset] = true; - - return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset); - } - - $string = twig_convert_encoding($string, 'UTF-8', $charset); - $string = htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); - - return iconv('UTF-8', $charset, $string); - - case 'js': - // escape all non-alphanumeric characters - // into their \x or \uHHHH representations - if ('UTF-8' !== $charset) { - $string = twig_convert_encoding($string, 'UTF-8', $charset); - } - - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } - - $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) { - $char = $matches[0]; - - /* - * A few characters have short escape sequences in JSON and JavaScript. - * Escape sequences supported only by JavaScript, not JSON, are omitted. - * \" is also supported but omitted, because the resulting string is not HTML safe. - */ - static $shortMap = [ - '\\' => '\\\\', - '/' => '\\/', - "\x08" => '\b', - "\x0C" => '\f', - "\x0A" => '\n', - "\x0D" => '\r', - "\x09" => '\t', - ]; - - if (isset($shortMap[$char])) { - return $shortMap[$char]; - } - - $codepoint = mb_ord($char, 'UTF-8'); - if (0x10000 > $codepoint) { - return sprintf('\u%04X', $codepoint); - } - - // Split characters outside the BMP into surrogate pairs - // https://tools.ietf.org/html/rfc2781.html#section-2.1 - $u = $codepoint - 0x10000; - $high = 0xD800 | ($u >> 10); - $low = 0xDC00 | ($u & 0x3FF); - - return sprintf('\u%04X\u%04X', $high, $low); - }, $string); - - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } - - return $string; - - case 'css': - if ('UTF-8' !== $charset) { - $string = twig_convert_encoding($string, 'UTF-8', $charset); - } - - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } - - $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) { - $char = $matches[0]; - - return sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8')); - }, $string); - - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } - - return $string; - - case 'html_attr': - if ('UTF-8' !== $charset) { - $string = twig_convert_encoding($string, 'UTF-8', $charset); - } - - if (!preg_match('//u', $string)) { - throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); - } - - $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) { - /** - * This function is adapted from code coming from Zend Framework. - * - * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com) - * @license https://framework.zend.com/license/new-bsd New BSD License - */ - $chr = $matches[0]; - $ord = \ord($chr); - - /* - * The following replaces characters undefined in HTML with the - * hex entity for the Unicode replacement character. - */ - if (($ord <= 0x1f && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7f && $ord <= 0x9f)) { - return '�'; - } - - /* - * Check if the current character to escape has a name entity we should - * replace it with while grabbing the hex value of the character. - */ - if (1 === \strlen($chr)) { - /* - * While HTML supports far more named entities, the lowest common denominator - * has become HTML5's XML Serialisation which is restricted to the those named - * entities that XML supports. Using HTML entities would result in this error: - * XML Parsing Error: undefined entity - */ - static $entityMap = [ - 34 => '"', /* quotation mark */ - 38 => '&', /* ampersand */ - 60 => '<', /* less-than sign */ - 62 => '>', /* greater-than sign */ - ]; - - if (isset($entityMap[$ord])) { - return $entityMap[$ord]; - } - - return sprintf('&#x%02X;', $ord); - } - - /* - * Per OWASP recommendations, we'll use hex entities for any other - * characters where a named entity does not exist. - */ - return sprintf('&#x%04X;', mb_ord($chr, 'UTF-8')); - }, $string); - - if ('UTF-8' !== $charset) { - $string = iconv('UTF-8', $charset, $string); - } - - return $string; - - case 'url': - return rawurlencode($string); - - default: - $escapers = $env->getExtension(EscaperExtension::class)->getEscapers(); - if (array_key_exists($strategy, $escapers)) { - return $escapers[$strategy]($env, $string, $charset); + /** + * @internal + * + * @return array + */ + public static function escapeFilterIsSafe(Node $filterArgs) + { + foreach ($filterArgs as $arg) { + if ($arg instanceof ConstantExpression) { + return [$arg->getAttribute('value')]; } - $validStrategies = implode(', ', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($escapers))); - - throw new RuntimeError(sprintf('Invalid escaping strategy "%s" (valid ones: %s).', $strategy, $validStrategies)); - } -} - -/** - * @internal - */ -function twig_escape_filter_is_safe(Node $filterArgs) -{ - foreach ($filterArgs as $arg) { - if ($arg instanceof ConstantExpression) { - return [$arg->getAttribute('value')]; + return []; } - return []; + return ['html']; } - - return ['html']; -} } diff --git a/src/Extension/ExtensionInterface.php b/src/Extension/ExtensionInterface.php index ab9c2c37c19..60ca35b4e6a 100644 --- a/src/Extension/ExtensionInterface.php +++ b/src/Extension/ExtensionInterface.php @@ -12,6 +12,8 @@ namespace Twig\Extension; use Twig\ExpressionParser; +use Twig\ExpressionParser\ExpressionParserInterface; +use Twig\ExpressionParser\PrecedenceChange; use Twig\Node\Expression\Binary\AbstractBinary; use Twig\Node\Expression\Unary\AbstractUnary; use Twig\NodeVisitor\NodeVisitorInterface; @@ -24,6 +26,8 @@ * Interface implemented by extension classes. * * @author Fabien Potencier + * + * @method array getExpressionParsers() */ interface ExtensionInterface { @@ -65,11 +69,11 @@ public function getFunctions(); /** * Returns a list of operators to add to the existing list. * - * @return array First array of unary operators, second array of binary operators + * @return array * * @psalm-return array{ - * array}>, - * array, associativity: ExpressionParser::OPERATOR_*}> + * array}>, + * array, associativity: ExpressionParser::OPERATOR_*}> * } */ public function getOperators(); diff --git a/src/Extension/GlobalsInterface.php b/src/Extension/GlobalsInterface.php index 6f1dfe8a73e..d52cd107e5e 100644 --- a/src/Extension/GlobalsInterface.php +++ b/src/Extension/GlobalsInterface.php @@ -12,10 +12,7 @@ namespace Twig\Extension; /** - * Enables usage of the deprecated Twig\Extension\AbstractExtension::getGlobals() method. - * - * Explicitly implement this interface if you really need to implement the - * deprecated getGlobals() method in your extensions. + * Allows Twig extensions to add globals to the context. * * @author Fabien Potencier */ diff --git a/src/Extension/LastModifiedExtensionInterface.php b/src/Extension/LastModifiedExtensionInterface.php new file mode 100644 index 00000000000..4bab0c07cd3 --- /dev/null +++ b/src/Extension/LastModifiedExtensionInterface.php @@ -0,0 +1,23 @@ +optimizers = $optimizers; + public function __construct( + private int $optimizers = -1, + ) { } public function getNodeVisitors(): array diff --git a/src/Extension/SandboxExtension.php b/src/Extension/SandboxExtension.php index c861159b6f0..5d0f6444316 100644 --- a/src/Extension/SandboxExtension.php +++ b/src/Extension/SandboxExtension.php @@ -15,6 +15,7 @@ use Twig\Sandbox\SecurityNotAllowedMethodError; use Twig\Sandbox\SecurityNotAllowedPropertyError; use Twig\Sandbox\SecurityPolicyInterface; +use Twig\Sandbox\SourcePolicyInterface; use Twig\Source; use Twig\TokenParser\SandboxTokenParser; @@ -23,11 +24,13 @@ final class SandboxExtension extends AbstractExtension private $sandboxedGlobally; private $sandboxed; private $policy; + private $sourcePolicy; - public function __construct(SecurityPolicyInterface $policy, $sandboxed = false) + public function __construct(SecurityPolicyInterface $policy, $sandboxed = false, ?SourcePolicyInterface $sourcePolicy = null) { $this->policy = $policy; $this->sandboxedGlobally = $sandboxed; + $this->sourcePolicy = $sourcePolicy; } public function getTokenParsers(): array @@ -50,9 +53,9 @@ public function disableSandbox(): void $this->sandboxed = false; } - public function isSandboxed(): bool + public function isSandboxed(?Source $source = null): bool { - return $this->sandboxedGlobally || $this->sandboxed; + return $this->sandboxedGlobally || $this->sandboxed || $this->isSourceSandboxed($source); } public function isSandboxedGlobally(): bool @@ -60,7 +63,16 @@ public function isSandboxedGlobally(): bool return $this->sandboxedGlobally; } - public function setSecurityPolicy(SecurityPolicyInterface $policy) + private function isSourceSandboxed(?Source $source): bool + { + if (null === $source || null === $this->sourcePolicy) { + return false; + } + + return $this->sourcePolicy->enableSandbox($source); + } + + public function setSecurityPolicy(SecurityPolicyInterface $policy): void { $this->policy = $policy; } @@ -70,16 +82,16 @@ public function getSecurityPolicy(): SecurityPolicyInterface return $this->policy; } - public function checkSecurity($tags, $filters, $functions): void + public function checkSecurity($tags, $filters, $functions, ?Source $source = null): void { - if ($this->isSandboxed()) { + if ($this->isSandboxed($source)) { $this->policy->checkSecurity($tags, $filters, $functions); } } - public function checkMethodAllowed($obj, $method, int $lineno = -1, Source $source = null): void + public function checkMethodAllowed($obj, $method, int $lineno = -1, ?Source $source = null): void { - if ($this->isSandboxed()) { + if ($this->isSandboxed($source)) { try { $this->policy->checkMethodAllowed($obj, $method); } catch (SecurityNotAllowedMethodError $e) { @@ -91,9 +103,9 @@ public function checkMethodAllowed($obj, $method, int $lineno = -1, Source $sour } } - public function checkPropertyAllowed($obj, $property, int $lineno = -1, Source $source = null): void + public function checkPropertyAllowed($obj, $property, int $lineno = -1, ?Source $source = null): void { - if ($this->isSandboxed()) { + if ($this->isSandboxed($source)) { try { $this->policy->checkPropertyAllowed($obj, $property); } catch (SecurityNotAllowedPropertyError $e) { @@ -105,9 +117,18 @@ public function checkPropertyAllowed($obj, $property, int $lineno = -1, Source $ } } - public function ensureToStringAllowed($obj, int $lineno = -1, Source $source = null) + /** + * @throws SecurityNotAllowedMethodError + */ + public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null) { - if ($this->isSandboxed() && \is_object($obj) && method_exists($obj, '__toString')) { + if (\is_array($obj)) { + $this->ensureToStringAllowedForArray($obj, $lineno, $source); + + return $obj; + } + + if ($obj instanceof \Stringable && $this->isSandboxed($source)) { try { $this->policy->checkMethodAllowed($obj, '__toString'); } catch (SecurityNotAllowedMethodError $e) { @@ -120,4 +141,28 @@ public function ensureToStringAllowed($obj, int $lineno = -1, Source $source = n return $obj; } + + private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, array &$stack = []): void + { + foreach ($obj as $k => $v) { + if (!$v) { + continue; + } + + if (!\is_array($v)) { + $this->ensureToStringAllowed($v, $lineno, $source); + continue; + } + + if ($r = \ReflectionReference::fromArrayElement($obj, $k)) { + if (isset($stack[$r->getId()])) { + continue; + } + + $stack[$r->getId()] = true; + } + + $this->ensureToStringAllowedForArray($v, $lineno, $source, $stack); + } + } } diff --git a/src/Extension/StagingExtension.php b/src/Extension/StagingExtension.php index 0ea47f90c5c..59db2ca7d4a 100644 --- a/src/Extension/StagingExtension.php +++ b/src/Extension/StagingExtension.php @@ -35,7 +35,7 @@ final class StagingExtension extends AbstractExtension public function addFunction(TwigFunction $function): void { if (isset($this->functions[$function->getName()])) { - throw new \LogicException(sprintf('Function "%s" is already registered.', $function->getName())); + throw new \LogicException(\sprintf('Function "%s" is already registered.', $function->getName())); } $this->functions[$function->getName()] = $function; @@ -49,7 +49,7 @@ public function getFunctions(): array public function addFilter(TwigFilter $filter): void { if (isset($this->filters[$filter->getName()])) { - throw new \LogicException(sprintf('Filter "%s" is already registered.', $filter->getName())); + throw new \LogicException(\sprintf('Filter "%s" is already registered.', $filter->getName())); } $this->filters[$filter->getName()] = $filter; @@ -73,7 +73,7 @@ public function getNodeVisitors(): array public function addTokenParser(TokenParserInterface $parser): void { if (isset($this->tokenParsers[$parser->getTag()])) { - throw new \LogicException(sprintf('Tag "%s" is already registered.', $parser->getTag())); + throw new \LogicException(\sprintf('Tag "%s" is already registered.', $parser->getTag())); } $this->tokenParsers[$parser->getTag()] = $parser; @@ -87,7 +87,7 @@ public function getTokenParsers(): array public function addTest(TwigTest $test): void { if (isset($this->tests[$test->getName()])) { - throw new \LogicException(sprintf('Test "%s" is already registered.', $test->getName())); + throw new \LogicException(\sprintf('Test "%s" is already registered.', $test->getName())); } $this->tests[$test->getName()] = $test; diff --git a/src/Extension/StringLoaderExtension.php b/src/Extension/StringLoaderExtension.php index 7b451471007..698d181f177 100644 --- a/src/Extension/StringLoaderExtension.php +++ b/src/Extension/StringLoaderExtension.php @@ -9,7 +9,10 @@ * file that was distributed with this source code. */ -namespace Twig\Extension { +namespace Twig\Extension; + +use Twig\Environment; +use Twig\TemplateWrapper; use Twig\TwigFunction; final class StringLoaderExtension extends AbstractExtension @@ -17,26 +20,21 @@ final class StringLoaderExtension extends AbstractExtension public function getFunctions(): array { return [ - new TwigFunction('template_from_string', 'twig_template_from_string', ['needs_environment' => true]), + new TwigFunction('template_from_string', [self::class, 'templateFromString'], ['needs_environment' => true]), ]; } -} -} - -namespace { -use Twig\Environment; -use Twig\TemplateWrapper; -/** - * Loads a template from a string. - * - * {{ include(template_from_string("Hello {{ name }}")) }} - * - * @param string $template A template as a string or object implementing __toString() - * @param string $name An optional name of the template to be used in error messages - */ -function twig_template_from_string(Environment $env, $template, string $name = null): TemplateWrapper -{ - return $env->createTemplate((string) $template, $name); -} + /** + * Loads a template from a string. + * + * {{ include(template_from_string("Hello {{ name }}")) }} + * + * @param string|null $name An optional name of the template to be used in error messages + * + * @internal + */ + public static function templateFromString(Environment $env, string|\Stringable $template, ?string $name = null): TemplateWrapper + { + return $env->createTemplate((string) $template, $name); + } } diff --git a/src/Extension/YieldNotReadyExtension.php b/src/Extension/YieldNotReadyExtension.php new file mode 100644 index 00000000000..49dfb808569 --- /dev/null +++ b/src/Extension/YieldNotReadyExtension.php @@ -0,0 +1,30 @@ +useYield)]; + } +} diff --git a/src/ExtensionSet.php b/src/ExtensionSet.php index d32200ceb51..66467b0b498 100644 --- a/src/ExtensionSet.php +++ b/src/ExtensionSet.php @@ -12,11 +12,18 @@ namespace Twig; use Twig\Error\RuntimeError; +use Twig\ExpressionParser\ExpressionParsers; +use Twig\ExpressionParser\Infix\BinaryOperatorExpressionParser; +use Twig\ExpressionParser\InfixAssociativity; +use Twig\ExpressionParser\InfixExpressionParserInterface; +use Twig\ExpressionParser\PrecedenceChange; +use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser; +use Twig\Extension\AttributeExtension; use Twig\Extension\ExtensionInterface; use Twig\Extension\GlobalsInterface; +use Twig\Extension\LastModifiedExtensionInterface; use Twig\Extension\StagingExtension; -use Twig\Node\Expression\Binary\AbstractBinary; -use Twig\Node\Expression\Unary\AbstractUnary; +use Twig\Node\Expression\AbstractExpression; use Twig\NodeVisitor\NodeVisitorInterface; use Twig\TokenParser\TokenParserInterface; @@ -35,18 +42,26 @@ final class ExtensionSet private $visitors; /** @var array */ private $filters; + /** @var array */ + private $dynamicFilters; /** @var array */ private $tests; + /** @var array */ + private $dynamicTests; /** @var array */ private $functions; - /** @var array}> */ - private $unaryOperators; - /** @var array, associativity: ExpressionParser::OPERATOR_*}> */ - private $binaryOperators; - /** @var array */ + /** @var array */ + private $dynamicFunctions; + private ExpressionParsers $expressionParsers; + /** @var array|null */ private $globals; + /** @var array */ private $functionCallbacks = []; + /** @var array */ private $filterCallbacks = []; + /** @var array */ + private $testCallbacks = []; + /** @var array */ private $parserCallbacks = []; private $lastModified = 0; @@ -55,6 +70,9 @@ public function __construct() $this->staging = new StagingExtension(); } + /** + * @return void + */ public function initRuntime() { $this->runtimeInitialized = true; @@ -70,7 +88,7 @@ public function getExtension(string $class): ExtensionInterface $class = ltrim($class, '\\'); if (!isset($this->extensions[$class])) { - throw new RuntimeError(sprintf('The "%s" extension is not enabled.', $class)); + throw new RuntimeError(\sprintf('The "%s" extension is not enabled.', $class)); } return $this->extensions[$class]; @@ -110,26 +128,35 @@ public function getLastModified(): int return $this->lastModified; } + $lastModified = 0; foreach ($this->extensions as $extension) { - $r = new \ReflectionObject($extension); - if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $this->lastModified) { - $this->lastModified = $extensionTime; + if ($extension instanceof LastModifiedExtensionInterface) { + $lastModified = max($extension->getLastModified(), $lastModified); + } else { + $r = new \ReflectionObject($extension); + if (is_file($r->getFileName())) { + $lastModified = max(filemtime($r->getFileName()), $lastModified); + } } } - return $this->lastModified; + return $this->lastModified = $lastModified; } public function addExtension(ExtensionInterface $extension): void { - $class = \get_class($extension); + if ($extension instanceof AttributeExtension) { + $class = $extension->getClass(); + } else { + $class = $extension::class; + } if ($this->initialized) { - throw new \LogicException(sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class)); + throw new \LogicException(\sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class)); } if (isset($this->extensions[$class])) { - throw new \LogicException(sprintf('Unable to register extension "%s" as it is already registered.', $class)); + throw new \LogicException(\sprintf('Unable to register extension "%s" as it is already registered.', $class)); } $this->extensions[$class] = $extension; @@ -138,7 +165,7 @@ public function addExtension(ExtensionInterface $extension): void public function addFunction(TwigFunction $function): void { if ($this->initialized) { - throw new \LogicException(sprintf('Unable to add function "%s" as extensions have already been initialized.', $function->getName())); + throw new \LogicException(\sprintf('Unable to add function "%s" as extensions have already been initialized.', $function->getName())); } $this->staging->addFunction($function); @@ -166,14 +193,11 @@ public function getFunction(string $name): ?TwigFunction return $this->functions[$name]; } - foreach ($this->functions as $pattern => $function) { - $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count); - - if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) { + foreach ($this->dynamicFunctions as $pattern => $function) { + if (preg_match($pattern, $name, $matches)) { array_shift($matches); - $function->setArguments($matches); - return $function; + return $function->withDynamicArguments($name, $function->getName(), $matches); } } @@ -186,6 +210,9 @@ public function getFunction(string $name): ?TwigFunction return null; } + /** + * @param callable(string): (TwigFunction|false) $callable + */ public function registerUndefinedFunctionCallback(callable $callable): void { $this->functionCallbacks[] = $callable; @@ -194,7 +221,7 @@ public function registerUndefinedFunctionCallback(callable $callable): void public function addFilter(TwigFilter $filter): void { if ($this->initialized) { - throw new \LogicException(sprintf('Unable to add filter "%s" as extensions have already been initialized.', $filter->getName())); + throw new \LogicException(\sprintf('Unable to add filter "%s" as extensions have already been initialized.', $filter->getName())); } $this->staging->addFilter($filter); @@ -222,14 +249,11 @@ public function getFilter(string $name): ?TwigFilter return $this->filters[$name]; } - foreach ($this->filters as $pattern => $filter) { - $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count); - - if ($count && preg_match('#^'.$pattern.'$#', $name, $matches)) { + foreach ($this->dynamicFilters as $pattern => $filter) { + if (preg_match($pattern, $name, $matches)) { array_shift($matches); - $filter->setArguments($matches); - return $filter; + return $filter->withDynamicArguments($name, $filter->getName(), $matches); } } @@ -242,6 +266,9 @@ public function getFilter(string $name): ?TwigFilter return null; } + /** + * @param callable(string): (TwigFilter|false) $callable + */ public function registerUndefinedFilterCallback(callable $callable): void { $this->filterCallbacks[] = $callable; @@ -308,6 +335,9 @@ public function getTokenParser(string $name): ?TokenParserInterface return null; } + /** + * @param callable(string): (TokenParserInterface|false) $callable + */ public function registerUndefinedTokenParserCallback(callable $callable): void { $this->parserCallbacks[] = $callable; @@ -328,12 +358,7 @@ public function getGlobals(): array continue; } - $extGlobals = $extension->getGlobals(); - if (!\is_array($extGlobals)) { - throw new \UnexpectedValueException(sprintf('"%s::getGlobals()" must return an array of globals.', \get_class($extension))); - } - - $globals = array_merge($globals, $extGlobals); + $globals = array_merge($globals, $extension->getGlobals()); } if ($this->initialized) { @@ -343,10 +368,15 @@ public function getGlobals(): array return $globals; } + public function resetGlobals(): void + { + $this->globals = null; + } + public function addTest(TwigTest $test): void { if ($this->initialized) { - throw new \LogicException(sprintf('Unable to add test "%s" as extensions have already been initialized.', $test->getName())); + throw new \LogicException(\sprintf('Unable to add test "%s" as extensions have already been initialized.', $test->getName())); } $this->staging->addTest($test); @@ -374,16 +404,17 @@ public function getTest(string $name): ?TwigTest return $this->tests[$name]; } - foreach ($this->tests as $pattern => $test) { - $pattern = str_replace('\\*', '(.*?)', preg_quote($pattern, '#'), $count); + foreach ($this->dynamicTests as $pattern => $test) { + if (preg_match($pattern, $name, $matches)) { + array_shift($matches); - if ($count) { - if (preg_match('#^'.$pattern.'$#', $name, $matches)) { - array_shift($matches); - $test->setArguments($matches); + return $test->withDynamicArguments($name, $test->getName(), $matches); + } + } - return $test; - } + foreach ($this->testCallbacks as $callback) { + if (false !== $test = $callback($name)) { + return $test; } } @@ -391,27 +422,20 @@ public function getTest(string $name): ?TwigTest } /** - * @return array}> + * @param callable(string): (TwigTest|false) $callable */ - public function getUnaryOperators(): array + public function registerUndefinedTestCallback(callable $callable): void { - if (!$this->initialized) { - $this->initExtensions(); - } - - return $this->unaryOperators; + $this->testCallbacks[] = $callable; } - /** - * @return array, associativity: ExpressionParser::OPERATOR_*}> - */ - public function getBinaryOperators(): array + public function getExpressionParsers(): ExpressionParsers { if (!$this->initialized) { $this->initExtensions(); } - return $this->binaryOperators; + return $this->expressionParsers; } private function initExtensions(): void @@ -420,9 +444,11 @@ private function initExtensions(): void $this->filters = []; $this->functions = []; $this->tests = []; + $this->dynamicFilters = []; + $this->dynamicFunctions = []; + $this->dynamicTests = []; $this->visitors = []; - $this->unaryOperators = []; - $this->binaryOperators = []; + $this->expressionParsers = new ExpressionParsers(); foreach ($this->extensions as $extension) { $this->initExtension($extension); @@ -436,17 +462,26 @@ private function initExtension(ExtensionInterface $extension): void { // filters foreach ($extension->getFilters() as $filter) { - $this->filters[$filter->getName()] = $filter; + $this->filters[$name = $filter->getName()] = $filter; + if (str_contains($name, '*')) { + $this->dynamicFilters['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $filter; + } } // functions foreach ($extension->getFunctions() as $function) { - $this->functions[$function->getName()] = $function; + $this->functions[$name = $function->getName()] = $function; + if (str_contains($name, '*')) { + $this->dynamicFunctions['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $function; + } } // tests foreach ($extension->getTests() as $test) { - $this->tests[$test->getName()] = $test; + $this->tests[$name = $test->getName()] = $test; + if (str_contains($name, '*')) { + $this->dynamicTests['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $test; + } } // token parsers @@ -463,18 +498,66 @@ private function initExtension(ExtensionInterface $extension): void $this->visitors[] = $visitor; } - // operators - if ($operators = $extension->getOperators()) { - if (!\is_array($operators)) { - throw new \InvalidArgumentException(sprintf('"%s::getOperators()" must return an array with operators, got "%s".', \get_class($extension), \is_object($operators) ? \get_class($operators) : \gettype($operators).(\is_resource($operators) ? '' : '#'.$operators))); - } + // expression parsers + if (method_exists($extension, 'getExpressionParsers')) { + $this->expressionParsers->add($extension->getExpressionParsers()); + } + + $operators = $extension->getOperators(); + if (!\is_array($operators)) { + throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array with operators, got "%s".', $extension::class, get_debug_type($operators).(\is_resource($operators) ? '' : '#'.$operators))); + } - if (2 !== \count($operators)) { - throw new \InvalidArgumentException(sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', \get_class($extension), \count($operators))); + if (2 !== \count($operators)) { + throw new \InvalidArgumentException(\sprintf('"%s::getOperators()" must return an array of 2 elements, got %d.', $extension::class, \count($operators))); + } + + $expressionParsers = []; + foreach ($operators[0] as $operator => $op) { + $expressionParsers[] = new UnaryOperatorExpressionParser($op['class'], $operator, $op['precedence'], $op['precedence_change'] ?? null, '', $op['aliases'] ?? []); + } + foreach ($operators[1] as $operator => $op) { + $op['associativity'] = match ($op['associativity']) { + 1 => InfixAssociativity::Left, + 2 => InfixAssociativity::Right, + default => throw new \InvalidArgumentException(\sprintf('Invalid associativity "%s" for operator "%s".', $op['associativity'], $operator)), + }; + + if (isset($op['callable'])) { + $expressionParsers[] = $this->convertInfixExpressionParser($op['class'], $operator, $op['precedence'], $op['associativity'], $op['precedence_change'] ?? null, $op['aliases'] ?? [], $op['callable']); + } else { + $expressionParsers[] = new BinaryOperatorExpressionParser($op['class'], $operator, $op['precedence'], $op['associativity'], $op['precedence_change'] ?? null, '', $op['aliases'] ?? []); } + } + + if (\count($expressionParsers)) { + trigger_deprecation('twig/twig', '3.21', \sprintf('Extension "%s" uses the old signature for "getOperators()", please implement "getExpressionParsers()" instead.', $extension::class)); - $this->unaryOperators = array_merge($this->unaryOperators, $operators[0]); - $this->binaryOperators = array_merge($this->binaryOperators, $operators[1]); + $this->expressionParsers->add($expressionParsers); } } + + private function convertInfixExpressionParser(string $nodeClass, string $operator, int $precedence, InfixAssociativity $associativity, ?PrecedenceChange $precedenceChange, array $aliases, callable $callable): InfixExpressionParserInterface + { + trigger_deprecation('twig/twig', '3.21', \sprintf('Using a non-ExpressionParserInterface object to define the "%s" binary operator is deprecated.', $operator)); + + return new class($nodeClass, $operator, $precedence, $associativity, $precedenceChange, $aliases, $callable) extends BinaryOperatorExpressionParser { + public function __construct( + string $nodeClass, + string $operator, + int $precedence, + InfixAssociativity $associativity = InfixAssociativity::Left, + ?PrecedenceChange $precedenceChange = null, + array $aliases = [], + private $callable = null, + ) { + parent::__construct($nodeClass, $operator, $precedence, $associativity, $precedenceChange, $aliases); + } + + public function parse(Parser $parser, AbstractExpression $expr, Token $token): AbstractExpression + { + return ($this->callable)($parser, $expr); + } + }; + } } diff --git a/src/FileExtensionEscapingStrategy.php b/src/FileExtensionEscapingStrategy.php index 65198bbb649..2785ab7f497 100644 --- a/src/FileExtensionEscapingStrategy.php +++ b/src/FileExtensionEscapingStrategy.php @@ -33,11 +33,11 @@ class FileExtensionEscapingStrategy */ public static function guess(string $name) { - if (\in_array(substr($name, -1), ['/', '\\'])) { + if (\in_array(substr($name, -1), ['/', '\\'], true)) { return 'html'; // return html for directories } - if ('.twig' === substr($name, -5)) { + if (str_ends_with($name, '.twig')) { $name = substr($name, 0, -5); } @@ -45,6 +45,7 @@ public static function guess(string $name) switch ($extension) { case 'js': + case 'json': return 'js'; case 'css': diff --git a/src/Lexer.php b/src/Lexer.php index 9ff028c87d9..027771accb9 100644 --- a/src/Lexer.php +++ b/src/Lexer.php @@ -19,6 +19,8 @@ */ class Lexer { + private $isInitialized = false; + private $tokens; private $code; private $cursor; @@ -34,6 +36,8 @@ class Lexer private $position; private $positions; private $currentVarBlockLine; + private array $openingBrackets = ['{', '(', '[']; + private array $closingBrackets = ['}', ')', ']']; public const STATE_DATA = 0; public const STATE_BLOCK = 1; @@ -42,12 +46,29 @@ class Lexer public const STATE_INTERPOLATION = 4; public const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A'; - public const REGEX_NUMBER = '/[0-9]+(?:\.[0-9]+)?([Ee][\+\-][0-9]+)?/A'; public const REGEX_STRING = '/"([^#"\\\\]*(?:\\\\.[^#"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\'/As'; + + public const REGEX_NUMBER = '/(?(DEFINE) + (?[0-9]+(_[0-9]+)*) # Integers (with underscores) 123_456 + (?\.(?&LNUM)) # Fractional part .456 + (?[eE][+-]?(?&LNUM)) # Exponent part E+10 + (?(?&LNUM)(?:(?&FRAC))?) # Decimal number 123_456.456 + )(?:(?&DNUM)(?:(?&EXPONENT))?) # 123_456.456E+10 + /Ax'; + public const REGEX_DQ_STRING_DELIM = '/"/A'; public const REGEX_DQ_STRING_PART = '/[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*/As'; + public const REGEX_INLINE_COMMENT = '/#[^\n]*/A'; public const PUNCTUATION = '()[]{}?:.,|'; + private const SPECIAL_CHARS = [ + 'f' => "\f", + 'n' => "\n", + 'r' => "\r", + 't' => "\t", + 'v' => "\v", + ]; + public function __construct(Environment $env, array $options = []) { $this->env = $env; @@ -61,6 +82,13 @@ public function __construct(Environment $env, array $options = []) 'whitespace_line_chars' => ' \t\0\x0B', 'interpolation' => ['#{', '}'], ], $options); + } + + private function initialize(): void + { + if ($this->isInitialized) { + return; + } // when PHP 7.3 is the min version, we will be able to remove the '#' part in preg_quote as it's part of the default $this->regexes = [ @@ -149,10 +177,14 @@ public function __construct(Environment $env, array $options = []) 'interpolation_start' => '{'.preg_quote($this->options['interpolation'][0], '#').'\s*}A', 'interpolation_end' => '{\s*'.preg_quote($this->options['interpolation'][1], '#').'}A', ]; + + $this->isInitialized = true; } public function tokenize(Source $source): TokenStream { + $this->initialize(); + $this->source = $source; $this->code = str_replace(["\r\n", "\r"], "\n", $source->getCode()); $this->cursor = 0; @@ -194,11 +226,11 @@ public function tokenize(Source $source): TokenStream } } - $this->pushToken(/* Token::EOF_TYPE */ -1); + $this->pushToken(Token::EOF_TYPE); - if (!empty($this->brackets)) { - list($expect, $lineno) = array_pop($this->brackets); - throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $lineno, $this->source); + if ($this->brackets) { + [$expect, $lineno] = array_pop($this->brackets); + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source); } return new TokenStream($this->tokens, $this->source); @@ -208,7 +240,7 @@ private function lexData(): void { // if no matches are left we return the rest of the template as simple text token if ($this->position == \count($this->positions[0]) - 1) { - $this->pushToken(/* Token::TEXT_TYPE */ 0, substr($this->code, $this->cursor)); + $this->pushToken(Token::TEXT_TYPE, substr($this->code, $this->cursor)); $this->cursor = $this->end; return; @@ -237,7 +269,7 @@ private function lexData(): void $text = rtrim($text, " \t\0\x0B"); } } - $this->pushToken(/* Token::TEXT_TYPE */ 0, $text); + $this->pushToken(Token::TEXT_TYPE, $text); $this->moveCursor($textContent.$position[0]); switch ($this->positions[1][$this->position][0]) { @@ -255,14 +287,14 @@ private function lexData(): void $this->moveCursor($match[0]); $this->lineno = (int) $match[1]; } else { - $this->pushToken(/* Token::BLOCK_START_TYPE */ 1); + $this->pushToken(Token::BLOCK_START_TYPE); $this->pushState(self::STATE_BLOCK); $this->currentVarBlockLine = $this->lineno; } break; case $this->options['tag_variable'][0]: - $this->pushToken(/* Token::VAR_START_TYPE */ 2); + $this->pushToken(Token::VAR_START_TYPE); $this->pushState(self::STATE_VAR); $this->currentVarBlockLine = $this->lineno; break; @@ -271,8 +303,8 @@ private function lexData(): void private function lexBlock(): void { - if (empty($this->brackets) && preg_match($this->regexes['lex_block'], $this->code, $match, 0, $this->cursor)) { - $this->pushToken(/* Token::BLOCK_END_TYPE */ 3); + if (!$this->brackets && preg_match($this->regexes['lex_block'], $this->code, $match, 0, $this->cursor)) { + $this->pushToken(Token::BLOCK_END_TYPE); $this->moveCursor($match[0]); $this->popState(); } else { @@ -282,8 +314,8 @@ private function lexBlock(): void private function lexVar(): void { - if (empty($this->brackets) && preg_match($this->regexes['lex_var'], $this->code, $match, 0, $this->cursor)) { - $this->pushToken(/* Token::VAR_END_TYPE */ 4); + if (!$this->brackets && preg_match($this->regexes['lex_var'], $this->code, $match, 0, $this->cursor)) { + $this->pushToken(Token::VAR_END_TYPE); $this->moveCursor($match[0]); $this->popState(); } else { @@ -298,58 +330,38 @@ private function lexExpression(): void $this->moveCursor($match[0]); if ($this->cursor >= $this->end) { - throw new SyntaxError(sprintf('Unclosed "%s".', self::STATE_BLOCK === $this->state ? 'block' : 'variable'), $this->currentVarBlockLine, $this->source); + throw new SyntaxError(\sprintf('Unclosed "%s".', self::STATE_BLOCK === $this->state ? 'block' : 'variable'), $this->currentVarBlockLine, $this->source); } } - // arrow function - if ('=' === $this->code[$this->cursor] && '>' === $this->code[$this->cursor + 1]) { - $this->pushToken(Token::ARROW_TYPE, '=>'); - $this->moveCursor('=>'); - } // operators - elseif (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) { - $this->pushToken(/* Token::OPERATOR_TYPE */ 8, preg_replace('/\s+/', ' ', $match[0])); + if (preg_match($this->regexes['operator'], $this->code, $match, 0, $this->cursor)) { + $operator = preg_replace('/\s+/', ' ', $match[0]); + if (\in_array($operator, $this->openingBrackets, true)) { + $this->checkBrackets($operator); + } + $this->pushToken(Token::OPERATOR_TYPE, $operator); $this->moveCursor($match[0]); } // names elseif (preg_match(self::REGEX_NAME, $this->code, $match, 0, $this->cursor)) { - $this->pushToken(/* Token::NAME_TYPE */ 5, $match[0]); + $this->pushToken(Token::NAME_TYPE, $match[0]); $this->moveCursor($match[0]); } // numbers elseif (preg_match(self::REGEX_NUMBER, $this->code, $match, 0, $this->cursor)) { - $number = (float) $match[0]; // floats - if (ctype_digit($match[0]) && $number <= \PHP_INT_MAX) { - $number = (int) $match[0]; // integers lower than the maximum - } - $this->pushToken(/* Token::NUMBER_TYPE */ 6, $number); + $this->pushToken(Token::NUMBER_TYPE, 0 + str_replace('_', '', $match[0])); $this->moveCursor($match[0]); } // punctuation - elseif (false !== strpos(self::PUNCTUATION, $this->code[$this->cursor])) { - // opening bracket - if (false !== strpos('([{', $this->code[$this->cursor])) { - $this->brackets[] = [$this->code[$this->cursor], $this->lineno]; - } - // closing bracket - elseif (false !== strpos(')]}', $this->code[$this->cursor])) { - if (empty($this->brackets)) { - throw new SyntaxError(sprintf('Unexpected "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); - } - - list($expect, $lineno) = array_pop($this->brackets); - if ($this->code[$this->cursor] != strtr($expect, '([{', ')]}')) { - throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $lineno, $this->source); - } - } - - $this->pushToken(/* Token::PUNCTUATION_TYPE */ 9, $this->code[$this->cursor]); + elseif (str_contains(self::PUNCTUATION, $this->code[$this->cursor])) { + $this->checkBrackets($this->code[$this->cursor]); + $this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]); ++$this->cursor; } // strings elseif (preg_match(self::REGEX_STRING, $this->code, $match, 0, $this->cursor)) { - $this->pushToken(/* Token::STRING_TYPE */ 7, stripcslashes(substr($match[0], 1, -1))); + $this->pushToken(Token::STRING_TYPE, $this->stripcslashes(substr($match[0], 1, -1), substr($match[0], 0, 1))); $this->moveCursor($match[0]); } // opening double quoted string @@ -358,10 +370,71 @@ private function lexExpression(): void $this->pushState(self::STATE_STRING); $this->moveCursor($match[0]); } + // inline comment + elseif (preg_match(self::REGEX_INLINE_COMMENT, $this->code, $match, 0, $this->cursor)) { + $this->moveCursor($match[0]); + } // unlexable else { - throw new SyntaxError(sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); + throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); + } + } + + private function stripcslashes(string $str, string $quoteType): string + { + $result = ''; + $length = \strlen($str); + + $i = 0; + while ($i < $length) { + if (false === $pos = strpos($str, '\\', $i)) { + $result .= substr($str, $i); + break; + } + + $result .= substr($str, $i, $pos - $i); + $i = $pos + 1; + + if ($i >= $length) { + $result .= '\\'; + break; + } + + $nextChar = $str[$i]; + + if (isset(self::SPECIAL_CHARS[$nextChar])) { + $result .= self::SPECIAL_CHARS[$nextChar]; + } elseif ('\\' === $nextChar) { + $result .= $nextChar; + } elseif ("'" === $nextChar || '"' === $nextChar) { + if ($nextChar !== $quoteType) { + trigger_deprecation('twig/twig', '3.12', 'Character "%s" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position %d in "%s" at line %d.', $nextChar, $i + 1, $this->source->getName(), $this->lineno); + } + $result .= $nextChar; + } elseif ('#' === $nextChar && $i + 1 < $length && '{' === $str[$i + 1]) { + $result .= '#{'; + ++$i; + } elseif ('x' === $nextChar && $i + 1 < $length && ctype_xdigit($str[$i + 1])) { + $hex = $str[++$i]; + if ($i + 1 < $length && ctype_xdigit($str[$i + 1])) { + $hex .= $str[++$i]; + } + $result .= \chr(hexdec($hex)); + } elseif (ctype_digit($nextChar) && $nextChar < '8') { + $octal = $nextChar; + while ($i + 1 < $length && ctype_digit($str[$i + 1]) && $str[$i + 1] < '8' && \strlen($octal) < 3) { + $octal .= $str[++$i]; + } + $result .= \chr(octdec($octal)); + } else { + trigger_deprecation('twig/twig', '3.12', 'Character "%s" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position %d in "%s" at line %d.', $nextChar, $i + 1, $this->source->getName(), $this->lineno); + $result .= $nextChar; + } + + ++$i; } + + return $result; } private function lexRawData(): void @@ -385,7 +458,7 @@ private function lexRawData(): void } } - $this->pushToken(/* Token::TEXT_TYPE */ 0, $text); + $this->pushToken(Token::TEXT_TYPE, $text); } private function lexComment(): void @@ -401,23 +474,23 @@ private function lexString(): void { if (preg_match($this->regexes['interpolation_start'], $this->code, $match, 0, $this->cursor)) { $this->brackets[] = [$this->options['interpolation'][0], $this->lineno]; - $this->pushToken(/* Token::INTERPOLATION_START_TYPE */ 10); + $this->pushToken(Token::INTERPOLATION_START_TYPE); $this->moveCursor($match[0]); $this->pushState(self::STATE_INTERPOLATION); - } elseif (preg_match(self::REGEX_DQ_STRING_PART, $this->code, $match, 0, $this->cursor) && \strlen($match[0]) > 0) { - $this->pushToken(/* Token::STRING_TYPE */ 7, stripcslashes($match[0])); + } elseif (preg_match(self::REGEX_DQ_STRING_PART, $this->code, $match, 0, $this->cursor) && '' !== $match[0]) { + $this->pushToken(Token::STRING_TYPE, $this->stripcslashes($match[0], '"')); $this->moveCursor($match[0]); } elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, 0, $this->cursor)) { - list($expect, $lineno) = array_pop($this->brackets); + [$expect, $lineno] = array_pop($this->brackets); if ('"' != $this->code[$this->cursor]) { - throw new SyntaxError(sprintf('Unclosed "%s".', $expect), $lineno, $this->source); + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source); } $this->popState(); ++$this->cursor; } else { // unlexable - throw new SyntaxError(sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); + throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source); } } @@ -426,7 +499,7 @@ private function lexInterpolation(): void $bracket = end($this->brackets); if ($this->options['interpolation'][0] === $bracket[0] && preg_match($this->regexes['interpolation_end'], $this->code, $match, 0, $this->cursor)) { array_pop($this->brackets); - $this->pushToken(/* Token::INTERPOLATION_END_TYPE */ 11); + $this->pushToken(Token::INTERPOLATION_END_TYPE); $this->moveCursor($match[0]); $this->popState(); } else { @@ -437,7 +510,7 @@ private function lexInterpolation(): void private function pushToken($type, $value = ''): void { // do not push empty text tokens - if (/* Token::TEXT_TYPE */ 0 === $type && '' === $value) { + if (Token::TEXT_TYPE === $type && '' === $value) { return; } @@ -452,26 +525,25 @@ private function moveCursor($text): void private function getOperatorRegex(): string { - $operators = array_merge( - ['='], - array_keys($this->env->getUnaryOperators()), - array_keys($this->env->getBinaryOperators()) - ); + $expressionParsers = ['=']; + foreach ($this->env->getExpressionParsers() as $expressionParser) { + $expressionParsers = array_merge($expressionParsers, [$expressionParser->getName()], $expressionParser->getAliases()); + } - $operators = array_combine($operators, array_map('strlen', $operators)); - arsort($operators); + $expressionParsers = array_combine($expressionParsers, array_map('strlen', $expressionParsers)); + arsort($expressionParsers); $regex = []; - foreach ($operators as $operator => $length) { + foreach ($expressionParsers as $expressionParser => $length) { // an operator that ends with a character must be followed by // a whitespace, a parenthesis, an opening map [ or sequence { - $r = preg_quote($operator, '/'); - if (ctype_alpha($operator[$length - 1])) { + $r = preg_quote($expressionParser, '/'); + if (ctype_alpha($expressionParser[$length - 1])) { $r .= '(?=[\s()\[{])'; } // an operator that begins with a character must not have a dot or pipe before - if (ctype_alpha($operator[0])) { + if (ctype_alpha($expressionParser[0])) { $r = '(?state = array_pop($this->states); } + + private function checkBrackets(string $code): void + { + // opening bracket + if (\in_array($code, $this->openingBrackets, true)) { + $this->brackets[] = [$code, $this->lineno]; + } elseif (\in_array($code, $this->closingBrackets, true)) { + // closing bracket + if (!$this->brackets) { + throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source); + } + + [$expect, $lineno] = array_pop($this->brackets); + if ($code !== str_replace($this->openingBrackets, $this->closingBrackets, $expect)) { + throw new SyntaxError(\sprintf('Unclosed "%s".', $expect), $lineno, $this->source); + } + } + } } diff --git a/src/Loader/ArrayLoader.php b/src/Loader/ArrayLoader.php index 5d726c35ac7..2bb54b7a8df 100644 --- a/src/Loader/ArrayLoader.php +++ b/src/Loader/ArrayLoader.php @@ -28,14 +28,12 @@ */ final class ArrayLoader implements LoaderInterface { - private $templates = []; - /** * @param array $templates An array of templates (keys are the names, and values are the source code) */ - public function __construct(array $templates = []) - { - $this->templates = $templates; + public function __construct( + private array $templates = [], + ) { } public function setTemplate(string $name, string $template): void @@ -46,7 +44,7 @@ public function setTemplate(string $name, string $template): void public function getSourceContext(string $name): Source { if (!isset($this->templates[$name])) { - throw new LoaderError(sprintf('Template "%s" is not defined.', $name)); + throw new LoaderError(\sprintf('Template "%s" is not defined.', $name)); } return new Source($this->templates[$name], $name); @@ -60,7 +58,7 @@ public function exists(string $name): bool public function getCacheKey(string $name): string { if (!isset($this->templates[$name])) { - throw new LoaderError(sprintf('Template "%s" is not defined.', $name)); + throw new LoaderError(\sprintf('Template "%s" is not defined.', $name)); } return $name.':'.$this->templates[$name]; @@ -69,7 +67,7 @@ public function getCacheKey(string $name): string public function isFresh(string $name, int $time): bool { if (!isset($this->templates[$name])) { - throw new LoaderError(sprintf('Template "%s" is not defined.', $name)); + throw new LoaderError(\sprintf('Template "%s" is not defined.', $name)); } return true; diff --git a/src/Loader/ChainLoader.php b/src/Loader/ChainLoader.php index fbf4f3a0654..0859dcd2f76 100644 --- a/src/Loader/ChainLoader.php +++ b/src/Loader/ChainLoader.php @@ -21,22 +21,28 @@ */ final class ChainLoader implements LoaderInterface { + /** + * @var array + */ private $hasSourceCache = []; - private $loaders = []; /** - * @param LoaderInterface[] $loaders + * @param iterable $loaders */ - public function __construct(array $loaders = []) - { - foreach ($loaders as $loader) { - $this->addLoader($loader); - } + public function __construct( + private iterable $loaders = [], + ) { } public function addLoader(LoaderInterface $loader): void { - $this->loaders[] = $loader; + $current = $this->loaders; + + $this->loaders = (static function () use ($current, $loader): \Generator { + yield from $current; + yield $loader; + })(); + $this->hasSourceCache = []; } @@ -45,13 +51,18 @@ public function addLoader(LoaderInterface $loader): void */ public function getLoaders(): array { + if (!\is_array($this->loaders)) { + $this->loaders = iterator_to_array($this->loaders, false); + } + return $this->loaders; } public function getSourceContext(string $name): Source { $exceptions = []; - foreach ($this->loaders as $loader) { + + foreach ($this->getLoaders() as $loader) { if (!$loader->exists($name)) { continue; } @@ -63,7 +74,7 @@ public function getSourceContext(string $name): Source } } - throw new LoaderError(sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); + throw new LoaderError(\sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); } public function exists(string $name): bool @@ -72,7 +83,7 @@ public function exists(string $name): bool return $this->hasSourceCache[$name]; } - foreach ($this->loaders as $loader) { + foreach ($this->getLoaders() as $loader) { if ($loader->exists($name)) { return $this->hasSourceCache[$name] = true; } @@ -84,7 +95,8 @@ public function exists(string $name): bool public function getCacheKey(string $name): string { $exceptions = []; - foreach ($this->loaders as $loader) { + + foreach ($this->getLoaders() as $loader) { if (!$loader->exists($name)) { continue; } @@ -92,17 +104,18 @@ public function getCacheKey(string $name): string try { return $loader->getCacheKey($name); } catch (LoaderError $e) { - $exceptions[] = \get_class($loader).': '.$e->getMessage(); + $exceptions[] = $loader::class.': '.$e->getMessage(); } } - throw new LoaderError(sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); + throw new LoaderError(\sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); } public function isFresh(string $name, int $time): bool { $exceptions = []; - foreach ($this->loaders as $loader) { + + foreach ($this->getLoaders() as $loader) { if (!$loader->exists($name)) { continue; } @@ -110,10 +123,10 @@ public function isFresh(string $name, int $time): bool try { return $loader->isFresh($name, $time); } catch (LoaderError $e) { - $exceptions[] = \get_class($loader).': '.$e->getMessage(); + $exceptions[] = $loader::class.': '.$e->getMessage(); } } - throw new LoaderError(sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); + throw new LoaderError(\sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); } } diff --git a/src/Loader/FilesystemLoader.php b/src/Loader/FilesystemLoader.php index 62267a11c89..49f2b891556 100644 --- a/src/Loader/FilesystemLoader.php +++ b/src/Loader/FilesystemLoader.php @@ -24,6 +24,9 @@ class FilesystemLoader implements LoaderInterface /** Identifier of the main namespace. */ public const MAIN_NAMESPACE = '__main__'; + /** + * @var array> + */ protected $paths = []; protected $cache = []; protected $errorCache = []; @@ -31,12 +34,12 @@ class FilesystemLoader implements LoaderInterface private $rootPath; /** - * @param string|array $paths A path or an array of paths where to look for templates - * @param string|null $rootPath The root path common to all relative paths (null for getcwd()) + * @param string|string[] $paths A path or an array of paths where to look for templates + * @param string|null $rootPath The root path common to all relative paths (null for getcwd()) */ - public function __construct($paths = [], string $rootPath = null) + public function __construct($paths = [], ?string $rootPath = null) { - $this->rootPath = (null === $rootPath ? getcwd() : $rootPath).\DIRECTORY_SEPARATOR; + $this->rootPath = ($rootPath ?? getcwd()).\DIRECTORY_SEPARATOR; if (null !== $rootPath && false !== ($realPath = realpath($rootPath))) { $this->rootPath = $realPath.\DIRECTORY_SEPARATOR; } @@ -48,6 +51,8 @@ public function __construct($paths = [], string $rootPath = null) /** * Returns the paths to the templates. + * + * @return list */ public function getPaths(string $namespace = self::MAIN_NAMESPACE): array { @@ -58,6 +63,8 @@ public function getPaths(string $namespace = self::MAIN_NAMESPACE): array * Returns the path namespaces. * * The main namespace is always defined. + * + * @return list */ public function getNamespaces(): array { @@ -65,7 +72,7 @@ public function getNamespaces(): array } /** - * @param string|array $paths A path or an array of paths where to look for templates + * @param string|string[] $paths A path or an array of paths where to look for templates */ public function setPaths($paths, string $namespace = self::MAIN_NAMESPACE): void { @@ -89,7 +96,7 @@ public function addPath(string $path, string $namespace = self::MAIN_NAMESPACE): $checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path; if (!is_dir($checkPath)) { - throw new LoaderError(sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath)); + throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath)); } $this->paths[$namespace][] = rtrim($path, '/\\'); @@ -105,7 +112,7 @@ public function prependPath(string $path, string $namespace = self::MAIN_NAMESPA $checkPath = $this->isAbsolutePath($path) ? $path : $this->rootPath.$path; if (!is_dir($checkPath)) { - throw new LoaderError(sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath)); + throw new LoaderError(\sprintf('The "%s" directory does not exist ("%s").', $path, $checkPath)); } $path = rtrim($path, '/\\'); @@ -183,7 +190,7 @@ protected function findTemplate(string $name, bool $throw = true) } try { - list($namespace, $shortname) = $this->parseName($name); + [$namespace, $shortname] = $this->parseName($name); $this->validateName($shortname); } catch (LoaderError $e) { @@ -195,7 +202,7 @@ protected function findTemplate(string $name, bool $throw = true) } if (!isset($this->paths[$namespace])) { - $this->errorCache[$name] = sprintf('There are no registered paths for namespace "%s".', $namespace); + $this->errorCache[$name] = \sprintf('There are no registered paths for namespace "%s".', $namespace); if (!$throw) { return null; @@ -218,7 +225,7 @@ protected function findTemplate(string $name, bool $throw = true) } } - $this->errorCache[$name] = sprintf('Unable to find template "%s" (looked into: %s).', $name, implode(', ', $this->paths[$namespace])); + $this->errorCache[$name] = \sprintf('Unable to find template "%s" (looked into: %s).', $name, implode(', ', $this->paths[$namespace])); if (!$throw) { return null; @@ -236,7 +243,7 @@ private function parseName(string $name, string $default = self::MAIN_NAMESPACE) { if (isset($name[0]) && '@' == $name[0]) { if (false === $pos = strpos($name, '/')) { - throw new LoaderError(sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name)); + throw new LoaderError(\sprintf('Malformed namespaced template name "%s" (expecting "@namespace/template_name").', $name)); } $namespace = substr($name, 1, $pos - 1); @@ -250,7 +257,7 @@ private function parseName(string $name, string $default = self::MAIN_NAMESPACE) private function validateName(string $name): void { - if (false !== strpos($name, "\0")) { + if (str_contains($name, "\0")) { throw new LoaderError('A template name cannot contain NUL bytes.'); } @@ -265,7 +272,7 @@ private function validateName(string $name): void } if ($level < 0) { - throw new LoaderError(sprintf('Looks like you try to load a template outside configured directories (%s).', $name)); + throw new LoaderError(\sprintf('Looks like you try to load a template outside configured directories (%s).', $name)); } } } diff --git a/src/Markup.php b/src/Markup.php index 1788acc4f73..a933b69d327 100644 --- a/src/Markup.php +++ b/src/Markup.php @@ -16,10 +16,10 @@ * * @author Fabien Potencier */ -class Markup implements \Countable, \JsonSerializable +class Markup implements \Countable, \JsonSerializable, \Stringable { private $content; - private $charset; + private ?string $charset; public function __construct($content, $charset) { @@ -27,11 +27,16 @@ public function __construct($content, $charset) $this->charset = $charset; } - public function __toString() + public function __toString(): string { return $this->content; } + public function getCharset(): string + { + return $this->charset; + } + /** * @return int */ diff --git a/src/Node/AutoEscapeNode.php b/src/Node/AutoEscapeNode.php index cd970411b8d..ee806396ece 100644 --- a/src/Node/AutoEscapeNode.php +++ b/src/Node/AutoEscapeNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -24,11 +25,12 @@ * * @author Fabien Potencier */ +#[YieldReady] class AutoEscapeNode extends Node { - public function __construct($value, Node $body, int $lineno, string $tag = 'autoescape') + public function __construct($value, Node $body, int $lineno) { - parent::__construct(['body' => $body], ['value' => $value], $lineno, $tag); + parent::__construct(['body' => $body], ['value' => $value], $lineno); } public function compile(Compiler $compiler): void diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index 0632ba74754..b4f939cf630 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -19,24 +20,29 @@ * * @author Fabien Potencier */ +#[YieldReady] class BlockNode extends Node { - public function __construct(string $name, Node $body, int $lineno, string $tag = null) + public function __construct(string $name, Node $body, int $lineno) { - parent::__construct(['body' => $body], ['name' => $name], $lineno, $tag); + parent::__construct(['body' => $body], ['name' => $name], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) - ->write(sprintf("public function block_%s(\$context, array \$blocks = [])\n", $this->getAttribute('name')), "{\n") + ->write("/**\n") + ->write(" * @return iterable\n") + ->write(" */\n") + ->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n") ->indent() ->write("\$macros = \$this->macros;\n") ; $compiler ->subcompile($this->getNode('body')) + ->write("yield from [];\n") ->outdent() ->write("}\n\n") ; diff --git a/src/Node/BlockReferenceNode.php b/src/Node/BlockReferenceNode.php index cc8af5b5253..7c313a04cec 100644 --- a/src/Node/BlockReferenceNode.php +++ b/src/Node/BlockReferenceNode.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -19,18 +20,19 @@ * * @author Fabien Potencier */ +#[YieldReady] class BlockReferenceNode extends Node implements NodeOutputInterface { - public function __construct(string $name, int $lineno, string $tag = null) + public function __construct(string $name, int $lineno) { - parent::__construct([], ['name' => $name], $lineno, $tag); + parent::__construct([], ['name' => $name], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) - ->write(sprintf("\$this->displayBlock('%s', \$context, \$blocks);\n", $this->getAttribute('name'))) + ->write(\sprintf("yield from \$this->unwrap()->yieldBlock('%s', \$context, \$blocks);\n", $this->getAttribute('name'))) ; } } diff --git a/src/Node/BodyNode.php b/src/Node/BodyNode.php index 041cbf685b1..08115b3bd00 100644 --- a/src/Node/BodyNode.php +++ b/src/Node/BodyNode.php @@ -11,11 +11,14 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; + /** * Represents a body node. * * @author Fabien Potencier */ +#[YieldReady] class BodyNode extends Node { } diff --git a/src/Node/CaptureNode.php b/src/Node/CaptureNode.php new file mode 100644 index 00000000000..3b7f0b6d838 --- /dev/null +++ b/src/Node/CaptureNode.php @@ -0,0 +1,57 @@ + + */ +#[YieldReady] +class CaptureNode extends Node +{ + public function __construct(Node $body, int $lineno) + { + parent::__construct(['body' => $body], ['raw' => false], $lineno); + } + + public function compile(Compiler $compiler): void + { + $useYield = $compiler->getEnvironment()->useYield(); + + if (!$this->getAttribute('raw')) { + $compiler->raw("('' === \$tmp = "); + } + $compiler + ->raw($useYield ? "implode('', iterator_to_array(" : '\\Twig\\Extension\\CoreExtension::captureOutput(') + ->raw("(function () use (&\$context, \$macros, \$blocks) {\n") + ->indent() + ->subcompile($this->getNode('body')) + ->write("yield from [];\n") + ->outdent() + ->write('})()') + ; + if ($useYield) { + $compiler->raw(', false))'); + } else { + $compiler->raw(')'); + } + if (!$this->getAttribute('raw')) { + $compiler->raw(") ? '' : new Markup(\$tmp, \$this->env->getCharset());"); + } else { + $compiler->raw(';'); + } + } +} diff --git a/src/Node/CheckSecurityCallNode.php b/src/Node/CheckSecurityCallNode.php index a78a38d80bb..bb8783bc3d9 100644 --- a/src/Node/CheckSecurityCallNode.php +++ b/src/Node/CheckSecurityCallNode.php @@ -11,17 +11,22 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** * @author Fabien Potencier */ +#[YieldReady] class CheckSecurityCallNode extends Node { + /** + * @return void + */ public function compile(Compiler $compiler) { $compiler - ->write("\$this->sandbox = \$this->env->getExtension('\Twig\Extension\SandboxExtension');\n") + ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n") ->write("\$this->checkSecurity();\n") ; } diff --git a/src/Node/CheckSecurityNode.php b/src/Node/CheckSecurityNode.php index 472732796ed..6e591aad40a 100644 --- a/src/Node/CheckSecurityNode.php +++ b/src/Node/CheckSecurityNode.php @@ -11,17 +11,24 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** * @author Fabien Potencier */ +#[YieldReady] class CheckSecurityNode extends Node { private $usedFilters; private $usedTags; private $usedFunctions; + /** + * @param array $usedFilters + * @param array $usedTags + * @param array $usedFunctions + */ public function __construct(array $usedFilters, array $usedTags, array $usedFunctions) { $this->usedFilters = $usedFilters; @@ -33,32 +40,22 @@ public function __construct(array $usedFilters, array $usedTags, array $usedFunc public function compile(Compiler $compiler): void { - $tags = $filters = $functions = []; - foreach (['tags', 'filters', 'functions'] as $type) { - foreach ($this->{'used'.ucfirst($type)} as $name => $node) { - if ($node instanceof Node) { - ${$type}[$name] = $node->getTemplateLine(); - } else { - ${$type}[$node] = null; - } - } - } - $compiler ->write("\n") ->write("public function checkSecurity()\n") ->write("{\n") ->indent() - ->write('static $tags = ')->repr(array_filter($tags))->raw(";\n") - ->write('static $filters = ')->repr(array_filter($filters))->raw(";\n") - ->write('static $functions = ')->repr(array_filter($functions))->raw(";\n\n") + ->write('static $tags = ')->repr(array_filter($this->usedTags))->raw(";\n") + ->write('static $filters = ')->repr(array_filter($this->usedFilters))->raw(";\n") + ->write('static $functions = ')->repr(array_filter($this->usedFunctions))->raw(";\n\n") ->write("try {\n") ->indent() ->write("\$this->sandbox->checkSecurity(\n") ->indent() - ->write(!$tags ? "[],\n" : "['".implode("', '", array_keys($tags))."'],\n") - ->write(!$filters ? "[],\n" : "['".implode("', '", array_keys($filters))."'],\n") - ->write(!$functions ? "[]\n" : "['".implode("', '", array_keys($functions))."']\n") + ->write(!$this->usedTags ? "[],\n" : "['".implode("', '", array_keys($this->usedTags))."'],\n") + ->write(!$this->usedFilters ? "[],\n" : "['".implode("', '", array_keys($this->usedFilters))."'],\n") + ->write(!$this->usedFunctions ? "[],\n" : "['".implode("', '", array_keys($this->usedFunctions))."'],\n") + ->write("\$this->source\n") ->outdent() ->write(");\n") ->outdent() diff --git a/src/Node/CheckToStringNode.php b/src/Node/CheckToStringNode.php index c7a9d6984e7..937240c1d3a 100644 --- a/src/Node/CheckToStringNode.php +++ b/src/Node/CheckToStringNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; @@ -24,11 +25,12 @@ * * @author Fabien Potencier */ +#[YieldReady] class CheckToStringNode extends AbstractExpression { public function __construct(AbstractExpression $expr) { - parent::__construct(['expr' => $expr], [], $expr->getTemplateLine(), $expr->getNodeTag()); + parent::__construct(['expr' => $expr], [], $expr->getTemplateLine()); } public function compile(Compiler $compiler): void diff --git a/src/Node/DeprecatedNode.php b/src/Node/DeprecatedNode.php index 5ff44307fc5..0772adfc361 100644 --- a/src/Node/DeprecatedNode.php +++ b/src/Node/DeprecatedNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; @@ -20,11 +21,12 @@ * * @author Yonel Ceruto */ +#[YieldReady] class DeprecatedNode extends Node { - public function __construct(AbstractExpression $expr, int $lineno, string $tag = null) + public function __construct(AbstractExpression $expr, int $lineno) { - parent::__construct(['expr' => $expr], [], $lineno, $tag); + parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void @@ -33,21 +35,39 @@ public function compile(Compiler $compiler): void $expr = $this->getNode('expr'); - if ($expr instanceof ConstantExpression) { - $compiler->write('@trigger_error(') - ->subcompile($expr); - } else { + if (!$expr instanceof ConstantExpression) { $varName = $compiler->getVarName(); - $compiler->write(sprintf('$%s = ', $varName)) + $compiler + ->write(\sprintf('$%s = ', $varName)) ->subcompile($expr) ->raw(";\n") - ->write(sprintf('@trigger_error($%s', $varName)); + ; + } + + $compiler->write('trigger_deprecation('); + if ($this->hasNode('package')) { + $compiler->subcompile($this->getNode('package')); + } else { + $compiler->raw("''"); + } + $compiler->raw(', '); + if ($this->hasNode('version')) { + $compiler->subcompile($this->getNode('version')); + } else { + $compiler->raw("''"); + } + $compiler->raw(', '); + + if ($expr instanceof ConstantExpression) { + $compiler->subcompile($expr); + } else { + $compiler->write(\sprintf('$%s', $varName)); } $compiler ->raw('.') - ->string(sprintf(' ("%s" at line %d).', $this->getTemplateName(), $this->getTemplateLine())) - ->raw(", E_USER_DEPRECATED);\n") + ->string(\sprintf(' in "%s" at line %d.', $this->getTemplateName(), $this->getTemplateLine())) + ->raw(");\n") ; } } diff --git a/src/Node/DoNode.php b/src/Node/DoNode.php index f7783d19f40..1593fd05024 100644 --- a/src/Node/DoNode.php +++ b/src/Node/DoNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; @@ -19,11 +20,12 @@ * * @author Fabien Potencier */ +#[YieldReady] class DoNode extends Node { - public function __construct(AbstractExpression $expr, int $lineno, string $tag = null) + public function __construct(AbstractExpression $expr, int $lineno) { - parent::__construct(['expr' => $expr], [], $lineno, $tag); + parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void diff --git a/src/Node/EmbedNode.php b/src/Node/EmbedNode.php index 903c3f6c7ac..2de39ebb942 100644 --- a/src/Node/EmbedNode.php +++ b/src/Node/EmbedNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; @@ -20,29 +21,34 @@ * * @author Fabien Potencier */ +#[YieldReady] class EmbedNode extends IncludeNode { // we don't inject the module to avoid node visitors to traverse it twice (as it will be already visited in the main module) - public function __construct(string $name, int $index, ?AbstractExpression $variables, bool $only, bool $ignoreMissing, int $lineno, string $tag = null) + public function __construct(string $name, int $index, ?AbstractExpression $variables, bool $only, bool $ignoreMissing, int $lineno) { - parent::__construct(new ConstantExpression('not_used', $lineno), $variables, $only, $ignoreMissing, $lineno, $tag); + parent::__construct(new ConstantExpression('not_used', $lineno), $variables, $only, $ignoreMissing, $lineno); $this->setAttribute('name', $name); $this->setAttribute('index', $index); } - protected function addGetTemplate(Compiler $compiler): void + protected function addGetTemplate(Compiler $compiler, string $template = ''): void { $compiler - ->write('$this->loadTemplate(') + ->raw('$this->load(') ->string($this->getAttribute('name')) ->raw(', ') - ->repr($this->getTemplateName()) - ->raw(', ') ->repr($this->getTemplateLine()) ->raw(', ') - ->string($this->getAttribute('index')) + ->repr($this->getAttribute('index')) ->raw(')') ; + if ($this->getAttribute('ignore_missing')) { + $compiler + ->raw(";\n") + ->write(\sprintf("\$%s->getParent(\$context);\n", $template)) + ; + } } } diff --git a/src/Node/EmptyNode.php b/src/Node/EmptyNode.php new file mode 100644 index 00000000000..fd4717ff4ba --- /dev/null +++ b/src/Node/EmptyNode.php @@ -0,0 +1,33 @@ + + */ +#[YieldReady] +final class EmptyNode extends Node +{ + public function __construct(int $lineno = 0) + { + parent::__construct([], [], $lineno); + } + + public function setNode(string $name, Node $node): void + { + throw new \LogicException('EmptyNode cannot have children.'); + } +} diff --git a/src/Node/Expression/AbstractExpression.php b/src/Node/Expression/AbstractExpression.php index 42da0559d12..22d8617cd72 100644 --- a/src/Node/Expression/AbstractExpression.php +++ b/src/Node/Expression/AbstractExpression.php @@ -21,4 +21,23 @@ */ abstract class AbstractExpression extends Node { + public function isGenerator(): bool + { + return $this->hasAttribute('is_generator') && $this->getAttribute('is_generator'); + } + + /** + * @return static + */ + public function setExplicitParentheses(): self + { + $this->setAttribute('with_parentheses', true); + + return $this; + } + + public function hasExplicitParentheses(): bool + { + return $this->hasAttribute('with_parentheses') && $this->getAttribute('with_parentheses'); + } } diff --git a/src/Node/Expression/ArrayExpression.php b/src/Node/Expression/ArrayExpression.php index 0e25fe46ad8..b6f8a6ba48f 100644 --- a/src/Node/Expression/ArrayExpression.php +++ b/src/Node/Expression/ArrayExpression.php @@ -12,9 +12,14 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Node\Expression\Unary\SpreadUnary; +use Twig\Node\Expression\Unary\StringCastUnary; +use Twig\Node\Expression\Variable\ContextVariable; -class ArrayExpression extends AbstractExpression +class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface { + use SupportDefinedTestTrait; + private $index; public function __construct(array $elements, int $lineno) @@ -55,7 +60,7 @@ public function hasElement(AbstractExpression $key): bool return false; } - public function addElement(AbstractExpression $value, AbstractExpression $key = null): void + public function addElement(AbstractExpression $value, ?AbstractExpression $key = null): void { if (null === $key) { $key = new ConstantExpression(++$this->index, $value->getTemplateLine()); @@ -66,19 +71,41 @@ public function addElement(AbstractExpression $value, AbstractExpression $key = public function compile(Compiler $compiler): void { + if ($this->definedTest) { + $compiler->repr(true); + + return; + } + $compiler->raw('['); - $first = true; - foreach ($this->getKeyValuePairs() as $pair) { - if (!$first) { + $isSequence = true; + foreach ($this->getKeyValuePairs() as $i => $pair) { + if (0 !== $i) { $compiler->raw(', '); } - $first = false; - $compiler - ->subcompile($pair['key']) - ->raw(' => ') - ->subcompile($pair['value']) - ; + $key = null; + if ($pair['key'] instanceof ContextVariable) { + $pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine()); + } elseif ($pair['key'] instanceof TempNameExpression) { + $key = $pair['key']->getAttribute('name'); + $pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine()); + } elseif ($pair['key'] instanceof ConstantExpression) { + $key = $pair['key']->getAttribute('value'); + } + + if ($key !== $i) { + $isSequence = false; + } + + if (!$isSequence && !$pair['value'] instanceof SpreadUnary) { + $compiler + ->subcompile($pair['key']) + ->raw(' => ') + ; + } + + $compiler->subcompile($pair['value']); } $compiler->raw(']'); } diff --git a/src/Node/Expression/ArrowFunctionExpression.php b/src/Node/Expression/ArrowFunctionExpression.php index eaad03c9c05..552b8fe9115 100644 --- a/src/Node/Expression/ArrowFunctionExpression.php +++ b/src/Node/Expression/ArrowFunctionExpression.php @@ -12,6 +12,9 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Error\SyntaxError; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; /** @@ -21,9 +24,17 @@ */ class ArrowFunctionExpression extends AbstractExpression { - public function __construct(AbstractExpression $expr, Node $names, $lineno, $tag = null) + public function __construct(AbstractExpression $expr, Node $names, $lineno) { - parent::__construct(['expr' => $expr, 'names' => $names], [], $lineno, $tag); + if (!$names instanceof ListExpression && !$names instanceof ContextVariable) { + throw new SyntaxError('The arrow function argument must be a list of variables or a single variable.', $names->getTemplateLine(), $names->getSourceContext()); + } + + if ($names instanceof ContextVariable) { + $names = new ListExpression([new AssignContextVariable($names->getAttribute('name'), $names->getTemplateLine())], $lineno); + } + + parent::__construct(['expr' => $expr, 'names' => $names], [], $lineno); } public function compile(Compiler $compiler): void @@ -31,19 +42,7 @@ public function compile(Compiler $compiler): void $compiler ->addDebugInfo($this) ->raw('function (') - ; - foreach ($this->getNode('names') as $i => $name) { - if ($i) { - $compiler->raw(', '); - } - - $compiler - ->raw('$__') - ->raw($name->getAttribute('name')) - ->raw('__') - ; - } - $compiler + ->subcompile($this->getNode('names')) ->raw(') use ($context, $macros) { ') ; foreach ($this->getNode('names') as $name) { diff --git a/src/Node/Expression/AssignNameExpression.php b/src/Node/Expression/AssignNameExpression.php index 7dd1bc4a372..9a7f0f92bca 100644 --- a/src/Node/Expression/AssignNameExpression.php +++ b/src/Node/Expression/AssignNameExpression.php @@ -13,9 +13,26 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Error\SyntaxError; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Expression\Variable\ContextVariable; -class AssignNameExpression extends NameExpression +class AssignNameExpression extends ContextVariable { + public function __construct(string $name, int $lineno) + { + if (self::class === static::class) { + trigger_deprecation('twig/twig', '3.15', 'The "%s" class is deprecated, use "%s" instead.', self::class, AssignContextVariable::class); + } + + // All names supported by ExpressionParser::parsePrimaryExpression() should be excluded + if (\in_array(strtolower($name), ['true', 'false', 'none', 'null'], true)) { + throw new SyntaxError(\sprintf('You cannot assign a value to "%s".', $name), $lineno); + } + + parent::__construct($name, $lineno); + } + public function compile(Compiler $compiler): void { $compiler diff --git a/src/Node/Expression/Binary/AbstractBinary.php b/src/Node/Expression/Binary/AbstractBinary.php index c424e5cc5f0..b4bf6662eda 100644 --- a/src/Node/Expression/Binary/AbstractBinary.php +++ b/src/Node/Expression/Binary/AbstractBinary.php @@ -16,10 +16,21 @@ use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; -abstract class AbstractBinary extends AbstractExpression +abstract class AbstractBinary extends AbstractExpression implements BinaryInterface { + /** + * @param AbstractExpression $left + * @param AbstractExpression $right + */ public function __construct(Node $left, Node $right, int $lineno) { + if (!$left instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "left" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $left::class); + } + if (!$right instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "right" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $right::class); + } + parent::__construct(['left' => $left, 'right' => $right], [], $lineno); } diff --git a/src/Node/Expression/Binary/AddBinary.php b/src/Node/Expression/Binary/AddBinary.php index ee4307e33e2..42377aea056 100644 --- a/src/Node/Expression/Binary/AddBinary.php +++ b/src/Node/Expression/Binary/AddBinary.php @@ -13,8 +13,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class AddBinary extends AbstractBinary +class AddBinary extends AbstractBinary implements ReturnNumberInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/AndBinary.php b/src/Node/Expression/Binary/AndBinary.php index 5f2380da545..454ea70e5b2 100644 --- a/src/Node/Expression/Binary/AndBinary.php +++ b/src/Node/Expression/Binary/AndBinary.php @@ -13,8 +13,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class AndBinary extends AbstractBinary +class AndBinary extends AbstractBinary implements ReturnBoolInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/BinaryInterface.php b/src/Node/Expression/Binary/BinaryInterface.php new file mode 100644 index 00000000000..eeeb2eb99ec --- /dev/null +++ b/src/Node/Expression/Binary/BinaryInterface.php @@ -0,0 +1,22 @@ +setNode('test', clone $left); + $left->setAttribute('always_defined', true); + } + + public function compile(Compiler $compiler): void + { + $compiler + ->raw('((') + ->subcompile($this->getNode('test')) + ->raw(') ? (') + ->subcompile($this->getNode('left')) + ->raw(') : (') + ->subcompile($this->getNode('right')) + ->raw('))') + ; + } + + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw('?:'); + } + + public function getOperandNamesToEscape(): array + { + return ['left', 'right']; + } +} diff --git a/src/Node/Expression/Binary/EndsWithBinary.php b/src/Node/Expression/Binary/EndsWithBinary.php index c3516b853fc..e689d668a67 100644 --- a/src/Node/Expression/Binary/EndsWithBinary.php +++ b/src/Node/Expression/Binary/EndsWithBinary.php @@ -12,19 +12,20 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class EndsWithBinary extends AbstractBinary +class EndsWithBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { $left = $compiler->getVarName(); $right = $compiler->getVarName(); $compiler - ->raw(sprintf('(is_string($%s = ', $left)) + ->raw(\sprintf('(is_string($%s = ', $left)) ->subcompile($this->getNode('left')) - ->raw(sprintf(') && is_string($%s = ', $right)) + ->raw(\sprintf(') && is_string($%s = ', $right)) ->subcompile($this->getNode('right')) - ->raw(sprintf(') && (\'\' === $%2$s || $%2$s === substr($%1$s, -strlen($%2$s))))', $left, $right)) + ->raw(\sprintf(') && str_ends_with($%1$s, $%2$s))', $left, $right)) ; } diff --git a/src/Node/Expression/Binary/EqualBinary.php b/src/Node/Expression/Binary/EqualBinary.php index 6b48549ef26..8c365035583 100644 --- a/src/Node/Expression/Binary/EqualBinary.php +++ b/src/Node/Expression/Binary/EqualBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class EqualBinary extends AbstractBinary +class EqualBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { @@ -24,7 +25,7 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('(0 === twig_compare(') + ->raw('(0 === CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/FloorDivBinary.php b/src/Node/Expression/Binary/FloorDivBinary.php index d7e7980efde..a60ab3b2129 100644 --- a/src/Node/Expression/Binary/FloorDivBinary.php +++ b/src/Node/Expression/Binary/FloorDivBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class FloorDivBinary extends AbstractBinary +class FloorDivBinary extends AbstractBinary implements ReturnNumberInterface { public function compile(Compiler $compiler): void { diff --git a/src/Node/Expression/Binary/GreaterBinary.php b/src/Node/Expression/Binary/GreaterBinary.php index e1dd06780b7..71a980b3ee4 100644 --- a/src/Node/Expression/Binary/GreaterBinary.php +++ b/src/Node/Expression/Binary/GreaterBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class GreaterBinary extends AbstractBinary +class GreaterBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { @@ -24,7 +25,7 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('(1 === twig_compare(') + ->raw('(1 === CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/GreaterEqualBinary.php b/src/Node/Expression/Binary/GreaterEqualBinary.php index df9bfcfbf9d..c92e61b3796 100644 --- a/src/Node/Expression/Binary/GreaterEqualBinary.php +++ b/src/Node/Expression/Binary/GreaterEqualBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class GreaterEqualBinary extends AbstractBinary +class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { @@ -24,7 +25,7 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('(0 <= twig_compare(') + ->raw('(0 <= CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/HasEveryBinary.php b/src/Node/Expression/Binary/HasEveryBinary.php new file mode 100644 index 00000000000..22b38011810 --- /dev/null +++ b/src/Node/Expression/Binary/HasEveryBinary.php @@ -0,0 +1,34 @@ +raw('CoreExtension::arrayEvery($this->env, ') + ->subcompile($this->getNode('left')) + ->raw(', ') + ->subcompile($this->getNode('right')) + ->raw(')') + ; + } + + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw(''); + } +} diff --git a/src/Node/Expression/Binary/HasSomeBinary.php b/src/Node/Expression/Binary/HasSomeBinary.php new file mode 100644 index 00000000000..a2a363e99a8 --- /dev/null +++ b/src/Node/Expression/Binary/HasSomeBinary.php @@ -0,0 +1,34 @@ +raw('CoreExtension::arraySome($this->env, ') + ->subcompile($this->getNode('left')) + ->raw(', ') + ->subcompile($this->getNode('right')) + ->raw(')') + ; + } + + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw(''); + } +} diff --git a/src/Node/Expression/Binary/InBinary.php b/src/Node/Expression/Binary/InBinary.php index 6dbfa97f05c..31a21e7d696 100644 --- a/src/Node/Expression/Binary/InBinary.php +++ b/src/Node/Expression/Binary/InBinary.php @@ -12,13 +12,14 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class InBinary extends AbstractBinary +class InBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { $compiler - ->raw('twig_in_filter(') + ->raw('CoreExtension::inFilter(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/LessBinary.php b/src/Node/Expression/Binary/LessBinary.php index 598e629134b..293d98d5175 100644 --- a/src/Node/Expression/Binary/LessBinary.php +++ b/src/Node/Expression/Binary/LessBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class LessBinary extends AbstractBinary +class LessBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { @@ -24,7 +25,7 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('(-1 === twig_compare(') + ->raw('(-1 === CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/LessEqualBinary.php b/src/Node/Expression/Binary/LessEqualBinary.php index e3c4af58d4c..239d9fdfeae 100644 --- a/src/Node/Expression/Binary/LessEqualBinary.php +++ b/src/Node/Expression/Binary/LessEqualBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class LessEqualBinary extends AbstractBinary +class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { @@ -24,7 +25,7 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('(0 >= twig_compare(') + ->raw('(0 >= CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/MatchesBinary.php b/src/Node/Expression/Binary/MatchesBinary.php index bc97292cda5..569dfde05f1 100644 --- a/src/Node/Expression/Binary/MatchesBinary.php +++ b/src/Node/Expression/Binary/MatchesBinary.php @@ -12,13 +12,32 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Error\SyntaxError; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\ReturnBoolInterface; +use Twig\Node\Node; -class MatchesBinary extends AbstractBinary +class MatchesBinary extends AbstractBinary implements ReturnBoolInterface { + public function __construct(Node $left, Node $right, int $lineno) + { + if ($right instanceof ConstantExpression) { + $regexp = $right->getAttribute('value'); + set_error_handler(static fn ($t, $m) => throw new SyntaxError(\sprintf('Regexp "%s" passed to "matches" is not valid: %s.', $regexp, substr($m, 14)), $lineno)); + try { + preg_match($regexp, ''); + } finally { + restore_error_handler(); + } + } + + parent::__construct($left, $right, $lineno); + } + public function compile(Compiler $compiler): void { $compiler - ->raw('preg_match(') + ->raw('CoreExtension::matches(') ->subcompile($this->getNode('right')) ->raw(', ') ->subcompile($this->getNode('left')) diff --git a/src/Node/Expression/Binary/ModBinary.php b/src/Node/Expression/Binary/ModBinary.php index 271b45cac88..aef48f3d0a2 100644 --- a/src/Node/Expression/Binary/ModBinary.php +++ b/src/Node/Expression/Binary/ModBinary.php @@ -13,8 +13,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class ModBinary extends AbstractBinary +class ModBinary extends AbstractBinary implements ReturnNumberInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/MulBinary.php b/src/Node/Expression/Binary/MulBinary.php index 6d4c1e0b6b8..beb881ae38d 100644 --- a/src/Node/Expression/Binary/MulBinary.php +++ b/src/Node/Expression/Binary/MulBinary.php @@ -13,8 +13,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class MulBinary extends AbstractBinary +class MulBinary extends AbstractBinary implements ReturnNumberInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/NotEqualBinary.php b/src/Node/Expression/Binary/NotEqualBinary.php index db47a289050..fd24ef9115e 100644 --- a/src/Node/Expression/Binary/NotEqualBinary.php +++ b/src/Node/Expression/Binary/NotEqualBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class NotEqualBinary extends AbstractBinary +class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { @@ -24,7 +25,7 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('(0 !== twig_compare(') + ->raw('(0 !== CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/NotInBinary.php b/src/Node/Expression/Binary/NotInBinary.php index fcba6cca1cb..9fd27311fec 100644 --- a/src/Node/Expression/Binary/NotInBinary.php +++ b/src/Node/Expression/Binary/NotInBinary.php @@ -12,13 +12,14 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class NotInBinary extends AbstractBinary +class NotInBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { $compiler - ->raw('!twig_in_filter(') + ->raw('!CoreExtension::inFilter(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) diff --git a/src/Node/Expression/Binary/NullCoalesceBinary.php b/src/Node/Expression/Binary/NullCoalesceBinary.php new file mode 100644 index 00000000000..a047b6030dd --- /dev/null +++ b/src/Node/Expression/Binary/NullCoalesceBinary.php @@ -0,0 +1,71 @@ +getTemplateLine()); + // for "block()", we don't need the null test as the return value is always a string + if (!$left instanceof BlockReferenceExpression) { + $test = new AndBinary( + $test, + new NotUnary(new NullTest($left, new TwigTest('null'), new EmptyNode(), $left->getTemplateLine()), $left->getTemplateLine()), + $left->getTemplateLine(), + ); + } + + $left->setAttribute('always_defined', true); + $this->setNode('test', $test); + } + + public function compile(Compiler $compiler): void + { + $compiler + ->raw('((') + ->subcompile($this->getNode('test')) + ->raw(') ? (') + ->subcompile($this->getNode('left')) + ->raw(') : (') + ->subcompile($this->getNode('right')) + ->raw('))') + ; + } + + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw('??'); + } + + public function getOperandNamesToEscape(): array + { + return ['left', 'right']; + } +} diff --git a/src/Node/Expression/Binary/OrBinary.php b/src/Node/Expression/Binary/OrBinary.php index 21f87c91b4f..82dcb7e953b 100644 --- a/src/Node/Expression/Binary/OrBinary.php +++ b/src/Node/Expression/Binary/OrBinary.php @@ -13,8 +13,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class OrBinary extends AbstractBinary +class OrBinary extends AbstractBinary implements ReturnBoolInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/PowerBinary.php b/src/Node/Expression/Binary/PowerBinary.php index c9f4c6697df..5325e8eb0b2 100644 --- a/src/Node/Expression/Binary/PowerBinary.php +++ b/src/Node/Expression/Binary/PowerBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class PowerBinary extends AbstractBinary +class PowerBinary extends AbstractBinary implements ReturnNumberInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/RangeBinary.php b/src/Node/Expression/Binary/RangeBinary.php index 55982c819d7..f318d8e5548 100644 --- a/src/Node/Expression/Binary/RangeBinary.php +++ b/src/Node/Expression/Binary/RangeBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnArrayInterface; -class RangeBinary extends AbstractBinary +class RangeBinary extends AbstractBinary implements ReturnArrayInterface { public function compile(Compiler $compiler): void { diff --git a/src/Node/Expression/Binary/SpaceshipBinary.php b/src/Node/Expression/Binary/SpaceshipBinary.php index ae5a4a49373..c0a28b0a88f 100644 --- a/src/Node/Expression/Binary/SpaceshipBinary.php +++ b/src/Node/Expression/Binary/SpaceshipBinary.php @@ -12,8 +12,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class SpaceshipBinary extends AbstractBinary +class SpaceshipBinary extends AbstractBinary implements ReturnNumberInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/StartsWithBinary.php b/src/Node/Expression/Binary/StartsWithBinary.php index d0df1c4b639..ef2fc950242 100644 --- a/src/Node/Expression/Binary/StartsWithBinary.php +++ b/src/Node/Expression/Binary/StartsWithBinary.php @@ -12,19 +12,20 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnBoolInterface; -class StartsWithBinary extends AbstractBinary +class StartsWithBinary extends AbstractBinary implements ReturnBoolInterface { public function compile(Compiler $compiler): void { $left = $compiler->getVarName(); $right = $compiler->getVarName(); $compiler - ->raw(sprintf('(is_string($%s = ', $left)) + ->raw(\sprintf('(is_string($%s = ', $left)) ->subcompile($this->getNode('left')) - ->raw(sprintf(') && is_string($%s = ', $right)) + ->raw(\sprintf(') && is_string($%s = ', $right)) ->subcompile($this->getNode('right')) - ->raw(sprintf(') && (\'\' === $%2$s || 0 === strpos($%1$s, $%2$s)))', $left, $right)) + ->raw(\sprintf(') && str_starts_with($%1$s, $%2$s))', $left, $right)) ; } diff --git a/src/Node/Expression/Binary/SubBinary.php b/src/Node/Expression/Binary/SubBinary.php index eeb87faca96..10663f5c1ef 100644 --- a/src/Node/Expression/Binary/SubBinary.php +++ b/src/Node/Expression/Binary/SubBinary.php @@ -13,8 +13,9 @@ namespace Twig\Node\Expression\Binary; use Twig\Compiler; +use Twig\Node\Expression\ReturnNumberInterface; -class SubBinary extends AbstractBinary +class SubBinary extends AbstractBinary implements ReturnNumberInterface { public function operator(Compiler $compiler): Compiler { diff --git a/src/Node/Expression/Binary/XorBinary.php b/src/Node/Expression/Binary/XorBinary.php new file mode 100644 index 00000000000..6f412d22fe2 --- /dev/null +++ b/src/Node/Expression/Binary/XorBinary.php @@ -0,0 +1,24 @@ +raw('xor'); + } +} diff --git a/src/Node/Expression/BlockReferenceExpression.php b/src/Node/Expression/BlockReferenceExpression.php index b1e2a8f7bb6..cb7d38c5755 100644 --- a/src/Node/Expression/BlockReferenceExpression.php +++ b/src/Node/Expression/BlockReferenceExpression.php @@ -20,28 +20,39 @@ * * @author Fabien Potencier */ -class BlockReferenceExpression extends AbstractExpression +class BlockReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface { - public function __construct(Node $name, ?Node $template, int $lineno, string $tag = null) + use SupportDefinedTestDeprecationTrait; + use SupportDefinedTestTrait; + + /** + * @param AbstractExpression $name + */ + public function __construct(Node $name, ?Node $template, int $lineno) { + if (!$name instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "node" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $name::class); + } + $nodes = ['name' => $name]; if (null !== $template) { $nodes['template'] = $template; } - parent::__construct($nodes, ['is_defined_test' => false, 'output' => false], $lineno, $tag); + parent::__construct($nodes, ['output' => false], $lineno); } public function compile(Compiler $compiler): void { - if ($this->getAttribute('is_defined_test')) { + if ($this->definedTest) { $this->compileTemplateCall($compiler, 'hasBlock'); } else { if ($this->getAttribute('output')) { $compiler->addDebugInfo($this); + $compiler->write('yield from '); $this - ->compileTemplateCall($compiler, 'displayBlock') + ->compileTemplateCall($compiler, 'yieldBlock') ->raw(";\n"); } else { $this->compileTemplateCall($compiler, 'renderBlock'); @@ -55,17 +66,15 @@ private function compileTemplateCall(Compiler $compiler, string $method): Compil $compiler->write('$this'); } else { $compiler - ->write('$this->loadTemplate(') + ->write('$this->load(') ->subcompile($this->getNode('template')) ->raw(', ') - ->repr($this->getTemplateName()) - ->raw(', ') ->repr($this->getTemplateLine()) ->raw(')') ; } - $compiler->raw(sprintf('->%s', $method)); + $compiler->raw(\sprintf('->unwrap()->%s', $method)); return $this->compileBlockArguments($compiler); } diff --git a/src/Node/Expression/CallExpression.php b/src/Node/Expression/CallExpression.php index 28881066bc0..330d82535c3 100644 --- a/src/Node/Expression/CallExpression.php +++ b/src/Node/Expression/CallExpression.php @@ -15,40 +15,52 @@ use Twig\Error\SyntaxError; use Twig\Extension\ExtensionInterface; use Twig\Node\Node; +use Twig\TwigCallableInterface; +use Twig\TwigFilter; +use Twig\TwigFunction; +use Twig\TwigTest; +use Twig\Util\CallableArgumentsExtractor; +use Twig\Util\ReflectionCallable; abstract class CallExpression extends AbstractExpression { - private $reflector; + private $reflector = null; + /** + * @return void + */ protected function compileCallable(Compiler $compiler) { - $callable = $this->getAttribute('callable'); + $twigCallable = $this->getTwigCallable(); + $callable = $twigCallable->getCallable(); - if (\is_string($callable) && false === strpos($callable, '::')) { + if (\is_string($callable) && !str_contains($callable, '::')) { $compiler->raw($callable); } else { - [$r, $callable] = $this->reflectCallable($callable); + $rc = $this->reflectCallable($twigCallable); + $r = $rc->getReflector(); + $callable = $rc->getCallable(); if (\is_string($callable)) { $compiler->raw($callable); } elseif (\is_array($callable) && \is_string($callable[0])) { if (!$r instanceof \ReflectionMethod || $r->isStatic()) { - $compiler->raw(sprintf('%s::%s', $callable[0], $callable[1])); + $compiler->raw(\sprintf('%s::%s', $callable[0], $callable[1])); } else { - $compiler->raw(sprintf('$this->env->getRuntime(\'%s\')->%s', $callable[0], $callable[1])); + $compiler->raw(\sprintf('$this->env->getRuntime(\'%s\')->%s', $callable[0], $callable[1])); } } elseif (\is_array($callable) && $callable[0] instanceof ExtensionInterface) { $class = \get_class($callable[0]); if (!$compiler->getEnvironment()->hasExtension($class)) { // Compile a non-optimized call to trigger a \Twig\Error\RuntimeError, which cannot be a compile-time error - $compiler->raw(sprintf('$this->env->getExtension(\'%s\')', $class)); + $compiler->raw(\sprintf('$this->env->getExtension(\'%s\')', $class)); } else { - $compiler->raw(sprintf('$this->extensions[\'%s\']', ltrim($class, '\\'))); + $compiler->raw(\sprintf('$this->extensions[\'%s\']', ltrim($class, '\\'))); } - $compiler->raw(sprintf('->%s', $callable[1])); + $compiler->raw(\sprintf('->%s', $callable[1])); } else { - $compiler->raw(sprintf('$this->env->get%s(\'%s\')->getCallable()', ucfirst($this->getAttribute('type')), $this->getAttribute('name'))); + $compiler->raw(\sprintf('$this->env->get%s(\'%s\')->getCallable()', ucfirst($this->getAttribute('type')), $twigCallable->getDynamicName())); } } @@ -57,16 +69,30 @@ protected function compileCallable(Compiler $compiler) protected function compileArguments(Compiler $compiler, $isArray = false): void { + if (\func_num_args() >= 2) { + trigger_deprecation('twig/twig', '3.11', 'Passing a second argument to "%s()" is deprecated.', __METHOD__); + } + $compiler->raw($isArray ? '[' : '('); $first = true; - if ($this->hasAttribute('needs_environment') && $this->getAttribute('needs_environment')) { + $twigCallable = $this->getAttribute('twig_callable'); + + if ($twigCallable->needsCharset()) { + $compiler->raw('$this->env->getCharset()'); + $first = false; + } + + if ($twigCallable->needsEnvironment()) { + if (!$first) { + $compiler->raw(', '); + } $compiler->raw('$this->env'); $first = false; } - if ($this->hasAttribute('needs_context') && $this->getAttribute('needs_context')) { + if ($twigCallable->needsContext()) { if (!$first) { $compiler->raw(', '); } @@ -74,14 +100,12 @@ protected function compileArguments(Compiler $compiler, $isArray = false): void $first = false; } - if ($this->hasAttribute('arguments')) { - foreach ($this->getAttribute('arguments') as $argument) { - if (!$first) { - $compiler->raw(', '); - } - $compiler->string($argument); - $first = false; + foreach ($twigCallable->getArguments() as $argument) { + if (!$first) { + $compiler->raw(', '); } + $compiler->string($argument); + $first = false; } if ($this->hasNode('node')) { @@ -93,8 +117,7 @@ protected function compileArguments(Compiler $compiler, $isArray = false): void } if ($this->hasNode('arguments')) { - $callable = $this->getAttribute('callable'); - $arguments = $this->getArguments($callable, $this->getNode('arguments')); + $arguments = (new CallableArgumentsExtractor($this, $this->getTwigCallable()))->extractArguments($this->getNode('arguments')); foreach ($arguments as $node) { if (!$first) { $compiler->raw(', '); @@ -107,8 +130,13 @@ protected function compileArguments(Compiler $compiler, $isArray = false): void $compiler->raw($isArray ? ']' : ')'); } + /** + * @deprecated since Twig 3.12, use Twig\Util\CallableArgumentsExtractor::getArguments() instead + */ protected function getArguments($callable, $arguments) { + trigger_deprecation('twig/twig', '3.12', 'The "%s()" method is deprecated, use Twig\Util\CallableArgumentsExtractor::getArguments() instead.', __METHOD__); + $callType = $this->getAttribute('type'); $callName = $this->getAttribute('name'); @@ -119,28 +147,28 @@ protected function getArguments($callable, $arguments) $named = true; $name = $this->normalizeName($name); } elseif ($named) { - throw new SyntaxError(sprintf('Positional arguments cannot be used after named arguments for %s "%s".', $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); + throw new SyntaxError(\sprintf('Positional arguments cannot be used after named arguments for %s "%s".', $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); } $parameters[$name] = $node; } - $isVariadic = $this->hasAttribute('is_variadic') && $this->getAttribute('is_variadic'); + $isVariadic = $this->getAttribute('twig_callable')->isVariadic(); if (!$named && !$isVariadic) { return $parameters; } if (!$callable) { if ($named) { - $message = sprintf('Named arguments are not supported for %s "%s".', $callType, $callName); + $message = \sprintf('Named arguments are not supported for %s "%s".', $callType, $callName); } else { - $message = sprintf('Arbitrary positional arguments are not supported for %s "%s".', $callType, $callName); + $message = \sprintf('Arbitrary positional arguments are not supported for %s "%s".', $callType, $callName); } throw new \LogicException($message); } - list($callableParameters, $isPhpVariadic) = $this->getCallableParameters($callable, $isVariadic); + [$callableParameters, $isPhpVariadic] = $this->getCallableParameters($callable, $isVariadic); $arguments = []; $names = []; $missingArguments = []; @@ -160,11 +188,11 @@ protected function getArguments($callable, $arguments) if (\array_key_exists($name, $parameters)) { if (\array_key_exists($pos, $parameters)) { - throw new SyntaxError(sprintf('Argument "%s" is defined twice for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); + throw new SyntaxError(\sprintf('Argument "%s" is defined twice for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); } if (\count($missingArguments)) { - throw new SyntaxError(sprintf( + throw new SyntaxError(\sprintf( 'Argument "%s" could not be assigned for %s "%s(%s)" because it is mapped to an internal PHP function which cannot determine default value for optional argument%s "%s".', $name, $callType, $callName, implode(', ', $names), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments) ), $this->getTemplateLine(), $this->getSourceContext()); @@ -183,13 +211,13 @@ protected function getArguments($callable, $arguments) } elseif ($callableParameter->isDefaultValueAvailable()) { $optionalArguments[] = new ConstantExpression($callableParameter->getDefaultValue(), -1); } elseif ($callableParameter->isOptional()) { - if (empty($parameters)) { + if (!$parameters) { break; } else { $missingArguments[] = $name; } } else { - throw new SyntaxError(sprintf('Value for argument "%s" is required for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); + throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); } } @@ -210,7 +238,7 @@ protected function getArguments($callable, $arguments) } } - if (!empty($parameters)) { + if ($parameters) { $unknownParameter = null; foreach ($parameters as $parameter) { if ($parameter instanceof Node) { @@ -220,7 +248,7 @@ protected function getArguments($callable, $arguments) } throw new SyntaxError( - sprintf( + \sprintf( 'Unknown argument%s "%s" for %s "%s(%s)".', \count($parameters) > 1 ? 's' : '', implode('", "', array_keys($parameters)), $callType, $callName, implode(', ', $names) ), @@ -232,88 +260,106 @@ protected function getArguments($callable, $arguments) return $arguments; } + /** + * @deprecated since Twig 3.12 + */ protected function normalizeName(string $name): string { + trigger_deprecation('twig/twig', '3.12', 'The "%s()" method is deprecated.', __METHOD__); + return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], ['\\1_\\2', '\\1_\\2'], $name)); } + // To be removed in 4.0 private function getCallableParameters($callable, bool $isVariadic): array { - [$r, , $callableName] = $this->reflectCallable($callable); + $twigCallable = $this->getAttribute('twig_callable'); + $rc = $this->reflectCallable($twigCallable); + $r = $rc->getReflector(); + $callableName = $rc->getName(); $parameters = $r->getParameters(); if ($this->hasNode('node')) { array_shift($parameters); } - if ($this->hasAttribute('needs_environment') && $this->getAttribute('needs_environment')) { + if ($twigCallable->needsCharset()) { array_shift($parameters); } - if ($this->hasAttribute('needs_context') && $this->getAttribute('needs_context')) { + if ($twigCallable->needsEnvironment()) { array_shift($parameters); } - if ($this->hasAttribute('arguments') && null !== $this->getAttribute('arguments')) { - foreach ($this->getAttribute('arguments') as $argument) { - array_shift($parameters); - } + if ($twigCallable->needsContext()) { + array_shift($parameters); + } + foreach ($twigCallable->getArguments() as $argument) { + array_shift($parameters); } + $isPhpVariadic = false; if ($isVariadic) { $argument = end($parameters); - $isArray = $argument && $argument->hasType() && 'array' === $argument->getType()->getName(); + $isArray = $argument && $argument->hasType() && $argument->getType() instanceof \ReflectionNamedType && 'array' === $argument->getType()->getName(); if ($isArray && $argument->isDefaultValueAvailable() && [] === $argument->getDefaultValue()) { array_pop($parameters); } elseif ($argument && $argument->isVariadic()) { array_pop($parameters); $isPhpVariadic = true; } else { - throw new \LogicException(sprintf('The last parameter of "%s" for %s "%s" must be an array with default value, eg. "array $arg = []".', $callableName, $this->getAttribute('type'), $this->getAttribute('name'))); + throw new \LogicException(\sprintf('The last parameter of "%s" for %s "%s" must be an array with default value, eg. "array $arg = []".', $callableName, $this->getAttribute('type'), $twigCallable->getName())); } } return [$parameters, $isPhpVariadic]; } - private function reflectCallable($callable) + private function reflectCallable(TwigCallableInterface $callable): ReflectionCallable { - if (null !== $this->reflector) { - return $this->reflector; - } - - if (\is_string($callable) && false !== $pos = strpos($callable, '::')) { - $callable = [substr($callable, 0, $pos), substr($callable, 2 + $pos)]; + if (!$this->reflector) { + $this->reflector = new ReflectionCallable($callable); } - if (\is_array($callable) && method_exists($callable[0], $callable[1])) { - $r = new \ReflectionMethod($callable[0], $callable[1]); - - return $this->reflector = [$r, $callable, $r->class.'::'.$r->name]; - } - - $checkVisibility = $callable instanceof \Closure; - try { - $closure = \Closure::fromCallable($callable); - } catch (\TypeError $e) { - throw new \LogicException(sprintf('Callback for %s "%s" is not callable in the current scope.', $this->getAttribute('type'), $this->getAttribute('name')), 0, $e); - } - $r = new \ReflectionFunction($closure); - - if (false !== strpos($r->name, '{closure}')) { - return $this->reflector = [$r, $callable, 'Closure']; - } - - if ($object = $r->getClosureThis()) { - $callable = [$object, $r->name]; - $callableName = (\function_exists('get_debug_type') ? get_debug_type($object) : \get_class($object)).'::'.$r->name; - } elseif ($class = $r->getClosureScopeClass()) { - $callableName = (\is_array($callable) ? $callable[0] : $class->name).'::'.$r->name; - } else { - $callable = $callableName = $r->name; - } - - if ($checkVisibility && \is_array($callable) && method_exists(...$callable) && !(new \ReflectionMethod(...$callable))->isPublic()) { - $callable = $r->getClosure(); - } + return $this->reflector; + } - return $this->reflector = [$r, $callable, $callableName]; + /** + * Overrides the Twig callable based on attributes (as potentially, attributes changed between the creation and the compilation of the node). + * + * To be removed in 4.0 and replace by $this->getAttribute('twig_callable'). + */ + private function getTwigCallable(): TwigCallableInterface + { + $current = $this->getAttribute('twig_callable'); + + $this->setAttribute('twig_callable', match ($this->getAttribute('type')) { + 'test' => (new TwigTest( + $this->getAttribute('name'), + $this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(), + [ + 'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(), + ], + ))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()), + 'function' => (new TwigFunction( + $this->hasAttribute('name') ? $this->getAttribute('name') : $current->getName(), + $this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(), + [ + 'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(), + 'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(), + 'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(), + 'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(), + ], + ))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()), + 'filter' => (new TwigFilter( + $this->getAttribute('name'), + $this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(), + [ + 'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(), + 'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(), + 'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(), + 'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(), + ], + ))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()), + }); + + return $this->getAttribute('twig_callable'); } } diff --git a/src/Node/Expression/ConditionalExpression.php b/src/Node/Expression/ConditionalExpression.php index 2c7bd0a276c..7fe309cf300 100644 --- a/src/Node/Expression/ConditionalExpression.php +++ b/src/Node/Expression/ConditionalExpression.php @@ -13,24 +13,41 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Node\Expression\Ternary\ConditionalTernary; -class ConditionalExpression extends AbstractExpression +class ConditionalExpression extends AbstractExpression implements OperatorEscapeInterface { public function __construct(AbstractExpression $expr1, AbstractExpression $expr2, AbstractExpression $expr3, int $lineno) { + trigger_deprecation('twig/twig', '3.17', \sprintf('"%s" is deprecated; use "%s" instead.', __CLASS__, ConditionalTernary::class)); + parent::__construct(['expr1' => $expr1, 'expr2' => $expr2, 'expr3' => $expr3], [], $lineno); } public function compile(Compiler $compiler): void { - $compiler - ->raw('((') - ->subcompile($this->getNode('expr1')) - ->raw(') ? (') - ->subcompile($this->getNode('expr2')) - ->raw(') : (') - ->subcompile($this->getNode('expr3')) - ->raw('))') - ; + // Ternary with no then uses Elvis operator + if ($this->getNode('expr1') === $this->getNode('expr2')) { + $compiler + ->raw('((') + ->subcompile($this->getNode('expr1')) + ->raw(') ?: (') + ->subcompile($this->getNode('expr3')) + ->raw('))'); + } else { + $compiler + ->raw('((') + ->subcompile($this->getNode('expr1')) + ->raw(') ? (') + ->subcompile($this->getNode('expr2')) + ->raw(') : (') + ->subcompile($this->getNode('expr3')) + ->raw('))'); + } + } + + public function getOperandNamesToEscape(): array + { + return ['expr2', 'expr3']; } } diff --git a/src/Node/Expression/ConstantExpression.php b/src/Node/Expression/ConstantExpression.php index 7ddbcc6fa99..12dc0621fea 100644 --- a/src/Node/Expression/ConstantExpression.php +++ b/src/Node/Expression/ConstantExpression.php @@ -14,8 +14,13 @@ use Twig\Compiler; -class ConstantExpression extends AbstractExpression +/** + * @final + */ +class ConstantExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnPrimitiveTypeInterface { + use SupportDefinedTestTrait; + public function __construct($value, int $lineno) { parent::__construct([], ['value' => $value], $lineno); @@ -23,6 +28,6 @@ public function __construct($value, int $lineno) public function compile(Compiler $compiler): void { - $compiler->repr($this->getAttribute('value')); + $compiler->repr($this->definedTest ? true : $this->getAttribute('value')); } } diff --git a/src/Node/Expression/Filter/DefaultFilter.php b/src/Node/Expression/Filter/DefaultFilter.php index 6a572d48848..04ef06cc4ee 100644 --- a/src/Node/Expression/Filter/DefaultFilter.php +++ b/src/Node/Expression/Filter/DefaultFilter.php @@ -11,14 +11,20 @@ namespace Twig\Node\Expression\Filter; +use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; -use Twig\Node\Expression\ConditionalExpression; +use Twig\Extension\CoreExtension; +use Twig\Node\EmptyNode; +use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\GetAttrExpression; -use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\Ternary\ConditionalTernary; use Twig\Node\Expression\Test\DefinedTest; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; +use Twig\TwigFilter; +use Twig\TwigTest; /** * Returns the value or the default value when it is undefined or empty. @@ -29,20 +35,34 @@ */ class DefaultFilter extends FilterExpression { - public function __construct(Node $node, ConstantExpression $filterName, Node $arguments, int $lineno, string $tag = null) + /** + * @param AbstractExpression $node + */ + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigFilter|ConstantExpression $filter, Node $arguments, int $lineno) { - $default = new FilterExpression($node, new ConstantExpression('default', $node->getTemplateLine()), $arguments, $node->getTemplateLine()); + if (!$node instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "node" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $node::class); + } + + if ($filter instanceof TwigFilter) { + $name = $filter->getName(); + $default = new FilterExpression($node, $filter, $arguments, $node->getTemplateLine()); + } else { + $name = $filter->getAttribute('value'); + $default = new FilterExpression($node, new TwigFilter('default', [CoreExtension::class, 'default']), $arguments, $node->getTemplateLine()); + } - if ('default' === $filterName->getAttribute('value') && ($node instanceof NameExpression || $node instanceof GetAttrExpression)) { - $test = new DefinedTest(clone $node, 'defined', new Node(), $node->getTemplateLine()); - $false = \count($arguments) ? $arguments->getNode(0) : new ConstantExpression('', $node->getTemplateLine()); + if ('default' === $name && ($node instanceof ContextVariable || $node instanceof GetAttrExpression)) { + $test = new DefinedTest(clone $node, new TwigTest('defined'), new EmptyNode(), $node->getTemplateLine()); + $false = \count($arguments) ? $arguments->getNode('0') : new ConstantExpression('', $node->getTemplateLine()); - $node = new ConditionalExpression($test, $default, $false, $node->getTemplateLine()); + $node = new ConditionalTernary($test, $default, $false, $node->getTemplateLine()); } else { $node = $default; } - parent::__construct($node, $filterName, $arguments, $lineno, $tag); + parent::__construct($node, $filter, $arguments, $lineno); } public function compile(Compiler $compiler): void diff --git a/src/Node/Expression/Filter/RawFilter.php b/src/Node/Expression/Filter/RawFilter.php new file mode 100644 index 00000000000..707e8ec245c --- /dev/null +++ b/src/Node/Expression/Filter/RawFilter.php @@ -0,0 +1,45 @@ + + */ +class RawFilter extends FilterExpression +{ + /** + * @param AbstractExpression $node + */ + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigFilter|ConstantExpression|null $filter = null, ?Node $arguments = null, int $lineno = 0) + { + if (!$node instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "node" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $node::class); + } + + parent::__construct($node, $filter ?: new TwigFilter('raw', null, ['is_safe' => ['all']]), $arguments ?: new EmptyNode(), $lineno ?: $node->getTemplateLine()); + } + + public function compile(Compiler $compiler): void + { + $compiler->subcompile($this->getNode('node')); + } +} diff --git a/src/Node/Expression/FilterExpression.php b/src/Node/Expression/FilterExpression.php index 0fc1588696b..a66b0266d73 100644 --- a/src/Node/Expression/FilterExpression.php +++ b/src/Node/Expression/FilterExpression.php @@ -12,28 +12,68 @@ namespace Twig\Node\Expression; +use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; +use Twig\Node\NameDeprecation; use Twig\Node\Node; +use Twig\TwigFilter; class FilterExpression extends CallExpression { - public function __construct(Node $node, ConstantExpression $filterName, Node $arguments, int $lineno, string $tag = null) + /** + * @param AbstractExpression $node + */ + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigFilter|ConstantExpression $filter, Node $arguments, int $lineno) { - parent::__construct(['node' => $node, 'filter' => $filterName, 'arguments' => $arguments], [], $lineno, $tag); + if (!$node instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "node" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $node::class); + } + + if ($filter instanceof TwigFilter) { + $name = $filter->getName(); + $filterName = new ConstantExpression($name, $lineno); + } else { + $name = $filter->getAttribute('value'); + $filterName = $filter; + trigger_deprecation('twig/twig', '3.12', 'Not passing an instance of "TwigFilter" when creating a "%s" filter of type "%s" is deprecated.', $name, static::class); + } + + parent::__construct(['node' => $node, 'filter' => $filterName, 'arguments' => $arguments], ['name' => $name, 'type' => 'filter'], $lineno); + + if ($filter instanceof TwigFilter) { + $this->setAttribute('twig_callable', $filter); + } + + $this->deprecateNode('filter', new NameDeprecation('twig/twig', '3.12')); + + $this->deprecateAttribute('needs_charset', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('needs_environment', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('needs_context', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('arguments', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('callable', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('is_variadic', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('dynamic_name', new NameDeprecation('twig/twig', '3.12')); } public function compile(Compiler $compiler): void { - $name = $this->getNode('filter')->getAttribute('value'); - $filter = $compiler->getEnvironment()->getFilter($name); - - $this->setAttribute('name', $name); - $this->setAttribute('type', 'filter'); - $this->setAttribute('needs_environment', $filter->needsEnvironment()); - $this->setAttribute('needs_context', $filter->needsContext()); - $this->setAttribute('arguments', $filter->getArguments()); - $this->setAttribute('callable', $filter->getCallable()); - $this->setAttribute('is_variadic', $filter->isVariadic()); + $name = $this->getNode('filter', false)->getAttribute('value'); + if ($name !== $this->getAttribute('name')) { + trigger_deprecation('twig/twig', '3.11', 'Changing the value of a "filter" node in a NodeVisitor class is not supported anymore.'); + $this->removeAttribute('twig_callable'); + } + if ('raw' === $name) { + trigger_deprecation('twig/twig', '3.11', 'Creating the "raw" filter via "FilterExpression" is deprecated; use "RawFilter" instead.'); + + $compiler->subcompile($this->getNode('node')); + + return; + } + + if (!$this->hasAttribute('twig_callable')) { + $this->setAttribute('twig_callable', $compiler->getEnvironment()->getFilter($name)); + } $this->compileCallable($compiler); } diff --git a/src/Node/Expression/FunctionExpression.php b/src/Node/Expression/FunctionExpression.php index 71269775c3d..183145c4148 100644 --- a/src/Node/Expression/FunctionExpression.php +++ b/src/Node/Expression/FunctionExpression.php @@ -11,32 +11,70 @@ namespace Twig\Node\Expression; +use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; +use Twig\Node\NameDeprecation; use Twig\Node\Node; +use Twig\TwigFunction; -class FunctionExpression extends CallExpression +class FunctionExpression extends CallExpression implements SupportDefinedTestInterface { - public function __construct(string $name, Node $arguments, int $lineno) + use SupportDefinedTestDeprecationTrait; + use SupportDefinedTestTrait; + + #[FirstClassTwigCallableReady] + public function __construct(TwigFunction|string $function, Node $arguments, int $lineno) + { + if ($function instanceof TwigFunction) { + $name = $function->getName(); + } else { + $name = $function; + trigger_deprecation('twig/twig', '3.12', 'Not passing an instance of "TwigFunction" when creating a "%s" function of type "%s" is deprecated.', $name, static::class); + } + + parent::__construct(['arguments' => $arguments], ['name' => $name, 'type' => 'function'], $lineno); + + if ($function instanceof TwigFunction) { + $this->setAttribute('twig_callable', $function); + } + + $this->deprecateAttribute('needs_charset', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('needs_environment', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('needs_context', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('arguments', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('callable', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('is_variadic', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('dynamic_name', new NameDeprecation('twig/twig', '3.12')); + } + + public function enableDefinedTest(): void { - parent::__construct(['arguments' => $arguments], ['name' => $name, 'is_defined_test' => false], $lineno); + if ('constant' === $this->getAttribute('name')) { + $this->definedTest = true; + } } + /** + * @return void + */ public function compile(Compiler $compiler) { $name = $this->getAttribute('name'); - $function = $compiler->getEnvironment()->getFunction($name); - - $this->setAttribute('name', $name); - $this->setAttribute('type', 'function'); - $this->setAttribute('needs_environment', $function->needsEnvironment()); - $this->setAttribute('needs_context', $function->needsContext()); - $this->setAttribute('arguments', $function->getArguments()); - $callable = $function->getCallable(); - if ('constant' === $name && $this->getAttribute('is_defined_test')) { - $callable = 'twig_constant_is_defined'; + if ($this->hasAttribute('twig_callable')) { + $name = $this->getAttribute('twig_callable')->getName(); + if ($name !== $this->getAttribute('name')) { + trigger_deprecation('twig/twig', '3.12', 'Changing the value of a "function" node in a NodeVisitor class is not supported anymore.'); + $this->removeAttribute('twig_callable'); + } + } + + if (!$this->hasAttribute('twig_callable')) { + $this->setAttribute('twig_callable', $compiler->getEnvironment()->getFunction($name)); + } + + if ('constant' === $name && $this->isDefinedTestEnabled()) { + $this->getNode('arguments')->setNode('checkDefined', new ConstantExpression(true, $this->getTemplateLine())); } - $this->setAttribute('callable', $callable); - $this->setAttribute('is_variadic', $function->isVariadic()); $this->compileCallable($compiler); } diff --git a/src/Node/Expression/FunctionNode/EnumCasesFunction.php b/src/Node/Expression/FunctionNode/EnumCasesFunction.php new file mode 100644 index 00000000000..170d0a13b93 --- /dev/null +++ b/src/Node/Expression/FunctionNode/EnumCasesFunction.php @@ -0,0 +1,50 @@ +getNode('arguments'); + if ($arguments->hasNode('enum')) { + $firstArgument = $arguments->getNode('enum'); + } elseif ($arguments->hasNode('0')) { + $firstArgument = $arguments->getNode('0'); + } else { + $firstArgument = null; + } + + if (!$firstArgument instanceof ConstantExpression || 1 !== \count($arguments)) { + parent::compile($compiler); + + return; + } + + $value = $firstArgument->getAttribute('value'); + + if (!\is_string($value)) { + throw new SyntaxError('The first argument of the "enum_cases" function must be a string.', $this->getTemplateLine(), $this->getSourceContext()); + } + + if (!enum_exists($value)) { + throw new SyntaxError(\sprintf('The first argument of the "enum_cases" function must be the name of an enum, "%s" given.', $value), $this->getTemplateLine(), $this->getSourceContext()); + } + + $compiler->raw(\sprintf('%s::cases()', $value)); + } +} diff --git a/src/Node/Expression/FunctionNode/EnumFunction.php b/src/Node/Expression/FunctionNode/EnumFunction.php new file mode 100644 index 00000000000..af15b64f767 --- /dev/null +++ b/src/Node/Expression/FunctionNode/EnumFunction.php @@ -0,0 +1,54 @@ +getNode('arguments'); + if ($arguments->hasNode('enum')) { + $firstArgument = $arguments->getNode('enum'); + } elseif ($arguments->hasNode('0')) { + $firstArgument = $arguments->getNode('0'); + } else { + $firstArgument = null; + } + + if (!$firstArgument instanceof ConstantExpression || 1 !== \count($arguments)) { + parent::compile($compiler); + + return; + } + + $value = $firstArgument->getAttribute('value'); + + if (!\is_string($value)) { + throw new SyntaxError('The first argument of the "enum" function must be a string.', $this->getTemplateLine(), $this->getSourceContext()); + } + + if (!enum_exists($value)) { + throw new SyntaxError(\sprintf('The first argument of the "enum" function must be the name of an enum, "%s" given.', $value), $this->getTemplateLine(), $this->getSourceContext()); + } + + if (!$cases = $value::cases()) { + throw new SyntaxError(\sprintf('The first argument of the "enum" function must be a non-empty enum, "%s" given.', $value), $this->getTemplateLine(), $this->getSourceContext()); + } + + $compiler->raw(\sprintf('%s::%s', $value, $cases[0]->name)); + } +} diff --git a/src/Node/Expression/GetAttrExpression.php b/src/Node/Expression/GetAttrExpression.php index e6a75ce9404..1222d77599b 100644 --- a/src/Node/Expression/GetAttrExpression.php +++ b/src/Node/Expression/GetAttrExpression.php @@ -14,10 +14,17 @@ use Twig\Compiler; use Twig\Extension\SandboxExtension; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Template; -class GetAttrExpression extends AbstractExpression +class GetAttrExpression extends AbstractExpression implements SupportDefinedTestInterface { + use SupportDefinedTestDeprecationTrait; + use SupportDefinedTestTrait; + + /** + * @param ArrayExpression|NameExpression|null $arguments + */ public function __construct(AbstractExpression $node, AbstractExpression $attribute, ?AbstractExpression $arguments, string $type, int $lineno) { $nodes = ['node' => $node, 'attribute' => $attribute]; @@ -25,18 +32,29 @@ public function __construct(AbstractExpression $node, AbstractExpression $attrib $nodes['arguments'] = $arguments; } - parent::__construct($nodes, ['type' => $type, 'is_defined_test' => false, 'ignore_strict_check' => false, 'optimizable' => true], $lineno); + if ($arguments && !$arguments instanceof ArrayExpression && !$arguments instanceof ContextVariable) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Not passing a "%s" instance as the "arguments" argument of the "%s" constructor is deprecated ("%s" given).', ArrayExpression::class, static::class, $arguments::class)); + } + + parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => true], $lineno); + } + + public function enableDefinedTest(): void + { + $this->definedTest = true; + $this->changeIgnoreStrictCheck($this); } public function compile(Compiler $compiler): void { $env = $compiler->getEnvironment(); + $arrayAccessSandbox = false; // optimize array calls if ( $this->getAttribute('optimizable') && (!$env->isStrictVariables() || $this->getAttribute('ignore_strict_check')) - && !$this->getAttribute('is_defined_test') + && !$this->definedTest && Template::ARRAY_CALL === $this->getAttribute('type') ) { $var = '$'.$compiler->getVarName(); @@ -44,20 +62,38 @@ public function compile(Compiler $compiler): void ->raw('(('.$var.' = ') ->subcompile($this->getNode('node')) ->raw(') && is_array(') - ->raw($var) + ->raw($var); + + if (!$env->hasExtension(SandboxExtension::class)) { + $compiler + ->raw(') || ') + ->raw($var) + ->raw(' instanceof ArrayAccess ? (') + ->raw($var) + ->raw('[') + ->subcompile($this->getNode('attribute')) + ->raw('] ?? null) : null)') + ; + + return; + } + + $arrayAccessSandbox = true; + + $compiler ->raw(') || ') ->raw($var) - ->raw(' instanceof ArrayAccess ? (') + ->raw(' instanceof ArrayAccess && in_array(') + ->raw($var.'::class') + ->raw(', CoreExtension::ARRAY_LIKE_CLASSES, true) ? (') ->raw($var) ->raw('[') ->subcompile($this->getNode('attribute')) - ->raw('] ?? null) : null)') + ->raw('] ?? null) : ') ; - - return; } - $compiler->raw('twig_get_attribute($this->env, $this->source, '); + $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); if ($this->getAttribute('ignore_strict_check')) { $this->getNode('node')->setAttribute('ignore_strict_check', true); @@ -77,11 +113,25 @@ public function compile(Compiler $compiler): void $compiler->raw(', ') ->repr($this->getAttribute('type')) - ->raw(', ')->repr($this->getAttribute('is_defined_test')) + ->raw(', ')->repr($this->definedTest) ->raw(', ')->repr($this->getAttribute('ignore_strict_check')) ->raw(', ')->repr($env->hasExtension(SandboxExtension::class)) ->raw(', ')->repr($this->getNode('node')->getTemplateLine()) ->raw(')') ; + + if ($arrayAccessSandbox) { + $compiler->raw(')'); + } + } + + private function changeIgnoreStrictCheck(self $node): void + { + $node->setAttribute('optimizable', false); + $node->setAttribute('ignore_strict_check', true); + + if ($node->getNode('node') instanceof self) { + $this->changeIgnoreStrictCheck($node->getNode('node')); + } } } diff --git a/src/Node/Expression/InlinePrint.php b/src/Node/Expression/InlinePrint.php index 1ad4751e462..5509f7942b1 100644 --- a/src/Node/Expression/InlinePrint.php +++ b/src/Node/Expression/InlinePrint.php @@ -19,17 +19,21 @@ */ final class InlinePrint extends AbstractExpression { + /** + * @param AbstractExpression $node + */ public function __construct(Node $node, int $lineno) { + trigger_deprecation('twig/twig', '3.16', \sprintf('The "%s" class is deprecated with no replacement.', static::class)); + parent::__construct(['node' => $node], [], $lineno); } public function compile(Compiler $compiler): void { $compiler - ->raw('print (') + ->raw('yield ') ->subcompile($this->getNode('node')) - ->raw(')') ; } } diff --git a/src/Node/Expression/ListExpression.php b/src/Node/Expression/ListExpression.php new file mode 100644 index 00000000000..dd7fc1f9cd5 --- /dev/null +++ b/src/Node/Expression/ListExpression.php @@ -0,0 +1,41 @@ + $items + */ + public function __construct(array $items, int $lineno) + { + parent::__construct($items, [], $lineno); + } + + public function compile(Compiler $compiler): void + { + foreach ($this as $i => $name) { + if ($i) { + $compiler->raw(', '); + } + + $compiler + ->raw('$__') + ->raw($name->getAttribute('name')) + ->raw('__') + ; + } + } +} diff --git a/src/Node/Expression/MacroReferenceExpression.php b/src/Node/Expression/MacroReferenceExpression.php new file mode 100644 index 00000000000..fd7f1e733a9 --- /dev/null +++ b/src/Node/Expression/MacroReferenceExpression.php @@ -0,0 +1,59 @@ + + */ +class MacroReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface +{ + use SupportDefinedTestDeprecationTrait; + use SupportDefinedTestTrait; + + public function __construct(TemplateVariable $template, string $name, AbstractExpression $arguments, int $lineno) + { + parent::__construct(['template' => $template, 'arguments' => $arguments], ['name' => $name], $lineno); + } + + public function compile(Compiler $compiler): void + { + if ($this->definedTest) { + $compiler + ->subcompile($this->getNode('template')) + ->raw('->hasMacro(') + ->repr($this->getAttribute('name')) + ->raw(', $context') + ->raw(')') + ; + + return; + } + + $compiler + ->subcompile($this->getNode('template')) + ->raw('->getTemplateForMacro(') + ->repr($this->getAttribute('name')) + ->raw(', $context, ') + ->repr($this->getTemplateLine()) + ->raw(', $this->getSourceContext())') + ->raw(\sprintf('->%s', $this->getAttribute('name'))) + ->raw('(...') + ->subcompile($this->getNode('arguments')) + ->raw(')') + ; + } +} diff --git a/src/Node/Expression/MethodCallExpression.php b/src/Node/Expression/MethodCallExpression.php index d5ec0b6efcb..4b180534df9 100644 --- a/src/Node/Expression/MethodCallExpression.php +++ b/src/Node/Expression/MethodCallExpression.php @@ -12,21 +12,27 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Node\Expression\Variable\ContextVariable; -class MethodCallExpression extends AbstractExpression +class MethodCallExpression extends AbstractExpression implements SupportDefinedTestInterface { + use SupportDefinedTestDeprecationTrait; + use SupportDefinedTestTrait; + public function __construct(AbstractExpression $node, string $method, ArrayExpression $arguments, int $lineno) { - parent::__construct(['node' => $node, 'arguments' => $arguments], ['method' => $method, 'safe' => false, 'is_defined_test' => false], $lineno); + trigger_deprecation('twig/twig', '3.15', 'The "%s" class is deprecated, use "%s" instead.', __CLASS__, MacroReferenceExpression::class); + + parent::__construct(['node' => $node, 'arguments' => $arguments], ['method' => $method, 'safe' => false], $lineno); - if ($node instanceof NameExpression) { + if ($node instanceof ContextVariable) { $node->setAttribute('always_defined', true); } } public function compile(Compiler $compiler): void { - if ($this->getAttribute('is_defined_test')) { + if ($this->definedTest) { $compiler ->raw('method_exists($macros[') ->repr($this->getNode('node')->getAttribute('name')) @@ -39,23 +45,13 @@ public function compile(Compiler $compiler): void } $compiler - ->raw('twig_call_macro($macros[') + ->raw('CoreExtension::callMacro($macros[') ->repr($this->getNode('node')->getAttribute('name')) ->raw('], ') ->repr($this->getAttribute('method')) - ->raw(', [') - ; - $first = true; - foreach ($this->getNode('arguments')->getKeyValuePairs() as $pair) { - if (!$first) { - $compiler->raw(', '); - } - $first = false; - - $compiler->subcompile($pair['value']); - } - $compiler - ->raw('], ') + ->raw(', ') + ->subcompile($this->getNode('arguments')) + ->raw(', ') ->repr($this->getTemplateLine()) ->raw(', $context, $this->getSourceContext())'); } diff --git a/src/Node/Expression/NameExpression.php b/src/Node/Expression/NameExpression.php index c3563f01238..0e036742086 100644 --- a/src/Node/Expression/NameExpression.php +++ b/src/Node/Expression/NameExpression.php @@ -13,9 +13,13 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Node\Expression\Variable\ContextVariable; -class NameExpression extends AbstractExpression +class NameExpression extends AbstractExpression implements SupportDefinedTestInterface { + use SupportDefinedTestDeprecationTrait; + use SupportDefinedTestTrait; + private $specialVars = [ '_self' => '$this->getTemplateName()', '_context' => '$context', @@ -24,7 +28,11 @@ class NameExpression extends AbstractExpression public function __construct(string $name, int $lineno) { - parent::__construct([], ['name' => $name, 'is_defined_test' => false, 'ignore_strict_check' => false, 'always_defined' => false], $lineno); + if (self::class === static::class) { + trigger_deprecation('twig/twig', '3.15', 'The "%s" class is deprecated, use "%s" instead.', self::class, ContextVariable::class); + } + + parent::__construct([], ['name' => $name, 'ignore_strict_check' => false, 'always_defined' => false], $lineno); } public function compile(Compiler $compiler): void @@ -33,8 +41,8 @@ public function compile(Compiler $compiler): void $compiler->addDebugInfo($this); - if ($this->getAttribute('is_defined_test')) { - if ($this->isSpecial()) { + if ($this->definedTest) { + if (isset($this->specialVars[$name]) || $this->getAttribute('always_defined')) { $compiler->repr(true); } elseif (\PHP_VERSION_ID >= 70400) { $compiler @@ -51,7 +59,7 @@ public function compile(Compiler $compiler): void ->raw(', $context))') ; } - } elseif ($this->isSpecial()) { + } elseif (isset($this->specialVars[$name])) { $compiler->raw($this->specialVars[$name]); } elseif ($this->getAttribute('always_defined')) { $compiler @@ -85,13 +93,23 @@ public function compile(Compiler $compiler): void } } + /** + * @deprecated since Twig 3.11 (to be removed in 4.0) + */ public function isSpecial() { + trigger_deprecation('twig/twig', '3.11', 'The "%s()" method is deprecated and will be removed in Twig 4.0.', __METHOD__); + return isset($this->specialVars[$this->getAttribute('name')]); } + /** + * @deprecated since Twig 3.11 (to be removed in 4.0) + */ public function isSimple() { - return !$this->isSpecial() && !$this->getAttribute('is_defined_test'); + trigger_deprecation('twig/twig', '3.11', 'The "%s()" method is deprecated and will be removed in Twig 4.0.', __METHOD__); + + return !isset($this->specialVars[$this->getAttribute('name')]) && !$this->definedTest; } } diff --git a/src/Node/Expression/NullCoalesceExpression.php b/src/Node/Expression/NullCoalesceExpression.php index a72bc4fc654..f397f71f039 100644 --- a/src/Node/Expression/NullCoalesceExpression.php +++ b/src/Node/Expression/NullCoalesceExpression.php @@ -12,22 +12,39 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Node\EmptyNode; use Twig\Node\Expression\Binary\AndBinary; +use Twig\Node\Expression\Binary\NullCoalesceBinary; use Twig\Node\Expression\Test\DefinedTest; use Twig\Node\Expression\Test\NullTest; use Twig\Node\Expression\Unary\NotUnary; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; +use Twig\TwigTest; class NullCoalesceExpression extends ConditionalExpression { + /** + * @param AbstractExpression $left + * @param AbstractExpression $right + */ public function __construct(Node $left, Node $right, int $lineno) { - $test = new DefinedTest(clone $left, 'defined', new Node(), $left->getTemplateLine()); + trigger_deprecation('twig/twig', '3.17', \sprintf('"%s" is deprecated; use "%s" instead.', __CLASS__, NullCoalesceBinary::class)); + + if (!$left instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "left" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $left::class); + } + if (!$right instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "right" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $right::class); + } + + $test = new DefinedTest(clone $left, new TwigTest('defined'), new EmptyNode(), $left->getTemplateLine()); // for "block()", we don't need the null test as the return value is always a string if (!$left instanceof BlockReferenceExpression) { $test = new AndBinary( $test, - new NotUnary(new NullTest($left, 'null', new Node(), $left->getTemplateLine()), $left->getTemplateLine()), + new NotUnary(new NullTest($left, new TwigTest('null'), new EmptyNode(), $left->getTemplateLine()), $left->getTemplateLine()), $left->getTemplateLine() ); } @@ -44,7 +61,7 @@ public function compile(Compiler $compiler): void * cases might be implemented as an optimizer node visitor, but has not been done * as benefits are probably not worth the added complexity. */ - if ($this->getNode('expr2') instanceof NameExpression) { + if ($this->getNode('expr2') instanceof ContextVariable) { $this->getNode('expr2')->setAttribute('always_defined', true); $compiler ->raw('((') diff --git a/src/Node/Expression/OperatorEscapeInterface.php b/src/Node/Expression/OperatorEscapeInterface.php new file mode 100644 index 00000000000..06db6c61657 --- /dev/null +++ b/src/Node/Expression/OperatorEscapeInterface.php @@ -0,0 +1,25 @@ + 1. + * + * @author Fabien Potencier + */ +interface OperatorEscapeInterface +{ + /** + * @return string[] + */ + public function getOperandNamesToEscape(): array; +} diff --git a/src/Node/Expression/ParentExpression.php b/src/Node/Expression/ParentExpression.php index 25491971841..22fe38f6a2d 100644 --- a/src/Node/Expression/ParentExpression.php +++ b/src/Node/Expression/ParentExpression.php @@ -21,9 +21,9 @@ */ class ParentExpression extends AbstractExpression { - public function __construct(string $name, int $lineno, string $tag = null) + public function __construct(string $name, int $lineno) { - parent::__construct([], ['output' => false, 'name' => $name], $lineno, $tag); + parent::__construct([], ['output' => false, 'name' => $name], $lineno); } public function compile(Compiler $compiler): void @@ -31,7 +31,7 @@ public function compile(Compiler $compiler): void if ($this->getAttribute('output')) { $compiler ->addDebugInfo($this) - ->write('$this->displayParentBlock(') + ->write('yield from $this->yieldParentBlock(') ->string($this->getAttribute('name')) ->raw(", \$context, \$blocks);\n") ; diff --git a/src/Node/Expression/ReturnArrayInterface.php b/src/Node/Expression/ReturnArrayInterface.php new file mode 100644 index 00000000000..a74864b5dbf --- /dev/null +++ b/src/Node/Expression/ReturnArrayInterface.php @@ -0,0 +1,16 @@ + + */ +trait SupportDefinedTestDeprecationTrait +{ + public function getAttribute($name, $default = null) + { + if ('is_defined_test' === $name) { + trigger_deprecation('twig/twig', '3.21', 'The "is_defined_test" attribute is deprecated, call "isDefinedTestEnabled()" instead.'); + + return $this->isDefinedTestEnabled(); + } + + return parent::getAttribute($name, $default); + } + + public function setAttribute(string $name, $value): void + { + if ('is_defined_test' === $name) { + trigger_deprecation('twig/twig', '3.21', 'The "is_defined_test" attribute is deprecated, call "enableDefinedTest()" instead.'); + + $this->definedTest = (bool) $value; + } else { + parent::setAttribute($name, $value); + } + } +} diff --git a/src/Node/Expression/SupportDefinedTestInterface.php b/src/Node/Expression/SupportDefinedTestInterface.php new file mode 100644 index 00000000000..450c691c59e --- /dev/null +++ b/src/Node/Expression/SupportDefinedTestInterface.php @@ -0,0 +1,24 @@ + + */ +interface SupportDefinedTestInterface +{ + public function enableDefinedTest(): void; + + public function isDefinedTestEnabled(): bool; +} diff --git a/src/Node/Expression/SupportDefinedTestTrait.php b/src/Node/Expression/SupportDefinedTestTrait.php new file mode 100644 index 00000000000..4cf1a58d537 --- /dev/null +++ b/src/Node/Expression/SupportDefinedTestTrait.php @@ -0,0 +1,27 @@ +definedTest = true; + } + + public function isDefinedTestEnabled(): bool + { + return $this->definedTest; + } +} diff --git a/src/Node/Expression/TempNameExpression.php b/src/Node/Expression/TempNameExpression.php index 004c704a588..f996aab05de 100644 --- a/src/Node/Expression/TempNameExpression.php +++ b/src/Node/Expression/TempNameExpression.php @@ -12,20 +12,38 @@ namespace Twig\Node\Expression; use Twig\Compiler; +use Twig\Error\SyntaxError; class TempNameExpression extends AbstractExpression { - public function __construct(string $name, int $lineno) + public const RESERVED_NAMES = ['varargs', 'context', 'macros', 'blocks', 'this']; + + public function __construct(string|int|null $name, int $lineno) { + // All names supported by ExpressionParser::parsePrimaryExpression() should be excluded + if ($name && \in_array(strtolower($name), ['true', 'false', 'none', 'null'], true)) { + throw new SyntaxError(\sprintf('You cannot assign a value to "%s".', $name), $lineno); + } + + if (self::class === static::class) { + trigger_deprecation('twig/twig', '3.15', 'The "%s" class is deprecated.', self::class); + } + + if (null !== $name && (\is_int($name) || ctype_digit($name))) { + $name = (int) $name; + } elseif (\in_array($name, self::RESERVED_NAMES, true)) { + $name = "\u{035C}".$name; + } + parent::__construct([], ['name' => $name], $lineno); } public function compile(Compiler $compiler): void { - $compiler - ->raw('$_') - ->raw($this->getAttribute('name')) - ->raw('_') - ; + if (null === $this->getAttribute('name')) { + $this->setAttribute('name', $compiler->getVarName()); + } + + $compiler->raw('$'.$this->getAttribute('name')); } } diff --git a/src/Node/Expression/Ternary/ConditionalTernary.php b/src/Node/Expression/Ternary/ConditionalTernary.php new file mode 100644 index 00000000000..f7cd78c5c6f --- /dev/null +++ b/src/Node/Expression/Ternary/ConditionalTernary.php @@ -0,0 +1,49 @@ +getTemplateLine()); + } + + parent::__construct(['test' => $test, 'left' => $left, 'right' => $right], [], $lineno); + } + + public function compile(Compiler $compiler): void + { + $compiler + ->raw('((') + ->subcompile($this->getNode('test')) + ->raw(') ? (') + ->subcompile($this->getNode('left')) + ->raw(') : (') + ->subcompile($this->getNode('right')) + ->raw('))') + ; + } + + public function getOperandNamesToEscape(): array + { + return ['left', 'right']; + } +} diff --git a/src/Node/Expression/Test/ConstantTest.php b/src/Node/Expression/Test/ConstantTest.php index 57e9319d574..867fd09517c 100644 --- a/src/Node/Expression/Test/ConstantTest.php +++ b/src/Node/Expression/Test/ConstantTest.php @@ -33,16 +33,16 @@ public function compile(Compiler $compiler): void ->raw(' === constant(') ; - if ($this->getNode('arguments')->hasNode(1)) { + if ($this->getNode('arguments')->hasNode('1')) { $compiler ->raw('get_class(') - ->subcompile($this->getNode('arguments')->getNode(1)) + ->subcompile($this->getNode('arguments')->getNode('1')) ->raw(')."::".') ; } $compiler - ->subcompile($this->getNode('arguments')->getNode(0)) + ->subcompile($this->getNode('arguments')->getNode('0')) ->raw('))') ; } diff --git a/src/Node/Expression/Test/DefinedTest.php b/src/Node/Expression/Test/DefinedTest.php index 3953bbbe2cc..d735029901f 100644 --- a/src/Node/Expression/Test/DefinedTest.php +++ b/src/Node/Expression/Test/DefinedTest.php @@ -11,17 +11,14 @@ namespace Twig\Node\Expression\Test; +use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; use Twig\Error\SyntaxError; -use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\BlockReferenceExpression; -use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\FunctionExpression; -use Twig\Node\Expression\GetAttrExpression; -use Twig\Node\Expression\MethodCallExpression; -use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\AbstractExpression; +use Twig\Node\Expression\SupportDefinedTestInterface; use Twig\Node\Expression\TestExpression; use Twig\Node\Node; +use Twig\TwigTest; /** * Checks if a variable is defined in the current context. @@ -35,36 +32,27 @@ */ class DefinedTest extends TestExpression { - public function __construct(Node $node, string $name, ?Node $arguments, int $lineno) + /** + * @param AbstractExpression $node + */ + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigTest|string $name, ?Node $arguments, int $lineno) { - if ($node instanceof NameExpression) { - $node->setAttribute('is_defined_test', true); - } elseif ($node instanceof GetAttrExpression) { - $node->setAttribute('is_defined_test', true); - $this->changeIgnoreStrictCheck($node); - } elseif ($node instanceof BlockReferenceExpression) { - $node->setAttribute('is_defined_test', true); - } elseif ($node instanceof FunctionExpression && 'constant' === $node->getAttribute('name')) { - $node->setAttribute('is_defined_test', true); - } elseif ($node instanceof ConstantExpression || $node instanceof ArrayExpression) { - $node = new ConstantExpression(true, $node->getTemplateLine()); - } elseif ($node instanceof MethodCallExpression) { - $node->setAttribute('is_defined_test', true); - } else { - throw new SyntaxError('The "defined" test only works with simple variables.', $lineno); + if (!$node instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "node" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $node::class); } - parent::__construct($node, $name, $arguments, $lineno); - } + if (!$node instanceof SupportDefinedTestInterface) { + throw new SyntaxError('The "defined" test only works with simple variables.', $lineno); + } - private function changeIgnoreStrictCheck(GetAttrExpression $node) - { - $node->setAttribute('optimizable', false); - $node->setAttribute('ignore_strict_check', true); + $node->enableDefinedTest(); - if ($node->getNode('node') instanceof GetAttrExpression) { - $this->changeIgnoreStrictCheck($node->getNode('node')); + if (\is_string($name) && 'defined' !== $name) { + trigger_deprecation('twig/twig', '3.12', 'Creating a "DefinedTest" instance with a test name that is not "defined" is deprecated.'); } + + parent::__construct($node, $name, $arguments, $lineno); } public function compile(Compiler $compiler): void diff --git a/src/Node/Expression/Test/DivisiblebyTest.php b/src/Node/Expression/Test/DivisiblebyTest.php index 4cb3ee09692..90d58a49a16 100644 --- a/src/Node/Expression/Test/DivisiblebyTest.php +++ b/src/Node/Expression/Test/DivisiblebyTest.php @@ -29,7 +29,7 @@ public function compile(Compiler $compiler): void ->raw('(0 == ') ->subcompile($this->getNode('node')) ->raw(' % ') - ->subcompile($this->getNode('arguments')->getNode(0)) + ->subcompile($this->getNode('arguments')->getNode('0')) ->raw(')') ; } diff --git a/src/Node/Expression/Test/NullTest.php b/src/Node/Expression/Test/NullTest.php index 45b54ae3709..be5d3889199 100644 --- a/src/Node/Expression/Test/NullTest.php +++ b/src/Node/Expression/Test/NullTest.php @@ -15,7 +15,7 @@ use Twig\Node\Expression\TestExpression; /** - * Checks that a variable is null. + * Checks that an expression is null. * * {{ var is none }} * diff --git a/src/Node/Expression/Test/SameasTest.php b/src/Node/Expression/Test/SameasTest.php index c96d2bc01a3..f1e24db6f7e 100644 --- a/src/Node/Expression/Test/SameasTest.php +++ b/src/Node/Expression/Test/SameasTest.php @@ -27,7 +27,7 @@ public function compile(Compiler $compiler): void ->raw('(') ->subcompile($this->getNode('node')) ->raw(' === ') - ->subcompile($this->getNode('arguments')->getNode(0)) + ->subcompile($this->getNode('arguments')->getNode('0')) ->raw(')') ; } diff --git a/src/Node/Expression/Test/TrueTest.php b/src/Node/Expression/Test/TrueTest.php new file mode 100644 index 00000000000..22186a6898a --- /dev/null +++ b/src/Node/Expression/Test/TrueTest.php @@ -0,0 +1,34 @@ + + */ +class TrueTest extends TestExpression +{ + public function compile(Compiler $compiler): void + { + $compiler + ->raw('(($tmp = ') + ->subcompile($this->getNode('node')) + ->raw(') && $tmp instanceof Markup ? (string) $tmp : $tmp)') + ; + } +} diff --git a/src/Node/Expression/TestExpression.php b/src/Node/Expression/TestExpression.php index e518bd8f10b..3b51dd320d5 100644 --- a/src/Node/Expression/TestExpression.php +++ b/src/Node/Expression/TestExpression.php @@ -11,31 +11,62 @@ namespace Twig\Node\Expression; +use Twig\Attribute\FirstClassTwigCallableReady; use Twig\Compiler; +use Twig\Node\NameDeprecation; use Twig\Node\Node; +use Twig\TwigTest; -class TestExpression extends CallExpression +class TestExpression extends CallExpression implements ReturnBoolInterface { - public function __construct(Node $node, string $name, ?Node $arguments, int $lineno) + #[FirstClassTwigCallableReady] + /** + * @param AbstractExpression $node + */ + public function __construct(Node $node, string|TwigTest $test, ?Node $arguments, int $lineno) { + if (!$node instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "node" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $node::class); + } + $nodes = ['node' => $node]; if (null !== $arguments) { $nodes['arguments'] = $arguments; } - parent::__construct($nodes, ['name' => $name], $lineno); + if ($test instanceof TwigTest) { + $name = $test->getName(); + } else { + $name = $test; + trigger_deprecation('twig/twig', '3.12', 'Not passing an instance of "TwigTest" when creating a "%s" test of type "%s" is deprecated.', $name, static::class); + } + + parent::__construct($nodes, ['name' => $name, 'type' => 'test'], $lineno); + + if ($test instanceof TwigTest) { + $this->setAttribute('twig_callable', $test); + } + + $this->deprecateAttribute('arguments', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('callable', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('is_variadic', new NameDeprecation('twig/twig', '3.12')); + $this->deprecateAttribute('dynamic_name', new NameDeprecation('twig/twig', '3.12')); } public function compile(Compiler $compiler): void { $name = $this->getAttribute('name'); - $test = $compiler->getEnvironment()->getTest($name); + if ($this->hasAttribute('twig_callable')) { + $name = $this->getAttribute('twig_callable')->getName(); + if ($name !== $this->getAttribute('name')) { + trigger_deprecation('twig/twig', '3.12', 'Changing the value of a "test" node in a NodeVisitor class is not supported anymore.'); + $this->removeAttribute('twig_callable'); + } + } - $this->setAttribute('name', $name); - $this->setAttribute('type', 'test'); - $this->setAttribute('arguments', $test->getArguments()); - $this->setAttribute('callable', $test->getCallable()); - $this->setAttribute('is_variadic', $test->isVariadic()); + if (!$this->hasAttribute('twig_callable')) { + $this->setAttribute('twig_callable', $compiler->getEnvironment()->getTest($this->getAttribute('name'))); + } $this->compileCallable($compiler); } diff --git a/src/Node/Expression/Unary/AbstractUnary.php b/src/Node/Expression/Unary/AbstractUnary.php index e31e3f84b07..09f3d0984a5 100644 --- a/src/Node/Expression/Unary/AbstractUnary.php +++ b/src/Node/Expression/Unary/AbstractUnary.php @@ -16,18 +16,32 @@ use Twig\Node\Expression\AbstractExpression; use Twig\Node\Node; -abstract class AbstractUnary extends AbstractExpression +abstract class AbstractUnary extends AbstractExpression implements UnaryInterface { + /** + * @param AbstractExpression $node + */ public function __construct(Node $node, int $lineno) { - parent::__construct(['node' => $node], [], $lineno); + if (!$node instanceof AbstractExpression) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance argument to "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $node::class); + } + + parent::__construct(['node' => $node], ['with_parentheses' => false], $lineno); } public function compile(Compiler $compiler): void { - $compiler->raw(' '); + if ($this->hasExplicitParentheses()) { + $compiler->raw('('); + } else { + $compiler->raw(' '); + } $this->operator($compiler); $compiler->subcompile($this->getNode('node')); + if ($this->hasExplicitParentheses()) { + $compiler->raw(')'); + } } abstract public function operator(Compiler $compiler): Compiler; diff --git a/src/Node/Expression/Unary/SpreadUnary.php b/src/Node/Expression/Unary/SpreadUnary.php new file mode 100644 index 00000000000..f99072c257f --- /dev/null +++ b/src/Node/Expression/Unary/SpreadUnary.php @@ -0,0 +1,22 @@ +raw('...'); + } +} diff --git a/src/Node/Expression/Unary/StringCastUnary.php b/src/Node/Expression/Unary/StringCastUnary.php new file mode 100644 index 00000000000..87ea17ca8ad --- /dev/null +++ b/src/Node/Expression/Unary/StringCastUnary.php @@ -0,0 +1,22 @@ +raw('(string)'); + } +} diff --git a/src/Node/Expression/Unary/UnaryInterface.php b/src/Node/Expression/Unary/UnaryInterface.php new file mode 100644 index 00000000000..b094ef4f4ce --- /dev/null +++ b/src/Node/Expression/Unary/UnaryInterface.php @@ -0,0 +1,22 @@ + $var], ['global' => $global], $var->getTemplateLine()); + } + + public function compile(Compiler $compiler): void + { + /** @var TemplateVariable $var */ + $var = $this->nodes['var']; + + $compiler + ->addDebugInfo($this) + ->write('$macros[') + ->string($var->getName($compiler)) + ->raw('] = ') + ; + + if ($this->getAttribute('global')) { + $compiler + ->raw('$this->macros[') + ->string($var->getName($compiler)) + ->raw('] = ') + ; + } + } +} diff --git a/src/Node/Expression/Variable/ContextVariable.php b/src/Node/Expression/Variable/ContextVariable.php new file mode 100644 index 00000000000..01bbcb71183 --- /dev/null +++ b/src/Node/Expression/Variable/ContextVariable.php @@ -0,0 +1,18 @@ +getAttribute('name')) { + $this->setAttribute('name', $compiler->getVarName()); + } + + return $this->getAttribute('name'); + } + + public function compile(Compiler $compiler): void + { + $name = $this->getName($compiler); + + if ('_self' === $name) { + $compiler->raw('$this'); + } else { + $compiler + ->raw('$macros[') + ->string($name) + ->raw(']') + ; + } + } +} diff --git a/src/Node/FlushNode.php b/src/Node/FlushNode.php index fa50a88ee56..ff3bd1cf194 100644 --- a/src/Node/FlushNode.php +++ b/src/Node/FlushNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -18,18 +19,22 @@ * * @author Fabien Potencier */ +#[YieldReady] class FlushNode extends Node { - public function __construct(int $lineno, string $tag) + public function __construct(int $lineno) { - parent::__construct([], [], $lineno, $tag); + parent::__construct([], [], $lineno); } public function compile(Compiler $compiler): void { - $compiler - ->addDebugInfo($this) - ->write("flush();\n") - ; + $compiler->addDebugInfo($this); + + if ($compiler->getEnvironment()->useYield()) { + $compiler->write("yield '';\n"); + } + + $compiler->write("flush();\n"); } } diff --git a/src/Node/ForElseNode.php b/src/Node/ForElseNode.php new file mode 100644 index 00000000000..56d6646bf3b --- /dev/null +++ b/src/Node/ForElseNode.php @@ -0,0 +1,41 @@ + + */ +#[YieldReady] +class ForElseNode extends Node +{ + public function __construct(Node $body, int $lineno) + { + parent::__construct(['body' => $body], [], $lineno); + } + + public function compile(Compiler $compiler): void + { + $compiler + ->addDebugInfo($this) + ->write("if (!\$context['_iterated']) {\n") + ->indent() + ->subcompile($this->getNode('body')) + ->outdent() + ->write("}\n") + ; + } +} diff --git a/src/Node/ForLoopNode.php b/src/Node/ForLoopNode.php index d5ce845a791..1f0a4f32134 100644 --- a/src/Node/ForLoopNode.php +++ b/src/Node/ForLoopNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -18,11 +19,12 @@ * * @author Fabien Potencier */ +#[YieldReady] class ForLoopNode extends Node { - public function __construct(int $lineno, string $tag = null) + public function __construct(int $lineno) { - parent::__construct([], ['with_loop' => false, 'ifexpr' => false, 'else' => false], $lineno, $tag); + parent::__construct([], ['with_loop' => false, 'ifexpr' => false, 'else' => false], $lineno); } public function compile(Compiler $compiler): void @@ -36,7 +38,7 @@ public function compile(Compiler $compiler): void ->write("++\$context['loop']['index0'];\n") ->write("++\$context['loop']['index'];\n") ->write("\$context['loop']['first'] = false;\n") - ->write("if (isset(\$context['loop']['length'])) {\n") + ->write("if (isset(\$context['loop']['revindex0'], \$context['loop']['revindex'])) {\n") ->indent() ->write("--\$context['loop']['revindex0'];\n") ->write("--\$context['loop']['revindex'];\n") diff --git a/src/Node/ForNode.php b/src/Node/ForNode.php index 04addfbfe58..2c86622d473 100644 --- a/src/Node/ForNode.php +++ b/src/Node/ForNode.php @@ -12,29 +12,41 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; -use Twig\Node\Expression\AssignNameExpression; +use Twig\Node\Expression\Variable\AssignContextVariable; /** * Represents a for node. * * @author Fabien Potencier */ +#[YieldReady] class ForNode extends Node { private $loop; - public function __construct(AssignNameExpression $keyTarget, AssignNameExpression $valueTarget, AbstractExpression $seq, ?Node $ifexpr, Node $body, ?Node $else, int $lineno, string $tag = null) + public function __construct(AssignContextVariable $keyTarget, AssignContextVariable $valueTarget, AbstractExpression $seq, ?Node $ifexpr, Node $body, ?Node $else, int $lineno) { - $body = new Node([$body, $this->loop = new ForLoopNode($lineno, $tag)]); + $body = new Nodes([$body, $this->loop = new ForLoopNode($lineno)]); + + if (null !== $ifexpr) { + trigger_deprecation('twig/twig', '3.19', \sprintf('Passing not-null to the "ifexpr" argument of the "%s" constructor is deprecated.', static::class)); + } + + if (null !== $else && !$else instanceof ForElseNode) { + trigger_deprecation('twig/twig', '3.19', \sprintf('Not passing an instance of "%s" to the "else" argument of the "%s" constructor is deprecated.', ForElseNode::class, static::class)); + + $else = new ForElseNode($else, $else->getTemplateLine()); + } $nodes = ['key_target' => $keyTarget, 'value_target' => $valueTarget, 'seq' => $seq, 'body' => $body]; if (null !== $else) { $nodes['else'] = $else; } - parent::__construct($nodes, ['with_loop' => true], $lineno, $tag); + parent::__construct($nodes, ['with_loop' => true], $lineno); } public function compile(Compiler $compiler): void @@ -42,7 +54,7 @@ public function compile(Compiler $compiler): void $compiler ->addDebugInfo($this) ->write("\$context['_parent'] = \$context;\n") - ->write("\$context['_seq'] = twig_ensure_traversable(") + ->write("\$context['_seq'] = CoreExtension::ensureTraversable(") ->subcompile($this->getNode('seq')) ->raw(");\n") ; @@ -87,19 +99,20 @@ public function compile(Compiler $compiler): void ; if ($this->hasNode('else')) { - $compiler - ->write("if (!\$context['_iterated']) {\n") - ->indent() - ->subcompile($this->getNode('else')) - ->outdent() - ->write("}\n") - ; + $compiler->subcompile($this->getNode('else')); } $compiler->write("\$_parent = \$context['_parent'];\n"); // remove some "private" loop variables (needed for nested loops) - $compiler->write('unset($context[\'_seq\'], $context[\'_iterated\'], $context[\''.$this->getNode('key_target')->getAttribute('name').'\'], $context[\''.$this->getNode('value_target')->getAttribute('name').'\'], $context[\'_parent\'], $context[\'loop\']);'."\n"); + $compiler->write('unset($context[\'_seq\'], $context[\''.$this->getNode('key_target')->getAttribute('name').'\'], $context[\''.$this->getNode('value_target')->getAttribute('name').'\'], $context[\'_parent\']'); + if ($this->hasNode('else')) { + $compiler->raw(', $context[\'_iterated\']'); + } + if ($this->getAttribute('with_loop')) { + $compiler->raw(', $context[\'loop\']'); + } + $compiler->raw(");\n"); // keep the values set in the inner context for variables defined in the outer context $compiler->write("\$context = array_intersect_key(\$context, \$_parent) + \$_parent;\n"); diff --git a/src/Node/IfNode.php b/src/Node/IfNode.php index 5fa20082a74..2c0e2a8e91b 100644 --- a/src/Node/IfNode.php +++ b/src/Node/IfNode.php @@ -12,23 +12,34 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; +use Twig\Node\Expression\ReturnPrimitiveTypeInterface; +use Twig\Node\Expression\Test\TrueTest; +use Twig\TwigTest; /** * Represents an if node. * * @author Fabien Potencier */ +#[YieldReady] class IfNode extends Node { - public function __construct(Node $tests, ?Node $else, int $lineno, string $tag = null) + public function __construct(Node $tests, ?Node $else, int $lineno) { + for ($i = 0, $count = \count($tests); $i < $count; $i += 2) { + $test = $tests->getNode((string) $i); + if (!$test instanceof ReturnPrimitiveTypeInterface) { + $tests->setNode($i, new TrueTest($test, new TwigTest('true'), null, $test->getTemplateLine())); + } + } $nodes = ['tests' => $tests]; if (null !== $else) { $nodes['else'] = $else; } - parent::__construct($nodes, [], $lineno, $tag); + parent::__construct($nodes, [], $lineno); } public function compile(Compiler $compiler): void @@ -47,11 +58,14 @@ public function compile(Compiler $compiler): void } $compiler - ->subcompile($this->getNode('tests')->getNode($i)) + ->subcompile($this->getNode('tests')->getNode((string) $i)) ->raw(") {\n") ->indent() - ->subcompile($this->getNode('tests')->getNode($i + 1)) ; + // The node might not exists if the content is empty + if ($this->getNode('tests')->hasNode((string) ($i + 1))) { + $compiler->subcompile($this->getNode('tests')->getNode((string) ($i + 1))); + } } if ($this->hasNode('else')) { diff --git a/src/Node/ImportNode.php b/src/Node/ImportNode.php index 5378d799e28..92bdd5ebf84 100644 --- a/src/Node/ImportNode.php +++ b/src/Node/ImportNode.php @@ -11,48 +11,46 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; -use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\Variable\AssignTemplateVariable; +use Twig\Node\Expression\Variable\ContextVariable; /** * Represents an import node. * * @author Fabien Potencier */ +#[YieldReady] class ImportNode extends Node { - public function __construct(AbstractExpression $expr, AbstractExpression $var, int $lineno, string $tag = null, bool $global = true) + public function __construct(AbstractExpression $expr, AbstractExpression|AssignTemplateVariable $var, int $lineno) { - parent::__construct(['expr' => $expr, 'var' => $var], ['global' => $global], $lineno, $tag); + if (\func_num_args() > 3) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Passing more than 3 arguments to "%s()" is deprecated.', __METHOD__)); + } + + if (!$var instanceof AssignTemplateVariable) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Passing a "%s" instance as the second argument of "%s" is deprecated, pass a "%s" instead.', $var::class, __CLASS__, AssignTemplateVariable::class)); + + $var = new AssignTemplateVariable($var->getAttribute('name'), $lineno); + } + + parent::__construct(['expr' => $expr, 'var' => $var], [], $lineno); } public function compile(Compiler $compiler): void { - $compiler - ->addDebugInfo($this) - ->write('$macros[') - ->repr($this->getNode('var')->getAttribute('name')) - ->raw('] = ') - ; - - if ($this->getAttribute('global')) { - $compiler - ->raw('$this->macros[') - ->repr($this->getNode('var')->getAttribute('name')) - ->raw('] = ') - ; - } + $compiler->subcompile($this->getNode('var')); - if ($this->getNode('expr') instanceof NameExpression && '_self' === $this->getNode('expr')->getAttribute('name')) { + if ($this->getNode('expr') instanceof ContextVariable && '_self' === $this->getNode('expr')->getAttribute('name')) { $compiler->raw('$this'); } else { $compiler - ->raw('$this->loadTemplate(') + ->raw('$this->load(') ->subcompile($this->getNode('expr')) ->raw(', ') - ->repr($this->getTemplateName()) - ->raw(', ') ->repr($this->getTemplateLine()) ->raw(')->unwrap()') ; diff --git a/src/Node/IncludeNode.php b/src/Node/IncludeNode.php index d540d6b23bf..6e17300f075 100644 --- a/src/Node/IncludeNode.php +++ b/src/Node/IncludeNode.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; @@ -20,16 +21,17 @@ * * @author Fabien Potencier */ +#[YieldReady] class IncludeNode extends Node implements NodeOutputInterface { - public function __construct(AbstractExpression $expr, ?AbstractExpression $variables, bool $only, bool $ignoreMissing, int $lineno, string $tag = null) + public function __construct(AbstractExpression $expr, ?AbstractExpression $variables, bool $only, bool $ignoreMissing, int $lineno) { $nodes = ['expr' => $expr]; if (null !== $variables) { $nodes['variables'] = $variables; } - parent::__construct($nodes, ['only' => $only, 'ignore_missing' => $ignoreMissing], $lineno, $tag); + parent::__construct($nodes, ['only' => $only, 'ignore_missing' => $ignoreMissing], $lineno); } public function compile(Compiler $compiler): void @@ -40,13 +42,12 @@ public function compile(Compiler $compiler): void $template = $compiler->getVarName(); $compiler - ->write(sprintf("$%s = null;\n", $template)) ->write("try {\n") ->indent() - ->write(sprintf('$%s = ', $template)) + ->write(\sprintf('$%s = ', $template)) ; - $this->addGetTemplate($compiler); + $this->addGetTemplate($compiler, $template); $compiler ->raw(";\n") @@ -54,12 +55,14 @@ public function compile(Compiler $compiler): void ->write("} catch (LoaderError \$e) {\n") ->indent() ->write("// ignore missing template\n") + ->write(\sprintf("\$$template = null;\n", $template)) ->outdent() ->write("}\n") - ->write(sprintf("if ($%s) {\n", $template)) + ->write(\sprintf("if ($%s) {\n", $template)) ->indent() - ->write(sprintf('$%s->display(', $template)) + ->write(\sprintf('yield from $%s->unwrap()->yield(', $template)) ; + $this->addTemplateArguments($compiler); $compiler ->raw(");\n") @@ -67,38 +70,43 @@ public function compile(Compiler $compiler): void ->write("}\n") ; } else { + $compiler->write('yield from '); $this->addGetTemplate($compiler); - $compiler->raw('->display('); + $compiler->raw('->unwrap()->yield('); $this->addTemplateArguments($compiler); $compiler->raw(");\n"); } } - protected function addGetTemplate(Compiler $compiler) + /** + * @return void + */ + protected function addGetTemplate(Compiler $compiler/* , string $template = '' */) { $compiler - ->write('$this->loadTemplate(') + ->raw('$this->load(') ->subcompile($this->getNode('expr')) ->raw(', ') - ->repr($this->getTemplateName()) - ->raw(', ') ->repr($this->getTemplateLine()) ->raw(')') ; } + /** + * @return void + */ protected function addTemplateArguments(Compiler $compiler) { if (!$this->hasNode('variables')) { $compiler->raw(false === $this->getAttribute('only') ? '$context' : '[]'); } elseif (false === $this->getAttribute('only')) { $compiler - ->raw('twig_array_merge($context, ') + ->raw('CoreExtension::merge($context, ') ->subcompile($this->getNode('variables')) ->raw(')') ; } else { - $compiler->raw('twig_to_array('); + $compiler->raw('CoreExtension::toArray('); $compiler->subcompile($this->getNode('variables')); $compiler->raw(')'); } diff --git a/src/Node/MacroNode.php b/src/Node/MacroNode.php index 7f1b24d5372..db3ca458c88 100644 --- a/src/Node/MacroNode.php +++ b/src/Node/MacroNode.php @@ -11,101 +11,109 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Error\SyntaxError; +use Twig\Node\Expression\ArrayExpression; +use Twig\Node\Expression\Variable\LocalVariable; /** * Represents a macro node. * * @author Fabien Potencier */ +#[YieldReady] class MacroNode extends Node { public const VARARGS_NAME = 'varargs'; - public function __construct(string $name, Node $body, Node $arguments, int $lineno, string $tag = null) + /** + * @param BodyNode $body + * @param ArrayExpression $arguments + */ + public function __construct(string $name, Node $body, Node $arguments, int $lineno) { - foreach ($arguments as $argumentName => $argument) { - if (self::VARARGS_NAME === $argumentName) { - throw new SyntaxError(sprintf('The argument "%s" in macro "%s" cannot be defined because the variable "%s" is reserved for arbitrary arguments.', self::VARARGS_NAME, $name, self::VARARGS_NAME), $argument->getTemplateLine(), $argument->getSourceContext()); + if (!$body instanceof BodyNode) { + trigger_deprecation('twig/twig', '3.12', \sprintf('Not passing a "%s" instance as the "body" argument of the "%s" constructor is deprecated ("%s" given).', BodyNode::class, static::class, $body::class)); + } + + if (!$arguments instanceof ArrayExpression) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Not passing a "%s" instance as the "arguments" argument of the "%s" constructor is deprecated ("%s" given).', ArrayExpression::class, static::class, $arguments::class)); + + $args = new ArrayExpression([], $arguments->getTemplateLine()); + foreach ($arguments as $n => $default) { + $args->addElement($default, new LocalVariable($n, $default->getTemplateLine())); + } + $arguments = $args; + } + + foreach ($arguments->getKeyValuePairs() as $pair) { + if ("\u{035C}".self::VARARGS_NAME === $pair['key']->getAttribute('name')) { + throw new SyntaxError(\sprintf('The argument "%s" in macro "%s" cannot be defined because the variable "%s" is reserved for arbitrary arguments.', self::VARARGS_NAME, $name, self::VARARGS_NAME), $pair['value']->getTemplateLine(), $pair['value']->getSourceContext()); } } - parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name], $lineno, $tag); + parent::__construct(['body' => $body, 'arguments' => $arguments], ['name' => $name], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) - ->write(sprintf('public function macro_%s(', $this->getAttribute('name'))) + ->write(\sprintf('public function macro_%s(', $this->getAttribute('name'))) ; - $count = \count($this->getNode('arguments')); - $pos = 0; - foreach ($this->getNode('arguments') as $name => $default) { + /** @var ArrayExpression $arguments */ + $arguments = $this->getNode('arguments'); + foreach ($arguments->getKeyValuePairs() as $pair) { + $name = $pair['key']; + $default = $pair['value']; $compiler - ->raw('$__'.$name.'__ = ') + ->subcompile($name) + ->raw(' = ') ->subcompile($default) + ->raw(', ') ; - - if (++$pos < $count) { - $compiler->raw(', '); - } - } - - if ($count) { - $compiler->raw(', '); } $compiler - ->raw('...$__varargs__') - ->raw(")\n") + ->raw('...$varargs') + ->raw("): string|Markup\n") ->write("{\n") ->indent() ->write("\$macros = \$this->macros;\n") - ->write("\$context = \$this->env->mergeGlobals([\n") + ->write("\$context = [\n") ->indent() ; - foreach ($this->getNode('arguments') as $name => $default) { + foreach ($arguments->getKeyValuePairs() as $pair) { + $name = $pair['key']; + $var = $name->getAttribute('name'); + if (str_starts_with($var, "\u{035C}")) { + $var = substr($var, \strlen("\u{035C}")); + } $compiler ->write('') - ->string($name) - ->raw(' => $__'.$name.'__') + ->string($var) + ->raw(' => ') + ->subcompile($name) ->raw(",\n") ; } + $node = new CaptureNode($this->getNode('body'), $this->getNode('body')->lineno); + $compiler ->write('') ->string(self::VARARGS_NAME) ->raw(' => ') - ; - - $compiler - ->raw("\$__varargs__,\n") + ->raw("\$varargs,\n") ->outdent() - ->write("]);\n\n") + ->write("] + \$this->env->getGlobals();\n\n") ->write("\$blocks = [];\n\n") - ; - if ($compiler->getEnvironment()->isDebug()) { - $compiler->write("ob_start();\n"); - } else { - $compiler->write("ob_start(function () { return ''; });\n"); - } - $compiler - ->write("try {\n") - ->indent() - ->subcompile($this->getNode('body')) + ->write('return ') + ->subcompile($node) ->raw("\n") - ->write("return ('' === \$tmp = ob_get_contents()) ? '' : new Markup(\$tmp, \$this->env->getCharset());\n") - ->outdent() - ->write("} finally {\n") - ->indent() - ->write("ob_end_clean();\n") - ->outdent() - ->write("}\n") ->outdent() ->write("}\n\n") ; diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index e972b6ba582..71c57201982 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; @@ -20,26 +21,43 @@ /** * Represents a module node. * - * Consider this class as being final. If you need to customize the behavior of - * the generated class, consider adding nodes to the following nodes: display_start, - * display_end, constructor_start, constructor_end, and class_end. + * If you need to customize the behavior of the generated class, add nodes to + * the following nodes: display_start, display_end, constructor_start, + * constructor_end, and class_end. * * @author Fabien Potencier */ +#[YieldReady] final class ModuleNode extends Node { + /** + * @param BodyNode $body + */ public function __construct(Node $body, ?AbstractExpression $parent, Node $blocks, Node $macros, Node $traits, $embeddedTemplates, Source $source) { + if (!$body instanceof BodyNode) { + trigger_deprecation('twig/twig', '3.12', \sprintf('Not passing a "%s" instance as the "body" argument of the "%s" constructor is deprecated.', BodyNode::class, static::class)); + } + if (!$embeddedTemplates instanceof Node) { + trigger_deprecation('twig/twig', '3.21', \sprintf('Not passing a "%s" instance as the "embedded_templates" argument of the "%s" constructor is deprecated.', Node::class, static::class)); + + if (null !== $embeddedTemplates) { + $embeddedTemplates = new Nodes($embeddedTemplates); + } else { + $embeddedTemplates = new EmptyNode(); + } + } + $nodes = [ 'body' => $body, 'blocks' => $blocks, 'macros' => $macros, 'traits' => $traits, - 'display_start' => new Node(), - 'display_end' => new Node(), - 'constructor_start' => new Node(), - 'constructor_end' => new Node(), - 'class_end' => new Node(), + 'display_start' => new Nodes(), + 'display_end' => new Nodes(), + 'constructor_start' => new Nodes(), + 'constructor_end' => new Nodes(), + 'class_end' => new Nodes(), ]; if (null !== $parent) { $nodes['parent'] = $parent; @@ -55,6 +73,9 @@ public function __construct(Node $body, ?AbstractExpression $parent, Node $block $this->setSourceContext($source); } + /** + * @return void + */ public function setIndex($index) { $this->setAttribute('index', $index); @@ -69,6 +90,9 @@ public function compile(Compiler $compiler): void } } + /** + * @return void + */ protected function compileTemplate(Compiler $compiler) { if (!$this->getAttribute('index')) { @@ -98,6 +122,9 @@ protected function compileTemplate(Compiler $compiler) $this->compileClassFooter($compiler); } + /** + * @return void + */ protected function compileGetParent(Compiler $compiler) { if (!$this->hasNode('parent')) { @@ -106,7 +133,7 @@ protected function compileGetParent(Compiler $compiler) $parent = $this->getNode('parent'); $compiler - ->write("protected function doGetParent(array \$context)\n", "{\n") + ->write("protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper\n", "{\n") ->indent() ->addDebugInfo($parent) ->write('return ') @@ -116,11 +143,9 @@ protected function compileGetParent(Compiler $compiler) $compiler->subcompile($parent); } else { $compiler - ->raw('$this->loadTemplate(') + ->raw('$this->load(') ->subcompile($parent) ->raw(', ') - ->repr($this->getSourceContext()->getName()) - ->raw(', ') ->repr($parent->getTemplateLine()) ->raw(')') ; @@ -133,6 +158,9 @@ protected function compileGetParent(Compiler $compiler) ; } + /** + * @return void + */ protected function compileClassHeader(Compiler $compiler) { $compiler @@ -143,6 +171,7 @@ protected function compileClassHeader(Compiler $compiler) ->write("use Twig\Environment;\n") ->write("use Twig\Error\LoaderError;\n") ->write("use Twig\Error\RuntimeError;\n") + ->write("use Twig\Extension\CoreExtension;\n") ->write("use Twig\Extension\SandboxExtension;\n") ->write("use Twig\Markup;\n") ->write("use Twig\Sandbox\SecurityError;\n") @@ -150,7 +179,9 @@ protected function compileClassHeader(Compiler $compiler) ->write("use Twig\Sandbox\SecurityNotAllowedFilterError;\n") ->write("use Twig\Sandbox\SecurityNotAllowedFunctionError;\n") ->write("use Twig\Source;\n") - ->write("use Twig\Template;\n\n") + ->write("use Twig\Template;\n") + ->write("use Twig\TemplateWrapper;\n") + ->write("\n") ; } $compiler @@ -160,11 +191,17 @@ protected function compileClassHeader(Compiler $compiler) ->raw(" extends Template\n") ->write("{\n") ->indent() - ->write("private \$source;\n") - ->write("private \$macros = [];\n\n") + ->write("private Source \$source;\n") + ->write("/**\n") + ->write(" * @var array\n") + ->write(" */\n") + ->write("private array \$macros = [];\n\n") ; } + /** + * @return void + */ protected function compileConstructor(Compiler $compiler) { $compiler @@ -188,14 +225,12 @@ protected function compileConstructor(Compiler $compiler) $compiler ->addDebugInfo($node) - ->write(sprintf('$_trait_%s = $this->loadTemplate(', $i)) + ->write(\sprintf('$_trait_%s = $this->load(', $i)) ->subcompile($node) ->raw(', ') - ->repr($node->getTemplateName()) - ->raw(', ') ->repr($node->getTemplateLine()) ->raw(");\n") - ->write(sprintf("if (!\$_trait_%s->isTraitable()) {\n", $i)) + ->write(\sprintf("if (!\$_trait_%s->unwrap()->isTraitable()) {\n", $i)) ->indent() ->write("throw new RuntimeError('Template \"'.") ->subcompile($trait->getNode('template')) @@ -204,12 +239,12 @@ protected function compileConstructor(Compiler $compiler) ->raw(", \$this->source);\n") ->outdent() ->write("}\n") - ->write(sprintf("\$_trait_%s_blocks = \$_trait_%s->getBlocks();\n\n", $i, $i)) + ->write(\sprintf("\$_trait_%s_blocks = \$_trait_%s->unwrap()->getBlocks();\n\n", $i, $i)) ; foreach ($trait->getNode('targets') as $key => $value) { $compiler - ->write(sprintf('if (!isset($_trait_%s_blocks[', $i)) + ->write(\sprintf('if (!isset($_trait_%s_blocks[', $i)) ->string($key) ->raw("])) {\n") ->indent() @@ -223,13 +258,17 @@ protected function compileConstructor(Compiler $compiler) ->outdent() ->write("}\n\n") - ->write(sprintf('$_trait_%s_blocks[', $i)) + ->write(\sprintf('$_trait_%s_blocks[', $i)) ->subcompile($value) - ->raw(sprintf('] = $_trait_%s_blocks[', $i)) + ->raw(\sprintf('] = $_trait_%s_blocks[', $i)) ->string($key) - ->raw(sprintf(']; unset($_trait_%s_blocks[', $i)) + ->raw(\sprintf(']; unset($_trait_%s_blocks[', $i)) ->string($key) - ->raw("]);\n\n") + ->raw(']); $this->traitAliases[') + ->subcompile($value) + ->raw('] = ') + ->string($key) + ->raw(";\n\n") ; } } @@ -242,7 +281,7 @@ protected function compileConstructor(Compiler $compiler) for ($i = 0; $i < $countTraits; ++$i) { $compiler - ->write(sprintf('$_trait_%s_blocks'.($i == $countTraits - 1 ? '' : ',')."\n", $i)) + ->write(\sprintf('$_trait_%s_blocks'.($i == $countTraits - 1 ? '' : ',')."\n", $i)) ; } @@ -275,7 +314,7 @@ protected function compileConstructor(Compiler $compiler) foreach ($this->getNode('blocks') as $name => $node) { $compiler - ->write(sprintf("'%s' => [\$this, 'block_%s'],\n", $name, $name)) + ->write(\sprintf("'%s' => [\$this, 'block_%s'],\n", $name, $name)) ; } @@ -300,10 +339,13 @@ protected function compileConstructor(Compiler $compiler) ; } + /** + * @return void + */ protected function compileDisplay(Compiler $compiler) { $compiler - ->write("protected function doDisplay(array \$context, array \$blocks = [])\n", "{\n") + ->write("protected function doDisplay(array \$context, array \$blocks = []): iterable\n", "{\n") ->indent() ->write("\$macros = \$this->macros;\n") ->subcompile($this->getNode('display_start')) @@ -316,28 +358,38 @@ protected function compileDisplay(Compiler $compiler) $compiler->addDebugInfo($parent); if ($parent instanceof ConstantExpression) { $compiler - ->write('$this->parent = $this->loadTemplate(') + ->write('$this->parent = $this->load(') ->subcompile($parent) ->raw(', ') - ->repr($this->getSourceContext()->getName()) - ->raw(', ') ->repr($parent->getTemplateLine()) ->raw(");\n") ; - $compiler->write('$this->parent'); + } + $compiler->write('yield from '); + + if ($parent instanceof ConstantExpression) { + $compiler->raw('$this->parent'); } else { - $compiler->write('$this->getParent($context)'); + $compiler->raw('$this->getParent($context)'); } - $compiler->raw("->display(\$context, array_merge(\$this->blocks, \$blocks));\n"); + $compiler->raw("->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks));\n"); + } + + $compiler->subcompile($this->getNode('display_end')); + + if (!$this->hasNode('parent')) { + $compiler->write("yield from [];\n"); } $compiler - ->subcompile($this->getNode('display_end')) ->outdent() ->write("}\n\n") ; } + /** + * @return void + */ protected function compileClassFooter(Compiler $compiler) { $compiler @@ -347,15 +399,24 @@ protected function compileClassFooter(Compiler $compiler) ; } + /** + * @return void + */ protected function compileMacros(Compiler $compiler) { $compiler->subcompile($this->getNode('macros')); } + /** + * @return void + */ protected function compileGetTemplateName(Compiler $compiler) { $compiler - ->write("public function getTemplateName()\n", "{\n") + ->write("/**\n") + ->write(" * @codeCoverageIgnore\n") + ->write(" */\n") + ->write("public function getTemplateName(): string\n", "{\n") ->indent() ->write('return ') ->repr($this->getSourceContext()->getName()) @@ -365,6 +426,9 @@ protected function compileGetTemplateName(Compiler $compiler) ; } + /** + * @return void + */ protected function compileIsTraitable(Compiler $compiler) { // A template can be used as a trait if: @@ -377,13 +441,13 @@ protected function compileIsTraitable(Compiler $compiler) $traitable = !$this->hasNode('parent') && 0 === \count($this->getNode('macros')); if ($traitable) { if ($this->getNode('body') instanceof BodyNode) { - $nodes = $this->getNode('body')->getNode(0); + $nodes = $this->getNode('body')->getNode('0'); } else { $nodes = $this->getNode('body'); } if (!\count($nodes)) { - $nodes = new Node([$nodes]); + $nodes = new Nodes([$nodes]); } foreach ($nodes as $node) { @@ -391,14 +455,6 @@ protected function compileIsTraitable(Compiler $compiler) continue; } - if ($node instanceof TextNode && ctype_space($node->getAttribute('data'))) { - continue; - } - - if ($node instanceof BlockReferenceNode) { - continue; - } - $traitable = false; break; } @@ -409,29 +465,41 @@ protected function compileIsTraitable(Compiler $compiler) } $compiler - ->write("public function isTraitable()\n", "{\n") + ->write("/**\n") + ->write(" * @codeCoverageIgnore\n") + ->write(" */\n") + ->write("public function isTraitable(): bool\n", "{\n") ->indent() - ->write(sprintf("return %s;\n", $traitable ? 'true' : 'false')) + ->write("return false;\n") ->outdent() ->write("}\n\n") ; } + /** + * @return void + */ protected function compileDebugInfo(Compiler $compiler) { $compiler - ->write("public function getDebugInfo()\n", "{\n") + ->write("/**\n") + ->write(" * @codeCoverageIgnore\n") + ->write(" */\n") + ->write("public function getDebugInfo(): array\n", "{\n") ->indent() - ->write(sprintf("return %s;\n", str_replace("\n", '', var_export(array_reverse($compiler->getDebugInfo(), true), true)))) + ->write(\sprintf("return %s;\n", str_replace("\n", '', var_export(array_reverse($compiler->getDebugInfo(), true), true)))) ->outdent() ->write("}\n\n") ; } + /** + * @return void + */ protected function compileGetSourceContext(Compiler $compiler) { $compiler - ->write("public function getSourceContext()\n", "{\n") + ->write("public function getSourceContext(): Source\n", "{\n") ->indent() ->write('return new Source(') ->string($compiler->getEnvironment()->isDebug() ? $this->getSourceContext()->getCode() : '') @@ -444,21 +512,4 @@ protected function compileGetSourceContext(Compiler $compiler) ->write("}\n") ; } - - protected function compileLoadTemplate(Compiler $compiler, $node, $var) - { - if ($node instanceof ConstantExpression) { - $compiler - ->write(sprintf('%s = $this->loadTemplate(', $var)) - ->subcompile($node) - ->raw(', ') - ->repr($node->getTemplateName()) - ->raw(', ') - ->repr($node->getTemplateLine()) - ->raw(");\n") - ; - } else { - throw new \LogicException('Trait templates can only be constant nodes.'); - } - } } diff --git a/src/Node/NameDeprecation.php b/src/Node/NameDeprecation.php new file mode 100644 index 00000000000..63ab285761a --- /dev/null +++ b/src/Node/NameDeprecation.php @@ -0,0 +1,46 @@ + + */ +class NameDeprecation +{ + private $package; + private $version; + private $newName; + + public function __construct(string $package = '', string $version = '', string $newName = '') + { + $this->package = $package; + $this->version = $version; + $this->newName = $newName; + } + + public function getPackage(): string + { + return $this->package; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getNewName(): string + { + return $this->newName; + } +} diff --git a/src/Node/Node.php b/src/Node/Node.php index c0558b9afdc..dcf912c2198 100644 --- a/src/Node/Node.php +++ b/src/Node/Node.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Source; @@ -19,62 +20,96 @@ * Represents a node in the AST. * * @author Fabien Potencier + * + * @implements \IteratorAggregate */ +#[YieldReady] class Node implements \Countable, \IteratorAggregate { + /** + * @var array + */ protected $nodes; protected $attributes; protected $lineno; protected $tag; - private $name; private $sourceContext; + /** @var array */ + private $nodeNameDeprecations = []; + /** @var array */ + private $attributeNameDeprecations = []; /** - * @param array $nodes An array of named nodes - * @param array $attributes An array of attributes (should not be nodes) - * @param int $lineno The line number - * @param string $tag The tag name associated with the Node + * @param array $nodes An array of named nodes + * @param array $attributes An array of attributes (should not be nodes) + * @param int $lineno The line number */ - public function __construct(array $nodes = [], array $attributes = [], int $lineno = 0, string $tag = null) + public function __construct(array $nodes = [], array $attributes = [], int $lineno = 0) { + if (self::class === static::class) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Instantiating "%s" directly is deprecated; the class will become abstract in 4.0.', self::class)); + } + foreach ($nodes as $name => $node) { if (!$node instanceof self) { - throw new \InvalidArgumentException(sprintf('Using "%s" for the value of node "%s" of "%s" is not supported. You must pass a \Twig\Node\Node instance.', \is_object($node) ? \get_class($node) : (null === $node ? 'null' : \gettype($node)), $name, static::class)); + throw new \InvalidArgumentException(\sprintf('Using "%s" for the value of node "%s" of "%s" is not supported. You must pass a \Twig\Node\Node instance.', get_debug_type($node), $name, static::class)); } } $this->nodes = $nodes; $this->attributes = $attributes; $this->lineno = $lineno; - $this->tag = $tag; + + if (\func_num_args() > 3) { + trigger_deprecation('twig/twig', '3.12', \sprintf('The "tag" constructor argument of the "%s" class is deprecated and ignored (check which TokenParser class set it to "%s"), the tag is now automatically set by the Parser when needed.', static::class, func_get_arg(3) ?: 'null')); + } } - public function __toString() + public function __toString(): string { + $repr = static::class; + + if ($this->tag) { + $repr .= \sprintf("\n tag: %s", $this->tag); + } + $attributes = []; foreach ($this->attributes as $name => $value) { - $attributes[] = sprintf('%s: %s', $name, str_replace("\n", '', var_export($value, true))); + if (\is_callable($value)) { + $v = '\Closure'; + } elseif ($value instanceof \Stringable) { + $v = (string) $value; + } else { + $v = str_replace("\n", '', var_export($value, true)); + } + $attributes[] = \sprintf('%s: %s', $name, $v); } - $repr = [static::class.'('.implode(', ', $attributes)]; + if ($attributes) { + $repr .= \sprintf("\n attributes:\n %s", implode("\n ", $attributes)); + } if (\count($this->nodes)) { + $repr .= "\n nodes:"; foreach ($this->nodes as $name => $node) { - $len = \strlen($name) + 4; + $len = \strlen($name) + 6; $noderepr = []; foreach (explode("\n", (string) $node) as $line) { $noderepr[] = str_repeat(' ', $len).$line; } - $repr[] = sprintf(' %s: %s', $name, ltrim(implode("\n", $noderepr))); + $repr .= \sprintf("\n %s: %s", $name, ltrim(implode("\n", $noderepr))); } - - $repr[] = ')'; - } else { - $repr[0] .= ')'; } - return implode("\n", $repr); + return $repr; + } + + public function __clone() + { + foreach ($this->nodes as $name => $node) { + $this->nodes[$name] = clone $node; + } } /** @@ -83,7 +118,7 @@ public function __toString() public function compile(Compiler $compiler) { foreach ($this->nodes as $node) { - $node->compile($compiler); + $compiler->subcompile($node); } } @@ -97,6 +132,18 @@ public function getNodeTag(): ?string return $this->tag; } + /** + * @internal + */ + public function setNodeTag(string $tag): void + { + if ($this->tag) { + throw new \LogicException('The tag of a node can only be set once.'); + } + + $this->tag = $tag; + } + public function hasAttribute(string $name): bool { return \array_key_exists($name, $this->attributes); @@ -105,7 +152,17 @@ public function hasAttribute(string $name): bool public function getAttribute(string $name) { if (!\array_key_exists($name, $this->attributes)) { - throw new \LogicException(sprintf('Attribute "%s" does not exist for Node "%s".', $name, static::class)); + throw new \LogicException(\sprintf('Attribute "%s" does not exist for Node "%s".', $name, static::class)); + } + + $triggerDeprecation = \func_num_args() > 1 ? func_get_arg(1) : true; + if ($triggerDeprecation && isset($this->attributeNameDeprecations[$name])) { + $dep = $this->attributeNameDeprecations[$name]; + if ($dep->getNewName()) { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Getting attribute "%s" on a "%s" class is deprecated, get the "%s" attribute instead.', $name, static::class, $dep->getNewName()); + } else { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Getting attribute "%s" on a "%s" class is deprecated.', $name, static::class); + } } return $this->attributes[$name]; @@ -113,38 +170,96 @@ public function getAttribute(string $name) public function setAttribute(string $name, $value): void { + $triggerDeprecation = \func_num_args() > 2 ? func_get_arg(2) : true; + if ($triggerDeprecation && isset($this->attributeNameDeprecations[$name])) { + $dep = $this->attributeNameDeprecations[$name]; + if ($dep->getNewName()) { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Setting attribute "%s" on a "%s" class is deprecated, set the "%s" attribute instead.', $name, static::class, $dep->getNewName()); + } else { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Setting attribute "%s" on a "%s" class is deprecated.', $name, static::class); + } + } + $this->attributes[$name] = $value; } + public function deprecateAttribute(string $name, NameDeprecation $dep): void + { + $this->attributeNameDeprecations[$name] = $dep; + } + public function removeAttribute(string $name): void { unset($this->attributes[$name]); } + /** + * @param string|int $name + */ public function hasNode(string $name): bool { return isset($this->nodes[$name]); } + /** + * @param string|int $name + */ public function getNode(string $name): self { if (!isset($this->nodes[$name])) { - throw new \LogicException(sprintf('Node "%s" does not exist for Node "%s".', $name, static::class)); + throw new \LogicException(\sprintf('Node "%s" does not exist for Node "%s".', $name, static::class)); + } + + $triggerDeprecation = \func_num_args() > 1 ? func_get_arg(1) : true; + if ($triggerDeprecation && isset($this->nodeNameDeprecations[$name])) { + $dep = $this->nodeNameDeprecations[$name]; + if ($dep->getNewName()) { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Getting node "%s" on a "%s" class is deprecated, get the "%s" node instead.', $name, static::class, $dep->getNewName()); + } else { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Getting node "%s" on a "%s" class is deprecated.', $name, static::class); + } } return $this->nodes[$name]; } + /** + * @param string|int $name + */ public function setNode(string $name, self $node): void { + $triggerDeprecation = \func_num_args() > 2 ? func_get_arg(2) : true; + if ($triggerDeprecation && isset($this->nodeNameDeprecations[$name])) { + $dep = $this->nodeNameDeprecations[$name]; + if ($dep->getNewName()) { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Setting node "%s" on a "%s" class is deprecated, set the "%s" node instead.', $name, static::class, $dep->getNewName()); + } else { + trigger_deprecation($dep->getPackage(), $dep->getVersion(), 'Setting node "%s" on a "%s" class is deprecated.', $name, static::class); + } + } + + if (null !== $this->sourceContext) { + $node->setSourceContext($this->sourceContext); + } $this->nodes[$name] = $node; } + /** + * @param string|int $name + */ public function removeNode(string $name): void { unset($this->nodes[$name]); } + /** + * @param string|int $name + */ + public function deprecateNode(string $name, NameDeprecation $dep): void + { + $this->nodeNameDeprecations[$name] = $dep; + } + /** * @return int */ diff --git a/src/Node/Nodes.php b/src/Node/Nodes.php new file mode 100644 index 00000000000..bd67053abe1 --- /dev/null +++ b/src/Node/Nodes.php @@ -0,0 +1,28 @@ + + */ +#[YieldReady] +final class Nodes extends Node +{ + public function __construct(array $nodes = [], int $lineno = 0) + { + parent::__construct($nodes, [], $lineno); + } +} diff --git a/src/Node/PrintNode.php b/src/Node/PrintNode.php index 60386d29969..e3c23bbfa1c 100644 --- a/src/Node/PrintNode.php +++ b/src/Node/PrintNode.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\AbstractExpression; @@ -20,19 +21,23 @@ * * @author Fabien Potencier */ +#[YieldReady] class PrintNode extends Node implements NodeOutputInterface { - public function __construct(AbstractExpression $expr, int $lineno, string $tag = null) + public function __construct(AbstractExpression $expr, int $lineno) { - parent::__construct(['expr' => $expr], [], $lineno, $tag); + parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void { + /** @var AbstractExpression */ + $expr = $this->getNode('expr'); + $compiler ->addDebugInfo($this) - ->write('echo ') - ->subcompile($this->getNode('expr')) + ->write($expr->isGenerator() ? 'yield from ' : 'yield ') + ->subcompile($expr) ->raw(";\n") ; } diff --git a/src/Node/SandboxNode.php b/src/Node/SandboxNode.php index 4d5666bff13..d51cea44b48 100644 --- a/src/Node/SandboxNode.php +++ b/src/Node/SandboxNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -18,11 +19,12 @@ * * @author Fabien Potencier */ +#[YieldReady] class SandboxNode extends Node { - public function __construct(Node $body, int $lineno, string $tag = null) + public function __construct(Node $body, int $lineno) { - parent::__construct(['body' => $body], [], $lineno, $tag); + parent::__construct(['body' => $body], [], $lineno); } public function compile(Compiler $compiler): void diff --git a/src/Node/SetNode.php b/src/Node/SetNode.php index 96b6bd8bf58..7b063b00b2a 100644 --- a/src/Node/SetNode.php +++ b/src/Node/SetNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Expression\ConstantExpression; @@ -19,26 +20,35 @@ * * @author Fabien Potencier */ +#[YieldReady] class SetNode extends Node implements NodeCaptureInterface { - public function __construct(bool $capture, Node $names, Node $values, int $lineno, string $tag = null) + public function __construct(bool $capture, Node $names, Node $values, int $lineno) { - parent::__construct(['names' => $names, 'values' => $values], ['capture' => $capture, 'safe' => false], $lineno, $tag); - /* * Optimizes the node when capture is used for a large block of text. * * {% set foo %}foo{% endset %} is compiled to $context['foo'] = new Twig\Markup("foo"); */ - if ($this->getAttribute('capture')) { - $this->setAttribute('safe', true); - - $values = $this->getNode('values'); - if ($values instanceof TextNode) { - $this->setNode('values', new ConstantExpression($values->getAttribute('data'), $values->getTemplateLine())); - $this->setAttribute('capture', false); + $safe = false; + if ($capture) { + $safe = true; + // Node::class === get_class($values) should be removed in Twig 4.0 + if (($values instanceof Nodes || Node::class === $values::class) && !\count($values)) { + $values = new ConstantExpression('', $values->getTemplateLine()); + $capture = false; + } elseif ($values instanceof TextNode) { + $values = new ConstantExpression($values->getAttribute('data'), $values->getTemplateLine()); + $capture = false; + } elseif ($values instanceof PrintNode && $values->getNode('expr') instanceof ConstantExpression) { + $values = $values->getNode('expr'); + $capture = false; + } else { + $values = new CaptureNode($values, $values->getTemplateLine()); } } + + parent::__construct(['names' => $names, 'values' => $values], ['capture' => $capture, 'safe' => $safe], $lineno); } public function compile(Compiler $compiler): void @@ -46,7 +56,7 @@ public function compile(Compiler $compiler): void $compiler->addDebugInfo($this); if (\count($this->getNode('names')) > 1) { - $compiler->write('list('); + $compiler->write('['); foreach ($this->getNode('names') as $idx => $node) { if ($idx) { $compiler->raw(', '); @@ -54,29 +64,15 @@ public function compile(Compiler $compiler): void $compiler->subcompile($node); } - $compiler->raw(')'); + $compiler->raw(']'); } else { - if ($this->getAttribute('capture')) { - if ($compiler->getEnvironment()->isDebug()) { - $compiler->write("ob_start();\n"); - } else { - $compiler->write("ob_start(function () { return ''; });\n"); - } - $compiler - ->subcompile($this->getNode('values')) - ; - } - $compiler->subcompile($this->getNode('names'), false); - - if ($this->getAttribute('capture')) { - $compiler->raw(" = ('' === \$tmp = ob_get_clean()) ? '' : new Markup(\$tmp, \$this->env->getCharset())"); - } } + $compiler->raw(' = '); - if (!$this->getAttribute('capture')) { - $compiler->raw(' = '); - + if ($this->getAttribute('capture')) { + $compiler->subcompile($this->getNode('values')); + } else { if (\count($this->getNode('names')) > 1) { $compiler->write('['); foreach ($this->getNode('values') as $idx => $value) { @@ -89,17 +85,31 @@ public function compile(Compiler $compiler): void $compiler->raw(']'); } else { if ($this->getAttribute('safe')) { - $compiler - ->raw("('' === \$tmp = ") - ->subcompile($this->getNode('values')) - ->raw(") ? '' : new Markup(\$tmp, \$this->env->getCharset())") - ; + if ($this->getNode('values') instanceof ConstantExpression) { + if ('' === $this->getNode('values')->getAttribute('value')) { + $compiler->raw('""'); + } else { + $compiler + ->raw('new Markup(') + ->subcompile($this->getNode('values')) + ->raw(', $this->env->getCharset())') + ; + } + } else { + $compiler + ->raw("('' === \$tmp = ") + ->subcompile($this->getNode('values')) + ->raw(") ? '' : new Markup(\$tmp, \$this->env->getCharset())") + ; + } } else { $compiler->subcompile($this->getNode('values')); } } + + $compiler->raw(';'); } - $compiler->raw(";\n"); + $compiler->raw("\n"); } } diff --git a/src/Node/TextNode.php b/src/Node/TextNode.php index d74ebe630cc..fae65fb2cb4 100644 --- a/src/Node/TextNode.php +++ b/src/Node/TextNode.php @@ -12,6 +12,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -19,6 +20,7 @@ * * @author Fabien Potencier */ +#[YieldReady] class TextNode extends Node implements NodeOutputInterface { public function __construct(string $data, int $lineno) @@ -28,9 +30,10 @@ public function __construct(string $data, int $lineno) public function compile(Compiler $compiler): void { + $compiler->addDebugInfo($this); + $compiler - ->addDebugInfo($this) - ->write('echo ') + ->write('yield ') ->string($this->getAttribute('data')) ->raw(";\n") ; diff --git a/src/Node/TypesNode.php b/src/Node/TypesNode.php new file mode 100644 index 00000000000..a1828808385 --- /dev/null +++ b/src/Node/TypesNode.php @@ -0,0 +1,40 @@ + + */ +#[YieldReady] +class TypesNode extends Node +{ + /** + * @param array $types + */ + public function __construct(array $types, int $lineno) + { + parent::__construct([], ['mapping' => $types], $lineno); + } + + /** + * @return void + */ + public function compile(Compiler $compiler) + { + // Don't compile anything. + } +} diff --git a/src/Node/WithNode.php b/src/Node/WithNode.php index 56a334496e9..487e2800bfd 100644 --- a/src/Node/WithNode.php +++ b/src/Node/WithNode.php @@ -11,6 +11,7 @@ namespace Twig\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; /** @@ -18,16 +19,17 @@ * * @author Fabien Potencier */ +#[YieldReady] class WithNode extends Node { - public function __construct(Node $body, ?Node $variables, bool $only, int $lineno, string $tag = null) + public function __construct(Node $body, ?Node $variables, bool $only, int $lineno) { $nodes = ['body' => $body]; if (null !== $variables) { $nodes['variables'] = $variables; } - parent::__construct($nodes, ['only' => $only], $lineno, $tag); + parent::__construct($nodes, ['only' => $only], $lineno); } public function compile(Compiler $compiler): void @@ -36,35 +38,35 @@ public function compile(Compiler $compiler): void $parentContextName = $compiler->getVarName(); - $compiler->write(sprintf("\$%s = \$context;\n", $parentContextName)); + $compiler->write(\sprintf("\$%s = \$context;\n", $parentContextName)); if ($this->hasNode('variables')) { $node = $this->getNode('variables'); $varsName = $compiler->getVarName(); $compiler - ->write(sprintf('$%s = ', $varsName)) + ->write(\sprintf('$%s = ', $varsName)) ->subcompile($node) ->raw(";\n") - ->write(sprintf("if (!twig_test_iterable(\$%s)) {\n", $varsName)) + ->write(\sprintf("if (!is_iterable(\$%s)) {\n", $varsName)) ->indent() - ->write("throw new RuntimeError('Variables passed to the \"with\" tag must be a hash.', ") + ->write("throw new RuntimeError('Variables passed to the \"with\" tag must be a mapping.', ") ->repr($node->getTemplateLine()) ->raw(", \$this->getSourceContext());\n") ->outdent() ->write("}\n") - ->write(sprintf("\$%s = twig_to_array(\$%s);\n", $varsName, $varsName)) + ->write(\sprintf("\$%s = CoreExtension::toArray(\$%s);\n", $varsName, $varsName)) ; if ($this->getAttribute('only')) { $compiler->write("\$context = [];\n"); } - $compiler->write(sprintf("\$context = \$this->env->mergeGlobals(array_merge(\$context, \$%s));\n", $varsName)); + $compiler->write(\sprintf("\$context = \$%s + \$context + \$this->env->getGlobals();\n", $varsName)); } $compiler ->subcompile($this->getNode('body')) - ->write(sprintf("\$context = \$%s;\n", $parentContextName)) + ->write(\sprintf("\$context = \$%s;\n", $parentContextName)) ; } } diff --git a/src/NodeVisitor/AbstractNodeVisitor.php b/src/NodeVisitor/AbstractNodeVisitor.php index d7036ae5511..38b1ec9d04f 100644 --- a/src/NodeVisitor/AbstractNodeVisitor.php +++ b/src/NodeVisitor/AbstractNodeVisitor.php @@ -17,9 +17,9 @@ /** * Used to make node visitors compatible with Twig 1.x and 2.x. * - * To be removed in Twig 3.1. - * * @author Fabien Potencier + * + * @deprecated since Twig 3.9 (to be removed in 4.0) */ abstract class AbstractNodeVisitor implements NodeVisitorInterface { diff --git a/src/NodeVisitor/EscaperNodeVisitor.php b/src/NodeVisitor/EscaperNodeVisitor.php index fe56ea30741..a9f829770a2 100644 --- a/src/NodeVisitor/EscaperNodeVisitor.php +++ b/src/NodeVisitor/EscaperNodeVisitor.php @@ -16,14 +16,14 @@ use Twig\Node\AutoEscapeNode; use Twig\Node\BlockNode; use Twig\Node\BlockReferenceNode; -use Twig\Node\DoNode; -use Twig\Node\Expression\ConditionalExpression; +use Twig\Node\Expression\AbstractExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; -use Twig\Node\Expression\InlinePrint; +use Twig\Node\Expression\OperatorEscapeInterface; use Twig\Node\ImportNode; use Twig\Node\ModuleNode; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\PrintNode; use Twig\NodeTraverser; @@ -57,9 +57,9 @@ public function enterNode(Node $node, Environment $env): Node } elseif ($node instanceof AutoEscapeNode) { $this->statusStack[] = $node->getAttribute('value'); } elseif ($node instanceof BlockNode) { - $this->statusStack[] = isset($this->blocks[$node->getAttribute('name')]) ? $this->blocks[$node->getAttribute('name')] : $this->needEscaping($env); + $this->statusStack[] = $this->blocks[$node->getAttribute('name')] ?? $this->needEscaping(); } elseif ($node instanceof ImportNode) { - $this->safeVars[] = $node->getNode('var')->getAttribute('name'); + $this->safeVars[] = $node->getNode('var')->getNode('var')->getAttribute('name'); } return $node; @@ -73,103 +73,77 @@ public function leaveNode(Node $node, Environment $env): ?Node $this->blocks = []; } elseif ($node instanceof FilterExpression) { return $this->preEscapeFilterNode($node, $env); - } elseif ($node instanceof PrintNode && false !== $type = $this->needEscaping($env)) { + } elseif ($node instanceof PrintNode && false !== $type = $this->needEscaping()) { $expression = $node->getNode('expr'); - if ($expression instanceof ConditionalExpression && $this->shouldUnwrapConditional($expression, $env, $type)) { - return new DoNode($this->unwrapConditional($expression, $env, $type), $expression->getTemplateLine()); + if ($expression instanceof OperatorEscapeInterface) { + $this->escapeConditional($expression, $env, $type); + } else { + $node->setNode('expr', $this->escapeExpression($expression, $env, $type)); } - return $this->escapePrintNode($node, $env, $type); + return $node; } if ($node instanceof AutoEscapeNode || $node instanceof BlockNode) { array_pop($this->statusStack); } elseif ($node instanceof BlockReferenceNode) { - $this->blocks[$node->getAttribute('name')] = $this->needEscaping($env); + $this->blocks[$node->getAttribute('name')] = $this->needEscaping(); } return $node; } - private function shouldUnwrapConditional(ConditionalExpression $expression, Environment $env, string $type): bool - { - $expr2Safe = $this->isSafeFor($type, $expression->getNode('expr2'), $env); - $expr3Safe = $this->isSafeFor($type, $expression->getNode('expr3'), $env); - - return $expr2Safe !== $expr3Safe; - } - - private function unwrapConditional(ConditionalExpression $expression, Environment $env, string $type): ConditionalExpression - { - // convert "echo a ? b : c" to "a ? echo b : echo c" recursively - $expr2 = $expression->getNode('expr2'); - if ($expr2 instanceof ConditionalExpression && $this->shouldUnwrapConditional($expr2, $env, $type)) { - $expr2 = $this->unwrapConditional($expr2, $env, $type); - } else { - $expr2 = $this->escapeInlinePrintNode(new InlinePrint($expr2, $expr2->getTemplateLine()), $env, $type); - } - $expr3 = $expression->getNode('expr3'); - if ($expr3 instanceof ConditionalExpression && $this->shouldUnwrapConditional($expr3, $env, $type)) { - $expr3 = $this->unwrapConditional($expr3, $env, $type); - } else { - $expr3 = $this->escapeInlinePrintNode(new InlinePrint($expr3, $expr3->getTemplateLine()), $env, $type); - } - - return new ConditionalExpression($expression->getNode('expr1'), $expr2, $expr3, $expression->getTemplateLine()); - } - - private function escapeInlinePrintNode(InlinePrint $node, Environment $env, string $type): Node + /** + * @param AbstractExpression&OperatorEscapeInterface $expression + */ + private function escapeConditional($expression, Environment $env, string $type): void { - $expression = $node->getNode('node'); - - if ($this->isSafeFor($type, $expression, $env)) { - return $node; + foreach ($expression->getOperandNamesToEscape() as $name) { + /** @var AbstractExpression $operand */ + $operand = $expression->getNode($name); + if ($operand instanceof OperatorEscapeInterface) { + $this->escapeConditional($operand, $env, $type); + } else { + $expression->setNode($name, $this->escapeExpression($operand, $env, $type)); + } } - - return new InlinePrint($this->getEscaperFilter($type, $expression), $node->getTemplateLine()); } - private function escapePrintNode(PrintNode $node, Environment $env, string $type): Node + private function escapeExpression(AbstractExpression $expression, Environment $env, string $type): AbstractExpression { - if (false === $type) { - return $node; - } - - $expression = $node->getNode('expr'); - - if ($this->isSafeFor($type, $expression, $env)) { - return $node; - } - - $class = \get_class($node); - - return new $class($this->getEscaperFilter($type, $expression), $node->getTemplateLine()); + return $this->isSafeFor($type, $expression, $env) ? $expression : $this->getEscaperFilter($env, $type, $expression); } private function preEscapeFilterNode(FilterExpression $filter, Environment $env): FilterExpression { - $name = $filter->getNode('filter')->getAttribute('value'); + if ($filter->hasAttribute('twig_callable')) { + $type = $filter->getAttribute('twig_callable')->getPreEscape(); + } else { + // legacy + $name = $filter->getNode('filter', false)->getAttribute('value'); + $type = $env->getFilter($name)->getPreEscape(); + } - $type = $env->getFilter($name)->getPreEscape(); if (null === $type) { return $filter; } + /** @var AbstractExpression $node */ $node = $filter->getNode('node'); if ($this->isSafeFor($type, $node, $env)) { return $filter; } - $filter->setNode('node', $this->getEscaperFilter($type, $node)); + $filter->setNode('node', $this->getEscaperFilter($env, $type, $node)); return $filter; } - private function isSafeFor(string $type, Node $expression, Environment $env): bool + private function isSafeFor(string $type, AbstractExpression $expression, Environment $env): bool { $safe = $this->safeAnalysis->getSafe($expression); - if (null === $safe) { + if (!$safe) { if (null === $this->traverser) { $this->traverser = new NodeTraverser($env, [$this->safeAnalysis]); } @@ -180,25 +154,28 @@ private function isSafeFor(string $type, Node $expression, Environment $env): bo $safe = $this->safeAnalysis->getSafe($expression); } - return \in_array($type, $safe) || \in_array('all', $safe); + return \in_array($type, $safe, true) || \in_array('all', $safe, true); } - private function needEscaping(Environment $env) + /** + * @return string|false + */ + private function needEscaping(): string|bool { if (\count($this->statusStack)) { return $this->statusStack[\count($this->statusStack) - 1]; } - return $this->defaultStrategy ? $this->defaultStrategy : false; + return $this->defaultStrategy ?: false; } - private function getEscaperFilter(string $type, Node $node): FilterExpression + private function getEscaperFilter(Environment $env, string $type, AbstractExpression $node): FilterExpression { $line = $node->getTemplateLine(); - $name = new ConstantExpression('escape', $line); - $args = new Node([new ConstantExpression($type, $line), new ConstantExpression(null, $line), new ConstantExpression(true, $line)]); + $filter = $env->getFilter('escape'); + $args = new Nodes([new ConstantExpression($type, $line), new ConstantExpression(null, $line), new ConstantExpression(true, $line)]); - return new FilterExpression($node, $name, $args, $line); + return new FilterExpression($node, $filter, $args, $line); } public function getPriority(): int diff --git a/src/NodeVisitor/MacroAutoImportNodeVisitor.php b/src/NodeVisitor/MacroAutoImportNodeVisitor.php deleted file mode 100644 index af477e65356..00000000000 --- a/src/NodeVisitor/MacroAutoImportNodeVisitor.php +++ /dev/null @@ -1,74 +0,0 @@ - - * - * @internal - */ -final class MacroAutoImportNodeVisitor implements NodeVisitorInterface -{ - private $inAModule = false; - private $hasMacroCalls = false; - - public function enterNode(Node $node, Environment $env): Node - { - if ($node instanceof ModuleNode) { - $this->inAModule = true; - $this->hasMacroCalls = false; - } - - return $node; - } - - public function leaveNode(Node $node, Environment $env): Node - { - if ($node instanceof ModuleNode) { - $this->inAModule = false; - if ($this->hasMacroCalls) { - $node->getNode('constructor_end')->setNode('_auto_macro_import', new ImportNode(new NameExpression('_self', 0), new AssignNameExpression('_self', 0), 0, 'import', true)); - } - } elseif ($this->inAModule) { - if ( - $node instanceof GetAttrExpression && - $node->getNode('node') instanceof NameExpression && - '_self' === $node->getNode('node')->getAttribute('name') && - $node->getNode('attribute') instanceof ConstantExpression - ) { - $this->hasMacroCalls = true; - - $name = $node->getNode('attribute')->getAttribute('value'); - $node = new MethodCallExpression($node->getNode('node'), 'macro_'.$name, $node->getNode('arguments'), $node->getTemplateLine()); - $node->setAttribute('safe', true); - } - } - - return $node; - } - - public function getPriority(): int - { - // we must be ran before auto-escaping - return -10; - } -} diff --git a/src/NodeVisitor/OptimizerNodeVisitor.php b/src/NodeVisitor/OptimizerNodeVisitor.php index 7ac75e41ad3..b778ba40efa 100644 --- a/src/NodeVisitor/OptimizerNodeVisitor.php +++ b/src/NodeVisitor/OptimizerNodeVisitor.php @@ -15,15 +15,15 @@ use Twig\Node\BlockReferenceNode; use Twig\Node\Expression\BlockReferenceExpression; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\FunctionExpression; use Twig\Node\Expression\GetAttrExpression; -use Twig\Node\Expression\NameExpression; use Twig\Node\Expression\ParentExpression; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ForNode; use Twig\Node\IncludeNode; use Twig\Node\Node; use Twig\Node\PrintNode; +use Twig\Node\TextNode; /** * Tries to optimize the AST. @@ -43,27 +43,34 @@ final class OptimizerNodeVisitor implements NodeVisitorInterface public const OPTIMIZE_NONE = 0; public const OPTIMIZE_FOR = 2; public const OPTIMIZE_RAW_FILTER = 4; + public const OPTIMIZE_TEXT_NODES = 8; private $loops = []; private $loopsTargets = []; - private $optimizers; /** * @param int $optimizers The optimizer mode */ - public function __construct(int $optimizers = -1) - { - if ($optimizers > (self::OPTIMIZE_FOR | self::OPTIMIZE_RAW_FILTER)) { - throw new \InvalidArgumentException(sprintf('Optimizer mode "%s" is not valid.', $optimizers)); + public function __construct( + private int $optimizers = -1, + ) { + if ($optimizers > (self::OPTIMIZE_FOR | self::OPTIMIZE_RAW_FILTER | self::OPTIMIZE_TEXT_NODES)) { + throw new \InvalidArgumentException(\sprintf('Optimizer mode "%s" is not valid.', $optimizers)); + } + + if (-1 !== $optimizers && self::OPTIMIZE_RAW_FILTER === (self::OPTIMIZE_RAW_FILTER & $optimizers)) { + trigger_deprecation('twig/twig', '3.11', 'The "Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_RAW_FILTER" option is deprecated and does nothing.'); } - $this->optimizers = $optimizers; + if (-1 !== $optimizers && self::OPTIMIZE_TEXT_NODES === (self::OPTIMIZE_TEXT_NODES & $optimizers)) { + trigger_deprecation('twig/twig', '3.12', 'The "Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_TEXT_NODES" option is deprecated and does nothing.'); + } } public function enterNode(Node $node, Environment $env): Node { if (self::OPTIMIZE_FOR === (self::OPTIMIZE_FOR & $this->optimizers)) { - $this->enterOptimizeFor($node, $env); + $this->enterOptimizeFor($node); } return $node; @@ -72,14 +79,10 @@ public function enterNode(Node $node, Environment $env): Node public function leaveNode(Node $node, Environment $env): ?Node { if (self::OPTIMIZE_FOR === (self::OPTIMIZE_FOR & $this->optimizers)) { - $this->leaveOptimizeFor($node, $env); + $this->leaveOptimizeFor($node); } - if (self::OPTIMIZE_RAW_FILTER === (self::OPTIMIZE_RAW_FILTER & $this->optimizers)) { - $node = $this->optimizeRawFilter($node, $env); - } - - $node = $this->optimizePrintNode($node, $env); + $node = $this->optimizePrintNode($node); return $node; } @@ -91,16 +94,21 @@ public function leaveNode(Node $node, Environment $env): ?Node * * * "echo $this->render(Parent)Block()" with "$this->display(Parent)Block()" */ - private function optimizePrintNode(Node $node, Environment $env): Node + private function optimizePrintNode(Node $node): Node { if (!$node instanceof PrintNode) { return $node; } $exprNode = $node->getNode('expr'); + + if ($exprNode instanceof ConstantExpression && \is_string($exprNode->getAttribute('value'))) { + return new TextNode($exprNode->getAttribute('value'), $exprNode->getTemplateLine()); + } + if ( - $exprNode instanceof BlockReferenceExpression || - $exprNode instanceof ParentExpression + $exprNode instanceof BlockReferenceExpression + || $exprNode instanceof ParentExpression ) { $exprNode->setAttribute('output', true); @@ -110,22 +118,10 @@ private function optimizePrintNode(Node $node, Environment $env): Node return $node; } - /** - * Removes "raw" filters. - */ - private function optimizeRawFilter(Node $node, Environment $env): Node - { - if ($node instanceof FilterExpression && 'raw' == $node->getNode('filter')->getAttribute('value')) { - return $node->getNode('node'); - } - - return $node; - } - /** * Optimizes "for" tag by removing the "loop" variable creation whenever possible. */ - private function enterOptimizeFor(Node $node, Environment $env): void + private function enterOptimizeFor(Node $node): void { if ($node instanceof ForNode) { // disable the loop variable by default @@ -141,13 +137,13 @@ private function enterOptimizeFor(Node $node, Environment $env): void // when do we need to add the loop variable back? // the loop variable is referenced for the current loop - elseif ($node instanceof NameExpression && 'loop' === $node->getAttribute('name')) { + elseif ($node instanceof ContextVariable && 'loop' === $node->getAttribute('name')) { $node->setAttribute('always_defined', true); $this->addLoopToCurrent(); } // optimize access to loop targets - elseif ($node instanceof NameExpression && \in_array($node->getAttribute('name'), $this->loopsTargets)) { + elseif ($node instanceof ContextVariable && \in_array($node->getAttribute('name'), $this->loopsTargets, true)) { $node->setAttribute('always_defined', true); } @@ -166,7 +162,7 @@ private function enterOptimizeFor(Node $node, Environment $env): void && 'include' === $node->getAttribute('name') && (!$node->getNode('arguments')->hasNode('with_context') || false !== $node->getNode('arguments')->getNode('with_context')->getAttribute('value') - ) + ) ) { $this->addLoopToAll(); } @@ -175,12 +171,12 @@ private function enterOptimizeFor(Node $node, Environment $env): void elseif ($node instanceof GetAttrExpression && (!$node->getNode('attribute') instanceof ConstantExpression || 'parent' === $node->getNode('attribute')->getAttribute('value') - ) + ) && (true === $this->loops[0]->getAttribute('with_loop') - || ($node->getNode('node') instanceof NameExpression - && 'loop' === $node->getNode('node')->getAttribute('name') - ) - ) + || ($node->getNode('node') instanceof ContextVariable + && 'loop' === $node->getNode('node')->getAttribute('name') + ) + ) ) { $this->addLoopToAll(); } @@ -189,7 +185,7 @@ private function enterOptimizeFor(Node $node, Environment $env): void /** * Optimizes "for" tag by removing the "loop" variable creation whenever possible. */ - private function leaveOptimizeFor(Node $node, Environment $env): void + private function leaveOptimizeFor(Node $node): void { if ($node instanceof ForNode) { array_shift($this->loops); diff --git a/src/NodeVisitor/SafeAnalysisNodeVisitor.php b/src/NodeVisitor/SafeAnalysisNodeVisitor.php index 90d6f2e0fd0..8cb5f7a39a5 100644 --- a/src/NodeVisitor/SafeAnalysisNodeVisitor.php +++ b/src/NodeVisitor/SafeAnalysisNodeVisitor.php @@ -13,14 +13,15 @@ use Twig\Environment; use Twig\Node\Expression\BlockReferenceExpression; -use Twig\Node\Expression\ConditionalExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\FunctionExpression; use Twig\Node\Expression\GetAttrExpression; +use Twig\Node\Expression\MacroReferenceExpression; use Twig\Node\Expression\MethodCallExpression; -use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\OperatorEscapeInterface; use Twig\Node\Expression\ParentExpression; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; /** @@ -36,11 +37,14 @@ public function setSafeVars(array $safeVars): void $this->safeVars = $safeVars; } + /** + * @return array + */ public function getSafe(Node $node) { - $hash = spl_object_hash($node); + $hash = spl_object_id($node); if (!isset($this->data[$hash])) { - return; + return []; } foreach ($this->data[$hash] as $bucket) { @@ -48,17 +52,19 @@ public function getSafe(Node $node) continue; } - if (\in_array('html_attr', $bucket['value'])) { + if (\in_array('html_attr', $bucket['value'], true)) { $bucket['value'][] = 'html'; } return $bucket['value']; } + + return []; } private function setSafe(Node $node, array $safe): void { - $hash = spl_object_hash($node); + $hash = spl_object_id($node); if (isset($this->data[$hash])) { foreach ($this->data[$hash] as &$bucket) { if ($bucket['key'] === $node) { @@ -90,63 +96,77 @@ public function leaveNode(Node $node, Environment $env): ?Node } elseif ($node instanceof ParentExpression) { // parent block is safe by definition $this->setSafe($node, ['all']); - } elseif ($node instanceof ConditionalExpression) { - // intersect safeness of both operands - $safe = $this->intersectSafe($this->getSafe($node->getNode('expr2')), $this->getSafe($node->getNode('expr3'))); - $this->setSafe($node, $safe); + } elseif ($node instanceof OperatorEscapeInterface) { + // intersect safeness of operands + $operands = $node->getOperandNamesToEscape(); + if (2 < \count($operands)) { + throw new \LogicException(\sprintf('Operators with more than 2 operands are not supported yet, got %d.', \count($operands))); + } elseif (2 === \count($operands)) { + $safe = $this->intersectSafe($this->getSafe($node->getNode($operands[0])), $this->getSafe($node->getNode($operands[1]))); + $this->setSafe($node, $safe); + } } elseif ($node instanceof FilterExpression) { // filter expression is safe when the filter is safe - $name = $node->getNode('filter')->getAttribute('value'); - $args = $node->getNode('arguments'); - if ($filter = $env->getFilter($name)) { - $safe = $filter->getSafe($args); + if ($node->hasAttribute('twig_callable')) { + $filter = $node->getAttribute('twig_callable'); + } else { + // legacy + $filter = $env->getFilter($node->getAttribute('name')); + } + + if ($filter) { + $safe = $filter->getSafe($node->getNode('arguments')); if (null === $safe) { + trigger_deprecation('twig/twig', '3.16', 'The "%s::getSafe()" method should not return "null" anymore, return "[]" instead.', $filter::class); + $safe = []; + } + + if (!$safe) { $safe = $this->intersectSafe($this->getSafe($node->getNode('node')), $filter->getPreservesSafety()); } $this->setSafe($node, $safe); - } else { - $this->setSafe($node, []); } } elseif ($node instanceof FunctionExpression) { // function expression is safe when the function is safe - $name = $node->getAttribute('name'); - $args = $node->getNode('arguments'); - if ($function = $env->getFunction($name)) { - $this->setSafe($node, $function->getSafe($args)); + if ($node->hasAttribute('twig_callable')) { + $function = $node->getAttribute('twig_callable'); } else { - $this->setSafe($node, []); + // legacy + $function = $env->getFunction($node->getAttribute('name')); } - } elseif ($node instanceof MethodCallExpression) { - if ($node->getAttribute('safe')) { - $this->setSafe($node, ['all']); - } else { - $this->setSafe($node, []); + + if ($function) { + $safe = $function->getSafe($node->getNode('arguments')); + if (null === $safe) { + trigger_deprecation('twig/twig', '3.16', 'The "%s::getSafe()" method should not return "null" anymore, return "[]" instead.', $function::class); + $safe = []; + } + $this->setSafe($node, $safe); } - } elseif ($node instanceof GetAttrExpression && $node->getNode('node') instanceof NameExpression) { + } elseif ($node instanceof MethodCallExpression || $node instanceof MacroReferenceExpression) { + // all macro calls are safe + $this->setSafe($node, ['all']); + } elseif ($node instanceof GetAttrExpression && $node->getNode('node') instanceof ContextVariable) { $name = $node->getNode('node')->getAttribute('name'); - if (\in_array($name, $this->safeVars)) { + if (\in_array($name, $this->safeVars, true)) { $this->setSafe($node, ['all']); - } else { - $this->setSafe($node, []); } - } else { - $this->setSafe($node, []); } return $node; } - private function intersectSafe(array $a = null, array $b = null): array + private function intersectSafe(array $a, array $b): array { - if (null === $a || null === $b) { + if (!$a || !$b) { return []; } - if (\in_array('all', $a)) { + if (\in_array('all', $a, true)) { return $b; } - if (\in_array('all', $b)) { + if (\in_array('all', $b, true)) { return $a; } diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index 1446cee6b98..9dd48f5be95 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -15,14 +15,17 @@ use Twig\Node\CheckSecurityCallNode; use Twig\Node\CheckSecurityNode; use Twig\Node\CheckToStringNode; +use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\Binary\ConcatBinary; use Twig\Node\Expression\Binary\RangeBinary; use Twig\Node\Expression\FilterExpression; use Twig\Node\Expression\FunctionExpression; use Twig\Node\Expression\GetAttrExpression; -use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\Unary\SpreadUnary; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\PrintNode; use Twig\Node\SetNode; @@ -34,8 +37,11 @@ final class SandboxNodeVisitor implements NodeVisitorInterface { private $inAModule = false; + /** @var array */ private $tags; + /** @var array */ private $filters; + /** @var array */ private $functions; private $needsToStringWrap = false; @@ -51,22 +57,22 @@ public function enterNode(Node $node, Environment $env): Node } elseif ($this->inAModule) { // look for tags if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()])) { - $this->tags[$node->getNodeTag()] = $node; + $this->tags[$node->getNodeTag()] = $node->getTemplateLine(); } // look for filters - if ($node instanceof FilterExpression && !isset($this->filters[$node->getNode('filter')->getAttribute('value')])) { - $this->filters[$node->getNode('filter')->getAttribute('value')] = $node; + if ($node instanceof FilterExpression && !isset($this->filters[$node->getAttribute('name')])) { + $this->filters[$node->getAttribute('name')] = $node->getTemplateLine(); } // look for functions if ($node instanceof FunctionExpression && !isset($this->functions[$node->getAttribute('name')])) { - $this->functions[$node->getAttribute('name')] = $node; + $this->functions[$node->getAttribute('name')] = $node->getTemplateLine(); } // the .. operator is equivalent to the range() function if ($node instanceof RangeBinary && !isset($this->functions['range'])) { - $this->functions['range'] = $node; + $this->functions['range'] = $node->getTemplateLine(); } if ($node instanceof PrintNode) { @@ -102,8 +108,8 @@ public function leaveNode(Node $node, Environment $env): ?Node if ($node instanceof ModuleNode) { $this->inAModule = false; - $node->setNode('constructor_end', new Node([new CheckSecurityCallNode(), $node->getNode('constructor_end')])); - $node->setNode('class_end', new Node([new CheckSecurityNode($this->filters, $this->tags, $this->functions), $node->getNode('class_end')])); + $node->setNode('constructor_end', new Nodes([new CheckSecurityCallNode(), $node->getNode('constructor_end')])); + $node->setNode('class_end', new Nodes([new CheckSecurityNode($this->filters, $this->tags, $this->functions), $node->getNode('class_end')])); } elseif ($this->inAModule) { if ($node instanceof PrintNode || $node instanceof SetNode) { $this->needsToStringWrap = false; @@ -116,8 +122,14 @@ public function leaveNode(Node $node, Environment $env): ?Node private function wrapNode(Node $node, string $name): void { $expr = $node->getNode($name); - if ($expr instanceof NameExpression || $expr instanceof GetAttrExpression) { + if (($expr instanceof ContextVariable || $expr instanceof GetAttrExpression) && !$expr->isGenerator()) { $node->setNode($name, new CheckToStringNode($expr)); + } elseif ($expr instanceof SpreadUnary) { + $this->wrapNode($expr, 'node'); + } elseif ($expr instanceof ArrayExpression) { + foreach ($expr as $name => $_) { + $this->wrapNode($expr, $name); + } } } diff --git a/src/NodeVisitor/YieldNotReadyNodeVisitor.php b/src/NodeVisitor/YieldNotReadyNodeVisitor.php new file mode 100644 index 00000000000..4d6cf60a0ae --- /dev/null +++ b/src/NodeVisitor/YieldNotReadyNodeVisitor.php @@ -0,0 +1,59 @@ +yieldReadyNodes[$class])) { + return $node; + } + + if (!$this->yieldReadyNodes[$class] = (bool) (new \ReflectionClass($class))->getAttributes(YieldReady::class)) { + if ($this->useYield) { + throw new \LogicException(\sprintf('You cannot enable the "use_yield" option of Twig as node "%s" is not marked as ready for it; please make it ready and then flag it with the #[\Twig\Attribute\YieldReady] attribute.', $class)); + } + + trigger_deprecation('twig/twig', '3.9', 'Twig node "%s" is not marked as ready for using "yield" instead of "echo"; please make it ready and then flag it with the #[\Twig\Attribute\YieldReady] attribute.', $class); + } + + return $node; + } + + public function leaveNode(Node $node, Environment $env): ?Node + { + return $node; + } + + public function getPriority(): int + { + return 255; + } +} diff --git a/src/OperatorPrecedenceChange.php b/src/OperatorPrecedenceChange.php new file mode 100644 index 00000000000..31ebaef48cb --- /dev/null +++ b/src/OperatorPrecedenceChange.php @@ -0,0 +1,34 @@ + + * + * @deprecated since Twig 1.20 Use Twig\ExpressionParser\PrecedenceChange instead + */ +class OperatorPrecedenceChange extends PrecedenceChange +{ + public function __construct( + private string $package, + private string $version, + private int $newPrecedence, + ) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" class is deprecated since Twig 3.21. Use "%s" instead.', self::class, PrecedenceChange::class); + + parent::__construct($package, $version, $newPrecedence); + } +} diff --git a/src/Parser.php b/src/Parser.php index 4428208fed3..acc1a4dc99c 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -13,18 +13,29 @@ namespace Twig; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\ExpressionParserInterface; +use Twig\ExpressionParser\ExpressionParsers; +use Twig\ExpressionParser\ExpressionParserType; +use Twig\ExpressionParser\InfixExpressionParserInterface; +use Twig\ExpressionParser\Prefix\LiteralExpressionParser; +use Twig\ExpressionParser\PrefixExpressionParserInterface; use Twig\Node\BlockNode; use Twig\Node\BlockReferenceNode; use Twig\Node\BodyNode; +use Twig\Node\EmptyNode; use Twig\Node\Expression\AbstractExpression; +use Twig\Node\Expression\Variable\AssignTemplateVariable; +use Twig\Node\Expression\Variable\TemplateVariable; use Twig\Node\MacroNode; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\Node\NodeCaptureInterface; use Twig\Node\NodeOutputInterface; +use Twig\Node\Nodes; use Twig\Node\PrintNode; use Twig\Node\TextNode; use Twig\TokenParser\TokenParserInterface; +use Twig\Util\ReflectionCallable; /** * @author Fabien Potencier @@ -32,6 +43,7 @@ class Parser { private $stack = []; + private ?\WeakMap $expressionRefs = null; private $stream; private $parent; private $visitors; @@ -39,20 +51,29 @@ class Parser private $blocks; private $blockStack; private $macros; - private $env; private $importedSymbols; private $traits; private $embeddedTemplates = []; private $varNameSalt = 0; + private $ignoreUnknownTwigCallables = false; + private ExpressionParsers $parsers; - public function __construct(Environment $env) + public function __construct( + private Environment $env, + ) { + $this->parsers = $env->getExpressionParsers(); + } + + public function getEnvironment(): Environment { - $this->env = $env; + return $this->env; } public function getVarName(): string { - return sprintf('__internal_parse_%d', $this->varNameSalt++); + trigger_deprecation('twig/twig', '3.15', 'The "%s()" method is deprecated.', __METHOD__); + + return \sprintf('__internal_parse_%d', $this->varNameSalt++); } public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode @@ -66,10 +87,6 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals $this->visitors = $this->env->getNodeVisitors(); } - if (null === $this->expressionParser) { - $this->expressionParser = new ExpressionParser($this, $this->env); - } - $this->stream = $stream; $this->parent = null; $this->blocks = []; @@ -78,12 +95,13 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals $this->blockStack = []; $this->importedSymbols = [[]]; $this->embeddedTemplates = []; + $this->expressionRefs = new \WeakMap(); try { $body = $this->subparse($test, $dropNeedle); if (null !== $this->parent && null === $body = $this->filterBodyNodes($body)) { - $body = new Node(); + $body = new EmptyNode(); } } catch (SyntaxError $e) { if (!$e->getSourceContext()) { @@ -91,16 +109,29 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals } if (!$e->getTemplateLine()) { - $e->setTemplateLine($this->stream->getCurrent()->getLine()); + $e->setTemplateLine($this->getCurrentToken()->getLine()); } throw $e; + } finally { + $this->expressionRefs = null; } - $node = new ModuleNode(new BodyNode([$body]), $this->parent, new Node($this->blocks), new Node($this->macros), new Node($this->traits), $this->embeddedTemplates, $stream->getSourceContext()); + $node = new ModuleNode( + new BodyNode([$body]), + $this->parent, + $this->blocks ? new Nodes($this->blocks) : new EmptyNode(), + $this->macros ? new Nodes($this->macros) : new EmptyNode(), + $this->traits ? new Nodes($this->traits) : new EmptyNode(), + $this->embeddedTemplates ? new Nodes($this->embeddedTemplates) : new EmptyNode(), + $stream->getSourceContext(), + ); $traverser = new NodeTraverser($this->env, $this->visitors); + /** + * @var ModuleNode $node + */ $node = $traverser->traverse($node); // restore previous stack so previous parse() call can resume working @@ -111,29 +142,45 @@ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = fals return $node; } + public function shouldIgnoreUnknownTwigCallables(): bool + { + return $this->ignoreUnknownTwigCallables; + } + + public function subparseIgnoreUnknownTwigCallables($test, bool $dropNeedle = false): void + { + $previous = $this->ignoreUnknownTwigCallables; + $this->ignoreUnknownTwigCallables = true; + try { + $this->subparse($test, $dropNeedle); + } finally { + $this->ignoreUnknownTwigCallables = $previous; + } + } + public function subparse($test, bool $dropNeedle = false): Node { $lineno = $this->getCurrentToken()->getLine(); $rv = []; while (!$this->stream->isEOF()) { - switch ($this->getCurrentToken()->getType()) { - case /* Token::TEXT_TYPE */ 0: + switch (true) { + case $this->stream->getCurrent()->test(Token::TEXT_TYPE): $token = $this->stream->next(); $rv[] = new TextNode($token->getValue(), $token->getLine()); break; - case /* Token::VAR_START_TYPE */ 2: + case $this->stream->getCurrent()->test(Token::VAR_START_TYPE): $token = $this->stream->next(); - $expr = $this->expressionParser->parseExpression(); - $this->stream->expect(/* Token::VAR_END_TYPE */ 4); + $expr = $this->parseExpression(); + $this->stream->expect(Token::VAR_END_TYPE); $rv[] = new PrintNode($expr, $token->getLine()); break; - case /* Token::BLOCK_START_TYPE */ 1: + case $this->stream->getCurrent()->test(Token::BLOCK_START_TYPE): $this->stream->next(); $token = $this->getCurrentToken(); - if (/* Token::NAME_TYPE */ 5 !== $token->getType()) { + if (!$token->test(Token::NAME_TYPE)) { throw new SyntaxError('A block must start with a tag name.', $token->getLine(), $this->stream->getSourceContext()); } @@ -146,18 +193,19 @@ public function subparse($test, bool $dropNeedle = false): Node return $rv[0]; } - return new Node($rv, [], $lineno); + return new Nodes($rv, $lineno); } if (!$subparser = $this->env->getTokenParser($token->getValue())) { if (null !== $test) { - $e = new SyntaxError(sprintf('Unexpected "%s" tag', $token->getValue()), $token->getLine(), $this->stream->getSourceContext()); + $e = new SyntaxError(\sprintf('Unexpected "%s" tag', $token->getValue()), $token->getLine(), $this->stream->getSourceContext()); - if (\is_array($test) && isset($test[0]) && $test[0] instanceof TokenParserInterface) { - $e->appendMessage(sprintf(' (expecting closing tag for the "%s" tag defined near line %s).', $test[0]->getTag(), $lineno)); + $callable = (new ReflectionCallable(new TwigTest('decision', $test)))->getCallable(); + if (\is_array($callable) && $callable[0] instanceof TokenParserInterface) { + $e->appendMessage(\sprintf(' (expecting closing tag for the "%s" tag defined near line %s).', $callable[0]->getTag(), $lineno)); } } else { - $e = new SyntaxError(sprintf('Unknown "%s" tag.', $token->getValue()), $token->getLine(), $this->stream->getSourceContext()); + $e = new SyntaxError(\sprintf('Unknown "%s" tag.', $token->getValue()), $token->getLine(), $this->stream->getSourceContext()); $e->addSuggestions($token->getValue(), array_keys($this->env->getTokenParsers())); } @@ -168,13 +216,16 @@ public function subparse($test, bool $dropNeedle = false): Node $subparser->setParser($this); $node = $subparser->parse($token); - if (null !== $node) { + if (!$node) { + trigger_deprecation('twig/twig', '3.12', 'Returning "null" from "%s" is deprecated and forbidden by "TokenParserInterface".', $subparser::class); + } else { + $node->setNodeTag($subparser->getTag()); $rv[] = $node; } break; default: - throw new SyntaxError('Lexer or parser ended up in unsupported state.', $this->getCurrentToken()->getLine(), $this->stream->getSourceContext()); + throw new SyntaxError('The lexer or the parser ended up in an unsupported state.', $this->getCurrentToken()->getLine(), $this->stream->getSourceContext()); } } @@ -182,14 +233,19 @@ public function subparse($test, bool $dropNeedle = false): Node return $rv[0]; } - return new Node($rv, [], $lineno); + return new Nodes($rv, $lineno); } public function getBlockStack(): array { + trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); + return $this->blockStack; } + /** + * @return string|null + */ public function peekBlockStack() { return $this->blockStack[\count($this->blockStack) - 1] ?? null; @@ -207,21 +263,31 @@ public function pushBlockStack($name): void public function hasBlock(string $name): bool { + trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); + return isset($this->blocks[$name]); } public function getBlock(string $name): Node { + trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); + return $this->blocks[$name]; } public function setBlock(string $name, BlockNode $value): void { + if (isset($this->blocks[$name])) { + throw new SyntaxError(\sprintf("The block '%s' has already been defined line %d.", $name, $this->blocks[$name]->getTemplateLine()), $this->getCurrentToken()->getLine(), $this->blocks[$name]->getSourceContext()); + } + $this->blocks[$name] = new BodyNode([$value], [], $value->getTemplateLine()); } public function hasMacro(string $name): bool { + trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); + return isset($this->macros[$name]); } @@ -237,9 +303,14 @@ public function addTrait($trait): void public function hasTraits(): bool { + trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); + return \count($this->traits) > 0; } + /** + * @return void + */ public function embedTemplate(ModuleNode $template) { $template->setIndex(mt_rand()); @@ -247,11 +318,20 @@ public function embedTemplate(ModuleNode $template) $this->embeddedTemplates[] = $template; } - public function addImportedSymbol(string $type, string $alias, string $name = null, AbstractExpression $node = null): void + public function addImportedSymbol(string $type, string $alias, ?string $name = null, AbstractExpression|AssignTemplateVariable|null $internalRef = null): void { - $this->importedSymbols[0][$type][$alias] = ['name' => $name, 'node' => $node]; + if ($internalRef && !$internalRef instanceof AssignTemplateVariable) { + trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance as an internal reference is deprecated ("%s" given).', __METHOD__, AssignTemplateVariable::class, $internalRef::class); + + $internalRef = new AssignTemplateVariable(new TemplateVariable($internalRef->getAttribute('name'), $internalRef->getTemplateLine()), $internalRef->getAttribute('global')); + } + + $this->importedSymbols[0][$type][$alias] = ['name' => $name, 'node' => $internalRef]; } + /** + * @return array{name: string, node: AssignTemplateVariable|null}|null + */ public function getImportedSymbol(string $type, string $alias) { // if the symbol does not exist in the current scope (0), try in the main/global scope (last index) @@ -273,18 +353,67 @@ public function popLocalScope(): void array_shift($this->importedSymbols); } + /** + * @deprecated since Twig 3.21 + */ public function getExpressionParser(): ExpressionParser { + trigger_deprecation('twig/twig', '3.21', 'Method "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); + + if (null === $this->expressionParser) { + $this->expressionParser = new ExpressionParser($this, $this->env); + } + return $this->expressionParser; } + public function parseExpression(int $precedence = 0): AbstractExpression + { + $token = $this->getCurrentToken(); + if ($token->test(Token::OPERATOR_TYPE) && $ep = $this->parsers->getByName(PrefixExpressionParserInterface::class, $token->getValue())) { + $this->getStream()->next(); + $expr = $ep->parse($this, $token); + $this->checkPrecedenceDeprecations($ep, $expr); + } else { + $expr = $this->parsers->getByClass(LiteralExpressionParser::class)->parse($this, $token); + } + + $token = $this->getCurrentToken(); + while ($token->test(Token::OPERATOR_TYPE) && ($ep = $this->parsers->getByName(InfixExpressionParserInterface::class, $token->getValue())) && $ep->getPrecedence() >= $precedence) { + $this->getStream()->next(); + $expr = $ep->parse($this, $expr, $token); + $this->checkPrecedenceDeprecations($ep, $expr); + $token = $this->getCurrentToken(); + } + + return $expr; + } + public function getParent(): ?Node { + trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__); + return $this->parent; } + /** + * @return bool + */ + public function hasInheritance() + { + return $this->parent || 0 < \count($this->traits); + } + public function setParent(?Node $parent): void { + if (null === $parent) { + trigger_deprecation('twig/twig', '3.12', 'Passing "null" to "%s()" is deprecated.', __METHOD__); + } + + if (null !== $this->parent) { + throw new SyntaxError('Multiple extends tags are forbidden.', $parent->getTemplateLine(), $parent->getSourceContext()); + } + $this->parent = $parent; } @@ -298,15 +427,121 @@ public function getCurrentToken(): Token return $this->stream->getCurrent(); } + public function getFunction(string $name, int $line): TwigFunction + { + try { + $function = $this->env->getFunction($name); + } catch (SyntaxError $e) { + if (!$this->shouldIgnoreUnknownTwigCallables()) { + throw $e; + } + + $function = null; + } + + if (!$function) { + if ($this->shouldIgnoreUnknownTwigCallables()) { + return new TwigFunction($name, fn () => ''); + } + $e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getFunctions())); + + throw $e; + } + + if ($function->isDeprecated()) { + $src = $this->stream->getSourceContext(); + $function->triggerDeprecation($src->getPath() ?: $src->getName(), $line); + } + + return $function; + } + + public function getFilter(string $name, int $line): TwigFilter + { + try { + $filter = $this->env->getFilter($name); + } catch (SyntaxError $e) { + if (!$this->shouldIgnoreUnknownTwigCallables()) { + throw $e; + } + + $filter = null; + } + if (!$filter) { + if ($this->shouldIgnoreUnknownTwigCallables()) { + return new TwigFilter($name, fn () => ''); + } + $e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getFilters())); + + throw $e; + } + + if ($filter->isDeprecated()) { + $src = $this->stream->getSourceContext(); + $filter->triggerDeprecation($src->getPath() ?: $src->getName(), $line); + } + + return $filter; + } + + public function getTest(int $line): TwigTest + { + $name = $this->stream->expect(Token::NAME_TYPE)->getValue(); + + if ($this->stream->test(Token::NAME_TYPE)) { + // try 2-words tests + $name = $name.' '.$this->getCurrentToken()->getValue(); + + try { + $test = $this->env->getTest($name); + } catch (SyntaxError $e) { + if (!$this->shouldIgnoreUnknownTwigCallables()) { + throw $e; + } + + $test = null; + } + $this->stream->next(); + } else { + try { + $test = $this->env->getTest($name); + } catch (SyntaxError $e) { + if (!$this->shouldIgnoreUnknownTwigCallables()) { + throw $e; + } + + $test = null; + } + } + + if (!$test) { + if ($this->shouldIgnoreUnknownTwigCallables()) { + return new TwigTest($name, fn () => ''); + } + $e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $this->stream->getSourceContext()); + $e->addSuggestions($name, array_keys($this->env->getTests())); + + throw $e; + } + + if ($test->isDeprecated()) { + $src = $this->stream->getSourceContext(); + $test->triggerDeprecation($src->getPath() ?: $src->getName(), $this->stream->getCurrent()->getLine()); + } + + return $test; + } + private function filterBodyNodes(Node $node, bool $nested = false): ?Node { // check that the body does not contain non-empty output nodes if ( ($node instanceof TextNode && !ctype_space($node->getAttribute('data'))) - || - (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface) + || (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface) ) { - if (false !== strpos((string) $node, \chr(0xEF).\chr(0xBB).\chr(0xBF))) { + if (str_contains((string) $node, \chr(0xEF).\chr(0xBB).\chr(0xBF))) { $t = substr($node->getAttribute('data'), 3); if ('' === $t || ctype_space($t)) { // bypass empty nodes starting with a BOM @@ -336,7 +571,8 @@ private function filterBodyNodes(Node $node, bool $nested = false): ?Node // here, $nested means "being at the root level of a child template" // we need to discard the wrapping "Node" for the "body" node - $nested = $nested || Node::class !== \get_class($node); + // Node::class !== \get_class($node) should be removed in Twig 4.0 + $nested = $nested || (Node::class !== $node::class && !$node instanceof Nodes); foreach ($node as $k => $n) { if (null !== $n && null === $this->filterBodyNodes($n, $nested)) { $node->removeNode($k); @@ -345,4 +581,43 @@ private function filterBodyNodes(Node $node, bool $nested = false): ?Node return $node; } + + private function checkPrecedenceDeprecations(ExpressionParserInterface $expressionParser, AbstractExpression $expr) + { + $this->expressionRefs[$expr] = $expressionParser; + $precedenceChanges = $this->parsers->getPrecedenceChanges(); + + // Check that the all nodes that are between the 2 precedences have explicit parentheses + if (!isset($precedenceChanges[$expressionParser])) { + return; + } + + if ($expr->hasExplicitParentheses()) { + return; + } + + if ($expressionParser instanceof PrefixExpressionParserInterface) { + /** @var AbstractExpression $node */ + $node = $expr->getNode('node'); + foreach ($precedenceChanges as $ep => $changes) { + if (!\in_array($expressionParser, $changes, true)) { + continue; + } + if (isset($this->expressionRefs[$node]) && $ep === $this->expressionRefs[$node]) { + $change = $expressionParser->getPrecedenceChange(); + trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('As the "%s" %s operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "%s" at line %d.', $expressionParser->getName(), ExpressionParserType::getType($expressionParser)->value, $this->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); + } + } + } + + foreach ($precedenceChanges[$expressionParser] as $ep) { + foreach ($expr as $node) { + /** @var AbstractExpression $node */ + if (isset($this->expressionRefs[$node]) && $ep === $this->expressionRefs[$node] && !$node->hasExplicitParentheses()) { + $change = $ep->getPrecedenceChange(); + trigger_deprecation($change->getPackage(), $change->getVersion(), \sprintf('As the "%s" %s operator will change its precedence in the next major version, add explicit parentheses to avoid behavior change in "%s" at line %d.', $ep->getName(), ExpressionParserType::getType($ep)->value, $this->getStream()->getSourceContext()->getName(), $node->getTemplateLine())); + } + } + } + } } diff --git a/src/Profiler/Dumper/BaseDumper.php b/src/Profiler/Dumper/BaseDumper.php index 4da43e475fb..267718c1f5f 100644 --- a/src/Profiler/Dumper/BaseDumper.php +++ b/src/Profiler/Dumper/BaseDumper.php @@ -50,7 +50,7 @@ private function dumpProfile(Profile $profile, $prefix = '', $sibling = false): if ($profile->getDuration() * 1000 < 1) { $str = $start."\n"; } else { - $str = sprintf("%s %s\n", $start, $this->formatTime($profile, $percent)); + $str = \sprintf("%s %s\n", $start, $this->formatTime($profile, $percent)); } $nCount = \count($profile->getProfiles()); diff --git a/src/Profiler/Dumper/BlackfireDumper.php b/src/Profiler/Dumper/BlackfireDumper.php index 03abe0fa071..7cfae16f1c6 100644 --- a/src/Profiler/Dumper/BlackfireDumper.php +++ b/src/Profiler/Dumper/BlackfireDumper.php @@ -24,7 +24,7 @@ public function dump(Profile $profile): string $this->dumpProfile('main()', $profile, $data); $this->dumpChildren('main()', $profile, $data); - $start = sprintf('%f', microtime(true)); + $start = \sprintf('%f', microtime(true)); $str = <<isTemplate()) { $name = $p->getTemplate(); } else { - $name = sprintf('%s::%s(%s)', $p->getTemplate(), $p->getType(), $p->getName()); + $name = \sprintf('%s::%s(%s)', $p->getTemplate(), $p->getType(), $p->getName()); } - $this->dumpProfile(sprintf('%s==>%s', $parent, $name), $p, $data); + $this->dumpProfile(\sprintf('%s==>%s', $parent, $name), $p, $data); $this->dumpChildren($name, $p, $data); } } - private function dumpProfile(string $edge, Profile $profile, &$data) + private function dumpProfile(string $edge, Profile $profile, &$data): void { if (isset($data[$edge])) { ++$data[$edge]['ct']; diff --git a/src/Profiler/Dumper/HtmlDumper.php b/src/Profiler/Dumper/HtmlDumper.php index 1f2433b4d36..cdab2de5953 100644 --- a/src/Profiler/Dumper/HtmlDumper.php +++ b/src/Profiler/Dumper/HtmlDumper.php @@ -32,16 +32,16 @@ public function dump(Profile $profile): string protected function formatTemplate(Profile $profile, $prefix): string { - return sprintf('%s└ %s', $prefix, self::$colors['template'], $profile->getTemplate()); + return \sprintf('%s└ %s', $prefix, self::$colors['template'], $profile->getTemplate()); } protected function formatNonTemplate(Profile $profile, $prefix): string { - return sprintf('%s└ %s::%s(%s)', $prefix, $profile->getTemplate(), $profile->getType(), isset(self::$colors[$profile->getType()]) ? self::$colors[$profile->getType()] : 'auto', $profile->getName()); + return \sprintf('%s└ %s::%s(%s)', $prefix, $profile->getTemplate(), $profile->getType(), self::$colors[$profile->getType()] ?? 'auto', $profile->getName()); } protected function formatTime(Profile $profile, $percent): string { - return sprintf('%.2fms/%.0f%%', $percent > 20 ? self::$colors['big'] : 'auto', $profile->getDuration() * 1000, $percent); + return \sprintf('%.2fms/%.0f%%', $percent > 20 ? self::$colors['big'] : 'auto', $profile->getDuration() * 1000, $percent); } } diff --git a/src/Profiler/Dumper/TextDumper.php b/src/Profiler/Dumper/TextDumper.php index 31561c466bb..1c1f77e949c 100644 --- a/src/Profiler/Dumper/TextDumper.php +++ b/src/Profiler/Dumper/TextDumper.php @@ -20,16 +20,16 @@ final class TextDumper extends BaseDumper { protected function formatTemplate(Profile $profile, $prefix): string { - return sprintf('%s└ %s', $prefix, $profile->getTemplate()); + return \sprintf('%s└ %s', $prefix, $profile->getTemplate()); } protected function formatNonTemplate(Profile $profile, $prefix): string { - return sprintf('%s└ %s::%s(%s)', $prefix, $profile->getTemplate(), $profile->getType(), $profile->getName()); + return \sprintf('%s└ %s::%s(%s)', $prefix, $profile->getTemplate(), $profile->getType(), $profile->getName()); } protected function formatTime(Profile $profile, $percent): string { - return sprintf('%.2fms/%.0f%%', $profile->getDuration() * 1000, $percent); + return \sprintf('%.2fms/%.0f%%', $profile->getDuration() * 1000, $percent); } } diff --git a/src/Profiler/Node/EnterProfileNode.php b/src/Profiler/Node/EnterProfileNode.php index 1494baf44a3..4d8e504d1d7 100644 --- a/src/Profiler/Node/EnterProfileNode.php +++ b/src/Profiler/Node/EnterProfileNode.php @@ -11,6 +11,7 @@ namespace Twig\Profiler\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Node; @@ -19,6 +20,7 @@ * * @author Fabien Potencier */ +#[YieldReady] class EnterProfileNode extends Node { public function __construct(string $extensionName, string $type, string $name, string $varName) @@ -29,10 +31,10 @@ public function __construct(string $extensionName, string $type, string $name, s public function compile(Compiler $compiler): void { $compiler - ->write(sprintf('$%s = $this->extensions[', $this->getAttribute('var_name'))) + ->write(\sprintf('$%s = $this->extensions[', $this->getAttribute('var_name'))) ->repr($this->getAttribute('extension_name')) ->raw("];\n") - ->write(sprintf('$%s->enter($%s = new \Twig\Profiler\Profile($this->getTemplateName(), ', $this->getAttribute('var_name'), $this->getAttribute('var_name').'_prof')) + ->write(\sprintf('$%s->enter($%s = new \Twig\Profiler\Profile($this->getTemplateName(), ', $this->getAttribute('var_name'), $this->getAttribute('var_name').'_prof')) ->repr($this->getAttribute('type')) ->raw(', ') ->repr($this->getAttribute('name')) diff --git a/src/Profiler/Node/LeaveProfileNode.php b/src/Profiler/Node/LeaveProfileNode.php index 94cebbaa832..bd9227e5271 100644 --- a/src/Profiler/Node/LeaveProfileNode.php +++ b/src/Profiler/Node/LeaveProfileNode.php @@ -11,6 +11,7 @@ namespace Twig\Profiler\Node; +use Twig\Attribute\YieldReady; use Twig\Compiler; use Twig\Node\Node; @@ -19,6 +20,7 @@ * * @author Fabien Potencier */ +#[YieldReady] class LeaveProfileNode extends Node { public function __construct(string $varName) @@ -30,7 +32,7 @@ public function compile(Compiler $compiler): void { $compiler ->write("\n") - ->write(sprintf("\$%s->leave(\$%s);\n\n", $this->getAttribute('var_name'), $this->getAttribute('var_name').'_prof')) + ->write(\sprintf("\$%s->leave(\$%s);\n\n", $this->getAttribute('var_name'), $this->getAttribute('var_name').'_prof')) ; } } diff --git a/src/Profiler/NodeVisitor/ProfilerNodeVisitor.php b/src/Profiler/NodeVisitor/ProfilerNodeVisitor.php index 91abee807df..4c5c2005d21 100644 --- a/src/Profiler/NodeVisitor/ProfilerNodeVisitor.php +++ b/src/Profiler/NodeVisitor/ProfilerNodeVisitor.php @@ -17,6 +17,7 @@ use Twig\Node\MacroNode; use Twig\Node\ModuleNode; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\NodeVisitor\NodeVisitorInterface; use Twig\Profiler\Node\EnterProfileNode; use Twig\Profiler\Node\LeaveProfileNode; @@ -27,13 +28,12 @@ */ final class ProfilerNodeVisitor implements NodeVisitorInterface { - private $extensionName; private $varName; - public function __construct(string $extensionName) - { - $this->extensionName = $extensionName; - $this->varName = sprintf('__internal_%s', hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $extensionName)); + public function __construct( + private string $extensionName, + ) { + $this->varName = \sprintf('__internal_%s', hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $extensionName)); } public function enterNode(Node $node, Environment $env): Node @@ -44,8 +44,8 @@ public function enterNode(Node $node, Environment $env): Node public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ModuleNode) { - $node->setNode('display_start', new Node([new EnterProfileNode($this->extensionName, Profile::TEMPLATE, $node->getTemplateName(), $this->varName), $node->getNode('display_start')])); - $node->setNode('display_end', new Node([new LeaveProfileNode($this->varName), $node->getNode('display_end')])); + $node->setNode('display_start', new Nodes([new EnterProfileNode($this->extensionName, Profile::TEMPLATE, $node->getTemplateName(), $this->varName), $node->getNode('display_start')])); + $node->setNode('display_end', new Nodes([new LeaveProfileNode($this->varName), $node->getNode('display_end')])); } elseif ($node instanceof BlockNode) { $node->setNode('body', new BodyNode([ new EnterProfileNode($this->extensionName, Profile::BLOCK, $node->getAttribute('name'), $this->varName), diff --git a/src/Profiler/Profile.php b/src/Profiler/Profile.php index 252ca9b0cf4..a3c6ee02e5a 100644 --- a/src/Profiler/Profile.php +++ b/src/Profiler/Profile.php @@ -20,19 +20,16 @@ final class Profile implements \IteratorAggregate, \Serializable public const BLOCK = 'block'; public const TEMPLATE = 'template'; public const MACRO = 'macro'; - - private $template; - private $name; - private $type; private $starts = []; private $ends = []; private $profiles = []; - public function __construct(string $template = 'main', string $type = self::ROOT, string $name = 'main') - { - $this->template = $template; - $this->type = $type; - $this->name = 0 === strpos($name, '__internal_') ? 'INTERNAL' : $name; + public function __construct( + private string $template = 'main', + private string $type = self::ROOT, + private string $name = 'main', + ) { + $this->name = str_starts_with($name, '__internal_') ? 'INTERNAL' : $name; $this->enter(); } @@ -102,6 +99,22 @@ public function getDuration(): float return isset($this->ends['wt']) && isset($this->starts['wt']) ? $this->ends['wt'] - $this->starts['wt'] : 0; } + /** + * Returns the start time in microseconds. + */ + public function getStartTime(): float + { + return $this->starts['wt'] ?? 0.0; + } + + /** + * Returns the end time in microseconds. + */ + public function getEndTime(): float + { + return $this->ends['wt'] ?? 0.0; + } + /** * Returns the memory usage in bytes. */ @@ -176,6 +189,6 @@ public function __serialize(): array */ public function __unserialize(array $data): void { - list($this->template, $this->name, $this->type, $this->starts, $this->ends, $this->profiles) = $data; + [$this->template, $this->name, $this->type, $this->starts, $this->ends, $this->profiles] = $data; } } diff --git a/src/Resources/core.php b/src/Resources/core.php new file mode 100644 index 00000000000..bc0b27104b0 --- /dev/null +++ b/src/Resources/core.php @@ -0,0 +1,541 @@ +getCharset(), $values, $max); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_date_format_filter(Environment $env, $date, $format = null, $timezone = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return $env->getExtension(CoreExtension::class)->formatDate($date, $format, $timezone); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_date_modify_filter(Environment $env, $date, $modifier) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return $env->getExtension(CoreExtension::class)->modifyDate($date, $modifier); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_sprintf($format, ...$values) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::sprintf($format, ...$values); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_date_converter(Environment $env, $date = null, $timezone = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return $env->getExtension(CoreExtension::class)->convertDate($date, $timezone); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_replace_filter($str, $from) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::replace($str, $from); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_round($value, $precision = 0, $method = 'common') +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::round($value, $precision, $method); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_number_format_filter(Environment $env, $number, $decimal = null, $decimalPoint = null, $thousandSep = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return $env->getExtension(CoreExtension::class)->formatNumber($number, $decimal, $decimalPoint, $thousandSep); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_urlencode_filter($url) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::urlencode($url); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_merge(...$arrays) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::merge(...$arrays); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_slice(Environment $env, $item, $start, $length = null, $preserveKeys = false) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::slice($env->getCharset(), $item, $start, $length, $preserveKeys); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_first(Environment $env, $item) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::first($env->getCharset(), $item); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_last(Environment $env, $item) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::last($env->getCharset(), $item); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_join_filter($value, $glue = '', $and = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::join($value, $glue, $and); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_split_filter(Environment $env, $value, $delimiter, $limit = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::split($env->getCharset(), $value, $delimiter, $limit); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_get_array_keys_filter($array) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::keys($array); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_reverse_filter(Environment $env, $item, $preserveKeys = false) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::reverse($env->getCharset(), $item, $preserveKeys); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_sort_filter(Environment $env, $array, $arrow = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::sort($env, $array, $arrow); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_matches(string $regexp, ?string $str) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::matches($regexp, $str); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_trim_filter($string, $characterMask = null, $side = 'both') +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::trim($string, $characterMask, $side); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_nl2br($string) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::nl2br($string); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_spaceless($content) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::spaceless($content); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_convert_encoding($string, $to, $from) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::convertEncoding($string, $to, $from); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_length_filter(Environment $env, $thing) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::length($env->getCharset(), $thing); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_upper_filter(Environment $env, $string) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::upper($env->getCharset(), $string); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_lower_filter(Environment $env, $string) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::lower($env->getCharset(), $string); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_striptags($string, $allowable_tags = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::striptags($string, $allowable_tags); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_title_string_filter(Environment $env, $string) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::titleCase($env->getCharset(), $string); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_capitalize_string_filter(Environment $env, $string) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::capitalize($env->getCharset(), $string); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_test_empty($value) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::testEmpty($value); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_test_iterable($value) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return is_iterable($value); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_include(Environment $env, $context, $template, $variables = [], $withContext = true, $ignoreMissing = false, $sandboxed = false) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::include($env, $context, $template, $variables, $withContext, $ignoreMissing, $sandboxed); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_source(Environment $env, $name, $ignoreMissing = false) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::source($env, $name, $ignoreMissing); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_constant($constant, $object = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::constant($constant, $object); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_constant_is_defined($constant, $object = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::constant($constant, $object, true); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_batch($items, $size, $fill = null, $preserveKeys = true) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::batch($items, $size, $fill, $preserveKeys); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_column($array, $name, $index = null): array +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::column($array, $name, $index); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_filter(Environment $env, $array, $arrow) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::filter($env, $array, $arrow); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_map(Environment $env, $array, $arrow) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::map($env, $array, $arrow); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_reduce(Environment $env, $array, $arrow, $initial = null) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::reduce($env, $array, $arrow, $initial); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_some(Environment $env, $array, $arrow) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::arraySome($env, $array, $arrow); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_array_every(Environment $env, $array, $arrow) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return CoreExtension::arrayEvery($env, $array, $arrow); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_check_arrow_in_sandbox(Environment $env, $arrow, $thing, $type) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + CoreExtension::checkArrow($env, $arrow, $thing, $type); +} diff --git a/src/Resources/debug.php b/src/Resources/debug.php new file mode 100644 index 00000000000..a0392ff513b --- /dev/null +++ b/src/Resources/debug.php @@ -0,0 +1,25 @@ +getRuntime(EscaperRuntime::class)->escape($string, $strategy, $charset, $autoescape); +} + +/** + * @internal + * + * @deprecated since Twig 3.9 + */ +function twig_escape_filter_is_safe(Node $filterArgs) +{ + trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__); + + return EscaperExtension::escapeFilterIsSafe($filterArgs); +} diff --git a/src/Resources/string_loader.php b/src/Resources/string_loader.php new file mode 100644 index 00000000000..c499e5ec2b0 --- /dev/null +++ b/src/Resources/string_loader.php @@ -0,0 +1,26 @@ + */ + private $escapers = []; + + /** @internal */ + public $safeClasses = []; + + /** @internal */ + public $safeLookup = []; + + public function __construct( + private $charset = 'UTF-8', + ) { + } + + /** + * Defines a new escaper to be used via the escape filter. + * + * @param string $strategy The strategy name that should be used as a strategy in the escape call + * @param callable(string $string, string $charset): string $callable A valid PHP callable + * + * @return void + */ + public function setEscaper($strategy, callable $callable) + { + $this->escapers[$strategy] = $callable; + } + + /** + * Gets all defined escapers. + * + * @return array An array of escapers + */ + public function getEscapers() + { + return $this->escapers; + } + + /** + * @param array, string[]> $safeClasses + * + * @return void + */ + public function setSafeClasses(array $safeClasses = []) + { + $this->safeClasses = []; + $this->safeLookup = []; + foreach ($safeClasses as $class => $strategies) { + $this->addSafeClass($class, $strategies); + } + } + + /** + * @param class-string<\Stringable> $class + * @param string[] $strategies + * + * @return void + */ + public function addSafeClass(string $class, array $strategies) + { + $class = ltrim($class, '\\'); + if (!isset($this->safeClasses[$class])) { + $this->safeClasses[$class] = []; + } + $this->safeClasses[$class] = array_merge($this->safeClasses[$class], $strategies); + + foreach ($strategies as $strategy) { + $this->safeLookup[$strategy][$class] = true; + } + } + + /** + * Escapes a string. + * + * @param mixed $string The value to be escaped + * @param string $strategy The escaping strategy + * @param string|null $charset The charset + * @param bool $autoescape Whether the function is called by the auto-escaping feature (true) or by the developer (false) + * + * @throws RuntimeError + */ + public function escape($string, string $strategy = 'html', ?string $charset = null, bool $autoescape = false) + { + if ($autoescape && $string instanceof Markup) { + return $string; + } + + if (!\is_string($string)) { + if ($string instanceof \Stringable) { + if ($autoescape) { + $c = $string::class; + if (!isset($this->safeClasses[$c])) { + $this->safeClasses[$c] = []; + foreach (class_parents($string) + class_implements($string) as $class) { + if (isset($this->safeClasses[$class])) { + $this->safeClasses[$c] = array_unique(array_merge($this->safeClasses[$c], $this->safeClasses[$class])); + foreach ($this->safeClasses[$class] as $s) { + $this->safeLookup[$s][$c] = true; + } + } + } + } + if (isset($this->safeLookup[$strategy][$c]) || isset($this->safeLookup['all'][$c])) { + return (string) $string; + } + } + + $string = (string) $string; + } elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'], true)) { + // we return the input as is (which can be of any type) + return $string; + } + } + + if ('' === $string) { + return ''; + } + + $charset = $charset ?: $this->charset; + + switch ($strategy) { + case 'html': + // see https://www.php.net/htmlspecialchars + + if ('UTF-8' === $charset) { + return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + } + + // Using a static variable to avoid initializing the array + // each time the function is called. Moving the declaration on the + // top of the function slow downs other escaping strategies. + static $htmlspecialcharsCharsets = [ + 'ISO-8859-1' => true, 'ISO8859-1' => true, + 'ISO-8859-15' => true, 'ISO8859-15' => true, + 'utf-8' => true, 'UTF-8' => true, + 'CP866' => true, 'IBM866' => true, '866' => true, + 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, + '1251' => true, + 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, + 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, + 'BIG5' => true, '950' => true, + 'GB2312' => true, '936' => true, + 'BIG5-HKSCS' => true, + 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, + 'EUC-JP' => true, 'EUCJP' => true, + 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, + ]; + + if (isset($htmlspecialcharsCharsets[$charset])) { + return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset); + } + + if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { + // cache the lowercase variant for future iterations + $htmlspecialcharsCharsets[$charset] = true; + + return htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, $charset); + } + + $string = $this->convertEncoding($string, 'UTF-8', $charset); + $string = htmlspecialchars($string, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + + return iconv('UTF-8', $charset, $string); + + case 'js': + // escape all non-alphanumeric characters + // into their \x or \uHHHH representations + if ('UTF-8' !== $charset) { + $string = $this->convertEncoding($string, 'UTF-8', $charset); + } + + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } + + $string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) { + $char = $matches[0]; + + /* + * A few characters have short escape sequences in JSON and JavaScript. + * Escape sequences supported only by JavaScript, not JSON, are omitted. + * \" is also supported but omitted, because the resulting string is not HTML safe. + */ + $short = match ($char) { + '\\' => '\\\\', + '/' => '\\/', + "\x08" => '\b', + "\x0C" => '\f', + "\x0A" => '\n', + "\x0D" => '\r', + "\x09" => '\t', + default => false, + }; + + if ($short) { + return $short; + } + + $codepoint = mb_ord($char, 'UTF-8'); + if (0x10000 > $codepoint) { + return \sprintf('\u%04X', $codepoint); + } + + // Split characters outside the BMP into surrogate pairs + // https://tools.ietf.org/html/rfc2781.html#section-2.1 + $u = $codepoint - 0x10000; + $high = 0xD800 | ($u >> 10); + $low = 0xDC00 | ($u & 0x3FF); + + return \sprintf('\u%04X\u%04X', $high, $low); + }, $string); + + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } + + return $string; + + case 'css': + if ('UTF-8' !== $charset) { + $string = $this->convertEncoding($string, 'UTF-8', $charset); + } + + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } + + $string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) { + $char = $matches[0]; + + return \sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8')); + }, $string); + + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } + + return $string; + + case 'html_attr': + if ('UTF-8' !== $charset) { + $string = $this->convertEncoding($string, 'UTF-8', $charset); + } + + if (!preg_match('//u', $string)) { + throw new RuntimeError('The string to escape is not a valid UTF-8 string.'); + } + + $string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) { + /** + * This function is adapted from code coming from Zend Framework. + * + * @copyright Copyright (c) 2005-2012 Zend Technologies USA Inc. (https://www.zend.com) + * @license https://framework.zend.com/license/new-bsd New BSD License + */ + $chr = $matches[0]; + $ord = \ord($chr); + + /* + * The following replaces characters undefined in HTML with the + * hex entity for the Unicode replacement character. + */ + if (($ord <= 0x1F && "\t" != $chr && "\n" != $chr && "\r" != $chr) || ($ord >= 0x7F && $ord <= 0x9F)) { + return '�'; + } + + /* + * Check if the current character to escape has a name entity we should + * replace it with while grabbing the hex value of the character. + */ + if (1 === \strlen($chr)) { + /* + * While HTML supports far more named entities, the lowest common denominator + * has become HTML5's XML Serialisation which is restricted to the those named + * entities that XML supports. Using HTML entities would result in this error: + * XML Parsing Error: undefined entity + */ + return match ($ord) { + 34 => '"', /* quotation mark */ + 38 => '&', /* ampersand */ + 60 => '<', /* less-than sign */ + 62 => '>', /* greater-than sign */ + default => \sprintf('&#x%02X;', $ord), + }; + } + + /* + * Per OWASP recommendations, we'll use hex entities for any other + * characters where a named entity does not exist. + */ + return \sprintf('&#x%04X;', mb_ord($chr, 'UTF-8')); + }, $string); + + if ('UTF-8' !== $charset) { + $string = iconv('UTF-8', $charset, $string); + } + + return $string; + + case 'url': + return rawurlencode($string); + + default: + if (\array_key_exists($strategy, $this->escapers)) { + return $this->escapers[$strategy]($string, $charset); + } + + $validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($this->escapers))); + + throw new RuntimeError(\sprintf('Invalid escaping strategy "%s" (valid ones: "%s").', $strategy, $validStrategies)); + } + } + + private function convertEncoding(string $string, string $to, string $from) + { + if (!\function_exists('iconv')) { + throw new RuntimeError('Unable to convert encoding: required function iconv() does not exist. You should install ext-iconv or symfony/polyfill-iconv.'); + } + + return iconv($from, $to, $string); + } +} diff --git a/src/RuntimeLoader/ContainerRuntimeLoader.php b/src/RuntimeLoader/ContainerRuntimeLoader.php index b360d7beaf1..05106680c4f 100644 --- a/src/RuntimeLoader/ContainerRuntimeLoader.php +++ b/src/RuntimeLoader/ContainerRuntimeLoader.php @@ -23,11 +23,9 @@ */ class ContainerRuntimeLoader implements RuntimeLoaderInterface { - private $container; - - public function __construct(ContainerInterface $container) - { - $this->container = $container; + public function __construct( + private ContainerInterface $container, + ) { } public function load(string $class) diff --git a/src/RuntimeLoader/FactoryRuntimeLoader.php b/src/RuntimeLoader/FactoryRuntimeLoader.php index 13064839267..5d4e70b921d 100644 --- a/src/RuntimeLoader/FactoryRuntimeLoader.php +++ b/src/RuntimeLoader/FactoryRuntimeLoader.php @@ -18,14 +18,12 @@ */ class FactoryRuntimeLoader implements RuntimeLoaderInterface { - private $map; - /** * @param array $map An array where keys are class names and values factory callables */ - public function __construct(array $map = []) - { - $this->map = $map; + public function __construct( + private array $map = [], + ) { } public function load(string $class) diff --git a/src/Sandbox/SecurityNotAllowedFilterError.php b/src/Sandbox/SecurityNotAllowedFilterError.php index 02d306360ba..9293a3f0b93 100644 --- a/src/Sandbox/SecurityNotAllowedFilterError.php +++ b/src/Sandbox/SecurityNotAllowedFilterError.php @@ -18,7 +18,7 @@ */ final class SecurityNotAllowedFilterError extends SecurityError { - private $filterName; + private string $filterName; public function __construct(string $message, string $functionName) { diff --git a/src/Sandbox/SecurityNotAllowedFunctionError.php b/src/Sandbox/SecurityNotAllowedFunctionError.php index 4f76dc6ece4..71c9f02bc5b 100644 --- a/src/Sandbox/SecurityNotAllowedFunctionError.php +++ b/src/Sandbox/SecurityNotAllowedFunctionError.php @@ -18,7 +18,7 @@ */ final class SecurityNotAllowedFunctionError extends SecurityError { - private $functionName; + private string $functionName; public function __construct(string $message, string $functionName) { diff --git a/src/Sandbox/SecurityNotAllowedMethodError.php b/src/Sandbox/SecurityNotAllowedMethodError.php index 8df9d0baa96..98e8e434d93 100644 --- a/src/Sandbox/SecurityNotAllowedMethodError.php +++ b/src/Sandbox/SecurityNotAllowedMethodError.php @@ -18,8 +18,8 @@ */ final class SecurityNotAllowedMethodError extends SecurityError { - private $className; - private $methodName; + private string $className; + private string $methodName; public function __construct(string $message, string $className, string $methodName) { @@ -33,7 +33,7 @@ public function getClassName(): string return $this->className; } - public function getMethodName() + public function getMethodName(): string { return $this->methodName; } diff --git a/src/Sandbox/SecurityNotAllowedPropertyError.php b/src/Sandbox/SecurityNotAllowedPropertyError.php index 42ec4f38694..e74ffeddb25 100644 --- a/src/Sandbox/SecurityNotAllowedPropertyError.php +++ b/src/Sandbox/SecurityNotAllowedPropertyError.php @@ -18,8 +18,8 @@ */ final class SecurityNotAllowedPropertyError extends SecurityError { - private $className; - private $propertyName; + private string $className; + private string $propertyName; public function __construct(string $message, string $className, string $propertyName) { @@ -33,7 +33,7 @@ public function getClassName(): string return $this->className; } - public function getPropertyName() + public function getPropertyName(): string { return $this->propertyName; } diff --git a/src/Sandbox/SecurityNotAllowedTagError.php b/src/Sandbox/SecurityNotAllowedTagError.php index 4522150e1b7..f9cd625b4c0 100644 --- a/src/Sandbox/SecurityNotAllowedTagError.php +++ b/src/Sandbox/SecurityNotAllowedTagError.php @@ -18,7 +18,7 @@ */ final class SecurityNotAllowedTagError extends SecurityError { - private $tagName; + private string $tagName; public function __construct(string $message, string $tagName) { diff --git a/src/Sandbox/SecurityPolicy.php b/src/Sandbox/SecurityPolicy.php index 2fc0d01318a..b2c83ee106c 100644 --- a/src/Sandbox/SecurityPolicy.php +++ b/src/Sandbox/SecurityPolicy.php @@ -50,7 +50,7 @@ public function setAllowedMethods(array $methods): void { $this->allowedMethods = []; foreach ($methods as $class => $m) { - $this->allowedMethods[$class] = array_map(function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, \is_array($m) ? $m : [$m]); + $this->allowedMethods[$class] = array_map('strtolower', \is_array($m) ? $m : [$m]); } } @@ -67,20 +67,26 @@ public function setAllowedFunctions(array $functions): void public function checkSecurity($tags, $filters, $functions): void { foreach ($tags as $tag) { - if (!\in_array($tag, $this->allowedTags)) { - throw new SecurityNotAllowedTagError(sprintf('Tag "%s" is not allowed.', $tag), $tag); + if (!\in_array($tag, $this->allowedTags, true)) { + if ('extends' === $tag) { + trigger_deprecation('twig/twig', '3.12', 'The "extends" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.'); + } elseif ('use' === $tag) { + trigger_deprecation('twig/twig', '3.12', 'The "use" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.'); + } else { + throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag); + } } } foreach ($filters as $filter) { - if (!\in_array($filter, $this->allowedFilters)) { - throw new SecurityNotAllowedFilterError(sprintf('Filter "%s" is not allowed.', $filter), $filter); + if (!\in_array($filter, $this->allowedFilters, true)) { + throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter); } } foreach ($functions as $function) { - if (!\in_array($function, $this->allowedFunctions)) { - throw new SecurityNotAllowedFunctionError(sprintf('Function "%s" is not allowed.', $function), $function); + if (!\in_array($function, $this->allowedFunctions, true)) { + throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function); } } } @@ -92,18 +98,17 @@ public function checkMethodAllowed($obj, $method): void } $allowed = false; - $method = strtr($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + $method = strtolower($method); foreach ($this->allowedMethods as $class => $methods) { - if ($obj instanceof $class) { - $allowed = \in_array($method, $methods); - + if ($obj instanceof $class && \in_array($method, $methods, true)) { + $allowed = true; break; } } if (!$allowed) { - $class = \get_class($obj); - throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); + $class = $obj::class; + throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); } } @@ -111,16 +116,15 @@ public function checkPropertyAllowed($obj, $property): void { $allowed = false; foreach ($this->allowedProperties as $class => $properties) { - if ($obj instanceof $class) { - $allowed = \in_array($property, \is_array($properties) ? $properties : [$properties]); - + if ($obj instanceof $class && \in_array($property, \is_array($properties) ? $properties : [$properties], true)) { + $allowed = true; break; } } if (!$allowed) { - $class = \get_class($obj); - throw new SecurityNotAllowedPropertyError(sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); + $class = $obj::class; + throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); } } } diff --git a/src/Sandbox/SourcePolicyInterface.php b/src/Sandbox/SourcePolicyInterface.php new file mode 100644 index 00000000000..b952f1ea6fa --- /dev/null +++ b/src/Sandbox/SourcePolicyInterface.php @@ -0,0 +1,24 @@ +code = $code; - $this->name = $name; - $this->path = $path; + public function __construct( + private string $code, + private string $name, + private string $path = '', + ) { } public function getCode(): string diff --git a/src/Template.php b/src/Template.php index e04bd04a63e..c3720928746 100644 --- a/src/Template.php +++ b/src/Template.php @@ -13,7 +13,6 @@ namespace Twig; use Twig\Error\Error; -use Twig\Error\LoaderError; use Twig\Error\RuntimeError; /** @@ -35,38 +34,37 @@ abstract class Template protected $parent; protected $parents = []; - protected $env; protected $blocks = []; protected $traits = []; + protected $traitAliases = []; protected $extensions = []; protected $sandbox; - public function __construct(Environment $env) - { - $this->env = $env; + private $useYield; + + public function __construct( + protected Environment $env, + ) { + $this->useYield = $env->useYield(); $this->extensions = $env->getExtensions(); } /** * Returns the template name. - * - * @return string The template name */ - abstract public function getTemplateName(); + abstract public function getTemplateName(): string; /** * Returns debug information about the template. * - * @return array Debug information + * @return array Debug information */ - abstract public function getDebugInfo(); + abstract public function getDebugInfo(): array; /** * Returns information about the original template source code. - * - * @return Source */ - abstract public function getSourceContext(); + abstract public function getSourceContext(): Source; /** * Returns the parent template. @@ -74,44 +72,35 @@ abstract public function getSourceContext(); * This method is for internal use only and should never be called * directly. * - * @return Template|TemplateWrapper|false The parent template or false if there is no parent + * @return self|TemplateWrapper|false The parent template or false if there is no parent */ - public function getParent(array $context) + public function getParent(array $context): self|TemplateWrapper|false { if (null !== $this->parent) { return $this->parent; } - try { - $parent = $this->doGetParent($context); - - if (false === $parent) { - return false; - } - - if ($parent instanceof self || $parent instanceof TemplateWrapper) { - return $this->parents[$parent->getSourceContext()->getName()] = $parent; - } + if (!$parent = $this->doGetParent($context)) { + return false; + } - if (!isset($this->parents[$parent])) { - $this->parents[$parent] = $this->loadTemplate($parent); - } - } catch (LoaderError $e) { - $e->setSourceContext(null); - $e->guess(); + if ($parent instanceof self || $parent instanceof TemplateWrapper) { + return $this->parents[$parent->getSourceContext()->getName()] = $parent; + } - throw $e; + if (!isset($this->parents[$parent])) { + $this->parents[$parent] = $this->load($parent, -1); } return $this->parents[$parent]; } - protected function doGetParent(array $context) + protected function doGetParent(array $context): bool|string|self|TemplateWrapper { return false; } - public function isTraitable() + public function isTraitable(): bool { return true; } @@ -126,14 +115,10 @@ public function isTraitable() * @param array $context The context * @param array $blocks The current set of blocks */ - public function displayParentBlock($name, array $context, array $blocks = []) + public function displayParentBlock($name, array $context, array $blocks = []): void { - if (isset($this->traits[$name])) { - $this->traits[$name][0]->displayBlock($name, $context, $blocks, false); - } elseif (false !== $parent = $this->getParent($context)) { - $parent->displayBlock($name, $context, $blocks, false); - } else { - throw new RuntimeError(sprintf('The template has no parent and no traits defining the "%s" block.', $name), -1, $this->getSourceContext()); + foreach ($this->yieldParentBlock($name, $context, $blocks) as $data) { + echo $data; } } @@ -148,51 +133,10 @@ public function displayParentBlock($name, array $context, array $blocks = []) * @param array $blocks The current set of blocks * @param bool $useBlocks Whether to use the current set of blocks */ - public function displayBlock($name, array $context, array $blocks = [], $useBlocks = true, self $templateContext = null) + public function displayBlock($name, array $context, array $blocks = [], $useBlocks = true, ?self $templateContext = null): void { - if ($useBlocks && isset($blocks[$name])) { - $template = $blocks[$name][0]; - $block = $blocks[$name][1]; - } elseif (isset($this->blocks[$name])) { - $template = $this->blocks[$name][0]; - $block = $this->blocks[$name][1]; - } else { - $template = null; - $block = null; - } - - // avoid RCEs when sandbox is enabled - if (null !== $template && !$template instanceof self) { - throw new \LogicException('A block must be a method on a \Twig\Template instance.'); - } - - if (null !== $template) { - try { - $template->$block($context, $blocks); - } catch (Error $e) { - if (!$e->getSourceContext()) { - $e->setSourceContext($template->getSourceContext()); - } - - // this is mostly useful for \Twig\Error\LoaderError exceptions - // see \Twig\Error\LoaderError - if (-1 === $e->getTemplateLine()) { - $e->guess(); - } - - throw $e; - } catch (\Exception $e) { - $e = new RuntimeError(sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $template->getSourceContext(), $e); - $e->guess(); - - throw $e; - } - } elseif (false !== $parent = $this->getParent($context)) { - $parent->displayBlock($name, $context, array_merge($this->blocks, $blocks), false, $templateContext ?? $this); - } elseif (isset($blocks[$name])) { - throw new RuntimeError(sprintf('Block "%s" should not call parent() in "%s" as the block does not exist in the parent template "%s".', $name, $blocks[$name][0]->getTemplateName(), $this->getTemplateName()), -1, $blocks[$name][0]->getSourceContext()); - } else { - throw new RuntimeError(sprintf('Block "%s" on template "%s" does not exist.', $name, $this->getTemplateName()), -1, ($templateContext ?? $this)->getSourceContext()); + foreach ($this->yieldBlock($name, $context, $blocks, $useBlocks, $templateContext) as $data) { + echo $data; } } @@ -208,16 +152,25 @@ public function displayBlock($name, array $context, array $blocks = [], $useBloc * * @return string The rendered block */ - public function renderParentBlock($name, array $context, array $blocks = []) + public function renderParentBlock($name, array $context, array $blocks = []): string { - if ($this->env->isDebug()) { - ob_start(); - } else { - ob_start(function () { return ''; }); + if (!$this->useYield) { + if ($this->env->isDebug()) { + ob_start(); + } else { + ob_start(function () { return ''; }); + } + $this->displayParentBlock($name, $context, $blocks); + + return ob_get_clean(); + } + + $content = ''; + foreach ($this->yieldParentBlock($name, $context, $blocks) as $data) { + $content .= $data; } - $this->displayParentBlock($name, $context, $blocks); - return ob_get_clean(); + return $content; } /** @@ -233,16 +186,34 @@ public function renderParentBlock($name, array $context, array $blocks = []) * * @return string The rendered block */ - public function renderBlock($name, array $context, array $blocks = [], $useBlocks = true) + public function renderBlock($name, array $context, array $blocks = [], $useBlocks = true): string { - if ($this->env->isDebug()) { - ob_start(); - } else { - ob_start(function () { return ''; }); + if (!$this->useYield) { + $level = ob_get_level(); + if ($this->env->isDebug()) { + ob_start(); + } else { + ob_start(function () { return ''; }); + } + try { + $this->displayBlock($name, $context, $blocks, $useBlocks); + } catch (\Throwable $e) { + while (ob_get_level() > $level) { + ob_end_clean(); + } + + throw $e; + } + + return ob_get_clean(); + } + + $content = ''; + foreach ($this->yieldBlock($name, $context, $blocks, $useBlocks) as $data) { + $content .= $data; } - $this->displayBlock($name, $context, $blocks, $useBlocks); - return ob_get_clean(); + return $content; } /** @@ -257,7 +228,7 @@ public function renderBlock($name, array $context, array $blocks = [], $useBlock * * @return bool true if the block exists, false otherwise */ - public function hasBlock($name, array $context, array $blocks = []) + public function hasBlock($name, array $context, array $blocks = []): bool { if (isset($blocks[$name])) { return $blocks[$name][0] instanceof self; @@ -267,7 +238,7 @@ public function hasBlock($name, array $context, array $blocks = []) return true; } - if (false !== $parent = $this->getParent($context)) { + if ($parent = $this->getParent($context)) { return $parent->hasBlock($name, $context); } @@ -283,13 +254,13 @@ public function hasBlock($name, array $context, array $blocks = []) * @param array $context The context * @param array $blocks The current set of blocks * - * @return array An array of block names + * @return array An array of block names */ - public function getBlockNames(array $context, array $blocks = []) + public function getBlockNames(array $context, array $blocks = []): array { $names = array_merge(array_keys($blocks), array_keys($this->blocks)); - if (false !== $parent = $this->getParent($context)) { + if ($parent = $this->getParent($context)) { $names = array_merge($names, $parent->getBlockNames($context)); } @@ -297,17 +268,17 @@ public function getBlockNames(array $context, array $blocks = []) } /** - * @return Template|TemplateWrapper + * @param string|TemplateWrapper|array $template */ - protected function loadTemplate($template, $templateName = null, $line = null, $index = null) + protected function load(string|TemplateWrapper|array $template, int $line, ?int $index = null): self { try { if (\is_array($template)) { - return $this->env->resolveTemplate($template); + return $this->env->resolveTemplate($template)->unwrap(); } - if ($template instanceof self || $template instanceof TemplateWrapper) { - return $template; + if ($template instanceof TemplateWrapper) { + return $template->unwrap(); } if ($template === $this->getTemplateName()) { @@ -322,14 +293,14 @@ protected function loadTemplate($template, $templateName = null, $line = null, $ return $this->env->loadTemplate($class, $template, $index); } catch (Error $e) { if (!$e->getSourceContext()) { - $e->setSourceContext($templateName ? new Source('', $templateName) : $this->getSourceContext()); + $e->setSourceContext($this->getSourceContext()); } if ($e->getTemplateLine() > 0) { throw $e; } - if (!$line) { + if (-1 === $line) { $e->guess(); } else { $e->setTemplateLine($line); @@ -339,12 +310,32 @@ protected function loadTemplate($template, $templateName = null, $line = null, $ } } + /** + * @param string|TemplateWrapper|array $template + * + * @deprecated since Twig 3.21 and will be removed in 4.0. Use Template::load() instead. + */ + protected function loadTemplate($template, $templateName = null, ?int $line = null, ?int $index = null): self|TemplateWrapper + { + trigger_deprecation('twig/twig', '3.21', 'The "%s" method is deprecated.', __METHOD__); + + if (null === $line) { + $line = -1; + } + + if ($template instanceof self) { + return $template; + } + + return $this->load($template, $line, $index); + } + /** * @internal * - * @return Template + * @return $this */ - public function unwrap() + public function unwrap(): self { return $this; } @@ -357,41 +348,58 @@ public function unwrap() * * @return array An array of blocks */ - public function getBlocks() + public function getBlocks(): array { return $this->blocks; } - public function display(array $context, array $blocks = []) + public function display(array $context, array $blocks = []): void { - $this->displayWithErrorHandling($this->env->mergeGlobals($context), array_merge($this->blocks, $blocks)); + foreach ($this->yield($context, $blocks) as $data) { + echo $data; + } } - public function render(array $context) + public function render(array $context): string { - $level = ob_get_level(); - if ($this->env->isDebug()) { - ob_start(); - } else { - ob_start(function () { return ''; }); - } - try { - $this->display($context); - } catch (\Throwable $e) { - while (ob_get_level() > $level) { - ob_end_clean(); + if (!$this->useYield) { + $level = ob_get_level(); + if ($this->env->isDebug()) { + ob_start(); + } else { + ob_start(function () { return ''; }); } + try { + $this->display($context); + } catch (\Throwable $e) { + while (ob_get_level() > $level) { + ob_end_clean(); + } - throw $e; + throw $e; + } + + return ob_get_clean(); } - return ob_get_clean(); + $content = ''; + foreach ($this->yield($context) as $data) { + $content .= $data; + } + + return $content; } - protected function displayWithErrorHandling(array $context, array $blocks = []) + /** + * @return iterable + */ + public function yield(array $context, array $blocks = []): iterable { + $context += $this->env->getGlobals(); + $blocks = array_merge($this->blocks, $blocks); + try { - $this->doDisplay($context, $blocks); + yield from $this->doDisplay($context, $blocks); } catch (Error $e) { if (!$e->getSourceContext()) { $e->setSourceContext($this->getSourceContext()); @@ -404,19 +412,124 @@ protected function displayWithErrorHandling(array $context, array $blocks = []) } throw $e; - } catch (\Exception $e) { - $e = new RuntimeError(sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $this->getSourceContext(), $e); + } catch (\Throwable $e) { + $e = new RuntimeError(\sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $this->getSourceContext(), $e); $e->guess(); throw $e; } } + /** + * @return iterable + */ + public function yieldBlock($name, array $context, array $blocks = [], $useBlocks = true, ?self $templateContext = null): iterable + { + if ($useBlocks && isset($blocks[$name])) { + $template = $blocks[$name][0]; + $block = $blocks[$name][1]; + } elseif (isset($this->blocks[$name])) { + $template = $this->blocks[$name][0]; + $block = $this->blocks[$name][1]; + } else { + $template = null; + $block = null; + } + + // avoid RCEs when sandbox is enabled + if (null !== $template && !$template instanceof self) { + throw new \LogicException('A block must be a method on a \Twig\Template instance.'); + } + + if (null !== $template) { + try { + yield from $template->$block($context, $blocks); + } catch (Error $e) { + if (!$e->getSourceContext()) { + $e->setSourceContext($template->getSourceContext()); + } + + // this is mostly useful for \Twig\Error\LoaderError exceptions + // see \Twig\Error\LoaderError + if (-1 === $e->getTemplateLine()) { + $e->guess(); + } + + throw $e; + } catch (\Throwable $e) { + $e = new RuntimeError(\sprintf('An exception has been thrown during the rendering of a template ("%s").', $e->getMessage()), -1, $template->getSourceContext(), $e); + $e->guess(); + + throw $e; + } + } elseif ($parent = $this->getParent($context)) { + yield from $parent->unwrap()->yieldBlock($name, $context, array_merge($this->blocks, $blocks), false, $templateContext ?? $this); + } elseif (isset($blocks[$name])) { + throw new RuntimeError(\sprintf('Block "%s" should not call parent() in "%s" as the block does not exist in the parent template "%s".', $name, $blocks[$name][0]->getTemplateName(), $this->getTemplateName()), -1, $blocks[$name][0]->getSourceContext()); + } else { + throw new RuntimeError(\sprintf('Block "%s" on template "%s" does not exist.', $name, $this->getTemplateName()), -1, ($templateContext ?? $this)->getSourceContext()); + } + } + + /** + * Yields a parent block. + * + * This method is for internal use only and should never be called + * directly. + * + * @param string $name The block name to display from the parent + * @param array $context The context + * @param array $blocks The current set of blocks + * + * @return iterable + */ + public function yieldParentBlock($name, array $context, array $blocks = []): iterable + { + if (isset($this->traits[$name])) { + yield from $this->traits[$name][0]->yieldBlock($this->traitAliases[$name] ?? $name, $context, $blocks, false); + } elseif ($parent = $this->getParent($context)) { + yield from $parent->unwrap()->yieldBlock($name, $context, $blocks, false); + } else { + throw new RuntimeError(\sprintf('The template has no parent and no traits defining the "%s" block.', $name), -1, $this->getSourceContext()); + } + } + + protected function hasMacro(string $name, array $context): bool + { + if (method_exists($this, $name)) { + return true; + } + + if (!$parent = $this->getParent($context)) { + return false; + } + + return $parent->hasMacro($name, $context); + } + + protected function getTemplateForMacro(string $name, array $context, int $line, Source $source): self + { + if (method_exists($this, $name)) { + return $this; + } + + $parent = $this; + while ($parent = $parent->getParent($context)) { + if (method_exists($parent, $name)) { + return $parent; + } + } + + throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($name, \strlen('macro_')), $this->getTemplateName()), $line, $source); + } + /** * Auto-generated method to display the template with the given context. * * @param array $context An array of parameters to pass to the template * @param array $blocks An array of blocks to pass to the template + * + * @return iterable */ - abstract protected function doDisplay(array $context, array $blocks = []); + abstract protected function doDisplay(array $context, array $blocks = []): iterable; } diff --git a/src/TemplateWrapper.php b/src/TemplateWrapper.php index c9c6b07c669..265ce3e1ccc 100644 --- a/src/TemplateWrapper.php +++ b/src/TemplateWrapper.php @@ -18,28 +18,42 @@ */ final class TemplateWrapper { - private $env; - private $template; - /** * This method is for internal use only and should never be called * directly (use Twig\Environment::load() instead). * * @internal */ - public function __construct(Environment $env, Template $template) + public function __construct( + private Environment $env, + private Template $template, + ) { + } + + /** + * @return iterable + */ + public function stream(array $context = []): iterable { - $this->env = $env; - $this->template = $template; + yield from $this->template->yield($context); + } + + /** + * @return iterable + */ + public function streamBlock(string $name, array $context = []): iterable + { + yield from $this->template->yieldBlock($name, $context); } public function render(array $context = []): string { - // using func_get_args() allows to not expose the blocks argument - // as it should only be used by internal code - return $this->template->render($context, \func_get_args()[1] ?? []); + return $this->template->render($context); } + /** + * @return void + */ public function display(array $context = []) { // using func_get_args() allows to not expose the blocks argument @@ -62,29 +76,18 @@ public function getBlockNames(array $context = []): array public function renderBlock(string $name, array $context = []): string { - $context = $this->env->mergeGlobals($context); - $level = ob_get_level(); - if ($this->env->isDebug()) { - ob_start(); - } else { - ob_start(function () { return ''; }); - } - try { - $this->template->displayBlock($name, $context); - } catch (\Throwable $e) { - while (ob_get_level() > $level) { - ob_end_clean(); - } - - throw $e; - } - - return ob_get_clean(); + return $this->template->renderBlock($name, $context + $this->env->getGlobals()); } + /** + * @return void + */ public function displayBlock(string $name, array $context = []) { - $this->template->displayBlock($name, $this->env->mergeGlobals($context)); + $context += $this->env->getGlobals(); + foreach ($this->template->yieldBlock($name, $context) as $data) { + echo $data; + } } public function getSourceContext(): Source diff --git a/src/Test/IntegrationTestCase.php b/src/Test/IntegrationTestCase.php index 307302bb624..a4a3b561d78 100644 --- a/src/Test/IntegrationTestCase.php +++ b/src/Test/IntegrationTestCase.php @@ -17,6 +17,7 @@ use Twig\Extension\ExtensionInterface; use Twig\Loader\ArrayLoader; use Twig\RuntimeLoader\RuntimeLoaderInterface; +use Twig\TokenParser\TokenParserInterface; use Twig\TwigFilter; use Twig\TwigFunction; use Twig\TwigTest; @@ -30,9 +31,19 @@ abstract class IntegrationTestCase extends TestCase { /** + * @deprecated since Twig 3.13, use getFixturesDirectory() instead. + * * @return string */ - abstract protected function getFixturesDir(); + protected function getFixturesDir() + { + throw new \BadMethodCallException('Not implemented.'); + } + + protected static function getFixturesDirectory(): string + { + throw new \BadMethodCallException('Not implemented.'); + } /** * @return RuntimeLoaderInterface[] @@ -74,8 +85,42 @@ protected function getTwigTests() return []; } + /** + * @return array + */ + protected function getUndefinedFilterCallbacks(): array + { + return []; + } + + /** + * @return array + */ + protected function getUndefinedFunctionCallbacks(): array + { + return []; + } + + /** + * @return array + */ + protected function getUndefinedTestCallbacks(): array + { + return []; + } + + /** + * @return array + */ + protected function getUndefinedTokenParserCallbacks(): array + { + return []; + } + /** * @dataProvider getTests + * + * @return void */ public function testIntegration($file, $message, $condition, $templates, $exception, $outputs, $deprecation = '') { @@ -84,16 +129,31 @@ public function testIntegration($file, $message, $condition, $templates, $except /** * @dataProvider getLegacyTests + * * @group legacy + * + * @return void */ public function testLegacyIntegration($file, $message, $condition, $templates, $exception, $outputs, $deprecation = '') { $this->doIntegrationTest($file, $message, $condition, $templates, $exception, $outputs, $deprecation); } + /** + * @return iterable + * + * @final since Twig 3.13 + */ public function getTests($name, $legacyTests = false) { - $fixturesDir = realpath($this->getFixturesDir()); + try { + $fixturesDir = static::getFixturesDirectory(); + } catch (\BadMethodCallException) { + trigger_deprecation('twig/twig', '3.13', 'Not overriding "%s::getFixturesDirectory()" in "%s" is deprecated. This method will be abstract in 4.0.', self::class, static::class); + $fixturesDir = $this->getFixturesDir(); + } + + $fixturesDir = realpath($fixturesDir); $tests = []; foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($fixturesDir), \RecursiveIteratorIterator::LEAVES_ONLY) as $file) { @@ -101,7 +161,7 @@ public function getTests($name, $legacyTests = false) continue; } - if ($legacyTests xor false !== strpos($file->getRealpath(), '.legacy.test')) { + if ($legacyTests xor str_contains($file->getRealpath(), '.legacy.test')) { continue; } @@ -122,13 +182,13 @@ public function getTests($name, $legacyTests = false) $exception = false; preg_match_all('/--DATA--(.*?)(?:--CONFIG--(.*?))?--EXPECT--(.*?)(?=\-\-DATA\-\-|$)/s', $test, $outputs, \PREG_SET_ORDER); } else { - throw new \InvalidArgumentException(sprintf('Test "%s" is not valid.', str_replace($fixturesDir.'/', '', $file))); + throw new \InvalidArgumentException(\sprintf('Test "%s" is not valid.', str_replace($fixturesDir.'/', '', $file))); } - $tests[] = [str_replace($fixturesDir.'/', '', $file), $message, $condition, $templates, $exception, $outputs, $deprecation]; + $tests[str_replace($fixturesDir.'/', '', $file)] = [str_replace($fixturesDir.'/', '', $file), $message, $condition, $templates, $exception, $outputs, $deprecation]; } - if ($legacyTests && empty($tests)) { + if ($legacyTests && !$tests) { // add a dummy test to avoid a PHPUnit message return [['not', '-', '', [], '', []]]; } @@ -136,11 +196,19 @@ public function getTests($name, $legacyTests = false) return $tests; } + /** + * @final since Twig 3.13 + * + * @return iterable + */ public function getLegacyTests() { return $this->getTests('testLegacyIntegration', true); } + /** + * @return void + */ protected function doIntegrationTest($file, $message, $condition, $templates, $exception, $outputs, $deprecation = '') { if (!$outputs) { @@ -148,19 +216,23 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e } if ($condition) { + $ret = ''; eval('$ret = '.$condition.';'); if (!$ret) { $this->markTestSkipped($condition); } } - $loader = new ArrayLoader($templates); - foreach ($outputs as $i => $match) { $config = array_merge([ 'cache' => false, 'strict_variables' => true, ], $match[2] ? eval($match[2].';') : []); + // make sure that template are always compiled even if they are the same (useful when testing with more than one data/expect sections) + foreach ($templates as $j => $template) { + $templates[$j] = $template.str_repeat(' ', $i); + } + $loader = new ArrayLoader($templates); $twig = new Environment($loader, $config); $twig->addGlobal('global', 'global'); foreach ($this->getRuntimeLoaders() as $runtimeLoader) { @@ -183,10 +255,21 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e $twig->addFunction($function); } - // avoid using the same PHP class name for different cases - $p = new \ReflectionProperty($twig, 'templateClassPrefix'); - $p->setAccessible(true); - $p->setValue($twig, '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', uniqid(mt_rand(), true), false).'_'); + foreach ($this->getUndefinedFilterCallbacks() as $callback) { + $twig->registerUndefinedFilterCallback($callback); + } + + foreach ($this->getUndefinedFunctionCallbacks() as $callback) { + $twig->registerUndefinedFunctionCallback($callback); + } + + foreach ($this->getUndefinedTestCallbacks() as $callback) { + $twig->registerUndefinedTestCallback($callback); + } + + foreach ($this->getUndefinedTokenParserCallbacks() as $callback) { + $twig->registerUndefinedTokenParserCallback($callback); + } $deprecations = []; try { @@ -204,14 +287,14 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e } catch (\Exception $e) { if (false !== $exception) { $message = $e->getMessage(); - $this->assertSame(trim($exception), trim(sprintf('%s: %s', \get_class($e), $message))); + $this->assertSame(trim($exception), trim(\sprintf('%s: %s', $e::class, $message))); $last = substr($message, \strlen($message) - 1); $this->assertTrue('.' === $last || '?' === $last, 'Exception message must end with a dot or a question mark.'); return; } - throw new Error(sprintf('%s: %s', \get_class($e), $e->getMessage()), -1, null, $e); + throw new Error(\sprintf('%s: %s', $e::class, $e->getMessage()), -1, null, $e); } finally { restore_error_handler(); } @@ -222,18 +305,18 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e $output = trim($template->render(eval($match[1].';')), "\n "); } catch (\Exception $e) { if (false !== $exception) { - $this->assertSame(trim($exception), trim(sprintf('%s: %s', \get_class($e), $e->getMessage()))); + $this->assertStringMatchesFormat(trim($exception), trim(\sprintf('%s: %s', $e::class, $e->getMessage()))); return; } - $e = new Error(sprintf('%s: %s', \get_class($e), $e->getMessage()), -1, null, $e); + $e = new Error(\sprintf('%s: %s', $e::class, $e->getMessage()), -1, null, $e); - $output = trim(sprintf('%s: %s', \get_class($e), $e->getMessage())); + $output = trim(\sprintf('%s: %s', $e::class, $e->getMessage())); } if (false !== $exception) { - list($class) = explode(':', $exception); + [$class] = explode(':', $exception); $constraintClass = class_exists('PHPUnit\Framework\Constraint\Exception') ? 'PHPUnit\Framework\Constraint\Exception' : 'PHPUnit_Framework_Constraint_Exception'; $this->assertThat(null, new $constraintClass($class)); } @@ -252,12 +335,15 @@ protected function doIntegrationTest($file, $message, $condition, $templates, $e } } + /** + * @return array + */ protected static function parseTemplates($test) { $templates = []; preg_match_all('/--TEMPLATE(?:\((.*?)\))?--(.*?)(?=\-\-TEMPLATE|$)/s', $test, $matches, \PREG_SET_ORDER); foreach ($matches as $match) { - $templates[($match[1] ?: 'index.twig')] = $match[2]; + $templates[$match[1] ?: 'index.twig'] = $match[2]; } return $templates; diff --git a/src/Test/NodeTestCase.php b/src/Test/NodeTestCase.php index 3b8b2c86c67..0cb5b2fab05 100644 --- a/src/Test/NodeTestCase.php +++ b/src/Test/NodeTestCase.php @@ -11,6 +11,8 @@ namespace Twig\Test; +use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Twig\Compiler; use Twig\Environment; @@ -19,17 +21,45 @@ abstract class NodeTestCase extends TestCase { - abstract public function getTests(); + /** + * @var Environment + */ + private $currentEnv; + + /** + * @return iterable + */ + public function getTests() + { + return []; + } + + /** + * @return iterable + */ + public static function provideTests(): iterable + { + trigger_deprecation('twig/twig', '3.13', 'Not implementing "%s()" in "%s" is deprecated. This method will be abstract in 4.0.', __METHOD__, static::class); + + return []; + } /** * @dataProvider getTests + * @dataProvider provideTests + * + * @return void */ + #[DataProvider('getTests'), DataProvider('provideTests')] public function testCompile($node, $source, $environment = null, $isPattern = false) { $this->assertNodeCompilation($source, $node, $environment, $isPattern); } - public function assertNodeCompilation($source, Node $node, Environment $environment = null, $isPattern = false) + /** + * @return void + */ + public function assertNodeCompilation($source, Node $node, ?Environment $environment = null, $isPattern = false) { $compiler = $this->getCompiler($environment); $compiler->compile($node); @@ -41,25 +71,72 @@ public function assertNodeCompilation($source, Node $node, Environment $environm } } - protected function getCompiler(Environment $environment = null) + /** + * @return Compiler + */ + protected function getCompiler(?Environment $environment = null) { - return new Compiler(null === $environment ? $this->getEnvironment() : $environment); + return new Compiler($environment ?? $this->getEnvironment()); } + /** + * @return Environment + * + * @final since Twig 3.13 + */ protected function getEnvironment() { - return new Environment(new ArrayLoader([])); + return $this->currentEnv ??= static::createEnvironment(); } + protected static function createEnvironment(): Environment + { + return new Environment(new ArrayLoader()); + } + + /** + * @return string + * + * @deprecated since Twig 3.13, use createVariableGetter() instead. + */ protected function getVariableGetter($name, $line = false) + { + trigger_deprecation('twig/twig', '3.13', 'Method "%s()" is deprecated, use "createVariableGetter()" instead.', __METHOD__); + + return self::createVariableGetter($name, $line); + } + + final protected static function createVariableGetter(string $name, bool $line = false): string { $line = $line > 0 ? "// line $line\n" : ''; - return sprintf('%s($context["%s"] ?? null)', $line, $name); + return \sprintf('%s($context["%s"] ?? null)', $line, $name); } + /** + * @return string + * + * @deprecated since Twig 3.13, use createAttributeGetter() instead. + */ protected function getAttributeGetter() { - return 'twig_get_attribute($this->env, $this->source, '; + trigger_deprecation('twig/twig', '3.13', 'Method "%s()" is deprecated, use "createAttributeGetter()" instead.', __METHOD__); + + return self::createAttributeGetter(); + } + + final protected static function createAttributeGetter(): string + { + return 'CoreExtension::getAttribute($this->env, $this->source, '; + } + + /** @beforeClass */ + #[BeforeClass] + final public static function checkDataProvider(): void + { + $r = new \ReflectionMethod(static::class, 'getTests'); + if (self::class !== $r->getDeclaringClass()->getName()) { + trigger_deprecation('twig/twig', '3.13', 'Implementing "%s::getTests()" in "%s" is deprecated, implement "provideTests()" instead.', self::class, static::class); + } } } diff --git a/src/Token.php b/src/Token.php index 53a6cafc350..823c7738769 100644 --- a/src/Token.php +++ b/src/Token.php @@ -17,10 +17,6 @@ */ final class Token { - private $value; - private $type; - private $lineno; - public const EOF_TYPE = -1; public const TEXT_TYPE = 0; public const BLOCK_START_TYPE = 1; @@ -34,18 +30,31 @@ final class Token public const PUNCTUATION_TYPE = 9; public const INTERPOLATION_START_TYPE = 10; public const INTERPOLATION_END_TYPE = 11; + /** + * @deprecated since Twig 3.21, "arrow" is now an operator + */ public const ARROW_TYPE = 12; + /** + * @deprecated since Twig 3.21, "spread" is now an operator + */ + public const SPREAD_TYPE = 13; - public function __construct(int $type, $value, int $lineno) - { - $this->type = $type; - $this->value = $value; - $this->lineno = $lineno; + public function __construct( + private int $type, + private $value, + private int $lineno, + ) { + if (self::ARROW_TYPE === $type) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::ARROW_TYPE); + } + if (self::SPREAD_TYPE === $type) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "spread" is now an operator.', self::SPREAD_TYPE); + } } - public function __toString() + public function __toString(): string { - return sprintf('%s(%s)', self::typeToString($this->type, true), $this->value); + return \sprintf('%s(%s)', self::typeToString($this->type, true), $this->value); } /** @@ -66,10 +75,47 @@ public function test($type, $values = null): bool $type = self::NAME_TYPE; } - return ($this->type === $type) && ( - null === $values || - (\is_array($values) && \in_array($this->value, $values)) || - $this->value == $values + if (self::ARROW_TYPE === $type) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::typeToEnglish(self::ARROW_TYPE)); + + return self::OPERATOR_TYPE === $this->type && '=>' === $this->value; + } + if (self::SPREAD_TYPE === $type) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "spread" is now an operator.', self::typeToEnglish(self::SPREAD_TYPE)); + + return self::OPERATOR_TYPE === $this->type && '...' === $this->value; + } + + $typeMatches = $this->type === $type; + if ($typeMatches && self::PUNCTUATION_TYPE === $type && \in_array($this->value, ['(', '[', '|', '.', '?', '?:'], true) && $values) { + foreach ((array) $values as $value) { + if (\in_array($value, ['(', '[', '|', '.', '?', '?:'], true)) { + trigger_deprecation('twig/twig', '3.21', 'The "%s" token is now an "%s" token instead of a "%s" one.', $this->value, self::typeToEnglish(self::OPERATOR_TYPE), $this->toEnglish()); + + break; + } + } + } + if (!$typeMatches) { + if (self::OPERATOR_TYPE === $type && self::PUNCTUATION_TYPE === $this->type) { + if ($values) { + foreach ((array) $values as $value) { + if (\in_array($value, ['(', '[', '|', '.', '?', '?:'], true)) { + $typeMatches = true; + + break; + } + } + } else { + $typeMatches = true; + } + } + } + + return $typeMatches && ( + null === $values + || (\is_array($values) && \in_array($this->value, $values, true)) + || $this->value == $values ); } @@ -78,8 +124,13 @@ public function getLine(): int return $this->lineno; } + /** + * @deprecated since Twig 3.19 + */ public function getType(): int { + trigger_deprecation('twig/twig', '3.19', \sprintf('The "%s()" method is deprecated.', __METHOD__)); + return $this->type; } @@ -88,6 +139,11 @@ public function getValue() return $this->value; } + public function toEnglish(): string + { + return self::typeToEnglish($this->type); + } + public static function typeToString(int $type, bool $short = false): string { switch ($type) { @@ -133,8 +189,11 @@ public static function typeToString(int $type, bool $short = false): string case self::ARROW_TYPE: $name = 'ARROW_TYPE'; break; + case self::SPREAD_TYPE: + $name = 'SPREAD_TYPE'; + break; default: - throw new \LogicException(sprintf('Token of type "%s" does not exist.', $type)); + throw new \LogicException(\sprintf('Token of type "%s" does not exist.', $type)); } return $short ? $name : 'Twig\Token::'.$name; @@ -171,8 +230,10 @@ public static function typeToEnglish(int $type): string return 'end of string interpolation'; case self::ARROW_TYPE: return 'arrow function'; + case self::SPREAD_TYPE: + return 'spread operator'; default: - throw new \LogicException(sprintf('Token of type "%s" does not exist.', $type)); + throw new \LogicException(\sprintf('Token of type "%s" does not exist.', $type)); } } } diff --git a/src/TokenParser/AbstractTokenParser.php b/src/TokenParser/AbstractTokenParser.php index 720ea676283..8acaa6f56e9 100644 --- a/src/TokenParser/AbstractTokenParser.php +++ b/src/TokenParser/AbstractTokenParser.php @@ -11,7 +11,11 @@ namespace Twig\TokenParser; +use Twig\Lexer; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Nodes; use Twig\Parser; +use Twig\Token; /** * Base class for all token parsers. @@ -29,4 +33,29 @@ public function setParser(Parser $parser): void { $this->parser = $parser; } + + /** + * Parses an assignment expression like "a, b". + */ + protected function parseAssignmentExpression(): Nodes + { + $stream = $this->parser->getStream(); + $targets = []; + while (true) { + $token = $stream->getCurrent(); + if ($stream->test(Token::OPERATOR_TYPE) && preg_match(Lexer::REGEX_NAME, $token->getValue())) { + // in this context, string operators are variable names + $stream->next(); + } else { + $stream->expect(Token::NAME_TYPE, null, 'Only variables can be assigned to'); + } + $targets[] = new AssignContextVariable($token->getValue(), $token->getLine()); + + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + return new Nodes($targets); + } } diff --git a/src/TokenParser/ApplyTokenParser.php b/src/TokenParser/ApplyTokenParser.php index 4dbf30406b0..5b560e74916 100644 --- a/src/TokenParser/ApplyTokenParser.php +++ b/src/TokenParser/ApplyTokenParser.php @@ -11,8 +11,10 @@ namespace Twig\TokenParser; -use Twig\Node\Expression\TempNameExpression; +use Twig\ExpressionParser\Infix\FilterExpressionParser; +use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\PrintNode; use Twig\Node\SetNode; use Twig\Token; @@ -31,21 +33,25 @@ final class ApplyTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $lineno = $token->getLine(); - $name = $this->parser->getVarName(); - - $ref = new TempNameExpression($name, $lineno); - $ref->setAttribute('always_defined', true); - - $filter = $this->parser->getExpressionParser()->parseFilterExpressionRaw($ref, $this->getTag()); + $ref = new LocalVariable(null, $lineno); + $filter = $ref; + $op = $this->parser->getEnvironment()->getExpressionParsers()->getByClass(FilterExpressionParser::class); + while (true) { + $filter = $op->parse($this->parser, $filter, $this->parser->getCurrentToken()); + if (!$this->parser->getStream()->test(Token::OPERATOR_TYPE, '|')) { + break; + } + $this->parser->getStream()->next(); + } $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideApplyEnd'], true); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new Node([ - new SetNode(true, $ref, $body, $lineno, $this->getTag()), - new PrintNode($filter, $lineno, $this->getTag()), - ]); + return new Nodes([ + new SetNode(true, $ref, $body, $lineno), + new PrintNode($filter, $lineno), + ], $lineno); } public function decideApplyEnd(Token $token): bool diff --git a/src/TokenParser/AutoEscapeTokenParser.php b/src/TokenParser/AutoEscapeTokenParser.php index b674bea4ab0..86feb27e621 100644 --- a/src/TokenParser/AutoEscapeTokenParser.php +++ b/src/TokenParser/AutoEscapeTokenParser.php @@ -29,21 +29,21 @@ public function parse(Token $token): Node $lineno = $token->getLine(); $stream = $this->parser->getStream(); - if ($stream->test(/* Token::BLOCK_END_TYPE */ 3)) { + if ($stream->test(Token::BLOCK_END_TYPE)) { $value = 'html'; } else { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); if (!$expr instanceof ConstantExpression) { throw new SyntaxError('An escaping strategy must be a string or false.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } $value = $expr->getAttribute('value'); } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - return new AutoEscapeNode($value, $body, $lineno, $this->getTag()); + return new AutoEscapeNode($value, $body, $lineno); } public function decideBlockEnd(Token $token): bool diff --git a/src/TokenParser/BlockTokenParser.php b/src/TokenParser/BlockTokenParser.php index 5878131bec3..452b323e533 100644 --- a/src/TokenParser/BlockTokenParser.php +++ b/src/TokenParser/BlockTokenParser.php @@ -15,7 +15,9 @@ use Twig\Error\SyntaxError; use Twig\Node\BlockNode; use Twig\Node\BlockReferenceNode; +use Twig\Node\EmptyNode; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\PrintNode; use Twig\Token; @@ -35,35 +37,32 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $name = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); - if ($this->parser->hasBlock($name)) { - throw new SyntaxError(sprintf("The block '%s' has already been defined line %d.", $name, $this->parser->getBlock($name)->getTemplateLine()), $stream->getCurrent()->getLine(), $stream->getSourceContext()); - } - $this->parser->setBlock($name, $block = new BlockNode($name, new Node([]), $lineno)); + $name = $stream->expect(Token::NAME_TYPE)->getValue(); + $this->parser->setBlock($name, $block = new BlockNode($name, new EmptyNode(), $lineno)); $this->parser->pushLocalScope(); $this->parser->pushBlockStack($name); - if ($stream->nextIf(/* Token::BLOCK_END_TYPE */ 3)) { + if ($stream->nextIf(Token::BLOCK_END_TYPE)) { $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); - if ($token = $stream->nextIf(/* Token::NAME_TYPE */ 5)) { + if ($token = $stream->nextIf(Token::NAME_TYPE)) { $value = $token->getValue(); if ($value != $name) { - throw new SyntaxError(sprintf('Expected endblock for block "%s" (but "%s" given).', $name, $value), $stream->getCurrent()->getLine(), $stream->getSourceContext()); + throw new SyntaxError(\sprintf('Expected endblock for block "%s" (but "%s" given).', $name, $value), $stream->getCurrent()->getLine(), $stream->getSourceContext()); } } } else { - $body = new Node([ - new PrintNode($this->parser->getExpressionParser()->parseExpression(), $lineno), + $body = new Nodes([ + new PrintNode($this->parser->parseExpression(), $lineno), ]); } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $block->setNode('body', $body); $this->parser->popBlockStack(); $this->parser->popLocalScope(); - return new BlockReferenceNode($name, $lineno, $this->getTag()); + return new BlockReferenceNode($name, $lineno); } public function decideBlockEnd(Token $token): bool diff --git a/src/TokenParser/DeprecatedTokenParser.php b/src/TokenParser/DeprecatedTokenParser.php index 31416c79c15..df1ba381f44 100644 --- a/src/TokenParser/DeprecatedTokenParser.php +++ b/src/TokenParser/DeprecatedTokenParser.php @@ -11,6 +11,7 @@ namespace Twig\TokenParser; +use Twig\Error\SyntaxError; use Twig\Node\DeprecatedNode; use Twig\Node\Node; use Twig\Token; @@ -21,6 +22,8 @@ * {% deprecated 'The "base.twig" template is deprecated, use "layout.twig" instead.' %} * {% extends 'layout.html.twig' %} * + * {% deprecated 'The "base.twig" template is deprecated, use "layout.twig" instead.' package="foo/bar" version="1.1" %} + * * @author Yonel Ceruto * * @internal @@ -29,11 +32,30 @@ final class DeprecatedTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $stream = $this->parser->getStream(); + $expr = $this->parser->parseExpression(); + $node = new DeprecatedNode($expr, $token->getLine()); + + while ($stream->test(Token::NAME_TYPE)) { + $k = $stream->getCurrent()->getValue(); + $stream->next(); + $stream->expect(Token::OPERATOR_TYPE, '='); + + switch ($k) { + case 'package': + $node->setNode('package', $this->parser->parseExpression()); + break; + case 'version': + $node->setNode('version', $this->parser->parseExpression()); + break; + default: + throw new SyntaxError(\sprintf('Unknown "%s" option.', $k), $stream->getCurrent()->getLine(), $stream->getSourceContext()); + } + } - $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + $stream->expect(Token::BLOCK_END_TYPE); - return new DeprecatedNode($expr, $token->getLine(), $this->getTag()); + return $node; } public function getTag(): string diff --git a/src/TokenParser/DoTokenParser.php b/src/TokenParser/DoTokenParser.php index 32c8f12ff86..ca9d03d454f 100644 --- a/src/TokenParser/DoTokenParser.php +++ b/src/TokenParser/DoTokenParser.php @@ -24,11 +24,11 @@ final class DoTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); - $this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new DoNode($expr, $token->getLine(), $this->getTag()); + return new DoNode($expr, $token->getLine()); } public function getTag(): string diff --git a/src/TokenParser/EmbedTokenParser.php b/src/TokenParser/EmbedTokenParser.php index 64b4f296f27..fa279104614 100644 --- a/src/TokenParser/EmbedTokenParser.php +++ b/src/TokenParser/EmbedTokenParser.php @@ -13,7 +13,7 @@ use Twig\Node\EmbedNode; use Twig\Node\Expression\ConstantExpression; -use Twig\Node\Expression\NameExpression; +use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\Node; use Twig\Token; @@ -28,23 +28,23 @@ public function parse(Token $token): Node { $stream = $this->parser->getStream(); - $parent = $this->parser->getExpressionParser()->parseExpression(); + $parent = $this->parser->parseExpression(); - list($variables, $only, $ignoreMissing) = $this->parseArguments(); + [$variables, $only, $ignoreMissing] = $this->parseArguments(); - $parentToken = $fakeParentToken = new Token(/* Token::STRING_TYPE */ 7, '__parent__', $token->getLine()); + $parentToken = $fakeParentToken = new Token(Token::STRING_TYPE, '__parent__', $token->getLine()); if ($parent instanceof ConstantExpression) { - $parentToken = new Token(/* Token::STRING_TYPE */ 7, $parent->getAttribute('value'), $token->getLine()); - } elseif ($parent instanceof NameExpression) { - $parentToken = new Token(/* Token::NAME_TYPE */ 5, $parent->getAttribute('name'), $token->getLine()); + $parentToken = new Token(Token::STRING_TYPE, $parent->getAttribute('value'), $token->getLine()); + } elseif ($parent instanceof ContextVariable) { + $parentToken = new Token(Token::NAME_TYPE, $parent->getAttribute('name'), $token->getLine()); } // inject a fake parent to make the parent() function work $stream->injectTokens([ - new Token(/* Token::BLOCK_START_TYPE */ 1, '', $token->getLine()), - new Token(/* Token::NAME_TYPE */ 5, 'extends', $token->getLine()), + new Token(Token::BLOCK_START_TYPE, '', $token->getLine()), + new Token(Token::NAME_TYPE, 'extends', $token->getLine()), $parentToken, - new Token(/* Token::BLOCK_END_TYPE */ 3, '', $token->getLine()), + new Token(Token::BLOCK_END_TYPE, '', $token->getLine()), ]); $module = $this->parser->parse($stream, [$this, 'decideBlockEnd'], true); @@ -56,9 +56,9 @@ public function parse(Token $token): Node $this->parser->embedTemplate($module); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - return new EmbedNode($module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $ignoreMissing, $token->getLine(), $this->getTag()); + return new EmbedNode($module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $ignoreMissing, $token->getLine()); } public function decideBlockEnd(Token $token): bool diff --git a/src/TokenParser/ExtendsTokenParser.php b/src/TokenParser/ExtendsTokenParser.php index 0ca46dd29f7..8f64698187d 100644 --- a/src/TokenParser/ExtendsTokenParser.php +++ b/src/TokenParser/ExtendsTokenParser.php @@ -13,6 +13,7 @@ namespace Twig\TokenParser; use Twig\Error\SyntaxError; +use Twig\Node\EmptyNode; use Twig\Node\Node; use Twig\Token; @@ -35,14 +36,11 @@ public function parse(Token $token): Node throw new SyntaxError('Cannot use "extend" in a macro.', $token->getLine(), $stream->getSourceContext()); } - if (null !== $this->parser->getParent()) { - throw new SyntaxError('Multiple extends tags are forbidden.', $token->getLine(), $stream->getSourceContext()); - } - $this->parser->setParent($this->parser->getExpressionParser()->parseExpression()); + $this->parser->setParent($this->parser->parseExpression()); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - return new Node(); + return new EmptyNode($token->getLine()); } public function getTag(): string diff --git a/src/TokenParser/FlushTokenParser.php b/src/TokenParser/FlushTokenParser.php index 02c74aa134b..0d238874579 100644 --- a/src/TokenParser/FlushTokenParser.php +++ b/src/TokenParser/FlushTokenParser.php @@ -26,9 +26,9 @@ final class FlushTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new FlushNode($token->getLine(), $this->getTag()); + return new FlushNode($token->getLine()); } public function getTag(): string diff --git a/src/TokenParser/ForTokenParser.php b/src/TokenParser/ForTokenParser.php index bac8ba2dae8..21166fc1fab 100644 --- a/src/TokenParser/ForTokenParser.php +++ b/src/TokenParser/ForTokenParser.php @@ -12,7 +12,8 @@ namespace Twig\TokenParser; -use Twig\Node\Expression\AssignNameExpression; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\ForElseNode; use Twig\Node\ForNode; use Twig\Node\Node; use Twig\Token; @@ -34,31 +35,32 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $targets = $this->parser->getExpressionParser()->parseAssignmentExpression(); - $stream->expect(/* Token::OPERATOR_TYPE */ 8, 'in'); - $seq = $this->parser->getExpressionParser()->parseExpression(); + $targets = $this->parseAssignmentExpression(); + $stream->expect(Token::OPERATOR_TYPE, 'in'); + $seq = $this->parser->parseExpression(); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideForFork']); if ('else' == $stream->next()->getValue()) { - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); - $else = $this->parser->subparse([$this, 'decideForEnd'], true); + $elseLineno = $stream->getCurrent()->getLine(); + $stream->expect(Token::BLOCK_END_TYPE); + $else = new ForElseNode($this->parser->subparse([$this, 'decideForEnd'], true), $elseLineno); } else { $else = null; } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); if (\count($targets) > 1) { - $keyTarget = $targets->getNode(0); - $keyTarget = new AssignNameExpression($keyTarget->getAttribute('name'), $keyTarget->getTemplateLine()); - $valueTarget = $targets->getNode(1); + $keyTarget = $targets->getNode('0'); + $keyTarget = new AssignContextVariable($keyTarget->getAttribute('name'), $keyTarget->getTemplateLine()); + $valueTarget = $targets->getNode('1'); } else { - $keyTarget = new AssignNameExpression('_key', $lineno); - $valueTarget = $targets->getNode(0); + $keyTarget = new AssignContextVariable('_key', $lineno); + $valueTarget = $targets->getNode('0'); } - $valueTarget = new AssignNameExpression($valueTarget->getAttribute('name'), $valueTarget->getTemplateLine()); + $valueTarget = new AssignContextVariable($valueTarget->getAttribute('name'), $valueTarget->getTemplateLine()); - return new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, $lineno, $this->getTag()); + return new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, $lineno); } public function decideForFork(Token $token): bool diff --git a/src/TokenParser/FromTokenParser.php b/src/TokenParser/FromTokenParser.php index 35098c267b1..1c80a171777 100644 --- a/src/TokenParser/FromTokenParser.php +++ b/src/TokenParser/FromTokenParser.php @@ -11,7 +11,9 @@ namespace Twig\TokenParser; -use Twig\Node\Expression\AssignNameExpression; +use Twig\Node\Expression\Variable\AssignContextVariable; +use Twig\Node\Expression\Variable\AssignTemplateVariable; +use Twig\Node\Expression\Variable\TemplateVariable; use Twig\Node\ImportNode; use Twig\Node\Node; use Twig\Token; @@ -19,7 +21,7 @@ /** * Imports macros. * - * {% from 'forms.html' import forms %} + * {% from 'forms.html.twig' import forms %} * * @internal */ @@ -27,33 +29,34 @@ final class FromTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $macro = $this->parser->getExpressionParser()->parseExpression(); + $macro = $this->parser->parseExpression(); $stream = $this->parser->getStream(); - $stream->expect(/* Token::NAME_TYPE */ 5, 'import'); + $stream->expect(Token::NAME_TYPE, 'import'); $targets = []; - do { - $name = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); + while (true) { + $name = $stream->expect(Token::NAME_TYPE)->getValue(); - $alias = $name; if ($stream->nextIf('as')) { - $alias = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); + $alias = new AssignContextVariable($stream->expect(Token::NAME_TYPE)->getValue(), $token->getLine()); + } else { + $alias = new AssignContextVariable($name, $token->getLine()); } $targets[$name] = $alias; - if (!$stream->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ',')) { + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { break; } - } while (true); + } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - $var = new AssignNameExpression($this->parser->getVarName(), $token->getLine()); - $node = new ImportNode($macro, $var, $token->getLine(), $this->getTag(), $this->parser->isMainScope()); + $internalRef = new AssignTemplateVariable(new TemplateVariable(null, $token->getLine()), $this->parser->isMainScope()); + $node = new ImportNode($macro, $internalRef, $token->getLine()); foreach ($targets as $name => $alias) { - $this->parser->addImportedSymbol('function', $alias, 'macro_'.$name, $var); + $this->parser->addImportedSymbol('function', $alias->getAttribute('name'), 'macro_'.$name, $internalRef); } return $node; diff --git a/src/TokenParser/GuardTokenParser.php b/src/TokenParser/GuardTokenParser.php new file mode 100644 index 00000000000..eb48865795c --- /dev/null +++ b/src/TokenParser/GuardTokenParser.php @@ -0,0 +1,79 @@ +parser->getStream(); + $typeToken = $stream->expect(Token::NAME_TYPE); + if (!\in_array($typeToken->getValue(), ['function', 'filter', 'test'], true)) { + throw new SyntaxError(\sprintf('Supported guard types are function, filter and test, "%s" given.', $typeToken->getValue()), $typeToken->getLine(), $stream->getSourceContext()); + } + $method = 'get'.$typeToken->getValue(); + + $nameToken = $stream->expect(Token::NAME_TYPE); + $name = $nameToken->getValue(); + if ('test' === $typeToken->getValue() && $stream->test(Token::NAME_TYPE)) { + // try 2-words tests + $name .= ' '.$stream->getCurrent()->getValue(); + $stream->next(); + } + + try { + $exists = null !== $this->parser->getEnvironment()->$method($name); + } catch (SyntaxError) { + $exists = false; + } + + $stream->expect(Token::BLOCK_END_TYPE); + if ($exists) { + $body = $this->parser->subparse([$this, 'decideGuardFork']); + } else { + $body = new EmptyNode(); + $this->parser->subparseIgnoreUnknownTwigCallables([$this, 'decideGuardFork']); + } + $else = new EmptyNode(); + if ('else' === $stream->next()->getValue()) { + $stream->expect(Token::BLOCK_END_TYPE); + $else = $this->parser->subparse([$this, 'decideGuardEnd'], true); + } + $stream->expect(Token::BLOCK_END_TYPE); + + return new Nodes([$exists ? $body : $else]); + } + + public function decideGuardFork(Token $token): bool + { + return $token->test(['else', 'endguard']); + } + + public function decideGuardEnd(Token $token): bool + { + return $token->test(['endguard']); + } + + public function getTag(): string + { + return 'guard'; + } +} diff --git a/src/TokenParser/IfTokenParser.php b/src/TokenParser/IfTokenParser.php index c0fe6df0d10..4e3588e5be5 100644 --- a/src/TokenParser/IfTokenParser.php +++ b/src/TokenParser/IfTokenParser.php @@ -15,6 +15,7 @@ use Twig\Error\SyntaxError; use Twig\Node\IfNode; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Token; /** @@ -35,9 +36,9 @@ final class IfTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $lineno = $token->getLine(); - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); $stream = $this->parser->getStream(); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideIfFork']); $tests = [$expr, $body]; $else = null; @@ -46,13 +47,13 @@ public function parse(Token $token): Node while (!$end) { switch ($stream->next()->getValue()) { case 'else': - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $else = $this->parser->subparse([$this, 'decideIfEnd']); break; case 'elseif': - $expr = $this->parser->getExpressionParser()->parseExpression(); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $expr = $this->parser->parseExpression(); + $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideIfFork']); $tests[] = $expr; $tests[] = $body; @@ -63,13 +64,13 @@ public function parse(Token $token): Node break; default: - throw new SyntaxError(sprintf('Unexpected end of template. Twig was looking for the following tags "else", "elseif", or "endif" to close the "if" block started at line %d).', $lineno), $stream->getCurrent()->getLine(), $stream->getSourceContext()); + throw new SyntaxError(\sprintf('Unexpected end of template. Twig was looking for the following tags "else", "elseif", or "endif" to close the "if" block started at line %d).', $lineno), $stream->getCurrent()->getLine(), $stream->getSourceContext()); } } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - return new IfNode(new Node($tests), $else, $lineno, $this->getTag()); + return new IfNode(new Nodes($tests), $else, $lineno); } public function decideIfFork(Token $token): bool diff --git a/src/TokenParser/ImportTokenParser.php b/src/TokenParser/ImportTokenParser.php index 44cb4dad79d..6dcb7662cbf 100644 --- a/src/TokenParser/ImportTokenParser.php +++ b/src/TokenParser/ImportTokenParser.php @@ -11,7 +11,8 @@ namespace Twig\TokenParser; -use Twig\Node\Expression\AssignNameExpression; +use Twig\Node\Expression\Variable\AssignTemplateVariable; +use Twig\Node\Expression\Variable\TemplateVariable; use Twig\Node\ImportNode; use Twig\Node\Node; use Twig\Token; @@ -19,7 +20,7 @@ /** * Imports macros. * - * {% import 'forms.html' as forms %} + * {% import 'forms.html.twig' as forms %} * * @internal */ @@ -27,14 +28,14 @@ final class ImportTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $macro = $this->parser->getExpressionParser()->parseExpression(); - $this->parser->getStream()->expect(/* Token::NAME_TYPE */ 5, 'as'); - $var = new AssignNameExpression($this->parser->getStream()->expect(/* Token::NAME_TYPE */ 5)->getValue(), $token->getLine()); - $this->parser->getStream()->expect(/* Token::BLOCK_END_TYPE */ 3); + $macro = $this->parser->parseExpression(); + $this->parser->getStream()->expect(Token::NAME_TYPE, 'as'); + $name = $this->parser->getStream()->expect(Token::NAME_TYPE)->getValue(); + $var = new AssignTemplateVariable(new TemplateVariable($name, $token->getLine()), $this->parser->isMainScope()); + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + $this->parser->addImportedSymbol('template', $name); - $this->parser->addImportedSymbol('template', $var->getAttribute('name')); - - return new ImportNode($macro, $var, $token->getLine(), $this->getTag(), $this->parser->isMainScope()); + return new ImportNode($macro, $var, $token->getLine()); } public function getTag(): string diff --git a/src/TokenParser/IncludeTokenParser.php b/src/TokenParser/IncludeTokenParser.php index 28beb8ae477..55ac1516c4e 100644 --- a/src/TokenParser/IncludeTokenParser.php +++ b/src/TokenParser/IncludeTokenParser.php @@ -12,6 +12,7 @@ namespace Twig\TokenParser; +use Twig\Node\Expression\AbstractExpression; use Twig\Node\IncludeNode; use Twig\Node\Node; use Twig\Token; @@ -19,9 +20,9 @@ /** * Includes a template. * - * {% include 'header.html' %} + * {% include 'header.html.twig' %} * Body - * {% include 'footer.html' %} + * {% include 'footer.html.twig' %} * * @internal */ @@ -29,35 +30,38 @@ class IncludeTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $expr = $this->parser->getExpressionParser()->parseExpression(); + $expr = $this->parser->parseExpression(); - list($variables, $only, $ignoreMissing) = $this->parseArguments(); + [$variables, $only, $ignoreMissing] = $this->parseArguments(); - return new IncludeNode($expr, $variables, $only, $ignoreMissing, $token->getLine(), $this->getTag()); + return new IncludeNode($expr, $variables, $only, $ignoreMissing, $token->getLine()); } + /** + * @return array{0: ?AbstractExpression, 1: bool, 2: bool} + */ protected function parseArguments() { $stream = $this->parser->getStream(); $ignoreMissing = false; - if ($stream->nextIf(/* Token::NAME_TYPE */ 5, 'ignore')) { - $stream->expect(/* Token::NAME_TYPE */ 5, 'missing'); + if ($stream->nextIf(Token::NAME_TYPE, 'ignore')) { + $stream->expect(Token::NAME_TYPE, 'missing'); $ignoreMissing = true; } $variables = null; - if ($stream->nextIf(/* Token::NAME_TYPE */ 5, 'with')) { - $variables = $this->parser->getExpressionParser()->parseExpression(); + if ($stream->nextIf(Token::NAME_TYPE, 'with')) { + $variables = $this->parser->parseExpression(); } $only = false; - if ($stream->nextIf(/* Token::NAME_TYPE */ 5, 'only')) { + if ($stream->nextIf(Token::NAME_TYPE, 'only')) { $only = true; } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); return [$variables, $only, $ignoreMissing]; } diff --git a/src/TokenParser/MacroTokenParser.php b/src/TokenParser/MacroTokenParser.php index f584927e908..38e66c81073 100644 --- a/src/TokenParser/MacroTokenParser.php +++ b/src/TokenParser/MacroTokenParser.php @@ -13,6 +13,12 @@ use Twig\Error\SyntaxError; use Twig\Node\BodyNode; +use Twig\Node\EmptyNode; +use Twig\Node\Expression\ArrayExpression; +use Twig\Node\Expression\ConstantExpression; +use Twig\Node\Expression\Unary\NegUnary; +use Twig\Node\Expression\Unary\PosUnary; +use Twig\Node\Expression\Variable\LocalVariable; use Twig\Node\MacroNode; use Twig\Node\Node; use Twig\Token; @@ -32,26 +38,25 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $name = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); + $name = $stream->expect(Token::NAME_TYPE)->getValue(); + $arguments = $this->parseDefinition(); - $arguments = $this->parser->getExpressionParser()->parseArguments(true, true); - - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $this->parser->pushLocalScope(); $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); - if ($token = $stream->nextIf(/* Token::NAME_TYPE */ 5)) { + if ($token = $stream->nextIf(Token::NAME_TYPE)) { $value = $token->getValue(); if ($value != $name) { - throw new SyntaxError(sprintf('Expected endmacro for macro "%s" (but "%s" given).', $name, $value), $stream->getCurrent()->getLine(), $stream->getSourceContext()); + throw new SyntaxError(\sprintf('Expected endmacro for macro "%s" (but "%s" given).', $name, $value), $stream->getCurrent()->getLine(), $stream->getSourceContext()); } } $this->parser->popLocalScope(); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - $this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno, $this->getTag())); + $this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno)); - return new Node(); + return new EmptyNode($lineno); } public function decideBlockEnd(Token $token): bool @@ -63,4 +68,56 @@ public function getTag(): string { return 'macro'; } + + private function parseDefinition(): ArrayExpression + { + $arguments = new ArrayExpression([], $this->parser->getCurrentToken()->getLine()); + $stream = $this->parser->getStream(); + $stream->expect(Token::OPERATOR_TYPE, '(', 'A list of arguments must begin with an opening parenthesis'); + while (!$stream->test(Token::PUNCTUATION_TYPE, ')')) { + if (\count($arguments)) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'Arguments must be separated by a comma'); + + // if the comma above was a trailing comma, early exit the argument parse loop + if ($stream->test(Token::PUNCTUATION_TYPE, ')')) { + break; + } + } + + $token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name'); + $name = new LocalVariable($token->getValue(), $this->parser->getCurrentToken()->getLine()); + if ($token = $stream->nextIf(Token::OPERATOR_TYPE, '=')) { + $default = $this->parser->parseExpression(); + } else { + $default = new ConstantExpression(null, $this->parser->getCurrentToken()->getLine()); + $default->setAttribute('is_implicit', true); + } + + if (!$this->checkConstantExpression($default)) { + throw new SyntaxError('A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping).', $token->getLine(), $stream->getSourceContext()); + } + $arguments->addElement($default, $name); + } + $stream->expect(Token::PUNCTUATION_TYPE, ')', 'A list of arguments must be closed by a parenthesis'); + + return $arguments; + } + + // checks that the node only contains "constant" elements + private function checkConstantExpression(Node $node): bool + { + if (!($node instanceof ConstantExpression || $node instanceof ArrayExpression + || $node instanceof NegUnary || $node instanceof PosUnary + )) { + return false; + } + + foreach ($node as $n) { + if (!$this->checkConstantExpression($n)) { + return false; + } + } + + return true; + } } diff --git a/src/TokenParser/SandboxTokenParser.php b/src/TokenParser/SandboxTokenParser.php index c919556eccb..536c14f30ba 100644 --- a/src/TokenParser/SandboxTokenParser.php +++ b/src/TokenParser/SandboxTokenParser.php @@ -22,7 +22,7 @@ * Marks a section of a template as untrusted code that must be evaluated in the sandbox mode. * * {% sandbox %} - * {% include 'user.html' %} + * {% include 'user.html.twig' %} * {% endsandbox %} * * @see https://twig.symfony.com/doc/api.html#sandbox-extension for details @@ -34,9 +34,11 @@ final class SandboxTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $stream = $this->parser->getStream(); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + trigger_deprecation('twig/twig', '3.15', \sprintf('The "sandbox" tag is deprecated in "%s" at line %d.', $stream->getSourceContext()->getName(), $token->getLine())); + + $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); // in a sandbox tag, only include tags are allowed if (!$body instanceof IncludeNode) { @@ -51,7 +53,7 @@ public function parse(Token $token): Node } } - return new SandboxNode($body, $token->getLine(), $this->getTag()); + return new SandboxNode($body, $token->getLine()); } public function decideBlockEnd(Token $token): bool diff --git a/src/TokenParser/SetTokenParser.php b/src/TokenParser/SetTokenParser.php index 2fbdfe0901f..1aabbf582b1 100644 --- a/src/TokenParser/SetTokenParser.php +++ b/src/TokenParser/SetTokenParser.php @@ -13,6 +13,7 @@ use Twig\Error\SyntaxError; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Node\SetNode; use Twig\Token; @@ -34,13 +35,13 @@ public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); - $names = $this->parser->getExpressionParser()->parseAssignmentExpression(); + $names = $this->parseAssignmentExpression(); $capture = false; - if ($stream->nextIf(/* Token::OPERATOR_TYPE */ 8, '=')) { - $values = $this->parser->getExpressionParser()->parseMultitargetExpression(); + if ($stream->nextIf(Token::OPERATOR_TYPE, '=')) { + $values = $this->parseMultitargetExpression(); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); if (\count($names) !== \count($values)) { throw new SyntaxError('When using set, you must have the same number of variables and assignments.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); @@ -52,13 +53,13 @@ public function parse(Token $token): Node throw new SyntaxError('When using set with a block, you cannot have a multi-target.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $values = $this->parser->subparse([$this, 'decideBlockEnd'], true); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); } - return new SetNode($capture, $names, $values, $lineno, $this->getTag()); + return new SetNode($capture, $names, $values, $lineno); } public function decideBlockEnd(Token $token): bool @@ -70,4 +71,17 @@ public function getTag(): string { return 'set'; } + + private function parseMultitargetExpression(): Nodes + { + $targets = []; + while (true) { + $targets[] = $this->parser->parseExpression(); + if (!$this->parser->getStream()->nextIf(Token::PUNCTUATION_TYPE, ',')) { + break; + } + } + + return new Nodes($targets); + } } diff --git a/src/TokenParser/TypesTokenParser.php b/src/TokenParser/TypesTokenParser.php new file mode 100644 index 00000000000..2c7b77c024b --- /dev/null +++ b/src/TokenParser/TypesTokenParser.php @@ -0,0 +1,89 @@ + + * + * @internal + */ +final class TypesTokenParser extends AbstractTokenParser +{ + public function parse(Token $token): Node + { + $stream = $this->parser->getStream(); + $types = $this->parseSimpleMappingExpression($stream); + $stream->expect(Token::BLOCK_END_TYPE); + + return new TypesNode($types, $token->getLine()); + } + + /** + * @return array + * + * @throws SyntaxError + */ + private function parseSimpleMappingExpression(TokenStream $stream): array + { + $enclosed = null !== $stream->nextIf(Token::PUNCTUATION_TYPE, '{'); + $types = []; + $first = true; + while (!($stream->test(Token::PUNCTUATION_TYPE, '}') || $stream->test(Token::BLOCK_END_TYPE))) { + if (!$first) { + $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A type string must be followed by a comma'); + + // trailing ,? + if ($stream->test(Token::PUNCTUATION_TYPE, '}') || $stream->test(Token::BLOCK_END_TYPE)) { + break; + } + } + $first = false; + + $nameToken = $stream->expect(Token::NAME_TYPE); + + if ($stream->nextIf(Token::OPERATOR_TYPE, '?:')) { + $isOptional = true; + } else { + $isOptional = null !== $stream->nextIf(Token::OPERATOR_TYPE, '?'); + $stream->expect(Token::PUNCTUATION_TYPE, ':', 'A type name must be followed by a colon (:)'); + } + + $valueToken = $stream->expect(Token::STRING_TYPE); + + $types[$nameToken->getValue()] = [ + 'type' => $valueToken->getValue(), + 'optional' => $isOptional, + ]; + } + + if ($enclosed) { + $stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed'); + } + + return $types; + } + + public function getTag(): string + { + return 'types'; + } +} diff --git a/src/TokenParser/UseTokenParser.php b/src/TokenParser/UseTokenParser.php index d0a2de41a2e..41386c8b479 100644 --- a/src/TokenParser/UseTokenParser.php +++ b/src/TokenParser/UseTokenParser.php @@ -12,8 +12,10 @@ namespace Twig\TokenParser; use Twig\Error\SyntaxError; +use Twig\Node\EmptyNode; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Node; +use Twig\Node\Nodes; use Twig\Token; /** @@ -34,7 +36,7 @@ final class UseTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { - $template = $this->parser->getExpressionParser()->parseExpression(); + $template = $this->parser->parseExpression(); $stream = $this->parser->getStream(); if (!$template instanceof ConstantExpression) { @@ -43,27 +45,27 @@ public function parse(Token $token): Node $targets = []; if ($stream->nextIf('with')) { - do { - $name = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); + while (true) { + $name = $stream->expect(Token::NAME_TYPE)->getValue(); $alias = $name; if ($stream->nextIf('as')) { - $alias = $stream->expect(/* Token::NAME_TYPE */ 5)->getValue(); + $alias = $stream->expect(Token::NAME_TYPE)->getValue(); } $targets[$name] = new ConstantExpression($alias, -1); - if (!$stream->nextIf(/* Token::PUNCTUATION_TYPE */ 9, ',')) { + if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { break; } - } while (true); + } } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - $this->parser->addTrait(new Node(['template' => $template, 'targets' => new Node($targets)])); + $this->parser->addTrait(new Nodes(['template' => $template, 'targets' => new Nodes($targets)])); - return new Node(); + return new EmptyNode($token->getLine()); } public function getTag(): string diff --git a/src/TokenParser/WithTokenParser.php b/src/TokenParser/WithTokenParser.php index 7d8cbe26165..83470d8651f 100644 --- a/src/TokenParser/WithTokenParser.php +++ b/src/TokenParser/WithTokenParser.php @@ -30,18 +30,18 @@ public function parse(Token $token): Node $variables = null; $only = false; - if (!$stream->test(/* Token::BLOCK_END_TYPE */ 3)) { - $variables = $this->parser->getExpressionParser()->parseExpression(); - $only = (bool) $stream->nextIf(/* Token::NAME_TYPE */ 5, 'only'); + if (!$stream->test(Token::BLOCK_END_TYPE)) { + $variables = $this->parser->parseExpression(); + $only = (bool) $stream->nextIf(Token::NAME_TYPE, 'only'); } - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideWithEnd'], true); - $stream->expect(/* Token::BLOCK_END_TYPE */ 3); + $stream->expect(Token::BLOCK_END_TYPE); - return new WithNode($body, $variables, $only, $token->getLine(), $this->getTag()); + return new WithNode($body, $variables, $only, $token->getLine()); } public function decideWithEnd(Token $token): bool diff --git a/src/TokenStream.php b/src/TokenStream.php index 1eac11a02d6..7ee7539f1a3 100644 --- a/src/TokenStream.php +++ b/src/TokenStream.php @@ -21,21 +21,27 @@ */ final class TokenStream { - private $tokens; private $current = 0; - private $source; - public function __construct(array $tokens, Source $source = null) - { - $this->tokens = $tokens; - $this->source = $source ?: new Source('', ''); + public function __construct( + private array $tokens, + private ?Source $source = null, + ) { + if (null === $this->source) { + trigger_deprecation('twig/twig', '3.16', \sprintf('Not passing a "%s" object to "%s" constructor is deprecated.', Source::class, __CLASS__)); + + $this->source = new Source('', ''); + } } - public function __toString() + public function __toString(): string { return implode("\n", $this->tokens); } + /** + * @return void + */ public function injectTokens(array $tokens) { $this->tokens = array_merge(\array_slice($this->tokens, 0, $this->current), $tokens, \array_slice($this->tokens, $this->current)); @@ -60,24 +66,22 @@ public function next(): Token */ public function nextIf($primary, $secondary = null) { - if ($this->tokens[$this->current]->test($primary, $secondary)) { - return $this->next(); - } + return $this->tokens[$this->current]->test($primary, $secondary) ? $this->next() : null; } /** * Tests a token and returns it or throws a syntax error. */ - public function expect($type, $value = null, string $message = null): Token + public function expect($type, $value = null, ?string $message = null): Token { $token = $this->tokens[$this->current]; if (!$token->test($type, $value)) { $line = $token->getLine(); - throw new SyntaxError(sprintf('%sUnexpected token "%s"%s ("%s" expected%s).', + throw new SyntaxError(\sprintf('%sUnexpected token "%s"%s ("%s" expected%s).', $message ? $message.'. ' : '', - Token::typeToEnglish($token->getType()), - $token->getValue() ? sprintf(' of value "%s"', $token->getValue()) : '', - Token::typeToEnglish($type), $value ? sprintf(' with value "%s"', $value) : ''), + $token->toEnglish(), + $token->getValue() ? \sprintf(' of value "%s"', $token->getValue()) : '', + Token::typeToEnglish($type), $value ? \sprintf(' with value "%s"', $value) : ''), $line, $this->source ); @@ -112,7 +116,7 @@ public function test($primary, $secondary = null): bool */ public function isEOF(): bool { - return /* Token::EOF_TYPE */ -1 === $this->tokens[$this->current]->getType(); + return $this->tokens[$this->current]->test(Token::EOF_TYPE); } public function getCurrent(): Token @@ -120,11 +124,6 @@ public function getCurrent(): Token return $this->tokens[$this->current]; } - /** - * Gets the source associated with this stream. - * - * @internal - */ public function getSourceContext(): Source { return $this->source; diff --git a/src/TwigCallableInterface.php b/src/TwigCallableInterface.php new file mode 100644 index 00000000000..2a8ff6116bc --- /dev/null +++ b/src/TwigCallableInterface.php @@ -0,0 +1,53 @@ + + */ +interface TwigCallableInterface extends \Stringable +{ + public function getName(): string; + + public function getType(): string; + + public function getDynamicName(): string; + + /** + * @return callable|array{class-string, string}|null + */ + public function getCallable(); + + public function getNodeClass(): string; + + public function needsCharset(): bool; + + public function needsEnvironment(): bool; + + public function needsContext(): bool; + + public function withDynamicArguments(string $name, string $dynamicName, array $arguments): self; + + public function getArguments(): array; + + public function isVariadic(): bool; + + public function isDeprecated(): bool; + + public function getDeprecatingPackage(): string; + + public function getDeprecatedVersion(): string; + + public function getAlternative(): ?string; + + public function getMinimalNumberOfRequiredArguments(): int; +} diff --git a/src/TwigFilter.php b/src/TwigFilter.php index 94e5f9b012b..dece5184355 100644 --- a/src/TwigFilter.php +++ b/src/TwigFilter.php @@ -21,72 +21,27 @@ * * @see https://twig.symfony.com/doc/templates.html#filters */ -final class TwigFilter +final class TwigFilter extends AbstractTwigCallable { - private $name; - private $callable; - private $options; - private $arguments = []; - /** - * @param callable|null $callable A callable implementing the filter. If null, you need to overwrite the "node_class" option to customize compilation. + * @param callable|array{class-string, string}|null $callable A callable implementing the filter. If null, you need to overwrite the "node_class" option to customize compilation. */ public function __construct(string $name, $callable = null, array $options = []) { - $this->name = $name; - $this->callable = $callable; + parent::__construct($name, $callable, $options); + $this->options = array_merge([ - 'needs_environment' => false, - 'needs_context' => false, - 'is_variadic' => false, 'is_safe' => null, 'is_safe_callback' => null, 'pre_escape' => null, 'preserves_safety' => null, 'node_class' => FilterExpression::class, - 'deprecated' => false, - 'alternative' => null, - ], $options); - } - - public function getName(): string - { - return $this->name; - } - - /** - * Returns the callable to execute for this filter. - * - * @return callable|null - */ - public function getCallable() - { - return $this->callable; - } - - public function getNodeClass(): string - { - return $this->options['node_class']; - } - - public function setArguments(array $arguments): void - { - $this->arguments = $arguments; + ], $this->options); } - public function getArguments(): array + public function getType(): string { - return $this->arguments; - } - - public function needsEnvironment(): bool - { - return $this->options['needs_environment']; - } - - public function needsContext(): bool - { - return $this->options['needs_context']; + return 'filter'; } public function getSafe(Node $filterArgs): ?array @@ -99,12 +54,12 @@ public function getSafe(Node $filterArgs): ?array return $this->options['is_safe_callback']($filterArgs); } - return null; + return []; } - public function getPreservesSafety(): ?array + public function getPreservesSafety(): array { - return $this->options['preserves_safety']; + return $this->options['preserves_safety'] ?? []; } public function getPreEscape(): ?string @@ -112,23 +67,8 @@ public function getPreEscape(): ?string return $this->options['pre_escape']; } - public function isVariadic(): bool - { - return $this->options['is_variadic']; - } - - public function isDeprecated(): bool - { - return (bool) $this->options['deprecated']; - } - - public function getDeprecatedVersion(): string - { - return \is_bool($this->options['deprecated']) ? '' : $this->options['deprecated']; - } - - public function getAlternative(): ?string + public function getMinimalNumberOfRequiredArguments(): int { - return $this->options['alternative']; + return parent::getMinimalNumberOfRequiredArguments() + 1; } } diff --git a/src/TwigFunction.php b/src/TwigFunction.php index 494d45b08c5..4a10df95e65 100644 --- a/src/TwigFunction.php +++ b/src/TwigFunction.php @@ -21,70 +21,31 @@ * * @see https://twig.symfony.com/doc/templates.html#functions */ -final class TwigFunction +final class TwigFunction extends AbstractTwigCallable { - private $name; - private $callable; - private $options; - private $arguments = []; - /** - * @param callable|null $callable A callable implementing the function. If null, you need to overwrite the "node_class" option to customize compilation. + * @param callable|array{class-string, string}|null $callable A callable implementing the function. If null, you need to overwrite the "node_class" option to customize compilation. */ public function __construct(string $name, $callable = null, array $options = []) { - $this->name = $name; - $this->callable = $callable; + parent::__construct($name, $callable, $options); + $this->options = array_merge([ - 'needs_environment' => false, - 'needs_context' => false, - 'is_variadic' => false, 'is_safe' => null, 'is_safe_callback' => null, 'node_class' => FunctionExpression::class, - 'deprecated' => false, - 'alternative' => null, - ], $options); - } - - public function getName(): string - { - return $this->name; - } - - /** - * Returns the callable to execute for this function. - * - * @return callable|null - */ - public function getCallable() - { - return $this->callable; - } - - public function getNodeClass(): string - { - return $this->options['node_class']; - } - - public function setArguments(array $arguments): void - { - $this->arguments = $arguments; + 'parser_callable' => null, + ], $this->options); } - public function getArguments(): array + public function getType(): string { - return $this->arguments; + return 'function'; } - public function needsEnvironment(): bool + public function getParserCallable(): ?callable { - return $this->options['needs_environment']; - } - - public function needsContext(): bool - { - return $this->options['needs_context']; + return $this->options['parser_callable']; } public function getSafe(Node $functionArgs): ?array @@ -99,24 +60,4 @@ public function getSafe(Node $functionArgs): ?array return []; } - - public function isVariadic(): bool - { - return (bool) $this->options['is_variadic']; - } - - public function isDeprecated(): bool - { - return (bool) $this->options['deprecated']; - } - - public function getDeprecatedVersion(): string - { - return \is_bool($this->options['deprecated']) ? '' : $this->options['deprecated']; - } - - public function getAlternative(): ?string - { - return $this->options['alternative']; - } } diff --git a/src/TwigTest.php b/src/TwigTest.php index 4c18632f559..5e58ad8b0e8 100644 --- a/src/TwigTest.php +++ b/src/TwigTest.php @@ -20,81 +20,48 @@ * * @see https://twig.symfony.com/doc/templates.html#test-operator */ -final class TwigTest +final class TwigTest extends AbstractTwigCallable { - private $name; - private $callable; - private $options; - private $arguments = []; - /** - * @param callable|null $callable A callable implementing the test. If null, you need to overwrite the "node_class" option to customize compilation. + * @param callable|array{class-string, string}|null $callable A callable implementing the test. If null, you need to overwrite the "node_class" option to customize compilation. */ public function __construct(string $name, $callable = null, array $options = []) { - $this->name = $name; - $this->callable = $callable; + parent::__construct($name, $callable, $options); + $this->options = array_merge([ - 'is_variadic' => false, 'node_class' => TestExpression::class, - 'deprecated' => false, - 'alternative' => null, 'one_mandatory_argument' => false, - ], $options); - } - - public function getName(): string - { - return $this->name; - } - - /** - * Returns the callable to execute for this test. - * - * @return callable|null - */ - public function getCallable() - { - return $this->callable; + ], $this->options); } - public function getNodeClass(): string + public function getType(): string { - return $this->options['node_class']; + return 'test'; } - public function setArguments(array $arguments): void + public function needsCharset(): bool { - $this->arguments = $arguments; + return false; } - public function getArguments(): array + public function needsEnvironment(): bool { - return $this->arguments; + return false; } - public function isVariadic(): bool + public function needsContext(): bool { - return (bool) $this->options['is_variadic']; + return false; } - public function isDeprecated(): bool - { - return (bool) $this->options['deprecated']; - } - - public function getDeprecatedVersion(): string - { - return \is_bool($this->options['deprecated']) ? '' : $this->options['deprecated']; - } - - public function getAlternative(): ?string + public function hasOneMandatoryArgument(): bool { - return $this->options['alternative']; + return (bool) $this->options['one_mandatory_argument']; } - public function hasOneMandatoryArgument(): bool + public function getMinimalNumberOfRequiredArguments(): int { - return (bool) $this->options['one_mandatory_argument']; + return parent::getMinimalNumberOfRequiredArguments() + 1; } } diff --git a/src/Util/CallableArgumentsExtractor.php b/src/Util/CallableArgumentsExtractor.php new file mode 100644 index 00000000000..d8625169d70 --- /dev/null +++ b/src/Util/CallableArgumentsExtractor.php @@ -0,0 +1,219 @@ + + * + * @internal + */ +final class CallableArgumentsExtractor +{ + private ReflectionCallable $rc; + + public function __construct( + private Node $node, + private TwigCallableInterface $twigCallable, + ) { + $this->rc = new ReflectionCallable($twigCallable); + } + + /** + * @return array + */ + public function extractArguments(Node $arguments): array + { + $extractedArguments = []; + $extractedArgumentNameMap = []; + $named = false; + foreach ($arguments as $name => $node) { + if (!\is_int($name)) { + $named = true; + } elseif ($named) { + throw new SyntaxError(\sprintf('Positional arguments cannot be used after named arguments for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); + } + + $extractedArguments[$normalizedName = $this->normalizeName($name)] = $node; + $extractedArgumentNameMap[$normalizedName] = $name; + } + + if (!$named && !$this->twigCallable->isVariadic()) { + $min = $this->twigCallable->getMinimalNumberOfRequiredArguments(); + if (\count($extractedArguments) < $this->rc->getReflector()->getNumberOfRequiredParameters() - $min) { + $argName = $this->toSnakeCase($this->rc->getReflector()->getParameters()[$min + \count($extractedArguments)]->getName()); + + throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $argName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); + } + + return $extractedArguments; + } + + if (!$callable = $this->twigCallable->getCallable()) { + if ($named) { + throw new SyntaxError(\sprintf('Named arguments are not supported for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName())); + } + + throw new SyntaxError(\sprintf('Arbitrary positional arguments are not supported for %s "%s".', $this->twigCallable->getType(), $this->twigCallable->getName())); + } + + [$callableParameters, $isPhpVariadic] = $this->getCallableParameters(); + $arguments = []; + $callableParameterNames = []; + $missingArguments = []; + $optionalArguments = []; + $pos = 0; + foreach ($callableParameters as $callableParameter) { + $callableParameterName = $callableParameter->name; + if (\PHP_VERSION_ID >= 80000 && 'range' === $callable) { + if ('start' === $callableParameterName) { + $callableParameterName = 'low'; + } elseif ('end' === $callableParameterName) { + $callableParameterName = 'high'; + } + } + + $callableParameterNames[] = $callableParameterName; + $normalizedCallableParameterName = $this->normalizeName($callableParameterName); + + if (\array_key_exists($normalizedCallableParameterName, $extractedArguments)) { + if (\array_key_exists($pos, $extractedArguments)) { + throw new SyntaxError(\sprintf('Argument "%s" is defined twice for %s "%s".', $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); + } + + if (\count($missingArguments)) { + throw new SyntaxError(\sprintf( + 'Argument "%s" could not be assigned for %s "%s(%s)" because it is mapped to an internal PHP function which cannot determine default value for optional argument%s "%s".', + $callableParameterName, $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', array_map([$this, 'toSnakeCase'], $callableParameterNames)), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments) + ), $this->node->getTemplateLine(), $this->node->getSourceContext()); + } + + $arguments = array_merge($arguments, $optionalArguments); + $arguments[] = $extractedArguments[$normalizedCallableParameterName]; + unset($extractedArguments[$normalizedCallableParameterName]); + $optionalArguments = []; + } elseif (\array_key_exists($pos, $extractedArguments)) { + $arguments = array_merge($arguments, $optionalArguments); + $arguments[] = $extractedArguments[$pos]; + unset($extractedArguments[$pos]); + $optionalArguments = []; + ++$pos; + } elseif ($callableParameter->isDefaultValueAvailable()) { + $optionalArguments[] = new ConstantExpression($callableParameter->getDefaultValue(), $this->node->getTemplateLine()); + } elseif ($callableParameter->isOptional()) { + if (!$extractedArguments) { + break; + } + + $missingArguments[] = $callableParameterName; + } else { + throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $this->toSnakeCase($callableParameterName), $this->twigCallable->getType(), $this->twigCallable->getName()), $this->node->getTemplateLine(), $this->node->getSourceContext()); + } + } + + if ($this->twigCallable->isVariadic()) { + $arbitraryArguments = $isPhpVariadic ? new VariadicExpression([], $this->node->getTemplateLine()) : new ArrayExpression([], $this->node->getTemplateLine()); + foreach ($extractedArguments as $key => $value) { + if (\is_int($key)) { + $arbitraryArguments->addElement($value); + } else { + $originalKey = $extractedArgumentNameMap[$key]; + if ($originalKey !== $this->toSnakeCase($originalKey)) { + trigger_deprecation('twig/twig', '3.15', \sprintf('Using "snake_case" for variadic arguments is required for a smooth upgrade with Twig 4.0; rename "%s" to "%s" in "%s" at line %d.', $originalKey, $this->toSnakeCase($originalKey), $this->node->getSourceContext()->getName(), $this->node->getTemplateLine())); + } + $arbitraryArguments->addElement($value, new ConstantExpression($this->toSnakeCase($originalKey), $this->node->getTemplateLine())); + // I Twig 4.0, don't convert the key: + // $arbitraryArguments->addElement($value, new ConstantExpression($originalKey, $this->node->getTemplateLine())); + } + unset($extractedArguments[$key]); + } + + if ($arbitraryArguments->count()) { + $arguments = array_merge($arguments, $optionalArguments); + $arguments[] = $arbitraryArguments; + } + } + + if ($extractedArguments) { + $unknownArgument = null; + foreach ($extractedArguments as $extractedArgument) { + if ($extractedArgument instanceof Node) { + $unknownArgument = $extractedArgument; + break; + } + } + + throw new SyntaxError( + \sprintf( + 'Unknown argument%s "%s" for %s "%s(%s)".', + \count($extractedArguments) > 1 ? 's' : '', implode('", "', array_keys($extractedArguments)), $this->twigCallable->getType(), $this->twigCallable->getName(), implode(', ', array_map([$this, 'toSnakeCase'], $callableParameterNames)) + ), + $unknownArgument ? $unknownArgument->getTemplateLine() : $this->node->getTemplateLine(), + $unknownArgument ? $unknownArgument->getSourceContext() : $this->node->getSourceContext() + ); + } + + return $arguments; + } + + private function normalizeName(string $name): string + { + return strtolower(str_replace('_', '', $name)); + } + + private function toSnakeCase(string $name): string + { + return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z0-9])([A-Z])/'], '\1_\2', $name)); + } + + private function getCallableParameters(): array + { + $parameters = $this->rc->getReflector()->getParameters(); + if ($this->node->hasNode('node')) { + array_shift($parameters); + } + if ($this->twigCallable->needsCharset()) { + array_shift($parameters); + } + if ($this->twigCallable->needsEnvironment()) { + array_shift($parameters); + } + if ($this->twigCallable->needsContext()) { + array_shift($parameters); + } + foreach ($this->twigCallable->getArguments() as $argument) { + array_shift($parameters); + } + + $isPhpVariadic = false; + if ($this->twigCallable->isVariadic()) { + $argument = end($parameters); + $isArray = $argument && $argument->hasType() && $argument->getType() instanceof \ReflectionNamedType && 'array' === $argument->getType()->getName(); + if ($isArray && $argument->isDefaultValueAvailable() && [] === $argument->getDefaultValue()) { + array_pop($parameters); + } elseif ($argument && $argument->isVariadic()) { + array_pop($parameters); + $isPhpVariadic = true; + } else { + throw new SyntaxError(\sprintf('The last parameter of "%s" for %s "%s" must be an array with default value, eg. "array $arg = []".', $this->rc->getName(), $this->twigCallable->getType(), $this->twigCallable->getName())); + } + } + + return [$parameters, $isPhpVariadic]; + } +} diff --git a/src/Util/DeprecationCollector.php b/src/Util/DeprecationCollector.php index 378b666bdb8..0ea26ed4baf 100644 --- a/src/Util/DeprecationCollector.php +++ b/src/Util/DeprecationCollector.php @@ -20,11 +20,9 @@ */ final class DeprecationCollector { - private $twig; - - public function __construct(Environment $twig) - { - $this->twig = $twig; + public function __construct( + private Environment $twig, + ) { } /** @@ -60,6 +58,8 @@ public function collect(\Traversable $iterator): array if (\E_USER_DEPRECATED === $type) { $deprecations[] = $msg; } + + return false; }); foreach ($iterator as $name => $contents) { diff --git a/src/Util/ReflectionCallable.php b/src/Util/ReflectionCallable.php new file mode 100644 index 00000000000..0298e291de3 --- /dev/null +++ b/src/Util/ReflectionCallable.php @@ -0,0 +1,95 @@ + + * + * @internal + */ +final class ReflectionCallable +{ + private $reflector; + private $callable; + private $name; + + public function __construct( + TwigCallableInterface $twigCallable, + ) { + $callable = $twigCallable->getCallable(); + if (\is_string($callable) && false !== $pos = strpos($callable, '::')) { + $callable = [substr($callable, 0, $pos), substr($callable, 2 + $pos)]; + } + + if (\is_array($callable) && method_exists($callable[0], $callable[1])) { + $this->reflector = $r = new \ReflectionMethod($callable[0], $callable[1]); + $this->callable = $callable; + $this->name = $r->class.'::'.$r->name; + + return; + } + + $checkVisibility = $callable instanceof \Closure; + try { + $closure = \Closure::fromCallable($callable); + } catch (\TypeError $e) { + throw new \LogicException(\sprintf('Callback for %s "%s" is not callable in the current scope.', $twigCallable->getType(), $twigCallable->getName()), 0, $e); + } + $this->reflector = $r = new \ReflectionFunction($closure); + + if (str_contains($r->name, '{closure')) { + $this->callable = $callable; + $this->name = 'Closure'; + + return; + } + + if ($object = $r->getClosureThis()) { + $callable = [$object, $r->name]; + $this->name = get_debug_type($object).'::'.$r->name; + } elseif (\PHP_VERSION_ID >= 80111 && $class = $r->getClosureCalledClass()) { + $callable = [$class->name, $r->name]; + $this->name = $class->name.'::'.$r->name; + } elseif (\PHP_VERSION_ID < 80111 && $class = $r->getClosureScopeClass()) { + $callable = [\is_array($callable) ? $callable[0] : $class->name, $r->name]; + $this->name = (\is_array($callable) ? $callable[0] : $class->name).'::'.$r->name; + } else { + $callable = $this->name = $r->name; + } + + if ($checkVisibility && \is_array($callable) && method_exists(...$callable) && !(new \ReflectionMethod(...$callable))->isPublic()) { + $callable = $r->getClosure(); + } + + $this->callable = $callable; + } + + public function getReflector(): \ReflectionFunctionAbstract + { + return $this->reflector; + } + + /** + * @return callable + */ + public function getCallable() + { + return $this->callable; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Util/TemplateDirIterator.php b/src/Util/TemplateDirIterator.php index 3bef14beec3..d739b285f2c 100644 --- a/src/Util/TemplateDirIterator.php +++ b/src/Util/TemplateDirIterator.php @@ -17,7 +17,7 @@ class TemplateDirIterator extends \IteratorIterator { /** - * @return mixed + * @return string */ #[\ReturnTypeWillChange] public function current() @@ -26,7 +26,7 @@ public function current() } /** - * @return mixed + * @return string */ #[\ReturnTypeWillChange] public function key() diff --git a/tests/Cache/ChainTest.php b/tests/Cache/ChainTest.php new file mode 100644 index 00000000000..4383e603430 --- /dev/null +++ b/tests/Cache/ChainTest.php @@ -0,0 +1,238 @@ +className = '__Twig_Tests_Cache_ChainTest_Template_'.$nonce; + $this->directory = sys_get_temp_dir().'/twig-test'; + $this->cache = new ChainCache([ + new FilesystemCache($this->directory.'/A'), + new FilesystemCache($this->directory.'/B'), + ]); + $this->key = $this->cache->generateKey('_test_', $this->className); + } + + protected function tearDown(): void + { + if (file_exists($this->directory)) { + FilesystemHelper::removeDir($this->directory); + } + } + + public function testLoadInA() + { + $cache = new FilesystemCache($this->directory.'/A'); + $key = $cache->generateKey('_test_', $this->className); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->className, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $this->cache->load($this->key); + + $this->assertTrue(class_exists($this->className, false)); + } + + public function testLoadInB() + { + $cache = new FilesystemCache($this->directory.'/B'); + $key = $cache->generateKey('_test_', $this->className); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->className, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $this->cache->load($this->key); + + $this->assertTrue(class_exists($this->className, false)); + } + + public function testLoadInBoth() + { + $cache = new FilesystemCache($this->directory.'/A'); + $key = $cache->generateKey('_test_', $this->className); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->className, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $cache = new FilesystemCache($this->directory.'/B'); + $key = $cache->generateKey('_test_', $this->className); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->className, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $this->cache->load($this->key); + + $this->assertTrue(class_exists($this->className, false)); + } + + public function testLoadMissing() + { + $this->assertFalse(class_exists($this->className, false)); + + $this->cache->load($this->key); + + $this->assertFalse(class_exists($this->className, false)); + } + + public function testWrite() + { + $content = $this->generateSource(); + + $cacheA = new FilesystemCache($this->directory.'/A'); + $keyA = $cacheA->generateKey('_test_', $this->className); + + $this->assertFileDoesNotExist($keyA); + $this->assertFileDoesNotExist($this->directory.'/A'); + + $cacheB = new FilesystemCache($this->directory.'/B'); + $keyB = $cacheB->generateKey('_test_', $this->className); + + $this->assertFileDoesNotExist($keyB); + $this->assertFileDoesNotExist($this->directory.'/B'); + + $this->cache->write($this->key, $content); + + $this->assertFileExists($this->directory.'/A'); + $this->assertFileExists($keyA); + $this->assertSame(file_get_contents($keyA), $content); + + $this->assertFileExists($this->directory.'/B'); + $this->assertFileExists($keyB); + $this->assertSame(file_get_contents($keyB), $content); + } + + public function testGetTimestampInA() + { + $cache = new FilesystemCache($this->directory.'/A'); + $key = $cache->generateKey('_test_', $this->className); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($key, 1234567890); + + $this->assertSame(1234567890, $this->cache->getTimestamp($this->key)); + } + + public function testGetTimestampInB() + { + $cache = new FilesystemCache($this->directory.'/B'); + $key = $cache->generateKey('_test_', $this->className); + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($key, 1234567890); + + $this->assertSame(1234567890, $this->cache->getTimestamp($this->key)); + } + + public function testGetTimestampInBoth() + { + $cacheA = new FilesystemCache($this->directory.'/A'); + $keyA = $cacheA->generateKey('_test_', $this->className); + + $dir = \dirname($keyA); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($keyA, 1234567890); + + $cacheB = new FilesystemCache($this->directory.'/B'); + $keyB = $cacheB->generateKey('_test_', $this->className); + + $dir = \dirname($keyB); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($keyB, 1234567891); + + $this->assertSame(1234567890, $this->cache->getTimestamp($this->key)); + } + + public function testGetTimestampMissingFile() + { + $this->assertSame(0, $this->cache->getTimestamp($this->key)); + } + + /** + * @dataProvider provideInput + */ + public function testGenerateKey($expected, $input) + { + $cache = new ChainCache([]); + $this->assertSame($expected, $cache->generateKey($input, static::class)); + } + + public static function provideInput() + { + return [ + ['Twig\Tests\Cache\ChainTest#_test_', '_test_'], + ['Twig\Tests\Cache\ChainTest#_test#with#hashtag_', '_test#with#hashtag_'], + ]; + } + + private function generateSource() + { + return strtr(' $this->className, + ]); + } +} diff --git a/tests/Cache/FilesystemTest.php b/tests/Cache/FilesystemTest.php index 349869c26e3..85b1e976c10 100644 --- a/tests/Cache/FilesystemTest.php +++ b/tests/Cache/FilesystemTest.php @@ -1,5 +1,14 @@ classname = '__Twig_Tests_Cache_FilesystemTest_Template_'.$nonce; + $nonce = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', random_bytes(32)); + $this->className = '__Twig_Tests_Cache_FilesystemTest_Template_'.$nonce; $this->directory = sys_get_temp_dir().'/twig-test'; $this->cache = new FilesystemCache($this->directory); } @@ -43,25 +52,25 @@ public function testLoad() $dir = \dirname($key); @mkdir($dir, 0777, true); $this->assertDirectoryExists($dir); - $this->assertFalse(class_exists($this->classname, false)); + $this->assertFalse(class_exists($this->className, false)); $content = $this->generateSource(); file_put_contents($key, $content); $this->cache->load($key); - $this->assertTrue(class_exists($this->classname, false)); + $this->assertTrue(class_exists($this->className, false)); } public function testLoadMissing() { $key = $this->directory.'/cache/cachefile.php'; - $this->assertFalse(class_exists($this->classname, false)); + $this->assertFalse(class_exists($this->className, false)); $this->cache->load($key); - $this->assertFalse(class_exists($this->classname, false)); + $this->assertFalse(class_exists($this->className, false)); } public function testWrite() @@ -81,9 +90,6 @@ public function testWrite() public function testWriteFailMkdir() { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unable to create the cache directory'); - if (\defined('PHP_WINDOWS_VERSION_BUILD')) { $this->markTestSkipped('Read-only directories not possible on Windows.'); } @@ -97,14 +103,14 @@ public function testWriteFailMkdir() @mkdir($this->directory, 0555, true); $this->assertDirectoryExists($this->directory); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to create the cache directory'); + $this->cache->write($key, $content); } public function testWriteFailDirWritable() { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unable to write in the cache directory'); - if (\defined('PHP_WINDOWS_VERSION_BUILD')) { $this->markTestSkipped('Read-only directories not possible on Windows.'); } @@ -120,14 +126,14 @@ public function testWriteFailDirWritable() @mkdir($this->directory.'/cache', 0555); $this->assertDirectoryExists($this->directory.'/cache'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to write in the cache directory'); + $this->cache->write($key, $content); } public function testWriteFailWriteFile() { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Failed to write cache file'); - $key = $this->directory.'/cache/cachefile.php'; $content = $this->generateSource(); @@ -137,6 +143,9 @@ public function testWriteFailWriteFile() @mkdir($key, 0777, true); $this->assertDirectoryExists($key); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to write cache file'); + $this->cache->write($key, $content); } @@ -171,7 +180,7 @@ public function testGenerateKey($expected, $input) $this->assertMatchesRegularExpression($expected, $cache->generateKey('_test_', static::class)); } - public function provideDirectories() + public static function provideDirectories() { $pattern = '#a/b/[a-zA-Z0-9]+/[a-zA-Z0-9]+.php$#'; @@ -187,8 +196,8 @@ public function provideDirectories() private function generateSource() { - return strtr(' $this->classname, + return strtr(' $this->className, ]); } } diff --git a/tests/Cache/ReadOnlyFilesystemTest.php b/tests/Cache/ReadOnlyFilesystemTest.php new file mode 100644 index 00000000000..d67276b84ac --- /dev/null +++ b/tests/Cache/ReadOnlyFilesystemTest.php @@ -0,0 +1,141 @@ +className = '__Twig_Tests_Cache_ReadOnlyFilesystemTest_Template_'.$nonce; + $this->directory = sys_get_temp_dir().'/twig-test'; + $this->cache = new ReadOnlyFilesystemCache($this->directory); + } + + protected function tearDown(): void + { + if (file_exists($this->directory)) { + FilesystemHelper::removeDir($this->directory); + } + } + + public function testLoad() + { + $key = $this->directory.'/cache/ro-cachefile.php'; + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + $this->assertFalse(class_exists($this->className, false)); + + $content = $this->generateSource(); + file_put_contents($key, $content); + + $this->cache->load($key); + + $this->assertTrue(class_exists($this->className, false)); + } + + public function testLoadMissing() + { + $key = $this->directory.'/cache/cachefile.php'; + + $this->assertFalse(class_exists($this->className, false)); + + $this->cache->load($key); + + $this->assertFalse(class_exists($this->className, false)); + } + + public function testWrite() + { + $key = $this->directory.'/cache/cachefile.php'; + $content = $this->generateSource(); + + $this->assertFileDoesNotExist($key); + $this->assertFileDoesNotExist($this->directory); + + $this->cache->write($key, $content); + + $this->assertFileDoesNotExist($this->directory); + $this->assertFileDoesNotExist($key); + } + + public function testGetTimestamp() + { + $key = $this->directory.'/cache/cachefile.php'; + + $dir = \dirname($key); + @mkdir($dir, 0777, true); + $this->assertDirectoryExists($dir); + + // Create the file with a specific modification time. + touch($key, 1234567890); + + $this->assertSame(1234567890, $this->cache->getTimestamp($key)); + } + + public function testGetTimestampMissingFile() + { + $key = $this->directory.'/cache/cachefile.php'; + $this->assertSame(0, $this->cache->getTimestamp($key)); + } + + /** + * Test file cache is tolerant towards trailing (back)slashes on the configured cache directory. + * + * @dataProvider provideDirectories + */ + public function testGenerateKey($expected, $input) + { + $cache = new ReadOnlyFilesystemCache($input); + $this->assertMatchesRegularExpression($expected, $cache->generateKey('_test_', static::class)); + } + + public static function provideDirectories() + { + $pattern = '#a/b/[a-zA-Z0-9]+/[a-zA-Z0-9]+.php$#'; + + return [ + [$pattern, 'a/b'], + [$pattern, 'a/b/'], + [$pattern, 'a/b\\'], + [$pattern, 'a/b\\/'], + [$pattern, 'a/b\\//'], + ['#/'.substr($pattern, 1), '/a/b'], + ]; + } + + private function generateSource() + { + return strtr(' $this->className, + ]); + } +} diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php index 35ffad90980..58b3379669b 100644 --- a/tests/CompilerTest.php +++ b/tests/CompilerTest.php @@ -1,5 +1,14 @@ createMock(LoaderInterface::class))); + $compiler = new Compiler(new Environment(new ArrayLoader())); - $locale = setlocale(\LC_NUMERIC, 0); + $locale = setlocale(\LC_NUMERIC, '0'); if (false === $locale) { $this->markTestSkipped('Your platform does not support locales.'); } @@ -33,7 +42,7 @@ public function testReprNumericValueWithLocale() } $this->assertEquals('1.2', $compiler->repr(1.2)->getSource()); - $this->assertStringContainsString('fr', strtolower(setlocale(\LC_NUMERIC, 0))); + $this->assertStringContainsString('fr', strtolower(setlocale(\LC_NUMERIC, '0'))); setlocale(\LC_NUMERIC, $locale); } diff --git a/tests/ContainerRuntimeLoaderTest.php b/tests/ContainerRuntimeLoaderTest.php index cadd6826f11..ae1429257b3 100644 --- a/tests/ContainerRuntimeLoaderTest.php +++ b/tests/ContainerRuntimeLoaderTest.php @@ -1,5 +1,14 @@ addExtension($extension); + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); - $env = new Environment($this->createMock(LoaderInterface::class)); - $env->addExtension($extension); - $env->getUnaryOperators(); + $env->getExpressionParsers(); } - public function provideInvalidExtensions() + public static function provideInvalidExtensions() { return [ [new InvalidOperatorExtension([1, 2, 3]), '"Twig\Tests\InvalidOperatorExtension::getOperators()" must return an array of 2 elements, got 3.'], diff --git a/tests/DeprecatedCallableInfoTest.php b/tests/DeprecatedCallableInfoTest.php new file mode 100644 index 00000000000..69969b952fa --- /dev/null +++ b/tests/DeprecatedCallableInfoTest.php @@ -0,0 +1,89 @@ +setType('function'); + $info->setName('foo'); + + $deprecations = []; + try { + set_error_handler(function ($type, $msg) use (&$deprecations) { + if (\E_USER_DEPRECATED === $type) { + $deprecations[] = $msg; + } + + return false; + }); + + $info->triggerDeprecation('foo.twig', 1); + } finally { + restore_error_handler(); + } + + $this->assertSame([$expected], $deprecations); + } + + public static function provideTestsForTriggerDeprecation(): iterable + { + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1')]; + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated; use "alt_foo" from the "all/bar" package (available since version 12.10) instead in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1', 'alt_foo', 'all/bar', '12.10')]; + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated; use "alt_foo" from the "all/bar" package instead in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1', 'alt_foo', 'all/bar')]; + yield ['Since foo/bar 1.1: Twig Function "foo" is deprecated; use "alt_foo" instead in foo.twig at line 1.', new DeprecatedCallableInfo('foo/bar', '1.1', 'alt_foo')]; + } + + public function testTriggerDeprecationWithoutFileOrLine() + { + $info = new DeprecatedCallableInfo('foo/bar', '1.1'); + $info->setType('function'); + $info->setName('foo'); + + $deprecations = []; + try { + set_error_handler(function ($type, $msg) use (&$deprecations) { + if (\E_USER_DEPRECATED === $type) { + $deprecations[] = $msg; + } + + return false; + }); + + $info->triggerDeprecation(); + $info->triggerDeprecation('foo.twig'); + } finally { + restore_error_handler(); + } + + $this->assertSame([ + 'Since foo/bar 1.1: Twig Function "foo" is deprecated.', + 'Since foo/bar 1.1: Twig Function "foo" is deprecated in foo.twig.', + ], $deprecations); + } +} diff --git a/tests/DummyBackedEnum.php b/tests/DummyBackedEnum.php new file mode 100644 index 00000000000..560e7fb86d3 --- /dev/null +++ b/tests/DummyBackedEnum.php @@ -0,0 +1,18 @@ +assertEquals(Environment::EXTRA_VERSION, $exploded[1] ?? ''); + + $version = $exploded[0]; + $exploded = explode('.', $version); + $this->assertEquals(Environment::MAJOR_VERSION, $exploded[0]); + $this->assertEquals(Environment::MINOR_VERSION, $exploded[1]); + $this->assertEquals(Environment::RELEASE_VERSION, $exploded[2]); + + $this->assertEquals(Environment::VERSION_ID, Environment::MAJOR_VERSION * 10000 + Environment::MINOR_VERSION * 100 + Environment::RELEASE_VERSION); + } + public function testAutoescapeOption() { $loader = new ArrayLoader([ @@ -159,19 +192,26 @@ public function testExtensionsAreNotInitializedWhenRenderingACompiledTemplate() // force compilation $twig = new Environment($loader = new ArrayLoader(['index' => '{{ foo }}']), $options); + $twig->addExtension($extension = new class extends AbstractExtension { + public bool $throw = false; + + public function getFilters(): array + { + if ($this->throw) { + throw new \RuntimeException('Extension are not supposed to be initialized.'); + } + + return parent::getFilters(); + } + }); $key = $cache->generateKey('index', $twig->getTemplateClass('index')); $cache->write($key, $twig->compileSource(new Source('{{ foo }}', 'index'))); // check that extensions won't be initialized when rendering a template that is already in the cache - $twig = $this - ->getMockBuilder(Environment::class) - ->setConstructorArgs([$loader, $options]) - ->setMethods(['initExtensions']) - ->getMock() - ; - - $twig->expects($this->never())->method('initExtensions'); + $twig = new Environment($loader, $options); + $extension->throw = true; + $twig->addExtension($extension); // render template $output = $twig->render('index', ['foo' => 'bar']); @@ -265,23 +305,23 @@ public function testAutoReloadOutdatedCacheHit() public function testHasGetExtensionByClassName() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $twig->addExtension($ext = new EnvironmentTest_Extension()); - $this->assertSame($ext, $twig->getExtension('Twig\Tests\EnvironmentTest_Extension')); - $this->assertSame($ext, $twig->getExtension('\Twig\Tests\EnvironmentTest_Extension')); + $this->assertSame($ext, $twig->getExtension(EnvironmentTest_Extension::class)); + $this->assertSame($ext, $twig->getExtension(EnvironmentTest_Extension::class)); } public function testAddExtension() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $twig->addExtension(new EnvironmentTest_Extension()); $this->assertArrayHasKey('test', $twig->getTokenParsers()); $this->assertArrayHasKey('foo_filter', $twig->getFilters()); $this->assertArrayHasKey('foo_function', $twig->getFunctions()); $this->assertArrayHasKey('foo_test', $twig->getTests()); - $this->assertArrayHasKey('foo_unary', $twig->getUnaryOperators()); - $this->assertArrayHasKey('foo_binary', $twig->getBinaryOperators()); + $this->assertNotNull($twig->getExpressionParsers()->getByName(PrefixExpressionParserInterface::class, 'foo_unary')); + $this->assertNotNull($twig->getExpressionParsers()->getByName(InfixExpressionParserInterface::class, 'foo_binary')); $this->assertArrayHasKey('foo_global', $twig->getGlobals()); $visitors = $twig->getNodeVisitors(); $found = false; @@ -301,18 +341,18 @@ public function testAddMockExtension() $twig = new Environment($loader); $twig->addExtension($extension); - $this->assertInstanceOf(ExtensionInterface::class, $twig->getExtension(\get_class($extension))); + $this->assertInstanceOf(ExtensionInterface::class, $twig->getExtension($extension::class)); $this->assertTrue($twig->isTemplateFresh('page', time())); } public function testOverrideExtension() { + $twig = new Environment(new ArrayLoader()); + $twig->addExtension(new EnvironmentTest_Extension()); + $this->expectException(\LogicException::class); $this->expectExceptionMessage('Unable to register extension "Twig\Tests\EnvironmentTest_Extension" as it is already registered.'); - $twig = new Environment($this->createMock(LoaderInterface::class)); - - $twig->addExtension(new EnvironmentTest_Extension()); $twig->addExtension(new EnvironmentTest_Extension()); } @@ -330,7 +370,7 @@ public function testAddRuntimeLoader() 'func_string_named_args' => '{{ from_runtime_string(name="foo") }}', ]); - $twig = new Environment($loader); + $twig = new Environment($loader, ['autoescape' => false]); $twig->addExtension(new EnvironmentTest_ExtensionWithoutRuntime()); $twig->addRuntimeLoader($runtimeLoader); @@ -344,17 +384,18 @@ public function testAddRuntimeLoader() public function testFailLoadTemplate() { + $template = 'testFailLoadTemplate.twig'; + $twig = new Environment(new ArrayLoader([$template => false])); + $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Failed to load Twig template "testFailLoadTemplate.twig", index "112233": cache might be corrupted in "testFailLoadTemplate.twig".'); - $template = 'testFailLoadTemplate.twig'; - $twig = new Environment(new ArrayLoader([$template => false])); $twig->loadTemplate($twig->getTemplateClass($template), $template, 112233); } public function testUndefinedFunctionCallback() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $twig->registerUndefinedFunctionCallback(function (string $name) { if ('dynamic' === $name) { return new TwigFunction('dynamic', function () { return 'dynamic'; }); @@ -370,7 +411,7 @@ public function testUndefinedFunctionCallback() public function testUndefinedFilterCallback() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $twig->registerUndefinedFilterCallback(function (string $name) { if ('dynamic' === $name) { return new TwigFilter('dynamic', function () { return 'dynamic'; }); @@ -384,9 +425,25 @@ public function testUndefinedFilterCallback() $this->assertSame('dynamic', $filter->getName()); } + public function testUndefinedTestCallback() + { + $twig = new Environment(new ArrayLoader()); + $twig->registerUndefinedTestCallback(function (string $name) { + if ('dynamic' === $name) { + return new TwigTest('dynamic', function () { return 'dynamic'; }); + } + + return false; + }); + + $this->assertNull($twig->getTest('does_not_exist')); + $this->assertInstanceOf(TwigTest::class, $test = $twig->getTest('dynamic')); + $this->assertSame('dynamic', $test->getName()); + } + public function testUndefinedTokenParserCallback() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $twig->registerUndefinedTokenParserCallback(function (string $name) { if ('dynamic' === $name) { $parser = $this->createMock(TokenParserInterface::class); @@ -403,6 +460,32 @@ public function testUndefinedTokenParserCallback() $this->assertSame('dynamic', $parser->getTag()); } + /** + * @group legacy + * + * @requires PHP 8 + */ + 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']); + + $twig = new Environment($loader); + $twig->addExtension(new EnvironmentTest_Extension()); + + if ($twig->useYield()) { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('An exception has been thrown during the compilation of a template ("You cannot enable the "use_yield" option of Twig as node "Twig\Tests\EnvironmentTest_LegacyEchoingNode" is not marked as ready for it; please make it ready and then flag it with the #[\Twig\Attribute\YieldReady] attribute.") in "echo_bar".'); + } else { + $this->expectDeprecation(<<<'EOF' +Since twig/twig 3.9: Twig node "Twig\Tests\EnvironmentTest_LegacyEchoingNode" is not marked as ready for using "yield" instead of "echo"; please make it ready and then flag it with the #[\Twig\Attribute\YieldReady] attribute. + Since twig/twig 3.9: Using "echo" is deprecated, use "yield" instead in "Twig\Tests\EnvironmentTest_LegacyEchoingNode", then flag the class with #[\Twig\Attribute\YieldReady]. +EOF + ); + } + + $this->assertSame('ADbarEBbarCFGbarH', $twig->render('echo_bar')); + } + protected function getMockLoader($templateName, $templateContent) { $loader = $this->createMock(LoaderInterface::class); @@ -417,6 +500,80 @@ protected function getMockLoader($templateName, $templateContent) return $loader; } + + public function testResettingGlobals() + { + $twig = new Environment(new ArrayLoader(['index' => ''])); + $twig->addExtension(new class extends AbstractExtension implements GlobalsInterface { + public function getGlobals(): array + { + return [ + 'global_ext' => bin2hex(random_bytes(16)), + ]; + } + }); + + // Force extensions initialization + $twig->load('index'); + + // Simulate request + $g1 = $twig->getGlobals(); + // Simulate another call from request 1 (the globals are cached) + $g2 = $twig->getGlobals(); + $this->assertSame($g1['global_ext'], $g2['global_ext']); + + // Simulate request 2 + $twig->resetGlobals(); + $g3 = $twig->getGlobals(); + $this->assertNotSame($g3['global_ext'], $g2['global_ext']); + } + + public function testHotCache() + { + $dir = sys_get_temp_dir().'/twig-hot-cache-test'; + if (is_dir($dir)) { + FilesystemHelper::removeDir($dir); + } + mkdir($dir); + file_put_contents($dir.'/index.twig', 'x'); + try { + $twig = new Environment(new FilesystemLoader($dir), [ + 'debug' => false, + 'auto_reload' => false, + 'cache' => $dir.'/cache', + ]); + + // prime the cache + $this->assertSame('x', $twig->load('index.twig')->render([])); + + // update the template + file_put_contents($dir.'/index.twig', 'y'); + + // re-render, should use the cached version + $this->assertSame('x', $twig->load('index.twig')->render([])); + + // clear the cache + $twig->removeCache('index.twig'); + + // re-render, should use the updated template + $this->assertSame('y', $twig->load('index.twig')->render([])); + + // the new template should not be cached + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir.'/cache', \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + $count = 0; + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isDir()) { + ++$count; + } + } + $this->assertSame(0, $count); + + // re-render, should use the updated template + $this->assertSame('y', $twig->load('index.twig')->render([])); + } finally { + FilesystemHelper::removeDir($dir); + } + } } class EnvironmentTest_Extension_WithGlobals extends AbstractExtension @@ -466,11 +623,11 @@ public function getFunctions(): array ]; } - public function getOperators(): array + public function getExpressionParsers(): array { return [ - ['foo_unary' => []], - ['foo_binary' => []], + new UnaryOperatorExpressionParser('', 'foo_unary', 0), + new BinaryOperatorExpressionParser('', 'foo_binary', 0), ]; } @@ -486,6 +643,9 @@ class EnvironmentTest_TokenParser extends AbstractTokenParser { public function parse(Token $token): Node { + $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); + + return new EnvironmentTest_LegacyEchoingNode([], [], 1); } public function getTag(): string @@ -530,3 +690,14 @@ public function fromRuntime($name = 'bar') return $name; } } + +class EnvironmentTest_LegacyEchoingNode extends Node +{ + public function compile($compiler) + { + $compiler + ->addDebugInfo($this) + ->write('echo "bar";') + ; + } +} diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php index 7892be9d07b..1dc145f5ef0 100644 --- a/tests/ErrorTest.php +++ b/tests/ErrorTest.php @@ -1,5 +1,14 @@ true, 'debug' => true, 'cache' => false]); @@ -70,7 +87,7 @@ public function testTwigExceptionGuessWithExceptionAndArrayLoader() {% block foo %} {{ foo.bar }} {% endblock %} -EOHTML +EOHTML, ]); $twig = new Environment($loader, ['strict_variables' => true, 'debug' => true, 'cache' => false]); @@ -97,7 +114,7 @@ public function testTwigExceptionGuessWithMissingVarAndFilesystemLoader() $this->fail(); } catch (RuntimeError $e) { - $this->assertEquals('Variable "foo" does not exist.', $e->getMessage()); + $this->assertEquals('Variable "foo" does not exist in "index.html" at line 3.', $e->getMessage()); $this->assertEquals(3, $e->getTemplateLine()); $this->assertEquals('index.html', $e->getSourceContext()->getName()); $this->assertEquals(3, $e->getLine()); @@ -116,7 +133,7 @@ public function testTwigExceptionGuessWithExceptionAndFilesystemLoader() $this->fail(); } catch (RuntimeError $e) { - $this->assertEquals('An exception has been thrown during the rendering of a template ("Runtime error...").', $e->getMessage()); + $this->assertEquals('An exception has been thrown during the rendering of a template ("Runtime error...") in "index.html" at line 3.', $e->getMessage()); $this->assertEquals(3, $e->getTemplateLine()); $this->assertEquals('index.html', $e->getSourceContext()->getName()); $this->assertEquals(3, $e->getLine()); @@ -139,7 +156,7 @@ public function testTwigExceptionAddsFileAndLine($templates, $name, $line) $this->fail(); } catch (RuntimeError $e) { - $this->assertEquals(sprintf('Variable "foo" does not exist in "%s" at line %d.', $name, $line), $e->getMessage()); + $this->assertEquals(\sprintf('Variable "foo" does not exist in "%s" at line %d.', $name, $line), $e->getMessage()); $this->assertEquals($line, $e->getTemplateLine()); $this->assertEquals($name, $e->getSourceContext()->getName()); } @@ -149,7 +166,7 @@ public function testTwigExceptionAddsFileAndLine($templates, $name, $line) $this->fail(); } catch (RuntimeError $e) { - $this->assertEquals(sprintf('An exception has been thrown during the rendering of a template ("Runtime error...") in "%s" at line %d.', $name, $line), $e->getMessage()); + $this->assertEquals(\sprintf('An exception has been thrown during the rendering of a template ("Runtime error...") in "%s" at line %d.', $name, $line), $e->getMessage()); $this->assertEquals($line, $e->getTemplateLine()); $this->assertEquals($name, $e->getSourceContext()->getName()); } @@ -163,7 +180,7 @@ public function testTwigArrayFilterThrowsRuntimeExceptions() {% for n in variable|filter(x => x > 3) %} This list contains {{n}}. {% endfor %} -EOHTML +EOHTML, ]); $twig = new Environment($loader, ['debug' => true, 'cache' => false]); @@ -190,7 +207,7 @@ public function testTwigArrayMapThrowsRuntimeExceptions() {% for n in variable|map(x => x * 3) %} {{- n -}} {% endfor %} -EOHTML +EOHTML, ]); $twig = new Environment($loader, ['debug' => true, 'cache' => false]); @@ -215,7 +232,7 @@ public function testTwigArrayReduceThrowsRuntimeExceptions() 'reduce-null.html' => << carry + x) }} -EOHTML +EOHTML, ]); $twig = new Environment($loader, ['debug' => true, 'cache' => false]); @@ -234,7 +251,134 @@ public function testTwigArrayReduceThrowsRuntimeExceptions() } } - public function getErroredTemplates() + public function testTwigExceptionUpdateFileAndLineTogether() + { + $twig = new Environment(new ArrayLoader([ + 'index' => "\n\n\n\n{{ foo() }}", + ]), ['debug' => true, 'cache' => false]); + + try { + $twig->load('index')->render([]); + } catch (SyntaxError $e) { + $this->assertSame('Unknown "foo" function in "index" at line 5.', $e->getMessage()); + $this->assertSame(5, $e->getTemplateLine()); + // as we are using an ArrayLoader, we don't have a file, so the line should not be the template line, + // but the line of the error in the Parser.php file + $this->assertStringContainsString('Parser.php', $e->getFile()); + $this->assertNotSame(5, $e->getLine()); + } + } + + /** + * @dataProvider getErrorWithoutLineAndContextData + */ + public function testErrorWithoutLineAndContext(LoaderInterface $loader, bool $debug, bool $addDebugInfo, bool $exceptionWithLineAndContext, int $errorLine) + { + $twig = new Environment($loader, ['debug' => $debug, 'cache' => false]); + $twig->removeCache('no_line_and_context_exception.twig'); + $twig->removeCache('no_line_and_context_exception_include_line_5.twig'); + $twig->removeCache('no_line_and_context_exception_include_line_1.twig'); + $twig->addTokenParser(new class($addDebugInfo, $exceptionWithLineAndContext) extends AbstractTokenParser { + public function __construct(private bool $addDebugInfo, private bool $exceptionWithLineAndContext) + { + } + + public function parse(Token $token) + { + $stream = $this->parser->getStream(); + $lineno = $stream->getCurrent()->getLine(); + $stream->expect(Token::BLOCK_END_TYPE); + + return new #[YieldReady] class($lineno, $this->addDebugInfo, $this->exceptionWithLineAndContext) extends Node { + public function __construct(int $lineno, private bool $addDebugInfo, private bool $exceptionWithLineAndContext) + { + parent::__construct([], [], $lineno); + } + + public function compile(Compiler $compiler): void + { + if ($this->addDebugInfo) { + $compiler->addDebugInfo($this); + } + if ($this->exceptionWithLineAndContext) { + $compiler + ->write('throw new \Twig\Error\RuntimeError("Runtime error.", ') + ->repr($this->lineno)->raw(', $this->getSourceContext()') + ->raw(");\n") + ; + } else { + $compiler->write('throw new \Twig\Error\RuntimeError("Runtime error.");'); + } + } + }; + } + + public function getTag() + { + return 'foo'; + } + }); + + try { + $twig->render('no_line_and_context_exception.twig', ['line' => $errorLine]); + $this->fail(); + } catch (RuntimeError $e) { + if (1 === $errorLine && !$addDebugInfo && !$exceptionWithLineAndContext) { + // When the template only has the custom node that throws the error, we cannot find the line of the error + // as we have no debug info and no line and context in the exception + $this->assertSame(\sprintf('Runtime error in "no_line_and_context_exception_include_line_%d.twig".', $errorLine), $e->getMessage()); + $this->assertSame(0, $e->getTemplateLine()); + } else { + // When the template has some space before the custom node, the associated TextNode outputs some debug info at line 1 + // that's why the line is 1 when we have no debug info and no line and context in the exception + $line = $addDebugInfo || $exceptionWithLineAndContext ? $errorLine : 1; + $this->assertSame(\sprintf('Runtime error in "no_line_and_context_exception_include_line_%d.twig" at line %d.', $errorLine, $line), $e->getMessage()); + $this->assertSame($line, $e->getTemplateLine()); + } + + $line = $addDebugInfo || $exceptionWithLineAndContext ? $errorLine : 1; + if ($loader instanceof FilesystemLoader) { + $this->assertStringContainsString(\sprintf('errors/no_line_and_context_exception_include_line_%d.twig', $errorLine), $e->getFile()); + $line = $addDebugInfo || $exceptionWithLineAndContext ? $errorLine : (1 === $errorLine ? -1 : 1); + $this->assertSame($line, $e->getLine()); + } else { + $this->assertStringContainsString('Environment.php', $e->getFile()); + $this->assertNotSame($line, $e->getLine()); + } + } + } + + public static function getErrorWithoutLineAndContextData(): iterable + { + $fileLoaders = [ + new ArrayLoader([ + 'no_line_and_context_exception.twig' => "\n\n{{ include('no_line_and_context_exception_include_line_' ~ line ~ '.twig') }}", + 'no_line_and_context_exception_include_line_5.twig' => "\n\n\n\n{% foo %}", + 'no_line_and_context_exception_include_line_1.twig' => '{% foo %}', + ]), + new FilesystemLoader(__DIR__.'/Fixtures/errors'), + ]; + + foreach ($fileLoaders as $loader) { + foreach ([false, true] as $exceptionWithLineAndContext) { + foreach ([false, true] as $addDebugInfo) { + foreach ([false, true] as $debug) { + foreach ([5, 1] as $line) { + $name = ($loader instanceof FilesystemLoader ? 'filesystem' : 'array') + .($debug ? '_with_debug' : '_without_debug') + .($addDebugInfo ? '_with_debug_info' : '_without_debug_info') + .($exceptionWithLineAndContext ? '_with_context' : '_without_context') + .('_line_'.$line) + ; + yield $name => [$loader, $debug, $addDebugInfo, $exceptionWithLineAndContext, $line]; + } + } + } + } + } + } + + public static function getErroredTemplates() { return [ // error occurs in a template @@ -280,21 +424,70 @@ public function getErroredTemplates() ], 'index', 3, ], + + // error occurs in an embed tag + [ + [ + 'index' => " + {% embed 'base' %} + {% endembed %}", + 'base' => '{% block foo %}{{ foo.bar }}{% endblock %}', + ], + 'base', 1, + ], + + // error occurs in an overridden block from an embed tag + [ + [ + 'index' => " + {% embed 'base' %} + {% block foo %} + {{ foo.bar }} + {% endblock %} + {% endembed %}", + 'base' => '{% block foo %}{% endblock %}', + ], + 'index', 4, + ], ]; } - public function testTwigLeakOutputInDebugMode() + public function testErrorFromArrayLoader() { - $output = exec(sprintf('%s %s debug', \PHP_BINARY, escapeshellarg(__DIR__.'/Fixtures/errors/leak-output.php'))); + $templates = [ + 'index.twig' => '{% include "include.twig" %}', + 'include.twig' => $include = <<assertSame('Hello OOPS', $output); + {% extends 'invalid.twig' %} + EOF, + ]; + $twig = new Environment(new ArrayLoader($templates), ['debug' => true, 'cache' => false]); + try { + $twig->render('index.twig'); + $this->fail('Expected LoaderError to be thrown'); + } catch (LoaderError $e) { + $this->assertSame('Template "invalid.twig" is not defined.', $e->getRawMessage()); + $this->assertSame(4, $e->getTemplateLine()); + $this->assertSame('include.twig', $e->getSourceContext()->getName()); + $this->assertSame($include, $e->getSourceContext()->getCode()); + } } - public function testDoesNotTwigLeakOutput() + public function testErrorFromFilesystemLoader() { - $output = exec(sprintf('%s %s', \PHP_BINARY, escapeshellarg(__DIR__.'/Fixtures/errors/leak-output.php'))); - - $this->assertSame('', $output); + $twig = new Environment(new FilesystemLoader([$dir = __DIR__.'/Fixtures/errors/extends']), ['debug' => true, 'cache' => false]); + $include = file_get_contents($dir.'/include.twig'); + try { + $twig->render('index.twig'); + $this->fail('Expected LoaderError to be thrown'); + } catch (LoaderError $e) { + $this->assertStringContainsString('Unable to find template "invalid.twig"', $e->getRawMessage()); + $this->assertSame(4, $e->getTemplateLine()); + $this->assertSame('include.twig', $e->getSourceContext()->getName()); + $this->assertSame($include, $e->getSourceContext()->getCode()); + } } } diff --git a/tests/ExpressionParserTest.php b/tests/ExpressionParserTest.php index 1b6f385dac9..4f16858208d 100644 --- a/tests/ExpressionParserTest.php +++ b/tests/ExpressionParserTest.php @@ -1,5 +1,14 @@ expectException(SyntaxError::class); - - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); + + $this->expectException(SyntaxError::class); $parser->parse($env->tokenize(new Source($template, 'index'))); } - public function getFailingTestsForAssignment() + public static function getFailingTestsForAssignment() { return [ ['{% set false = "foo" %}'], @@ -55,31 +80,31 @@ public function getFailingTestsForAssignment() } /** - * @dataProvider getTestsForArray + * @dataProvider getTestsForSequence */ - public function testArrayExpression($template, $expected) + public function testSequenceExpression($template, $expected) { - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $stream = $env->tokenize($source = new Source($template, '')); $parser = new Parser($env); $expected->setSourceContext($source); - $this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode(0)->getNode('expr')); + $this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode('0')->getNode('expr')); } /** - * @dataProvider getFailingTestsForArray + * @dataProvider getFailingTestsForSequence */ - public function testArraySyntaxError($template) + public function testSequenceSyntaxError($template) { - $this->expectException(SyntaxError::class); - - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); + + $this->expectException(SyntaxError::class); $parser->parse($env->tokenize(new Source($template, 'index'))); } - public function getFailingTestsForArray() + public static function getFailingTestsForSequence() { return [ ['{{ [1, "a": "b"] }}'], @@ -88,96 +113,121 @@ public function getFailingTestsForArray() ]; } - public function getTestsForArray() + public static function getTestsForSequence() { return [ - // simple array + // simple sequence ['{{ [1, 2] }}', new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + new ConstantExpression(0, 1), + new ConstantExpression(1, 1), - new ConstantExpression(1, 1), - new ConstantExpression(2, 1), - ], 1), + new ConstantExpression(1, 1), + new ConstantExpression(2, 1), + ], 1), ], - // array with trailing , + // sequence with trailing , ['{{ [1, 2, ] }}', new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + new ConstantExpression(0, 1), + new ConstantExpression(1, 1), - new ConstantExpression(1, 1), - new ConstantExpression(2, 1), - ], 1), + new ConstantExpression(1, 1), + new ConstantExpression(2, 1), + ], 1), ], - // simple hash + // simple mapping ['{{ {"a": "b", "b": "c"} }}', new ArrayExpression([ - new ConstantExpression('a', 1), - new ConstantExpression('b', 1), + new ConstantExpression('a', 1), + new ConstantExpression('b', 1), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), - ], 1), + new ConstantExpression('b', 1), + new ConstantExpression('c', 1), + ], 1), ], - // hash with trailing , + // mapping with trailing , ['{{ {"a": "b", "b": "c", } }}', new ArrayExpression([ - new ConstantExpression('a', 1), - new ConstantExpression('b', 1), + new ConstantExpression('a', 1), + new ConstantExpression('b', 1), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), - ], 1), + new ConstantExpression('b', 1), + new ConstantExpression('c', 1), + ], 1), ], - // hash in an array + // mapping in a sequence ['{{ [1, {"a": "b", "b": "c"}] }}', new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), + new ConstantExpression(0, 1), + new ConstantExpression(1, 1), - new ConstantExpression(1, 1), - new ArrayExpression([ - new ConstantExpression('a', 1), - new ConstantExpression('b', 1), + new ConstantExpression(1, 1), + new ArrayExpression([ + new ConstantExpression('a', 1), + new ConstantExpression('b', 1), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), - ], 1), + new ConstantExpression('b', 1), + new ConstantExpression('c', 1), ], 1), + ], 1), ], - // array in a hash + // sequence in a mapping ['{{ {"a": [1, 2], "b": "c"} }}', new ArrayExpression([ - new ConstantExpression('a', 1), - new ArrayExpression([ - new ConstantExpression(0, 1), - new ConstantExpression(1, 1), - - new ConstantExpression(1, 1), - new ConstantExpression(2, 1), - ], 1), - new ConstantExpression('b', 1), - new ConstantExpression('c', 1), + new ConstantExpression('a', 1), + new ArrayExpression([ + new ConstantExpression(0, 1), + new ConstantExpression(1, 1), + + new ConstantExpression(1, 1), + new ConstantExpression(2, 1), ], 1), + new ConstantExpression('b', 1), + new ConstantExpression('c', 1), + ], 1), ], ['{{ {a, b} }}', new ArrayExpression([ new ConstantExpression('a', 1), - new NameExpression('a', 1), + new ContextVariable('a', 1), new ConstantExpression('b', 1), - new NameExpression('b', 1), + new ContextVariable('b', 1), ], 1)], + + // sequence with spread operator + ['{{ [1, 2, ...foo] }}', + new ArrayExpression([ + new ConstantExpression(0, 1), + new ConstantExpression(1, 1), + + new ConstantExpression(1, 1), + new ConstantExpression(2, 1), + + new ConstantExpression(2, 1), + new SpreadUnary(new ContextVariable('foo', 1), 1), + ], 1)], + + // mapping with spread operator + ['{{ {"a": "b", "b": "c", ...otherLetters} }}', + new ArrayExpression([ + new ConstantExpression('a', 1), + new ConstantExpression('b', 1), + + new ConstantExpression('b', 1), + new ConstantExpression('c', 1), + + new ConstantExpression(0, 1), + new SpreadUnary(new ContextVariable('otherLetters', 1), 1), + ], 1)], ]; } public function testStringExpressionDoesNotConcatenateTwoConsecutiveStrings() { - $this->expectException(SyntaxError::class); - - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]); $stream = $env->tokenize(new Source('{{ "a" "b" }}', 'index')); $parser = new Parser($env); + $this->expectException(SyntaxError::class); $parser->parse($stream); } @@ -186,24 +236,21 @@ public function testStringExpressionDoesNotConcatenateTwoConsecutiveStrings() */ public function testStringExpression($template, $expected) { - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false, 'optimizations' => 0]); $stream = $env->tokenize($source = new Source($template, '')); $parser = new Parser($env); $expected->setSourceContext($source); - $this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode(0)->getNode('expr')); + $this->assertEquals($expected, $parser->parse($stream)->getNode('body')->getNode('0')->getNode('expr')); } - public function getTestsForString() + public static function getTestsForString() { return [ - [ - '{{ "foo" }}', new ConstantExpression('foo', 1), - ], [ '{{ "foo #{bar}" }}', new ConcatBinary( new ConstantExpression('foo ', 1), - new NameExpression('bar', 1), + new ContextVariable('bar', 1), 1 ), ], @@ -211,7 +258,7 @@ public function getTestsForString() '{{ "foo #{bar} baz" }}', new ConcatBinary( new ConcatBinary( new ConstantExpression('foo ', 1), - new NameExpression('bar', 1), + new ContextVariable('bar', 1), 1 ), new ConstantExpression(' baz', 1), @@ -226,7 +273,7 @@ public function getTestsForString() new ConcatBinary( new ConcatBinary( new ConstantExpression('foo ', 1), - new NameExpression('bar', 1), + new ContextVariable('bar', 1), 1 ), new ConstantExpression(' baz', 1), @@ -241,34 +288,14 @@ public function getTestsForString() ]; } - public function testAttributeCallDoesNotSupportNamedArguments() + public function testMacroDefinitionDoesNotSupportNonNameVariableName() { - $this->expectException(SyntaxError::class); - - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{{ foo.bar(name="Foo") }}', 'index'))); - } - - public function testMacroCallDoesNotSupportNamedArguments() - { - $this->expectException(SyntaxError::class); - - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - - $parser->parse($env->tokenize(new Source('{% from _self import foo %}{% macro foo() %}{% endmacro %}{{ foo(name="Foo") }}', 'index'))); - } - - public function testMacroDefinitionDoesNotSupportNonNameVariableName() - { $this->expectException(SyntaxError::class); $this->expectExceptionMessage('An argument must be a name. Unexpected token "string" of value "a" ("name" expected) in "index" at line 1.'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{% macro foo("a") %}{% endmacro %}', 'index'))); } @@ -277,16 +304,16 @@ public function testMacroDefinitionDoesNotSupportNonNameVariableName() */ public function testMacroDefinitionDoesNotSupportNonConstantDefaultValues($template) { - $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('A default value for an argument must be a constant (a boolean, a string, a number, or an array) in "index" at line 1'); - - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('A default value for an argument must be a constant (a boolean, a string, a number, a sequence, or a mapping) in "index" at line 1'); + $parser->parse($env->tokenize(new Source($template, 'index'))); } - public function getMacroDefinitionDoesNotSupportNonConstantDefaultValues() + public static function getMacroDefinitionDoesNotSupportNonConstantDefaultValues() { return [ ['{% macro foo(name = "a #{foo} a") %}{% endmacro %}'], @@ -299,7 +326,7 @@ public function getMacroDefinitionDoesNotSupportNonConstantDefaultValues() */ public function testMacroDefinitionSupportsConstantDefaultValues($template) { - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); $parser->parse($env->tokenize(new Source($template, 'index'))); @@ -309,7 +336,7 @@ public function testMacroDefinitionSupportsConstantDefaultValues($template) $this->addToAssertionCount(1); } - public function getMacroDefinitionSupportsConstantDefaultValues() + public static function getMacroDefinitionSupportsConstantDefaultValues() { return [ ['{% macro foo(name = "aa") %}{% endmacro %}'], @@ -324,67 +351,396 @@ public function getMacroDefinitionSupportsConstantDefaultValues() public function testUnknownFunction() { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $parser = new Parser($env); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "cycl" function. Did you mean "cycle" in "index" at line 1?'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{{ cycl() }}', 'index'))); } public function testUnknownFunctionWithoutSuggestions() { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $parser = new Parser($env); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "foobar" function in "index" at line 1.'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{{ foobar() }}', 'index'))); } public function testUnknownFilter() { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $parser = new Parser($env); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "lowe" filter. Did you mean "lower" in "index" at line 1?'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{{ 1|lowe }}', 'index'))); } public function testUnknownFilterWithoutSuggestions() { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $parser = new Parser($env); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "foobar" filter in "index" at line 1.'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{{ 1|foobar }}', 'index'))); } public function testUnknownTest() { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $parser = new Parser($env); + $stream = $env->tokenize(new Source('{{ 1 is nul }}', 'index')); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "nul" test. Did you mean "null" in "index" at line 1'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); - $parser = new Parser($env); - $stream = $env->tokenize(new Source('{{ 1 is nul }}', 'index')); $parser->parse($stream); } public function testUnknownTestWithoutSuggestions() { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $parser = new Parser($env); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "foobar" test in "index" at line 1.'); - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $parser->parse($env->tokenize(new Source('{{ 1 is foobar }}', 'index'))); + } + + public function testCompiledCodeForDynamicTest() + { + $env = new Environment(new ArrayLoader(['index' => '{{ "a" is foo_foo_bar_bar }}']), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class extends AbstractExtension { + public function getTests() + { + return [ + new TwigTest('*_foo_*_bar', function ($foo, $bar, $a) {}), + ]; + } + }); + + $this->assertStringContainsString('$this->env->getTest(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index'))))); + } + + public function testCompiledCodeForDynamicFunction() + { + $env = new Environment(new ArrayLoader(['index' => '{{ foo_foo_bar_bar("a") }}']), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class extends AbstractExtension { + public function getFunctions() + { + return [ + new TwigFunction('*_foo_*_bar', function ($foo, $bar, $a) {}), + ]; + } + }); + + $this->assertStringContainsString('$this->env->getFunction(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index'))))); + } + + public function testCompiledCodeForDynamicFilter() + { + $env = new Environment(new ArrayLoader(['index' => '{{ "a"|foo_foo_bar_bar }}']), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class extends AbstractExtension { + public function getFilters() + { + return [ + new TwigFilter('*_foo_*_bar', function ($foo, $bar, $a) {}), + ]; + } + }); + + $this->assertStringContainsString('$this->env->getFilter(\'*_foo_*_bar\')->getCallable()("foo", "bar", "a")', $env->compile($env->parse($env->tokenize(new Source($env->getLoader()->getSourceContext('index')->getCode(), 'index'))))); + } + + public function testNotReadyFunctionWithNoConstructor() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addFunction(new TwigFunction('foo', 'foo', ['node_class' => NotReadyFunctionExpressionWithNoConstructor::class])); $parser = new Parser($env); - $parser->parse($env->tokenize(new Source('{{ 1 is foobar }}', 'index'))); + $parser->parse($env->tokenize(new Source('{{ foo() }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + public function testNotReadyFilterWithNoConstructor() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addFilter(new TwigFilter('foo', 'foo', ['node_class' => NotReadyFilterExpressionWithNoConstructor::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1|foo }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + public function testNotReadyTestWithNoConstructor() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addTest(new TwigTest('foo', 'foo', ['node_class' => NotReadyTestExpressionWithNoConstructor::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1 is foo }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + /** + * @group legacy + */ + 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.'); + $this->expectDeprecation('Since twig/twig 3.12: Not passing an instance of "TwigFunction" when creating a "foo" function of type "Twig\Tests\NotReadyFunctionExpression" is deprecated.'); + + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addFunction(new TwigFunction('foo', 'foo', ['node_class' => NotReadyFunctionExpression::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ foo() }}', 'index'))); + } + + /** + * @group legacy + */ + 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.'); + $this->expectDeprecation('Since twig/twig 3.12: Not passing an instance of "TwigFilter" when creating a "foo" filter of type "Twig\Tests\NotReadyFilterExpression" is deprecated.'); + + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addFilter(new TwigFilter('foo', 'foo', ['node_class' => NotReadyFilterExpression::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1|foo }}', 'index'))); + } + + /** + * @group legacy + */ + 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.'); + $this->expectDeprecation('Since twig/twig 3.12: Not passing an instance of "TwigTest" when creating a "foo" test of type "Twig\Tests\NotReadyTestExpression" is deprecated.'); + + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addTest(new TwigTest('foo', 'foo', ['node_class' => NotReadyTestExpression::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1 is foo }}', 'index'))); + } + + public function testReadyFunction() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addFunction(new TwigFunction('foo', 'foo', ['node_class' => ReadyFunctionExpression::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ foo() }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + public function testReadyFilter() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addFilter(new TwigFilter('foo', 'foo', ['node_class' => ReadyFilterExpression::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1|foo }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + public function testReadyTest() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addTest(new TwigTest('foo', 'foo', ['node_class' => ReadyTestExpression::class])); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1 is foo }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + public function testTwoWordTestPrecedence() + { + // a "empty element" test must have precedence over "empty" + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addTest(new TwigTest('empty element', 'foo')); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ 1 is empty element }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + public function testUnaryPrecedenceChange() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->addExtension(new class extends AbstractExtension { + public function getExpressionParsers(): array + { + $class = new class(new ConstantExpression('foo', 1), 1) extends AbstractUnary { + public function operator(Compiler $compiler): Compiler + { + return $compiler->raw('!'); + } + }; + + return [ + new UnaryOperatorExpressionParser($class::class, '!', 50), + ]; + } + }); + $parser = new Parser($env); + + $parser->parse($env->tokenize(new Source('{{ !false ? "OK" : "KO" }}', 'index'))); + $this->expectNotToPerformAssertions(); + } + + /** + * @dataProvider getBindingPowerTests + */ + public function testBindingPower(string $expression, string $expectedExpression, mixed $expectedResult, array $context = []) + { + $env = new Environment(new ArrayLoader([ + 'expression' => $expression, + 'expected' => $expectedExpression, + ])); + + $this->assertSame($env->render('expected', $context), $env->render('expression', $context)); + $this->assertEquals($expectedResult, $env->render('expression', $context)); + } + + public static function getBindingPowerTests(): iterable + { + // * / // % stronger than + - + foreach (['*', '/', '//', '%'] as $op1) { + foreach (['+', '-'] as $op2) { + $e = "12 $op1 6 $op2 3"; + if ('//' === $op1) { + $php = eval("return (int) floor(12 / 6) $op2 3;"); + } else { + $php = eval("return $e;"); + } + yield "$op1 vs $op2" => ["{{ $e }}", "{{ (12 $op1 6) $op2 3 }}", $php]; + + $e = "12 $op2 6 $op1 3"; + if ('//' === $op1) { + $php = eval("return 12 $op2 (int) floor(6 / 3);"); + } else { + $php = eval("return $e;"); + } + yield "$op2 vs $op1" => ["{{ $e }}", "{{ 12 $op2 (6 $op1 3) }}", $php]; + } + } + + // + - * / // % stronger than == != <=> < > >= <= `not in` `in` `matches` `starts with` `ends with` `has some` `has every` + foreach (['+', '-', '*', '/', '//', '%'] as $op1) { + foreach (['==', '!=', '<=>', '<', '>', '>=', '<='] as $op2) { + $e = "12 $op1 6 $op2 3"; + if ('//' === $op1) { + $php = eval("return (int) floor(12 / 6) $op2 3;"); + } else { + $php = eval("return $e;"); + } + yield "$op1 vs $op2" => ["{{ $e }}", "{{ (12 $op1 6) $op2 3 }}", $php]; + } + } + yield '+ vs not in' => ['{{ 1 + 2 not in [3, 4] }}', '{{ (1 + 2) not in [3, 4] }}', eval('return !in_array(1 + 2, [3, 4]);')]; + yield '+ vs in' => ['{{ 1 + 2 in [3, 4] }}', '{{ (1 + 2) in [3, 4] }}', eval('return in_array(1 + 2, [3, 4]);')]; + yield '+ vs matches' => ['{{ 1 + 2 matches "/^3$/" }}', '{{ (1 + 2) matches "/^3$/" }}', eval("return preg_match('/^3$/', 1 + 2);")]; + + // ~ stronger than `starts with` `ends with` + yield '~ vs starts with' => ['{{ "a" ~ "b" starts with "a" }}', '{{ ("a" ~ "b") starts with "a" }}', eval("return str_starts_with('ab', 'a');")]; + yield '~ vs ends with' => ['{{ "a" ~ "b" ends with "b" }}', '{{ ("a" ~ "b") ends with "b" }}', eval("return str_ends_with('ab', 'b');")]; + + // [] . stronger than anything else + $context = ['a' => ['b' => 1, 'c' => ['d' => 2]]]; + yield '[] vs unary -' => ['{{ -a["b"] + 3 }}', '{{ -(a["b"]) + 3 }}', eval("\$a = ['b' => 1]; return -\$a['b'] + 3;"), $context]; + yield '[] vs unary - (multiple levels)' => ['{{ -a["c"]["d"] }}', '{{ -((a["c"])["d"]) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + yield '. vs unary -' => ['{{ -a.b }}', '{{ -(a.b) }}', eval("\$a = ['b' => 1]; return -\$a['b'];"), $context]; + yield '. vs unary - (multiple levels)' => ['{{ -a.c.d }}', '{{ -((a.c).d) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + yield '. [] vs unary -' => ['{{ -a.c["d"] }}', '{{ -((a.c)["d"]) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + yield '[] . vs unary -' => ['{{ -a["c"].d }}', '{{ -((a["c"]).d) }}', eval("\$a = ['c' => ['d' => 2]]; return -\$a['c']['d'];"), $context]; + + // () stronger than anything else + yield '() vs unary -' => ['{{ -random(1, 1) + 3 }}', '{{ -(random(1, 1)) + 3 }}', eval('return -rand(1, 1) + 3;')]; + + // + - stronger than | + yield '+ vs |' => ['{{ 10 + 2|length }}', '{{ 10 + (2|length) }}', eval('return 10 + strlen(2);'), $context]; + + // - unary stronger than | + // To be uncomment in Twig 4.0 + // yield '- vs |' => ['{{ -1|abs }}', '{{ (-1)|abs }}', eval("return abs(-1);"), $context]; + + // ?? stronger than () + // yield '?? vs ()' => ['{{ (1 ?? "a") }}', '{{ ((1 ?? "a")) }}', eval("return 1;")]; + } +} + +class NotReadyFunctionExpression extends FunctionExpression +{ + public function __construct(string $function, Node $arguments, int $lineno) + { + parent::__construct($function, $arguments, $lineno); + } +} + +class NotReadyFilterExpression extends FilterExpression +{ + public function __construct(Node $node, ConstantExpression $filter, Node $arguments, int $lineno) + { + parent::__construct($node, $filter, $arguments, $lineno); + } +} + +class NotReadyTestExpression extends TestExpression +{ + public function __construct(Node $node, string $test, ?Node $arguments, int $lineno) + { + parent::__construct($node, $test, $arguments, $lineno); + } +} + +class NotReadyFunctionExpressionWithNoConstructor extends FunctionExpression +{ +} + +class NotReadyFilterExpressionWithNoConstructor extends FilterExpression +{ +} + +class NotReadyTestExpressionWithNoConstructor extends TestExpression +{ +} + +class ReadyFunctionExpression extends FunctionExpression +{ + #[FirstClassTwigCallableReady] + public function __construct(TwigFunction|string $function, Node $arguments, int $lineno) + { + parent::__construct($function, $arguments, $lineno); + } +} + +class ReadyFilterExpression extends FilterExpression +{ + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigFilter|ConstantExpression $filter, Node $arguments, int $lineno) + { + parent::__construct($node, $filter, $arguments, $lineno); + } +} + +class ReadyTestExpression extends TestExpression +{ + #[FirstClassTwigCallableReady] + public function __construct(Node $node, TwigTest|string $test, ?Node $arguments, int $lineno) + { + parent::__construct($node, $test, $arguments, $lineno); } } diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php new file mode 100644 index 00000000000..273ac2c24b2 --- /dev/null +++ b/tests/Extension/AttributeExtensionTest.php @@ -0,0 +1,169 @@ +getFilters() as $filter) { + if ($filter->getName() === $name) { + $this->assertEquals(new TwigFilter($name, [ExtensionWithAttributes::class, $method], $options), $filter); + + return; + } + } + + $this->fail(\sprintf('Filter "%s" is not registered.', $name)); + } + + public static function provideFilters() + { + yield 'with name' => ['foo', 'fooFilter', ['is_safe' => ['html']]]; + yield 'with env' => ['with_env_filter', 'withEnvFilter', ['needs_environment' => true]]; + yield 'with context' => ['with_context_filter', 'withContextFilter', ['needs_context' => true]]; + yield 'with env and context' => ['with_env_and_context_filter', 'withEnvAndContextFilter', ['needs_environment' => true, 'needs_context' => true]]; + yield 'variadic' => ['variadic_filter', 'variadicFilter', ['is_variadic' => true]]; + yield 'deprecated' => ['deprecated_filter', 'deprecatedFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + yield 'pattern' => ['pattern_*_filter', 'patternFilter', []]; + } + + /** + * @dataProvider provideFunctions + */ + public function testFunction(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); + foreach ($extension->getFunctions() as $function) { + if ($function->getName() === $name) { + $this->assertEquals(new TwigFunction($name, [ExtensionWithAttributes::class, $method], $options), $function); + + return; + } + } + + $this->fail(\sprintf('Function "%s" is not registered.', $name)); + } + + public static function provideFunctions() + { + yield 'with name' => ['foo', 'fooFunction', ['is_safe' => ['html']]]; + yield 'with env' => ['with_env_function', 'withEnvFunction', ['needs_environment' => true]]; + yield 'with context' => ['with_context_function', 'withContextFunction', ['needs_context' => true]]; + yield 'with env and context' => ['with_env_and_context_function', 'withEnvAndContextFunction', ['needs_environment' => true, 'needs_context' => true]]; + yield 'no argument' => ['no_arg_function', 'noArgFunction', []]; + yield 'variadic' => ['variadic_function', 'variadicFunction', ['is_variadic' => true]]; + yield 'deprecated' => ['deprecated_function', 'deprecatedFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + } + + /** + * @dataProvider provideTests + */ + public function testTest(string $name, string $method, array $options) + { + $extension = new AttributeExtension(ExtensionWithAttributes::class); + foreach ($extension->getTests() as $test) { + if ($test->getName() === $name) { + $this->assertEquals(new TwigTest($name, [ExtensionWithAttributes::class, $method], $options), $test); + + return; + } + } + + $this->fail(\sprintf('Test "%s" is not registered.', $name)); + } + + public static function provideTests() + { + yield 'with name' => ['foo', 'fooTest', []]; + yield 'with env' => ['with_env_test', 'withEnvTest', ['needs_environment' => true]]; + yield 'with context' => ['with_context_test', 'withContextTest', ['needs_context' => true]]; + yield 'with env and context' => ['with_env_and_context_test', 'withEnvAndContextTest', ['needs_environment' => true, 'needs_context' => true]]; + yield 'variadic' => ['variadic_test', 'variadicTest', ['is_variadic' => true]]; + yield 'deprecated' => ['deprecated_test', 'deprecatedTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]]; + } + + public function testFilterRequireOneArgument() + { + $extension = new AttributeExtension(FilterWithoutValue::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('"'.FilterWithoutValue::class.'::myFilter()" needs at least 1 arguments to be used AsTwigFilter, but only 0 defined.'); + + $extension->getTests(); + } + + public function testTestRequireOneArgument() + { + $extension = new AttributeExtension(TestWithoutValue::class); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('"'.TestWithoutValue::class.'::myTest()" needs at least 1 arguments to be used AsTwigTest, but only 0 defined.'); + + $extension->getTests(); + } + + public function testLastModifiedWithObject() + { + $extension = new AttributeExtension(\stdClass::class); + + $this->assertSame(filemtime((new \ReflectionClass(AttributeExtension::class))->getFileName()), $extension->getLastModified()); + } + + public function testLastModifiedWithClass() + { + $extension = new AttributeExtension('__CLASS_FOR_TEST_LAST_MODIFIED__'); + + $filename = tempnam(sys_get_temp_dir(), 'twig'); + try { + file_put_contents($filename, 'assertSame(filemtime($filename), $extension->getLastModified()); + } finally { + unlink($filename); + } + } + + public function testMultipleRegistrations() + { + $extensionSet = new ExtensionSet(); + $extensionSet->addExtension($extension1 = new AttributeExtension(ExtensionWithAttributes::class)); + $extensionSet->addExtension($extension2 = new AttributeExtension(\stdClass::class)); + + $this->assertCount(2, $extensionSet->getExtensions()); + $this->assertNotNull($extensionSet->getFilter('foo')); + + $this->assertSame($extension1, $extensionSet->getExtension(ExtensionWithAttributes::class)); + $this->assertSame($extension2, $extensionSet->getExtension(\stdClass::class)); + + $this->expectException(RuntimeError::class); + $this->expectExceptionMessage('The "Twig\Extension\AttributeExtension" extension is not enabled.'); + $extensionSet->getExtension(AttributeExtension::class); + } +} diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php index 29a799b8103..10d7e881daa 100644 --- a/tests/Extension/CoreTest.php +++ b/tests/Extension/CoreTest.php @@ -1,5 +1,14 @@ assertSame($expected, CoreExtension::cycle($values, $position)); + } + + public static function provideCycleCases() + { + return [ + [[1, 2, 3], 0, 1], + [[1, 2, 3], 1, 2], + [[1, 2, 3], 2, 3], + [[1, 2, 3], 3, 1], + [[false, 0, null], 0, false], + [[false, 0, null], 1, 0], + [[false, 0, null], 2, null], + + [[['a', 'b'], ['c', 'd']], 3, ['c', 'd']], + ]; + } + + /** + * @dataProvider provideCycleInvalidCases + */ + public function testCycleFunctionThrowRuntimeError($values, mixed $position = null) + { + $this->expectException(RuntimeError::class); + CoreExtension::cycle($values, $position ?? 0); + } + + public static function provideCycleInvalidCases() + { + return [ + 'empty' => [[]], + 'non-countable' => [new class extends \ArrayObject { + }], + ]; + } + /** * @dataProvider getRandomFunctionTestData */ public function testRandomFunction(array $expectedInArray, $value1, $value2 = null) { - $env = new Environment($this->createMock(LoaderInterface::class)); - for ($i = 0; $i < 100; ++$i) { - $this->assertTrue(\in_array(twig_random($env, $value1, $value2), $expectedInArray, true)); // assertContains() would not consider the type + $this->assertTrue(\in_array(CoreExtension::random('UTF-8', $value1, $value2), $expectedInArray, true)); // assertContains() would not consider the type } } - public function getRandomFunctionTestData() + public static function getRandomFunctionTestData() { return [ 'array' => [ @@ -84,45 +136,38 @@ public function testRandomFunctionWithoutParameter() $max = mt_getrandmax(); for ($i = 0; $i < 100; ++$i) { - $val = twig_random(new Environment($this->createMock(LoaderInterface::class))); + $val = CoreExtension::random('UTF-8'); $this->assertTrue(\is_int($val) && $val >= 0 && $val <= $max); } } public function testRandomFunctionReturnsAsIs() { - $this->assertSame('', twig_random(new Environment($this->createMock(LoaderInterface::class)), '')); - $this->assertSame('', twig_random(new Environment($this->createMock(LoaderInterface::class), ['charset' => null]), '')); + $this->assertSame('', CoreExtension::random('UTF-8', '')); $instance = new \stdClass(); - $this->assertSame($instance, twig_random(new Environment($this->createMock(LoaderInterface::class)), $instance)); + $this->assertSame($instance, CoreExtension::random('UTF-8', $instance)); } public function testRandomFunctionOfEmptyArrayThrowsException() { $this->expectException(RuntimeError::class); - twig_random(new Environment($this->createMock(LoaderInterface::class)), []); + CoreExtension::random('UTF-8', []); } public function testRandomFunctionOnNonUTF8String() { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $twig->setCharset('ISO-8859-1'); - $text = iconv('UTF-8', 'ISO-8859-1', 'Äé'); for ($i = 0; $i < 30; ++$i) { - $rand = twig_random($twig, $text); + $rand = CoreExtension::random('ISO-8859-1', $text); $this->assertTrue(\in_array(iconv('ISO-8859-1', 'UTF-8', $rand), ['Ä', 'é'], true)); } } public function testReverseFilterOnNonUTF8String() { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $twig->setCharset('ISO-8859-1'); - $input = iconv('UTF-8', 'ISO-8859-1', 'Äé'); - $output = iconv('ISO-8859-1', 'UTF-8', twig_reverse_filter($twig, $input)); + $output = iconv('ISO-8859-1', 'UTF-8', CoreExtension::reverse('ISO-8859-1', $input)); $this->assertEquals($output, 'éÄ'); } @@ -132,11 +177,10 @@ public function testReverseFilterOnNonUTF8String() */ public function testTwigFirst($expected, $input) { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertSame($expected, twig_first($twig, $input)); + $this->assertSame($expected, CoreExtension::first('UTF-8', $input)); } - public function provideTwigFirstCases() + public static function provideTwigFirstCases() { $i = [1 => 'a', 2 => 'b', 3 => 'c']; @@ -154,11 +198,10 @@ public function provideTwigFirstCases() */ public function testTwigLast($expected, $input) { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertSame($expected, twig_last($twig, $input)); + $this->assertSame($expected, CoreExtension::last('UTF-8', $input)); } - public function provideTwigLastCases() + public static function provideTwigLastCases() { $i = [1 => 'a', 2 => 'b', 3 => 'c']; @@ -176,10 +219,10 @@ public function provideTwigLastCases() */ public function testArrayKeysFilter(array $expected, $input) { - $this->assertSame($expected, twig_get_array_keys_filter($input)); + $this->assertSame($expected, CoreExtension::keys($input)); } - public function provideArrayKeyCases() + public static function provideArrayKeyCases() { $array = ['a' => 'a1', 'b' => 'b1', 'c' => 'c1']; $keys = array_keys($array); @@ -199,10 +242,10 @@ public function provideArrayKeyCases() */ public function testInFilter($expected, $value, $compare) { - $this->assertSame($expected, twig_in_filter($value, $compare)); + $this->assertSame($expected, CoreExtension::inFilter($value, $compare)); } - public function provideInFilterCases() + public static function provideInFilterCases() { $array = [1, 2, 'a' => 3, 5, 6, 7]; $keys = array_keys($array); @@ -227,11 +270,10 @@ public function provideInFilterCases() */ public function testSliceFilter($expected, $input, $start, $length = null, $preserveKeys = false) { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertSame($expected, twig_slice($twig, $input, $start, $length, $preserveKeys)); + $this->assertSame($expected, CoreExtension::slice('UTF-8', $input, $start, $length, $preserveKeys)); } - public function provideSliceFilterCases() + public static function provideSliceFilterCases() { $i = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; $keys = array_keys($i); @@ -257,19 +299,19 @@ public function provideSliceFilterCases() */ public function testCompare($expected, $a, $b) { - $this->assertSame($expected, twig_compare($a, $b)); - $this->assertSame($expected, -twig_compare($b, $a)); + $this->assertSame($expected, CoreExtension::compare($a, $b)); + $this->assertSame($expected, -CoreExtension::compare($b, $a)); } public function testCompareNAN() { - $this->assertSame(1, twig_compare(\NAN, 'NAN')); - $this->assertSame(1, twig_compare('NAN', \NAN)); - $this->assertSame(1, twig_compare(\NAN, 'foo')); - $this->assertSame(1, twig_compare('foo', \NAN)); + $this->assertSame(1, CoreExtension::compare(\NAN, 'NAN')); + $this->assertSame(1, CoreExtension::compare('NAN', \NAN)); + $this->assertSame(1, CoreExtension::compare(\NAN, 'foo')); + $this->assertSame(1, CoreExtension::compare('foo', \NAN)); } - public function provideCompareCases() + public static function provideCompareCases() { return [ [0, 'a', 'a'], @@ -326,6 +368,45 @@ public function provideCompareCases() [1, 42, "\x00\x34\x32"], ]; } + + public function testSandboxedInclude() + { + $twig = new Environment(new ArrayLoader([ + 'index' => '{{ include("included", sandboxed: true) }}', + 'included' => '{{ "included"|e }}', + ])); + $policy = new SecurityPolicy(allowedFunctions: ['include']); + $sandbox = new SandboxExtension($policy, false); + $twig->addExtension($sandbox); + + // We expect a compile error + $this->expectException(SecurityError::class); + $twig->render('index'); + } + + public function testSandboxedIncludeWithPreloadedTemplate() + { + $twig = new Environment(new ArrayLoader([ + 'index' => '{{ include("included", sandboxed: true) }}', + 'included' => '{{ "included"|e }}', + ])); + $policy = new SecurityPolicy(allowedFunctions: ['include']); + $sandbox = new SandboxExtension($policy, false); + $twig->addExtension($sandbox); + + // The template is loaded without the sandbox enabled + // so, no compile error + $twig->load('included'); + + // We expect a runtime error + $this->expectException(SecurityError::class); + $twig->render('index'); + } + + public function testLastModified() + { + $this->assertGreaterThan(1000000000, (new CoreExtension())->getLastModified()); + } } final class CoreTestIteratorAggregate implements \IteratorAggregate @@ -380,9 +461,6 @@ public function rewind(): void $this->position = 0; } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function current() { @@ -393,9 +471,6 @@ public function current() throw new \LogicException('Code should only use the keys, not the values provided by iterator.'); } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function key() { @@ -406,7 +481,7 @@ public function next(): void { ++$this->position; if ($this->position === $this->maxPosition) { - throw new \LogicException(sprintf('Code should not iterate beyond %d.', $this->maxPosition)); + throw new \LogicException(\sprintf('Code should not iterate beyond %d.', $this->maxPosition)); } } diff --git a/tests/Extension/EscaperTest.php b/tests/Extension/EscaperTest.php index 9804feaa5c7..52f06950c9f 100644 --- a/tests/Extension/EscaperTest.php +++ b/tests/Extension/EscaperTest.php @@ -1,5 +1,14 @@ ''', - '"' => '"', - '<' => '<', - '>' => '>', - '&' => '&', - ]; - - protected $htmlAttrSpecialChars = [ - '\'' => ''', - /* Characters beyond ASCII value 255 to unicode escape */ - 'Ā' => 'Ā', - '😀' => '😀', - /* Immune chars excluded */ - ',' => ',', - '.' => '.', - '-' => '-', - '_' => '_', - /* Basic alnums excluded */ - 'a' => 'a', - 'A' => 'A', - 'z' => 'z', - 'Z' => 'Z', - '0' => '0', - '9' => '9', - /* Basic control characters and null */ - "\r" => ' ', - "\n" => ' ', - "\t" => ' ', - "\0" => '�', // should use Unicode replacement char - /* Encode chars as named entities where possible */ - '<' => '<', - '>' => '>', - '&' => '&', - '"' => '"', - /* Encode spaces for quoteless attribute protection */ - ' ' => ' ', - ]; - - protected $jsSpecialChars = [ - /* HTML special chars - escape without exception to hex */ - '<' => '\\u003C', - '>' => '\\u003E', - '\'' => '\\u0027', - '"' => '\\u0022', - '&' => '\\u0026', - '/' => '\\/', - /* Characters beyond ASCII value 255 to unicode escape */ - 'Ā' => '\\u0100', - '😀' => '\\uD83D\\uDE00', - /* Immune chars excluded */ - ',' => ',', - '.' => '.', - '_' => '_', - /* Basic alnums excluded */ - 'a' => 'a', - 'A' => 'A', - 'z' => 'z', - 'Z' => 'Z', - '0' => '0', - '9' => '9', - /* Basic control characters and null */ - "\r" => '\r', - "\n" => '\n', - "\x08" => '\b', - "\t" => '\t', - "\x0C" => '\f', - "\0" => '\\u0000', - /* Encode spaces for quoteless attribute protection */ - ' ' => '\\u0020', - ]; - - protected $urlSpecialChars = [ - /* HTML special chars - escape without exception to percent encoding */ - '<' => '%3C', - '>' => '%3E', - '\'' => '%27', - '"' => '%22', - '&' => '%26', - /* Characters beyond ASCII value 255 to hex sequence */ - 'Ā' => '%C4%80', - /* Punctuation and unreserved check */ - ',' => '%2C', - '.' => '.', - '_' => '_', - '-' => '-', - ':' => '%3A', - ';' => '%3B', - '!' => '%21', - /* Basic alnums excluded */ - 'a' => 'a', - 'A' => 'A', - 'z' => 'z', - 'Z' => 'Z', - '0' => '0', - '9' => '9', - /* Basic control characters and null */ - "\r" => '%0D', - "\n" => '%0A', - "\t" => '%09', - "\0" => '%00', - /* PHP quirks from the past */ - ' ' => '%20', - '~' => '~', - '+' => '%2B', - ]; - - protected $cssSpecialChars = [ - /* HTML special chars - escape without exception to hex */ - '<' => '\\3C ', - '>' => '\\3E ', - '\'' => '\\27 ', - '"' => '\\22 ', - '&' => '\\26 ', - /* Characters beyond ASCII value 255 to unicode escape */ - 'Ā' => '\\100 ', - /* Immune chars excluded */ - ',' => '\\2C ', - '.' => '\\2E ', - '_' => '\\5F ', - /* Basic alnums excluded */ - 'a' => 'a', - 'A' => 'A', - 'z' => 'z', - 'Z' => 'Z', - '0' => '0', - '9' => '9', - /* Basic control characters and null */ - "\r" => '\\D ', - "\n" => '\\A ', - "\t" => '\\9 ', - "\0" => '\\0 ', - /* Encode spaces for quoteless attribute protection */ - ' ' => '\\20 ', - ]; - - public function testHtmlEscapingConvertsSpecialChars() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - foreach ($this->htmlSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'html'), 'Failed to escape: '.$key); - } - } - - public function testHtmlAttributeEscapingConvertsSpecialChars() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - foreach ($this->htmlAttrSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'html_attr'), 'Failed to escape: '.$key); - } - } - - public function testJavascriptEscapingConvertsSpecialChars() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - foreach ($this->jsSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'js'), 'Failed to escape: '.$key); - } - } - - public function testJavascriptEscapingConvertsSpecialCharsWithInternalEncoding() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $previousInternalEncoding = mb_internal_encoding(); - try { - mb_internal_encoding('ISO-8859-1'); - foreach ($this->jsSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'js'), 'Failed to escape: ' . $key); - } - } finally { - if ($previousInternalEncoding !== false) { - mb_internal_encoding($previousInternalEncoding); - } - } - } - - public function testJavascriptEscapingReturnsStringIfZeroLength() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertEquals('', twig_escape_filter($twig, '', 'js')); - } - - public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertEquals('123', twig_escape_filter($twig, '123', 'js')); - } - - public function testCssEscapingConvertsSpecialChars() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - foreach ($this->cssSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'css'), 'Failed to escape: '.$key); - } - } - - public function testCssEscapingReturnsStringIfZeroLength() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertEquals('', twig_escape_filter($twig, '', 'css')); - } - - public function testCssEscapingReturnsStringIfContainsOnlyDigits() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $this->assertEquals('123', twig_escape_filter($twig, '123', 'css')); - } - - public function testUrlEscapingConvertsSpecialChars() + public function testCustomEscaper($expected, $string, $strategy) { - $twig = new Environment($this->createMock(LoaderInterface::class)); - foreach ($this->urlSpecialChars as $key => $value) { - $this->assertEquals($value, twig_escape_filter($twig, $key, 'url'), 'Failed to escape: '.$key); - } + $twig = new Environment(new ArrayLoader()); + $escaperExt = $twig->getExtension(EscaperExtension::class); + $escaperExt->setEscaper('foo', 'Twig\Tests\legacy_escaper'); + $this->assertSame($expected, $twig->getRuntime(EscaperRuntime::class)->escape($string, $strategy, 'ISO-8859-1')); } - /** - * Range tests to confirm escaped range of characters is within OWASP recommendation. - */ - - /** - * Only testing the first few 2 ranges on this prot. function as that's all these - * other range tests require. - */ - public function testUnicodeCodepointConversionToUtf8() + public static function provideCustomEscaperCases() { - $expected = ' ~ޙ'; - $codepoints = [0x20, 0x7e, 0x799]; - $result = ''; - foreach ($codepoints as $value) { - $result .= $this->codepointToUtf8($value); - } - $this->assertEquals($expected, $result); + return [ + ['foo**ISO-8859-1**UTF-8', 'foo', 'foo'], + ['**ISO-8859-1**UTF-8', null, 'foo'], + ['42**ISO-8859-1**UTF-8', 42, 'foo'], + ]; } /** - * Convert a Unicode Codepoint to a literal UTF-8 character. - * - * @param int $codepoint Unicode codepoint in hex notation + * @dataProvider provideCustomEscaperCases * - * @return string UTF-8 literal string + * @group legacy */ - protected function codepointToUtf8($codepoint) - { - if ($codepoint < 0x80) { - return \chr($codepoint); - } - if ($codepoint < 0x800) { - return \chr($codepoint >> 6 & 0x3f | 0xc0) - .\chr($codepoint & 0x3f | 0x80); - } - if ($codepoint < 0x10000) { - return \chr($codepoint >> 12 & 0x0f | 0xe0) - .\chr($codepoint >> 6 & 0x3f | 0x80) - .\chr($codepoint & 0x3f | 0x80); - } - if ($codepoint < 0x110000) { - return \chr($codepoint >> 18 & 0x07 | 0xf0) - .\chr($codepoint >> 12 & 0x3f | 0x80) - .\chr($codepoint >> 6 & 0x3f | 0x80) - .\chr($codepoint & 0x3f | 0x80); - } - throw new \Exception('Codepoint requested outside of Unicode range.'); - } - - public function testJavascriptEscapingEscapesOwaspRecommendedRanges() + public function testCustomEscaperWithoutCallingSetEscaperRuntime($expected, $string, $strategy) { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $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, twig_escape_filter($twig, $literal, 'js')); - } else { - $literal = $this->codepointToUtf8($chr); - if (\in_array($literal, $immune)) { - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'js')); - } else { - $this->assertNotEquals( - $literal, - twig_escape_filter($twig, $literal, 'js'), - "$literal should be escaped!"); - } - } - } - } - - public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $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, twig_escape_filter($twig, $literal, 'html_attr')); - } else { - $literal = $this->codepointToUtf8($chr); - if (\in_array($literal, $immune)) { - $this->assertEquals($literal, twig_escape_filter($twig, $literal, 'html_attr')); - } else { - $this->assertNotEquals( - $literal, - twig_escape_filter($twig, $literal, 'html_attr'), - "$literal should be escaped!"); - } - } - } - } - - public function testCssEscapingEscapesOwaspRecommendedRanges() - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - // CSS has no 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, twig_escape_filter($twig, $literal, 'css')); - } else { - $literal = $this->codepointToUtf8($chr); - $this->assertNotEquals( - $literal, - twig_escape_filter($twig, $literal, 'css'), - "$literal should be escaped!"); - } - } - } - - public function testUnknownCustomEscaper() - { - $this->expectException(RuntimeError::class); - - twig_escape_filter(new Environment($this->createMock(LoaderInterface::class)), 'foo', 'bar'); + $twig = new Environment(new ArrayLoader()); + $escaperExt = $twig->getExtension(EscaperExtension::class); + $escaperExt->setEscaper('foo', 'Twig\Tests\legacy_escaper'); + $this->assertSame($expected, $twig->getRuntime(EscaperRuntime::class)->escape($string, $strategy, 'ISO-8859-1')); } /** - * @dataProvider provideCustomEscaperCases + * @group legacy */ - public function testCustomEscaper($expected, $string, $strategy) - { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $twig->getExtension(EscaperExtension::class)->setEscaper('foo', 'Twig\Tests\foo_escaper_for_test'); - - $this->assertSame($expected, twig_escape_filter($twig, $string, $strategy)); - } - - public function provideCustomEscaperCases() - { - return [ - ['fooUTF-8', 'foo', 'foo'], - ['UTF-8', null, 'foo'], - ['42UTF-8', 42, 'foo'], - ]; - } - public function testCustomEscapersOnMultipleEnvs() { - $env1 = new Environment($this->createMock(LoaderInterface::class)); - $env1->getExtension(EscaperExtension::class)->setEscaper('foo', 'Twig\Tests\foo_escaper_for_test'); - $env2 = new Environment($this->createMock(LoaderInterface::class)); - $env2->getExtension(EscaperExtension::class)->setEscaper('foo', 'Twig\Tests\foo_escaper_for_test1'); + $env1 = new Environment(new ArrayLoader()); + $escaperExt1 = $env1->getExtension(EscaperExtension::class); + $escaperExt1->setEscaper('foo', 'Twig\Tests\legacy_escaper'); - $this->assertSame('fooUTF-8', twig_escape_filter($env1, 'foo', 'foo')); - $this->assertSame('fooUTF-81', twig_escape_filter($env2, 'foo', 'foo')); - } + $env2 = new Environment(new ArrayLoader()); + $escaperExt2 = $env2->getExtension(EscaperExtension::class); + $escaperExt2->setEscaper('foo', 'Twig\Tests\legacy_escaper_again'); - /** - * @dataProvider provideObjectsForEscaping - */ - public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $safeClasses) - { - $obj = new Extension_TestClass(); - $twig = new Environment($this->createMock(LoaderInterface::class)); - $twig->getExtension('\Twig\Extension\EscaperExtension')->setSafeClasses($safeClasses); - $this->assertSame($escapedHtml, twig_escape_filter($twig, $obj, 'html', null, true)); - $this->assertSame($escapedJs, twig_escape_filter($twig, $obj, 'js', null, true)); + $this->assertSame('foo**ISO-8859-1**UTF-8', $env1->getRuntime(EscaperRuntime::class)->escape('foo', 'foo', 'ISO-8859-1')); + $this->assertSame('foo**ISO-8859-1**UTF-8**again', $env2->getRuntime(EscaperRuntime::class)->escape('foo', 'foo', 'ISO-8859-1')); } - public function provideObjectsForEscaping() + public function testLastModified() { - return [ - ['<br />', '
    ', ['\Twig\Tests\Extension_TestClass' => ['js']]], - ['
    ', '\u003Cbr\u0020\/\u003E', ['\Twig\Tests\Extension_TestClass' => ['html']]], - ['<br />', '
    ', ['\Twig\Tests\Extension_SafeHtmlInterface' => ['js']]], - ['
    ', '
    ', ['\Twig\Tests\Extension_SafeHtmlInterface' => ['all']]], - ]; + $this->assertGreaterThan(1000000000, (new EscaperExtension())->getLastModified()); } } -function foo_escaper_for_test(Environment $twig, $string, $charset) +function legacy_escaper(Environment $twig, $string, $charset) { - return $string.$charset; + return $string.'**'.$charset.'**'.$twig->getCharset(); } -function foo_escaper_for_test1(Environment $twig, $string, $charset) +function legacy_escaper_again(Environment $twig, $string, $charset) { - return $string.$charset.'1'; -} - -interface Extension_SafeHtmlInterface -{ -} -class Extension_TestClass implements Extension_SafeHtmlInterface -{ - public function __toString() - { - return '
    '; - } + return $string.'**'.$charset.'**'.$twig->getCharset().'**again'; } diff --git a/tests/Extension/Fixtures/ExtensionWithAttributes.php b/tests/Extension/Fixtures/ExtensionWithAttributes.php new file mode 100644 index 00000000000..ed4dff31fd6 --- /dev/null +++ b/tests/Extension/Fixtures/ExtensionWithAttributes.php @@ -0,0 +1,121 @@ +assertSame(DebugExtension::dump($env, 'Foo'), twig_var_dump($env, 'Foo')); + } +} diff --git a/tests/Extension/LegacyStringLoaderFunctionsTest.php b/tests/Extension/LegacyStringLoaderFunctionsTest.php new file mode 100644 index 00000000000..a6cb31df0c4 --- /dev/null +++ b/tests/Extension/LegacyStringLoaderFunctionsTest.php @@ -0,0 +1,30 @@ +assertSame(StringLoaderExtension::templateFromString($env, 'Foo')->render(), twig_template_from_string($env, 'Foo')->render()); + } +} diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index e365da63280..6d8e5035a73 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1,5 +1,14 @@ 'Fabien', 'obj' => new FooObject(), 'arr' => ['obj' => new FooObject()], + 'child_obj' => new ChildClass(), + 'some_array' => [5, 6, 7, new FooObject()], + 'array_like' => new ArrayLikeObject(), + 'magic' => new MagicObject(), + 'recursion' => [4], ]; + self::$params['recursion'][] = &self::$params['recursion']; + self::$params['recursion'][] = new FooObject(); self::$templates = [ '1_basic1' => '{{ obj.foo }}', @@ -54,17 +75,78 @@ protected function setUp(): void '1_basic2_include_template_from_string_sandboxed' => '{{ include(template_from_string("{{ name|upper }}"), sandboxed=true) }}', '1_basic2_include_template_from_string' => '{{ include(template_from_string("{{ name|upper }}")) }}', '1_range_operator' => '{{ (1..2)[0] }}', - '1_syntax_error_wrapper' => '{% sandbox %}{% include "1_syntax_error" %}{% endsandbox %}', + '1_syntax_error_wrapper_legacy' => '{% sandbox %}{% include "1_syntax_error" %}{% endsandbox %}', + '1_syntax_error_wrapper' => '{{ include("1_syntax_error", sandboxed: true) }}', '1_syntax_error' => '{% syntax error }}', + '1_childobj_parentmethod' => '{{ child_obj.ParentMethod() }}', + '1_childobj_childmethod' => '{{ child_obj.ChildMethod() }}', + '1_empty' => '', + '1_array_like' => '{{ array_like["foo"] }}', ]; } + /** + * @dataProvider getSandboxedForCoreTagsTests + */ + public function testSandboxForCoreTags(string $tag, string $template) + { + $twig = $this->getEnvironment(true, [], self::$templates, []); + + $this->expectException(SecurityError::class); + $this->expectExceptionMessageMatches(\sprintf('/Tag "%s" is not allowed in "index \(string template .+?\)" at line 1/', $tag)); + + $twig->createTemplate($template, 'index')->render([]); + } + + public static function getSandboxedForCoreTagsTests() + { + yield ['apply', '{% apply upper %}foo{% endapply %}']; + yield ['autoescape', '{% autoescape %}foo{% endautoescape %}']; + yield ['block', '{% block foo %}foo{% endblock %}']; + yield ['deprecated', '{% deprecated "message" %}']; + yield ['do', '{% do 1 + 2 %}']; + yield ['embed', '{% embed "base.twig" %}{% endembed %}']; + // To be uncommented in 4.0 + // yield ['extends', '{% extends "base.twig" %}']; + yield ['flush', '{% flush %}']; + yield ['for', '{% for i in 1..2 %}{% endfor %}']; + yield ['from', '{% from "macros" import foo %}']; + yield ['if', '{% if false %}{% endif %}']; + yield ['import', '{% import "macros" as macros %}']; + yield ['include', '{% include "macros" %}']; + yield ['macro', '{% macro foo() %}{% endmacro %}']; + yield ['set', '{% set foo = 1 %}']; + // To be uncommented in 4.0 + // yield ['use', '{% use "1_empty" %}']; + yield ['with', '{% with foo %}{% endwith %}']; + } + + /** + * @dataProvider getSandboxedForExtendsAndUseTagsTests + * + * @group legacy + */ + 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)); + + $twig = $this->getEnvironment(true, [], self::$templates, []); + $twig->createTemplate($template, 'index')->render([]); + } + + public static function getSandboxedForExtendsAndUseTagsTests() + { + yield ['extends', '{% extends "1_empty" %}']; + yield ['use', '{% use "1_empty" %}']; + } + public function testSandboxWithInheritance() { + $twig = $this->getEnvironment(true, [], self::$templates, ['extends', 'block']); + $this->expectException(SecurityError::class); $this->expectExceptionMessage('Filter "json_encode" is not allowed in "1_child" at line 3.'); - $twig = $this->getEnvironment(true, [], self::$templates, ['block']); $twig->load('1_child')->render([]); } @@ -74,16 +156,46 @@ public function testSandboxGloballySet() $this->assertEquals('FOO', $twig->load('1_basic')->render(self::$params), 'Sandbox does nothing if it is disabled globally'); } - public function testSandboxUnallowedMethodAccessor() + public function testSandboxUnallowedPropertyAccessor() { $twig = $this->getEnvironment(true, [], self::$templates); try { - $twig->load('1_basic1')->render(self::$params); + $twig->load('1_basic1')->render(['obj' => new MagicObject()]); $this->fail('Sandbox throws a SecurityError exception if an unallowed method is called'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedMethodError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedMethodError'); - $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\FooObject" class'); - $this->assertEquals('foo', $e->getMethodName(), 'Exception should be raised on the "foo" method'); + } catch (SecurityNotAllowedPropertyError $e) { + $this->assertEquals('Twig\Tests\Extension\MagicObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\MagicObject" class'); + $this->assertEquals('foo', $e->getPropertyName(), 'Exception should be raised on the "foo" property'); + } + } + + public function testSandboxUnallowedArrayIndexAccessor() + { + $twig = $this->getEnvironment(true, [], self::$templates); + + // ArrayObject and other internal array-like classes are exempted from sandbox restrictions + $this->assertSame('bar', $twig->load('1_array_like')->render(['array_like' => new \ArrayObject(['foo' => 'bar'])])); + + try { + $twig->load('1_array_like')->render(self::$params); + $this->fail('Sandbox throws a SecurityError exception if an unallowed method is called'); + } catch (SecurityNotAllowedPropertyError $e) { + $this->assertEquals('Twig\Tests\Extension\ArrayLikeObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\ArrayLikeObject" class'); + $this->assertEquals('foo', $e->getPropertyName(), 'Exception should be raised on the "foo" property'); + } + } + + /** + * @group legacy + */ + public function testIfSandBoxIsDisabledAfterSyntaxErrorLegacy() + { + $twig = $this->getEnvironment(false, [], self::$templates); + try { + $twig->load('1_syntax_error_wrapper_legacy')->render(self::$params); + } catch (SyntaxError $e) { + /** @var SandboxExtension $sandbox */ + $sandbox = $twig->getExtension(SandboxExtension::class); + $this->assertFalse($sandbox->isSandboxed()); } } @@ -106,8 +218,7 @@ public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromSt try { $twig->load('1_basic2_include_template_from_string_sandboxed')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called'); - } catch (SecurityError $e) { - $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedFilterError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } @@ -119,8 +230,7 @@ public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStr try { $twig->load('1_basic2_include_template_from_string_sandboxed')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called'); - } catch (SecurityError $e) { - $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedFilterError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } @@ -139,8 +249,7 @@ public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStr try { $twig->load('1_basic2_include_template_from_string')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called'); - } catch (SecurityError $e) { - $this->assertInstanceOf('\Twig\Sandbox\SecurityNotAllowedFilterError', $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } @@ -151,8 +260,7 @@ public function testSandboxUnallowedFilter() try { $twig->load('1_basic2')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedFilterError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFilterError'); + } catch (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } @@ -163,8 +271,7 @@ public function testSandboxUnallowedTag() try { $twig->load('1_basic3')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed tag is used in the template'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedTagError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedTagError'); + } catch (SecurityNotAllowedTagError $e) { $this->assertEquals('if', $e->getTagName(), 'Exception should be raised on the "if" tag'); } } @@ -175,8 +282,7 @@ public function testSandboxUnallowedProperty() try { $twig->load('1_basic4')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed property is called in the template'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedPropertyError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedPropertyError'); + } catch (SecurityNotAllowedPropertyError $e) { $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\FooObject" class'); $this->assertEquals('bar', $e->getPropertyName(), 'Exception should be raised on the "bar" property'); } @@ -187,18 +293,17 @@ public function testSandboxUnallowedProperty() */ public function testSandboxUnallowedToString($template) { - $twig = $this->getEnvironment(true, [], ['index' => $template], [], ['upper'], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject'], [], ['random']); + $twig = $this->getEnvironment(true, [], ['index' => $template], [], ['upper', 'join', 'replace'], ['Twig\Tests\Extension\FooObject' => 'getAnotherFooObject'], [], ['random']); try { $twig->load('index')->render(self::$params); - $this->fail('Sandbox throws a SecurityError exception if an unallowed method (__toString()) is called in the template'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedMethodError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedMethodError'); + $this->fail('Sandbox throws a SecurityError exception if an unallowed method "__toString()" method is called in the template'); + } catch (SecurityNotAllowedMethodError $e) { $this->assertEquals('Twig\Tests\Extension\FooObject', $e->getClassName(), 'Exception should be raised on the "Twig\Tests\Extension\FooObject" class'); $this->assertEquals('__tostring', $e->getMethodName(), 'Exception should be raised on the "__toString" method'); } } - public function getSandboxUnallowedToStringTests() + public static function getSandboxUnallowedToStringTests() { return [ 'simple' => ['{{ obj }}'], @@ -214,6 +319,17 @@ public function getSandboxUnallowedToStringTests() 'object_chain_and_function' => ['{{ random(obj.anotherFooObject) }}'], 'concat' => ['{{ obj ~ "" }}'], 'concat_again' => ['{{ "" ~ obj }}'], + 'object_in_arguments' => ['{{ "__toString"|replace({"__toString": obj}) }}'], + 'object_in_array' => ['{{ [12, "foo", obj]|join(", ") }}'], + 'object_in_array_var' => ['{{ some_array|join(", ") }}'], + 'object_in_array_nested' => ['{{ [12, "foo", [12, "foo", obj]]|join(", ") }}'], + 'object_in_array_var_nested' => ['{{ [12, "foo", some_array]|join(", ") }}'], + 'object_in_array_dynamic_key' => ['{{ {(obj): "foo"}|join(", ") }}'], + 'object_in_array_dynamic_key_nested' => ['{{ {"foo": { (obj): "foo" }}|join(", ") }}'], + 'context' => ['{{ _context|join(", ") }}'], + 'spread_array_operator' => ['{{ [1, 2, ...[5, 6, 7, obj]]|join(",") }}'], + 'spread_array_operator_var' => ['{{ [1, 2, ...some_array]|join(",") }}'], + 'recursion' => ['{{ recursion|join(", ") }}'], ]; } @@ -226,12 +342,13 @@ public function testSandboxAllowedToString($template, $output) $this->assertEquals($output, $twig->load('index')->render(self::$params)); } - public function getSandboxAllowedToStringTests() + public static function getSandboxAllowedToStringTests() { return [ 'constant_test' => ['{{ obj is constant("PHP_INT_MAX") }}', ''], 'set_object' => ['{% set a = obj.anotherFooObject %}{{ a.foo }}', 'foo'], - 'is_defined' => ['{{ obj.anotherFooObject is defined }}', '1'], + 'is_defined1' => ['{{ obj.anotherFooObject is defined }}', '1'], + 'is_defined2' => ['{{ magic.foo is defined }}', ''], 'is_null' => ['{{ obj is null }}', ''], 'is_sameas' => ['{{ obj is same as(obj) }}', '1'], 'is_sameas_no_brackets' => ['{{ obj is same as obj }}', '1'], @@ -264,8 +381,7 @@ public function testSandboxUnallowedFunction() try { $twig->load('1_basic7')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed function is called in the template'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedFunctionError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFunctionError'); + } catch (SecurityNotAllowedFunctionError $e) { $this->assertEquals('cycle', $e->getFunctionName(), 'Exception should be raised on the "cycle" function'); } } @@ -276,8 +392,7 @@ public function testSandboxUnallowedRangeOperator() try { $twig->load('1_range_operator')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if the unallowed range operator is called'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedFunctionError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedFunctionError'); + } catch (SecurityNotAllowedFunctionError $e) { $this->assertEquals('range', $e->getFunctionName(), 'Exception should be raised on the "range" function'); } } @@ -320,7 +435,7 @@ public function testSandboxAllowRangeOperator() $this->assertEquals('1', $twig->load('1_range_operator')->render(self::$params), 'Sandbox allow the range operator'); } - public function testSandboxAllowFunctionsCaseInsensitive() + public function testSandboxAllowMethodsCaseInsensitive() { foreach (['getfoobar', 'getFoobar', 'getFooBar'] as $name) { $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['Twig\Tests\Extension\FooObject' => $name]); @@ -343,17 +458,16 @@ public function testSandboxLocallySetForAnInclude() $this->assertEquals('fooFOOfoo', $twig->load('2_basic')->render(self::$params), 'Sandbox does nothing if disabled globally and sandboxed not used for the include'); self::$templates = [ - '3_basic' => '{{ obj.foo }}{% sandbox %}{% include "3_included" %}{% endsandbox %}{{ obj.foo }}', - '3_included' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}', + '3_basic' => '{{ include("3_included", sandboxed: true) }}', + '3_included' => '{% if true %}{{ "foo"|upper }}{% endif %}', ]; - $twig = $this->getEnvironment(true, [], self::$templates); + $twig = $this->getEnvironment(true, [], self::$templates, functions: ['include']); try { $twig->load('3_basic')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception when the included file is sandboxed'); - } catch (SecurityError $e) { - $this->assertInstanceOf(SecurityNotAllowedTagError::class, $e, 'Exception should be an instance of Twig_Sandbox_SecurityNotAllowedTagError'); - $this->assertEquals('sandbox', $e->getTagName()); + } catch (SecurityNotAllowedTagError $e) { + $this->assertEquals('if', $e->getTagName()); } } @@ -389,14 +503,14 @@ public function testSandboxDisabledAfterIncludeFunctionError() public function testSandboxWithNoClosureFilter() { - $this->expectException('\Twig\Error\RuntimeError'); - $this->expectExceptionMessage('The callable passed to the "filter" filter must be a Closure in sandbox mode in "index" at line 1.'); - $twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<expectException(RuntimeError::class); + $this->expectExceptionMessage('The callable passed to the "filter" filter must be a Closure in sandbox mode in "index" at line 1.'); + $twig->load('index')->render([]); } @@ -410,15 +524,101 @@ public function testSandboxWithClosureFilter() $this->assertSame('foo, bar', $twig->load('index')->render([])); } - protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = []) + public function testMultipleClassMatchesViaInheritanceInAllowedMethods() + { + $twig_child_first = $this->getEnvironment(true, [], self::$templates, [], [], [ + 'Twig\Tests\Extension\ChildClass' => ['ChildMethod'], + 'Twig\Tests\Extension\ParentClass' => ['ParentMethod'], + ]); + $twig_parent_first = $this->getEnvironment(true, [], self::$templates, [], [], [ + 'Twig\Tests\Extension\ParentClass' => ['ParentMethod'], + 'Twig\Tests\Extension\ChildClass' => ['ChildMethod'], + ]); + + try { + $twig_child_first->load('1_childobj_childmethod')->render(self::$params); + } catch (SecurityError $e) { + $this->fail('This test case is malfunctioning as even the child class method which comes first is not being allowed.'); + } + + try { + $twig_parent_first->load('1_childobj_parentmethod')->render(self::$params); + } catch (SecurityError $e) { + $this->fail('This test case is malfunctioning as even the parent class method which comes first is not being allowed.'); + } + + try { + $twig_parent_first->load('1_childobj_childmethod')->render(self::$params); + } catch (SecurityError $e) { + $this->fail('checkMethodAllowed is exiting prematurely after matching a parent class and not seeing a method allowed on a child class later in the list'); + } + + try { + $twig_child_first->load('1_childobj_parentmethod')->render(self::$params); + } catch (SecurityError $e) { + $this->fail('checkMethodAllowed is exiting prematurely after matching a child class and not seeing a method allowed on its parent class later in the list'); + } + + $this->expectNotToPerformAssertions(); + } + + protected function getEnvironment($sandboxed, $options, $templates, $tags = [], $filters = [], $methods = [], $properties = [], $functions = [], $sourcePolicy = null) { $loader = new ArrayLoader($templates); $twig = new Environment($loader, array_merge(['debug' => true, 'cache' => false, 'autoescape' => false], $options)); $policy = new SecurityPolicy($tags, $filters, $methods, $properties, $functions); - $twig->addExtension(new SandboxExtension($policy, $sandboxed)); + $twig->addExtension(new SandboxExtension($policy, $sandboxed, $sourcePolicy)); return $twig; } + + public function testSandboxSourcePolicyEnableReturningFalse() + { + $twig = $this->getEnvironment(false, [], self::$templates, [], [], [], [], [], new class implements \Twig\Sandbox\SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return '1_basic' != $source->getName(); + } + }); + $this->assertEquals('FOO', $twig->load('1_basic')->render(self::$params)); + } + + public function testSandboxSourcePolicyEnableReturningTrue() + { + $twig = $this->getEnvironment(false, [], self::$templates, [], [], [], [], [], new class implements \Twig\Sandbox\SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return '1_basic' === $source->getName(); + } + }); + $this->expectException(SecurityError::class); + $twig->load('1_basic')->render([]); + } + + public function testSandboxSourcePolicyFalseDoesntOverrideOtherEnables() + { + $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], [], new class implements \Twig\Sandbox\SourcePolicyInterface { + public function enableSandbox(Source $source): bool + { + return false; + } + }); + $this->expectException(SecurityError::class); + $twig->load('1_basic')->render([]); + } +} + +class ParentClass +{ + public function ParentMethod() + { + } +} +class ChildClass extends ParentClass +{ + public function ChildMethod() + { + } } class FooObject @@ -458,3 +658,37 @@ public function getAnotherFooObject() return new self(); } } + +class ArrayLikeObject extends \ArrayObject +{ + public function offsetExists($offset): bool + { + throw new \BadMethodCallException('Should not be called.'); + } + + public function offsetGet($offset): mixed + { + throw new \BadMethodCallException('Should not be called.'); + } + + public function offsetSet($offset, $value): void + { + } + + public function offsetUnset($offset): void + { + } +} + +class MagicObject +{ + public function __get($name): mixed + { + throw new \BadMethodCallException('Should not be called.'); + } + + public function __isset($name): bool + { + throw new \BadMethodCallException('Should not be called.'); + } +} diff --git a/tests/Extension/StringLoaderExtensionTest.php b/tests/Extension/StringLoaderExtensionTest.php index 363a0825ecb..d37b8f2634f 100644 --- a/tests/Extension/StringLoaderExtensionTest.php +++ b/tests/Extension/StringLoaderExtensionTest.php @@ -13,14 +13,16 @@ use PHPUnit\Framework\TestCase; use Twig\Environment; +use Twig\Extension\CoreExtension; use Twig\Extension\StringLoaderExtension; +use Twig\Loader\ArrayLoader; class StringLoaderExtensionTest extends TestCase { public function testIncludeWithTemplateStringAndNoSandbox() { - $twig = new Environment($this->createMock('\Twig\Loader\LoaderInterface')); + $twig = new Environment(new ArrayLoader()); $twig->addExtension(new StringLoaderExtension()); - $this->assertSame('something', twig_include($twig, [], twig_template_from_string($twig, 'something'))); + $this->assertSame('something', CoreExtension::include($twig, [], StringLoaderExtension::templateFromString($twig, 'something'))); } } diff --git a/tests/FactoryRuntimeLoaderTest.php b/tests/FactoryRuntimeLoaderTest.php index 35c2d5baf5b..33066886f96 100644 --- a/tests/FactoryRuntimeLoaderTest.php +++ b/tests/FactoryRuntimeLoaderTest.php @@ -1,5 +1,14 @@ assertSame($strategy, FileExtensionEscapingStrategy::guess($filename)); } - public function getGuessData() + public static function getGuessData() { return [ // default diff --git a/tests/FilesystemHelper.php b/tests/FilesystemHelper.php index 4920d54201f..ce7cf715473 100644 --- a/tests/FilesystemHelper.php +++ b/tests/FilesystemHelper.php @@ -1,5 +1,14 @@ 'Hello {{ "world"|broken }}', -]); -$twig = new Environment($loader, ['debug' => isset($argv[1])]); -$twig->addExtension(new BrokenExtension()); - -echo $twig->render('index.html.twig'); diff --git a/tests/Fixtures/errors/no_line_and_context_exception.twig b/tests/Fixtures/errors/no_line_and_context_exception.twig new file mode 100644 index 00000000000..4059c7485a4 --- /dev/null +++ b/tests/Fixtures/errors/no_line_and_context_exception.twig @@ -0,0 +1,3 @@ + + +{{ include('no_line_and_context_exception_include_line_' ~ line ~ '.twig') }} diff --git a/tests/Fixtures/errors/no_line_and_context_exception_include_line_1.twig b/tests/Fixtures/errors/no_line_and_context_exception_include_line_1.twig new file mode 100644 index 00000000000..9a2e6ffa6aa --- /dev/null +++ b/tests/Fixtures/errors/no_line_and_context_exception_include_line_1.twig @@ -0,0 +1 @@ +{% foo %} diff --git a/tests/Fixtures/errors/no_line_and_context_exception_include_line_5.twig b/tests/Fixtures/errors/no_line_and_context_exception_include_line_5.twig new file mode 100644 index 00000000000..bb229015a1e --- /dev/null +++ b/tests/Fixtures/errors/no_line_and_context_exception_include_line_5.twig @@ -0,0 +1,5 @@ + + + + +{% foo %} diff --git a/tests/Fixtures/exceptions/exception_in_extension_extends.test b/tests/Fixtures/exceptions/exception_in_extension_extends.test index 2ab298059d3..3b9ddeec84d 100644 --- a/tests/Fixtures/exceptions/exception_in_extension_extends.test +++ b/tests/Fixtures/exceptions/exception_in_extension_extends.test @@ -9,4 +9,4 @@ Exception thrown from a child for an extension error --DATA-- return [] --EXCEPTION-- -Twig\Error\RuntimeError: The random function cannot pick from an empty array in "base.twig" at line 4. +Twig\Error\RuntimeError: The "random" function cannot pick from an empty sequence or mapping in "base.twig" at line 4. diff --git a/tests/Fixtures/exceptions/exception_in_extension_include.test b/tests/Fixtures/exceptions/exception_in_extension_include.test index e2281b2903b..42927c2f6bb 100644 --- a/tests/Fixtures/exceptions/exception_in_extension_include.test +++ b/tests/Fixtures/exceptions/exception_in_extension_include.test @@ -9,4 +9,4 @@ Exception thrown from an include for an extension error --DATA-- return [] --EXCEPTION-- -Twig\Error\RuntimeError: The random function cannot pick from an empty array in "content.twig" at line 4. +Twig\Error\RuntimeError: The "random" function cannot pick from an empty sequence or mapping in "content.twig" at line 4. diff --git a/tests/Fixtures/expressions/array.test b/tests/Fixtures/expressions/array.test index 35579dc13d2..72efbf9eef9 100644 --- a/tests/Fixtures/expressions/array.test +++ b/tests/Fixtures/expressions/array.test @@ -34,19 +34,49 @@ Twig supports array notation {# keys can be any expression #} {% set a = 1 %} {% set b = "foo" %} -{% set ary = { (a): 'a', (b): 'b', 'c': 'c', (a ~ b): 'd' } %} +{% set markup_instance %}fooe{% endset %} +{% set ary = { (a): 'a', (b): 'b', 'c': 'c', (a ~ b): 'd', (markup_instance): 'e' } %} {{ ary|keys|join(',') }} {{ ary|join(',') }} {# ArrayAccess #} {{ array_access['a'] }} +{# ObjectStorage #} +{{ object_storage[object] }} +{{ object_storage[object_storage]|default('bar') }} + {# array that does not exist #} {{ does_not_exist[0]|default('ok') }} {{ does_not_exist[0].does_not_exist_either|default('ok') }} {{ does_not_exist[0]['does_not_exist_either']|default('ok') }} + +{# indexes are kept #} +{% set trad = {194:'ABC',141:'DEF',100:'GHI',170:'JKL',110:'MNO',111:'PQR'} %} +{% set trad2 = {'194':'ABC','141':'DEF','100':'GHI','170':'JKL','110':'MNO','111':'PQR'} %} +{{ trad == trad2 ? 'OK' : 'KO' }} +{% set trad = {11: 'ABC', 2: 'DEF', 4: 'GHI', 3: 'JKL'} %} +{% set trad2 = {'11': 'ABC', '2': 'DEF', '4': 'GHI', '3': 'JKL'} %} +{{ trad == trad2 ? 'OK' : 'KO' }} + +{# indexes are kept #} +{{ { 1: "first", 0: "second" } == { '1': "first", '0': "second" } ? 'OK' : 'KO' }} +{{ { 1: "first", 0: "second" } == indices_1 ? 'OK' : 'KO' }} +{{ { 1: "first", 'foo': "second", 2: "third" } == { '1': "first", 'foo': "second", '2': "third" } ? 'OK' : 'KO' }} +{{ { 1: "first", 'foo': "second", 2: "third" } == indices_2 ? 'OK' : 'KO' }} --DATA-- -return ['bar' => 'bar', 'foo' => ['bar' => 'bar'], 'array_access' => new \ArrayObject(['a' => 'b'])] +$objectStorage = new SplObjectStorage(); +$object = new stdClass(); +$objectStorage[$object] = 'foo'; +return [ + 'bar' => 'bar', + 'foo' => ['bar' => 'bar'], + 'array_access' => new \ArrayObject(['a' => 'b']), + 'object_storage' => $objectStorage, + 'object' => $object, + 'indices_1' => [ 1 => 'first', 0 => 'second' ], + 'indices_2' => [ 1 => 'first', 'foo' => 'second', 2 => 'third' ], +] --EXPECT-- 1,2 foo,bar @@ -65,16 +95,34 @@ FOO,BAR, 1,2 -1,foo,c,1foo -a,b,c,d +1,foo,c,1foo,fooe +a,b,c,d,e b +foo +bar + ok ok ok + +OK +OK + +OK +OK +OK +OK --DATA-- -return ['bar' => 'bar', 'foo' => ['bar' => 'bar'], 'array_access' => new \ArrayObject(['a' => 'b'])] +return [ + 'bar' => 'bar', + 'foo' => ['bar' => 'bar'], + 'array_access' => new \ArrayObject(['a' => 'b']), + 'object' => new stdClass(), + 'indices_1' => [ 1 => 'first', 0 => 'second' ], + 'indices_2' => [ 1 => 'first', 'foo' => 'second', 2 => 'third' ], +] --CONFIG-- return ['strict_variables' => false] --EXPECT-- @@ -95,11 +143,22 @@ FOO,BAR, 1,2 -1,foo,c,1foo -a,b,c,d +1,foo,c,1foo,fooe +a,b,c,d,e b + +bar + ok ok ok + +OK +OK + +OK +OK +OK +OK diff --git a/tests/Fixtures/expressions/attributes.test b/tests/Fixtures/expressions/attributes.test new file mode 100644 index 00000000000..9be8197a610 --- /dev/null +++ b/tests/Fixtures/expressions/attributes.test @@ -0,0 +1,13 @@ +--TEST-- +"." notation +--TEMPLATE-- +{{ property.foo }} +{{ date.timezone }} +--DATA-- +return [ + 'date' => new \DateTimeImmutable('now', new \DateTimeZone('Europe/Paris')), + 'property' => (object) array('foo' => 'bar'), +] +--EXPECT-- +bar +Europe/Paris diff --git a/tests/Fixtures/expressions/binary.test b/tests/Fixtures/expressions/binary.test index b4e8be58d3c..f7252e95477 100644 --- a/tests/Fixtures/expressions/binary.test +++ b/tests/Fixtures/expressions/binary.test @@ -1,5 +1,5 @@ --TEST-- -Twig supports binary operations (+, -, *, /, ~, %, and, or) +Twig supports binary operations (+, -, *, /, ~, %, and, xor, or) --TEMPLATE-- {{ 1 + 1 }} {{ 2 - 1 }} @@ -16,6 +16,12 @@ Twig supports binary operations (+, -, *, /, ~, %, and, or) {{ 0 or 0 }} {{ 0 or 1 and 0 }} {{ 1 or 0 and 1 }} +{{ 1 xor 1 }} +{{ 1 xor 0 }} +{{ 0 xor 1 }} +{{ 0 xor 0 }} +{{ 0 and 1 or 1 xor 1 }} +{{ 0 and 1 or 0 xor 1 }} {{ "foo" ~ "bar" }} {{ foo ~ "bar" }} {{ "foo" ~ bar }} @@ -38,6 +44,12 @@ return ['foo' => 'bar', 'bar' => 'foo'] 1 +1 + +1 +1 + + 1 foobar barbar diff --git a/tests/Fixtures/expressions/call_argument_unpacking.test b/tests/Fixtures/expressions/call_argument_unpacking.test new file mode 100644 index 00000000000..8e92310bfc9 --- /dev/null +++ b/tests/Fixtures/expressions/call_argument_unpacking.test @@ -0,0 +1,16 @@ +--TEST-- +Twig supports array unpacking for function calls +--TEMPLATE-- +{{ '%s %s %s'|format(...[1, 2, 3]) }} +{{ '%s %s %s'|format(...[1], ...[2, 3]) }} +{{ '%s %s %s'|format(1, ...[2, 3]) }} +{{ '%s %s %s'|format(1, ...[2], ...[3]) }} +{{ '%s %s %s'|format(...it) }} +--DATA-- +return ['it' => new \ArrayIterator([1, 2, 3])] +--EXPECT-- +1 2 3 +1 2 3 +1 2 3 +1 2 3 +1 2 3 diff --git a/tests/Fixtures/expressions/call_argument_unpacking_before_normal.test b/tests/Fixtures/expressions/call_argument_unpacking_before_normal.test new file mode 100644 index 00000000000..24259393fcd --- /dev/null +++ b/tests/Fixtures/expressions/call_argument_unpacking_before_normal.test @@ -0,0 +1,8 @@ +--TEST-- +Twig supports array unpacking for function calls (but not before normal args) +--TEMPLATE-- +{{ '%s %s %s'|format(...[1, 2], 3) }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Normal arguments must be placed before argument unpacking in "index.twig" at line 2. diff --git a/tests/Fixtures/expressions/const.test b/tests/Fixtures/expressions/const.test new file mode 100644 index 00000000000..336fc60012a --- /dev/null +++ b/tests/Fixtures/expressions/const.test @@ -0,0 +1,10 @@ +--TEST-- +Twig supports accessing constants +--TEMPLATE-- +{{ foo.BAR_NAME }} +--DATA-- +return ['foo' => new Twig\Tests\TwigTestFoo()] +--CONFIG-- +return ['strict_variables' => false] +--EXPECT-- +bar diff --git a/tests/Fixtures/expressions/dynamic_attribute.test b/tests/Fixtures/expressions/dynamic_attribute.test new file mode 100644 index 00000000000..93731076ab8 --- /dev/null +++ b/tests/Fixtures/expressions/dynamic_attribute.test @@ -0,0 +1,24 @@ +--TEST-- +"." notation with dynamic attributes +--TEMPLATE-- +{{ obj.(method) }} +{{ array.(item) }} +{{ obj.("bar")("a", "b") }} +{{ obj.("bar")(param1: "a", param2: "b") }} +{{ obj.("bar")(param2: "b", param1: "a") }} +{{ obj.("bar")("a", param2: "b") }} +{{ obj.("bar")(...arguments) }} +{{ obj.(method) is defined ? 'ok' : 'ko' }} +{{ obj.(nonmethod) is defined ? 'ok' : 'ko' }} +--DATA-- +return ['obj' => new Twig\Tests\TwigTestFoo(), 'method' => 'foo', 'array' => ['foo' => 'bar'], 'item' => 'foo', 'nonmethod' => 'xxx', 'arguments' => ['a', 'b']] +--EXPECT-- +foo +bar +bar_a-b +bar_a-b +bar_a-b +bar_a-b +bar_a-b +ok +ko diff --git a/tests/Fixtures/expressions/has_every.test b/tests/Fixtures/expressions/has_every.test new file mode 100644 index 00000000000..dc43b95ca45 --- /dev/null +++ b/tests/Fixtures/expressions/has_every.test @@ -0,0 +1,19 @@ +--TEST-- +Twig supports the "has every" operator +--TEMPLATE-- +{% if [0, 2, 4] has every v => 0 == v % 2 %}Every{% else %}Not every{% endif %} items are even in array +{{ ([0, 2, 4] has every v => 0 == v % 2) ? 'Every' : 'Not every' }} items are even in array +{{ ({ a: 0, b: 2, c: 4 } has every v => 0 == v % 2) ? 'Every' : 'Not every' }} items are even in object +{{ ({ a: 0, b: 2, c: 4 } has every (v, k) => "d" > k)? 'Every' : 'Not every' }} keys are before "d" in object +{{ (it has every v => 0 == v % 2) ? 'Every' : 'Not every' }} items are even in iterator +{{ ([0, 1, 2] has every v => 0 == v % 2) ? 'Every' : 'Not every' }} items are even in array +--DATA-- +return ['it' => new \ArrayIterator([0, 2, 4])] +--EXPECT-- +Every items are even in array +Every items are even in array +Every items are even in object +Every keys are before "d" in object +Every items are even in iterator +Not every items are even in array + diff --git a/tests/Fixtures/expressions/has_some.test b/tests/Fixtures/expressions/has_some.test new file mode 100644 index 00000000000..c5d75c77d1e --- /dev/null +++ b/tests/Fixtures/expressions/has_some.test @@ -0,0 +1,19 @@ +--TEST-- +Twig supports the "has some" operator +--TEMPLATE-- +{% if [1, 2, 3] has some v => 0 == v % 2 %}At least one{% else %}No{% endif %} item is even in array +{{ ([1, 2, 3] has some v => 0 == v % 2) ? 'At least one' : 'No' }} item is even in array +{{ ({ a: 1, b: 2, c: 3 } has some v => 0 == v % 2) ? 'At least one' : 'No' }} item is even in object +{{ ({ a: 1, b: 2, c: 3 } has some (v, k) => "b" == k)? 'At least one' : 'No' }} key is "b" in object +{{ (it has some v => 0 == v % 2) ? 'At least one' : 'No' }} item is even in iterator +{{ ([1, 3, 5] has some v => 0 == v % 2) ? 'At least one' : 'No' }} item is even in array +--DATA-- +return ['it' => new \ArrayIterator([1, 2, 3])] +--EXPECT-- +At least one item is even in array +At least one item is even in array +At least one item is even in object +At least one key is "b" in object +At least one item is even in iterator +No item is even in array + diff --git a/tests/Fixtures/expressions/matches.test b/tests/Fixtures/expressions/matches.test index 95459c3b0f2..843e5e89c0e 100644 --- a/tests/Fixtures/expressions/matches.test +++ b/tests/Fixtures/expressions/matches.test @@ -2,11 +2,19 @@ Twig supports the "matches" operator --TEMPLATE-- {{ 'foo' matches '/o/' ? 'OK' : 'KO' }} +{{ 'foo' matches '/o/'|lower ? 'OK' : 'KO' }} {{ 'foo' matches '/^fo/' ? 'OK' : 'KO' }} +{{ 'foo' matches '/^' ~ 'fo/' ? 'OK' : 'KO' }} {{ 'foo' matches '/O/i' ? 'OK' : 'KO' }} +{{ null matches '/o/' }} +{{ markup matches '/test/' ? 'OK': 'KO' }} --DATA-- -return [] +return ['markup' => new \Twig\Markup('test', 'UTF-8')] --EXPECT-- OK OK OK +OK +OK +0 +OK diff --git a/tests/Fixtures/expressions/matches_error_compilation.test b/tests/Fixtures/expressions/matches_error_compilation.test new file mode 100644 index 00000000000..c251be13484 --- /dev/null +++ b/tests/Fixtures/expressions/matches_error_compilation.test @@ -0,0 +1,8 @@ +--TEST-- +Twig supports the "matches" operator with a great error message +--TEMPLATE-- +{{ 'foo' matches '/o' }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Regexp "/o" passed to "matches" is not valid: No ending delimiter '/' found in "index.twig" at line 2. diff --git a/tests/Fixtures/expressions/matches_error_runtime.test b/tests/Fixtures/expressions/matches_error_runtime.test new file mode 100644 index 00000000000..4a2bb594352 --- /dev/null +++ b/tests/Fixtures/expressions/matches_error_runtime.test @@ -0,0 +1,8 @@ +--TEST-- +Twig supports the "matches" operator with a great error message +--TEMPLATE-- +{{ 'foo' matches 1 + 2 }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: Regexp "3" passed to "matches" is not valid: Delimiter must not be alphanumeric%sbackslash%sin "index.twig" at line 2 diff --git a/tests/Fixtures/expressions/method_call.test b/tests/Fixtures/expressions/method_call.test index bf49f389e00..ee700f80f69 100644 --- a/tests/Fixtures/expressions/method_call.test +++ b/tests/Fixtures/expressions/method_call.test @@ -6,6 +6,9 @@ Twig supports method calls {{ items.foo.bar }} {{ items.foo['bar'] }} {{ items.foo.bar('a', 43) }} +{{ items.foo.bar(param1: 'a', param2: 43) }} +{{ items.foo.bar(param2: 43, param1: 'a') }} +{{ items.foo.bar('a', param2: 43) }} {{ items.foo.bar(foo) }} {{ items.foo.self.foo() }} {{ items.foo.is }} @@ -20,6 +23,9 @@ foo foo bar +bar_a-43 +bar_a-43 +bar_a-43 bar_a-43 bar_bar foo diff --git a/tests/Fixtures/expressions/postfix.test b/tests/Fixtures/expressions/postfix.test index 276cbf197d1..6217a8410a5 100644 --- a/tests/Fixtures/expressions/postfix.test +++ b/tests/Fixtures/expressions/postfix.test @@ -8,7 +8,7 @@ Twig parses postfix expressions {{ 'a' }} {{ 'a'|upper }} {{ ('a')|upper }} -{{ -1|upper }} +{{ (-1)|abs }} {{ macros.foo() }} {{ (macros).foo() }} --DATA-- @@ -17,6 +17,6 @@ return [] a A A --1 +1 foo foo diff --git a/tests/Fixtures/expressions/power.test b/tests/Fixtures/expressions/power.test index 84fd23692ce..5fb3fa4b561 100644 --- a/tests/Fixtures/expressions/power.test +++ b/tests/Fixtures/expressions/power.test @@ -8,6 +8,10 @@ Twig parses power expressions {{ a ** b }} {{ b ** a }} {{ b ** b }} +{{ -1**0 }} +{{ (-1)**0 }} +{{ -a**0 }} +{{ (-a)**0 }} --DATA-- return ['a' => 4, 'b' => -2] --EXPECT-- @@ -18,3 +22,7 @@ return ['a' => 4, 'b' => -2] 0.0625 16 0.25 +-1 +1 +-1 +1 diff --git a/tests/Fixtures/expressions/spread_array_operator.test b/tests/Fixtures/expressions/spread_array_operator.test new file mode 100644 index 00000000000..292488eda9e --- /dev/null +++ b/tests/Fixtures/expressions/spread_array_operator.test @@ -0,0 +1,14 @@ +--TEST-- +Twig supports the spread operator on arrays +--TEMPLATE-- +{{ [1, 2, ...[3, 4]]|join(',') }} +{{ [1, 2, ...moreNumbers]|join(',') }} +{{ [1, 2, ...iterableNumbers]|join(',') }} +{{ [1, 2, ...iterableNumbers, 0, ...moreNumbers]|join(',') }} +--DATA-- +return ['moreNumbers' => [5, 6, 7, 8], 'iterableNumbers' => new \ArrayObject([6, 7, 8, 9])] +--EXPECT-- +1,2,3,4 +1,2,5,6,7,8 +1,2,6,7,8,9 +1,2,6,7,8,9,0,5,6,7,8 diff --git a/tests/Fixtures/expressions/spread_mapping_operator.test b/tests/Fixtures/expressions/spread_mapping_operator.test new file mode 100644 index 00000000000..e944eee8ac7 --- /dev/null +++ b/tests/Fixtures/expressions/spread_mapping_operator.test @@ -0,0 +1,37 @@ +--TEST-- +Twig supports the spread operator on mappings +--TEMPLATE-- +{% for key, value in { firstName: 'Ryan', lastName: 'Weaver', favoriteFood: 'popcorn', ...{favoriteFood: 'pizza', sport: 'running'} } %} + {{ key }}: {{ value }} +{% endfor %} + +{% for key, value in { firstName: 'Ryan', ...morePersonalDetails} %} + {{ key }}: {{ value }} +{% endfor %} + +{% for key, value in { firstName: 'Ryan', ...iterablePersonalDetails} %} + {{ key }}: {{ value }} +{% endfor %} + +{# multiple spreads #} +{% for key, value in { firstName: 'Ryan', ...iterablePersonalDetails, lastName: 'Weaver', ...morePersonalDetails} %} + {{ key }}: {{ value }} +{% endfor %} +--DATA-- +return ['morePersonalDetails' => ['favoriteColor' => 'orange'], 'iterablePersonalDetails' => new \ArrayObject(['favoriteShoes' => 'barefoot'])]; +--EXPECT-- + firstName: Ryan + lastName: Weaver + favoriteFood: pizza + sport: running + + firstName: Ryan + favoriteColor: orange + + firstName: Ryan + favoriteShoes: barefoot + + firstName: Ryan + favoriteShoes: barefoot + lastName: Weaver + favoriteColor: orange diff --git a/tests/Fixtures/expressions/ternary_operator.test b/tests/Fixtures/expressions/ternary_operator.test index 37eccc0f545..3617a8eb0db 100644 --- a/tests/Fixtures/expressions/ternary_operator.test +++ b/tests/Fixtures/expressions/ternary_operator.test @@ -5,10 +5,11 @@ Twig supports the ternary operator {{ 0 ? 'YES' : 'NO' }} {{ 0 ? 'YES' : (1 ? 'YES1' : 'NO1') }} {{ 0 ? 'YES' : (0 ? 'YES1' : 'NO1') }} -{{ 1 == 1 ? 'foo
    ':'' }} +{{ 1 == 1 ? 'foo
    ' : '' }} {{ foo ~ (bar ? ('-' ~ bar) : '') }} +{{ true ? tag : 'KO' }} --DATA-- -return ['foo' => 'foo', 'bar' => 'bar'] +return ['foo' => 'foo', 'bar' => 'bar', 'tag' => '
    '] --EXPECT-- YES NO @@ -16,3 +17,4 @@ YES1 NO1 foo
    foo-bar +<br> diff --git a/tests/Fixtures/expressions/ternary_operator_noelse.test b/tests/Fixtures/expressions/ternary_operator_noelse.test index 8b0f7284b9b..e82f465554d 100644 --- a/tests/Fixtures/expressions/ternary_operator_noelse.test +++ b/tests/Fixtures/expressions/ternary_operator_noelse.test @@ -3,8 +3,10 @@ Twig supports the ternary operator --TEMPLATE-- {{ 1 ? 'YES' }} {{ 0 ? 'YES' }} +{{ tag ? tag }} --DATA-- -return [] +return ['tag' => '
    '] --EXPECT-- YES +<br> diff --git a/tests/Fixtures/expressions/ternary_operator_nothen.test b/tests/Fixtures/expressions/ternary_operator_nothen.test index ecd6b754656..4aebb778677 100644 --- a/tests/Fixtures/expressions/ternary_operator_nothen.test +++ b/tests/Fixtures/expressions/ternary_operator_nothen.test @@ -3,8 +3,18 @@ Twig supports the ternary operator --TEMPLATE-- {{ 'YES' ?: 'NO' }} {{ 0 ?: 'NO' }} +{{ 'YES' ? : 'NO' }} +{{ 0 ? : 'NO' }} +{{ 'YES' ? : 'NO' }} +{{ 0 ? : 'NO' }} +{{ tag ?: 'KO' }} --DATA-- -return [] +return ['tag' => '
    '] --EXPECT-- YES NO +YES +NO +YES +NO +<br> diff --git a/tests/Fixtures/expressions/underscored_numbers.test b/tests/Fixtures/expressions/underscored_numbers.test new file mode 100644 index 00000000000..4a163ccb518 --- /dev/null +++ b/tests/Fixtures/expressions/underscored_numbers.test @@ -0,0 +1,24 @@ +--TEST-- +Twig compile numbers literals with underscores correctly +--TEMPLATE-- +{{ 0_0 is same as 0 ? 'ok' : 'ko' }} +{{ 1_23 is same as 123 ? 'ok' : 'ko' }} +{{ 12_3 is same as 123 ? 'ok' : 'ko' }} +{{ 1_2_3 is same as 123 ? 'ok' : 'ko' }} +{{ -1_2 is same as -12 ? 'ok' : 'ko' }} +{{ 1_2.3_4 is same as 12.34 ? 'ok' : 'ko' }} +{{ -1_2.3_4 is same as -12.34 ? 'ok' : 'ko' }} +{{ 1.2_3e-4 is same as 1.23e-4 ? 'ok' : 'ko' }} +{{ -1.2_3e+4 is same as -1.23e+4 ? 'ok' : 'ko' }} +--DATA-- +return [] +--EXPECT-- +ok +ok +ok +ok +ok +ok +ok +ok +ok diff --git a/tests/Fixtures/expressions/underscored_numbers_error.test b/tests/Fixtures/expressions/underscored_numbers_error.test new file mode 100644 index 00000000000..839d606f128 --- /dev/null +++ b/tests/Fixtures/expressions/underscored_numbers_error.test @@ -0,0 +1,8 @@ +--TEST-- +Twig does not allow to use 2 underscored between digits in numbers +--TEMPLATE-- +{{ 1__2 }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Unexpected token "name" of value "__2" ("end of print statement" expected) in "index.twig" at line 2. diff --git a/tests/Fixtures/extensions/anonymous_functions.test b/tests/Fixtures/extensions/anonymous_functions.test index 842ecf7a180..a850eeef170 100644 --- a/tests/Fixtures/extensions/anonymous_functions.test +++ b/tests/Fixtures/extensions/anonymous_functions.test @@ -4,7 +4,7 @@ use an anonymous function as a function {{ anon_foo('bar') }} {{ 'bar'|anon_foo }} --DATA-- -return array() +return [] --EXPECT-- *bar* *bar* diff --git a/tests/Fixtures/filters/arrow_reserved_names.test b/tests/Fixtures/filters/arrow_reserved_names.test new file mode 100644 index 00000000000..188373feee5 --- /dev/null +++ b/tests/Fixtures/filters/arrow_reserved_names.test @@ -0,0 +1,8 @@ +--TEST-- +"map" filter +--TEMPLATE-- +{{ [1, 2]|map(true => true * 2)|join(', ') }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: The arrow function argument must be a list of variables or a single variable in "index.twig" at line 2. diff --git a/tests/Fixtures/filters/column.test b/tests/Fixtures/filters/column.test index a2a7f2eb30d..d47c44e15dd 100644 --- a/tests/Fixtures/filters/column.test +++ b/tests/Fixtures/filters/column.test @@ -4,8 +4,8 @@ {{ array|column('foo')|join }} {{ traversable|column('foo')|join }} --DATA-- -$items = array(array('bar' => 'foo', 'foo' => 'bar'), array('foo' => 'foo', 'bar' => 'bar')); -return array('array' => $items, 'traversable' => new ArrayIterator($items)); +$items = [['bar' => 'foo', 'foo' => 'bar'], ['foo' => 'foo', 'bar' => 'bar']]; +return ['array' => $items, 'traversable' => new ArrayIterator($items)]; --EXPECT-- barfoo barfoo diff --git a/tests/Fixtures/filters/date_time_zone_conversion.test b/tests/Fixtures/filters/date_time_zone_conversion.test new file mode 100644 index 00000000000..3ba042b709b --- /dev/null +++ b/tests/Fixtures/filters/date_time_zone_conversion.test @@ -0,0 +1,91 @@ +--TEST-- +"date" filter with time zone conversion +--TEMPLATE-- +{{ date1|date }} +{{ date1|date('d/m/Y') }} +{{ date1|date('d/m/Y H:i:s', 'Asia/Hong_Kong') }} +{{ date1|date('d/m/Y H:i:s P', 'Asia/Hong_Kong') }} +{{ date1|date('d/m/Y H:i:s P', 'America/Chicago') }} +{{ date1|date('e') }} +{{ date1|date('d/m/Y H:i:s') }} + +{{ date2|date }} +{{ date2|date('d/m/Y') }} +{{ date2|date('d/m/Y H:i:s', 'Asia/Hong_Kong') }} +{{ date2|date('d/m/Y H:i:s', timezone1) }} +{{ date2|date('d/m/Y H:i:s') }} + +{{ date3|date }} +{{ date3|date('d/m/Y') }} + +{{ date4|date }} +{{ date4|date('d/m/Y') }} + +{{ date5|date }} +{{ date5|date('d/m/Y') }} + +{{ date6|date('d/m/Y H:i:s P', 'Europe/Paris') }} +{{ date6|date('d/m/Y H:i:s P', 'Asia/Hong_Kong') }} +{{ date6|date('d/m/Y H:i:s P', false) }} +{{ date6|date('e', 'Europe/Paris') }} +{{ date6|date('e', false) }} + +{{ date7|date }} +{{ date7|date(timezone='Europe/Paris') }} +{{ date7|date(timezone='Asia/Hong_Kong') }} +{{ date7|date(timezone=false) }} +{{ date7|date(timezone='Indian/Mauritius') }} + +{{ '2010-01-28 15:00:00'|date(timezone="Europe/Paris") }} +{{ '2010-01-28 15:00:00'|date(timezone="Asia/Hong_Kong") }} +--DATA-- +date_default_timezone_set('Europe/Paris'); +$twig->getExtension(\Twig\Extension\CoreExtension::class)->setTimezone('UTC'); +return [ + 'date1' => mktime(13, 45, 0, 10, 4, 2010), + 'date2' => new \DateTime('2010-10-04 13:45'), + 'date3' => '2010-10-04 13:45', + 'date4' => 1286199900, // \DateTime::createFromFormat('Y-m-d H:i', '2010-10-04 13:45', new \DateTimeZone('UTC'))->getTimestamp() -- A unixtimestamp is always GMT + 'date5' => -189291360, // \DateTime::createFromFormat('Y-m-d H:i', '1964-01-02 03:04', new \DateTimeZone('UTC'))->getTimestamp(), + 'date6' => new \DateTime('2010-10-04 13:45', new \DateTimeZone('America/New_York')), + 'date7' => '2010-01-28T15:00:00+04:00', + 'timezone1' => new \DateTimeZone('America/New_York'), +] +--EXPECT-- +October 4, 2010 11:45 +04/10/2010 +04/10/2010 19:45:00 +04/10/2010 19:45:00 +08:00 +04/10/2010 06:45:00 -05:00 +UTC +04/10/2010 11:45:00 + +October 4, 2010 11:45 +04/10/2010 +04/10/2010 19:45:00 +04/10/2010 07:45:00 +04/10/2010 11:45:00 + +October 4, 2010 11:45 +04/10/2010 + +October 4, 2010 13:45 +04/10/2010 + +January 2, 1964 03:04 +02/01/1964 + +04/10/2010 19:45:00 +02:00 +05/10/2010 01:45:00 +08:00 +04/10/2010 13:45:00 -04:00 +Europe/Paris +America/New_York + +January 28, 2010 11:00 +January 28, 2010 12:00 +January 28, 2010 19:00 +January 28, 2010 15:00 +January 28, 2010 15:00 + +January 28, 2010 15:00 +January 28, 2010 22:00 diff --git a/tests/Fixtures/filters/dynamic_filter.test b/tests/Fixtures/filters/dynamic_filter.test index 27dc8784c6b..15c47814d2f 100644 --- a/tests/Fixtures/filters/dynamic_filter.test +++ b/tests/Fixtures/filters/dynamic_filter.test @@ -2,9 +2,11 @@ dynamic filter --TEMPLATE-- {{ 'bar'|foo_path }} +{{ 'bar'|bar_path }} {{ 'bar'|a_foo_b_bar }} --DATA-- return [] --EXPECT-- foo/bar +bar/bar a/b/bar diff --git a/tests/Fixtures/filters/find.test b/tests/Fixtures/filters/find.test new file mode 100644 index 00000000000..86deae1717b --- /dev/null +++ b/tests/Fixtures/filters/find.test @@ -0,0 +1,42 @@ +--TEST-- +"filter" filter +--TEMPLATE-- + +{{ [1, 2]|find((v) => v > 3) }} + +{{ [1, 5, 3, 4, 5]|find((v) => v > 3) }} + +{{ {a: 1, b: 2, c: 5, d: 8}|find(v => v > 3) }} + +{{ {a: 1, b: 2, c: 5, d: 8}|find((v, k) => (v > 3) and (k != "c")) }} + +{{ [1, 5, 3, 4, 5]|find(v => v > 3) }} + +{{ it|find((v) => v > 3) }} + +{{ ita|find(v => v > 3) }} + +{{ xml|find(x => true) }} + +--DATA-- +return [ + 'it' => new \ArrayIterator(['a' => 1, 'b' => 2, 'c' => 5, 'd' => 8]), + 'ita' => new Twig\Tests\IteratorAggregateStub(['a' => 1, 'b' => 2, 'c' => 5, 'd' => 8]), + 'xml' => new \SimpleXMLElement('foobarbaz'), +] +--EXPECT-- + + +5 + +5 + +8 + +5 + +5 + +5 + +foo diff --git a/tests/Fixtures/filters/invoke.test b/tests/Fixtures/filters/invoke.test new file mode 100644 index 00000000000..b4a707b97a7 --- /dev/null +++ b/tests/Fixtures/filters/invoke.test @@ -0,0 +1,14 @@ +--TEST-- +"invoke" filter +--TEMPLATE-- +{% set func = x => 'Hello '~x %} +{{ func|invoke('World') }} +{% set func2 = (x, y) => x+y %} +{{ func2|invoke(3, 2) }} +--DATA-- +return [] +--CONFIG-- +return [] +--EXPECT-- +Hello World +5 diff --git a/tests/Fixtures/filters/raw.test b/tests/Fixtures/filters/raw.test new file mode 100644 index 00000000000..b23513a608f --- /dev/null +++ b/tests/Fixtures/filters/raw.test @@ -0,0 +1,8 @@ +--TEST-- +"raw" filter excludes a variable from being escaped +--TEMPLATE-- +{{ br|raw }} +--DATA-- +return ['br' => '
    '] +--EXPECT-- +
    diff --git a/tests/Fixtures/filters/reduce_key.test b/tests/Fixtures/filters/reduce_key.test new file mode 100644 index 00000000000..fe1fb0a7ac5 --- /dev/null +++ b/tests/Fixtures/filters/reduce_key.test @@ -0,0 +1,14 @@ +--TEST-- +"reduce" filter passes iterable key to callback +--TEMPLATE-- +{% set status_classes = { + 'success': 200, + 'warning': 400, + 'error': 500, +} %} + +{{ status_classes|reduce((carry, v, k) => status_code >= v ? k : carry, '') }} +--DATA-- +return ['status_code' => 404] +--EXPECT-- +warning diff --git a/tests/Fixtures/filters/replace_invalid_arg.test b/tests/Fixtures/filters/replace_invalid_arg.test index ba6fea4125a..3b1429c90ad 100644 --- a/tests/Fixtures/filters/replace_invalid_arg.test +++ b/tests/Fixtures/filters/replace_invalid_arg.test @@ -5,4 +5,4 @@ Exception for invalid argument type in replace call --DATA-- return ['stdClass' => new \stdClass()] --EXCEPTION-- -Twig\Error\RuntimeError: The "replace" filter expects an array or "Traversable" as replace values, got "stdClass" in "index.twig" at line 2. +Twig\Error\RuntimeError: The "replace" filter expects a sequence or a mapping, got "stdClass" in "index.twig" at line 2. diff --git a/tests/Fixtures/filters/shuffle.test b/tests/Fixtures/filters/shuffle.test new file mode 100644 index 00000000000..5a4029dccf0 --- /dev/null +++ b/tests/Fixtures/filters/shuffle.test @@ -0,0 +1,16 @@ +--TEST-- +"shuffle" filter +--TEMPLATE-- +{% set test = 'ok'|shuffle %}{{ 'ok' is same as test or 'ko' is same as test ? 'ok' : 'ko' }} +{% set test = [3, 1]|shuffle %}{{ [3, 1] is same as test or [1, 3] is same as test ? 'ok' : 'ko' }} +{% set test = ['foo', 'bar']|shuffle %}{{ ['foo', 'bar'] is same as test or ['bar', 'foo'] is same as test ? 'ok' : 'ko' }} +{% set test = {'a': 'd', 'b': 'e'}|shuffle %}{{ ['d', 'e'] is same as test or ['e', 'd'] is same as test ? 'ok' : 'ko' }} +{% set test = traversable|shuffle %}{{ [3, 1] is same as test or [1, 3] is same as test ? 'ok' : 'ko' }} +--DATA-- +return ['traversable' => new \ArrayObject([0 => 3, 1 => 1])] +--EXPECT-- +ok +ok +ok +ok +ok diff --git a/tests/Fixtures/filters/spaceless.legacy.test b/tests/Fixtures/filters/spaceless.legacy.test new file mode 100644 index 00000000000..6a01241e738 --- /dev/null +++ b/tests/Fixtures/filters/spaceless.legacy.test @@ -0,0 +1,16 @@ +--TEST-- +"spaceless" filter +--DEPRECATION-- +Since twig/twig 3.12: Twig Filter "spaceless" is deprecated in index.twig at line 2. +Since twig/twig 3.12: Twig Filter "spaceless" is deprecated in index.twig at line 3. +Since twig/twig 3.12: Twig Filter "spaceless" is deprecated in index.twig at line 4. +--TEMPLATE-- +{{ "
    foo
    "|spaceless }} +*{{ ""|spaceless }}* +*{{ null|spaceless }}* +--DATA-- +return [] +--EXPECT-- +
    foo
    +** +** diff --git a/tests/Fixtures/filters/spaceless.test b/tests/Fixtures/filters/spaceless.test deleted file mode 100644 index 166a7ea0bce..00000000000 --- a/tests/Fixtures/filters/spaceless.test +++ /dev/null @@ -1,12 +0,0 @@ ---TEST-- -"spaceless" filter ---TEMPLATE-- -{{ "
    foo
    "|spaceless }} -*{{ ""|spaceless }}* -*{{ null|spaceless }}* ---DATA-- -return [] ---EXPECT-- -
    foo
    -** -** diff --git a/tests/Fixtures/filters/trim.test b/tests/Fixtures/filters/trim.test index 141f863572b..c8d10c52828 100644 --- a/tests/Fixtures/filters/trim.test +++ b/tests/Fixtures/filters/trim.test @@ -4,11 +4,11 @@ {{ " I like Twig. "|trim }} {{ text|trim }} {{ " foo/"|trim("/") }} -{{ "xxxI like Twig.xxx"|trim(character_mask="x", side="left") }} -{{ "xxxI like Twig.xxx"|trim(side="right", character_mask="x") }} +{{ "xxxI like Twig.xxx"|trim(character_mask: "x", side: "left") }} +{{ "xxxI like Twig.xxx"|trim(side: "right", character_mask: "x") }} {{ "xxxI like Twig.xxx"|trim("x", "right") }} {{ "/ foo/"|trim("/", "left") }} -{{ "/ foo/"|trim(character_mask="/", side="left") }} +{{ "/ foo/"|trim(character_mask: "/", side: "left") }} {{ " do nothing. "|trim("", "right") }} *{{ ""|trim }}* *{{ ""|trim("", "left") }}* @@ -16,6 +16,14 @@ *{{ null|trim }}* *{{ null|trim("", "left") }}* *{{ null|trim("", "right") }}* + +{% set myhtml %} + Here is
    my HTML +{% endset %} +{% set myunsafestring = " I <3 u " %} +{{ myhtml | trim }} +{{ myunsafestring | trim }} +{{ myhtml | trim(character_mask: "f") }} --DATA-- return ['text' => " If you have some HTML it will be escaped. "] --EXPECT-- @@ -34,3 +42,7 @@ xxxI like Twig. ** ** ** + +Here is
    my HTML +I <3 u + Here is<br>my HTML diff --git a/tests/Fixtures/functions/attribute.test b/tests/Fixtures/functions/attribute.legacy.test similarity index 65% rename from tests/Fixtures/functions/attribute.test rename to tests/Fixtures/functions/attribute.legacy.test index 4499ad4bdee..31cca8c4661 100644 --- a/tests/Fixtures/functions/attribute.test +++ b/tests/Fixtures/functions/attribute.legacy.test @@ -2,17 +2,25 @@ "attribute" function --TEMPLATE-- {{ attribute(obj, method) }} +{{ attribute(variable=obj, attribute=method) }} +{{ attribute(variable: obj, attribute: method) }} {{ attribute(array, item) }} {{ attribute(obj, "bar", ["a", "b"]) }} {{ attribute(obj, "bar", arguments) }} +{{ attribute(variable=obj, attribute="bar", arguments=arguments) }} +{{ attribute(variable: obj, attribute: "bar", arguments: arguments) }} {{ attribute(obj, method) is defined ? 'ok' : 'ko' }} {{ attribute(obj, nonmethod) is defined ? 'ok' : 'ko' }} --DATA-- return ['obj' => new Twig\Tests\TwigTestFoo(), 'method' => 'foo', 'array' => ['foo' => 'bar'], 'item' => 'foo', 'nonmethod' => 'xxx', 'arguments' => ['a', 'b']] --EXPECT-- foo +foo +foo bar bar_a-b bar_a-b +bar_a-b +bar_a-b ok ko diff --git a/tests/Fixtures/functions/attribute_with_wrong_args.legacy.test b/tests/Fixtures/functions/attribute_with_wrong_args.legacy.test new file mode 100644 index 00000000000..6e8a17cf8d6 --- /dev/null +++ b/tests/Fixtures/functions/attribute_with_wrong_args.legacy.test @@ -0,0 +1,8 @@ +--TEST-- +"attribute" function +--TEMPLATE-- +{{ attribute(var=var, template="tpl") }} +--DATA-- +return ['var' => null] +--EXCEPTION-- +Twig\Error\SyntaxError: Value for argument "variable" is required for function "attribute" in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/block.test b/tests/Fixtures/functions/block.test index 1a4fd5492f8..bf8decd1e64 100644 --- a/tests/Fixtures/functions/block.test +++ b/tests/Fixtures/functions/block.test @@ -5,8 +5,9 @@ {% block bar %}BAR{% endblock %} --TEMPLATE(base.twig)-- {% block foo %}{{ block('bar') }}{% endblock %} +{% block baz %}{{ block(name='bar') }}{% endblock %} {% block bar %}BAR_BASE{% endblock %} --DATA-- return [] --EXPECT-- -BARBAR +BARBARBAR diff --git a/tests/Fixtures/functions/block_with_template.test b/tests/Fixtures/functions/block_with_template.test index 37cb7a4813f..2e0d2916c63 100644 --- a/tests/Fixtures/functions/block_with_template.test +++ b/tests/Fixtures/functions/block_with_template.test @@ -6,6 +6,10 @@ {{ block('foo', included_loaded_internal) }} {% set output = block('foo', 'included.twig') %} {{ output }} +{% set output = block(name='foo', template='included.twig') %} +{{ output }} +{% set output = block(template='included.twig', name='foo') %} +{{ output }} {% block foo %}NOT FOO{% endblock %} --TEMPLATE(included.twig)-- {% block foo %}FOO{% endblock %} @@ -19,4 +23,6 @@ FOO FOO FOO FOO +FOO +FOO NOT FOO diff --git a/tests/Fixtures/functions/block_without_name.test b/tests/Fixtures/functions/block_without_name.test index 236df945109..61896a8a22a 100644 --- a/tests/Fixtures/functions/block_without_name.test +++ b/tests/Fixtures/functions/block_without_name.test @@ -9,4 +9,4 @@ --DATA-- return [] --EXCEPTION-- -Twig\Error\SyntaxError: The "block" function takes one argument (the block name) in "base.twig" at line 2. +Twig\Error\SyntaxError: Value for argument "name" is required for function "block" in "base.twig" at line 2. diff --git a/tests/Fixtures/functions/block_without_parent.test b/tests/Fixtures/functions/block_without_parent.test index 7fb7ef63246..0f68cb9d0f7 100644 --- a/tests/Fixtures/functions/block_without_parent.test +++ b/tests/Fixtures/functions/block_without_parent.test @@ -6,6 +6,6 @@ --TEMPLATE(parent.twig)-- {{ block('label') }} --DATA-- -return array() +return [] --EXCEPTION-- Twig\Error\RuntimeError: Block "label" should not call parent() in "index.twig" as the block does not exist in the parent template "parent.twig" in "index.twig" at line 3. diff --git a/tests/Fixtures/functions/constant.test b/tests/Fixtures/functions/constant.test index cbad8506e2c..c7a8eb11f59 100644 --- a/tests/Fixtures/functions/constant.test +++ b/tests/Fixtures/functions/constant.test @@ -4,9 +4,11 @@ {{ constant('DATE_W3C') == expect ? 'true' : 'false' }} {{ constant('ARRAY_AS_PROPS', object) }} {{ constant('class', object) }} +{{ constant('ARRAY_AS_PROPS', object) ?? 'KO' }} --DATA-- return ['expect' => DATE_W3C, 'object' => new \ArrayObject(['hi'])] --EXPECT-- true 2 ArrayObject +2 diff --git a/tests/Fixtures/functions/cycle.test b/tests/Fixtures/functions/cycle.test index 0ac6dccd3ae..e54f433187f 100644 --- a/tests/Fixtures/functions/cycle.test +++ b/tests/Fixtures/functions/cycle.test @@ -2,15 +2,19 @@ "cycle" function --TEMPLATE-- {% for i in 0..6 %} -{{ cycle(array1, i) }}-{{ cycle(array2, i) }} +{{ cycle(array1, i) }}-{{ cycle(array2, i) }}-{{ cycle(array3, i) }} {% endfor %} --DATA-- -return ['array1' => ['odd', 'even'], 'array2' => ['apple', 'orange', 'citrus']] +return [ + 'array1' => ['odd', 'even'], + 'array2' => ['apple', 'orange', 'citrus'], + 'array3' => [1, 2, false, null], +]; --EXPECT-- -odd-apple -even-orange -odd-citrus -even-apple -odd-orange -even-citrus -odd-apple +odd-apple-1 +even-orange-2 +odd-citrus- +even-apple- +odd-orange-1 +even-citrus-2 +odd-apple- diff --git a/tests/Fixtures/functions/cycle_empty_mapping.test b/tests/Fixtures/functions/cycle_empty_mapping.test new file mode 100644 index 00000000000..65b1d949148 --- /dev/null +++ b/tests/Fixtures/functions/cycle_empty_mapping.test @@ -0,0 +1,8 @@ +--TEST-- +"cycle" function returns an error on empty mappings +--TEMPLATE-- +{{ cycle({}, 0) }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: The "cycle" function expects a non-empty sequence in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/cycle_empty_sequence.test b/tests/Fixtures/functions/cycle_empty_sequence.test new file mode 100644 index 00000000000..5d60bb1faea --- /dev/null +++ b/tests/Fixtures/functions/cycle_empty_sequence.test @@ -0,0 +1,8 @@ +--TEST-- +"cycle" function returns an error on empty sequences +--TEMPLATE-- +{{ cycle([], 0) }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: The "cycle" function expects a non-empty sequence in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/cycle_without_enough_args.test b/tests/Fixtures/functions/cycle_without_enough_args.test new file mode 100644 index 00000000000..ba4b2adfb18 --- /dev/null +++ b/tests/Fixtures/functions/cycle_without_enough_args.test @@ -0,0 +1,8 @@ +--TEST-- +"cycle" function without enough args and a named argument +--TEMPLATE-- +{{ cycle(position=2) }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Value for argument "values" is required for function "cycle" in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/date_namedargs.test b/tests/Fixtures/functions/date_namedargs.test index 11f60ee8bf2..819e8326b39 100644 --- a/tests/Fixtures/functions/date_namedargs.test +++ b/tests/Fixtures/functions/date_namedargs.test @@ -3,9 +3,11 @@ --TEMPLATE-- {{ date(date, "America/New_York")|date('d/m/Y H:i:s P', false) }} {{ date(timezone="America/New_York", date=date)|date('d/m/Y H:i:s P', false) }} +{{ date(timezone: "America/New_York", date: date)|date('d/m/Y H:i:s P', false) }} --DATA-- date_default_timezone_set('UTC'); return ['date' => mktime(13, 45, 0, 10, 4, 2010)] --EXPECT-- 04/10/2010 09:45:00 -04:00 04/10/2010 09:45:00 -04:00 +04/10/2010 09:45:00 -04:00 diff --git a/tests/Fixtures/functions/deprecated.test b/tests/Fixtures/functions/deprecated.test new file mode 100644 index 00000000000..42f2a5dd6f3 --- /dev/null +++ b/tests/Fixtures/functions/deprecated.test @@ -0,0 +1,21 @@ +--TEST-- +Functions can be deprecated_function +--DEPRECATION-- +Since foo/bar 1.1: Twig Function "deprecated_function" is deprecated; use "not_deprecated_function" instead in index.twig at line 2. +Since foo/bar 1.1: Twig Function "deprecated_function" is deprecated; use "not_deprecated_function" instead in index.twig at line 4. +--TEMPLATE-- +{{ deprecated_function() }} + +{{ deprecated_function() }} +--DATA-- +return [] +--EXPECT-- +foo + +foo +--DATA-- +return [] +--EXPECT-- +foo + +foo diff --git a/tests/Fixtures/functions/dynamic_function.test b/tests/Fixtures/functions/dynamic_function.test index c7b3539c402..ea851b6dc0a 100644 --- a/tests/Fixtures/functions/dynamic_function.test +++ b/tests/Fixtures/functions/dynamic_function.test @@ -2,9 +2,11 @@ dynamic function --TEMPLATE-- {{ foo_path('bar') }} +{{ bar_path('bar') }} {{ a_foo_b_bar('bar') }} --DATA-- return [] --EXPECT-- foo/bar +bar/bar a/b/bar diff --git a/tests/Fixtures/functions/enum/invalid_dynamic_enum.test b/tests/Fixtures/functions/enum/invalid_dynamic_enum.test new file mode 100644 index 00000000000..1ae27ffe566 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_dynamic_enum.test @@ -0,0 +1,13 @@ +--TEST-- +"enum" function with invalid dynamic enum class +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% set from_variable = 'Twig\\Tests\\NonExistentEnum' %} +{% for c in enum(from_variable).cases() %} + {{~ c.name }} +{% endfor %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: "Twig\Tests\NonExistentEnum" is not an enum in "index.twig" at line 3. diff --git a/tests/Fixtures/functions/enum/invalid_enum.test b/tests/Fixtures/functions/enum/invalid_enum.test new file mode 100644 index 00000000000..b38e7fc9ad1 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_enum.test @@ -0,0 +1,10 @@ +--TEST-- +"enum" function with invalid enum class +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum('Twig\\Tests\\NonExistentEnum').cases() %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum" function must be the name of an enum, "Twig\Tests\NonExistentEnum" given in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum/invalid_enum_escaping.test b/tests/Fixtures/functions/enum/invalid_enum_escaping.test new file mode 100644 index 00000000000..5c10afb10e7 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_enum_escaping.test @@ -0,0 +1,10 @@ +--TEST-- +"enum" function with missing \ escaping +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum('Twig\Tests\DummyBackedEnum').cases() %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum" function must be the name of an enum, "TwigTestsDummyBackedEnum" given in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum/invalid_literal_type.test b/tests/Fixtures/functions/enum/invalid_literal_type.test new file mode 100644 index 00000000000..9b79dec0625 --- /dev/null +++ b/tests/Fixtures/functions/enum/invalid_literal_type.test @@ -0,0 +1,10 @@ +--TEST-- +"enum" function with invalid literal type +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum(13).cases() %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum" function must be a string in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum/valid.test b/tests/Fixtures/functions/enum/valid.test new file mode 100644 index 00000000000..8eb1a510feb --- /dev/null +++ b/tests/Fixtures/functions/enum/valid.test @@ -0,0 +1,30 @@ +--TEST-- +"enum" function +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{{ enum('Twig\\Tests\\DummyBackedEnum').FOO.value }} +{% for c in enum('Twig\\Tests\\DummyBackedEnum').cases() %} + {{~ c.name }}: {{ c.value }} +{% endfor %} +{{ enum('Twig\\Tests\\DummyUnitEnum').BAR.name }} +{% for c in enum('Twig\\Tests\\DummyUnitEnum').cases() %} + {{~ c.name }} +{% endfor %} +{% set from_variable='Twig\\Tests\\DummyUnitEnum' %} +{{ enum(from_variable).BAR.name }} +{% for c in enum(from_variable).cases() %} + {{~ c.name }} +{% endfor %} +--DATA-- +return [] +--EXPECT-- +foo +FOO: foo +BAR: bar +BAR +BAR +BAZ +BAR +BAR +BAZ diff --git a/tests/Fixtures/functions/enum_cases/invalid_dynamic_enum.test b/tests/Fixtures/functions/enum_cases/invalid_dynamic_enum.test new file mode 100644 index 00000000000..2a6008bba02 --- /dev/null +++ b/tests/Fixtures/functions/enum_cases/invalid_dynamic_enum.test @@ -0,0 +1,13 @@ +--TEST-- +"enum_cases" function with invalid dynamic enum class +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% set from_variable = 'Twig\\Tests\\NonExistentEnum' %} +{% for c in enum_cases(from_variable) %} + {{~ c.name }} +{% endfor %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\RuntimeError: Enum "Twig\Tests\NonExistentEnum" does not exist in "index.twig" at line 3. diff --git a/tests/Fixtures/functions/enum_cases/invalid_enum.test b/tests/Fixtures/functions/enum_cases/invalid_enum.test new file mode 100644 index 00000000000..3084c37e34c --- /dev/null +++ b/tests/Fixtures/functions/enum_cases/invalid_enum.test @@ -0,0 +1,10 @@ +--TEST-- +"enum_cases" function with invalid enum class +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum_cases('Twig\\Tests\\NonExistentEnum') %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum_cases" function must be the name of an enum, "Twig\Tests\NonExistentEnum" given in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum_cases/invalid_enum_escaping.test b/tests/Fixtures/functions/enum_cases/invalid_enum_escaping.test new file mode 100644 index 00000000000..1d5828fbca5 --- /dev/null +++ b/tests/Fixtures/functions/enum_cases/invalid_enum_escaping.test @@ -0,0 +1,10 @@ +--TEST-- +"enum_cases" function with missing \ escaping +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum_cases('Twig\Tests\DummyBackedEnum') %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum_cases" function must be the name of an enum, "TwigTestsDummyBackedEnum" given in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum_cases/invalid_literal_type.test b/tests/Fixtures/functions/enum_cases/invalid_literal_type.test new file mode 100644 index 00000000000..6e7945d0003 --- /dev/null +++ b/tests/Fixtures/functions/enum_cases/invalid_literal_type.test @@ -0,0 +1,10 @@ +--TEST-- +"enum_cases" function with invalid literal type +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum_cases(13) %} + {{~ c.name }} +{% endfor %} +--EXCEPTION-- +Twig\Error\SyntaxError: The first argument of the "enum_cases" function must be a string in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/enum_cases/valid.test b/tests/Fixtures/functions/enum_cases/valid.test new file mode 100644 index 00000000000..011d6478651 --- /dev/null +++ b/tests/Fixtures/functions/enum_cases/valid.test @@ -0,0 +1,24 @@ +--TEST-- +"enum_cases" function +--CONDITION-- +\PHP_VERSION_ID >= 80100 +--TEMPLATE-- +{% for c in enum_cases('Twig\\Tests\\DummyBackedEnum') %} + {{~ c.name }}: {{ c.value }} +{% endfor %} +{% for c in enum_cases('Twig\\Tests\\DummyUnitEnum') %} + {{~ c.name }} +{% endfor %} +{% set from_variable='Twig\\Tests\\DummyUnitEnum' %} +{% for c in enum_cases(from_variable) %} + {{~ c.name }} +{% endfor %} +--DATA-- +return [] +--EXPECT-- +FOO: foo +BAR: bar +BAR +BAZ +BAR +BAZ diff --git a/tests/Fixtures/functions/include/sandbox.test b/tests/Fixtures/functions/include/sandbox.test index ebfdb1eb8ff..ec7e61e9703 100644 --- a/tests/Fixtures/functions/include/sandbox.test +++ b/tests/Fixtures/functions/include/sandbox.test @@ -5,8 +5,8 @@ --TEMPLATE(foo.twig)-- -{{ foo|e }} -{{ foo|e }} +{{ 'foo'|e }} +{{ 'foo'|e }} --DATA-- return [] --EXCEPTION-- diff --git a/tests/Fixtures/functions/include/template_instance.test b/tests/Fixtures/functions/include/template_instance.test index 4c8b450835c..be18d244ac0 100644 --- a/tests/Fixtures/functions/include/template_instance.test +++ b/tests/Fixtures/functions/include/template_instance.test @@ -1,5 +1,5 @@ --TEST-- -"include" function accepts Twig_Template instance +"include" function accepts Twig\Template instance --TEMPLATE-- {{ include(foo) }} FOO --TEMPLATE(foo.twig)-- diff --git a/tests/Fixtures/functions/max_without_args.test b/tests/Fixtures/functions/max_without_args.test new file mode 100644 index 00000000000..b9522192bcd --- /dev/null +++ b/tests/Fixtures/functions/max_without_args.test @@ -0,0 +1,8 @@ +--TEST-- +"max" function without an argument throws a compile time exception +--TEMPLATE-- +{{ max() }} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Value for argument "value" is required for function "max" in "index.twig" at line 2. diff --git a/tests/Fixtures/functions/parent_in_condition.test b/tests/Fixtures/functions/parent_in_condition.test new file mode 100644 index 00000000000..f3d51d2dd41 --- /dev/null +++ b/tests/Fixtures/functions/parent_in_condition.test @@ -0,0 +1,11 @@ +--TEST-- +"block" calling parent() in a conditional expression +--TEMPLATE-- +{% extends "parent.twig" %} +{% block label %}{{ parent() ?: 'foo' }}{% endblock %} +--TEMPLATE(parent.twig)-- +{% block label %}PARENT_LABEL{% endblock %} +--DATA-- +return [] +--EXPECT-- +PARENT_LABEL diff --git a/tests/Fixtures/functions/parent_outside_of_a_block.test b/tests/Fixtures/functions/parent_outside_of_a_block.test new file mode 100644 index 00000000000..03d4f5d662d --- /dev/null +++ b/tests/Fixtures/functions/parent_outside_of_a_block.test @@ -0,0 +1,10 @@ +--TEST-- +"parent" cannot be called outside of a block +--TEMPLATE-- +{% extends "parent.twig" %} +{{ parent() }} +--TEMPLATE(parent.twig)-- +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Calling the "parent" function outside of a block is forbidden in "index.twig" at line 3. diff --git a/tests/Fixtures/macros/arrow_as_arg.test b/tests/Fixtures/macros/arrow_as_arg.test new file mode 100644 index 00000000000..5bae34652f4 --- /dev/null +++ b/tests/Fixtures/macros/arrow_as_arg.test @@ -0,0 +1,19 @@ +--TEST-- +macro +--TEMPLATE-- +{% set people = [ + {first: "Bob", last: "Smith"}, + {first: "Alice", last: "Dupond"}, +] %} + +{% set first_name_fn = (p) => p.first %} + +{{ _self.display_people(people, first_name_fn) }} + +{% macro display_people(people, fn) %} + {{ people|map(fn)|join(', ') }} +{% endmacro %} +--DATA-- +return [] +--EXPECT-- +Bob, Alice diff --git a/tests/Fixtures/macros/macro_with_capture.test b/tests/Fixtures/macros/macro_with_capture.test new file mode 100644 index 00000000000..2f9caed6eea --- /dev/null +++ b/tests/Fixtures/macros/macro_with_capture.test @@ -0,0 +1,14 @@ +--TEST-- +macro +--TEMPLATE-- +{{ _self.some_macro() }} + +{% macro some_macro() %} + {% apply upper %} + {% if true %}foo{% endif %} + {% endapply %} +{% endmacro %} +--DATA-- +return [] +--EXPECT-- +FOO diff --git a/tests/Fixtures/macros/unknown_macro_different_template.test b/tests/Fixtures/macros/unknown_macro_different_template.test new file mode 100644 index 00000000000..61604e8a93f --- /dev/null +++ b/tests/Fixtures/macros/unknown_macro_different_template.test @@ -0,0 +1,11 @@ +--TEST-- +Exception for unknown macro in different template +--TEMPLATE-- +{% import foo_template as macros %} +{{ macros.foo() }} +--TEMPLATE(foo.twig)-- +foo +--DATA-- +return array('foo_template' => 'foo.twig') +--EXCEPTION-- +Twig\Error\RuntimeError: Macro "foo" is not defined in template "foo.twig" in "index.twig" at line 3. diff --git a/tests/Fixtures/operators/concat_vs_add_sub.test b/tests/Fixtures/operators/concat_vs_add_sub.test new file mode 100644 index 00000000000..298a3499b5e --- /dev/null +++ b/tests/Fixtures/operators/concat_vs_add_sub.test @@ -0,0 +1,16 @@ +--TEST-- ++/- will have a higher precedence over ~ in Twig 4.0 +--TEMPLATE-- +{{ 1 + 41 }} +{{ '42==' ~ '42' }} +{{ '42==' ~ (1 + 41) }} +{{ '42==' ~ (43 - 1) }} +{{ ('42' ~ 43) - 1 }} +--DATA-- +return [] +--EXPECT-- +42 +42==42 +42==42 +42==42 +4242 diff --git a/tests/Fixtures/operators/contat_vs_add_sub.legacy.test b/tests/Fixtures/operators/contat_vs_add_sub.legacy.test new file mode 100644 index 00000000000..a1370d2caed --- /dev/null +++ b/tests/Fixtures/operators/contat_vs_add_sub.legacy.test @@ -0,0 +1,13 @@ +--TEST-- ++/- will have a higher precedence over ~ in Twig 4.0 +--DEPRECATION-- +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 }} +--DATA-- +return [] +--EXPECT-- +462 +4242 diff --git a/tests/Fixtures/operators/minus_vs_pipe.legacy.test b/tests/Fixtures/operators/minus_vs_pipe.legacy.test new file mode 100644 index 00000000000..ac6cd3278a2 --- /dev/null +++ b/tests/Fixtures/operators/minus_vs_pipe.legacy.test @@ -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 --git a/tests/Fixtures/operators/not_precedence.legacy.test b/tests/Fixtures/operators/not_precedence.legacy.test new file mode 100644 index 00000000000..3a2f4a7ec3f --- /dev/null +++ b/tests/Fixtures/operators/not_precedence.legacy.test @@ -0,0 +1,9 @@ +--TEST-- +*, /, //, and % will have a higher precedence over not in Twig 4.0 +--DEPRECATION-- +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-- +return [] +--EXPECT-- diff --git a/tests/Fixtures/operators/not_precedence.test b/tests/Fixtures/operators/not_precedence.test new file mode 100644 index 00000000000..f21a0861653 --- /dev/null +++ b/tests/Fixtures/operators/not_precedence.test @@ -0,0 +1,9 @@ +--TEST-- +*, /, //, and % will have a higher precedence over not in Twig 4.0 +--TEMPLATE-- +{{ (not 1) * 2 }} +{{ not (1 * 2) }} +--DATA-- +return [] +--EXPECT-- +0 diff --git a/tests/Fixtures/regression/4029-iterator_to_array.test b/tests/Fixtures/regression/4029-iterator_to_array.test new file mode 100644 index 00000000000..99afd892f50 --- /dev/null +++ b/tests/Fixtures/regression/4029-iterator_to_array.test @@ -0,0 +1,14 @@ +--TEST-- +#4029 When use_yield is true, CaptureNode fall in iterator_to_array pitfall regarding index overwrite +--TEMPLATE-- +{%- set tmp -%} + {%- block foo 'foo' -%} + {%- block bar 'bar' -%} +{%- endset -%} +{{ tmp }} +--DATA-- +return [] +--CONFIG-- +return ['use_yield' => true] +--EXPECT-- +foobar \ No newline at end of file diff --git a/tests/Fixtures/regression/4033-missing-unwrap.test b/tests/Fixtures/regression/4033-missing-unwrap.test new file mode 100644 index 00000000000..37778731c66 --- /dev/null +++ b/tests/Fixtures/regression/4033-missing-unwrap.test @@ -0,0 +1,19 @@ +--TEST-- +Call to undefined method Twig\\TemplateWrapper::yieldBlock() +--TEMPLATE-- +{% extends 'parent' %} +{%- block content -%} + {{ parent() }} + child +{%- endblock -%} +--TEMPLATE(parent)-- +{% extends ['unknowngrandparent', 'grandparent'] %} +--TEMPLATE(grandparent)-- +{%- block content -%} + grandparent +{%- endblock -%} +--DATA-- +return [] +--EXPECT-- + grandparent + child \ No newline at end of file diff --git a/tests/Fixtures/regression/4701-block-inheritance-issue.test b/tests/Fixtures/regression/4701-block-inheritance-issue.test new file mode 100644 index 00000000000..f964be87143 --- /dev/null +++ b/tests/Fixtures/regression/4701-block-inheritance-issue.test @@ -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 --git a/tests/Fixtures/regression/issue_1143.test b/tests/Fixtures/regression/issue_1143.test deleted file mode 100644 index e2ab950e183..00000000000 --- a/tests/Fixtures/regression/issue_1143.test +++ /dev/null @@ -1,23 +0,0 @@ ---TEST-- -error in twig extension ---TEMPLATE-- -{{ object.region is not null ? object.regionChoices[object.region] }} ---DATA-- -class House -{ - const REGION_S = 1; - const REGION_P = 2; - - public static $regionChoices = [self::REGION_S => 'house.region.s', self::REGION_P => 'house.region.p']; - - public function getRegionChoices() - { - return self::$regionChoices; - } -} - -$object = new House(); -$object->region = 1; -return ['object' => $object] ---EXPECT-- -house.region.s diff --git a/tests/Fixtures/regression/markup_test.test b/tests/Fixtures/regression/markup_test.test new file mode 100644 index 00000000000..e03c5291b91 --- /dev/null +++ b/tests/Fixtures/regression/markup_test.test @@ -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 --git a/tests/Fixtures/regression/strings_like_numbers.test b/tests/Fixtures/regression/strings_like_numbers.test index 62fe8848587..3884226fa4b 100644 --- a/tests/Fixtures/regression/strings_like_numbers.test +++ b/tests/Fixtures/regression/strings_like_numbers.test @@ -1,8 +1,8 @@ --TEST-- Twig does not confuse strings with integers in getAttribute() --TEMPLATE-- -{{ hash['2e2'] }} +{{ mapping['2e2'] }} --DATA-- -return ['hash' => ['2e2' => 'works']] +return ['mapping' => ['2e2' => 'works']] --EXPECT-- works diff --git a/tests/Fixtures/tags/apply/scope.test b/tests/Fixtures/tags/apply/scope.test index a87ff9116ba..ff8a23116f7 100644 --- a/tests/Fixtures/tags/apply/scope.test +++ b/tests/Fixtures/tags/apply/scope.test @@ -2,7 +2,7 @@ "apply" tag does not create a new scope --TEMPLATE-- {% set foo = 'baz' %} -{% apply spaceless %} +{% apply upper %} {% set foo = 'foo' %} {% set bar = 'bar' %} {% endapply %} diff --git a/tests/Fixtures/tags/block/conditional_block.test b/tests/Fixtures/tags/block/conditional_block.test index d4e2ae009fb..04bf601e7d4 100644 --- a/tests/Fixtures/tags/block/conditional_block.test +++ b/tests/Fixtures/tags/block/conditional_block.test @@ -4,6 +4,6 @@ conditional "block" tag {% if false %}{% block foo %}FOO{% endblock %}{% endif %} {% if true %}{% block bar %}BAR{% endblock %}{% endif %} --DATA-- -return array() +return [] --EXPECT-- BAR diff --git a/tests/Fixtures/tags/deprecated/with_package.legacy.test b/tests/Fixtures/tags/deprecated/with_package.legacy.test new file mode 100644 index 00000000000..877643f01f4 --- /dev/null +++ b/tests/Fixtures/tags/deprecated/with_package.legacy.test @@ -0,0 +1,10 @@ +--TEST-- +Deprecating a template with "deprecated" tag +--TEMPLATE-- +{% deprecated 'The "index.twig" template is deprecated, use "greeting.twig" instead.' package="foo/bar" %} + +Hello Fabien +--DATA-- +return [] +--EXPECT-- +Hello Fabien diff --git a/tests/Fixtures/tags/deprecated/with_package_version.legacy.test b/tests/Fixtures/tags/deprecated/with_package_version.legacy.test new file mode 100644 index 00000000000..68722994e04 --- /dev/null +++ b/tests/Fixtures/tags/deprecated/with_package_version.legacy.test @@ -0,0 +1,10 @@ +--TEST-- +Deprecating a template with "deprecated" tag +--TEMPLATE-- +{% deprecated 'The "index.twig" template is deprecated, use "greeting.twig" instead.' package="foo/bar" version=1.1 %} + +Hello Fabien +--DATA-- +return [] +--EXPECT-- +Hello Fabien diff --git a/tests/Fixtures/tags/embed/embed_ignore_missing.test b/tests/Fixtures/tags/embed/embed_ignore_missing.test new file mode 100644 index 00000000000..f6f27a10abe --- /dev/null +++ b/tests/Fixtures/tags/embed/embed_ignore_missing.test @@ -0,0 +1,10 @@ +--TEST-- +"embed" tag +--TEMPLATE-- +{% set x = 'bad' %} +{% embed x ~ 'ger.twig' ignore missing %}{% endembed %} +HERE +--DATA-- +return [] +--EXPECT-- +HERE diff --git a/tests/Fixtures/tags/for/for_on_strings.test b/tests/Fixtures/tags/for/for_on_strings.test new file mode 100644 index 00000000000..62d48426978 --- /dev/null +++ b/tests/Fixtures/tags/for/for_on_strings.test @@ -0,0 +1,13 @@ +--TEST-- +"for" tag can iterate over a string via the "split" filter +--TEMPLATE-- +{% set jp = "諺 / ことわざ" %} + +{% for letter in jp|split('') -%} + -{{- letter }} + {{- loop.last ? '.' }} +{%- endfor %} +--DATA-- +return [] +--EXPECT-- +-諺- -/- -こ-と-わ-ざ. diff --git a/tests/Fixtures/tags/for/reserved_names.test b/tests/Fixtures/tags/for/reserved_names.test new file mode 100644 index 00000000000..6f9953175d1 --- /dev/null +++ b/tests/Fixtures/tags/for/reserved_names.test @@ -0,0 +1,9 @@ +--TEST-- +"for" tag +--TEMPLATE-- +{% for true in [1, 2] %} +{% endfor %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 2. diff --git a/tests/Fixtures/tags/guard/basic.test b/tests/Fixtures/tags/guard/basic.test new file mode 100644 index 00000000000..bfa66eebb25 --- /dev/null +++ b/tests/Fixtures/tags/guard/basic.test @@ -0,0 +1,38 @@ +--TEST-- +"guard" creates a compilation time condition on Twig callables availability +--TEMPLATE-- +{% guard filter foobar %} + NEVER + {{ 'a'|foobar }} +{% else -%} + The foobar filter doesn't exist +{% endguard %} + +{% guard function constant -%} + The constant function does exist +{% 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 --git a/tests/Fixtures/tags/guard/exception.test b/tests/Fixtures/tags/guard/exception.test new file mode 100644 index 00000000000..644eccd5486 --- /dev/null +++ b/tests/Fixtures/tags/guard/exception.test @@ -0,0 +1,12 @@ +--TEST-- +"guard" creates a compilation time condition on Twig callables availability +--TEMPLATE-- +{% guard function foobar %} + {{ foobar() }} +{% else %} + {{ foobar() }} +{% endguard %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Unknown "foobar" function in "index.twig" at line 5. diff --git a/tests/Fixtures/tags/guard/nested.test b/tests/Fixtures/tags/guard/nested.test new file mode 100644 index 00000000000..2918b141673 --- /dev/null +++ b/tests/Fixtures/tags/guard/nested.test @@ -0,0 +1,60 @@ +--TEST-- +"guard" creates a compilation time condition on Twig callables availability +--TEMPLATE-- +{% guard function constant %} + {% guard filter foobar %} + NEVER + {{ 'a'|foobar }} + {% else %} + The constant function does exist, but the foobar filter does not + {%- endguard %} +{% else %} + NEVER +{% endguard %} + +{% guard function constant -%} + {% guard filter upper -%} + The constant function does exist, and the upper filter as well + {%- else %} + NEVER + {% endguard %} +{% else %} + NEVER +{% endguard %} + +{% guard filter foobar %} + NEVER + {{ 'a'|foobar }} +{% else -%} + {% guard function barfoo %} + NEVER + {% else -%} + The foobar filter does not exist, and the barfoo function does not exist + {%- endguard %} +{% endguard %} + +{% guard filter foobar %} + NEVER + {{ 'a'|foobar }} +{% else %} + {%- guard function constant -%} + The foobar filter does not exist, but the constant function exists + {% else -%} + NEVER + {% endguard %} +{% endguard %} + +{% guard function first %} + {% guard function second %} + NEVER + {{ second() }} + {% endguard %} + {{ first() }} +{% endguard %} +--DATA-- +return [] +--EXPECT-- +The constant function does exist, but the foobar filter does not +The constant function does exist, and the upper filter as well +The foobar filter does not exist, and the barfoo function does not exist +The foobar filter does not exist, but the constant function exists diff --git a/tests/Fixtures/tags/guard/throwing_handler.test b/tests/Fixtures/tags/guard/throwing_handler.test new file mode 100644 index 00000000000..8b9f0708f7c --- /dev/null +++ b/tests/Fixtures/tags/guard/throwing_handler.test @@ -0,0 +1,40 @@ +--TEST-- +"guard" creates a compilation time condition on Twig callables availability +--TEMPLATE-- +{% guard filter throwing_undefined_filter %} + NEVER + {{ 'a'|throwing_undefined_filter }} +{% else -%} + The throwing_undefined_filter filter doesn't exist +{% endguard %} + +{% guard function throwing_undefined_function -%} + NEVER + {{ throwing_undefined_function() }} +{% 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 --git a/tests/Fixtures/tags/if/empty_body.test b/tests/Fixtures/tags/if/empty_body.test new file mode 100644 index 00000000000..ba49f6e1ce5 --- /dev/null +++ b/tests/Fixtures/tags/if/empty_body.test @@ -0,0 +1,32 @@ +--TEST-- +empty "if" body in child template +--TEMPLATE-- +{% extends 'base.twig' %} + +{% set foo = '' %} + +{% if a is defined %} + +{% else %} + {% set foo = 'NOTHING' %} +{% endif %} + +{% if a is defined %} + +{% endif %} + +{% if a is defined %} + {% set foo = 'NOTHING' %} +{% else %} + +{% endif %} + +{% block content %} + {{ foo }} +{% endblock %} +--TEMPLATE(base.twig)-- +{% block content %}{% endblock %} +--DATA-- +return [] +--EXPECT-- + NOTHING diff --git a/tests/Fixtures/tags/inheritance/capturing_block.test b/tests/Fixtures/tags/inheritance/capturing_block.test index 703e33fd0cc..91db2c22f59 100644 --- a/tests/Fixtures/tags/inheritance/capturing_block.test +++ b/tests/Fixtures/tags/inheritance/capturing_block.test @@ -12,6 +12,6 @@ capturing "block" tag with "extends" tag {% block content %}{% endblock %} {% block content1 %}{% endblock %} --DATA-- -return array() +return [] --EXPECT-- FOOBARFOO diff --git a/tests/Fixtures/tags/inheritance/conditional_block.test b/tests/Fixtures/tags/inheritance/conditional_block.test index 2c6e2e37f3b..0b42212dd1a 100644 --- a/tests/Fixtures/tags/inheritance/conditional_block.test +++ b/tests/Fixtures/tags/inheritance/conditional_block.test @@ -9,6 +9,6 @@ conditional "block" tag with "extends" tag --TEMPLATE(layout.twig)-- {% block content %}{% endblock %} --DATA-- -return array() +return [] --EXCEPTION-- Twig\Error\SyntaxError: A block definition cannot be nested under non-capturing nodes in "index.twig" at line 5. diff --git a/tests/Fixtures/tags/inheritance/conditional_block_nested.test b/tests/Fixtures/tags/inheritance/conditional_block_nested.test new file mode 100644 index 00000000000..1f99dfb6167 --- /dev/null +++ b/tests/Fixtures/tags/inheritance/conditional_block_nested.test @@ -0,0 +1,38 @@ +--TEST-- +conditional "block" tag with "extends" tag (nested) +--TEMPLATE-- +{% extends "layout.twig" %} + +{% block content_base %} + {{ parent() -}} + index +{% endblock %} + +{% block content_layout -%} + {{ parent() -}} + nested_index +{% endblock %} +--TEMPLATE(layout.twig)-- +{% extends "base.twig" %} + +{% block content_base %} + {{ parent() -}} + layout + {% if true -%} + {% block content_layout -%} + nested_layout + {% endblock -%} + {% endif %} +{% endblock %} +--TEMPLATE(base.twig)-- +{% block content_base %} + base +{% endblock %} +--DATA-- +return [] +--EXPECT-- +base +layout + nested_layout + nested_index +index diff --git a/tests/Fixtures/tags/inheritance/dynamic_parent_from_include.test b/tests/Fixtures/tags/inheritance/dynamic_parent_from_include.test new file mode 100644 index 00000000000..42a3d60e278 --- /dev/null +++ b/tests/Fixtures/tags/inheritance/dynamic_parent_from_include.test @@ -0,0 +1,14 @@ +--TEST-- +"extends" tag +--TEMPLATE-- +{{ include('included.twig') }} + +--TEMPLATE(included.twig)-- + + + +{% extends dynamic %} +--DATA-- +return ['dynamic' => 'unknown.twig'] +--EXCEPTION-- +Twig\Error\LoaderError: Template "unknown.twig" is not defined in "included.twig" at line 5. diff --git a/tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test b/tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test new file mode 100644 index 00000000000..01d23fce2c2 --- /dev/null +++ b/tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test @@ -0,0 +1,31 @@ +--TEST-- +"extends" tag +--TEMPLATE-- +{% extends ["parent.twig"] %} + +{% block outer %} + outer wrap start + {{~ parent() }} + outer wrap end +{% endblock %} + +{% block inner -%} + inner actual +{% endblock %} +--TEMPLATE(parent.twig)-- +{% block outer %} + outer start + {% block inner %} + inner default + {% endblock %} + outer end +{% endblock %} +--DATA-- +return [] +--EXPECT-- + outer wrap start + outer start + inner actual + outer end + + outer wrap end diff --git a/tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test b/tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test new file mode 100644 index 00000000000..496f278cf38 --- /dev/null +++ b/tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test @@ -0,0 +1,31 @@ +--TEST-- +"extends" tag +--TEMPLATE-- +{% extends "parent.twig" %} + +{% block outer %} + outer wrap start + {{~ parent() }} + outer wrap end +{% endblock %} + +{% block inner -%} + inner actual +{% endblock %} +--TEMPLATE(parent.twig)-- +{% block outer %} + outer start + {% block inner %} + inner default + {% endblock %} + outer end +{% endblock %} +--DATA-- +return [] +--EXPECT-- + outer wrap start + outer start + inner actual + outer end + + outer wrap end diff --git a/tests/Fixtures/tags/inheritance/parent_as_template_wrapper.test b/tests/Fixtures/tags/inheritance/parent_as_template_wrapper.test index 1aaed556c57..cf257f25dc7 100644 --- a/tests/Fixtures/tags/inheritance/parent_as_template_wrapper.test +++ b/tests/Fixtures/tags/inheritance/parent_as_template_wrapper.test @@ -1,5 +1,5 @@ --TEST-- -"extends" tag with a parent as a Twig_TemplateWrapper instance +"extends" tag with a parent as a Twig\TemplateWrapper instance --TEMPLATE-- {% extends foo %} diff --git a/tests/Fixtures/tags/inheritance/parent_without_extends.test b/tests/Fixtures/tags/inheritance/parent_without_extends.test index 6d98891553d..c2025f60ca7 100644 --- a/tests/Fixtures/tags/inheritance/parent_without_extends.test +++ b/tests/Fixtures/tags/inheritance/parent_without_extends.test @@ -5,4 +5,4 @@ {{ parent() }} {% endblock %} --EXCEPTION-- -Twig\Error\SyntaxError: Calling "parent" on a template that does not extend nor "use" another template is forbidden in "index.twig" at line 3. +Twig\Error\SyntaxError: Calling the "parent" function on a template that does not call "extends" or "use" is forbidden in "index.twig" at line 3. diff --git a/tests/Fixtures/tags/inheritance/template_instance.test b/tests/Fixtures/tags/inheritance/template_instance.test index a5a223886dc..b9009e5df53 100644 --- a/tests/Fixtures/tags/inheritance/template_instance.test +++ b/tests/Fixtures/tags/inheritance/template_instance.test @@ -1,5 +1,5 @@ --TEST-- -"extends" tag accepts Twig_Template instance +"extends" tag accepts Twig\Template instance --TEMPLATE-- {% extends foo %} diff --git a/tests/Fixtures/tags/macro/argument_reserved_names.test b/tests/Fixtures/tags/macro/argument_reserved_names.test new file mode 100644 index 00000000000..736c26a67fc --- /dev/null +++ b/tests/Fixtures/tags/macro/argument_reserved_names.test @@ -0,0 +1,12 @@ +--TEST-- +"macro" tag +--TEMPLATE-- +{% import _self as macros %} + +{% macro input(true, false, null) %} + {{ true }} +{% endmacro %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 4. diff --git a/tests/Fixtures/tags/macro/colon_not_supported_as_default_separator.test b/tests/Fixtures/tags/macro/colon_not_supported_as_default_separator.test new file mode 100644 index 00000000000..6b0bb25fbc0 --- /dev/null +++ b/tests/Fixtures/tags/macro/colon_not_supported_as_default_separator.test @@ -0,0 +1,10 @@ +--TEST-- +"macro" tag does not support : as a separator in definition, only = is supported +--TEMPLATE-- +{% macro test(foo: "foo") -%} + {{ foo }} +{%- endmacro %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: Arguments must be separated by a comma. Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") in "index.twig" at line 2. diff --git a/tests/Fixtures/tags/macro/from_reserved_names.test b/tests/Fixtures/tags/macro/from_reserved_names.test new file mode 100644 index 00000000000..243fe6250b5 --- /dev/null +++ b/tests/Fixtures/tags/macro/from_reserved_names.test @@ -0,0 +1,13 @@ +--TEST-- +"from" tag +--TEMPLATE-- +{% from _self import input as true %} + +{{ true('username') }} + +{% macro input(name) -%} +{% endmacro %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 2. diff --git a/tests/Fixtures/tags/macro/import_reserved_names.test b/tests/Fixtures/tags/macro/import_reserved_names.test new file mode 100644 index 00000000000..4e32089b283 --- /dev/null +++ b/tests/Fixtures/tags/macro/import_reserved_names.test @@ -0,0 +1,8 @@ +--TEST-- +"import" tag +--TEMPLATE-- +{% import _self as true %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 2. diff --git a/tests/Fixtures/tags/macro/named_arguments.test b/tests/Fixtures/tags/macro/named_arguments.test new file mode 100644 index 00000000000..58bd15b2024 --- /dev/null +++ b/tests/Fixtures/tags/macro/named_arguments.test @@ -0,0 +1,14 @@ +--TEST-- +"macro" tag +--TEMPLATE-- +{% import _self as forms %} + +{{ forms.input(size: 10, name: 'username') }} + +{% macro input(name, value, type, size) %} + +{% endmacro %} +--DATA-- +return [] +--EXPECT-- + diff --git a/tests/Fixtures/tags/sandbox/array.test b/tests/Fixtures/tags/sandbox/array.legacy.test similarity index 73% rename from tests/Fixtures/tags/sandbox/array.test rename to tests/Fixtures/tags/sandbox/array.legacy.test index b432427e4a6..df9d1e405a1 100644 --- a/tests/Fixtures/tags/sandbox/array.test +++ b/tests/Fixtures/tags/sandbox/array.legacy.test @@ -1,5 +1,7 @@ --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" %} diff --git a/tests/Fixtures/tags/sandbox/not_valid1.test b/tests/Fixtures/tags/sandbox/not_valid1.legacy.test similarity index 100% rename from tests/Fixtures/tags/sandbox/not_valid1.test rename to tests/Fixtures/tags/sandbox/not_valid1.legacy.test diff --git a/tests/Fixtures/tags/sandbox/not_valid2.test b/tests/Fixtures/tags/sandbox/not_valid2.legacy.test similarity index 100% rename from tests/Fixtures/tags/sandbox/not_valid2.test rename to tests/Fixtures/tags/sandbox/not_valid2.legacy.test diff --git a/tests/Fixtures/tags/sandbox/simple.test b/tests/Fixtures/tags/sandbox/simple.legacy.test similarity index 55% rename from tests/Fixtures/tags/sandbox/simple.test rename to tests/Fixtures/tags/sandbox/simple.legacy.test index 4d232d8bbd2..7126344753a 100644 --- a/tests/Fixtures/tags/sandbox/simple.test +++ b/tests/Fixtures/tags/sandbox/simple.legacy.test @@ -1,5 +1,9 @@ --TEST-- sandbox tag +--DEPRECATION-- +Since twig/twig 3.15: The "sandbox" tag is deprecated in "index.twig" at line 2. +Since twig/twig 3.15: The "sandbox" tag is deprecated in "index.twig" at line 6. +Since twig/twig 3.15: The "sandbox" tag is deprecated in "index.twig" at line 11. --TEMPLATE-- {%- sandbox %} {%- include "foo.twig" %} diff --git a/tests/Fixtures/tags/set/reserved_names.test b/tests/Fixtures/tags/set/reserved_names.test new file mode 100644 index 00000000000..aa9ad9fb077 --- /dev/null +++ b/tests/Fixtures/tags/set/reserved_names.test @@ -0,0 +1,8 @@ +--TEST-- +"set" tag +--TEMPLATE-- +{% set true = 'foo' %} +--DATA-- +return [] +--EXCEPTION-- +Twig\Error\SyntaxError: You cannot assign a value to "true" in "index.twig" at line 2. diff --git a/tests/Fixtures/tags/use/use_aliased_block_overridden.test b/tests/Fixtures/tags/use/use_aliased_block_overridden.test new file mode 100644 index 00000000000..8396c6f5864 --- /dev/null +++ b/tests/Fixtures/tags/use/use_aliased_block_overridden.test @@ -0,0 +1,21 @@ +--TEST-- +"use" tag with an overridden block that is aliased +--TEMPLATE-- +{% use "blocks.twig" with bar as baz %} + +{% block foo %}{{ parent() }}+{% endblock %} + +{% block baz %}{{ parent() }}+{% endblock %} + +{{ block('foo') }} +{{ block('baz') }} +--TEMPLATE(blocks.twig)-- +{% block foo %}Foo{% endblock %} +{% block bar %}Bar{% endblock %} +--DATA-- +return [] +--EXPECT-- +Foo+ +Bar+ +Foo+ +Bar+ diff --git a/tests/Fixtures/tags/with/with_no_hash.test b/tests/Fixtures/tags/with/with_no_mapping.test similarity index 56% rename from tests/Fixtures/tags/with/with_no_hash.test rename to tests/Fixtures/tags/with/with_no_mapping.test index 7083050b42e..7b22c5171a9 100644 --- a/tests/Fixtures/tags/with/with_no_hash.test +++ b/tests/Fixtures/tags/with/with_no_mapping.test @@ -1,10 +1,10 @@ --TEST-- -"with" tag with an expression that is not a hash +"with" tag with an expression that is not a mapping --TEMPLATE-- {% with vars %} {{ foo }}{{ bar }} {% endwith %} --DATA-- -return ['vars' => 'no-hash'] +return ['vars' => 'no-mapping'] --EXCEPTION-- -Twig\Error\RuntimeError: Variables passed to the "with" tag must be a hash in "index.twig" at line 2. +Twig\Error\RuntimeError: Variables passed to the "with" tag must be a mapping in "index.twig" at line 2. diff --git a/tests/Fixtures/tests/defined_for_attribute.legacy.test b/tests/Fixtures/tests/defined_for_attribute.legacy.test new file mode 100644 index 00000000000..5fd2fe3f2d2 --- /dev/null +++ b/tests/Fixtures/tests/defined_for_attribute.legacy.test @@ -0,0 +1,35 @@ +--TEST-- +"defined" support for attribute +--TEMPLATE-- +{{ attribute(nested, "definedVar") is defined ? 'ok' : 'ko' }} +{{ attribute(nested, "undefinedVar") is not defined ? 'ok' : 'ko' }} +{{ attribute(nested, definedVarName) is defined ? 'ok' : 'ko' }} +{{ attribute(nested, undefinedVarName) is not defined ? 'ok' : 'ko' }} +--DATA-- +return [ + 'nested' => [ + 'definedVar' => 'defined', + ], + 'definedVarName' => 'definedVar', + 'undefinedVarName' => 'undefinedVar', +] +--EXPECT-- +ok +ok +ok +ok +--DATA-- +return [ + 'nested' => [ + 'definedVar' => 'defined', + ], + 'definedVarName' => 'definedVar', + 'undefinedVarName' => 'undefinedVar', +] +--CONFIG-- +return ['strict_variables' => false] +--EXPECT-- +ok +ok +ok +ok diff --git a/tests/Fixtures/tests/defined_for_attribute.test b/tests/Fixtures/tests/defined_for_attribute.test index 5fd2fe3f2d2..6fc6eb68a1e 100644 --- a/tests/Fixtures/tests/defined_for_attribute.test +++ b/tests/Fixtures/tests/defined_for_attribute.test @@ -1,10 +1,10 @@ --TEST-- -"defined" support for attribute +"defined" support for dynamic attribute --TEMPLATE-- -{{ attribute(nested, "definedVar") is defined ? 'ok' : 'ko' }} -{{ attribute(nested, "undefinedVar") is not defined ? 'ok' : 'ko' }} -{{ attribute(nested, definedVarName) is defined ? 'ok' : 'ko' }} -{{ attribute(nested, undefinedVarName) is not defined ? 'ok' : 'ko' }} +{{ nested.("definedVar") is defined ? 'ok' : 'ko' }} +{{ nested.("undefinedVar") is not defined ? 'ok' : 'ko' }} +{{ nested.(definedVarName) is defined ? 'ok' : 'ko' }} +{{ nested.(undefinedVarName) is not defined ? 'ok' : 'ko' }} --DATA-- return [ 'nested' => [ diff --git a/tests/Fixtures/tests/defined_for_macros.test b/tests/Fixtures/tests/defined_for_macros.test index 1aa45fc8268..657b43e1788 100644 --- a/tests/Fixtures/tests/defined_for_macros.test +++ b/tests/Fixtures/tests/defined_for_macros.test @@ -1,41 +1,80 @@ --TEST-- "defined" support for macros --TEMPLATE-- +{% extends 'macros.twig' %} + +{% import 'macros.twig' as macros_ext %} +{% from 'macros.twig' import lol, baz %} {% import _self as macros %} {% from _self import hello, bar %} -{% if macros.hello is defined -%} - OK -{% endif %} +{% block content %} + {{~ macros.hello is defined ? 'OK' : 'KO' }} + {{~ macros.hello() is defined ? 'OK' : 'KO' }} + + {{~ macros_ext.lol is defined ? 'OK' : 'KO' }} + {{~ macros_ext.lol() is defined ? 'OK' : 'KO' }} + + {{~ macros.foo is not defined ? 'OK' : 'KO' }} + {{~ macros.foo() is not defined ? 'OK' : 'KO' }} + + {{~ macros_ext.hello is not defined ? 'OK' : 'KO' }} + {{~ macros_ext.hello() is not defined ? 'OK' : 'KO' }} + + {{~ hello is defined ? 'OK' : 'KO' }} + {{~ hello() is defined ? 'OK' : 'KO' }} + + {{~ lol is defined ? 'OK' : 'KO' }} + {{~ lol() is defined ? 'OK' : 'KO' }} + + {{~ baz is not defined ? 'OK' : 'KO' }} + {{~ baz() is not defined ? 'OK' : 'KO' }} -{% if macros.foo is not defined -%} - OK -{% endif %} + {{~ _self.hello is defined ? 'OK' : 'KO' }} + {{~ _self.hello() is defined ? 'OK' : 'KO' }} -{% if hello is defined -%} - OK -{% endif %} + {{~ _self.bar is not defined ? 'OK' : 'KO' }} + {{~ _self.bar() is not defined ? 'OK' : 'KO' }} -{% if bar is not defined -%} - OK -{% endif %} + {{~ _self.lol is defined ? 'OK' : 'KO' }} + {{~ _self.lol() is defined ? 'OK' : 'KO' }} +{% endblock %} -{% if foo is not defined -%} - OK -{% endif %} +{% macro hello(name) %}{% endmacro %} +--TEMPLATE(macros.twig)-- +{% block content %} +{% endblock %} -{% macro hello(name) %} - Hello {{ name }} -{% endmacro %} +{% macro lol(name) -%}{% endmacro %} --DATA-- return [] --EXPECT-- OK +OK +OK OK +OK OK +OK +OK + +OK OK OK +OK + +OK +OK + +OK +OK + +OK +OK + +OK +OK diff --git a/tests/Fixtures/tests/mapping.test b/tests/Fixtures/tests/mapping.test new file mode 100644 index 00000000000..3e4fce048aa --- /dev/null +++ b/tests/Fixtures/tests/mapping.test @@ -0,0 +1,38 @@ +--TEST-- +"mapping" test +--TEMPLATE-- +{{ empty is mapping ? 'ok' : 'ko' }} +{{ sequence is mapping ? 'ok' : 'ko' }} +{{ empty_array_obj is mapping ? 'ok' : 'ko' }} +{{ sequence_array_obj is mapping ? 'ok' : 'ko' }} +{{ mapping_array_obj is mapping ? 'ok' : 'ko' }} +{{ obj is mapping ? 'ok' : 'ko' }} +{{ mapping is mapping ? 'ok' : 'ko' }} +{{ string is mapping ? 'ok' : 'ko' }} +--DATA-- +return [ + 'empty' => [], + 'sequence' => [ + 'foo', + 'bar', + 'baz' + ], + 'empty_array_obj' => new \ArrayObject(), + 'sequence_array_obj' => new \ArrayObject(['foo', 'bar']), + 'mapping_array_obj' => new \ArrayObject(['foo' => 'bar']), + 'obj' => new \stdClass(), + 'mapping' => [ + 'foo' => 'bar', + 'bar' => 'foo' + ], + 'string' => 'test', +] +--EXPECT-- +ko +ko +ko +ko +ok +ok +ok +ko diff --git a/tests/Fixtures/tests/null_coalesce.legacy.test b/tests/Fixtures/tests/null_coalesce.legacy.test new file mode 100644 index 00000000000..4aaec83743a --- /dev/null +++ b/tests/Fixtures/tests/null_coalesce.legacy.test @@ -0,0 +1,42 @@ +--TEST-- +Twig supports the ?? operator +--DEPRECATION-- +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 #} + +{{ 1 + nope ?? nada ?? 2 }} +{{ 1 + nope ?? + 3 + nada ?? 2 }} +{{ 1 ~ 'notnull' ?? 'foo' ~ '_bar' }} +{{ + 1 ~ 2 + 3 + ?? + 1 ~ + 2 + 4 +}} +{{ ( + 1 ~ 2 + 3 + ?? + 1 ~ + 2 + 4 + ) +}} +--DATA-- +return [] +--EXPECT-- +OK +3 +6 +1notnull_bar +48 +48 diff --git a/tests/Fixtures/tests/null_coalesce.test b/tests/Fixtures/tests/null_coalesce.test index 7af3255d61d..685415758bb 100644 --- a/tests/Fixtures/tests/null_coalesce.test +++ b/tests/Fixtures/tests/null_coalesce.test @@ -10,11 +10,14 @@ Twig supports the ?? operator {{ foo.bar.baz.missing ?? 'OK' }} {{ foo['bar'] ?? 'KO' }} {{ foo['missing'] ?? 'OK' }} -{{ nope ?? nada ?? 'OK' }} -{{ 1 + nope ?? nada ?? 2 }} -{{ 1 + nope ?? 3 + nada ?? 2 }} +{{ nope ?? (nada ?? 'OK') }} +{{ 1 + (nope ?? (nada ?? 2)) }} +{{ 1 + (nope ?? 3) + (nada ?? 2) }} +{{ obj.null() ?? 'OK' }} +{{ obj.empty() ?? 'KO' }} +{{ tag ?? 'KO' }} --DATA-- -return ['bar' => 'OK', 'foo' => ['bar' => 'OK']] +return ['bar' => 'OK', 'foo' => ['bar' => 'OK'], 'obj' => new Twig\Tests\TwigTestFoo(), 'tag' => '
    '] --EXPECT-- OK OK @@ -28,3 +31,6 @@ OK OK 3 6 +OK + +<br> diff --git a/tests/Fixtures/tests/sequence.test b/tests/Fixtures/tests/sequence.test new file mode 100644 index 00000000000..fb8a31212d3 --- /dev/null +++ b/tests/Fixtures/tests/sequence.test @@ -0,0 +1,38 @@ +--TEST-- +"sequence" test +--TEMPLATE-- +{{ empty is sequence ? 'ok' : 'ko' }} +{{ sequence is sequence ? 'ok' : 'ko' }} +{{ empty_array_obj is sequence ? 'ok' : 'ko' }} +{{ sequence_array_obj is sequence ? 'ok' : 'ko' }} +{{ mapping_array_obj is sequence ? 'ok' : 'ko' }} +{{ obj is sequence ? 'ok' : 'ko' }} +{{ mapping is sequence ? 'ok' : 'ko' }} +{{ string is sequence ? 'ok' : 'ko' }} +--DATA-- +return [ + 'empty' => [], + 'sequence' => [ + 'foo', + 'bar', + 'baz' + ], + 'empty_array_obj' => new \ArrayObject(), + 'sequence_array_obj' => new \ArrayObject(['foo', 'bar']), + 'mapping_array_obj' => new \ArrayObject(['foo' => 'bar']), + 'obj' => new \stdClass(), + 'mapping' => [ + 'foo' => 'bar', + 'bar' => 'foo' + ], + 'string' => 'test', +] +--EXPECT-- +ok +ok +ok +ok +ko +ko +ko +ko diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 893bda345e8..c00f8154dd5 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -1,5 +1,14 @@ ''); + } + + return false; + }, + ]; + } + + protected function getUndefinedFilterCallbacks(): array + { + return [ + static function (string $name) { + if ('throwing_undefined_filter' === $name) { + throw new SyntaxError('This filter is undefined in the tests.'); + } + + return false; + }, + ]; + } + + protected static function getFixturesDirectory(): string { return __DIR__.'/Fixtures/'; } @@ -65,6 +124,8 @@ class TwigTestFoo implements \Iterator public $position = 0; public $array = [1, 2]; + public static $foo = 'Foo'; + public function bar($param1 = null, $param2 = null) { return 'bar'.($param1 ? '_'.$param1 : '').($param2 ? '-'.$param2 : ''); @@ -75,6 +136,16 @@ public function getFoo() return 'foo'; } + public function getEmpty() + { + return ''; + } + + public function getNull() + { + return null; + } + public function getSelf() { return $this; @@ -105,18 +176,12 @@ public function rewind(): void $this->position = 0; } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function current() { return $this->array[$this->position]; } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function key() { @@ -190,6 +255,7 @@ public function getFunctions(): array new TwigFunction('*_path', [$this, 'dynamic_path']), new TwigFunction('*_foo_*_bar', [$this, 'dynamic_foo']), new TwigFunction('anon_foo', function ($name) { return '*'.$name.'*'; }), + new TwigFunction('deprecated_function', function () { return 'foo'; }, ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1', 'not_deprecated_function')]), ]; } @@ -221,7 +287,7 @@ public function §Function($value) */ public function escape_and_nl2br($env, $value, $sep = '
    ') { - return $this->nl2br(twig_escape_filter($env, $value, 'html'), $sep); + return $this->nl2br($env->getRuntime(EscaperRuntime::class)->escape($value, 'html'), $sep); } /** @@ -271,13 +337,13 @@ public function br() public function is_multi_word($value) { - return false !== strpos($value, ' '); + return str_contains($value, ' '); } public function __call($method, $arguments) { if ('magicCall' !== $method) { - throw new \BadMethodCallException('Unexpected call to __call'); + throw new \BadMethodCallException('Unexpected call to __call.'); } return 'magic_'.$arguments[0]; @@ -286,7 +352,7 @@ public function __call($method, $arguments) public static function __callStatic($method, $arguments) { if ('magicStaticCall' !== $method) { - throw new \BadMethodCallException('Unexpected call to __callStatic'); + throw new \BadMethodCallException('Unexpected call to __callStatic.'); } return 'static_magic_'.$arguments[0]; @@ -301,7 +367,7 @@ class MagicCallStub { public function __call($name, $args) { - throw new \Exception('__call shall not be called'); + throw new \Exception('__call shall not be called.'); } } @@ -344,7 +410,7 @@ public function count(): int public function __toString() { - throw new \Exception('__toString shall not be called on \Countables'); + throw new \Exception('__toString shall not be called on \Countables.'); } } @@ -371,9 +437,6 @@ class SimpleIteratorForTesting implements \Iterator private $data = [1, 2, 3, 4, 5, 6, 7]; private $key = 0; - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function current() { @@ -385,9 +448,6 @@ public function next(): void ++$this->key; } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function key() { diff --git a/tests/LexerTest.php b/tests/LexerTest.php index fdb58c2d5b6..ffc0eff01d6 100644 --- a/tests/LexerTest.php +++ b/tests/LexerTest.php @@ -1,5 +1,14 @@ createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::BLOCK_START_TYPE); @@ -36,7 +48,7 @@ public function testNameLabelForFunction() { $template = '{{ §() }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); @@ -53,13 +65,13 @@ public function testBracketsNesting() protected function countToken($template, $type, $value = null) { - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $count = 0; while (!$stream->isEOF()) { $token = $stream->next(); - if ($type === $token->getType()) { + if ($token->test($type)) { if (null === $value || $value === $token->getValue()) { ++$count; } @@ -78,7 +90,7 @@ public function testLineDirective() ."baz\n" ."}}\n"; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); // foo\nbar\n @@ -98,7 +110,7 @@ public function testLineDirectiveInline() ."baz\n" ."}}\n"; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); // foo\nbar @@ -113,7 +125,7 @@ public function testLongComments() { $template = '{# '.str_repeat('*', 100000).' #}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $lexer->tokenize(new Source($template, 'index')); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above @@ -125,7 +137,7 @@ public function testLongVerbatim() { $template = '{% verbatim %}'.str_repeat('*', 100000).'{% endverbatim %}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $lexer->tokenize(new Source($template, 'index')); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above @@ -137,7 +149,7 @@ public function testLongVar() { $template = '{{ '.str_repeat('x', 100000).' }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $lexer->tokenize(new Source($template, 'index')); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above @@ -149,7 +161,7 @@ public function testLongBlock() { $template = '{% '.str_repeat('x', 100000).' %}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $lexer->tokenize(new Source($template, 'index')); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above @@ -161,37 +173,138 @@ public function testBigNumbers() { $template = '{{ 922337203685477580700 }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->next(); $node = $stream->next(); $this->assertEquals('922337203685477580700', $node->getValue()); } - public function testStringWithEscapedDelimiter() + /** + * @dataProvider getStringWithEscapedDelimiter + */ + public function testStringWithEscapedDelimiter(string $template, string $expected) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $token = $stream->expect(Token::STRING_TYPE); + $this->assertSame($expected, $token->getValue()); + } + + public static function getStringWithEscapedDelimiter() { - $tests = [ - "{{ 'foo \' bar' }}" => 'foo \' bar', - '{{ "foo \" bar" }}' => 'foo " bar', + yield [ + <<<'EOF' + {{ '\x6' }} + EOF, + "\x6", + ]; + yield [ + <<<'EOF' + {{ '\065\x64' }} + EOF, + "\065\x64", + ]; + yield [ + <<<'EOF' + {{ 'App\\Test' }} + EOF, + 'App\\Test', ]; + yield [ + <<<'EOF' + {{ "App\#{var}" }} + EOF, + 'App#{var}', + ]; + yield [ + <<<'EOF' + {{ 'foo \' bar' }} + EOF, + <<<'EOF' + foo ' bar + EOF, + ]; + yield [ + <<<'EOF' + {{ "foo \" bar" }} + EOF, + 'foo " bar', + ]; + yield [ + <<<'EOF' + {{ '\f\n\r\t\v' }} + EOF, + "\f\n\r\t\v", + ]; + yield [ + <<<'EOF' + {{ '\\f\\n\\r\\t\\v' }} + EOF, + '\\f\\n\\r\\t\\v', + ]; + yield [ + <<<'EOF' + {{ 'Ymd\\THis' }} + EOF, + <<<'EOF' + Ymd\THis + EOF, + ]; + } - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); - foreach ($tests as $template => $expected) { - $stream = $lexer->tokenize(new Source($template, 'index')); - $stream->expect(Token::VAR_START_TYPE); - $stream->expect(Token::STRING_TYPE, $expected); + /** + * @group legacy + * + * @dataProvider getStringWithEscapedDelimiterProducingDeprecation + */ + public function testStringWithEscapedDelimiterProducingDeprecation(string $template, string $expected, string $expectedDeprecation) + { + $this->expectDeprecation($expectedDeprecation); - // 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 - $this->addToAssertionCount(1); - } + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, $expected); + + // 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 + $this->addToAssertionCount(1); + } + + public static function getStringWithEscapedDelimiterProducingDeprecation() + { + yield [ + <<<'EOF' + {{ 'App\Test' }} + EOF, + 'AppTest', + 'Since twig/twig 3.12: Character "T" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position 5 in "index" at line 1.', + ]; + yield [ + <<<'EOF' + {{ "foo \' bar" }} + EOF, + <<<'EOF' + foo ' bar + EOF, + 'Since twig/twig 3.12: Character "\'" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position 6 in "index" at line 1.', + ]; + yield [ + <<<'EOF' + {{ 'foo \" bar' }} + EOF, + 'foo " bar', + 'Since twig/twig 3.12: Character """ should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position 6 in "index" at line 1.', + ]; } public function testStringWithInterpolation() { $template = 'foo {{ "bar #{ baz + 1 }" }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::TEXT_TYPE, 'foo '); $stream->expect(Token::VAR_START_TYPE); @@ -212,7 +325,7 @@ public function testStringWithEscapedInterpolation() { $template = '{{ "bar \#{baz+1}" }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::STRING_TYPE, 'bar #{baz+1}'); @@ -227,7 +340,7 @@ public function testStringWithHash() { $template = '{{ "bar # baz" }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::STRING_TYPE, 'bar # baz'); @@ -240,12 +353,12 @@ public function testStringWithHash() public function testStringWithUnterminatedInterpolation() { + $template = '{{ "bar #{x" }}'; + $lexer = new Lexer(new Environment(new ArrayLoader())); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unclosed """'); - $template = '{{ "bar #{x" }}'; - - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); $lexer->tokenize(new Source($template, 'index')); } @@ -253,7 +366,7 @@ public function testStringWithNestedInterpolations() { $template = '{{ "bar #{ "foo#{bar}" }" }}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::STRING_TYPE, 'bar '); @@ -274,7 +387,7 @@ public function testStringWithNestedInterpolationsInBlock() { $template = '{% foo "bar #{ "foo#{bar}" }" %}'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::BLOCK_START_TYPE); $stream->expect(Token::NAME_TYPE, 'foo'); @@ -296,7 +409,7 @@ public function testOperatorEndingWithALetterAtTheEndOfALine() { $template = "{{ 1 and\n0}}"; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::NUMBER_TYPE, 1); @@ -309,9 +422,6 @@ public function testOperatorEndingWithALetterAtTheEndOfALine() public function testUnterminatedVariable() { - $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Unclosed "variable" in "index" at line 3'); - $template = ' {{ @@ -321,15 +431,15 @@ public function testUnterminatedVariable() '; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unclosed "variable" in "index" at line 3'); $lexer->tokenize(new Source($template, 'index')); } public function testUnterminatedBlock() { - $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Unclosed "block" in "index" at line 3'); - $template = ' {% @@ -339,14 +449,18 @@ public function testUnterminatedBlock() '; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class))); + $lexer = new Lexer(new Environment(new ArrayLoader())); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unclosed "block" in "index" at line 3'); + $lexer->tokenize(new Source($template, 'index')); } public function testOverridingSyntax() { $template = '[# comment #]{# variable #}/# if true #/true/# endif #/'; - $lexer = new Lexer(new Environment($this->createMock(LoaderInterface::class)), [ + $lexer = new Lexer(new Environment(new ArrayLoader()), [ 'tag_comment' => ['[#', '#]'], 'tag_block' => ['/#', '#/'], 'tag_variable' => ['{#', '#}'], @@ -368,4 +482,195 @@ public function testOverridingSyntax() // can be executed without throwing any exceptions $this->addToAssertionCount(1); } + + /** + * @dataProvider getTemplateForErrorsAtTheEndOfTheStream + */ + public function testErrorsAtTheEndOfTheStream(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + set_error_handler(function () { + $this->fail('Lexer should not emit warnings.'); + }); + try { + $lexer->tokenize(new Source($template, 'index')); + $this->addToAssertionCount(1); + } finally { + restore_error_handler(); + } + } + + public static function getTemplateForErrorsAtTheEndOfTheStream() + { + yield ['{{ =']; + yield ['{{ ..']; + } + + /** + * @dataProvider getTemplateForStrings + */ + public function testStrings(string $expected) + { + $template = '{{ "'.$expected.'" }}'; + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, $expected); + + $template = "{{ '".$expected."' }}"; + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, $expected); + + // 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 + $this->addToAssertionCount(1); + } + + public static function getTemplateForStrings() + { + yield ['日本では、春になると桜の花が咲きます。多くの人々は、公園や川の近くに集まり、お花見を楽しみます。桜の花びらが風に舞い、まるで雪のように見える瞬間は、とても美しいです。']; + yield ['في العالم العربي، يُعتبر الخط العربي أحد أجمل أشكال الفن. يُستخدم الخط في تزيين المساجد والكتب والمخطوطات القديمة. يتميز الخط العربي بجماله وتناسقه، ويُعتبر رمزًا للثقافة الإسلامية.']; + } + + public function testInlineCommentWithHashInString() + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source('{{ "me # this is NOT an inline comment" }}', 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, 'me # this is NOT an inline comment'); + $stream->expect(Token::VAR_END_TYPE); + $this->assertTrue($stream->isEOF()); + } + + /** + * @dataProvider getTemplateForInlineCommentsForVariable + */ + public function testInlineCommentForVariable(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::VAR_START_TYPE); + $stream->expect(Token::STRING_TYPE, 'me'); + $stream->expect(Token::VAR_END_TYPE); + $this->assertTrue($stream->isEOF()); + } + + public static function getTemplateForInlineCommentsForVariable() + { + yield ['{{ + "me" + # this is an inline comment + }}']; + yield ['{{ + # this is an inline comment + "me" + }}']; + yield ['{{ + "me" # this is an inline comment + }}']; + yield ['{{ + # this is an inline comment + "me" # this is an inline comment + # this is an inline comment + }}']; + } + + /** + * @dataProvider getTemplateForInlineCommentsForBlock + */ + public function testInlineCommentForBlock(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $stream->expect(Token::BLOCK_START_TYPE); + $stream->expect(Token::NAME_TYPE, 'if'); + $stream->expect(Token::NAME_TYPE, 'true'); + $stream->expect(Token::BLOCK_END_TYPE); + $stream->expect(Token::TEXT_TYPE, 'me'); + $stream->expect(Token::BLOCK_START_TYPE); + $stream->expect(Token::NAME_TYPE, 'endif'); + $stream->expect(Token::BLOCK_END_TYPE); + $this->assertTrue($stream->isEOF()); + } + + public static function getTemplateForInlineCommentsForBlock() + { + yield ['{% + if true + # this is an inline comment + %}me{% endif %}']; + yield ['{% + # this is an inline comment + if true + %}me{% endif %}']; + yield ['{% + if true # this is an inline comment + %}me{% endif %}']; + yield ['{% + # this is an inline comment + if true # this is an inline comment + # this is an inline comment + %}me{% endif %}']; + } + + /** + * @dataProvider getTemplateForInlineCommentsForComment + */ + public function testInlineCommentForComment(string $template) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + $this->assertTrue($stream->isEOF()); + } + + public static function getTemplateForInlineCommentsForComment() + { + yield ['{# + Some regular comment # this is an inline comment + #}']; + } + + /** + * @dataProvider getTemplateForUnclosedBracketInExpression + */ + public function testUnclosedBracketInExpression(string $template, string $bracket) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage(\sprintf('Unclosed "%s" in "index" at line 1.', $bracket)); + + $lexer->tokenize(new Source($template, 'index')); + } + + public static function getTemplateForUnclosedBracketInExpression() + { + yield ['{{ (1 + 3 }}', '(']; + yield ['{{ obj["a" }}', '[']; + yield ['{{ ({ a: 1) }}', '{']; + yield ['{{ (([1]) + 3 }}', '(']; + } + + /** + * @dataProvider getTemplateForUnexpectedBracketInExpression + */ + public function testUnexpectedBracketInExpression(string $template, string $bracket) + { + $lexer = new Lexer(new Environment(new ArrayLoader())); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage(\sprintf('Unexpected "%s" in "index" at line 1.', $bracket)); + + $lexer->tokenize(new Source($template, 'index')); + } + + public static function getTemplateForUnexpectedBracketInExpression() + { + yield ['{{ 1 + 3) }}', ')']; + yield ['{{ obj] }}', ']']; + yield ['{{ { a: 1 }}', '}']; + yield ['{{ ([1] + 3)) }}', ')']; + } } diff --git a/tests/Loader/ArrayTest.php b/tests/Loader/ArrayTest.php index 76714bb0858..fd04dadb981 100644 --- a/tests/Loader/ArrayTest.php +++ b/tests/Loader/ArrayTest.php @@ -1,5 +1,14 @@ expectException(LoaderError::class); - - $loader = new ArrayLoader([]); + $loader = new ArrayLoader(); + $this->expectException(LoaderError::class); $loader->getSourceContext('foo'); } @@ -57,16 +65,15 @@ public function testGetCacheKeyIsProtectedFromEdgeCollisions() public function testGetCacheKeyWhenTemplateDoesNotExist() { - $this->expectException(LoaderError::class); - - $loader = new ArrayLoader([]); + $loader = new ArrayLoader(); + $this->expectException(LoaderError::class); $loader->getCacheKey('foo'); } public function testSetTemplate() { - $loader = new ArrayLoader([]); + $loader = new ArrayLoader(); $loader->setTemplate('foo', 'bar'); $this->assertEquals('bar', $loader->getSourceContext('foo')->getCode()); @@ -80,10 +87,9 @@ public function testIsFresh() public function testIsFreshWhenTemplateDoesNotExist() { - $this->expectException(LoaderError::class); - - $loader = new ArrayLoader([]); + $loader = new ArrayLoader(); + $this->expectException(LoaderError::class); $loader->isFresh('foo', time()); } } diff --git a/tests/Loader/ChainTest.php b/tests/Loader/ChainTest.php index faaaebe33ae..473ba768d03 100644 --- a/tests/Loader/ChainTest.php +++ b/tests/Loader/ChainTest.php @@ -1,5 +1,14 @@ expectException(LoaderError::class); - $loader = new ChainLoader([]); + $this->expectException(LoaderError::class); $loader->getSourceContext('foo'); } @@ -63,19 +71,38 @@ public function testGetCacheKey() public function testGetCacheKeyWhenTemplateDoesNotExist() { - $this->expectException(LoaderError::class); - $loader = new ChainLoader([]); + $this->expectException(LoaderError::class); $loader->getCacheKey('foo'); } public function testAddLoader() { - $loader = new ChainLoader(); - $loader->addLoader(new ArrayLoader(['foo' => 'bar'])); - - $this->assertEquals('bar', $loader->getSourceContext('foo')->getCode()); + $fooLoader = new ArrayLoader(['foo' => 'foo:code']); + $barLoader = new ArrayLoader(['bar' => 'bar:code']); + $bazLoader = new ArrayLoader(['baz' => 'baz:code']); + $quxLoader = new ArrayLoader(['qux' => 'qux:code']); + + $loader = new ChainLoader((static function () use ($fooLoader, $barLoader): \Generator { + yield $fooLoader; + yield $barLoader; + })()); + + $loader->addLoader($bazLoader); + $loader->addLoader($quxLoader); + + $this->assertEquals('foo:code', $loader->getSourceContext('foo')->getCode()); + $this->assertEquals('bar:code', $loader->getSourceContext('bar')->getCode()); + $this->assertEquals('baz:code', $loader->getSourceContext('baz')->getCode()); + $this->assertEquals('qux:code', $loader->getSourceContext('qux')->getCode()); + + $this->assertEquals([ + $fooLoader, + $barLoader, + $bazLoader, + $quxLoader, + ], $loader->getLoaders()); } public function testExists() diff --git a/tests/Loader/FilesystemTest.php b/tests/Loader/FilesystemTest.php index 44b3c170d0f..f4c0b972c3e 100644 --- a/tests/Loader/FilesystemTest.php +++ b/tests/Loader/FilesystemTest.php @@ -1,5 +1,14 @@ assertEquals("named path (final)\n", $loader->getSourceContext('@named/index.html')->getCode()); } - public function getBasePaths() + public static function getBasePaths() { return [ [ @@ -197,7 +206,7 @@ public function testLoadTemplateAndRenderBlockWithCache() $this->assertSame('block from theme 2', $template->renderBlock('b2', [])); } - public function getArrayInheritanceTests() + public static function getArrayInheritanceTests() { return [ 'valid array inheritance' => ['array_inheritance_valid_parent.html.twig'], diff --git a/tests/Node/AutoEscapeTest.php b/tests/Node/AutoEscapeTest.php index d0f641c083c..18da5aae2a6 100644 --- a/tests/Node/AutoEscapeTest.php +++ b/tests/Node/AutoEscapeTest.php @@ -1,5 +1,14 @@ assertEquals($body, $node->getNode('body')); $this->assertTrue($node->getAttribute('value')); } - public function getTests() + public static function provideTests(): iterable { - $body = new Node([new TextNode('foo', 1)]); + $body = new Nodes([new TextNode('foo', 1)]); $node = new AutoEscapeNode(true, $body, 1); return [ - [$node, "// line 1\necho \"foo\";"], + [$node, "// line 1\nyield \"foo\";"], ]; } } diff --git a/tests/Node/BlockReferenceTest.php b/tests/Node/BlockReferenceTest.php index 63dc0707c78..04a6b0ec360 100644 --- a/tests/Node/BlockReferenceTest.php +++ b/tests/Node/BlockReferenceTest.php @@ -1,5 +1,14 @@ assertEquals('foo', $node->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { return [ - [new BlockReferenceNode('foo', 1), <<displayBlock('foo', \$context, \$blocks); +yield from $this->unwrap()->yieldBlock('foo', $context, $blocks); EOF ], ]; diff --git a/tests/Node/BlockTest.php b/tests/Node/BlockTest.php index 8c0345885d0..6b2f6b006c7 100644 --- a/tests/Node/BlockTest.php +++ b/tests/Node/BlockTest.php @@ -1,5 +1,14 @@ assertEquals('foo', $node->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { - $body = new TextNode('foo', 1); - $node = new BlockNode('foo', $body, 1); - - return [ - [$node, << + */ +public function block_foo(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; - echo "foo"; + yield "foo"; + yield from []; } EOF - ], + , new Environment(new ArrayLoader()), ]; + + return $tests; } } diff --git a/tests/Node/DeprecatedTest.php b/tests/Node/DeprecatedTest.php index 24185f4b573..9b265a56187 100644 --- a/tests/Node/DeprecatedTest.php +++ b/tests/Node/DeprecatedTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('expr')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; $expr = new ConstantExpression('This section is deprecated', 1); - $node = new DeprecatedNode($expr, 1, 'deprecated'); + $node = new DeprecatedNode($expr, 1); $node->setSourceContext(new Source('', 'foo.twig')); + $node->setNode('package', new ConstantExpression('twig/twig', 1)); + $node->setNode('version', new ConstantExpression('1.1', 1)); $tests[] = [$node, <<setSourceContext(new Source('', 'foo.twig')); + $dep->setNode('package', new ConstantExpression('twig/twig', 1)); + $dep->setNode('version', new ConstantExpression('1.1', 1)); $tests[] = [$node, <<createMock(LoaderInterface::class)); - $environment->addFunction(new TwigFunction('foo', 'foo', [])); + $environment = new Environment(new ArrayLoader()); + $environment->addFunction($function = new TwigFunction('foo', 'Twig\Tests\Node\foo', [])); - $expr = new FunctionExpression('foo', new Node(), 1); - $node = new DeprecatedNode($expr, 1, 'deprecated'); + $expr = new FunctionExpression($function, new EmptyNode(), 1); + $node = new DeprecatedNode($expr, 1); $node->setSourceContext(new Source('', 'foo.twig')); + $node->setNode('package', new ConstantExpression('twig/twig', 1)); + $node->setNode('version', new ConstantExpression('1.1', 1)); - $compiler = $this->getCompiler($environment); + $compiler = new Compiler($environment); $varName = $compiler->getVarName(); $tests[] = [$node, <<assertEquals($expr, $node->getNode('expr')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; diff --git a/tests/Node/EmbedTest.php b/tests/Node/EmbedTest.php new file mode 100644 index 00000000000..13ff588d4f2 --- /dev/null +++ b/tests/Node/EmbedTest.php @@ -0,0 +1,91 @@ +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 --git a/tests/Node/Expression/ArrayTest.php b/tests/Node/Expression/ArrayTest.php index cfd9c67f3cb..403a0920270 100644 --- a/tests/Node/Expression/ArrayTest.php +++ b/tests/Node/Expression/ArrayTest.php @@ -1,5 +1,14 @@ assertEquals($foo, $node->getNode(1)); + $this->assertEquals($foo, $node->getNode('1')); } - public function getTests() + public static function provideTests(): iterable { $elements = [ new ConstantExpression('foo', 1), diff --git a/tests/Node/Expression/Binary/AddTest.php b/tests/Node/Expression/Binary/AddTest.php index 5cff2bcff1d..c249c2f5526 100644 --- a/tests/Node/Expression/Binary/AddTest.php +++ b/tests/Node/Expression/Binary/AddTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/AndTest.php b/tests/Node/Expression/Binary/AndTest.php index d83aed04d96..fc0ca9a501c 100644 --- a/tests/Node/Expression/Binary/AndTest.php +++ b/tests/Node/Expression/Binary/AndTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/ConcatTest.php b/tests/Node/Expression/Binary/ConcatTest.php index 0eff603ba28..1d738bbd2d3 100644 --- a/tests/Node/Expression/Binary/ConcatTest.php +++ b/tests/Node/Expression/Binary/ConcatTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/DivTest.php b/tests/Node/Expression/Binary/DivTest.php index 20cf4646f85..72aa644d2df 100644 --- a/tests/Node/Expression/Binary/DivTest.php +++ b/tests/Node/Expression/Binary/DivTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/FloorDivTest.php b/tests/Node/Expression/Binary/FloorDivTest.php index 826859851bb..e27b5df5725 100644 --- a/tests/Node/Expression/Binary/FloorDivTest.php +++ b/tests/Node/Expression/Binary/FloorDivTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/ModTest.php b/tests/Node/Expression/Binary/ModTest.php index 2069ef08950..0897f3eb3c1 100644 --- a/tests/Node/Expression/Binary/ModTest.php +++ b/tests/Node/Expression/Binary/ModTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/MulTest.php b/tests/Node/Expression/Binary/MulTest.php index c50dfc12b1b..1f85b70cd6d 100644 --- a/tests/Node/Expression/Binary/MulTest.php +++ b/tests/Node/Expression/Binary/MulTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/NullCoalesceTest.php b/tests/Node/Expression/Binary/NullCoalesceTest.php new file mode 100644 index 00000000000..ccefaaeeda3 --- /dev/null +++ b/tests/Node/Expression/Binary/NullCoalesceTest.php @@ -0,0 +1,38 @@ +assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/Binary/SubTest.php b/tests/Node/Expression/Binary/SubTest.php index 04eebe290d6..a74c8f745d4 100644 --- a/tests/Node/Expression/Binary/SubTest.php +++ b/tests/Node/Expression/Binary/SubTest.php @@ -1,5 +1,14 @@ assertEquals($right, $node->getNode('right')); } - public function getTests() + public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); diff --git a/tests/Node/Expression/CallTest.php b/tests/Node/Expression/CallTest.php index 10fa00bea6e..08e65228fce 100644 --- a/tests/Node/Expression/CallTest.php +++ b/tests/Node/Expression/CallTest.php @@ -1,5 +1,14 @@ 'function', 'name' => 'date']); + $node = $this->createFunctionExpression('date', 'date'); $this->assertEquals(['U', null], $this->getArguments($node, ['date', ['format' => 'U', 'timestamp' => null]])); } public function testGetArgumentsWhenPositionalArgumentsAfterNamedArguments() { + $node = $this->createFunctionExpression('date', 'date'); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Positional arguments cannot be used after named arguments for function "date".'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'date']); $this->getArguments($node, ['date', ['timestamp' => 123456, 'Y-m-d']]); } public function testGetArgumentsWhenArgumentIsDefinedTwice() { + $node = $this->createFunctionExpression('date', 'date'); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Argument "format" is defined twice for function "date".'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'date']); $this->getArguments($node, ['date', ['Y-m-d', 'format' => 'U']]); } public function testGetArgumentsWithWrongNamedArgumentName() { + $node = $this->createFunctionExpression('date', 'date'); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown argument "unknown" for function "date(format, timestamp)".'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'date']); $this->getArguments($node, ['date', ['Y-m-d', 'timestamp' => null, 'unknown' => '']]); } public function testGetArgumentsWithWrongNamedArgumentNames() { + $node = $this->createFunctionExpression('date', 'date'); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown arguments "unknown1", "unknown2" for function "date(format, timestamp)".'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'date']); $this->getArguments($node, ['date', ['Y-m-d', 'timestamp' => null, 'unknown1' => '', 'unknown2' => '']]); } @@ -65,88 +83,94 @@ public function testResolveArgumentsWithMissingValueForOptionalArgument() $this->markTestSkipped('substr_compare() has a default value in 8.0, so the test does not work anymore, one should find another PHP built-in function for this test to work in PHP 8.'); } + $node = $this->createFunctionExpression('substr_compare', 'substr_compare'); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Argument "case_sensitivity" could not be assigned for function "substr_compare(main_str, str, offset, length, case_sensitivity)" because it is mapped to an internal PHP function which cannot determine default value for optional argument "length".'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'substr_compare']); $this->getArguments($node, ['substr_compare', ['abcd', 'bc', 'offset' => 1, 'case_sensitivity' => true]]); } public function testResolveArgumentsOnlyNecessaryArgumentsForCustomFunction() { - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'custom_function']); - + $node = $this->createFunctionExpression('custom_function', [$this, 'customFunction']); $this->assertEquals(['arg1'], $this->getArguments($node, [[$this, 'customFunction'], ['arg1' => 'arg1']])); } public function testGetArgumentsForStaticMethod() { - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'custom_static_function']); + $node = $this->createFunctionExpression('custom_static_function', __CLASS__.'::customStaticFunction'); $this->assertEquals(['arg1'], $this->getArguments($node, [__CLASS__.'::customStaticFunction', ['arg1' => 'arg1']])); } public function testResolveArgumentsWithMissingParameterForArbitraryArguments() { + $node = $this->createFunctionExpression('foo', [$this, 'customFunctionWithArbitraryArguments'], true); + $this->expectException(\LogicException::class); $this->expectExceptionMessage('The last parameter of "Twig\\Tests\\Node\\Expression\\CallTest::customFunctionWithArbitraryArguments" for function "foo" must be an array with default value, eg. "array $arg = []".'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'foo', 'is_variadic' => true]); $this->getArguments($node, [[$this, 'customFunctionWithArbitraryArguments'], []]); } public function testGetArgumentsWithInvalidCallable() { + $node = $this->createFunctionExpression('foo', '', true); + $this->expectException(\LogicException::class); $this->expectExceptionMessage('Callback for function "foo" is not callable in the current scope.'); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'foo', 'is_variadic' => true]); + $this->getArguments($node, ['', []]); } - public static function customStaticFunction($arg1, $arg2 = 'default', $arg3 = []) + public function testResolveArgumentsWithMissingParameterForArbitraryArgumentsOnFunction() { + $node = $this->createFunctionExpression('foo', 'Twig\Tests\Node\Expression\custom_call_test_function', true); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('#^The last parameter of "Twig\\\\Tests\\\\Node\\\\Expression\\\\custom_call_test_function" for function "foo" must be an array with default value, eg\\. "array \\$arg \\= \\[\\]"\\.$#'); + + $this->getArguments($node, ['Twig\Tests\Node\Expression\custom_call_test_function', []]); } - public function customFunction($arg1, $arg2 = 'default', $arg3 = []) + public function testResolveArgumentsWithMissingParameterForArbitraryArgumentsOnObject() { + $node = $this->createFunctionExpression('foo', new CallableTestClass(), true); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('#^The last parameter of "Twig\\\\Tests\\\\Node\\\\Expression\\\\CallableTestClass\\:\\:__invoke" for function "foo" must be an array with default value, eg\\. "array \\$arg \\= \\[\\]"\\.$#'); + + $this->getArguments($node, [new CallableTestClass(), []]); } - private function getArguments($call, $args) + public static function customStaticFunction($arg1, $arg2 = 'default', $arg3 = []) { - $m = new \ReflectionMethod($call, 'getArguments'); - $m->setAccessible(true); + } - return $m->invokeArgs($call, $args); + public function customFunction($arg1, $arg2 = 'default', $arg3 = []) + { } public function customFunctionWithArbitraryArguments() { } - public function testResolveArgumentsWithMissingParameterForArbitraryArgumentsOnFunction() + private function getArguments($call, $args) { - $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('#^The last parameter of "Twig\\\\Tests\\\\Node\\\\Expression\\\\custom_Twig_Tests_Node_Expression_CallTest_function" for function "foo" must be an array with default value, eg\\. "array \\$arg \\= \\[\\]"\\.$#'); + $m = new \ReflectionMethod($call, 'getArguments'); + $m->setAccessible(true); - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'foo', 'is_variadic' => true]); - $node->getArguments('Twig\Tests\Node\Expression\custom_Twig_Tests_Node_Expression_CallTest_function', []); + return $m->invokeArgs($call, $args); } - public function testResolveArgumentsWithMissingParameterForArbitraryArgumentsOnObject() + private function createFunctionExpression($name, $callable, $isVariadic = false): Node_Expression_Call { - $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('#^The last parameter of "Twig\\\\Tests\\\\Node\\\\Expression\\\\CallableTestClass\\:\\:__invoke" for function "foo" must be an array with default value, eg\\. "array \\$arg \\= \\[\\]"\\.$#'); - - $node = new Node_Expression_Call([], ['type' => 'function', 'name' => 'foo', 'is_variadic' => true]); - $node->getArguments(new CallableTestClass(), []); + return new Node_Expression_Call(new TwigFunction($name, $callable, ['is_variadic' => $isVariadic]), new EmptyNode(), 0); } } -class Node_Expression_Call extends CallExpression +class Node_Expression_Call extends FunctionExpression { - public function getArguments($callable, $arguments) - { - return parent::getArguments($callable, $arguments); - } } class CallableTestClass @@ -156,6 +180,6 @@ public function __invoke($required) } } -function custom_Twig_Tests_Node_Expression_CallTest_function($required) +function custom_call_test_function($required) { } diff --git a/tests/Node/Expression/ConditionalTest.php b/tests/Node/Expression/ConditionalTest.php index 004e9c9513e..d5448efb3d8 100644 --- a/tests/Node/Expression/ConditionalTest.php +++ b/tests/Node/Expression/ConditionalTest.php @@ -1,5 +1,14 @@ assertEquals($expr3, $node->getNode('expr3')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; diff --git a/tests/Node/Expression/ConstantTest.php b/tests/Node/Expression/ConstantTest.php index 920892e942d..62fefddc409 100644 --- a/tests/Node/Expression/ConstantTest.php +++ b/tests/Node/Expression/ConstantTest.php @@ -1,5 +1,14 @@ assertEquals('foo', $node->getAttribute('value')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; diff --git a/tests/Node/Expression/Filter/RawTest.php b/tests/Node/Expression/Filter/RawTest.php new file mode 100644 index 00000000000..c254bf8c05b --- /dev/null +++ b/tests/Node/Expression/Filter/RawTest.php @@ -0,0 +1,48 @@ +assertSame(12, $filter->getTemplateLine()); + $this->assertSame('raw', $filter->getAttribute('name')); + $this->assertSame('raw', $filter->getNode('filter', false)->getAttribute('value')); + $this->assertSame($node, $filter->getNode('node')); + $this->assertCount(0, $filter->getNode('arguments')); + } + + public static function provideTests(): iterable + { + $node = new RawFilter(new ConstantExpression('foo', 12)); + + return [ + [$node, '"foo"'], + ]; + } +} diff --git a/tests/Node/Expression/FilterTest.php b/tests/Node/Expression/FilterTest.php index 4b30c9cae7e..1f071cce475 100644 --- a/tests/Node/Expression/FilterTest.php +++ b/tests/Node/Expression/FilterTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('node')); - $this->assertEquals($name, $node->getNode('filter')); + $this->assertEquals($name, $node->getAttribute('name')); $this->assertEquals($args, $node->getNode('arguments')); } - public function getTests() + public static function provideTests(): iterable { - $environment = new Environment($this->createMock(LoaderInterface::class)); - $environment->addFilter(new TwigFilter('bar', 'twig_tests_filter_dummy', ['needs_environment' => true])); - $environment->addFilter(new TwigFilter('bar_closure', \Closure::fromCallable(twig_tests_filter_dummy::class), ['needs_environment' => true])); - $environment->addFilter(new TwigFilter('barbar', 'Twig\Tests\Node\Expression\twig_tests_filter_barbar', ['needs_context' => true, 'is_variadic' => true])); - $environment->addFilter(new TwigFilter('magic_static', __NAMESPACE__.'\ChildMagicCallStub::magicStaticCall')); - - $extension = new class() extends AbstractExtension { - public function getFilters(): array - { - return [ - new TwigFilter('foo', \Closure::fromCallable([$this, 'foo'])), - new TwigFilter('foobar', \Closure::fromCallable([$this, 'foobar'])), - ]; - } - - public function foo() - { - } - - protected function foobar() - { - } - }; - $environment->addExtension($extension); + $environment = static::createEnvironment(); $tests = []; $expr = new ConstantExpression('foo', 1); - $node = $this->createFilter($expr, 'upper'); - $node = $this->createFilter($node, 'number_format', [new ConstantExpression(2, 1), new ConstantExpression('.', 1), new ConstantExpression(',', 1)]); + $node = self::createFilter($environment, $expr, 'upper'); + $node = self::createFilter($environment, $node, 'number_format', [new ConstantExpression(2, 1), new ConstantExpression('.', 1), new ConstantExpression(',', 1)]); - $tests[] = [$node, 'twig_number_format_filter($this->env, twig_upper_filter($this->env, "foo"), 2, ".", ",")']; + $tests[] = [$node, '$this->extensions[\'Twig\Extension\CoreExtension\']->formatNumber(Twig\Extension\CoreExtension::upper($this->env->getCharset(), "foo"), 2, ".", ",")']; // named arguments $date = new ConstantExpression(0, 1); - $node = $this->createFilter($date, 'date', [ + $node = self::createFilter($environment, $date, 'date', [ 'timezone' => new ConstantExpression('America/Chicago', 1), 'format' => new ConstantExpression('d/m/Y H:i:s P', 1), ]); - $tests[] = [$node, 'twig_date_format_filter($this->env, 0, "d/m/Y H:i:s P", "America/Chicago")']; + $tests[] = [$node, '$this->extensions[\'Twig\Extension\CoreExtension\']->formatDate(0, "d/m/Y H:i:s P", "America/Chicago")']; // skip an optional argument $date = new ConstantExpression(0, 1); - $node = $this->createFilter($date, 'date', [ + $node = self::createFilter($environment, $date, 'date', [ 'timezone' => new ConstantExpression('America/Chicago', 1), ]); - $tests[] = [$node, 'twig_date_format_filter($this->env, 0, null, "America/Chicago")']; + $tests[] = [$node, '$this->extensions[\'Twig\Extension\CoreExtension\']->formatDate(0, null, "America/Chicago")']; // underscores vs camelCase for named arguments $string = new ConstantExpression('abc', 1); - $node = $this->createFilter($string, 'reverse', [ + $node = self::createFilter($environment, $string, 'reverse', [ 'preserve_keys' => new ConstantExpression(true, 1), ]); - $tests[] = [$node, 'twig_reverse_filter($this->env, "abc", true)']; - $node = $this->createFilter($string, 'reverse', [ + $tests[] = [$node, 'Twig\Extension\CoreExtension::reverse($this->env->getCharset(), "abc", true)']; + $node = self::createFilter($environment, $string, 'reverse', [ 'preserveKeys' => new ConstantExpression(true, 1), ]); - $tests[] = [$node, 'twig_reverse_filter($this->env, "abc", true)']; + $tests[] = [$node, 'Twig\Extension\CoreExtension::reverse($this->env->getCharset(), "abc", true)']; // filter as an anonymous function - $node = $this->createFilter(new ConstantExpression('foo', 1), 'anonymous'); + $node = self::createFilter($environment, new ConstantExpression('foo', 1), 'anonymous'); $tests[] = [$node, '$this->env->getFilter(\'anonymous\')->getCallable()("foo")']; // needs environment - $node = $this->createFilter($string, 'bar'); - $tests[] = [$node, 'twig_tests_filter_dummy($this->env, "abc")', $environment]; + $node = self::createFilter($environment, $string, 'bar'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_dummy($this->env, "abc")', $environment]; - $node = $this->createFilter($string, 'bar_closure'); + $node = self::createFilter($environment, $string, 'bar_closure'); $tests[] = [$node, twig_tests_filter_dummy::class.'($this->env, "abc")', $environment]; - $node = $this->createFilter($string, 'bar', [new ConstantExpression('bar', 1)]); - $tests[] = [$node, 'twig_tests_filter_dummy($this->env, "abc", "bar")', $environment]; + $node = self::createFilter($environment, $string, 'bar', [new ConstantExpression('bar', 1)]); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_dummy($this->env, "abc", "bar")', $environment]; // arbitrary named arguments - $node = $this->createFilter($string, 'barbar'); + $node = self::createFilter($environment, $string, 'barbar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc")', $environment]; - $node = $this->createFilter($string, 'barbar', ['foo' => new ConstantExpression('bar', 1)]); + $node = self::createFilter($environment, $string, 'barbar', ['foo' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc", null, null, ["foo" => "bar"])', $environment]; - $node = $this->createFilter($string, 'barbar', ['arg2' => new ConstantExpression('bar', 1)]); + $node = self::createFilter($environment, $string, 'barbar', ['arg2' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc", null, "bar")', $environment]; - $node = $this->createFilter($string, 'barbar', [ + if (\PHP_VERSION_ID >= 80111) { + $node = self::createFilter($environment, $string, 'first_class_callable_static'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\FilterTestExtension::staticMethod("abc")', $environment]; + + $node = self::createFilter($environment, $string, 'first_class_callable_object'); + $tests[] = [$node, '$this->extensions[\'Twig\Tests\Node\Expression\FilterTestExtension\']->objectMethod("abc")', $environment]; + } + + $node = self::createFilter($environment, $string, 'barbar', [ new ConstantExpression('1', 1), new ConstantExpression('2', 1), new ConstantExpression('3', 1), 'foo' => new ConstantExpression('bar', 1), ]); - $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc", "1", "2", [0 => "3", "foo" => "bar"])', $environment]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc", "1", "2", ["3", "foo" => "bar"])', $environment]; // from extension - $node = $this->createFilter($string, 'foo'); - $tests[] = [$node, sprintf('$this->extensions[\'%s\']->foo("abc")', \get_class($extension)), $environment]; + $node = self::createFilter($environment, $string, 'foo'); + $tests[] = [$node, \sprintf('$this->extensions[\'%s\']->foo("abc")', \get_class(self::createExtension())), $environment]; - $node = $this->createFilter($string, 'foobar'); + $node = self::createFilter($environment, $string, 'foobar'); $tests[] = [$node, '$this->env->getFilter(\'foobar\')->getCallable()("abc")', $environment]; - $node = $this->createFilter($string, 'magic_static'); + $node = self::createFilter($environment, $string, 'magic_static'); $tests[] = [$node, 'Twig\Tests\Node\Expression\ChildMagicCallStub::magicStaticCall("abc")', $environment]; return $tests; @@ -144,47 +138,75 @@ protected function foobar() public function testCompileWithWrongNamedArgumentName() { - $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Unknown argument "foobar" for filter "date(format, timezone)" at line 1.'); - $date = new ConstantExpression(0, 1); - $node = $this->createFilter($date, 'date', [ + $node = $this->createFilter($this->getEnvironment(), $date, 'date', [ 'foobar' => new ConstantExpression('America/Chicago', 1), ]); $compiler = $this->getCompiler(); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unknown argument "foobar" for filter "date(format, timezone)" at line 1.'); + $compiler->compile($node); } public function testCompileWithMissingNamedArgument() { - $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Value for argument "from" is required for filter "replace" at line 1.'); - $value = new ConstantExpression(0, 1); - $node = $this->createFilter($value, 'replace', [ + $node = $this->createFilter($this->getEnvironment(), $value, 'replace', [ 'to' => new ConstantExpression('foo', 1), ]); $compiler = $this->getCompiler(); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Value for argument "from" is required for filter "replace" at line 1.'); + $compiler->compile($node); } - protected function createFilter($node, $name, array $arguments = []) + private static function createFilter(Environment $env, $node, $name, array $arguments = []): FilterExpression { - $name = new ConstantExpression($name, 1); - $arguments = new Node($arguments); - - return new FilterExpression($node, $name, $arguments, 1); + return new FilterExpression($node, $env->getFilter($name), new Nodes($arguments), 1); } - protected function getEnvironment() + protected static function createEnvironment(): Environment { - $env = new Environment(new ArrayLoader([])); + $env = new Environment(new ArrayLoader()); $env->addFilter(new TwigFilter('anonymous', 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('magic_static', __NAMESPACE__.'\ChildMagicCallStub::magicStaticCall')); + if (\PHP_VERSION_ID >= 80111) { + $env->addExtension(new FilterTestExtension()); + } + $env->addExtension(self::createExtension()); return $env; } + + private static function createExtension(): AbstractExtension + { + return new class extends AbstractExtension { + public function getFilters(): array + { + return [ + new TwigFilter('foo', \Closure::fromCallable([$this, 'foo'])), + new TwigFilter('foobar', \Closure::fromCallable([$this, 'foobar'])), + ]; + } + + public function foo() + { + } + + protected function foobar() + { + } + }; + } } function twig_tests_filter_dummy() diff --git a/tests/Node/Expression/FilterTestExtension.php b/tests/Node/Expression/FilterTestExtension.php new file mode 100644 index 00000000000..789f05c9ec3 --- /dev/null +++ b/tests/Node/Expression/FilterTestExtension.php @@ -0,0 +1,43 @@ +objectMethod(...)), + ]; + } + + public static function staticMethod() + { + } + + public function objectMethod() + { + } +} diff --git a/tests/Node/Expression/FunctionTest.php b/tests/Node/Expression/FunctionTest.php index 8c9beb370e9..5c3726719d7 100644 --- a/tests/Node/Expression/FunctionTest.php +++ b/tests/Node/Expression/FunctionTest.php @@ -1,5 +1,14 @@ assertEquals($name, $node->getAttribute('name')); $this->assertEquals($args, $node->getNode('arguments')); } - public function getTests() + public static function provideTests(): iterable { - $environment = new Environment($this->createMock(LoaderInterface::class)); - $environment->addFunction(new TwigFunction('foo', 'twig_tests_function_dummy', [])); - $environment->addFunction(new TwigFunction('foo_closure', \Closure::fromCallable(twig_tests_function_dummy::class), [])); - $environment->addFunction(new TwigFunction('bar', 'twig_tests_function_dummy', ['needs_environment' => true])); - $environment->addFunction(new TwigFunction('foofoo', 'twig_tests_function_dummy', ['needs_context' => true])); - $environment->addFunction(new TwigFunction('foobar', 'twig_tests_function_dummy', ['needs_environment' => true, 'needs_context' => true])); - $environment->addFunction(new TwigFunction('barbar', 'Twig\Tests\Node\Expression\twig_tests_function_barbar', ['is_variadic' => true])); + $environment = static::createEnvironment(); $tests = []; - $node = $this->createFunction('foo'); - $tests[] = [$node, 'twig_tests_function_dummy()', $environment]; + $node = self::createFunction($environment, 'foo'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy()', $environment]; - $node = $this->createFunction('foo_closure'); + $node = self::createFunction($environment, 'foo_closure'); $tests[] = [$node, twig_tests_function_dummy::class.'()', $environment]; - $node = $this->createFunction('foo', [new ConstantExpression('bar', 1), new ConstantExpression('foobar', 1)]); - $tests[] = [$node, 'twig_tests_function_dummy("bar", "foobar")', $environment]; + $node = self::createFunction($environment, 'foo', [new ConstantExpression('bar', 1), new ConstantExpression('foobar', 1)]); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy("bar", "foobar")', $environment]; - $node = $this->createFunction('bar'); - $tests[] = [$node, 'twig_tests_function_dummy($this->env)', $environment]; + $node = self::createFunction($environment, 'bar'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($this->env)', $environment]; - $node = $this->createFunction('bar', [new ConstantExpression('bar', 1)]); - $tests[] = [$node, 'twig_tests_function_dummy($this->env, "bar")', $environment]; + $node = self::createFunction($environment, 'bar', [new ConstantExpression('bar', 1)]); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($this->env, "bar")', $environment]; - $node = $this->createFunction('foofoo'); - $tests[] = [$node, 'twig_tests_function_dummy($context)', $environment]; + $node = self::createFunction($environment, 'foofoo'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($context)', $environment]; - $node = $this->createFunction('foofoo', [new ConstantExpression('bar', 1)]); - $tests[] = [$node, 'twig_tests_function_dummy($context, "bar")', $environment]; + $node = self::createFunction($environment, 'foofoo', [new ConstantExpression('bar', 1)]); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($context, "bar")', $environment]; - $node = $this->createFunction('foobar'); - $tests[] = [$node, 'twig_tests_function_dummy($this->env, $context)', $environment]; + $node = self::createFunction($environment, 'foobar'); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($this->env, $context)', $environment]; - $node = $this->createFunction('foobar', [new ConstantExpression('bar', 1)]); - $tests[] = [$node, 'twig_tests_function_dummy($this->env, $context, "bar")', $environment]; + $node = self::createFunction($environment, 'foobar', [new ConstantExpression('bar', 1)]); + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($this->env, $context, "bar")', $environment]; // named arguments - $node = $this->createFunction('date', [ + $node = self::createFunction($environment, 'date', [ 'timezone' => new ConstantExpression('America/Chicago', 1), 'date' => new ConstantExpression(0, 1), ]); - $tests[] = [$node, 'twig_date_converter($this->env, 0, "America/Chicago")']; + $tests[] = [$node, '$this->extensions[\'Twig\Extension\CoreExtension\']->convertDate(0, "America/Chicago")']; // arbitrary named arguments - $node = $this->createFunction('barbar'); + $node = self::createFunction($environment, 'barbar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar()', $environment]; - $node = $this->createFunction('barbar', ['foo' => new ConstantExpression('bar', 1)]); + $node = self::createFunction($environment, 'barbar', ['foo' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar(null, null, ["foo" => "bar"])', $environment]; - $node = $this->createFunction('barbar', ['arg2' => new ConstantExpression('bar', 1)]); + $node = self::createFunction($environment, 'barbar', ['arg2' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar(null, "bar")', $environment]; - $node = $this->createFunction('barbar', [ + $node = self::createFunction($environment, 'barbar', [ new ConstantExpression('1', 1), new ConstantExpression('2', 1), new ConstantExpression('3', 1), 'foo' => new ConstantExpression('bar', 1), ]); - $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar("1", "2", [0 => "3", "foo" => "bar"])', $environment]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar("1", "2", ["3", "foo" => "bar"])', $environment]; // function as an anonymous function - $node = $this->createFunction('anonymous', [new ConstantExpression('foo', 1)]); + $node = self::createFunction($environment, 'anonymous', [new ConstantExpression('foo', 1)]); $tests[] = [$node, '$this->env->getFunction(\'anonymous\')->getCallable()("foo")']; return $tests; } - protected function createFunction($name, array $arguments = []) + private static function createFunction(Environment $env, $name, array $arguments = []): FunctionExpression { - return new FunctionExpression($name, new Node($arguments), 1); + return new FunctionExpression($env->getFunction($name), new Nodes($arguments), 1); } - protected function getEnvironment() + protected static function createEnvironment(): Environment { - $env = new Environment(new ArrayLoader([])); + $env = new Environment(new ArrayLoader()); $env->addFunction(new TwigFunction('anonymous', 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])); + $env->addFunction(new TwigFunction('foofoo', 'Twig\Tests\Node\Expression\twig_tests_function_dummy', ['needs_context' => true])); + $env->addFunction(new TwigFunction('foobar', 'Twig\Tests\Node\Expression\twig_tests_function_dummy', ['needs_environment' => true, 'needs_context' => true])); + $env->addFunction(new TwigFunction('barbar', 'Twig\Tests\Node\Expression\twig_tests_function_barbar', ['is_variadic' => true])); return $env; } diff --git a/tests/Node/Expression/GetAttrTest.php b/tests/Node/Expression/GetAttrTest.php index 16c76c609c5..69f3d0a59a5 100644 --- a/tests/Node/Expression/GetAttrTest.php +++ b/tests/Node/Expression/GetAttrTest.php @@ -1,5 +1,14 @@ addElement(new NameExpression('foo', 1)); + $args->addElement(new ContextVariable('foo', 1)); $args->addElement(new ConstantExpression('bar', 1)); $node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1); @@ -35,25 +44,25 @@ public function testConstructor() $this->assertEquals(Template::ARRAY_CALL, $node->getAttribute('type')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; - $expr = new NameExpression('foo', 1); + $expr = new ContextVariable('foo', 1); $attr = new ConstantExpression('bar', 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)', $this->getAttributeGetter(), $this->getVariableGetter('foo', 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::ARRAY_CALL, 1); - $tests[] = [$node, '(($__internal_%s = // line 1'."\n". - '($context["foo"] ?? null)) && is_array($__internal_%s) || $__internal_%s instanceof ArrayAccess ? ($__internal_%s["bar"] ?? null) : null)', null, true, ]; + $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, ]; $args = new ArrayExpression([], 1); - $args->addElement(new NameExpression('foo', 1)); + $args->addElement(new ContextVariable('foo', 1)); $args->addElement(new ConstantExpression('bar', 1)); $node = new GetAttrExpression($expr, $attr, $args, Template::METHOD_CALL, 1); - $tests[] = [$node, sprintf('%s%s, "bar", [0 => %s, 1 => "bar"], "method", false, false, false, 1)', $this->getAttributeGetter(), $this->getVariableGetter('foo', 1), $this->getVariableGetter('foo'))]; + $tests[] = [$node, \sprintf('%s%s, "bar", [%s, "bar"], "method", false, false, false, 1)', self::createAttributeGetter(), self::createVariableGetter('foo', 1), self::createVariableGetter('foo'))]; return $tests; } diff --git a/tests/Node/Expression/NameTest.php b/tests/Node/Expression/NameTest.php deleted file mode 100644 index 57ba02b7600..00000000000 --- a/tests/Node/Expression/NameTest.php +++ /dev/null @@ -1,46 +0,0 @@ -assertEquals('foo', $node->getAttribute('name')); - } - - public function getTests() - { - $node = new NameExpression('foo', 1); - $self = new NameExpression('_self', 1); - $context = new NameExpression('_context', 1); - - $env = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => true]); - $env1 = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => false]); - - $output = '(isset($context["foo"]) || array_key_exists("foo", $context) ? $context["foo"] : (function () { throw new RuntimeError(\'Variable "foo" does not exist.\', 1, $this->source); })())'; - - return [ - [$node, "// line 1\n".$output, $env], - [$node, $this->getVariableGetter('foo', 1), $env1], - [$self, "// line 1\n\$this->getTemplateName()"], - [$context, "// line 1\n\$context"], - ]; - } -} diff --git a/tests/Node/Expression/NullCoalesceTest.php b/tests/Node/Expression/NullCoalesceTest.php index 188631c7a75..41a1ce4dab0 100644 --- a/tests/Node/Expression/NullCoalesceTest.php +++ b/tests/Node/Expression/NullCoalesceTest.php @@ -1,5 +1,14 @@ assertEquals('foo', $node->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; $tests[] = [new ParentExpression('foo', 1), '$this->renderParentBlock("foo", $context, $blocks)']; diff --git a/tests/Node/Expression/Ternary/ConditionalTernaryTest.php b/tests/Node/Expression/Ternary/ConditionalTernaryTest.php new file mode 100644 index 00000000000..d8cc8da3d0e --- /dev/null +++ b/tests/Node/Expression/Ternary/ConditionalTernaryTest.php @@ -0,0 +1,53 @@ +assertEquals($test, $node->getNode('test')); + $this->assertEquals($left, $node->getNode('left')); + $this->assertEquals($right, $node->getNode('right')); + } + + public static function provideTests(): iterable + { + $tests = []; + + $test = new ConstantExpression(1, 1); + $left = new ConstantExpression(2, 1); + $right = new ConstantExpression(3, 1); + $node = new ConditionalTernary($test, $left, $right, 1); + $tests[] = [$node, '((1) ? (2) : (3))']; + + return $tests; + } +} diff --git a/tests/Node/Expression/TestTest.php b/tests/Node/Expression/TestTest.php index 97955cb626f..1e2dcb661fa 100644 --- a/tests/Node/Expression/TestTest.php +++ b/tests/Node/Expression/TestTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('node')); $this->assertEquals($args, $node->getNode('arguments')); $this->assertEquals($name, $node->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { - $environment = new Environment($this->createMock(LoaderInterface::class)); - $environment->addTest(new TwigTest('barbar', 'Twig\Tests\Node\Expression\twig_tests_test_barbar', ['is_variadic' => true, 'need_context' => true])); + $environment = static::createEnvironment(); $tests = []; $expr = new ConstantExpression('foo', 1); - $node = new NullTest($expr, 'null', new Node([]), 1); + $node = new NullTest($expr, $environment->getTest('null'), new EmptyNode(), 1); $tests[] = [$node, '(null === "foo")']; // test as an anonymous function - $node = $this->createTest(new ConstantExpression('foo', 1), 'anonymous', [new ConstantExpression('foo', 1)]); + $node = self::createTest($environment, new ConstantExpression('foo', 1), 'anonymous', [new ConstantExpression('foo', 1)]); $tests[] = [$node, '$this->env->getTest(\'anonymous\')->getCallable()("foo", "foo")']; // arbitrary named arguments $string = new ConstantExpression('abc', 1); - $node = $this->createTest($string, 'barbar'); + $node = self::createTest($environment, $string, 'barbar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc")', $environment]; - $node = $this->createTest($string, 'barbar', ['foo' => new ConstantExpression('bar', 1)]); + $node = self::createTest($environment, $string, 'barbar', ['foo' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc", null, null, ["foo" => "bar"])', $environment]; - $node = $this->createTest($string, 'barbar', ['arg2' => new ConstantExpression('bar', 1)]); + $node = self::createTest($environment, $string, 'barbar', ['arg2' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc", null, "bar")', $environment]; - $node = $this->createTest($string, 'barbar', [ + $node = self::createTest($environment, $string, 'barbar', [ new ConstantExpression('1', 1), new ConstantExpression('2', 1), new ConstantExpression('3', 1), 'foo' => new ConstantExpression('bar', 1), ]); - $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc", "1", "2", [0 => "3", "foo" => "bar"])', $environment]; + $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc", "1", "2", ["3", "foo" => "bar"])', $environment]; return $tests; } - protected function createTest($node, $name, array $arguments = []) + private static function createTest(Environment $env, $node, $name, array $arguments = []): TestExpression { - return new TestExpression($node, $name, new Node($arguments), 1); + return new TestExpression($node, $env->getTest($name), new Nodes($arguments), 1); } - protected function getEnvironment() + protected static function createEnvironment(): Environment { - $env = new Environment(new ArrayLoader([])); + $env = new Environment(new ArrayLoader()); $env->addTest(new TwigTest('anonymous', function () {})); + $env->addTest(new TwigTest('barbar', 'Twig\Tests\Node\Expression\twig_tests_test_barbar', ['is_variadic' => true, 'need_context' => true])); return $env; } diff --git a/tests/Node/Expression/Unary/NegTest.php b/tests/Node/Expression/Unary/NegTest.php index fcbf66ece8f..06089e5fd92 100644 --- a/tests/Node/Expression/Unary/NegTest.php +++ b/tests/Node/Expression/Unary/NegTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('node')); } - public function getTests() + public static function provideTests(): iterable { $node = new ConstantExpression(1, 1); $node = new NegUnary($node, 1); diff --git a/tests/Node/Expression/Unary/NotTest.php b/tests/Node/Expression/Unary/NotTest.php index 8197111e17a..de90a15c867 100644 --- a/tests/Node/Expression/Unary/NotTest.php +++ b/tests/Node/Expression/Unary/NotTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('node')); } - public function getTests() + public static function provideTests(): iterable { $node = new ConstantExpression(1, 1); $node = new NotUnary($node, 1); diff --git a/tests/Node/Expression/Unary/PosTest.php b/tests/Node/Expression/Unary/PosTest.php index 780e339e0cf..46ba9774a92 100644 --- a/tests/Node/Expression/Unary/PosTest.php +++ b/tests/Node/Expression/Unary/PosTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('node')); } - public function getTests() + public static function provideTests(): iterable { $node = new ConstantExpression(1, 1); $node = new PosUnary($node, 1); diff --git a/tests/Node/Expression/AssignNameTest.php b/tests/Node/Expression/Variable/AssignContextVariableTest.php similarity index 50% rename from tests/Node/Expression/AssignNameTest.php rename to tests/Node/Expression/Variable/AssignContextVariableTest.php index 80dbe94c6c0..84deaa80bd5 100644 --- a/tests/Node/Expression/AssignNameTest.php +++ b/tests/Node/Expression/Variable/AssignContextVariableTest.php @@ -1,5 +1,14 @@ assertEquals('foo', $node->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { - $node = new AssignNameExpression('foo', 1); + $node = new AssignContextVariable('foo', 1); return [ [$node, '$context["foo"]'], diff --git a/tests/Node/Expression/Variable/ContextVariableTest.php b/tests/Node/Expression/Variable/ContextVariableTest.php new file mode 100644 index 00000000000..c10aaf2a9ce --- /dev/null +++ b/tests/Node/Expression/Variable/ContextVariableTest.php @@ -0,0 +1,82 @@ +assertEquals('foo', $node->getAttribute('name')); + } + + public static function provideTests(): iterable + { + // special variables + foreach (['_self' => '$this->getTemplateName()', '_context' => '$context', '_charset' => '$this->env->getCharset()'] as $special => $compiled) { + $node = new ContextVariable($special, 1); + yield $special => [$node, "// line 1\n$compiled"]; + $node = new ContextVariable($special, 1); + $node->enableDefinedTest(); + yield $special.'_defined_test' => [$node, "// line 1\ntrue"]; + } + + $env = new Environment(new ArrayLoader(), ['strict_variables' => false]); + $envStrict = new Environment(new ArrayLoader(), ['strict_variables' => true]); + + // regular + $node = new ContextVariable('foo', 1); + $output = '(isset($context["foo"]) || array_key_exists("foo", $context) ? $context["foo"] : (function () { throw new RuntimeError(\'Variable "foo" does not exist.\', 1, $this->source); })())'; + yield 'strict' => [$node, "// line 1\n".$output, $envStrict]; + yield 'non_strict' => [$node, self::createVariableGetter('foo', 1), $env]; + + // ignore strict check + $node = new ContextVariable('foo', 1); + $node->setAttribute('ignore_strict_check', true); + yield 'ignore_strict_check_strict' => [$node, "// line 1\n(\$context[\"foo\"] ?? null)", $envStrict]; + yield 'ignore_strict_check_non_strict' => [$node, "// line 1\n(\$context[\"foo\"] ?? null)", $env]; + + // always defined + $node = new ContextVariable('foo', 1); + $node->setAttribute('always_defined', true); + yield 'always_defined_strict' => [$node, "// line 1\n\$context[\"foo\"]", $envStrict]; + yield 'always_defined_non_strict' => [$node, "// line 1\n\$context[\"foo\"]", $env]; + + // is defined test + $node = new ContextVariable('foo', 1); + $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->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 --git a/tests/Node/ForTest.php b/tests/Node/ForTest.php index ce746481771..2e33263e554 100644 --- a/tests/Node/ForTest.php +++ b/tests/Node/ForTest.php @@ -1,5 +1,14 @@ setAttribute('with_loop', false); @@ -33,44 +43,48 @@ public function testConstructor() $this->assertEquals($keyTarget, $node->getNode('key_target')); $this->assertEquals($valueTarget, $node->getNode('value_target')); $this->assertEquals($seq, $node->getNode('seq')); - $this->assertEquals($body, $node->getNode('body')->getNode(0)); + $this->assertEquals($body, $node->getNode('body')->getNode('0')); $this->assertFalse($node->hasNode('else')); - $else = new PrintNode(new NameExpression('foo', 1), 1); + $else = new ForElseNode(new PrintNode(new ContextVariable('foo', 1), 1), 5); $node = new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, 1); $node->setAttribute('with_loop', false); $this->assertEquals($else, $node->getNode('else')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; - $keyTarget = new AssignNameExpression('key', 1); - $valueTarget = new AssignNameExpression('item', 1); - $seq = new NameExpression('items', 1); - $body = new Node([new PrintNode(new NameExpression('foo', 1), 1)], [], 1); + $keyTarget = new AssignContextVariable('key', 1); + $valueTarget = new AssignContextVariable('item', 1); + $seq = new ContextVariable('items', 1); + $body = new Nodes([new PrintNode(new ContextVariable('foo', 1), 1)], 1); $else = null; $node = new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, 1); $node->setAttribute('with_loop', false); + $itemsGetter = self::createVariableGetter('items'); + $fooGetter = self::createVariableGetter('foo'); + $valuesGetter = self::createVariableGetter('values'); + $tests[] = [$node, <<getVariableGetter('items')}); +\$context['_seq'] = CoreExtension::ensureTraversable($itemsGetter); foreach (\$context['_seq'] as \$context["key"] => \$context["item"]) { - echo {$this->getVariableGetter('foo')}; + yield $fooGetter; } \$_parent = \$context['_parent']; -unset(\$context['_seq'], \$context['_iterated'], \$context['key'], \$context['item'], \$context['_parent'], \$context['loop']); +unset(\$context['_seq'], \$context['key'], \$context['item'], \$context['_parent']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; - $keyTarget = new AssignNameExpression('k', 1); - $valueTarget = new AssignNameExpression('v', 1); - $seq = new NameExpression('values', 1); - $body = new Node([new PrintNode(new NameExpression('foo', 1), 1)], [], 1); + $keyTarget = new AssignContextVariable('k', 1); + $valueTarget = new AssignContextVariable('v', 1); + $seq = new ContextVariable('values', 1); + $body = new Nodes([new PrintNode(new ContextVariable('foo', 1), 1)], 1); $else = null; $node = new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, 1); $node->setAttribute('with_loop', true); @@ -78,7 +92,7 @@ public function getTests() $tests[] = [$node, <<getVariableGetter('values')}); +\$context['_seq'] = CoreExtension::ensureTraversable($valuesGetter); \$context['loop'] = [ 'parent' => \$context['_parent'], 'index0' => 0, @@ -93,26 +107,26 @@ public function getTests() \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - echo {$this->getVariableGetter('foo')}; + yield $fooGetter; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; - if (isset(\$context['loop']['length'])) { + if (isset(\$context['loop']['revindex0'], \$context['loop']['revindex'])) { --\$context['loop']['revindex0']; --\$context['loop']['revindex']; \$context['loop']['last'] = 0 === \$context['loop']['revindex0']; } } \$_parent = \$context['_parent']; -unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']); +unset(\$context['_seq'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; - $keyTarget = new AssignNameExpression('k', 1); - $valueTarget = new AssignNameExpression('v', 1); - $seq = new NameExpression('values', 1); - $body = new Node([new PrintNode(new NameExpression('foo', 1), 1)], [], 1); + $keyTarget = new AssignContextVariable('k', 1); + $valueTarget = new AssignContextVariable('v', 1); + $seq = new ContextVariable('values', 1); + $body = new Nodes([new PrintNode(new ContextVariable('foo', 1), 1)], 1); $else = null; $node = new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, 1); $node->setAttribute('with_loop', true); @@ -120,7 +134,7 @@ public function getTests() $tests[] = [$node, <<getVariableGetter('values')}); +\$context['_seq'] = CoreExtension::ensureTraversable($valuesGetter); \$context['loop'] = [ 'parent' => \$context['_parent'], 'index0' => 0, @@ -135,34 +149,34 @@ public function getTests() \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - echo {$this->getVariableGetter('foo')}; + yield $fooGetter; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; - if (isset(\$context['loop']['length'])) { + if (isset(\$context['loop']['revindex0'], \$context['loop']['revindex'])) { --\$context['loop']['revindex0']; --\$context['loop']['revindex']; \$context['loop']['last'] = 0 === \$context['loop']['revindex0']; } } \$_parent = \$context['_parent']; -unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']); +unset(\$context['_seq'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; - $keyTarget = new AssignNameExpression('k', 1); - $valueTarget = new AssignNameExpression('v', 1); - $seq = new NameExpression('values', 1); - $body = new Node([new PrintNode(new NameExpression('foo', 1), 1)], [], 1); - $else = new PrintNode(new NameExpression('foo', 1), 1); + $keyTarget = new AssignContextVariable('k', 1); + $valueTarget = new AssignContextVariable('v', 1); + $seq = new ContextVariable('values', 1); + $body = new Nodes([new PrintNode(new ContextVariable('foo', 1), 1)], 1); + $else = new ForElseNode(new PrintNode(new ContextVariable('foo', 6), 6), 5); $node = new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, 1); $node->setAttribute('with_loop', true); $tests[] = [$node, <<getVariableGetter('values')}); +\$context['_seq'] = CoreExtension::ensureTraversable($valuesGetter); \$context['_iterated'] = false; \$context['loop'] = [ 'parent' => \$context['_parent'], @@ -178,22 +192,24 @@ public function getTests() \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { - echo {$this->getVariableGetter('foo')}; + yield $fooGetter; \$context['_iterated'] = true; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; - if (isset(\$context['loop']['length'])) { + if (isset(\$context['loop']['revindex0'], \$context['loop']['revindex'])) { --\$context['loop']['revindex0']; --\$context['loop']['revindex']; \$context['loop']['last'] = 0 === \$context['loop']['revindex0']; } } +// line 5 if (!\$context['_iterated']) { - echo {$this->getVariableGetter('foo')}; + // line 6 + yield $fooGetter; } \$_parent = \$context['_parent']; -unset(\$context['_seq'], \$context['_iterated'], \$context['k'], \$context['v'], \$context['_parent'], \$context['loop']); +unset(\$context['_seq'], \$context['k'], \$context['v'], \$context['_parent'], \$context['_iterated'], \$context['loop']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; diff --git a/tests/Node/IfTest.php b/tests/Node/IfTest.php index d5a6eac8ab4..ab76f50af65 100644 --- a/tests/Node/IfTest.php +++ b/tests/Node/IfTest.php @@ -1,5 +1,14 @@ assertEquals($t, $node->getNode('tests')); $this->assertFalse($node->hasNode('else')); - $else = new PrintNode(new NameExpression('bar', 1), 1); + $else = new PrintNode(new ContextVariable('bar', 1), 1); $node = new IfNode($t, $else, 1); $this->assertEquals($else, $node->getNode('else')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; - $t = new Node([ + $t = new Nodes([ new ConstantExpression(true, 1), - new PrintNode(new NameExpression('foo', 1), 1), - ], [], 1); + new PrintNode(new ContextVariable('foo', 1), 1), + ], 1); $else = null; $node = new IfNode($t, $else, 1); + $fooGetter = self::createVariableGetter('foo'); + $barGetter = self::createVariableGetter('bar'); + $tests[] = [$node, <<getVariableGetter('foo')}; + yield $fooGetter; } EOF ]; - $t = new Node([ + $t = new Nodes([ new ConstantExpression(true, 1), - new PrintNode(new NameExpression('foo', 1), 1), + new PrintNode(new ContextVariable('foo', 1), 1), new ConstantExpression(false, 1), - new PrintNode(new NameExpression('bar', 1), 1), - ], [], 1); + new PrintNode(new ContextVariable('bar', 1), 1), + ], 1); $else = null; $node = new IfNode($t, $else, 1); $tests[] = [$node, <<getVariableGetter('foo')}; + yield $fooGetter; } elseif (false) { - echo {$this->getVariableGetter('bar')}; + yield $barGetter; } EOF ]; - $t = new Node([ + $t = new Nodes([ new ConstantExpression(true, 1), - new PrintNode(new NameExpression('foo', 1), 1), - ], [], 1); - $else = new PrintNode(new NameExpression('bar', 1), 1); + new PrintNode(new ContextVariable('foo', 1), 1), + ], 1); + $else = new PrintNode(new ContextVariable('bar', 1), 1); $node = new IfNode($t, $else, 1); $tests[] = [$node, <<getVariableGetter('foo')}; + yield $fooGetter; } else { - echo {$this->getVariableGetter('bar')}; + yield $barGetter; } EOF ]; diff --git a/tests/Node/ImportTest.php b/tests/Node/ImportTest.php index b069cabe5f6..314cf0622d2 100644 --- a/tests/Node/ImportTest.php +++ b/tests/Node/ImportTest.php @@ -1,5 +1,14 @@ assertEquals($macro, $node->getNode('expr')); - $this->assertEquals($var, $node->getNode('var')); + $this->assertEquals('macro', $node->getNode('var')->getNode('var')->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; $macro = new ConstantExpression('foo.twig', 1); - $var = new AssignNameExpression('macro', 1); - $node = new ImportNode($macro, $var, 1); + $node = new ImportNode($macro, new AssignTemplateVariable(new TemplateVariable('macro', 1), true), 1); $tests[] = [$node, <<macros["macro"] = \$this->loadTemplate("foo.twig", null, 1)->unwrap(); +\$macros["macro"] = \$this->macros["macro"] = \$this->load("foo.twig", 1)->unwrap(); EOF ]; diff --git a/tests/Node/IncludeTest.php b/tests/Node/IncludeTest.php index ab1fdf0bfe0..908eebf1eec 100644 --- a/tests/Node/IncludeTest.php +++ b/tests/Node/IncludeTest.php @@ -1,5 +1,14 @@ assertTrue($node->getAttribute('only')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; $expr = new ConstantExpression('foo.twig', 1); $node = new IncludeNode($expr, null, false, false, 1); - $tests[] = [$node, <<loadTemplate("foo.twig", null, 1)->display(\$context); +yield from $this->load("foo.twig", 1)->unwrap()->yield($context); EOF ]; - $expr = new ConditionalExpression( - new ConstantExpression(true, 1), - new ConstantExpression('foo', 1), - new ConstantExpression('foo', 1), - 0 - ); + $expr = new ConditionalTernary( + new ConstantExpression(true, 1), + new ConstantExpression('foo', 1), + new ConstantExpression('foo', 1), + 0 + ); $node = new IncludeNode($expr, null, false, false, 1); - $tests[] = [$node, <<loadTemplate(((true) ? ("foo") : ("foo")), null, 1)->display(\$context); +yield from $this->load(((true) ? ("foo") : ("foo")), 1)->unwrap()->yield($context); EOF ]; $expr = new ConstantExpression('foo.twig', 1); $vars = new ArrayExpression([new ConstantExpression('foo', 1), new ConstantExpression(true, 1)], 1); $node = new IncludeNode($expr, $vars, false, false, 1); - $tests[] = [$node, <<loadTemplate("foo.twig", null, 1)->display(twig_array_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, <<loadTemplate("foo.twig", null, 1)->display(twig_to_array(["foo" => true])); +yield from $this->load("foo.twig", 1)->unwrap()->yield(CoreExtension::toArray(["foo" => true])); EOF ]; $node = new IncludeNode($expr, $vars, true, true, 1); $tests[] = [$node, <<loadTemplate("foo.twig", null, 1); + \$_v%s = \$this->load("foo.twig", 1); } catch (LoaderError \$e) { // ignore missing template + \$_v%s = null; } -if (\$__internal_%s) { - \$__internal_%s->display(twig_to_array(["foo" => true])); +if (\$_v%s) { + yield from \$_v%s->unwrap()->yield(CoreExtension::toArray(["foo" => true])); } EOF - , null, true]; + , null, true]; return $tests; } diff --git a/tests/Node/MacroTest.php b/tests/Node/MacroTest.php index 88800b6e5c3..c828ab7d104 100644 --- a/tests/Node/MacroTest.php +++ b/tests/Node/MacroTest.php @@ -1,5 +1,14 @@ assertEquals($body, $node->getNode('body')); @@ -31,40 +44,64 @@ public function testConstructor() $this->assertEquals('foo', $node->getAttribute('name')); } - public function getTests() + public static function provideTests(): iterable { - $body = new TextNode('foo', 1); - $arguments = new Node([ - 'foo' => new ConstantExpression(null, 1), - 'bar' => new ConstantExpression('Foo', 1), - ], [], 1); + $arguments = new ArrayExpression([ + new LocalVariable('foo', 1), + new ConstantExpression(null, 1), + new LocalVariable('bar', 1), + new ConstantExpression('Foo', 1), + new LocalVariable('_underscore', 1), + new ConstantExpression(null, 1), + ], 1); + + $body = new BodyNode([new TextNode('foo', 1)]); $node = new MacroNode('foo', $body, $arguments, 1); - return [ - [$node, << [$node, <<macros; - \$context = \$this->env->mergeGlobals([ - "foo" => \$__foo__, - "bar" => \$__bar__, - "varargs" => \$__varargs__, - ]); + \$context = [ + "foo" => \$foo, + "bar" => \$bar, + "_underscore" => \$_underscore, + "varargs" => \$varargs, + ] + \$this->env->getGlobals(); \$blocks = []; - ob_start(function () { return ''; }); - try { - echo "foo"; + return ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { + yield "foo"; + yield from []; + })(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); +} +EOF + , new Environment(new ArrayLoader(), ['use_yield' => true]), + ]; - return ('' === \$tmp = ob_get_contents()) ? '' : new Markup(\$tmp, \$this->env->getCharset()); - } finally { - ob_end_clean(); - } + yield 'with use_yield = false' => [$node, <<macros; + \$context = [ + "foo" => \$foo, + "bar" => \$bar, + "_underscore" => \$_underscore, + "varargs" => \$varargs, + ] + \$this->env->getGlobals(); + + \$blocks = []; + + return ('' === \$tmp = \\Twig\\Extension\\CoreExtension::captureOutput((function () use (&\$context, \$macros, \$blocks) { + yield "foo"; + yield from []; + })())) ? '' : new Markup(\$tmp, \$this->env->getCharset()); } EOF - ], + , new Environment(new ArrayLoader(), ['use_yield' => false]), ]; } } diff --git a/tests/Node/ModuleTest.php b/tests/Node/ModuleTest.php index 85adc21c6e6..132d51454c5 100644 --- a/tests/Node/ModuleTest.php +++ b/tests/Node/ModuleTest.php @@ -1,5 +1,14 @@ assertEquals($body, $node->getNode('body')); $this->assertEquals($blocks, $node->getNode('blocks')); @@ -43,26 +56,27 @@ public function testConstructor() $this->assertEquals($source->getName(), $node->getTemplateName()); } - public function getTests() + public static function provideTests(): iterable { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader(['foo.twig' => '{{ foo }}'])); $tests = []; - $body = new TextNode('foo', 1); + $body = new BodyNode([new TextNode('foo', 1)]); $extends = null; - $blocks = new Node(); - $macros = new Node(); - $traits = new Node(); + $blocks = new EmptyNode(); + $macros = new EmptyNode(); + $traits = new EmptyNode(); $source = new Source('{{ foo }}', 'foo.twig'); - $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new Node([]), $source); + $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new EmptyNode(), $source); $tests[] = [$node, << + */ + private array \$macros = []; public function __construct(Environment \$env) { @@ -90,43 +108,51 @@ public function __construct(Environment \$env) ]; } - protected function doDisplay(array \$context, array \$blocks = []) + protected function doDisplay(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; // line 1 - echo "foo"; + yield "foo"; + yield from []; } - public function getTemplateName() + /** + * @codeCoverageIgnore + */ + public function getTemplateName(): string { return "foo.twig"; } - public function getDebugInfo() + /** + * @codeCoverageIgnore + */ + public function getDebugInfo(): array { - return array ( 37 => 1,); + return array ( 42 => 1,); } - public function getSourceContext() + public function getSourceContext(): Source { return new Source("", "foo.twig", ""); } } EOF - , $twig, true]; + , $twig, true]; - $import = new ImportNode(new ConstantExpression('foo.twig', 1), new AssignNameExpression('macro', 1), 2); + $import = new ImportNode(new ConstantExpression('foo.twig', 1), new AssignTemplateVariable(new TemplateVariable('macro', 2), true), 2); - $body = new Node([$import]); + $body = new BodyNode([$import]); $extends = new ConstantExpression('layout.twig', 1); - $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new Node([]), $source); + $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new EmptyNode(), $source); $tests[] = [$node, << + */ + private array \$macros = []; public function __construct(Environment \$env) { @@ -152,62 +182,72 @@ public function __construct(Environment \$env) ]; } - protected function doGetParent(array \$context) + protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper { // line 1 return "layout.twig"; } - protected function doDisplay(array \$context, array \$blocks = []) + protected function doDisplay(array \$context, array \$blocks = []): iterable { \$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->display(\$context, array_merge(\$this->blocks, \$blocks)); + \$this->parent = \$this->load("layout.twig", 1); + yield from \$this->parent->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks)); } - public function getTemplateName() + /** + * @codeCoverageIgnore + */ + public function getTemplateName(): string { return "foo.twig"; } - public function isTraitable() + /** + * @codeCoverageIgnore + */ + public function isTraitable(): bool { return false; } - public function getDebugInfo() + /** + * @codeCoverageIgnore + */ + public function getDebugInfo(): array { - return array ( 43 => 1, 41 => 2, 34 => 1,); + return array ( 48 => 1, 46 => 2, 39 => 1,); } - public function getSourceContext() + public function getSourceContext(): Source { return new Source("", "foo.twig", ""); } } EOF - , $twig, true]; - - $set = new SetNode(false, new Node([new AssignNameExpression('foo', 4)]), new Node([new ConstantExpression('foo', 4)]), 4); - $body = new Node([$set]); - $extends = new ConditionalExpression( - new ConstantExpression(true, 2), - new ConstantExpression('foo', 2), - new ConstantExpression('foo', 2), - 2 - ); - - $twig = new Environment($this->createMock(LoaderInterface::class), ['debug' => true]); - $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new Node([]), $source); + , $twig, true]; + + $set = new SetNode(false, new Nodes([new AssignContextVariable('foo', 4)]), new Nodes([new ConstantExpression('foo', 4)]), 4); + $body = new BodyNode([$set]); + $extends = new ConditionalTernary( + new ConstantExpression(true, 2), + new ConstantExpression('foo', 2), + new ConstantExpression('foo', 2), + 2 + ); + + $twig = new Environment(new ArrayLoader(['foo.twig' => '{{ foo }}']), ['debug' => true]); + $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new EmptyNode(), $source); $tests[] = [$node, << + */ + private array \$macros = []; public function __construct(Environment \$env) { @@ -233,43 +277,52 @@ public function __construct(Environment \$env) ]; } - protected function doGetParent(array \$context) + 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 = []) + protected function doDisplay(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; // line 4 \$context["foo"] = "foo"; // line 2 - \$this->getParent(\$context)->display(\$context, array_merge(\$this->blocks, \$blocks)); + yield from \$this->getParent(\$context)->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks)); } - public function getTemplateName() + /** + * @codeCoverageIgnore + */ + public function getTemplateName(): string { return "foo.twig"; } - public function isTraitable() + /** + * @codeCoverageIgnore + */ + public function isTraitable(): bool { return false; } - public function getDebugInfo() + /** + * @codeCoverageIgnore + */ + public function getDebugInfo(): array { - return array ( 43 => 2, 41 => 4, 34 => 2,); + return array ( 48 => 2, 46 => 4, 39 => 2,); } - public function getSourceContext() + public function getSourceContext(): Source { return new Source("{{ foo }}", "foo.twig", ""); } } EOF - , $twig, true]; + , $twig, true]; return $tests; } diff --git a/tests/Node/NodeTest.php b/tests/Node/NodeTest.php new file mode 100644 index 00000000000..a56b9c5e890 --- /dev/null +++ b/tests/Node/NodeTest.php @@ -0,0 +1,146 @@ + function () { return '1'; }], 1); + + $this->assertEquals(<< new TwigFunction('a_function'), + 'filter' => new TwigFilter('a_filter'), + 'test' => new TwigTest('a_test'), + ], 1); + + $this->assertEquals(<<setNodeTag('tag'); + + $this->assertEquals(<< false]); + $node->deprecateAttribute('foo', new NameDeprecation('foo/bar', '2.0', 'bar')); + + $this->assertFalse($node->getAttribute('foo', false)); + } + + /** + * @group legacy + */ + public function testAttributeDeprecationWithoutAlternative() + { + $node = new NodeForTest([], ['foo' => false]); + $node->deprecateAttribute('foo', new NameDeprecation('foo/bar', '2.0')); + + $this->expectDeprecation('Since foo/bar 2.0: Getting attribute "foo" on a "Twig\Tests\Node\NodeForTest" class is deprecated.'); + $this->assertFalse($node->getAttribute('foo')); + } + + /** + * @group legacy + */ + public function testAttributeDeprecationWithAlternative() + { + $node = new NodeForTest([], ['foo' => false]); + $node->deprecateAttribute('foo', new NameDeprecation('foo/bar', '2.0', 'bar')); + + $this->expectDeprecation('Since foo/bar 2.0: Getting attribute "foo" on a "Twig\Tests\Node\NodeForTest" class is deprecated, get the "bar" attribute instead.'); + $this->assertFalse($node->getAttribute('foo')); + } + + public function testNodeDeprecationIgnore() + { + $node = new NodeForTest(['foo' => $foo = new NodeForTest()]); + $node->deprecateNode('foo', new NameDeprecation('foo/bar', '2.0')); + + $this->assertSame($foo, $node->getNode('foo', false)); + } + + /** + * @group legacy + */ + public function testNodeDeprecationWithoutAlternative() + { + $node = new NodeForTest(['foo' => $foo = new NodeForTest()]); + $node->deprecateNode('foo', new NameDeprecation('foo/bar', '2.0')); + + $this->expectDeprecation('Since foo/bar 2.0: Getting node "foo" on a "Twig\Tests\Node\NodeForTest" class is deprecated.'); + $this->assertSame($foo, $node->getNode('foo')); + } + + /** + * @group legacy + */ + public function testNodeAttributeDeprecationWithAlternative() + { + $node = new NodeForTest(['foo' => $foo = new NodeForTest()]); + $node->deprecateNode('foo', new NameDeprecation('foo/bar', '2.0', 'bar')); + + $this->expectDeprecation('Since foo/bar 2.0: Getting node "foo" on a "Twig\Tests\Node\NodeForTest" class is deprecated, get the "bar" node instead.'); + $this->assertSame($foo, $node->getNode('foo')); + } +} + +class NodeForTest extends Node +{ +} diff --git a/tests/Node/PrintTest.php b/tests/Node/PrintTest.php index 49f8eb49840..250ba572ce9 100644 --- a/tests/Node/PrintTest.php +++ b/tests/Node/PrintTest.php @@ -1,5 +1,14 @@ assertEquals($expr, $node->getNode('expr')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; - $tests[] = [new PrintNode(new ConstantExpression('foo', 1), 1), "// line 1\necho \"foo\";"]; + $tests[] = [new PrintNode(new ConstantExpression('foo', 1), 1), "// line 1\nyield \"foo\";"]; + + $expr = new ContextVariable('foo', 1); + $attr = new ConstantExpression('bar', 1); + $node = new GetAttrExpression($expr, $attr, null, Template::METHOD_CALL, 1); + $node->setAttribute('is_generator', true); + $tests[] = [new PrintNode($node, 1), "// line 1\nyield from CoreExtension::getAttribute(\$this->env, \$this->source, (\$context[\"foo\"] ?? null), \"bar\", [], \"method\", false, false, false, 1);"]; return $tests; } diff --git a/tests/Node/SandboxTest.php b/tests/Node/SandboxTest.php index 7cbddd75fb9..c8df0ad8ddd 100644 --- a/tests/Node/SandboxTest.php +++ b/tests/Node/SandboxTest.php @@ -1,5 +1,14 @@ assertEquals($body, $node->getNode('body')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; @@ -38,7 +47,7 @@ public function getTests() \$this->sandbox->enableSandbox(); } try { - echo "foo"; + yield "foo"; } finally { if (!\$alreadySandboxed) { \$this->sandbox->disableSandbox(); diff --git a/tests/Node/SetTest.php b/tests/Node/SetTest.php index 370af95f2cd..c1eb95f4021 100644 --- a/tests/Node/SetTest.php +++ b/tests/Node/SetTest.php @@ -1,5 +1,14 @@ assertEquals($names, $node->getNode('names')); @@ -33,12 +44,12 @@ public function testConstructor() $this->assertFalse($node->getAttribute('capture')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; - $names = new Node([new AssignNameExpression('foo', 1)], [], 1); - $values = new Node([new ConstantExpression('foo', 1)], [], 1); + $names = new Nodes([new AssignContextVariable('foo', 1)], 1); + $values = new Nodes([new ConstantExpression('foo', 1)], 1); $node = new SetNode(false, $names, $values, 1); $tests[] = [$node, <<env->getCharset()); +\$context["foo"] = ('' === \$tmp = implode('', iterator_to_array((function () use (&\$context, \$macros, \$blocks) { + yield "foo"; + yield from []; +})(), false))) ? '' : new Markup(\$tmp, \$this->env->getCharset()); +EOF + , new Environment(new ArrayLoader(), ['use_yield' => true]), + ]; + + $tests[] = [$node, <<<'EOF' +// line 1 +$context["foo"] = ('' === $tmp = \Twig\Extension\CoreExtension::captureOutput((function () use (&$context, $macros, $blocks) { + yield "foo"; + yield from []; +})())) ? '' : new Markup($tmp, $this->env->getCharset()); EOF + , new Environment(new ArrayLoader(), ['use_yield' => false]), ]; - $names = new Node([new AssignNameExpression('foo', 1)], [], 1); + $names = new Nodes([new AssignContextVariable('foo', 1)], 1); $values = new TextNode('foo', 1); $node = new SetNode(true, $names, $values, 1); $tests[] = [$node, <<env->getCharset()); +\$context["foo"] = new Markup("foo", \$this->env->getCharset()); EOF ]; - $names = new Node([new AssignNameExpression('foo', 1), new AssignNameExpression('bar', 1)], [], 1); - $values = new Node([new ConstantExpression('foo', 1), new NameExpression('bar', 1)], [], 1); - $node = new SetNode(false, $names, $values, 1); + $names = new Nodes([new AssignContextVariable('foo', 1)], 1); + $values = new TextNode('', 1); + $node = new SetNode(true, $names, $values, 1); $tests[] = [$node, <<getVariableGetter('bar')}]; +\$context["foo"] = ""; +EOF + ]; + + $names = new Nodes([new AssignContextVariable('foo', 1)], 1); + $values = new PrintNode(new ConstantExpression('foo', 1), 1); + $node = new SetNode(true, $names, $values, 1); + $tests[] = [$node, <<env->getCharset()); +EOF + ]; + + $names = new Nodes([new AssignContextVariable('foo', 1), new AssignContextVariable('bar', 1)], 1); + $values = new Nodes([new ConstantExpression('foo', 1), new ContextVariable('bar', 1)], 1); + $node = new SetNode(false, $names, $values, 1); + $tests[] = [$node, <<<'EOF' +// line 1 +[$context["foo"], $context["bar"]] = ["foo", ($context["bar"] ?? null)]; EOF ]; diff --git a/tests/Node/TextTest.php b/tests/Node/TextTest.php index ace191213d8..ecb306872b1 100644 --- a/tests/Node/TextTest.php +++ b/tests/Node/TextTest.php @@ -1,5 +1,14 @@ assertEquals('foo', $node->getAttribute('data')); } - public function getTests() + public static function provideTests(): iterable { $tests = []; - $tests[] = [new TextNode('foo', 1), "// line 1\necho \"foo\";"]; + $tests[] = [new TextNode('foo', 1), "// line 1\nyield \"foo\";"]; return $tests; } diff --git a/tests/Node/TypesTest.php b/tests/Node/TypesTest.php new file mode 100644 index 00000000000..0bb1ef044f1 --- /dev/null +++ b/tests/Node/TypesTest.php @@ -0,0 +1,52 @@ + [ + 'type' => 'string', + 'optional' => false, + ], + 'bar' => [ + 'type' => 'number', + 'optional' => true, + ], + ]; + } + + public function testConstructor() + { + $types = self::getValidMapping(); + $node = new TypesNode($types, 1); + + $this->assertEquals($types, $node->getAttribute('mapping')); + } + + public static function provideTests(): iterable + { + return [ + // 1st test: Node shouldn't compile at all + [ + new TypesNode(self::getValidMapping(), 1), + '', + ], + ]; + } +} diff --git a/tests/NodeVisitor/OptimizerTest.php b/tests/NodeVisitor/OptimizerTest.php index 8d02f6c7e2a..b333f56cc1b 100644 --- a/tests/NodeVisitor/OptimizerTest.php +++ b/tests/NodeVisitor/OptimizerTest.php @@ -1,5 +1,14 @@ expectNotToPerformAssertions(); + + new OptimizerNodeVisitor(OptimizerNodeVisitor::OPTIMIZE_FOR); + } + public function testRenderBlockOptimizer() { - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $stream = $env->parse($env->tokenize(new Source('{{ block("foo") }}', 'index'))); - $node = $stream->getNode('body')->getNode(0); + $node = $stream->getNode('body')->getNode('0'); $this->assertInstanceOf(BlockReferenceExpression::class, $node); $this->assertTrue($node->getAttribute('output')); @@ -36,31 +54,54 @@ public function testRenderBlockOptimizer() public function testRenderParentBlockOptimizer() { - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false, 'autoescape' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $stream = $env->parse($env->tokenize(new Source('{% extends "foo" %}{% block content %}{{ parent() }}{% endblock %}', 'index'))); - $node = $stream->getNode('blocks')->getNode('content')->getNode(0)->getNode('body'); + $node = $stream->getNode('blocks')->getNode('content')->getNode('0')->getNode('body'); $this->assertInstanceOf(ParentExpression::class, $node); $this->assertTrue($node->getAttribute('output')); } + public function testForVarOptimizer() + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + + $template = '{% for i, j in foo %}{{ loop.index }}{{ i }}{{ j }}{% endfor %}'; + $stream = $env->parse($env->tokenize(new Source($template, 'index'))); + + foreach (['loop', 'i', 'j'] as $target) { + $this->checkForVarConfiguration($stream, $target); + } + } + + public function checkForVarConfiguration(Node $node, $target) + { + foreach ($node as $n) { + if (NameExpression::class === $n::class && $target === $n->getAttribute('name')) { + $this->assertTrue($n->getAttribute('always_defined')); + } else { + $this->checkForVarConfiguration($n, $target); + } + } + } + /** - * @dataProvider getTestsForForOptimizer + * @dataProvider getTestsForForLoopOptimizer */ - public function testForOptimizer($template, $expected) + public function testForLoopOptimizer($template, $expected) { - $env = new Environment($this->createMock(LoaderInterface::class), ['cache' => false]); + $env = new Environment(new ArrayLoader(), ['cache' => false]); $stream = $env->parse($env->tokenize(new Source($template, 'index'))); foreach ($expected as $target => $withLoop) { - $this->assertTrue($this->checkForConfiguration($stream, $target, $withLoop), sprintf('variable %s is %soptimized', $target, $withLoop ? 'not ' : '')); + $this->assertTrue($this->checkForLoopConfiguration($stream, $target, $withLoop), \sprintf('variable %s is %soptimized', $target, $withLoop ? 'not ' : '')); } } - public function getTestsForForOptimizer() + public static function getTestsForForLoopOptimizer() { return [ ['{% for i in foo %}{% endfor %}', ['i' => false]], @@ -99,7 +140,7 @@ public function getTestsForForOptimizer() ]; } - public function checkForConfiguration(Node $node, $target, $withLoop) + public function checkForLoopConfiguration(Node $node, $target, $withLoop) { foreach ($node as $n) { if ($n instanceof ForNode) { @@ -108,7 +149,7 @@ public function checkForConfiguration(Node $node, $target, $withLoop) } } - $ret = $this->checkForConfiguration($n, $target, $withLoop); + $ret = $this->checkForLoopConfiguration($n, $target, $withLoop); if (null !== $ret) { return $ret; } diff --git a/tests/NodeVisitor/SandboxTest.php b/tests/NodeVisitor/SandboxTest.php new file mode 100644 index 00000000000..e7efcc87489 --- /dev/null +++ b/tests/NodeVisitor/SandboxTest.php @@ -0,0 +1,50 @@ +setAttribute('is_generator', true); + $node = new ModuleNode(new BodyNode([new PrintNode($expr, 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); + + $this->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'))); + } +} diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 65956cf7e74..d103fefbf0e 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -1,5 +1,14 @@ expectException(SyntaxError::class); - $this->expectExceptionMessage('Unknown "foo" tag. Did you mean "for" at line 1?'); - $stream = new TokenStream([ new Token(Token::BLOCK_START_TYPE, '', 1), new Token(Token::NAME_TYPE, 'foo', 1), new Token(Token::BLOCK_END_TYPE, '', 1), new Token(Token::EOF_TYPE, '', 1), - ]); - $parser = new Parser(new Environment($this->createMock(LoaderInterface::class))); + ], new Source('', '')); + $parser = new Parser(new Environment(new ArrayLoader())); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unknown "foo" tag. Did you mean "for" at line 1?'); + $parser->parse($stream); } public function testUnknownTagWithoutSuggestions() { - $this->expectException(SyntaxError::class); - $this->expectExceptionMessage('Unknown "foobar" tag at line 1.'); - $stream = new TokenStream([ new Token(Token::BLOCK_START_TYPE, '', 1), new Token(Token::NAME_TYPE, 'foobar', 1), new Token(Token::BLOCK_END_TYPE, '', 1), new Token(Token::EOF_TYPE, '', 1), - ]); - $parser = new Parser(new Environment($this->createMock(LoaderInterface::class))); + ], new Source('', '')); + $parser = new Parser(new Environment(new ArrayLoader())); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unknown "foobar" tag at line 1.'); + $parser->parse($stream); } @@ -68,19 +82,19 @@ public function testFilterBodyNodes($input, $expected) $this->assertEquals($expected, $m->invoke($parser, $input)); } - public function getFilterBodyNodesData() + public static function getFilterBodyNodesData() { return [ [ - new Node([new TextNode(' ', 1)]), - new Node([]), + new Nodes([new TextNode(' ', 1)]), + new Nodes([]), ], [ - $input = new Node([new SetNode(false, new Node(), new Node(), 1)]), + $input = new Nodes([new SetNode(false, new EmptyNode(), new EmptyNode(), 1)]), $input, ], [ - $input = new Node([new SetNode(true, new Node(), new Node([new Node([new TextNode('foo', 1)])]), 1)]), + $input = new Nodes([new SetNode(true, new EmptyNode(), new Nodes([new Nodes([new TextNode('foo', 1)])]), 1)]), $input, ], ]; @@ -91,21 +105,20 @@ public function getFilterBodyNodesData() */ public function testFilterBodyNodesThrowsException($input) { - $this->expectException(SyntaxError::class); - $parser = $this->getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); $m->setAccessible(true); + $this->expectException(SyntaxError::class); $m->invoke($parser, $input); } - public function getFilterBodyNodesDataThrowsException() + public static function getFilterBodyNodesDataThrowsException() { return [ [new TextNode('foo', 1)], - [new Node([new Node([new TextNode('foo', 1)])])], + [new Nodes([new Nodes([new TextNode('foo', 1)])])], ]; } @@ -121,7 +134,7 @@ public function testFilterBodyNodesWithBOM($emptyNode) $this->assertNull($m->invoke($parser, new TextNode(\chr(0xEF).\chr(0xBB).\chr(0xBF).$emptyNode, 1))); } - public function getFilterBodyNodesWithBOMData() + public static function getFilterBodyNodesWithBOMData() { return [ [' '], @@ -133,7 +146,7 @@ public function getFilterBodyNodesWithBOMData() public function testParseIsReentrant() { - $twig = new Environment($this->createMock(LoaderInterface::class), [ + $twig = new Environment(new ArrayLoader(), [ 'autoescape' => false, 'optimizations' => 0, ]); @@ -149,14 +162,16 @@ public function testParseIsReentrant() new Token(Token::NAME_TYPE, 'foo', 1), new Token(Token::VAR_END_TYPE, '', 1), new Token(Token::EOF_TYPE, '', 1), - ])); + ], new Source('', ''))); - $this->assertNull($parser->getParent()); + $p = new \ReflectionProperty($parser, 'parent'); + $p->setAccessible(true); + $this->assertNull($p->getValue($parser)); } public function testGetVarName() { - $twig = new Environment($this->createMock(LoaderInterface::class), [ + $twig = new Environment(new ArrayLoader(), [ 'autoescape' => false, 'optimizations' => 0, ]); @@ -168,21 +183,41 @@ public function testGetVarName() {{ foo }} {% endmacro %} EOF - , 'index'))); + , 'index'))); // The getVarName() must not depend on the template loaders, // If this test does not throw any exception, that's good. $this->addToAssertionCount(1); } + public function testImplicitMacroArgumentDefaultValues() + { + $template = '{% macro marco (po, lo = true) %}{% endmacro %}'; + $lexer = new Lexer(new Environment(new ArrayLoader())); + $stream = $lexer->tokenize(new Source($template, 'index')); + + $argumentNodes = $this->getParser() + ->parse($stream) + ->getNode('macros') + ->getNode('marco') + ->getNode('arguments') + ; + + $this->assertTrue($argumentNodes->getNode(1)->hasAttribute('is_implicit')); + $this->assertNull($argumentNodes->getNode(1)->getAttribute('value')); + + $this->assertFalse($argumentNodes->getNode(3)->hasAttribute('is_implicit')); + $this->assertTrue($argumentNodes->getNode(3)->getAttribute('value')); + } + protected function getParser() { - $parser = new Parser(new Environment($this->createMock(LoaderInterface::class))); - $parser->setParent(new Node()); + $parser = new Parser(new Environment(new ArrayLoader())); + $parser->setParent(new EmptyNode()); $p = new \ReflectionProperty($parser, 'stream'); $p->setAccessible(true); - $p->setValue($parser, new TokenStream([])); + $p->setValue($parser, new TokenStream([], new Source('', ''))); return $parser; } @@ -199,11 +234,11 @@ public function parse(Token $token): Node new Token(Token::STRING_TYPE, 'base', 1), new Token(Token::BLOCK_END_TYPE, '', 1), new Token(Token::EOF_TYPE, '', 1), - ])); + ], new Source('', ''))); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); - return new Node([]); + return new EmptyNode(1); } public function getTag(): string diff --git a/tests/Profiler/Dumper/BlackfireTest.php b/tests/Profiler/Dumper/BlackfireTest.php index b1c2cd7d1f0..32eef2f860f 100644 --- a/tests/Profiler/Dumper/BlackfireTest.php +++ b/tests/Profiler/Dumper/BlackfireTest.php @@ -1,5 +1,14 @@ included.twig//2 %d %d %d index.twig==>index.twig::macro(foo)//1 %d %d %d EOF - , $dumper->dump($this->getProfile())); + , $dumper->dump($this->getProfile())); } } diff --git a/tests/Profiler/Dumper/HtmlTest.php b/tests/Profiler/Dumper/HtmlTest.php index 20a1ab439c5..a9907020a37 100644 --- a/tests/Profiler/Dumper/HtmlTest.php +++ b/tests/Profiler/Dumper/HtmlTest.php @@ -1,5 +1,14 @@ included.twig EOF - , $dumper->dump($this->getProfile())); + , $dumper->dump($this->getProfile())); } } diff --git a/tests/Profiler/Dumper/AbstractTest.php b/tests/Profiler/Dumper/ProfilerTestCase.php similarity index 92% rename from tests/Profiler/Dumper/AbstractTest.php rename to tests/Profiler/Dumper/ProfilerTestCase.php index 29e40d26cbc..24f2da2a184 100644 --- a/tests/Profiler/Dumper/AbstractTest.php +++ b/tests/Profiler/Dumper/ProfilerTestCase.php @@ -1,5 +1,14 @@ dump($this->getProfile())); + , $dumper->dump($this->getProfile())); } } diff --git a/tests/Profiler/ProfileTest.php b/tests/Profiler/ProfileTest.php index a1f553cb756..27e013fc6d5 100644 --- a/tests/Profiler/ProfileTest.php +++ b/tests/Profiler/ProfileTest.php @@ -1,5 +1,14 @@ leave(); - $this->assertTrue($profile->getDuration() > 0, sprintf('Expected duration > 0, got: %f', $profile->getDuration())); + $this->assertTrue($profile->getDuration() > 0, \sprintf('Expected duration > 0, got: %f', $profile->getDuration())); + } + + public function testTimeAccessors() + { + $current = microtime(true); + $profile = new Profile(); + + $this->assertEqualsWithDelta($current, $profile->getStartTime(), 1); + $this->assertSame(0.0, $profile->getEndTime()); } public function testSerialize() diff --git a/tests/Runtime/EscaperRuntimeTest.php b/tests/Runtime/EscaperRuntimeTest.php new file mode 100644 index 00000000000..606ca297921 --- /dev/null +++ b/tests/Runtime/EscaperRuntimeTest.php @@ -0,0 +1,415 @@ + ''', + '"' => '"', + '<' => '<', + '>' => '>', + '&' => '&', + ]; + + protected $htmlAttrSpecialChars = [ + '\'' => ''', + /* Characters beyond ASCII value 255 to unicode escape */ + 'Ā' => 'Ā', + '😀' => '😀', + /* Immune chars excluded */ + ',' => ',', + '.' => '.', + '-' => '-', + '_' => '_', + /* Basic alnums excluded */ + 'a' => 'a', + 'A' => 'A', + 'z' => 'z', + 'Z' => 'Z', + '0' => '0', + '9' => '9', + /* Basic control characters and null */ + "\r" => ' ', + "\n" => ' ', + "\t" => ' ', + "\0" => '�', // should use Unicode replacement char + /* Encode chars as named entities where possible */ + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + /* Encode spaces for quoteless attribute protection */ + ' ' => ' ', + ]; + + protected $jsSpecialChars = [ + /* HTML special chars - escape without exception to hex */ + '<' => '\\u003C', + '>' => '\\u003E', + '\'' => '\\u0027', + '"' => '\\u0022', + '&' => '\\u0026', + '/' => '\\/', + /* Characters beyond ASCII value 255 to unicode escape */ + 'Ā' => '\\u0100', + '😀' => '\\uD83D\\uDE00', + /* Immune chars excluded */ + ',' => ',', + '.' => '.', + '_' => '_', + /* Basic alnums excluded */ + 'a' => 'a', + 'A' => 'A', + 'z' => 'z', + 'Z' => 'Z', + '0' => '0', + '9' => '9', + /* Basic control characters and null */ + "\r" => '\r', + "\n" => '\n', + "\x08" => '\b', + "\t" => '\t', + "\x0C" => '\f', + "\0" => '\\u0000', + /* Encode spaces for quoteless attribute protection */ + ' ' => '\\u0020', + ]; + + protected $urlSpecialChars = [ + /* HTML special chars - escape without exception to percent encoding */ + '<' => '%3C', + '>' => '%3E', + '\'' => '%27', + '"' => '%22', + '&' => '%26', + /* Characters beyond ASCII value 255 to hex sequence */ + 'Ā' => '%C4%80', + /* Punctuation and unreserved check */ + ',' => '%2C', + '.' => '.', + '_' => '_', + '-' => '-', + ':' => '%3A', + ';' => '%3B', + '!' => '%21', + /* Basic alnums excluded */ + 'a' => 'a', + 'A' => 'A', + 'z' => 'z', + 'Z' => 'Z', + '0' => '0', + '9' => '9', + /* Basic control characters and null */ + "\r" => '%0D', + "\n" => '%0A', + "\t" => '%09', + "\0" => '%00', + /* PHP quirks from the past */ + ' ' => '%20', + '~' => '~', + '+' => '%2B', + ]; + + protected $cssSpecialChars = [ + /* HTML special chars - escape without exception to hex */ + '<' => '\\3C ', + '>' => '\\3E ', + '\'' => '\\27 ', + '"' => '\\22 ', + '&' => '\\26 ', + /* Characters beyond ASCII value 255 to unicode escape */ + 'Ā' => '\\100 ', + /* Immune chars excluded */ + ',' => '\\2C ', + '.' => '\\2E ', + '_' => '\\5F ', + /* Basic alnums excluded */ + 'a' => 'a', + 'A' => 'A', + 'z' => 'z', + 'Z' => 'Z', + '0' => '0', + '9' => '9', + /* Basic control characters and null */ + "\r" => '\\D ', + "\n" => '\\A ', + "\t" => '\\9 ', + "\0" => '\\0 ', + /* Encode spaces for quoteless attribute protection */ + ' ' => '\\20 ', + ]; + + public function testHtmlEscapingConvertsSpecialChars() + { + foreach ($this->htmlSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html'), 'Failed to escape: '.$key); + } + } + + public function testHtmlAttributeEscapingConvertsSpecialChars() + { + foreach ($this->htmlAttrSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr'), 'Failed to escape: '.$key); + } + } + + public function testJavascriptEscapingConvertsSpecialChars() + { + foreach ($this->jsSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'js'), 'Failed to escape: '.$key); + } + } + + public function testJavascriptEscapingConvertsSpecialCharsWithInternalEncoding() + { + $previousInternalEncoding = mb_internal_encoding(); + try { + mb_internal_encoding('ISO-8859-1'); + foreach ($this->jsSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'js'), 'Failed to escape: '.$key); + } + } finally { + if (false !== $previousInternalEncoding) { + mb_internal_encoding($previousInternalEncoding); + } + } + } + + public function testJavascriptEscapingReturnsStringIfZeroLength() + { + $this->assertEquals('', (new EscaperRuntime())->escape('', 'js')); + } + + public function testJavascriptEscapingReturnsStringIfContainsOnlyDigits() + { + $this->assertEquals('123', (new EscaperRuntime())->escape('123', 'js')); + } + + public function testCssEscapingConvertsSpecialChars() + { + foreach ($this->cssSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'css'), 'Failed to escape: '.$key); + } + } + + public function testCssEscapingReturnsStringIfZeroLength() + { + $this->assertEquals('', (new EscaperRuntime())->escape('', 'css')); + } + + public function testCssEscapingReturnsStringIfContainsOnlyDigits() + { + $this->assertEquals('123', (new EscaperRuntime())->escape('123', 'css')); + } + + public function testUrlEscapingConvertsSpecialChars() + { + foreach ($this->urlSpecialChars as $key => $value) { + $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'url'), 'Failed to escape: '.$key); + } + } + + /** + * Range tests to confirm escaped range of characters is within OWASP recommendation. + */ + + /** + * Only testing the first few 2 ranges on this prot. function as that's all these + * other range tests require. + */ + public function testUnicodeCodepointConversionToUtf8() + { + $expected = ' ~ޙ'; + $codepoints = [0x20, 0x7E, 0x799]; + $result = ''; + foreach ($codepoints as $value) { + $result .= $this->codepointToUtf8($value); + } + $this->assertEquals($expected, $result); + } + + /** + * Convert a Unicode Codepoint to a literal UTF-8 character. + * + * @param int $codepoint Unicode codepoint in hex notation + * + * @return string UTF-8 literal string + */ + protected function codepointToUtf8($codepoint) + { + if ($codepoint < 0x80) { + return \chr($codepoint); + } + if ($codepoint < 0x800) { + return \chr($codepoint >> 6 & 0x3F | 0xC0) + .\chr($codepoint & 0x3F | 0x80); + } + if ($codepoint < 0x10000) { + return \chr($codepoint >> 12 & 0x0F | 0xE0) + .\chr($codepoint >> 6 & 0x3F | 0x80) + .\chr($codepoint & 0x3F | 0x80); + } + if ($codepoint < 0x110000) { + return \chr($codepoint >> 18 & 0x07 | 0xF0) + .\chr($codepoint >> 12 & 0x3F | 0x80) + .\chr($codepoint >> 6 & 0x3F | 0x80) + .\chr($codepoint & 0x3F | 0x80); + } + throw new \Exception('Codepoint requested outside of Unicode range.'); + } + + public function testJavascriptEscapingEscapesOwaspRecommendedRanges() + { + $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, 'js')); + } else { + $literal = $this->codepointToUtf8($chr); + if (\in_array($literal, $immune)) { + $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'js')); + } else { + $this->assertNotEquals( + $literal, + (new EscaperRuntime())->escape($literal, 'js'), + "$literal should be escaped!"); + } + } + } + } + + public function testHtmlAttributeEscapingEscapesOwaspRecommendedRanges() + { + $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')); + } else { + $literal = $this->codepointToUtf8($chr); + if (\in_array($literal, $immune)) { + $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr')); + } else { + $this->assertNotEquals( + $literal, + (new EscaperRuntime())->escape($literal, 'html_attr'), + "$literal should be escaped!"); + } + } + } + } + + public function testCssEscapingEscapesOwaspRecommendedRanges() + { + // CSS has no 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, 'css')); + } else { + $literal = $this->codepointToUtf8($chr); + $this->assertNotEquals( + $literal, + (new EscaperRuntime())->escape($literal, 'css'), + "$literal should be escaped!"); + } + } + } + + public function testUnknownCustomEscaper() + { + $this->expectException(RuntimeError::class); + + (new EscaperRuntime())->escape('foo', 'bar'); + } + + /** + * @dataProvider provideCustomEscaperCases + */ + public function testCustomEscaper($expected, $string, $strategy, $charset) + { + $escaper = new EscaperRuntime(); + $escaper->setEscaper('foo', 'Twig\Tests\Runtime\escaper'); + $this->assertSame($expected, $escaper->escape($string, $strategy, $charset)); + } + + public static function provideCustomEscaperCases() + { + return [ + ['foo**ISO-8859-1', 'foo', 'foo', 'ISO-8859-1'], + ['**ISO-8859-1', null, 'foo', 'ISO-8859-1'], + ['42**UTF-8', 42, 'foo', null], + ]; + } + + /** + * @dataProvider provideObjectsForEscaping + */ + public function testObjectEscaping(string $escapedHtml, string $escapedJs, array $safeClasses) + { + $obj = new Extension_TestClass(); + $escaper = new EscaperRuntime(); + $escaper->setSafeClasses($safeClasses); + $this->assertSame($escapedHtml, $escaper->escape($obj, 'html', null, true)); + $this->assertSame($escapedJs, $escaper->escape($obj, 'js', null, true)); + } + + public static function provideObjectsForEscaping() + { + return [ + ['<br />', '
    ', ['\Twig\Tests\Runtime\Extension_TestClass' => ['js']]], + ['
    ', '\u003Cbr\u0020\/\u003E', ['\Twig\Tests\Runtime\Extension_TestClass' => ['html']]], + ['<br />', '
    ', ['\Twig\Tests\Runtime\Extension_SafeHtmlInterface' => ['js']]], + ['
    ', '
    ', ['\Twig\Tests\Runtime\Extension_SafeHtmlInterface' => ['all']]], + ]; + } +} + +function escaper($string, $charset) +{ + return $string.'**'.$charset; +} + +interface Extension_SafeHtmlInterface +{ +} +class Extension_TestClass implements Extension_SafeHtmlInterface +{ + public function __toString() + { + return '
    '; + } +} diff --git a/tests/TemplateTest.php b/tests/TemplateTest.php index bd26e9d95b7..0d851ab9b53 100644 --- a/tests/TemplateTest.php +++ b/tests/TemplateTest.php @@ -1,5 +1,14 @@ expectException(\LogicException::class); - - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); + + $this->expectException(\LogicException::class); $template->displayBlock('foo', [], ['foo' => [new \stdClass(), 'foo']]); } @@ -56,26 +66,26 @@ public function testGetAttributeExceptions($template, $message) $template->render($context); $this->fail('Accessing an invalid attribute should throw an exception.'); } catch (RuntimeError $e) { - $this->assertSame(sprintf($message, 'index'), $e->getMessage()); + $this->assertSame(\sprintf($message, 'index'), $e->getMessage()); } } - public function getAttributeExceptions() + public static function getAttributeExceptions() { return [ ['{{ string["a"] }}', 'Impossible to access a key ("a") on a string variable ("foo") in "%s" at line 1.'], ['{{ null["a"] }}', 'Impossible to access a key ("a") on a null variable in "%s" at line 1.'], - ['{{ empty_array["a"] }}', 'Key "a" does not exist as the array is empty in "%s" at line 1.'], - ['{{ array["a"] }}', 'Key "a" for array with keys "foo" does not exist in "%s" at line 1.'], - ['{{ array_access["a"] }}', 'Key "a" in object with ArrayAccess of class "Twig\Tests\TemplateArrayAccessObject" does not exist in "%s" at line 1.'], + ['{{ empty_array["a"] }}', 'Key "a" does not exist as the sequence/mapping is empty in "%s" at line 1.'], + ['{{ array["a"] }}', 'Key "a" for sequence/mapping with keys "foo" does not exist in "%s" at line 1.'], + ['{{ array_access["a"] }}', 'Key "a" does not exist in ArrayAccess-able object of class "Twig\Tests\TemplateArrayAccessObject" in "%s" at line 1.'], ['{{ string.a }}', 'Impossible to access an attribute ("a") on a string variable ("foo") in "%s" at line 1.'], ['{{ string.a() }}', 'Impossible to invoke a method ("a") on a string variable ("foo") in "%s" at line 1.'], ['{{ null.a }}', 'Impossible to access an attribute ("a") on a null variable in "%s" at line 1.'], ['{{ null.a() }}', 'Impossible to invoke a method ("a") on a null variable in "%s" at line 1.'], - ['{{ array.a() }}', 'Impossible to invoke a method ("a") on an array in "%s" at line 1.'], - ['{{ empty_array.a }}', 'Key "a" does not exist as the array is empty in "%s" at line 1.'], - ['{{ array.a }}', 'Key "a" for array with keys "foo" does not exist in "%s" at line 1.'], - ['{{ attribute(array, -10) }}', 'Key "-10" for array with keys "foo" does not exist in "%s" at line 1.'], + ['{{ array.a() }}', 'Impossible to invoke a method ("a") on a sequence/mapping in "%s" at line 1.'], + ['{{ empty_array.a }}', 'Key "a" does not exist as the sequence/mapping is empty in "%s" at line 1.'], + ['{{ array.a }}', 'Key "a" for sequence/mapping with keys "foo" does not exist in "%s" at line 1.'], + ['{{ array.(-10) }}', 'Key "-10" for sequence/mapping with keys "foo" does not exist in "%s" at line 1.'], ['{{ array_access.a }}', 'Neither the property "a" nor one of the methods "a()", "geta()"/"isa()"/"hasa()" or "__call()" exist and have public access in class "Twig\Tests\TemplateArrayAccessObject" in "%s" at line 1.'], ['{% from _self import foo %}{% macro foo(obj) %}{{ obj.missing_method() }}{% endmacro %}{{ foo(array_access) }}', 'Neither the property "missing_method" nor one of the methods "missing_method()", "getmissing_method()"/"ismissing_method()"/"hasmissing_method()" or "__call()" exist and have public access in class "Twig\Tests\TemplateArrayAccessObject" in "%s" at line 1.'], ['{{ magic_exception.test }}', 'An exception has been thrown during the rendering of a template ("Hey! Don\'t try to isset me!") in "%s" at line 1.'], @@ -88,13 +98,13 @@ public function getAttributeExceptions() */ public function testGetAttributeWithSandbox($object, $item, $allowed) { - $twig = new Environment($this->createMock(LoaderInterface::class)); - $policy = new SecurityPolicy([], [], [/*method*/], [/*prop*/], []); + $twig = new Environment(new ArrayLoader()); + $policy = new SecurityPolicy([], [], [/* method */], [/* prop */], []); $twig->addExtension(new SandboxExtension($policy, !$allowed)); $template = new TemplateForTest($twig); try { - twig_get_attribute($twig, $template->getSourceContext(), $object, $item, [], 'any', false, false, true); + CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, [], 'any', false, false, true); if (!$allowed) { $this->fail(); @@ -112,7 +122,7 @@ public function testGetAttributeWithSandbox($object, $item, $allowed) } } - public function getGetAttributeWithSandbox() + public static function getGetAttributeWithSandbox() { return [ [new TemplatePropertyObject(), 'defined', false], @@ -122,45 +132,60 @@ public function getGetAttributeWithSandbox() ]; } + /** + * @dataProvider getRenderTemplateWithoutOutputData + */ + public function testRenderTemplateWithoutOutput(string $template) + { + $twig = new Environment(new ArrayLoader(['index' => $template])); + $this->assertSame('', $twig->render('index')); + } + + public static function getRenderTemplateWithoutOutputData() + { + return [ + [''], + ['{% for var in [] %}{% endfor %}'], + ['{% if false %}{% endif %}'], + ]; + } + public function testRenderBlockWithUndefinedBlock() { + $twig = new Environment(new ArrayLoader()); + $template = new TemplateForTest($twig, 'index.twig'); + $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Block "unknown" on template "index.twig" does not exist in "index.twig".'); - $twig = new Environment($this->createMock(LoaderInterface::class)); - $template = new TemplateForTest($twig, 'index.twig'); - try { - $template->renderBlock('unknown', []); - } catch (\Exception $e) { - ob_end_clean(); - - throw $e; - } + $template->renderBlock('unknown', []); } public function testDisplayBlockWithUndefinedBlock() { + $twig = new Environment(new ArrayLoader()); + $template = new TemplateForTest($twig, 'index.twig'); + $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Block "unknown" on template "index.twig" does not exist in "index.twig".'); - $twig = new Environment($this->createMock(LoaderInterface::class)); - $template = new TemplateForTest($twig, 'index.twig'); $template->displayBlock('unknown', []); } public function testDisplayBlockWithUndefinedParentBlock() { + $twig = new Environment(new ArrayLoader()); + $template = new TemplateForTest($twig, 'parent.twig'); + $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Block "foo" should not call parent() in "index.twig" as the block does not exist in the parent template "parent.twig"'); - $twig = new Environment($this->createMock(LoaderInterface::class)); - $template = new TemplateForTest($twig, 'parent.twig'); $template->displayBlock('foo', [], ['foo' => [new TemplateForTest($twig, 'index.twig'), 'block_foo']], false); } public function testGetAttributeOnArrayWithConfusableKey() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); $array = ['Zero', 'One', -1 => 'MinusOne', '' => 'EmptyString', '1.5' => 'FloatButString', '01' => 'IntegerButStringWithLeadingZeros']; @@ -180,14 +205,14 @@ public function testGetAttributeOnArrayWithConfusableKey() $this->assertSame('IntegerButStringWithLeadingZeros', $array['01']); $this->assertSame('EmptyString', $array[null]); - $this->assertSame('Zero', twig_get_attribute($twig, $template->getSourceContext(), $array, false), 'false is treated as 0 when accessing an array (equals PHP behavior)'); - $this->assertSame('One', twig_get_attribute($twig, $template->getSourceContext(), $array, true), 'true is treated as 1 when accessing an array (equals PHP behavior)'); - $this->assertSame('One', twig_get_attribute($twig, $template->getSourceContext(), $array, 1.5), 'float is casted to int when accessing an array (equals PHP behavior)'); - $this->assertSame('One', twig_get_attribute($twig, $template->getSourceContext(), $array, '1'), '"1" is treated as integer 1 when accessing an array (equals PHP behavior)'); - $this->assertSame('MinusOne', twig_get_attribute($twig, $template->getSourceContext(), $array, -1.5), 'negative float is casted to int when accessing an array (equals PHP behavior)'); - $this->assertSame('FloatButString', twig_get_attribute($twig, $template->getSourceContext(), $array, '1.5'), '"1.5" is treated as-is when accessing an array (equals PHP behavior)'); - $this->assertSame('IntegerButStringWithLeadingZeros', twig_get_attribute($twig, $template->getSourceContext(), $array, '01'), '"01" is treated as-is when accessing an array (equals PHP behavior)'); - $this->assertSame('EmptyString', twig_get_attribute($twig, $template->getSourceContext(), $array, null), 'null is treated as "" when accessing an array (equals PHP behavior)'); + $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)'); + $this->assertSame('One', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, 1.5), 'float is casted to int when accessing a sequence/mapping (equals PHP behavior)'); + $this->assertSame('One', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, '1'), '"1" is treated as integer 1 when accessing a sequence/mapping (equals PHP behavior)'); + $this->assertSame('MinusOne', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, -1.5), 'negative float is casted to int when accessing a sequence/mapping (equals PHP behavior)'); + $this->assertSame('FloatButString', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, '1.5'), '"1.5" is treated as-is when accessing a sequence/mapping (equals PHP behavior)'); + $this->assertSame('IntegerButStringWithLeadingZeros', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, '01'), '"01" is treated as-is when accessing a sequence/mapping (equals PHP behavior)'); + $this->assertSame('EmptyString', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, null), 'null is treated as "" when accessing a sequence/mapping (equals PHP behavior)'); } /** @@ -195,10 +220,10 @@ public function testGetAttributeOnArrayWithConfusableKey() */ public function testGetAttribute($defined, $value, $object, $item, $arguments, $type) { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); - $this->assertEquals($value, twig_get_attribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); + $this->assertEquals($value, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); } /** @@ -206,17 +231,17 @@ public function testGetAttribute($defined, $value, $object, $item, $arguments, $ */ public function testGetAttributeStrict($defined, $value, $object, $item, $arguments, $type, $exceptionMessage = null) { - $twig = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => true]); + $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); $template = new TemplateForTest($twig); if ($defined) { - $this->assertEquals($value, twig_get_attribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); + $this->assertEquals($value, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); } else { $this->expectException(RuntimeError::class); if (null !== $exceptionMessage) { $this->expectExceptionMessage($exceptionMessage); } - $this->assertEquals($value, twig_get_attribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); + $this->assertEquals($value, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); } } @@ -225,10 +250,10 @@ public function testGetAttributeStrict($defined, $value, $object, $item, $argume */ public function testGetAttributeDefined($defined, $value, $object, $item, $arguments, $type) { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); - $this->assertEquals($defined, twig_get_attribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); + $this->assertEquals($defined, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); } /** @@ -236,23 +261,23 @@ public function testGetAttributeDefined($defined, $value, $object, $item, $argum */ public function testGetAttributeDefinedStrict($defined, $value, $object, $item, $arguments, $type) { - $twig = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => true]); + $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); $template = new TemplateForTest($twig); - $this->assertEquals($defined, twig_get_attribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); + $this->assertEquals($defined, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); } public function testGetAttributeCallExceptions() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); $object = new TemplateMagicMethodExceptionObject(); - $this->assertNull(twig_get_attribute($twig, $template->getSourceContext(), $object, 'foo')); + $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, 'foo')); } - public function getGetAttributeTests() + public static function getGetAttributeTests() { $array = [ 'defined' => 'defined', @@ -378,11 +403,15 @@ public function getGetAttributeTests() [true, ['foo' => 'bar'], $arrayAccess, 'vars', [], $anyType], ]); + // 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 when input is not an array or object $tests = array_merge($tests, [ - [false, null, 42, 'a', [], $anyType, 'Impossible to access an attribute ("a") on a integer variable ("42") in "index.twig".'], + [false, null, 42, 'a', [], $anyType, 'Impossible to access an attribute ("a") on a int variable ("42") in "index.twig".'], [false, null, 'string', 'a', [], $anyType, 'Impossible to access an attribute ("a") on a string variable ("string") in "index.twig".'], - [false, null, [], 'a', [], $anyType, 'Key "a" does not exist as the array is empty in "index.twig".'], + [false, null, [], 'a', [], $anyType, 'Key "a" does not exist as the sequence/mapping is empty in "index.twig".'], ]); return $tests; @@ -390,14 +419,14 @@ public function getGetAttributeTests() public function testGetIsMethods() { - $twig = new Environment($this->createMock(LoaderInterface::class)); + $twig = new Environment(new ArrayLoader()); $getIsObject = new TemplateGetIsMethods(); $template = new TemplateForTest($twig, 'index.twig'); // first time should not create a cache for "get" - $this->assertNull(twig_get_attribute($twig, $template->getSourceContext(), $getIsObject, 'get')); + $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $getIsObject, 'get')); // 0 should be in the method cache now, so this should fail - $this->assertNull(twig_get_attribute($twig, $template->getSourceContext(), $getIsObject, 0)); + $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $getIsObject, 0)); } } @@ -431,27 +460,27 @@ public function getTrue() return true; } - public function getTemplateName() + public function getTemplateName(): string { return $this->name; } - public function getDebugInfo() + public function getDebugInfo(): array { return []; } - public function getSourceContext() + public function getSourceContext(): Source { return new Source('', $this->getTemplateName()); } - protected function doGetParent(array $context) + protected function doGetParent(array $context): bool|string|Template|TemplateWrapper { return false; } - protected function doDisplay(array $context, array $blocks = []) + protected function doDisplay(array $context, array $blocks = []): iterable { } @@ -477,25 +506,22 @@ class TemplateArrayAccessObject implements \ArrayAccess '+4' => '+4', ]; - public function offsetExists($name) : bool + public function offsetExists($name): bool { return \array_key_exists($name, $this->attributes); } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function offsetGet($name) { return \array_key_exists($name, $this->attributes) ? $this->attributes[$name] : null; } - public function offsetSet($name, $value) : void + public function offsetSet($name, $value): void { } - public function offsetUnset($name) : void + public function offsetUnset($name): void { } } @@ -570,25 +596,22 @@ class TemplatePropertyObjectAndArrayAccess extends TemplatePropertyObject implem 'baf' => 'baf', ]; - public function offsetExists($offset) : bool + public function offsetExists($offset): bool { return \array_key_exists($offset, $this->data); } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->offsetExists($offset) ? $this->data[$offset] : 'n/a'; } - public function offsetSet($offset, $value) : void + public function offsetSet($offset, $value): void { } - public function offsetUnset($offset) : void + public function offsetUnset($offset): void { } } @@ -722,26 +745,23 @@ class TemplateArrayAccess implements \ArrayAccess ]; private $children = []; - public function offsetExists($offset) : bool + public function offsetExists($offset): bool { return \array_key_exists($offset, $this->children); } - /** - * @return mixed - */ #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->children[$offset]; } - public function offsetSet($offset, $value) : void + public function offsetSet($offset, $value): void { $this->children[$offset] = $value; } - public function offsetUnset($offset) : void + public function offsetUnset($offset): void { unset($this->children[$offset]); } @@ -759,6 +779,6 @@ class TemplateMagicMethodExceptionObject { public function __call($method, $arguments) { - throw new \BadMethodCallException(sprintf('Unknown method "%s".', $method)); + throw new \BadMethodCallException(\sprintf('Unknown method "%s".', $method)); } } diff --git a/tests/TemplateWrapperTest.php b/tests/TemplateWrapperTest.php index c524ebe3a8f..73e0cd17840 100644 --- a/tests/TemplateWrapperTest.php +++ b/tests/TemplateWrapperTest.php @@ -1,5 +1,14 @@ '{% block foo %}{{ foo }}{{ bar }}{% endblock %}', ])); + $twig->addGlobal('bar', 'BAR'); $wrapper = $twig->load('index'); diff --git a/tests/TokenParser/GuardTokenParserTest.php b/tests/TokenParser/GuardTokenParserTest.php new file mode 100644 index 00000000000..ae42264fe21 --- /dev/null +++ b/tests/TokenParser/GuardTokenParserTest.php @@ -0,0 +1,31 @@ +expectNotToPerformAssertions(); + + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $env->registerUndefinedFunctionCallback(fn ($name) => throw new SyntaxError('boom.')); + (new Parser($env))->parse($env->tokenize(new Source('{% guard function boom %}{% endguard %}', ''))); + } +} diff --git a/tests/TokenParser/TypesTokenParserTest.php b/tests/TokenParser/TypesTokenParserTest.php new file mode 100644 index 00000000000..49e0ac051eb --- /dev/null +++ b/tests/TokenParser/TypesTokenParserTest.php @@ -0,0 +1,87 @@ + false, 'autoescape' => false]); + $stream = $env->tokenize(new Source($template, '')); + $parser = new Parser($env); + + $typesNode = $parser->parse($stream)->getNode('body')->getNode('0'); + + self::assertEquals($expected, $typesNode->getAttribute('mapping')); + } + + public static function getMappingTests(): array + { + return [ + // empty mapping + [ + '{% types {} %}', + [], + ], + + // simple + [ + '{% types {foo: "bar"} %}', + [ + 'foo' => ['type' => 'bar', 'optional' => false], + ], + ], + + // trailing comma + [ + '{% types {foo: "bar",} %}', + [ + 'foo' => ['type' => 'bar', 'optional' => false], + ], + ], + + // optional name + [ + '{% types {foo?: "bar"} %}', + [ + 'foo' => ['type' => 'bar', 'optional' => true], + ], + ], + + // multiple pairs, duplicate values + [ + '{% types {foo: "foo", bar?: "foo", baz: "baz"} %}', + [ + 'foo' => ['type' => 'foo', 'optional' => false], + 'bar' => ['type' => 'foo', 'optional' => true], + 'baz' => ['type' => 'baz', 'optional' => false], + ], + ], + + // without {} enclosing + [ + '{% types foo: "foo", bar: "bar" %}', + [ + 'foo' => ['type' => 'foo', 'optional' => false], + 'bar' => ['type' => 'bar', 'optional' => false], + ], + ], + ]; + } +} diff --git a/tests/TokenStreamTest.php b/tests/TokenStreamTest.php index e8cb474b38f..d1c93220e8c 100644 --- a/tests/TokenStreamTest.php +++ b/tests/TokenStreamTest.php @@ -1,5 +1,14 @@ isEOF()) { $token = $stream->next(); @@ -48,12 +58,13 @@ public function testNext() public function testEndOfTemplateNext() { + $stream = new TokenStream([ + new Token(Token::BLOCK_START_TYPE, 1, 1), + ], new Source('', '')); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unexpected end of template'); - $stream = new TokenStream([ - new Token(Token::BLOCK_START_TYPE, 1, 1), - ]); while (!$stream->isEOF()) { $stream->next(); } @@ -61,12 +72,13 @@ public function testEndOfTemplateNext() public function testEndOfTemplateLook() { + $stream = new TokenStream([ + new Token(Token::BLOCK_START_TYPE, 1, 1), + ], new Source('', '')); + $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unexpected end of template'); - $stream = new TokenStream([ - new Token(Token::BLOCK_START_TYPE, 1, 1), - ]); while (!$stream->isEOF()) { $stream->look(); $stream->next(); diff --git a/tests/Util/CallableArgumentsExtractorTest.php b/tests/Util/CallableArgumentsExtractorTest.php new file mode 100644 index 00000000000..f97bf57dfb6 --- /dev/null +++ b/tests/Util/CallableArgumentsExtractorTest.php @@ -0,0 +1,224 @@ +assertEquals(['U', null], $this->getArguments('date', 'date', ['format' => 'U', 'timestamp' => null])); + } + + public function testGetArgumentsWhenPositionalArgumentsAfterNamedArguments() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Positional arguments cannot be used after named arguments for function "date" in "test.twig" at line 2.'); + + $this->getArguments('date', 'date', ['timestamp' => 123456, 'Y-m-d']); + } + + public function testGetArgumentsWhenArgumentIsDefinedTwice() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Argument "format" is defined twice for function "date" in "test.twig" at line 2.'); + + $this->getArguments('date', 'date', ['Y-m-d', 'format' => 'U']); + } + + public function testGetArgumentsWithWrongNamedArgumentName() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unknown argument "unknown" for function "date(format, timestamp)".'); + + $this->getArguments('date', 'date', ['Y-m-d', 'timestamp' => null, 'unknown' => '']); + } + + public function testGetArgumentsWithWrongNamedArgumentNames() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Unknown arguments "unknown1", "unknown2" for function "date(format, timestamp)".'); + + $this->getArguments('date', 'date', ['Y-m-d', 'timestamp' => null, 'unknown1' => '', 'unknown2' => '']); + } + + public function testResolveArgumentsWithMissingValueForOptionalArgument() + { + if (\PHP_VERSION_ID >= 80000) { + $this->markTestSkipped('substr_compare() has a default value in 8.0, so the test does not work anymore, one should find another PHP built-in function for this test to work in PHP 8.'); + } + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Argument "case_sensitivity" could not be assigned for function "substr_compare(main_str, str, offset, length, case_sensitivity)" because it is mapped to an internal PHP function which cannot determine default value for optional argument "length".'); + + $this->getArguments('substr_compare', 'substr_compare', ['abcd', 'bc', 'offset' => 1, 'case_sensitivity' => true]); + } + + public function testResolveArgumentsOnlyNecessaryArgumentsForCustomFunction() + { + $this->assertEquals(['arg1'], $this->getArguments('custom_function', [$this, 'customFunction'], ['arg1' => 'arg1'])); + } + + public function testGetArgumentsForStaticMethod() + { + $this->assertEquals(['arg1'], $this->getArguments('custom_static_function', __CLASS__.'::customStaticFunction', ['arg1' => 'arg1'])); + } + + /** + * @dataProvider getGetArgumentsConversionData + */ + public function testGetArgumentsConversion($arg1, $arg2) + { + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg1) => '';"), [$arg1 => null])); + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg2) => '';"), [$arg2 => null])); + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg1) => '';"), [$arg2 => null])); + $this->assertEquals([null], $this->getArguments('custom', eval("return fn (\$$arg2) => '';"), [$arg1 => null])); + } + + public static function getGetArgumentsConversionData() + { + yield ['some_name', 'some_name']; + yield ['someName', 'some_name']; + yield ['no_svg', 'noSVG']; + yield ['error_404', 'error404']; + yield ['errCode_404', 'err_code_404']; + yield ['errCode404', 'err_code_404']; + yield ['aBc', 'a_b_c']; + yield ['aBC', 'a_b_c']; + } + + /** + * @group legacy + */ + public function testGetArgumentsConversionForVariadics() + { + $this->expectDeprecation('Since twig/twig 3.15: Using "snake_case" for variadic arguments is required for a smooth upgrade with Twig 4.0; rename "someNumberVariadic" to "some_number_variadic" in "test.twig" at line 2.'); + + $this->assertEquals([ + new ConstantExpression('a', 0), + new ConstantExpression(12, 0), + new VariadicExpression([ + new ConstantExpression('some_text_variadic', 2), new ConstantExpression('a', 0), + new ConstantExpression('some_number_variadic', 2), new ConstantExpression(12, 0), + ], 2), + ], $this->getArguments('custom', eval("return fn (string \$someText, int \$some_number, ...\$args) => '';"), ['some_text' => 'a', 'someNumber' => 12, 'some_text_variadic' => 'a', 'someNumberVariadic' => 12], true)); + } + + public function testGetArgumentsError() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('Value for argument "some_name" is required for function "custom_static_function" in "test.twig" at line 2.'); + + $this->getArguments('custom_static_function', [$this, 'customFunctionSnakeCamel'], ['someCity' => 'Paris']); + } + + public function testResolveArgumentsWithMissingParameterForArbitraryArguments() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('The last parameter of "Twig\\Tests\\Util\\CallableArgumentsExtractorTest::customFunctionWithArbitraryArguments" for function "foo" must be an array with default value, eg. "array $arg = []".'); + + $this->getArguments('foo', [$this, 'customFunctionWithArbitraryArguments'], [], true); + } + + public function testGetArgumentsWithInvalidCallable() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Callback for function "foo" is not callable in the current scope.'); + $this->getArguments('foo', '', [], true); + } + + public function testResolveArgumentsWithMissingParameterForArbitraryArgumentsOnFunction() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessageMatches('#^The last parameter of "Twig\\\\Tests\\\\Util\\\\custom_call_test_function" for function "foo" must be an array with default value, eg\\. "array \\$arg \\= \\[\\]"\\.$#'); + + $this->getArguments('foo', 'Twig\Tests\Util\custom_call_test_function', [], true); + } + + public function testResolveArgumentsWithMissingParameterForArbitraryArgumentsOnObject() + { + $this->expectException(SyntaxError::class); + $this->expectExceptionMessageMatches('#^The last parameter of "Twig\\\\Tests\\\\Util\\\\CallableTestClass\\:\\:__invoke" for function "foo" must be an array with default value, eg\\. "array \\$arg \\= \\[\\]"\\.$#'); + + $this->getArguments('foo', new CallableTestClass(), [], true); + } + + public static function customStaticFunction($arg1, $arg2 = 'default', $arg3 = []) + { + } + + public function customFunction($arg1, $arg2 = 'default', $arg3 = []) + { + } + + public function customFunctionSnakeCamel($someName, $some_city) + { + } + + public function customFunctionWithArbitraryArguments() + { + } + + private function getArguments(string $name, $callable, array $args, bool $isVariadic = false): array + { + $function = new TwigFunction($name, $callable, ['is_variadic' => $isVariadic]); + $node = new ExpressionCall($function, new EmptyNode(), 2); + $node->setSourceContext(new Source('', 'test.twig')); + foreach ($args as $name => $arg) { + $args[$name] = new ConstantExpression($arg, 0); + } + + $arguments = (new CallableArgumentsExtractor($node, $function))->extractArguments(new Nodes($args)); + foreach ($arguments as $name => $argument) { + $arguments[$name] = $isVariadic ? $argument : $argument->getAttribute('value'); + } + + return $arguments; + } +} + +class ExpressionCall extends FunctionExpression +{ +} + +class CallableTestClass +{ + public function __invoke($required) + { + } +} + +function custom_call_test_function($required) +{ +} diff --git a/tests/Util/DeprecationCollectorTest.php b/tests/Util/DeprecationCollectorTest.php index 7b5794d83c4..9c76a6fcba1 100644 --- a/tests/Util/DeprecationCollectorTest.php +++ b/tests/Util/DeprecationCollectorTest.php @@ -1,5 +1,14 @@ createMock(LoaderInterface::class)); - $twig->addFunction(new TwigFunction('deprec', [$this, 'deprec'], ['deprecated' => '1.1'])); + $twig = new Environment(new ArrayLoader()); + $twig->addFunction(new TwigFunction('deprec', [$this, 'deprec'], ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')])); $collector = new DeprecationCollector($twig); - $deprecations = $collector->collect(new Twig_Tests_Util_Iterator()); + $deprecations = $collector->collect(new Iterator()); - $this->assertEquals(['Twig Function "deprec" is deprecated since version 1.1 in deprec.twig at line 1.'], $deprecations); + $this->assertEquals(['Since foo/bar 1.1: Twig Function "deprec" is deprecated in deprec.twig at line 1.'], $deprecations); } public function deprec() @@ -38,7 +48,7 @@ public function deprec() } } -class Twig_Tests_Util_Iterator implements \IteratorAggregate +class Iterator implements \IteratorAggregate { public function getIterator(): \Traversable { diff --git a/tests/drupal_test.sh b/tests/drupal_test.sh index a25d886f8fd..eff75f2495e 100644 --- a/tests/drupal_test.sh +++ b/tests/drupal_test.sh @@ -6,19 +6,20 @@ set -e REPO=`pwd` cd /tmp rm -rf drupal-twig-test -composer create-project --no-interaction drupal/recommended-project:9.1.x-dev drupal-twig-test +composer create-project --no-interaction drupal/recommended-project:10.1.x-dev drupal-twig-test cd drupal-twig-test (cd vendor/twig && rm -rf twig && ln -sf $REPO twig) +composer dump-autoload php ./web/core/scripts/drupal install --no-interaction demo_umami > 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/.symfony/bin:$PATH" +export PATH="$HOME/.symfony5/bin:$PATH" symfony server:start -d --no-tls -curl -OLsS https://get.blackfire.io/blackfire-player.phar +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 <