Repository: twigphp/Twig Branch: 3.x Commit: 76688da07c9e Files: 1117 Total size: 1.8 MB Directory structure: gitextract_my2eswkz/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ ├── documentation.yml │ └── fabbot.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG ├── LICENSE ├── README.rst ├── bin/ │ └── generate_operators_precedence.php ├── composer.json ├── doc/ │ ├── .doctor-rst.yaml │ ├── _build/ │ │ ├── build.php │ │ └── composer.json │ ├── advanced.rst │ ├── api.rst │ ├── coding_standards.rst │ ├── deprecated.rst │ ├── filters/ │ │ ├── abs.rst │ │ ├── batch.rst │ │ ├── capitalize.rst │ │ ├── column.rst │ │ ├── convert_encoding.rst │ │ ├── country_name.rst │ │ ├── currency_name.rst │ │ ├── currency_symbol.rst │ │ ├── data_uri.rst │ │ ├── date.rst │ │ ├── date_modify.rst │ │ ├── default.rst │ │ ├── escape.rst │ │ ├── filter.rst │ │ ├── find.rst │ │ ├── first.rst │ │ ├── format.rst │ │ ├── format_currency.rst │ │ ├── format_date.rst │ │ ├── format_datetime.rst │ │ ├── format_number.rst │ │ ├── format_time.rst │ │ ├── html_attr_merge.rst │ │ ├── html_attr_type.rst │ │ ├── html_to_markdown.rst │ │ ├── index.rst │ │ ├── inky_to_html.rst │ │ ├── inline_css.rst │ │ ├── invoke.rst │ │ ├── join.rst │ │ ├── json_encode.rst │ │ ├── keys.rst │ │ ├── language_name.rst │ │ ├── last.rst │ │ ├── length.rst │ │ ├── locale_name.rst │ │ ├── lower.rst │ │ ├── map.rst │ │ ├── markdown_to_html.rst │ │ ├── merge.rst │ │ ├── nl2br.rst │ │ ├── number_format.rst │ │ ├── plural.rst │ │ ├── raw.rst │ │ ├── reduce.rst │ │ ├── replace.rst │ │ ├── reverse.rst │ │ ├── round.rst │ │ ├── shuffle.rst │ │ ├── singular.rst │ │ ├── slice.rst │ │ ├── slug.rst │ │ ├── sort.rst │ │ ├── spaceless.rst │ │ ├── split.rst │ │ ├── striptags.rst │ │ ├── timezone_name.rst │ │ ├── title.rst │ │ ├── trim.rst │ │ ├── u.rst │ │ ├── upper.rst │ │ └── url_encode.rst │ ├── functions/ │ │ ├── attribute.rst │ │ ├── block.rst │ │ ├── constant.rst │ │ ├── country_names.rst │ │ ├── country_timezones.rst │ │ ├── currency_names.rst │ │ ├── cycle.rst │ │ ├── date.rst │ │ ├── dump.rst │ │ ├── enum.rst │ │ ├── enum_cases.rst │ │ ├── html_attr.rst │ │ ├── html_classes.rst │ │ ├── html_cva.rst │ │ ├── include.rst │ │ ├── index.rst │ │ ├── language_names.rst │ │ ├── locale_names.rst │ │ ├── max.rst │ │ ├── min.rst │ │ ├── parent.rst │ │ ├── random.rst │ │ ├── range.rst │ │ ├── script_names.rst │ │ ├── source.rst │ │ ├── template_from_string.rst │ │ └── timezone_names.rst │ ├── index.rst │ ├── installation.rst │ ├── internals.rst │ ├── intro.rst │ ├── operators_precedence.rst │ ├── recipes.rst │ ├── sandbox.rst │ ├── tags/ │ │ ├── apply.rst │ │ ├── autoescape.rst │ │ ├── block.rst │ │ ├── cache.rst │ │ ├── deprecated.rst │ │ ├── do.rst │ │ ├── embed.rst │ │ ├── extends.rst │ │ ├── flush.rst │ │ ├── for.rst │ │ ├── from.rst │ │ ├── guard.rst │ │ ├── if.rst │ │ ├── import.rst │ │ ├── include.rst │ │ ├── index.rst │ │ ├── macro.rst │ │ ├── sandbox.rst │ │ ├── set.rst │ │ ├── types.rst │ │ ├── use.rst │ │ ├── verbatim.rst │ │ └── with.rst │ ├── templates.rst │ └── tests/ │ ├── constant.rst │ ├── defined.rst │ ├── divisibleby.rst │ ├── empty.rst │ ├── even.rst │ ├── index.rst │ ├── iterable.rst │ ├── mapping.rst │ ├── null.rst │ ├── odd.rst │ ├── sameas.rst │ └── sequence.rst ├── extra/ │ ├── cache-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── CacheExtension.php │ │ ├── CacheRuntime.php │ │ ├── LICENSE │ │ ├── Node/ │ │ │ └── CacheNode.php │ │ ├── README.md │ │ ├── Tests/ │ │ │ ├── Fixtures/ │ │ │ │ ├── cache.test │ │ │ │ ├── cache_complex.test │ │ │ │ ├── cache_with_blocks.test │ │ │ │ └── macro.test │ │ │ ├── FunctionalTest.php │ │ │ └── IntegrationTest.php │ │ ├── TokenParser/ │ │ │ └── CacheTokenParser.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ ├── cssinliner-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── CssInlinerExtension.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── Resources/ │ │ │ └── functions.php │ │ ├── Tests/ │ │ │ ├── Fixtures/ │ │ │ │ └── inline_css.test │ │ │ ├── IntegrationTest.php │ │ │ └── LegacyFunctionsTest.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ ├── html-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── Cva.php │ │ ├── HtmlAttr/ │ │ │ ├── AttributeValueInterface.php │ │ │ ├── InlineStyle.php │ │ │ ├── MergeableInterface.php │ │ │ └── SeparatedTokenList.php │ │ ├── HtmlExtension.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── Resources/ │ │ │ └── functions.php │ │ ├── Tests/ │ │ │ ├── CvaTest.php │ │ │ ├── Fixtures/ │ │ │ │ ├── data_uri.test │ │ │ │ ├── html_attr.test │ │ │ │ ├── html_attr_merge.test │ │ │ │ ├── html_classes.test │ │ │ │ ├── html_classes_with_unsupported_arg.test │ │ │ │ ├── html_classes_with_unsupported_key.test │ │ │ │ ├── html_cva.test │ │ │ │ └── html_cva_pass_to_template.test │ │ │ ├── HtmlAttrMergeTest.php │ │ │ ├── HtmlAttrTest.php │ │ │ ├── IntegrationTest.php │ │ │ └── LegacyFunctionsTest.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ ├── inky-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── InkyExtension.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── Resources/ │ │ │ └── functions.php │ │ ├── Tests/ │ │ │ ├── Fixtures/ │ │ │ │ └── inky.test │ │ │ ├── IntegrationTest.php │ │ │ └── LegacyFunctionsTest.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ ├── intl-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── IntlExtension.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── Tests/ │ │ │ ├── Fixtures/ │ │ │ │ ├── country_name.test │ │ │ │ ├── country_names.test │ │ │ │ ├── country_timezones.test │ │ │ │ ├── currency_name.test │ │ │ │ ├── currency_names.test │ │ │ │ ├── currency_symbol.test │ │ │ │ ├── format_currency.test │ │ │ │ ├── format_date.test │ │ │ │ ├── format_date_ICU72.test │ │ │ │ ├── format_date_php8.test │ │ │ │ ├── format_date_php8_ICU72.test │ │ │ │ ├── format_number.test │ │ │ │ ├── language_name.test │ │ │ │ ├── language_names.test │ │ │ │ ├── locale_name.test │ │ │ │ ├── locale_names.test │ │ │ │ ├── script_names.test │ │ │ │ ├── timezone_name.test │ │ │ │ └── timezone_names.test │ │ │ ├── IntegrationTest.php │ │ │ └── IntlExtensionTest.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ ├── markdown-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── DefaultMarkdown.php │ │ ├── ErusevMarkdown.php │ │ ├── LICENSE │ │ ├── LeagueMarkdown.php │ │ ├── MarkdownExtension.php │ │ ├── MarkdownInterface.php │ │ ├── MarkdownRuntime.php │ │ ├── MichelfMarkdown.php │ │ ├── README.md │ │ ├── Resources/ │ │ │ └── functions.php │ │ ├── Tests/ │ │ │ ├── Fixtures/ │ │ │ │ └── html_to_markdown.test │ │ │ ├── FunctionalTest.php │ │ │ ├── IntegrationTest.php │ │ │ └── LegacyFunctionsTest.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ ├── string-extra/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── LICENSE │ │ ├── README.md │ │ ├── StringExtension.php │ │ ├── Tests/ │ │ │ ├── Fixtures/ │ │ │ │ ├── plural-invalid-language.test │ │ │ │ ├── plural.test │ │ │ │ ├── plural_es.test │ │ │ │ ├── singular-invalid-language.test │ │ │ │ ├── singular.test │ │ │ │ ├── singular_es.test │ │ │ │ ├── slug.test │ │ │ │ └── string.test │ │ │ └── IntegrationTest.php │ │ ├── composer.json │ │ └── phpunit.xml.dist │ └── twig-extra-bundle/ │ ├── .gitattributes │ ├── .gitignore │ ├── DependencyInjection/ │ │ ├── Compiler/ │ │ │ └── MissingExtensionSuggestorPass.php │ │ ├── Configuration.php │ │ └── TwigExtraExtension.php │ ├── Extensions.php │ ├── LICENSE │ ├── LeagueCommonMarkConverterFactory.php │ ├── MissingExtensionSuggestor.php │ ├── README.md │ ├── Resources/ │ │ └── config/ │ │ ├── cache.php │ │ ├── cssinliner.php │ │ ├── html.php │ │ ├── inky.php │ │ ├── intl.php │ │ ├── markdown.php │ │ ├── markdown_league.php │ │ ├── string.php │ │ └── suggestor.php │ ├── Tests/ │ │ ├── DependencyInjection/ │ │ │ └── TwigExtraExtensionTest.php │ │ ├── Fixture/ │ │ │ ├── Kernel.php │ │ │ └── views/ │ │ │ └── markdown_to_html.html.twig │ │ └── IntegrationTest.php │ ├── TwigExtraBundle.php │ ├── composer.json │ └── phpunit.xml.dist ├── phpstan-baseline.neon ├── phpstan.neon.dist ├── phpunit.xml.dist ├── splitsh.json ├── src/ │ ├── AbstractTwigCallable.php │ ├── Attribute/ │ │ ├── AsTwigFilter.php │ │ ├── AsTwigFunction.php │ │ ├── AsTwigTest.php │ │ ├── FirstClassTwigCallableReady.php │ │ └── YieldReady.php │ ├── Cache/ │ │ ├── CacheInterface.php │ │ ├── ChainCache.php │ │ ├── FilesystemCache.php │ │ ├── NullCache.php │ │ ├── ReadOnlyFilesystemCache.php │ │ └── RemovableCacheInterface.php │ ├── Compiler.php │ ├── DeprecatedCallableInfo.php │ ├── Environment.php │ ├── Error/ │ │ ├── Error.php │ │ ├── LoaderError.php │ │ ├── RuntimeError.php │ │ └── SyntaxError.php │ ├── ExpressionParser/ │ │ ├── AbstractExpressionParser.php │ │ ├── ExpressionParserDescriptionInterface.php │ │ ├── ExpressionParserInterface.php │ │ ├── ExpressionParserType.php │ │ ├── ExpressionParsers.php │ │ ├── Infix/ │ │ │ ├── ArgumentsTrait.php │ │ │ ├── ArrowExpressionParser.php │ │ │ ├── AssignmentExpressionParser.php │ │ │ ├── BinaryOperatorExpressionParser.php │ │ │ ├── ConditionalTernaryExpressionParser.php │ │ │ ├── DotExpressionParser.php │ │ │ ├── FilterExpressionParser.php │ │ │ ├── FunctionExpressionParser.php │ │ │ ├── IsExpressionParser.php │ │ │ ├── IsNotExpressionParser.php │ │ │ └── SquareBracketExpressionParser.php │ │ ├── InfixAssociativity.php │ │ ├── InfixExpressionParserInterface.php │ │ ├── PrecedenceChange.php │ │ ├── Prefix/ │ │ │ ├── GroupingExpressionParser.php │ │ │ ├── LiteralExpressionParser.php │ │ │ └── UnaryOperatorExpressionParser.php │ │ └── PrefixExpressionParserInterface.php │ ├── ExpressionParser.php │ ├── Extension/ │ │ ├── AbstractExtension.php │ │ ├── AttributeExtension.php │ │ ├── CoreExtension.php │ │ ├── DebugExtension.php │ │ ├── EscaperExtension.php │ │ ├── ExtensionInterface.php │ │ ├── GlobalsInterface.php │ │ ├── LastModifiedExtensionInterface.php │ │ ├── OptimizerExtension.php │ │ ├── ProfilerExtension.php │ │ ├── RuntimeExtensionInterface.php │ │ ├── SandboxExtension.php │ │ ├── StagingExtension.php │ │ ├── StringLoaderExtension.php │ │ └── YieldNotReadyExtension.php │ ├── ExtensionSet.php │ ├── FileExtensionEscapingStrategy.php │ ├── Lexer.php │ ├── Loader/ │ │ ├── ArrayLoader.php │ │ ├── ChainLoader.php │ │ ├── FilesystemLoader.php │ │ └── LoaderInterface.php │ ├── Markup.php │ ├── Node/ │ │ ├── AutoEscapeNode.php │ │ ├── BlockNode.php │ │ ├── BlockReferenceNode.php │ │ ├── BodyNode.php │ │ ├── CaptureNode.php │ │ ├── CheckSecurityCallNode.php │ │ ├── CheckSecurityNode.php │ │ ├── CheckToStringNode.php │ │ ├── DeprecatedNode.php │ │ ├── DoNode.php │ │ ├── EmbedNode.php │ │ ├── EmptyNode.php │ │ ├── Expression/ │ │ │ ├── AbstractExpression.php │ │ │ ├── ArrayExpression.php │ │ │ ├── ArrowFunctionExpression.php │ │ │ ├── AssignNameExpression.php │ │ │ ├── Binary/ │ │ │ │ ├── AbstractBinary.php │ │ │ │ ├── AddBinary.php │ │ │ │ ├── AndBinary.php │ │ │ │ ├── BinaryInterface.php │ │ │ │ ├── BitwiseAndBinary.php │ │ │ │ ├── BitwiseOrBinary.php │ │ │ │ ├── BitwiseXorBinary.php │ │ │ │ ├── ConcatBinary.php │ │ │ │ ├── DivBinary.php │ │ │ │ ├── ElvisBinary.php │ │ │ │ ├── EndsWithBinary.php │ │ │ │ ├── EqualBinary.php │ │ │ │ ├── FloorDivBinary.php │ │ │ │ ├── GreaterBinary.php │ │ │ │ ├── GreaterEqualBinary.php │ │ │ │ ├── HasEveryBinary.php │ │ │ │ ├── HasSomeBinary.php │ │ │ │ ├── InBinary.php │ │ │ │ ├── LessBinary.php │ │ │ │ ├── LessEqualBinary.php │ │ │ │ ├── MatchesBinary.php │ │ │ │ ├── ModBinary.php │ │ │ │ ├── MulBinary.php │ │ │ │ ├── NotEqualBinary.php │ │ │ │ ├── NotInBinary.php │ │ │ │ ├── NotSameAsBinary.php │ │ │ │ ├── NullCoalesceBinary.php │ │ │ │ ├── ObjectDestructuringSetBinary.php │ │ │ │ ├── OrBinary.php │ │ │ │ ├── PowerBinary.php │ │ │ │ ├── RangeBinary.php │ │ │ │ ├── SameAsBinary.php │ │ │ │ ├── SequenceDestructuringSetBinary.php │ │ │ │ ├── SetBinary.php │ │ │ │ ├── SpaceshipBinary.php │ │ │ │ ├── StartsWithBinary.php │ │ │ │ ├── SubBinary.php │ │ │ │ └── XorBinary.php │ │ │ ├── BlockReferenceExpression.php │ │ │ ├── CallExpression.php │ │ │ ├── ConditionalExpression.php │ │ │ ├── ConstantExpression.php │ │ │ ├── EmptyExpression.php │ │ │ ├── Filter/ │ │ │ │ ├── DefaultFilter.php │ │ │ │ └── RawFilter.php │ │ │ ├── FilterExpression.php │ │ │ ├── FunctionExpression.php │ │ │ ├── FunctionNode/ │ │ │ │ ├── EnumCasesFunction.php │ │ │ │ └── EnumFunction.php │ │ │ ├── GetAttrExpression.php │ │ │ ├── InlinePrint.php │ │ │ ├── ListExpression.php │ │ │ ├── MacroReferenceExpression.php │ │ │ ├── MethodCallExpression.php │ │ │ ├── NameExpression.php │ │ │ ├── NullCoalesceExpression.php │ │ │ ├── OperatorEscapeInterface.php │ │ │ ├── ParentExpression.php │ │ │ ├── ReturnArrayInterface.php │ │ │ ├── ReturnBoolInterface.php │ │ │ ├── ReturnNumberInterface.php │ │ │ ├── ReturnPrimitiveTypeInterface.php │ │ │ ├── ReturnStringInterface.php │ │ │ ├── SupportDefinedTestDeprecationTrait.php │ │ │ ├── SupportDefinedTestInterface.php │ │ │ ├── SupportDefinedTestTrait.php │ │ │ ├── TempNameExpression.php │ │ │ ├── Ternary/ │ │ │ │ └── ConditionalTernary.php │ │ │ ├── Test/ │ │ │ │ ├── ConstantTest.php │ │ │ │ ├── DefinedTest.php │ │ │ │ ├── DivisiblebyTest.php │ │ │ │ ├── EvenTest.php │ │ │ │ ├── NullTest.php │ │ │ │ ├── OddTest.php │ │ │ │ ├── SameasTest.php │ │ │ │ └── TrueTest.php │ │ │ ├── TestExpression.php │ │ │ ├── Unary/ │ │ │ │ ├── AbstractUnary.php │ │ │ │ ├── NegUnary.php │ │ │ │ ├── NotUnary.php │ │ │ │ ├── PosUnary.php │ │ │ │ ├── SpreadUnary.php │ │ │ │ ├── StringCastUnary.php │ │ │ │ └── UnaryInterface.php │ │ │ ├── Variable/ │ │ │ │ ├── AssignContextVariable.php │ │ │ │ ├── AssignTemplateVariable.php │ │ │ │ ├── ContextVariable.php │ │ │ │ ├── LocalVariable.php │ │ │ │ └── TemplateVariable.php │ │ │ └── VariadicExpression.php │ │ ├── FlushNode.php │ │ ├── ForElseNode.php │ │ ├── ForLoopNode.php │ │ ├── ForNode.php │ │ ├── IfNode.php │ │ ├── ImportNode.php │ │ ├── IncludeNode.php │ │ ├── MacroNode.php │ │ ├── ModuleNode.php │ │ ├── NameDeprecation.php │ │ ├── Node.php │ │ ├── NodeCaptureInterface.php │ │ ├── NodeOutputInterface.php │ │ ├── Nodes.php │ │ ├── PrintNode.php │ │ ├── SandboxNode.php │ │ ├── SetNode.php │ │ ├── TextNode.php │ │ ├── TypesNode.php │ │ └── WithNode.php │ ├── NodeTraverser.php │ ├── NodeVisitor/ │ │ ├── AbstractNodeVisitor.php │ │ ├── EscaperNodeVisitor.php │ │ ├── NodeVisitorInterface.php │ │ ├── OptimizerNodeVisitor.php │ │ ├── SafeAnalysisNodeVisitor.php │ │ ├── SandboxNodeVisitor.php │ │ └── YieldNotReadyNodeVisitor.php │ ├── OperatorPrecedenceChange.php │ ├── Parser.php │ ├── Profiler/ │ │ ├── Dumper/ │ │ │ ├── BaseDumper.php │ │ │ ├── BlackfireDumper.php │ │ │ ├── HtmlDumper.php │ │ │ └── TextDumper.php │ │ ├── Node/ │ │ │ ├── EnterProfileNode.php │ │ │ └── LeaveProfileNode.php │ │ ├── NodeVisitor/ │ │ │ └── ProfilerNodeVisitor.php │ │ └── Profile.php │ ├── Resources/ │ │ ├── core.php │ │ ├── debug.php │ │ ├── escaper.php │ │ └── string_loader.php │ ├── Runtime/ │ │ └── EscaperRuntime.php │ ├── RuntimeLoader/ │ │ ├── ContainerRuntimeLoader.php │ │ ├── FactoryRuntimeLoader.php │ │ └── RuntimeLoaderInterface.php │ ├── Sandbox/ │ │ ├── SecurityError.php │ │ ├── SecurityNotAllowedFilterError.php │ │ ├── SecurityNotAllowedFunctionError.php │ │ ├── SecurityNotAllowedMethodError.php │ │ ├── SecurityNotAllowedPropertyError.php │ │ ├── SecurityNotAllowedTagError.php │ │ ├── SecurityPolicy.php │ │ ├── SecurityPolicyInterface.php │ │ └── SourcePolicyInterface.php │ ├── Source.php │ ├── Template.php │ ├── TemplateWrapper.php │ ├── Test/ │ │ ├── IntegrationTestCase.php │ │ └── NodeTestCase.php │ ├── Token.php │ ├── TokenParser/ │ │ ├── AbstractTokenParser.php │ │ ├── ApplyTokenParser.php │ │ ├── AutoEscapeTokenParser.php │ │ ├── BlockTokenParser.php │ │ ├── DeprecatedTokenParser.php │ │ ├── DoTokenParser.php │ │ ├── EmbedTokenParser.php │ │ ├── ExtendsTokenParser.php │ │ ├── FlushTokenParser.php │ │ ├── ForTokenParser.php │ │ ├── FromTokenParser.php │ │ ├── GuardTokenParser.php │ │ ├── IfTokenParser.php │ │ ├── ImportTokenParser.php │ │ ├── IncludeTokenParser.php │ │ ├── MacroTokenParser.php │ │ ├── SandboxTokenParser.php │ │ ├── SetTokenParser.php │ │ ├── TokenParserInterface.php │ │ ├── TypesTokenParser.php │ │ ├── UseTokenParser.php │ │ └── WithTokenParser.php │ ├── TokenStream.php │ ├── TwigCallableInterface.php │ ├── TwigFilter.php │ ├── TwigFunction.php │ ├── TwigTest.php │ └── Util/ │ ├── CallableArgumentsExtractor.php │ ├── DeprecationCollector.php │ ├── ReflectionCallable.php │ └── TemplateDirIterator.php └── tests/ ├── Cache/ │ ├── ChainTest.php │ ├── FilesystemTest.php │ └── ReadOnlyFilesystemTest.php ├── CompilerTest.php ├── ContainerRuntimeLoaderTest.php ├── CustomExtensionTest.php ├── DeprecatedCallableInfoTest.php ├── DummyBackedEnum.php ├── DummyUnitEnum.php ├── EnvironmentTest.php ├── ErrorTest.php ├── ExpressionParserTest.php ├── Extension/ │ ├── AttributeExtensionTest.php │ ├── CoreTest.php │ ├── EscaperTest.php │ ├── Fixtures/ │ │ ├── ExtensionWithAttributes.php │ │ ├── FilterWithoutValue.php │ │ └── TestWithoutValue.php │ ├── LegacyDebugFunctionsTest.php │ ├── LegacyStringLoaderFunctionsTest.php │ ├── SandboxTest.php │ └── StringLoaderExtensionTest.php ├── FactoryRuntimeLoaderTest.php ├── FileExtensionEscapingStrategyTest.php ├── FilesystemHelper.php ├── Fixtures/ │ ├── autoescape/ │ │ ├── block.test │ │ └── name.test │ ├── errors/ │ │ ├── base.html │ │ ├── extends/ │ │ │ ├── include.twig │ │ │ └── index.twig │ │ ├── index.html │ │ ├── no_line_and_context_exception.twig │ │ ├── no_line_and_context_exception_include_line_1.twig │ │ └── no_line_and_context_exception_include_line_5.twig │ ├── exceptions/ │ │ ├── child_contents_outside_blocks.test │ │ ├── exception_in_extension_extends.test │ │ ├── exception_in_extension_include.test │ │ ├── multiline_array_with_undefined_variable.test │ │ ├── multiline_array_with_undefined_variable_again.test │ │ ├── multiline_function_with_undefined_variable.test │ │ ├── multiline_function_with_unknown_argument.test │ │ ├── multiline_tag_with_undefined_variable.test │ │ ├── syntax_error_in_reused_template.test │ │ ├── unclosed_tag.test │ │ ├── undefined_parent.test │ │ ├── undefined_template_in_child_template.test │ │ └── undefined_trait.test │ ├── expressions/ │ │ ├── _self.test │ │ ├── array.test │ │ ├── array_call.test │ │ ├── attributes.test │ │ ├── binary.test │ │ ├── bitwise.test │ │ ├── call_argument_defined_twice.test │ │ ├── call_argument_unpacking.test │ │ ├── call_argument_unpacking_before_normal.test │ │ ├── call_positional_arg_after_named_arg.test │ │ ├── comparison.test │ │ ├── comparison_precedence.test │ │ ├── const.test │ │ ├── divisibleby.test │ │ ├── dot_as_concatenation.test │ │ ├── dotdot.test │ │ ├── dynamic_attribute.test │ │ ├── ends_with.test │ │ ├── exponential_numbers.test │ │ ├── floats.test │ │ ├── grouping.test │ │ ├── has_every.test │ │ ├── has_some.test │ │ ├── literals.test │ │ ├── magic_call.test │ │ ├── matches.test │ │ ├── matches_error_compilation.test │ │ ├── matches_error_runtime.test │ │ ├── method_call.test │ │ ├── negative_numbers.test │ │ ├── not.test │ │ ├── not_arrow_fn.test │ │ ├── operators_as_variables.test │ │ ├── postfix.test │ │ ├── power.test │ │ ├── sameas.test │ │ ├── set.test │ │ ├── spread_array_operator.test │ │ ├── spread_mapping_operator.test │ │ ├── spread_ternary_precedence.test │ │ ├── starts_with.test │ │ ├── string_operator_as_var_assignment.test │ │ ├── strings.test │ │ ├── ternary_operator.test │ │ ├── ternary_operator_noelse.test │ │ ├── ternary_operator_nothen.test │ │ ├── two_word_operators_as_variables.test │ │ ├── unary.test │ │ ├── unary_macro_arguments.test │ │ ├── unary_precedence.test │ │ ├── underscored_numbers.test │ │ └── underscored_numbers_error.test │ ├── extensions/ │ │ └── anonymous_functions.test │ ├── filters/ │ │ ├── abs.test │ │ ├── arrow_reserved_names.test │ │ ├── batch.test │ │ ├── batch_float.test │ │ ├── batch_with_empty_fill.test │ │ ├── batch_with_exact_elements.test │ │ ├── batch_with_fill.test │ │ ├── batch_with_keys.test │ │ ├── batch_with_more_elements.test │ │ ├── batch_with_zero_elements.test │ │ ├── capitalize.test │ │ ├── column.test │ │ ├── convert_encoding.test │ │ ├── date.test │ │ ├── date_default_format.test │ │ ├── date_default_format_interval.test │ │ ├── date_immutable.test │ │ ├── date_interval.test │ │ ├── date_modify.test │ │ ├── date_namedargs.test │ │ ├── date_time_zone_conversion.test │ │ ├── default.test │ │ ├── dynamic_filter.test │ │ ├── escape.test │ │ ├── escape_html_attr.test │ │ ├── escape_html_attr_relaxed.test │ │ ├── escape_javascript.test │ │ ├── escape_non_supported_charset.test │ │ ├── filter.test │ │ ├── find.test │ │ ├── first.test │ │ ├── force_escape.test │ │ ├── format.test │ │ ├── invoke.test │ │ ├── join.test │ │ ├── json_encode.test │ │ ├── last.test │ │ ├── length.test │ │ ├── length_utf8.test │ │ ├── lower.test │ │ ├── map.test │ │ ├── merge.test │ │ ├── nl2br.test │ │ ├── number_format.test │ │ ├── number_format_default.test │ │ ├── raw.test │ │ ├── reduce.test │ │ ├── reduce_key.test │ │ ├── replace.test │ │ ├── replace_invalid_arg.test │ │ ├── reverse.test │ │ ├── round.test │ │ ├── shuffle.test │ │ ├── slice.test │ │ ├── sort.test │ │ ├── sort_with_arrow.test │ │ ├── spaceless.legacy.test │ │ ├── special_chars.test │ │ ├── split.test │ │ ├── split_utf8.test │ │ ├── static_calls.test │ │ ├── striptags.test │ │ ├── title.test │ │ ├── trailing_commas.test │ │ ├── trim.test │ │ ├── upper.test │ │ └── urlencode.test │ ├── functions/ │ │ ├── attribute.legacy.test │ │ ├── attribute_with_wrong_args.legacy.test │ │ ├── block.test │ │ ├── block_with_template.test │ │ ├── block_without_name.test │ │ ├── block_without_parent.test │ │ ├── constant.test │ │ ├── cycle.test │ │ ├── cycle_empty_mapping.test │ │ ├── cycle_empty_sequence.test │ │ ├── cycle_without_enough_args.test │ │ ├── date.test │ │ ├── date_namedargs.test │ │ ├── deprecated.test │ │ ├── dump.test │ │ ├── dump_array.test │ │ ├── dynamic_function.test │ │ ├── enum/ │ │ │ ├── invalid_dynamic_enum.test │ │ │ ├── invalid_enum.test │ │ │ ├── invalid_enum_escaping.test │ │ │ ├── invalid_literal_type.test │ │ │ └── valid.test │ │ ├── enum_cases/ │ │ │ ├── invalid_dynamic_enum.test │ │ │ ├── invalid_enum.test │ │ │ ├── invalid_enum_escaping.test │ │ │ ├── invalid_literal_type.test │ │ │ └── valid.test │ │ ├── include/ │ │ │ ├── assignment.test │ │ │ ├── autoescaping.test │ │ │ ├── basic.test │ │ │ ├── expression.test │ │ │ ├── ignore_missing.test │ │ │ ├── ignore_missing_exists.test │ │ │ ├── include_missing_extends.test │ │ │ ├── missing.test │ │ │ ├── missing_nested.test │ │ │ ├── sandbox.test │ │ │ ├── sandbox_disabling.test │ │ │ ├── sandbox_disabling_ignore_missing.test │ │ │ ├── template_instance.test │ │ │ ├── templates_as_array.test │ │ │ ├── with_context.test │ │ │ └── with_variables.test │ │ ├── include_template_from_string.test │ │ ├── magic_call.test │ │ ├── magic_static_call.test │ │ ├── max.test │ │ ├── max_without_args.test │ │ ├── min.test │ │ ├── parent_in_condition.test │ │ ├── parent_outside_of_a_block.test │ │ ├── range.test │ │ ├── recursive_block_with_inheritance.test │ │ ├── source.test │ │ ├── special_chars.test │ │ ├── static_calls.test │ │ ├── template_from_string.test │ │ ├── template_from_string_error.test │ │ ├── template_from_string_error_php80.test │ │ ├── trailing_commas.test │ │ ├── undefined_block.test │ │ └── undefined_block_deep.test │ ├── macros/ │ │ ├── arrow_as_arg.test │ │ ├── default_values.test │ │ ├── macro_with_capture.test │ │ ├── nested_calls.test │ │ ├── reserved_variables.test │ │ ├── simple.test │ │ ├── trailing_commas.test │ │ ├── unknown_macro.test │ │ ├── unknown_macro_different_template.test │ │ ├── varargs.test │ │ ├── varargs_argument.test │ │ └── with_filters.test │ ├── operators/ │ │ ├── concat_vs_add_sub.test │ │ ├── contat_vs_add_sub.legacy.test │ │ ├── minus_vs_pipe.legacy.test │ │ ├── not_precedence.legacy.test │ │ └── not_precedence.test │ ├── regression/ │ │ ├── 4029-iterator_to_array.test │ │ ├── 4033-missing-unwrap.test │ │ ├── 4701-block-inheritance-issue.test │ │ ├── block_names_unicity.test │ │ ├── combined_debug_info.test │ │ ├── empty_token.test │ │ ├── markup_test.test │ │ ├── multi_word_tests.test │ │ ├── simple_xml_element.test │ │ └── strings_like_numbers.test │ ├── tags/ │ │ ├── apply/ │ │ │ ├── basic.test │ │ │ ├── json_encode.test │ │ │ ├── multiple.test │ │ │ ├── nested.test │ │ │ ├── scope.test │ │ │ ├── with_for_tag.test │ │ │ └── with_if_tag.test │ │ ├── autoescape/ │ │ │ ├── basic.test │ │ │ ├── blocks.test │ │ │ ├── double_escaping.test │ │ │ ├── functions.test │ │ │ ├── literal.test │ │ │ ├── nested.test │ │ │ ├── objects.test │ │ │ ├── raw.test │ │ │ ├── strategy.test │ │ │ ├── type.test │ │ │ ├── with_filters.test │ │ │ ├── with_filters_arguments.test │ │ │ ├── with_pre_escape_filters.test │ │ │ └── with_preserves_safety_filters.test │ │ ├── block/ │ │ │ ├── basic.test │ │ │ ├── block_unique_name.test │ │ │ ├── conditional_block.test │ │ │ └── special_chars.test │ │ ├── deprecated/ │ │ │ ├── block.legacy.test │ │ │ ├── macro.legacy.test │ │ │ ├── template.legacy.test │ │ │ ├── with_package.legacy.test │ │ │ └── with_package_version.legacy.test │ │ ├── embed/ │ │ │ ├── basic.test │ │ │ ├── complex_dynamic_parent.test │ │ │ ├── dynamic_parent.test │ │ │ ├── embed_ignore_missing.test │ │ │ ├── error_line.test │ │ │ ├── multiple.test │ │ │ ├── nested.test │ │ │ └── with_extends.test │ │ ├── for/ │ │ │ ├── context.test │ │ │ ├── else.test │ │ │ ├── for_on_strings.test │ │ │ ├── inner_variables.test │ │ │ ├── keys.test │ │ │ ├── keys_and_values.test │ │ │ ├── loop_context.test │ │ │ ├── loop_context_local.test │ │ │ ├── nested_else.test │ │ │ ├── objects.test │ │ │ ├── objects_countable.test │ │ │ ├── recursive.test │ │ │ ├── reserved_names.test │ │ │ └── values.test │ │ ├── from.test │ │ ├── guard/ │ │ │ ├── basic.test │ │ │ ├── exception.test │ │ │ ├── nested.test │ │ │ └── throwing_handler.test │ │ ├── if/ │ │ │ ├── basic.test │ │ │ ├── empty_body.test │ │ │ └── expression.test │ │ ├── include/ │ │ │ ├── basic.test │ │ │ ├── expression.test │ │ │ ├── ignore_missing.test │ │ │ ├── ignore_missing_exists.test │ │ │ ├── include_missing_extends.test │ │ │ ├── missing.test │ │ │ ├── missing_nested.test │ │ │ ├── only.test │ │ │ ├── template_instance.test │ │ │ ├── templates_as_array.test │ │ │ └── with_variables.test │ │ ├── inheritance/ │ │ │ ├── basic.test │ │ │ ├── block_expr.test │ │ │ ├── block_expr2.test │ │ │ ├── capturing_block.test │ │ │ ├── conditional.test │ │ │ ├── conditional_block.test │ │ │ ├── conditional_block_nested.test │ │ │ ├── dynamic.test │ │ │ ├── dynamic_parent_from_include.test │ │ │ ├── empty.test │ │ │ ├── extends_as_array.test │ │ │ ├── extends_as_array_with_empty_name.test │ │ │ ├── extends_as_array_with_nested_blocks.test │ │ │ ├── extends_in_block.test │ │ │ ├── extends_in_macro.test │ │ │ ├── extends_with_nested_blocks.test │ │ │ ├── multiple.test │ │ │ ├── multiple_dynamic.test │ │ │ ├── nested_blocks.test │ │ │ ├── nested_blocks_parent_only.test │ │ │ ├── nested_inheritance.test │ │ │ ├── parent.test │ │ │ ├── parent_as_template_wrapper.test │ │ │ ├── parent_change.test │ │ │ ├── parent_isolation.test │ │ │ ├── parent_nested.test │ │ │ ├── parent_without_extends.test │ │ │ ├── parent_without_extends_but_traits.test │ │ │ ├── template_instance.test │ │ │ └── use.test │ │ ├── macro/ │ │ │ ├── argument_reserved_names.test │ │ │ ├── auto_import.test │ │ │ ├── auto_import_blocks.test │ │ │ ├── auto_import_without_blocks.test │ │ │ ├── basic.test │ │ │ ├── colon_not_supported_as_default_separator.test │ │ │ ├── endmacro_name.test │ │ │ ├── external.test │ │ │ ├── from.test │ │ │ ├── from_embed_with_global_macro.test │ │ │ ├── from_in_block_is_local.test │ │ │ ├── from_local_override.test │ │ │ ├── from_macro_in_a_macro.test │ │ │ ├── from_macros_in_parent.test │ │ │ ├── from_nested_blocks.test │ │ │ ├── from_nested_blocks_with_global_macro.test │ │ │ ├── from_recursive.test │ │ │ ├── from_reserved_names.test │ │ │ ├── from_self_parent.test │ │ │ ├── from_syntax_error.test │ │ │ ├── global.test │ │ │ ├── import_and_blocks.test │ │ │ ├── import_embed_with_global_macro.test │ │ │ ├── import_from_string_template.test │ │ │ ├── import_in_block_is_local.test │ │ │ ├── import_local_override.test │ │ │ ├── import_macro_in_a_macro.test │ │ │ ├── import_macros_in_parent.test │ │ │ ├── import_nested_blocks.test │ │ │ ├── import_nested_blocks_with_global_macro.test │ │ │ ├── import_reserved_names.test │ │ │ ├── import_same_parent_and_child.test │ │ │ ├── import_self_parent.test │ │ │ ├── import_syntax_error.test │ │ │ ├── named_arguments.test │ │ │ ├── self_import.test │ │ │ ├── special_chars.test │ │ │ └── super_globals.test │ │ ├── sandbox/ │ │ │ ├── array.legacy.test │ │ │ ├── not_valid1.legacy.test │ │ │ ├── not_valid2.legacy.test │ │ │ └── simple.legacy.test │ │ ├── set/ │ │ │ ├── basic.test │ │ │ ├── capture-empty.test │ │ │ ├── capture.test │ │ │ ├── capture_scope.test │ │ │ ├── expression.test │ │ │ ├── inheritance.test │ │ │ ├── inheritance_overriding.test │ │ │ ├── mutating.test │ │ │ └── reserved_names.test │ │ ├── special_chars.test │ │ ├── use/ │ │ │ ├── aliases.test │ │ │ ├── basic.test │ │ │ ├── deep.test │ │ │ ├── deep_empty.test │ │ │ ├── inheritance.test │ │ │ ├── inheritance2.test │ │ │ ├── multiple.test │ │ │ ├── multiple_aliases.test │ │ │ ├── parent_block.test │ │ │ ├── parent_block2.test │ │ │ ├── parent_block3.test │ │ │ ├── use_aliased_block_overridden.test │ │ │ └── use_with_parent.test │ │ ├── verbatim/ │ │ │ ├── basic.test │ │ │ └── whitespace_control.test │ │ └── with/ │ │ ├── basic.test │ │ ├── expression.test │ │ ├── globals.test │ │ ├── iterable.test │ │ ├── nested.test │ │ ├── with_no_mapping.test │ │ └── with_only.test │ ├── tests/ │ │ ├── array.test │ │ ├── constant.test │ │ ├── defined.test │ │ ├── defined_for_attribute.legacy.test │ │ ├── defined_for_attribute.test │ │ ├── defined_for_blocks.test │ │ ├── defined_for_blocks_with_template.test │ │ ├── defined_for_constants.test │ │ ├── defined_for_macros.test │ │ ├── defined_on_complex_expr.test │ │ ├── dynamic_test.test │ │ ├── empty.test │ │ ├── even.test │ │ ├── in.test │ │ ├── in_with_iterator.test │ │ ├── in_with_objects.test │ │ ├── iterable.test │ │ ├── mapping.test │ │ ├── null_coalesce.legacy.test │ │ ├── null_coalesce.test │ │ ├── null_coalesce_block.test │ │ ├── odd.test │ │ └── sequence.test │ └── whitespace/ │ ├── trim_block.test │ ├── trim_delimiter_as_strings.test │ ├── trim_left.test │ ├── trim_line_left.test │ ├── trim_line_right.test │ └── trim_right.test ├── IntegrationTest.php ├── LexerTest.php ├── Loader/ │ ├── ArrayTest.php │ ├── ChainTest.php │ ├── FilesystemTest.php │ └── Fixtures/ │ ├── inheritance/ │ │ ├── array_inheritance_empty_parent.html.twig │ │ ├── array_inheritance_nonexistent_parent.html.twig │ │ ├── array_inheritance_valid_parent.html.twig │ │ ├── parent.html.twig │ │ └── spare_parent.html.twig │ ├── named/ │ │ └── index.html │ ├── named_bis/ │ │ └── index.html │ ├── named_final/ │ │ └── index.html │ ├── named_quater/ │ │ └── named_absolute.html │ ├── named_ter/ │ │ └── index.html │ ├── normal/ │ │ └── index.html │ ├── normal_bis/ │ │ └── index.html │ ├── normal_final/ │ │ └── index.html │ ├── normal_ter/ │ │ └── index.html │ ├── phar/ │ │ └── phar-sample.phar │ └── themes/ │ ├── theme1/ │ │ └── blocks.html.twig │ └── theme2/ │ └── blocks.html.twig ├── Node/ │ ├── AutoEscapeTest.php │ ├── BlockReferenceTest.php │ ├── BlockTest.php │ ├── DeprecatedTest.php │ ├── DoTest.php │ ├── EmbedTest.php │ ├── Expression/ │ │ ├── ArrayTest.php │ │ ├── Binary/ │ │ │ ├── AddTest.php │ │ │ ├── AndTest.php │ │ │ ├── ConcatTest.php │ │ │ ├── DivTest.php │ │ │ ├── FloorDivTest.php │ │ │ ├── ModTest.php │ │ │ ├── MulTest.php │ │ │ ├── NullCoalesceTest.php │ │ │ ├── OrTest.php │ │ │ └── SubTest.php │ │ ├── CallTest.php │ │ ├── ConditionalTest.php │ │ ├── ConstantTest.php │ │ ├── Filter/ │ │ │ └── RawTest.php │ │ ├── FilterTest.php │ │ ├── FilterTestExtension.php │ │ ├── FunctionTest.php │ │ ├── GetAttrTest.php │ │ ├── NullCoalesceTest.php │ │ ├── ParentTest.php │ │ ├── Ternary/ │ │ │ └── ConditionalTernaryTest.php │ │ ├── TestTest.php │ │ ├── Unary/ │ │ │ ├── NegTest.php │ │ │ ├── NotTest.php │ │ │ └── PosTest.php │ │ └── Variable/ │ │ ├── AssignContextVariableTest.php │ │ └── ContextVariableTest.php │ ├── ForTest.php │ ├── IfTest.php │ ├── ImportTest.php │ ├── IncludeTest.php │ ├── MacroTest.php │ ├── ModuleTest.php │ ├── NodeTest.php │ ├── PrintTest.php │ ├── SandboxTest.php │ ├── SetTest.php │ ├── TextTest.php │ └── TypesTest.php ├── NodeVisitor/ │ ├── OptimizerTest.php │ └── SandboxTest.php ├── ParserTest.php ├── Profiler/ │ ├── Dumper/ │ │ ├── BlackfireTest.php │ │ ├── HtmlTest.php │ │ ├── ProfilerTestCase.php │ │ └── TextTest.php │ └── ProfileTest.php ├── Runtime/ │ └── EscaperRuntimeTest.php ├── TemplateTest.php ├── TemplateWrapperTest.php ├── TokenParser/ │ ├── GuardTokenParserTest.php │ └── TypesTokenParserTest.php ├── TokenStreamTest.php ├── Util/ │ ├── CallableArgumentsExtractorTest.php │ └── DeprecationCollectorTest.php └── drupal_test.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ ; top-most EditorConfig file root = true ; Unix-style newlines [*] end_of_line = LF [*.php] indent_style = space indent_size = 4 [*.test] indent_style = space indent_size = 4 [*.rst] indent_style = space indent_size = 4 ================================================ FILE: .gitattributes ================================================ /bin/ export-ignore /doc/ export-ignore /extra/ export-ignore /tests/ export-ignore /.editorconfig export-ignore /.git* export-ignore /.php-cs-fixer.dist.php export-ignore /phpunit.xml.dist export-ignore /phpstan.neon.dist export-ignore /phpstan-baseline.neon export-ignore /splitsh.json export-ignore ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/workflows/ci.yml ================================================ name: "CI" on: pull_request: push: env: SYMFONY_PHPUNIT_DISABLE_RESULT_CACHE: 1 permissions: contents: read jobs: tests: name: "PHP ${{ matrix.php-version }}" runs-on: 'ubuntu-latest' strategy: matrix: php-version: - '8.1' - '8.2' - '8.3' - '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 - name: "Add PHPUnit matcher" run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - 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 - name: "PHPUnit version" run: vendor/bin/simple-phpunit --version - name: "Run tests" run: vendor/bin/simple-phpunit extension-tests: needs: - 'tests' name: "${{ matrix.extension }} PHP ${{ matrix.php-version }}" runs-on: 'ubuntu-latest' continue-on-error: true strategy: matrix: php-version: - '8.1' - '8.2' - '8.3' - '8.4' extension: - 'cache-extra' - 'cssinliner-extra' - 'html-extra' - 'inky-extra' - 'intl-extra' - 'markdown-extra' - 'string-extra' - 'twig-extra-bundle' 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 - name: "Add PHPUnit matcher" run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: "Composer install Twig" run: composer install - name: "Install PHPUnit" run: vendor/bin/simple-phpunit install - name: "PHPUnit version" run: vendor/bin/simple-phpunit --version - name: "Prevent installing symfony/translation-contracts 3.0" if: "matrix.extension == 'twig-extra-bundle'" working-directory: extra/${{ matrix.extension }} run: "composer require --no-update 'symfony/translation-contracts:^1.1|^2.0'" - name: "Composer install ${{ matrix.extension }}" working-directory: extra/${{ matrix.extension }} run: composer install - 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 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 ================================================ FILE: .github/workflows/documentation.yml ================================================ name: "Documentation" on: pull_request: push: permissions: contents: read jobs: build: name: "Build" runs-on: ubuntu-latest steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Set-up PHP" uses: shivammathur/setup-php@v2 with: php-version: 8.2 coverage: none tools: "composer:v2" - name: Get composer cache directory id: composercache working-directory: doc/_build run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies uses: actions/cache@v4 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - name: "Install dependencies" working-directory: doc/_build run: composer install --prefer-dist --no-progress - name: "Build the docs" working-directory: doc/_build run: php build.php --disable-cache doctor-rst: name: "DOCtor-RST" runs-on: ubuntu-latest steps: - name: "Checkout code" uses: actions/checkout@v4 - name: "Run DOCtor-RST" uses: docker://oskarstark/doctor-rst with: args: --short env: DOCS_DIR: 'doc/' ================================================ FILE: .github/workflows/fabbot.yml ================================================ 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 ================================================ FILE: .gitignore ================================================ /doc/_build/vendor /doc/_build/output /composer.lock /phpunit.xml /vendor .phpunit.result.cache ================================================ FILE: .php-cs-fixer.dist.php ================================================ setRules([ '@Symfony' => true, '@Symfony:risky' => true, '@PHPUnit75Migration:risky' => true, 'php_unit_dedicate_assert' => ['target' => '5.6'], 'array_syntax' => ['syntax' => 'short'], 'php_unit_fqcn_annotation' => true, 'no_unreachable_default_argument_value' => false, 'braces' => ['allow_single_line_closure' => true], 'heredoc_to_nowdoc' => false, 'single_line_throw' => false, 'ordered_imports' => true, 'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'], 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], ]) ->setRiskyAllowed(true) ->setParallelConfig(ParallelConfigFactory::detect()) ->setFinder((new Finder())->in(__DIR__)) ; ================================================ FILE: CHANGELOG ================================================ # 3.24.1 (2026-XX-XX) * n/a # 3.24.0 (2026-03-17) * Deprecate not implementing the `getOperatorTokens()` method in `ExpressionParserInterface` implementations * Deprecate passing a non-`AbstractExpression` node to `Twig\Node\Expression\Binary\MatchesBinary` constructor * Deprecate passing a non-`AbstractExpression` node to `Parser::setParent()` * Add support for renaming variables in object destructuring (`{name: userName} = user`) * Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names * Add support for short-circuiting in null-safe operator chains * Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters # 3.23.0 (2026-01-23) * Add `=` assignment operator (allows to set variables in expression or to replace the short-form of the set tag) * Add sequence, mapping, and object destructuring * Add `?.` null-safe operator * Add `===` and `!==` operators (equivalent to the `same as` and `not same as` tests) * Fix opcache preload warning for unlinked anonymous class * Fix spread operator behavior # 3.22.2 (2025-12-14) * Fix "cycle" with non-countable ArrayAccess + Traversable objects * Use "getShareDir" as an indicator of Symfony version in Symfony bundle * Fix escaper compatibility with PHP 8.5 # 3.22.1 (2025-11-16) * Add support for Symfony 8 # 3.22.0 (2025-10-29) * Add support for two words test in guard tag * Add `Environment::registerUndefinedTestCallback()` * Fix compatibility with Symfony 8 * Fix accessing arrays with stringable objects as key * Avoid errors when failing to guess the template info for an error * Fix expression parser compatibility layer * Fix compiling 'index' with repr (not string) in EmbedNode * Update configuration keys + allow extra keys for CommonMark extensions * Allow usage of other Markdown converters than CommonMark in LeagueMarkdown # 3.21.1 (2025-05-03) * Fix ExtensionSet usage of BinaryOperatorExpressionParser # 3.21.0 (2025-05-02) * Fix wrong array index * Deprecate `Template::loadTemplate()` * Fix testing and expression when it evaluates to an instance of `Markup` * Add `ReturnPrimitiveTypeInterface` (and sub-interfaces for number, boolean, string, and array) * Add `SupportDefinedTestInterface` for expression nodes supporting the `defined` test * Deprecate using the `|` operator in an expression with `+` or `-` without using parentheses to clarify precedence * Deprecate operator precedence outside of the [0, 512] range * Introduce expression parser classes to describe operators and operands provided by extensions instead of arrays (it comes with many deprecations that are documented in the ``deprecated`` documentation chapter) * Deprecate the `Twig\ExpressionParser`, and `Twig\OperatorPrecedenceChange` classes * Add attributes `AsTwigFilter`, `AsTwigFunction`, and `AsTwigTest` to ease extension development # 3.20.0 (2025-02-13) * Fix support for ignoring syntax errors in an undefined handler in guard * 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) * Support for invoking closures * Fix unary operator precedence change * Ignore `SyntaxError` exceptions from undefined handlers when using the `guard` tag * Add a way to stream template rendering (`TemplateWrapper::stream()` and `TemplateWrapper::streamBlock()`) # 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 '?:' # 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) # 3.4.2 (2022-08-12) * Allow inherited magic method to still run with calling class * Fix CallExpression::reflectCallable() throwing TypeError * Fix typo in naming (currency_code) # 3.4.1 (2022-05-17) * Fix optimizing non-public named closures # 3.4.0 (2022-05-22) * Add support for named closures # 3.3.10 (2022-04-06) * Enable bytecode invalidation when auto_reload is enabled # 3.3.9 (2022-03-25) * Fix custom escapers when using multiple Twig environments * Add support for "constant('class', object)" * Do not reuse internally generated variable names during parsing # 3.3.8 (2022-02-04) * Fix a security issue when in a sandbox: the `sort` filter must require a Closure for the `arrow` parameter * Fix deprecation notice on `round` * Fix call to deprecated `convertToHtml` method # 3.3.7 (2022-01-03) * Allow more null support when Twig expects a string (for better 8.1 support) * Only use Commonmark extensions if markdown enabled # 3.3.6 (2022-01-03) * Only use Commonmark extensions if markdown enabled # 3.3.5 (2022-01-03) * Allow CommonMark extensions to easily be added * Allow null when Twig expects a string (for better 8.1 support) * Make some performance optimizations * Allow Symfony translation contract v3+ # 3.3.4 (2021-11-25) * Bump minimum supported Symfony component versions * Fix a deprecated message # 3.3.3 (2021-09-17) * Allow Symfony 6 * Improve compatibility with PHP 8.1 * Explicitly specify the encoding for mb_ord in JS escaper # 3.3.2 (2021-05-16) * Revert "Throw a proper exception when a template name is an absolute path (as it has never been supported)" # 3.3.1 (2021-05-12) * Fix PHP 8.1 compatibility * Throw a proper exception when a template name is an absolute path (as it has never been supported) # 3.3.0 (2021-02-08) * Fix macro calls in a "cache" tag * Add the slug filter * Allow extra bundle to be compatible with Twig 2 # 3.2.1 (2021-01-05) * Fix extra bundle compat with older versions of Symfony # 3.2.0 (2021-01-05) * Add the Cache extension in the "extra" repositories: "cache" tag * Add "registerUndefinedTokenParserCallback" * Mark built-in node visitors as @internal * Fix "odd" not working for negative numbers # 3.1.1 (2020-10-27) * Fix "include(template_from_string())" # 3.1.0 (2020-10-21) * Fix sandbox support when using "include(template_from_string())" * Make round brackets optional for one argument tests like "same as" or "divisible by" * Add support for ES2015 style object initialisation shortcut { a } is the same as { 'a': a } # 3.0.5 (2020-08-05) * Fix twig_compare w.r.t. whitespace trimming * Fix sandbox not disabled if syntax error occurs within {% sandbox %} tag * Fix a regression when not using a space before an operator * Restrict callables to closures in filters * Allow trailing commas in argument lists (in calls as well as definitions) # 3.0.4 (2020-07-05) * Fix comparison operators * Fix options not taken into account when using "Michelf\MarkdownExtra" * Fix "Twig\Extra\Intl\IntlExtension::getCountryName()" to accept "null" as a first argument * Throw exception in case non-Traversable data is passed to "filter" * Fix context optimization on PHP 7.4 * Fix PHP 8 compatibility * Fix ambiguous syntax parsing # 3.0.3 (2020-02-11) * Add a check to ensure that iconv() is defined # 3.0.2 (2020-02-11) * Avoid exceptions when an intl resource is not found * Fix implementation of case-insensitivity for method names # 3.0.1 (2019-12-28) * fixed Symfony 5.0 support for the HTML extra extension # 3.0.0 (2019-11-15) * fixed number formatter in Intl extra extension when using a formatter prototype # 3.0.0-BETA1 (2019-11-11) * removed the "if" condition support on the "for" tag * made the in, <, >, <=, >=, ==, and != operators more strict when comparing strings and integers/floats * removed the "filter" tag * added type hints everywhere * changed Environment::resolveTemplate() to always return a TemplateWrapper instance * removed Template::__toString() * removed Parser::isReservedMacroName() * removed SanboxedPrintNode * removed Node::setTemplateName() * made classes marked as "@final" final * removed InitRuntimeInterface, ExistsLoaderInterface, and SourceContextLoaderInterface * removed the "spaceless" tag * removed Twig\Environment::getBaseTemplateClass() and Twig\Environment::setBaseTemplateClass() * removed the "base_template_class" option on Twig\Environment * bumped minimum PHP version to 7.2 * removed PSR-0 classes ================================================ FILE: LICENSE ================================================ Copyright (c) 2009-present by the Twig Team. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Twig nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.rst ================================================ Twig, the flexible, fast, and secure template language for PHP ============================================================== Twig is a template language for PHP. Twig uses a syntax similar to the Django and Jinja template languages which inspired the Twig runtime environment. Sponsors -------- .. raw:: html Blackfire.io More Information ---------------- Read the `documentation`_ for more information. .. _documentation: https://twig.symfony.com/documentation ================================================ FILE: bin/generate_operators_precedence.php ================================================ getExpressionParsers() as $expressionParser) { if (!$seen->offsetExists($expressionParser)) { $expressionParsers[] = $expressionParser; $seen->offsetSet($expressionParser, true); $descriptionLength = max($descriptionLength, $expressionParser instanceof ExpressionParserDescriptionInterface ? strlen($expressionParser->getDescription()) : ''); } } fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); fwrite($output, '| Precedence | Operator | Type | Associativity | Description'.str_repeat(' ', $descriptionLength - 11)." |\n"); fwrite($output, '+============+==================+=========+===============+'.str_repeat('=', $descriptionLength + 2).'+'); usort($expressionParsers, static fn ($a, $b) => $b->getPrecedence() <=> $a->getPrecedence()); $previous = null; foreach ($expressionParsers as $expressionParser) { if (null !== $previous) { fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2).'+'); } $precedence = $expressionParser->getPrecedence(); $previousPrecedence = $previous ? $previous->getPrecedence() : \PHP_INT_MAX; $associativity = $expressionParser instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $expressionParser->getAssociativity() ? 'Left' : 'Right') : 'n/a'; $previousAssociativity = $previous ? ($previous instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $previous->getAssociativity() ? 'Left' : 'Right') : 'n/a') : 'n/a'; if ($previousPrecedence !== $precedence) { $previous = null; } $operatorName = '``'.$expressionParser->getName().'``'; if ($expressionParser->getAliases()) { $operatorName .= ', ``'.implode('``, ``', $expressionParser->getAliases()).'``'; } fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", (!$previous || $previousPrecedence !== $precedence ? $precedence : '').($expressionParser->getPrecedenceChange() ? ' => '.$expressionParser->getPrecedenceChange()->getNewPrecedence() : ''), $operatorName, !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', !$previous || $previousAssociativity !== $associativity ? $associativity : '', $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', ))); $previous = $expressionParser; } fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); fwrite($output, "\nWhen a precedence will change in 4.0, the new precedence is indicated by the arrow ``=>``.\n"); fwrite($output, "\nHere is the same table for Twig 4.0 with adjusted precedences:\n"); fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); fwrite($output, '| Precedence | Operator | Type | Associativity | Description'.str_repeat(' ', $descriptionLength - 11)." |\n"); fwrite($output, '+============+==================+=========+===============+'.str_repeat('=', $descriptionLength + 2).'+'); usort($expressionParsers, static function ($a, $b) { $aPrecedence = $a->getPrecedenceChange() ? $a->getPrecedenceChange()->getNewPrecedence() : $a->getPrecedence(); $bPrecedence = $b->getPrecedenceChange() ? $b->getPrecedenceChange()->getNewPrecedence() : $b->getPrecedence(); return $bPrecedence - $aPrecedence; }); $previous = null; foreach ($expressionParsers as $expressionParser) { if (null !== $previous) { fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2).'+'); } $precedence = $expressionParser->getPrecedenceChange() ? $expressionParser->getPrecedenceChange()->getNewPrecedence() : $expressionParser->getPrecedence(); $previousPrecedence = $previous ? ($previous->getPrecedenceChange() ? $previous->getPrecedenceChange()->getNewPrecedence() : $previous->getPrecedence()) : \PHP_INT_MAX; $associativity = $expressionParser instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $expressionParser->getAssociativity() ? 'Left' : 'Right') : 'n/a'; $previousAssociativity = $previous ? ($previous instanceof InfixExpressionParserInterface ? (InfixAssociativity::Left === $previous->getAssociativity() ? 'Left' : 'Right') : 'n/a') : 'n/a'; if ($previousPrecedence !== $precedence) { $previous = null; } $operatorName = '``'.$expressionParser->getName().'``'; if ($expressionParser->getAliases()) { $operatorName .= ', ``'.implode('``, ``', $expressionParser->getAliases()).'``'; } fwrite($output, rtrim(sprintf("\n| %-10s | %-16s | %-7s | %-13s | %-{$descriptionLength}s |\n", !$previous || $previousPrecedence !== $precedence ? $precedence : '', $operatorName, !$previous || ExpressionParserType::getType($previous) !== ExpressionParserType::getType($expressionParser) ? ExpressionParserType::getType($expressionParser)->value : '', !$previous || $previousAssociativity !== $associativity ? $associativity : '', $expressionParser instanceof ExpressionParserDescriptionInterface ? $expressionParser->getDescription() : '', ))); $previous = $expressionParser; } fwrite($output, "\n+------------+------------------+---------+---------------+".str_repeat('-', $descriptionLength + 2)."+\n"); fclose($output); ================================================ FILE: composer.json ================================================ { "name": "twig/twig", "type": "library", "description": "Twig, the flexible, fast, and secure template language for PHP", "keywords": ["templating"], "homepage": "https://twig.symfony.com", "license": "BSD-3-Clause", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" }, { "name": "Twig Team", "role": "Contributors" }, { "name": "Armin Ronacher", "email": "armin.ronacher@active-4.com", "role": "Project Founder" } ], "require": { "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": "^5.4.9|^6.4|^7.0", "psr/container": "^1.0|^2.0", "phpstan/phpstan": "^2.0@stable", "php-cs-fixer/shim": "^3.0@stable" }, "autoload": { "files": [ "src/Resources/core.php", "src/Resources/debug.php", "src/Resources/escaper.php", "src/Resources/string_loader.php" ], "psr-4" : { "Twig\\" : "src/" } }, "autoload-dev": { "psr-4" : { "Twig\\Tests\\" : "tests/" } } } ================================================ FILE: doc/.doctor-rst.yaml ================================================ rules: american_english: ~ avoid_repetetive_words: ~ blank_line_after_directive: ~ blank_line_before_directive: ~ composer_dev_option_not_at_the_end: ~ correct_code_block_directive_based_on_the_content: ~ deprecated_directive_should_have_version: ~ ensure_order_of_code_blocks_in_configuration_block: ~ extension_xlf_instead_of_xliff: ~ indention: ~ lowercase_as_in_use_statements: ~ max_blank_lines: max: 2 no_blank_line_after_filepath_in_php_code_block: ~ no_blank_line_after_filepath_in_twig_code_block: ~ no_blank_line_after_filepath_in_xml_code_block: ~ no_blank_line_after_filepath_in_yaml_code_block: ~ no_composer_req: ~ no_explicit_use_of_code_block_php: ~ no_inheritdoc: ~ no_namespace_after_use_statements: ~ no_php_open_tag_in_code_block_php_directive: ~ no_space_before_self_xml_closing_tag: ~ ordered_use_statements: ~ php_prefix_before_bin_console: ~ replace_code_block_types: ~ replacement: ~ short_array_syntax: ~ typo: ~ unused_links: ~ use_deprecated_directive_instead_of_versionadded: ~ use_https_xsd_urls: ~ valid_inline_highlighted_namespaces: ~ valid_use_statements: ~ versionadded_directive_should_have_version: ~ yaml_instead_of_yml_suffix: ~ yarn_dev_option_at_the_end: ~ versionadded_directive_major_version: major_version: 3 versionadded_directive_min_version: min_version: '3.0' deprecated_directive_major_version: major_version: 3 deprecated_directive_min_version: min_version: '3.0' whitelist: lines: - 'I like Twig.
' ================================================ FILE: doc/_build/build.php ================================================ #!/usr/bin/env php register('build-docs') ->addOption('disable-cache', null, InputOption::VALUE_NONE, 'Use this option to force a full regeneration of all doc contents') ->setCode(static function (InputInterface $input, OutputInterface $output) { $io = new SymfonyStyle($input, $output); $io->text('Building all Twig docs...'); $outputDir = __DIR__.'/output'; $buildConfig = (new BuildConfig()) ->setContentDir(__DIR__.'/..') ->setOutputDir($outputDir) ->setImagesDir(__DIR__.'/output/_images') ->setImagesPublicPrefix('_images') ->setTheme('rtd') ; $buildConfig->setExcludedPaths(['vendor/']); $buildConfig->disableJsonFileGeneration(); $buildConfig->disableBuildCache(); $result = (new DocBuilder())->build($buildConfig); if ($result->isSuccessful()) { // fix assets URLs to make them absolute (otherwise, they don't work in subdirectories) foreach (glob($outputDir.'/**/*.html') as $htmlFilePath) { $htmlContents = file_get_contents($htmlFilePath); file_put_contents($htmlFilePath, str_replace('href="assets/', 'href="/assets/', $htmlContents)); } $io->success(sprintf('The Twig docs were successfully built at %s', realpath($outputDir))); } else { $io->error(sprintf("There were some errors while building the docs:\n\n%s\n", $result->getErrorTrace())); $io->newLine(); $io->comment('Tip: you can add the -v, -vv or -vvv flags to this command to get debug information.'); return 1; } return 0; }) ->getApplication() ->setDefaultCommand('build-docs', true) ->run(); ================================================ FILE: doc/_build/composer.json ================================================ { "minimum-stability": "dev", "prefer-stable": true, "config": { "platform": { "php": "8.1.0" }, "preferred-install": { "*": "dist" }, "sort-packages": true }, "require": { "php": ">=8.1", "symfony/console": "^5.4", "symfony/process": "^5.4", "symfony-tools/docs-builder": "^0.18" } } ================================================ FILE: doc/advanced.rst ================================================ Extending Twig ============== Twig can be extended in many ways; you can add extra tags, filters, tests, operators, global variables, and functions. You can even extend the parser itself with node visitors. .. note:: The first section of this chapter describes how to extend Twig. If you want to reuse your changes in different projects or if you want to share them with others, you should then create an extension as described in the following section. .. caution:: When extending Twig without creating an extension, Twig won't be able to recompile your templates when the PHP code is updated. To see your changes in real-time, either disable template caching or package your code into an extension (see the next section of this chapter). Before extending Twig, you must understand the differences between all the different possible extension points and when to use them. First, remember that Twig has two main language constructs: * ``{{ }}``: used to print the result of an expression evaluation; * ``{% %}``: used to execute statements. To understand why Twig exposes so many extension points, let's see how to implement a *Lorem ipsum* generator (it needs to know the number of words to generate). You can use a ``lipsum`` *tag*: .. code-block:: twig {% lipsum 40 %} That works, but using a tag for ``lipsum`` is not a good idea for at least three main reasons: * ``lipsum`` is not a language construct; * The tag outputs something; * The tag is not flexible as you cannot use it in an expression: .. code-block:: twig {{ 'some text' ~ {% lipsum 40 %} ~ 'some more text' }} In fact, you rarely need to create tags; and that's good news because tags are the most complex extension point. Now, let's use a ``lipsum`` *filter*: .. code-block:: twig {{ 40|lipsum }} Again, it works. But a filter should transform the passed value to something else. Here, we use the value to indicate the number of words to generate (so, ``40`` is an argument of the filter, not the value we want to transform). Next, let's use a ``lipsum`` *function*: .. code-block:: twig {{ lipsum(40) }} Here we go. For this specific example, the creation of a function is the extension point to use. And you can use it anywhere an expression is accepted: .. code-block:: twig {{ 'some text' ~ lipsum(40) ~ 'some more text' }} {% set lipsum = lipsum(40) %} Lastly, you can also use a *global* object with a method able to generate lorem ipsum text: .. code-block:: twig {{ text.lipsum(40) }} As a rule of thumb, use functions for frequently used features and global objects for everything else. Keep in mind the following when you want to extend Twig: ========== ========================== ========== ========================= What? Implementation difficulty? How often? When? ========== ========================== ========== ========================= *macro* simple frequent Content generation *global* simple frequent Helper object *function* simple frequent Content generation *filter* simple frequent Value transformation *tag* complex rare DSL language construct *test* simple rare Boolean decision *operator* simple rare Values transformation ========== ========================== ========== ========================= Globals ------- Global variables are available in all templates and macros. Use ``addGlobal()`` to add a global variable to a Twig environment:: $twig = new \Twig\Environment($loader); $twig->addGlobal('text', new Text()); You can then use the ``text`` variable anywhere in a template: .. code-block:: twig {{ text.lipsum(40) }} Filters ------- Creating a filter consists of associating a name with a PHP callable:: // an anonymous function $filter = new \Twig\TwigFilter('rot13', function ($string) { return str_rot13($string); }); // or a simple PHP function $filter = new \Twig\TwigFilter('rot13', 'str_rot13'); // or a class static method $filter = new \Twig\TwigFilter('rot13', ['SomeClass', 'rot13Filter']); $filter = new \Twig\TwigFilter('rot13', 'SomeClass::rot13Filter'); // or a class method $filter = new \Twig\TwigFilter('rot13', [$this, 'rot13Filter']); // the one below needs a runtime implementation (see below for more information) $filter = new \Twig\TwigFilter('rot13', ['SomeClass', 'rot13Filter']); The first argument passed to the ``\Twig\TwigFilter`` constructor is the name of the filter you will use in templates and the second one is the PHP callable to associate with it. Then, add the filter to the Twig environment:: $twig = new \Twig\Environment($loader); $twig->addFilter($filter); And here is how to use it in a template: .. code-block:: twig {{ 'Twig'|rot13 }} {# will output Gjvt #} When called by Twig, the PHP callable receives the left side of the filter (before the pipe ``|``) as the first argument and the extra arguments passed to the filter (within parentheses ``()``) as extra arguments. For instance, the following code: .. code-block:: twig {{ 'TWIG'|lower }} {{ now|date('d/m/Y') }} is compiled to something like the following:: 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 ~~~~~~~~~~~~~~~~~~~~~~~~~ If you want to access the current environment instance in your filter, set the ``needs_environment`` option to ``true``; Twig will pass the current environment as the first argument to the filter call:: $filter = new \Twig\TwigFilter('rot13', function (\Twig\Environment $env, $string) { // get the current charset for instance $charset = $env->getCharset(); return str_rot13($string); }, ['needs_environment' => true]); Context-aware Filters ~~~~~~~~~~~~~~~~~~~~~ If you want to access the current context in your filter, set the ``needs_context`` option to ``true``; Twig will pass the current context as the first argument to the filter call (or the second one if ``needs_environment`` is also set to ``true``):: $filter = new \Twig\TwigFilter('rot13', function ($context, $string) { // ... }, ['needs_context' => true]); $filter = new \Twig\TwigFilter('rot13', function (\Twig\Environment $env, $context, $string) { // ... }, ['needs_context' => true, 'needs_environment' => true]); Automatic Escaping ~~~~~~~~~~~~~~~~~~ If automatic escaping is enabled, the output of the filter may be escaped before printing. If your filter acts as an escaper (or explicitly outputs HTML or JavaScript code), you will want the raw output to be printed. In such a case, set the ``is_safe`` option:: $filter = new \Twig\TwigFilter('nl2br', 'nl2br', ['is_safe' => ['html']]); Some filters may need to work on input that is already escaped or safe, for example when adding (safe) HTML tags to originally unsafe output. In such a case, set the ``pre_escape`` option to escape the input data before it is run through your filter:: $filter = new \Twig\TwigFilter('somefilter', 'somefilter', ['pre_escape' => 'html', 'is_safe' => ['html']]); Variadic Filters ~~~~~~~~~~~~~~~~ When a filter should accept an arbitrary number of arguments, set the ``is_variadic`` option to ``true``; Twig will pass the extra arguments as the last argument to the filter call as an array:: $filter = new \Twig\TwigFilter('thumbnail', function ($file, array $options = []) { // ... }, ['is_variadic' => true]); Be warned that :ref:`named arguments ` passed to a variadic filter cannot be checked for validity as they will automatically end up in the option array. Dynamic Filters ~~~~~~~~~~~~~~~ A filter name containing the special ``*`` character is a dynamic filter and the ``*`` part will match any string:: $filter = new \Twig\TwigFilter('*_path', function ($name, $arguments) { // ... }); The following filters are matched by the above defined dynamic filter: * ``product_path`` * ``category_path`` A dynamic filter can define more than one dynamic parts:: $filter = new \Twig\TwigFilter('*_path_*', function ($name, $suffix, $arguments) { // ... }); The filter receives all dynamic part values before the normal filter arguments, but after the environment and the context. For instance, a call to ``'Paris'|a_path_b()`` will result in the following arguments to be passed to the filter: ``('a', 'b', 'Paris')``. Deprecated Filters ~~~~~~~~~~~~~~~~~~ .. 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 () { // ... }, ['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 --------- Functions are defined in the exact same way as filters, but you need to create an instance of ``\Twig\TwigFunction``:: $twig = new \Twig\Environment($loader); $function = new \Twig\TwigFunction('function_name', function () { // ... }); $twig->addFunction($function); Functions support the same features as filters, except for the ``pre_escape`` and ``preserves_safety`` options. Tests ----- Tests are defined in the exact same way as filters and functions, but you need to create an instance of ``\Twig\TwigTest``:: $twig = new \Twig\Environment($loader); $test = new \Twig\TwigTest('test_name', function () { // ... }); $twig->addTest($test); Tests allow you to create custom application specific logic for evaluating boolean conditions. As a simple example, let's create a Twig test that checks if objects are 'red':: $twig = new \Twig\Environment($loader); $test = new \Twig\TwigTest('red', function ($value) { if (isset($value->color) && $value->color == 'red') { return true; } if (isset($value->paint) && $value->paint == 'red') { return true; } return false; }); $twig->addTest($test); Test functions must always return ``true``/``false``. When creating tests you can use the ``node_class`` option to provide custom test 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', null, ['node_class' => OddTestExpression::class]); $twig->addTest($test); class OddTestExpression extends TestExpression { public function compile(\Twig\Compiler $compiler) { $compiler ->raw('(') ->subcompile($this->getNode('node')) ->raw(' % 2 != 0') ->raw(')') ; } } The above example shows how you can create tests that use a node class. The node class has access to one sub-node called ``node``. This sub-node contains the value that is being tested. When the ``odd`` filter is used in code such as: .. code-block:: twig {% if my_value is odd %} The ``node`` sub-node will contain an expression of ``my_value``. Node-based tests also have access to the ``arguments`` node. This node will contain the various other arguments that have been provided to your test. If you want to pass a variable number of positional or named arguments to the test, set the ``is_variadic`` option to ``true``. Tests support dynamic names (see dynamic filters for the syntax). Tags ---- One of the most exciting features of a template engine like Twig is the possibility to define new **language constructs**. This is also the most complex feature as you need to understand how Twig's internals work. Most of the time though, a tag is not needed: * If your tag generates some output, use a **function** instead. * If your tag modifies some content and returns it, use a **filter** instead. For instance, if you want to create a tag that converts a Markdown formatted text to HTML, create a ``markdown`` filter instead: .. code-block:: twig {{ '**markdown** text'|markdown }} If you want use this filter on large amounts of text, wrap it with the :doc:`apply ` tag: .. code-block:: twig {% apply markdown %} Title ===== Much better than creating a tag as you can **compose** filters. {% endapply %} * 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:`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: .. code-block:: twig {% do log('Log some things') %} If you still want to create a tag for a new language construct, great! Let's create a ``set`` tag that allows the definition of simple variables from within a template. The tag can be used like follows: .. code-block:: twig {% set name = "value" %} {{ name }} {# should output value #} .. note:: The ``set`` tag is part of the Core extension and as such is always available. The built-in version is slightly more powerful and supports multiple assignments by default. Three steps are needed to define a new tag: * Defining a Token Parser class (responsible for parsing the template code); * Defining a Node class (responsible for converting the parsed code to PHP); * Registering the tag. Registering a new tag ~~~~~~~~~~~~~~~~~~~~~ Add a tag by calling the ``addTokenParser`` method on the ``\Twig\Environment`` instance:: $twig = new \Twig\Environment($loader); $twig->addTokenParser(new CustomSetTokenParser()); Defining a Token Parser ~~~~~~~~~~~~~~~~~~~~~~~ Now, let's see the actual code of this class:: 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(); $stream->expect(\Twig\Token::OPERATOR_TYPE, '='); $value = $parser->getExpressionParser()->parseExpression(); $stream->expect(\Twig\Token::BLOCK_END_TYPE); return new CustomSetNode($name, $value, $lineno); } public function getTag() { return 'set'; } } 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 ``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()``): * ``getCurrent()``: Gets the current token in the stream. * ``next()``: Moves to the next token in the stream, *but returns the old one*. * ``test($type)``, ``test($value)`` or ``test($type, $value)``: Determines whether the current token is of a particular type or value (or both). The value may be an array of several possible values. * ``expect($type[, $value[, $message]])``: If the current token isn't of the given type/value a syntax error is thrown. Otherwise, if the type and value are correct, the token is returned and the stream moves to the next token. * ``look()``: Looks at the next token without consuming it. 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 the nitty-gritty details of the parsing process. Defining a Node ~~~~~~~~~~~~~~~ The ``CustomSetNode`` class itself is quite short:: class CustomSetNode extends \Twig\Node\Node { public function __construct($name, \Twig\Node\Expression\AbstractExpression $value, $line) { parent::__construct(['value' => $value], ['name' => $name], $line); } public function compile(\Twig\Compiler $compiler) { $compiler ->addDebugInfo($this) ->write('$context[\''.$this->getAttribute('name').'\'] = ') ->subcompile($this->getNode('value')) ->raw(";\n") ; } } The compiler implements a fluid interface and provides methods that help the developer generate beautiful and readable PHP code: * ``subcompile()``: Compiles a node. * ``raw()``: Writes the given string as is. * ``write()``: Writes the given string by adding indentation at the beginning of each line. * ``string()``: Writes a quoted string. * ``repr()``: Writes a PHP representation of a given value (see ``\Twig\Node\ForNode`` for a usage example). * ``addDebugInfo()``: Adds the line of the original template file related to 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). * ``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 --------------------- The main motivation for writing an extension is to move often used code into a reusable class like adding support for internationalization. An extension can define tags, filters, tests, operators, functions, and node visitors. Most of the time, it is useful to create a single extension for your project, to host all the specific tags and filters you want to add to Twig. .. tip:: When packaging your code into an extension, Twig is smart enough to recompile your templates whenever you make a change to it (when ``auto_reload`` is enabled). An extension is a class that implements the following interface:: interface \Twig\Extension\ExtensionInterface { /** * Returns the token parser instances to add to the existing list. * * @return \Twig\TokenParser\TokenParserInterface[] */ public function getTokenParsers(); /** * Returns the node visitor instances to add to the existing list. * * @return \Twig\NodeVisitor\NodeVisitorInterface[] */ public function getNodeVisitors(); /** * Returns a list of filters to add to the existing list. * * @return \Twig\TwigFilter[] */ public function getFilters(); /** * Returns a list of tests to add to the existing list. * * @return \Twig\TwigTest[] */ public function getTests(); /** * Returns a list of functions to add to the existing list. * * @return \Twig\TwigFunction[] */ public function getFunctions(); /** * Returns a list of expression parsers to add to the existing list. * * @return \Twig\ExpressionParser\ExpressionParserInterface[] */ public function getExpressionParsers(); } 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 CustomTwigExtension extends \Twig\Extension\AbstractExtension { } This extension does nothing for now. We will customize it in the next sections. You can save your extension anywhere on the filesystem, as all extensions must be registered explicitly to be available in your templates. You can register an extension by using the ``addExtension()`` method on your main ``Environment`` object:: $twig = new \Twig\Environment($loader); $twig->addExtension(new CustomTwigExtension()); .. tip:: The Twig core extensions are great examples of how extensions work. Globals ~~~~~~~ Global variables can be registered in an extension via the ``getGlobals()`` method:: class CustomTwigExtension extends \Twig\Extension\AbstractExtension implements \Twig\Extension\GlobalsInterface { public function getGlobals(): array { return [ 'text' => new Text(), ]; } // ... } .. 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 CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getFunctions() { return [ new \Twig\TwigFunction('lipsum', 'generate_lipsum'), ]; } // ... } Filters ~~~~~~~ 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 CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getFilters() { return [ new \Twig\TwigFilter('rot13', 'str_rot13'), ]; } // ... } Tags ~~~~ 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 CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getTokenParsers() { return [new CustomSetTokenParser()]; } // ... } In the above code, we have added a single new tag, defined by the ``CustomSetTokenParser`` class. The ``CustomSetTokenParser`` class is responsible for parsing the tag and compiling it to PHP. Operators ~~~~~~~~~ .. versionadded:: 3.21 The ``getExpressionParsers()`` method was added in Twig 3.21. .. deprecated:: 3.21 The ``getExpressionParsers()`` method replaces the now deprecated ``getOperators()`` method. See the :doc:`deprecated ` page for details on how to upgrade from ``getOperators()`` to ``getExpressionParsers()``. The ``getExpressionParsers()`` method lets you add new operators. To implement a new one, have a look at the default operators provided by ``Twig\Extension\CoreExtension``. Tests ~~~~~ The ``getTests()`` method lets you add new test functions:: class CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getTests() { return [ new \Twig\TwigTest('even', 'twig_test_even'), ]; } // ... } Using PHP Attributes to define Extensions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 3.21 The attribute classes were added in Twig 3.21. You can add the ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and ``#[AsTwigTest]`` attributes to public methods of any class to define filters, functions, and tests. Create a class using these attributes:: use Twig\Attribute\AsTwigFilter; use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigTest; class ProjectExtension { #[AsTwigFilter('rot13')] public static function rot13(string $string): string { // ... } #[AsTwigFunction('lipsum')] public static function lipsum(int $count): string { // ... } #[AsTwigTest('even')] public static function isEven(int $number): bool { // ... } } Then register the ``Twig\Extension\AttributeExtension`` with the class name:: $twig = new \Twig\Environment($loader); $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class)); If all the methods are static, you are done. The ``ProjectExtension`` class will never be instantiated and the class attributes will be scanned only when a template is compiled. Otherwise, if some methods are not static, you need to register the class as a runtime extension using one of the runtime loaders:: use Twig\Attribute\AsTwigFunction; class ProjectExtension { // Inject hypothetical dependencies public function __construct(private LipsumProvider $lipsumProvider) {} #[AsTwigFunction('lipsum')] public function lipsum(int $count): string { return $this->lipsumProvider->lipsum($count); } } $twig = new \Twig\Environment($loader); $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class); $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([ ProjectExtension::class => function () use ($lipsumProvider) { return new ProjectExtension($lipsumProvider); }, ])); If you want to access the current environment instance in your filter or function, add the ``Twig\Environment`` type to the first argument of the method:: class ProjectExtension { #[AsTwigFunction('lipsum')] public function lipsum(\Twig\Environment $env, int $count): string { // ... } } ``#[AsTwigFilter]`` and ``#[AsTwigFunction]`` support variadic arguments automatically when applied to variadic methods:: class ProjectExtension { #[AsTwigFilter('thumbnail')] public function thumbnail(string $file, mixed ...$options): string { // ... } } The attributes support other options used to configure the Twig Callables: * ``AsTwigFilter``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``preEscape``, ``preservesSafety``, ``deprecationInfo`` * ``AsTwigFunction``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``deprecationInfo`` * ``AsTwigTest``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``deprecationInfo`` Definition vs Runtime ~~~~~~~~~~~~~~~~~~~~~ Twig filters, functions, and tests runtime implementations can be defined as any valid PHP callable: * **functions/static methods**: Simple to implement and fast (used by all Twig core extensions); but it is hard for the runtime to depend on external objects; * **closures**: Simple to implement; * **object methods**: More flexible and required if your runtime code depends on external objects. The simplest way to use methods is to define them on the extension itself:: class CustomTwigExtension extends \Twig\Extension\AbstractExtension { private $rot13Provider; public function __construct($rot13Provider) { $this->rot13Provider = $rot13Provider; } public function getFunctions() { return [ new \Twig\TwigFunction('rot13', [$this, 'rot13']), ]; } public function rot13($value) { return $this->rot13Provider->rot13($value); } } This is very convenient but not recommended as it makes template compilation depend on runtime dependencies even if they are not needed (think for instance as a dependency that connects to a database engine). You can decouple the extension definitions from their runtime implementations by registering a ``\Twig\RuntimeLoader\RuntimeLoaderInterface`` instance on the environment that knows how to instantiate such runtime classes (runtime classes must be autoload-able):: class RuntimeLoader implements \Twig\RuntimeLoader\RuntimeLoaderInterface { public function load($class) { // 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 ('CustomTwigRuntime' === $class) { return new $class(new Rot13Provider()); } else { // ... } } } $twig->addRuntimeLoader(new RuntimeLoader()); .. note:: Twig comes with a PSR-11 compatible runtime loader (``\Twig\RuntimeLoader\ContainerRuntimeLoader``). It is now possible to move the runtime logic to a new ``CustomTwigRuntime`` class and use it directly in the extension:: class CustomTwigRuntime { private $rot13Provider; public function __construct($rot13Provider) { $this->rot13Provider = $rot13Provider; } public function rot13($value) { return $this->rot13Provider->rot13($value); } } class CustomTwigExtension extends \Twig\Extension\AbstractExtension { public function getFunctions() { return [ new \Twig\TwigFunction('rot13', ['CustomTwigRuntime', 'rot13']), // or 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 -------------------- Functional Tests ~~~~~~~~~~~~~~~~ You can create functional tests for extensions by creating the following file structure in your test directory:: Fixtures/ filters/ lower.test upper.test functions/ date.test format.test tags/ for.test if.test IntegrationTest.php The ``IntegrationTest.php`` file should look like this:: namespace Project\Tests; use Twig\Test\IntegrationTestCase; class IntegrationTest extends IntegrationTestCase { public function getExtensions() { return [ new CustomTwigExtension1(), new CustomTwigExtension2(), ]; } public function getFixturesDir() { return __DIR__.'/Fixtures/'; } } Fixtures examples can be found within the Twig repository `tests/Twig/Fixtures`_ directory. Node Tests ~~~~~~~~~~ Testing the node visitors can be complex, so extend your test cases from ``\Twig\Test\NodeTestCase``. Examples can be found in the Twig repository `tests/Twig/Node`_ directory. .. _`tests/Twig/Fixtures`: https://github.com/twigphp/Twig/tree/3.x/tests/Fixtures .. _`tests/Twig/Node`: https://github.com/twigphp/Twig/tree/3.x/tests/Node ================================================ FILE: doc/api.rst ================================================ Twig for Developers =================== This chapter describes the API to Twig and not the template language. It will be most useful as reference to those implementing the template interface to the application and not those who are creating Twig templates. Basics ------ Twig uses a central object called the **environment** (of class ``\Twig\Environment``). Instances of this class are used to store the configuration and extensions, and are used to load templates. Most applications create one ``\Twig\Environment`` object on application initialization and use that to load templates. In some cases, it might be useful to have multiple environments side by side, with different configurations. The typical way to configure Twig to load templates for an application looks roughly like this:: require_once '/path/to/vendor/autoload.php'; $loader = new \Twig\Loader\FilesystemLoader('/path/to/templates'); $twig = new \Twig\Environment($loader, [ 'cache' => '/path/to/compilation_cache', ]); This creates a template environment with a default configuration and a loader that looks up templates in the ``/path/to/templates/`` directory. Different loaders are available and you can also write your own if you want to load templates from a database or other resources. .. note:: Notice that the second argument of the environment is an array of options. The ``cache`` option is a compilation cache directory, where Twig caches the compiled templates to avoid the parsing phase for subsequent requests. It is very different from the cache you might want to add for the evaluated templates. For such a need, you can use any available PHP cache library. Loading Templates ----------------- To load a template, call the ``load()`` method on a Twig environment which returns a ``\Twig\TemplateWrapper`` instance:: $template = $twig->load('index.html.twig'); Rendering Templates ------------------- To render a template with some variables, call the ``render()`` method:: echo $template->render(['the' => 'variables', 'go' => 'here']); .. note:: The ``display()`` method is a shortcut to output the rendered template. You can also load and render the template directly via the Environment:: 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 ------------------- When creating a new ``\Twig\Environment`` instance, you can pass an array of options as the constructor second argument:: $twig = new \Twig\Environment($loader, ['debug' => true]); The following options are available: * ``debug`` *boolean* When set to ``true``, the generated templates have a ``__toString()`` method that you can use to display the generated nodes (default to ``false``). * ``charset`` *string* (defaults to ``utf-8``) The charset used by the templates. * ``cache`` *string* or ``false`` An absolute path where to store the compiled templates, or ``false`` to disable caching (which is the default). * ``auto_reload`` *boolean* When developing with Twig, it's useful to recompile the template whenever the source code changes. If you don't provide a value for 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 variables (variables and or attributes/methods that do not exist) and replace them with a ``null`` value. When set to ``true``, Twig throws an exception instead (default to ``false``). * ``autoescape`` *string* Sets the default auto-escaping strategy (``name``, ``html``, ``js``, ``css``, ``url``, ``html_attr``, ``html_attr_relaxed``, or a PHP callback that takes the template "filename" and returns the escaping strategy to use -- the callback cannot be a function name to avoid collision with built-in escaping strategies); set it to ``false`` to disable auto-escaping. The ``name`` escaping strategy determines the escaping strategy to use for a template based on the template filename extension (this strategy does not incur any overhead at runtime as auto-escaping is done at compilation time.) * ``optimizations`` *integer* A flag that indicates which optimizations to apply (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 ------- Loaders are responsible for loading templates from a resource such as the file system. Compilation Cache ~~~~~~~~~~~~~~~~~ All template loaders can cache the compiled templates on the filesystem for future reuse. It speeds up Twig a lot as templates are only compiled once. Built-in Loaders ~~~~~~~~~~~~~~~~ Here is a list of the built-in loaders: ``\Twig\Loader\FilesystemLoader`` ................................. ``\Twig\Loader\FilesystemLoader`` loads templates from the file system. This loader can find templates in folders on the file system and is the preferred way to load them:: $loader = new \Twig\Loader\FilesystemLoader($templateDir); It can also look for templates in an array of directories:: $loader = new \Twig\Loader\FilesystemLoader([$templateDir1, $templateDir2]); With such a configuration, Twig will first look for templates in ``$templateDir1`` and if they do not exist, it will fallback to look for them in the ``$templateDir2``. You can add or prepend paths via the ``addPath()`` and ``prependPath()`` methods:: $loader->addPath($templateDir3); $loader->prependPath($templateDir4); The filesystem loader also supports namespaced templates. This allows you to group your templates under different namespaces which have their own template paths. When using the ``setPaths()``, ``addPath()``, and ``prependPath()`` methods, specify the namespace as the second argument (when not specified, these methods act on the "main" namespace):: $loader->addPath($templateDir, 'admin'); Namespaced templates can be accessed via the special ``@namespace_name/template_path`` notation:: $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 directory (for instance, it allows warming the cache from a build server where the directory might be different from the one used on production servers):: $loader = new \Twig\Loader\FilesystemLoader('templates', getcwd().'/..'); .. note:: When not passing the root path as a second argument, Twig uses ``getcwd()`` for relative paths. ``\Twig\Loader\ArrayLoader`` ............................ ``\Twig\Loader\ArrayLoader`` loads a template from a PHP array. It is passed an array of strings bound to template names:: $loader = new \Twig\Loader\ArrayLoader([ 'index.html.twig' => 'Hello {{ name }}!', ]); $twig = new \Twig\Environment($loader); 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. .. tip:: When using the ``Array`` loader with a cache mechanism, you should know that a new cache key is generated each time a template content "changes" (the cache key being the source code of the template). If you don't want to see your cache grows out of control, you need to take care of clearing the old cache file by yourself. ``\Twig\Loader\ChainLoader`` ............................ ``\Twig\Loader\ChainLoader`` delegates the loading of templates to other loaders:: $loader1 = new \Twig\Loader\ArrayLoader([ 'base.html.twig' => '{% block content %}{% endblock %}', ]); $loader2 = new \Twig\Loader\ArrayLoader([ '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]); $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.twig`` template from the above example, Twig will load it with ``$loader2`` but the ``base.html.twig`` template will be loaded from ``$loader1``. .. note:: You can also add loaders via the ``addLoader()`` method. Create your own Loader ~~~~~~~~~~~~~~~~~~~~~~ All loaders implement the ``\Twig\Loader\LoaderInterface``:: interface \Twig\Loader\LoaderInterface { /** * Returns the source context for a given template logical name. * * @param string $name The template logical name * * @return \Twig\Source * * @throws \Twig\Error\LoaderError When $name is not found */ public function getSourceContext($name); /** * Gets the cache key to use for the cache for a given template name. * * @param string $name The name of the template to load * * @return string The cache key * * @throws \Twig\Error\LoaderError When $name is not found */ public function getCacheKey($name); /** * Returns true if the template is still fresh. * * @param string $name The template name * @param timestamp $time The last modification time of the cached template * * @return bool true if the template is fresh, false otherwise * * @throws \Twig\Error\LoaderError When $name is not found */ public function isFresh($name, $time); /** * Check if we have the source code of a template, given its name. * * @param string $name The name of the template to check if we can load * * @return bool If the template source code is handled by this loader or not */ public function exists($name); } The ``isFresh()`` method must return ``true`` if the current cached template is still fresh, given the last modification time, or ``false`` otherwise. The ``getSourceContext()`` method must return an instance of ``\Twig\Source``. Using Extensions ---------------- Twig extensions are packages that add new features to Twig. Register an extension via the ``addExtension()`` method:: $twig->addExtension(new \Twig\Extension\SandboxExtension()); Twig comes bundled with the following extensions: * ``\Twig\Extension\CoreExtension``: Defines all the core features of Twig. * ``\Twig\Extension\DebugExtension``: Defines the ``dump`` function to help debug template variables. * ``\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 environment, making it safe to evaluate untrusted code. * ``\Twig\Extension\ProfilerExtension``: Enables the built-in Twig profiler. * ``\Twig\Extension\OptimizerExtension``: Optimizes the node tree before compilation. * ``\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. Built-in Extensions ------------------- This section describes the features added by the built-in extensions. .. tip:: Read the chapter about :doc:`extending Twig ` to learn how to create your own extensions. Core Extension ~~~~~~~~~~~~~~ The ``core`` extension defines all the core features of Twig: * :doc:`Tags `; * :doc:`Filters `; * :doc:`Functions `; * :doc:`Tests `. Escaper Extension ~~~~~~~~~~~~~~~~~ The ``escaper`` extension adds automatic output escaping to Twig. It defines a tag, ``autoescape``, and a filter, ``raw``. When creating the escaper extension, you can switch on or off the global output escaping strategy:: $escaper = new \Twig\Extension\EscaperExtension('html'); $twig->addExtension($escaper); If set to ``html``, all variables in templates are escaped (using the ``html`` escaping strategy), except those using the ``raw`` filter: .. code-block:: twig {{ article.to_html|raw }} You can also change the escaping mode locally by using the ``autoescape`` tag: .. code-block:: twig {% autoescape 'html' %} {{ var }} {{ var|raw }} {# var won't be escaped #} {{ var|escape }} {# var won't be double-escaped #} {% endautoescape %} .. warning:: The ``autoescape`` tag has no effect on included files. The escaping rules are implemented as follows: * Literals (integers, booleans, arrays, ...) used in the template directly as variables or filter arguments are never automatically escaped: .. code-block:: html+twig {{ "Twig
" }} {# won't be escaped #} {% set text = "Twig
" %} {{ text }} {# will be escaped #} * Expressions which the result is a literal or a variable marked safe are never automatically escaped: .. code-block:: html+twig {{ 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
" %} {{ 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 strategies via ``EscaperExtension::addSafeClass()``: .. code-block:: twig // mark objects of class "HtmlGenerator" as safe for the HTML strategy $escaper->addSafeClass('HtmlGenerator', ['html']); // mark objects of interface "HtmlGeneratorInterface" as safe for the HTML strategy $escaper->addSafeClass('HtmlGeneratorInterface', ['html']); // mark objects of class "HtmlGenerator" as safe for the HTML and JS strategies $escaper->addSafeClass('HtmlGenerator', ['html', 'js']); // 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: .. code-block:: twig {{ var|upper }} {# is equivalent to {{ var|upper|escape }} #} * The ``raw`` filter should only be used at the end of the filter chain: .. code-block:: twig {{ var|raw|upper }} {# will be escaped #} {{ var|upper|raw }} {# won't be escaped #} * Automatic escaping is not applied if the last filter in the chain is marked safe for the current context (e.g. ``html`` or ``js``). ``escape`` and ``escape('html')`` are marked safe for HTML, ``escape('js')`` is marked safe for JavaScript, ``raw`` is marked safe for everything. .. code-block:: twig {% autoescape 'js' %} {{ var|escape('html') }} {# will be escaped for HTML and JavaScript #} {{ var }} {# will be escaped for JavaScript #} {{ var|escape('js') }} {# won't be double-escaped #} {% endautoescape %} .. note:: Note that autoescaping has some limitations as escaping is applied on expressions after evaluation. For instance, when working with 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. Read more about it in the :doc:`sandbox` chapter. Profiler Extension ~~~~~~~~~~~~~~~~~~ The ``profiler`` extension enables a profiler for Twig templates; it should only be used on your development machines as it adds some overhead:: $profile = new \Twig\Profiler\Profile(); $twig->addExtension(new \Twig\Extension\ProfilerExtension($profile)); $dumper = new \Twig\Profiler\Dumper\TextDumper(); echo $dumper->dump($profile); A profile contains information about time and memory consumption for template, block, and macro executions. You can also dump the data in a `Blackfire.io `_ compatible format:: $dumper = new \Twig\Profiler\Dumper\BlackfireDumper(); file_put_contents('/path/to/profile.prof', $dumper->dump($profile)); Upload the profile to visualize it (create a `free account `_ first): .. code-block:: sh blackfire --slot=7 upload /path/to/profile.prof Optimizer Extension ~~~~~~~~~~~~~~~~~~~ The ``optimizer`` extension optimizes the node tree before compilation:: $twig->addExtension(new \Twig\Extension\OptimizerExtension()); By default, all optimizations are turned on. You can select the ones you want to enable by passing them to the constructor:: $optimizer = new \Twig\Extension\OptimizerExtension(\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_FOR); $twig->addExtension($optimizer); Twig supports the following optimizations: * ``\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_ALL``, enables all optimizations (this is the default value). * ``\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_NONE``, disables all optimizations. This reduces the compilation time, but it can increase the execution time and the consumed memory. * ``\Twig\NodeVisitor\OptimizerNodeVisitor::OPTIMIZE_FOR``, optimizes the ``for`` tag by removing the ``loop`` variable creation whenever possible. Exceptions ---------- Twig can throw exceptions: * ``\Twig\Error\Error``: The base exception for all errors. * ``\Twig\Error\SyntaxError``: Thrown to tell the user that there is a problem with the template syntax. * ``\Twig\Error\RuntimeError``: Thrown when an error occurs at runtime (when a filter does not exist for instance). * ``\Twig\Error\LoaderError``: Thrown when an error occurs during template loading. * ``\Twig\Sandbox\SecurityError``: Thrown when an unallowed tag, filter, or method is called in a sandboxed template. ================================================ FILE: doc/coding_standards.rst ================================================ 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 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 {{ user }} {# comment #} {##} {% if user %}{% endif %} When using the whitespace control character, do not put any spaces between it and the delimiter: .. code-block:: twig {{- user -}} {#- comment -#} {#--#} {%- if user -%}{%- endif -%} * Put exactly one space before and after the following operators: comparison operators (``==``, ``!=``, ``<``, ``>``, ``>=``, ``<=``), math operators (``+``, ``-``, ``/``, ``*``, ``%``, ``//``, ``**``), logic operators (``not``, ``and``, ``or``), ``~``, ``is``, ``in``, and the ternary operator (``?:``): .. code-block:: twig {{ 1 + 2 }} {{ first_name ~ ' ' ~ last_name }} {{ is_correct ? true : false }} * Put exactly one space after the ``:`` sign in mappings and ``,`` in sequences and mappings: .. code-block:: twig [1, 2, 3] {'name': 'Fabien'} * Do not put any spaces after an opening parenthesis and before a closing parenthesis in expressions: .. code-block:: twig {{ 1 + (2 * 3) }} * Do not put any spaces before and after string delimiters: .. code-block:: twig {{ 'Twig' }} {{ "Twig" }} * Do not put any spaces before and after the following operators: ``|``, ``.``, ``..``, ``[]``: .. code-block:: twig {{ name|upper|lower }} {{ user.name }} {{ user[name] }} {% for i in 1..12 %}{% endfor %} * Do not put any spaces before and after the parenthesis used for filter and function calls: .. code-block:: twig {{ name|default('Fabien') }} {{ range(1..10) }} * Do not put any spaces before and after the opening and the closing of sequences and mappings: .. code-block:: twig [1, 2, 3] {'name': 'Fabien'} * Put exactly one space before and after ``=`` in macro argument declarations: .. code-block:: twig {% 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 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') }} ================================================ FILE: doc/deprecated.rst ================================================ 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. * Passing a non-``AbstractExpression`` node to ``Twig\Parser::setParent()`` is deprecated as of Twig 3.24; the method will require an ``AbstractExpression`` instance in Twig 4.0. * Passing non-``AbstractExpression`` nodes to ``Twig\Node\Expression\Binary\MatchesBinary`` constructor is deprecated as of Twig 3.24; the constructor will require an ``AbstractExpression`` instance in Twig 4.0. * The ``Twig\Parser::getExpressionParser()`` method is deprecated as of Twig 3.21, use ``Twig\Parser::parseExpression()`` instead. * The ``Twig\ExpressionParser`` class is deprecated as of Twig 3.21: * ``parseExpression()``, use ``Parser::parseExpression()`` * ``parsePrimaryExpression()``, use ``Parser::parseExpression()`` * ``parseStringExpression()``, use ``Parser::parseExpression()`` * ``parseHashExpression()``, use ``Parser::parseExpression()`` * ``parseMappingExpression()``, use ``Parser::parseExpression()`` * ``parseArrayExpression()``, use ``Parser::parseExpression()`` * ``parseSequenceExpression()``, use ``Parser::parseExpression()`` * ``parsePostfixExpression`` * ``parseSubscriptExpression`` * ``parseFilterExpression`` * ``parseFilterExpressionRaw`` * ``parseArguments()``, use ``Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()`` * ``parseAssignmentExpression``, use ``AbstractTokenParser::parseAssignmentExpression`` * ``parseMultitargetExpression`` * ``parseOnlyArguments()``, use ``Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments()`` 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. * Not implementing the ``getOperatorTokens()`` method in ``Twig\ExpressionParser\ExpressionParserInterface`` implementations is deprecated as of Twig 3.24. This method will be added to the interface in Twig 4.0. It returns the operator token strings that the expression parser handles (used by the Lexer and the parser registry). If your custom expression parser extends ``Twig\ExpressionParser\AbstractExpressionParser``, the default implementation returns ``[$this->getName(), ...$this->getAliases()]``. Override it if your parser doesn't handle operator tokens (return ``[]``) or if the operator tokens differ from the parser name. ================================================ FILE: doc/filters/abs.rst ================================================ ``abs`` ======= The ``abs`` filter returns the absolute value. .. code-block:: twig {# number = -5 #} {{ number|abs }} {# outputs 5 #} .. note:: Internally, Twig uses the PHP `abs`_ function. .. _`abs`: https://www.php.net/abs ================================================ FILE: doc/filters/batch.rst ================================================ ``batch`` ========= The ``batch`` filter "batches" items by returning a list of lists with the given number of items. A second parameter can be provided and used to fill in missing items: .. code-block:: html+twig {% set items = ['a', 'b', 'c', 'd'] %} {% for row in items|batch(3, 'No item') %} {% for index, column in row %} {% endfor %} {% endfor %}
{{ index }} - {{ column }}
The above example will be rendered as: .. code-block:: html+twig
0 - a 1 - b 2 - c
3 - d 4 - No item 5 - 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 - a 1 - b 2 - c
0 - d 1 - No item 2 - No item
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 (defaults to ``true``) ================================================ FILE: doc/filters/capitalize.rst ================================================ ``capitalize`` ============== The ``capitalize`` filter capitalizes a value. The first character will be uppercase, all others lowercase: .. code-block:: twig {{ 'my first car'|capitalize }} {# outputs 'My first car' #} ================================================ FILE: doc/filters/column.rst ================================================ ``column`` ========== The ``column`` filter returns the values from a single column in the input array. .. code-block:: twig {% set items = [{ 'fruit' : 'apple'}, {'fruit' : 'orange' }] %} {% set fruits = items|column('fruit') %} {# fruits now contains ['apple', 'orange'] #} .. note:: Internally, Twig uses the PHP `array_column`_ function. Arguments --------- * ``name``: The column name to extract .. _`array_column`: https://www.php.net/array_column ================================================ FILE: doc/filters/convert_encoding.rst ================================================ ``convert_encoding`` ==================== The ``convert_encoding`` filter converts a string from one encoding to another. The first argument is the expected output charset and the second one is the input charset: .. code-block:: twig {{ data|convert_encoding('UTF-8', 'iso-2022-jp') }} .. note:: This filter relies on the `iconv`_ extension. Arguments --------- * ``to``: The output charset * ``from``: The input charset .. _`iconv`: https://www.php.net/iconv ================================================ FILE: doc/filters/country_name.rst ================================================ ``country_name`` ================ The ``country_name`` filter returns the country name given its ISO-3166 code: .. code-block:: twig {# France #} {{ 'FR'|country_name }} By default, the filter uses the current locale. You can pass it explicitly: .. code-block:: twig {# É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 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 ================================================ FILE: doc/filters/currency_name.rst ================================================ ``currency_name`` ================= The ``currency_name`` filter returns the currency name given its ISO 4217 code: .. code-block:: twig {# Euro #} {{ 'EUR'|currency_name }} {# Japanese Yen #} {{ 'JPY'|currency_name }} By default, the filter uses the current locale. You can pass it explicitly: .. code-block:: twig {# yen japonais #} {{ 'JPY'|currency_name('fr_FR') }} .. note:: The ``currency_name`` 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()); Arguments --------- * ``locale``: The locale code as defined in `RFC 5646`_ .. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 ================================================ FILE: doc/filters/currency_symbol.rst ================================================ ``currency_symbol`` =================== The ``currency_symbol`` filter returns the currency symbol given its ISO 4217 code: .. code-block:: twig {# € #} {{ 'EUR'|currency_symbol }} {# ¥ #} {{ 'JPY'|currency_symbol }} By default, the filter uses the current locale. You can pass it explicitly: .. code-block:: twig {# ¥ #} {{ 'JPY'|currency_symbol('fr') }} .. note:: The ``currency_symbol`` 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()); Arguments --------- * ``locale``: The locale code as defined in `RFC 5646`_ .. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 ================================================ FILE: doc/filters/data_uri.rst ================================================ ``data_uri`` ============ The ``data_uri`` filter generates a URL using the data scheme as defined in `RFC 2397`_: .. code-block:: html+twig {{ image_data|data_uri }} {{ source('path_to_image')|data_uri }} {# force the mime type, disable the guessing of the mime type #} {{ image_data|data_uri(mime: "image/svg") }} {# also works with plain text #} {{ 'foobar'|data_uri(mime: "text/html") }} {# add some extra parameters #} {{ 'foobar'|data_uri(mime: "text/html", parameters: {charset: "ascii"}) }} .. note:: The ``data_uri`` filter is part of the ``HtmlExtension`` which is not installed by default. Install it first: .. code-block:: bash $ composer require twig/html-extra Then, on Symfony projects, install the ``twig/extra-bundle``: .. code-block:: bash $ composer require twig/extra-bundle Otherwise, add the extension explicitly on the Twig environment:: use Twig\Extra\Html\HtmlExtension; $twig = new \Twig\Environment(...); $twig->addExtension(new HtmlExtension()); .. note:: The filter does not perform any length validation on purpose (limit depends on the usage context), validation should be done before calling this filter. Arguments --------- * ``mime``: The mime type * ``parameters``: A mapping of parameters .. _RFC 2397: https://tools.ietf.org/html/rfc2397 ================================================ FILE: doc/filters/date.rst ================================================ ``date`` ======== The ``date`` filter formats a date to a given format: .. code-block:: twig {{ post.published_at|date("m/d/Y") }} The format specifier is the same as supported by `date`_, except when the filtered data is of type `DateInterval`_, when the format must conform to `DateInterval::format`_ instead. The ``date`` filter accepts strings (it must be in a format supported by the `strtotime`_ function), `DateTime`_ instances, or `DateInterval`_ instances. For instance, to display the current date, filter the word "now": .. code-block:: twig {{ "now"|date("m/d/Y") }} To escape words and characters in the date format use ``\\`` in front of each character: .. code-block:: twig {{ post.published_at|date("F jS \\a\\t g:ia") }} If the value passed to the ``date`` filter is ``null``, it will return the current date by default. If an empty string is desired instead of the current date, use a ternary operator: .. code-block:: twig {{ post.published_at is empty ? "" : post.published_at|date("m/d/Y") }} If no format is provided, Twig will use the default one: ``F j, Y H:i``. This default can be changed by calling the ``setDateFormat()`` method on the ``core`` extension instance. The first argument is the default format for dates and the second one is the default format for date intervals:: $twig = new \Twig\Environment($loader); $twig->getExtension(\Twig\Extension\CoreExtension::class)->setDateFormat('d/m/Y', '%d days'); 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 supported `timezone`_: .. code-block:: twig {{ post.published_at|date("m/d/Y", "Europe/Paris") }} 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 {{ post.published_at|date("m/d/Y", false) }} The default timezone can also be set globally by calling ``setTimezone()``:: $twig = new \Twig\Environment($loader); $twig->getExtension(\Twig\Extension\CoreExtension::class)->setTimezone('Europe/Paris'); Arguments --------- * ``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 .. _`DateTime`: https://www.php.net/DateTime .. _`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 ================================================ FILE: doc/filters/date_modify.rst ================================================ ``date_modify`` =============== The ``date_modify`` filter modifies a date with a given modifier string: .. code-block:: twig {{ post.published_at|date_modify("+1 day")|date("m/d/Y") }} The ``date_modify`` filter accepts strings (it must be in a format supported by the `strtotime`_ function) or `DateTime`_ instances. You can combine it with the :doc:`date` filter for formatting. Arguments --------- * ``modifier``: The modifier .. _`strtotime`: https://www.php.net/strtotime .. _`DateTime`: https://www.php.net/DateTime ================================================ FILE: doc/filters/default.rst ================================================ ``default`` =========== The ``default`` filter returns the passed default value if the value is undefined or empty, otherwise the value of the variable: .. code-block:: twig {{ var|default('var is not defined') }} {{ user.name|default('name item on user is not defined') }} {{ user['name']|default('name item on user is not defined') }} {{ ''|default('passed var is empty') }} When using the ``default`` filter on an expression that uses variables in some method calls, be sure to use the ``default`` filter whenever a variable can be undefined: .. code-block:: twig {{ 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: .. code-block:: twig {% set value = false %} {{ value|default(true) }} {# true #} {{ value ?? true }} {# false #} .. note:: Read the documentation for the :doc:`defined<../tests/defined>` and :doc:`empty<../tests/empty>` tests to learn more about their semantics. Arguments --------- * ``default``: The default value ================================================ FILE: doc/filters/escape.rst ================================================ ``escape`` ========== The ``escape`` filter escapes a string using strategies that depend on the context. By default, it uses the HTML escaping strategy: .. code-block:: html+twig

{{ user.username|escape }}

For convenience, the ``e`` filter is defined as an alias: .. code-block:: html+twig

{{ user.username|e }}

The ``escape`` filter can also be used in other contexts than HTML thanks to an optional argument which defines the escaping strategy to use: .. code-block:: twig {{ user.username|e }} {# is equivalent to #} {{ user.username|e('html') }} And here is how to escape variables included in JavaScript code: .. code-block:: twig {{ user.username|escape('js') }} {{ user.username|e('js') }} The ``escape`` filter supports the following escaping strategies for HTML documents: * ``html``: escapes a string for the **HTML body** context, or for HTML attributes values **inside quotes**. * ``js``: escapes a string for the **JavaScript** context. This is intended for use in JavaScript or JSON strings, and encodes values using backslash escape 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 alphanumerics. * ``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 when used as an **HTML attribute** name, and also when used as the value of an HTML attribute **without quotes** (e.g. ``data-attribute={{ some_value }}``). * ``html_attr_relaxed``: like ``html_attr``, but **does not** escape the ``@``, ``:``, ``[`` and ``]`` characters. You may want to use this in combination with front-end frameworks that use attribute names like ``v-bind:href`` or ``@click``. But, be aware that in some processing contexts like XML, characters like the colon ``:`` may have meaning like for XML namespace separation. .. versionadded:: 3.24 The ``html_attr_relaxed`` strategy has been added in 3.23. Note that doing contextual escaping in HTML documents is hard and choosing the right escaping strategy depends on a lot of factors. Please, read related documentation like `the OWASP prevention cheat sheet `_ to learn more about this topic. .. note:: Internally, ``escape`` uses the PHP native `htmlspecialchars`_ function for the HTML escaping strategy. .. caution:: When using automatic escaping, Twig tries to not double-escape a variable when the automatic escaping strategy is the same as the one applied by the escape filter; but that does not work when using a variable as the escaping strategy: .. code-block:: twig {% set strategy = 'html' %} {% autoescape 'html' %} {{ var|escape('html') }} {# won't be double-escaped #} {{ var|escape(strategy) }} {# will be double-escaped #} {% endautoescape %} When using a variable as the escaping strategy, you should disable automatic escaping: .. code-block:: twig {% set strategy = 'html' %} {% autoescape 'html' %} {{ 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 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); $escaper = fn ($string, $charset) => $string; $twig->getRuntime(EscaperRuntime::class)->setEscaper('identity', $escaper); # Usage in a template: # {{ 'Twig'|escape('identity') }} .. note:: Built-in escapers cannot be overridden mainly because they should be considered as the final implementation and also for better performance. Arguments --------- * ``strategy``: The escaping strategy * ``charset``: The string charset .. _`htmlspecialchars`: https://www.php.net/htmlspecialchars ================================================ FILE: doc/filters/filter.rst ================================================ ``filter`` ========== The ``filter`` filter filters elements of a sequence or a mapping using an arrow function. The arrow function receives the value of the sequence or mapping: .. code-block:: twig {% set sizes = [34, 36, 38, 40, 42] %} {{ sizes|filter(v => v > 38)|join(', ') }} {# output 40, 42 #} Combined with the ``for`` tag, it allows you to filter the items to iterate over: .. code-block:: twig {% for v in sizes|filter(v => v > 38) -%} {{ v }} {% endfor %} {# output 40 42 #} It also works with mappings: .. code-block:: twig {% set sizes = { xs: 34, s: 36, m: 38, l: 40, xl: 42, } %} {% for k, v in sizes|filter(v => v > 38) -%} {{ k }} = {{ v }} {% endfor %} {# output l = 40 xl = 42 #} The arrow function also receives the key as a second argument: .. code-block:: twig {% for k, v in sizes|filter((v, k) => v > 38 and k != "xl") -%} {{ k }} = {{ v }} {% endfor %} {# output l = 40 #} Note that the arrow function has access to the current context. Arguments --------- * ``array``: The sequence or mapping * ``arrow``: The arrow function ================================================ FILE: doc/filters/find.rst ================================================ ``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 ================================================ FILE: doc/filters/first.rst ================================================ ``first`` ========= The ``first`` filter returns the first "element" of a sequence, a mapping, or a string: .. code-block:: twig {{ [1, 2, 3, 4]|first }} {# outputs 1 #} {{ {a: 1, b: 2, c: 3, d: 4}|first }} {# outputs 1 #} {{ '1234'|first }} {# outputs 1 #} .. note:: It also works with objects implementing the `Traversable`_ interface. .. _`Traversable`: https://www.php.net/manual/en/class.traversable.php ================================================ FILE: doc/filters/format.rst ================================================ ``format`` ========== The ``format`` filter formats a given string by replacing the placeholders (placeholders follows the `sprintf`_ notation): .. code-block:: twig {% set fruit = 'apples' %} {{ "I like %s and %s."|format(fruit, "oranges") }} {# outputs I like apples and oranges #} .. seealso:: :doc:`replace` .. _`sprintf`: https://www.php.net/sprintf ================================================ FILE: doc/filters/format_currency.rst ================================================ ``format_currency`` =================== The ``format_currency`` filter formats a number as a currency: .. code-block:: twig {# €1,000,000.00 #} {{ '1000000'|format_currency('EUR') }} You can pass attributes to tweak the output: .. code-block:: twig {# €12.34 #} {{ '12.345'|format_currency('EUR', {rounding_mode: 'floor'}) }} {# €1,000,000.0000 #} {{ '1000000'|format_currency('EUR', {fraction_digit: 4}) }} The list of supported options: * ``grouping_used``: Specifies whether to use grouping separator for thousands:: {# €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') }} .. note:: The ``format_currency`` 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()); Arguments --------- * ``currency``: The currency (ISO 4217 code) * ``attrs``: A map of attributes * ``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 ================================================ FILE: doc/filters/format_date.rst ================================================ ``format_date`` =============== The ``format_date`` filter formats a date. It behaves in the exact same way as the :doc:`format_datetime` filter, but without the time. .. note:: The ``format_date`` 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()); Arguments --------- * ``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) .. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 ================================================ FILE: doc/filters/format_datetime.rst ================================================ ``format_datetime`` =================== The ``format_datetime`` filter formats a date time: .. code-block:: twig {# 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 ------ 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') }} {# 07/08/2019 #} {{ '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') }} 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") }} Locale ------ 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') }} 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: .. code-block:: twig {{ 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) }} The default timezone can also be set globally by calling ``setTimezone()``:: $twig = new \Twig\Environment($loader); $twig->getExtension(\Twig\Extension\CoreExtension::class)->setTimezone('Europe/Paris'); .. 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()); Arguments --------- * ``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) .. _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 ================================================ FILE: doc/filters/format_number.rst ================================================ ``format_number`` ================= The ``format_number`` filter formats a number: .. code-block:: twig {{ '12.345'|format_number }} You can pass attributes to tweak the output: .. code-block:: twig {# 12.34 #} {{ '12.345'|format_number({rounding_mode: 'floor'}) }} {# 1000000.0000 #} {{ '1000000'|format_number({fraction_digit: 4}) }} The list of supported options: * ``grouping_used``: Specifies whether to use grouping separator for thousands:: {# 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') }} {# twelve point three four five #} {{ '12.345'|format_number(style: 'spellout') }} {# 12 sec. #} {{ '12'|format_duration_number }} The list of supported styles: * ``decimal``:: {# 1,234.568 #} {{ 1234.56789 | format_number(style: 'decimal', locale: 'en') }} * ``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 }} {# twelve point three four five #} {{ '12.345'|format_spellout_number }} 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:: {# 12,345 #} {{ '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:: sh $ composer require twig/intl-extra Then, on Symfony projects, install the ``twig/extra-bundle``: .. code-block:: sh $ 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`_ * ``attrs``: A map of attributes * ``style``: The style of the number output .. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 ================================================ FILE: doc/filters/format_time.rst ================================================ ``format_time`` =============== The ``format_time`` filter formats a time. It behaves in the exact same way as the :doc:`format_datetime` filter, but without the date. .. note:: The ``format_time`` 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()); Arguments --------- * ``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) .. _RFC 5646: https://www.rfc-editor.org/info/rfc5646 ================================================ FILE: doc/filters/html_attr_merge.rst ================================================ ``html_attr_merge`` =================== .. _html_attr_merge: .. versionadded:: 3.24 The ``html_attr_merge`` filter was added in Twig 3.24. The ``html_attr_merge`` filter merges multiple mappings that represent HTML attribute values. Such mappings contain the names of the HTML attributes as keys, and the corresponding values represent the attributes' values. It is primarily designed for working with arrays that are passed to the :ref:`html_attr` function. It closely resembles the :doc:`merge <../filters/merge>` filter, but has different merge behavior for values that are iterables themselves, as it will merge such values in turn. The filter returns a new merged array: .. code-block:: twig {% set base = {class: ['btn'], type: 'button'} %} {% set variant = {class: ['btn-primary'], disabled: true} %} {% set merged = base|html_attr_merge(variant) %} {# merged is now: { class: ['btn', 'btn-primary'], type: 'button', disabled: true } #} The filter accepts multiple arrays as arguments and merges them from left to right: .. code-block:: twig {% set merged = base|html_attr_merge(variant1, variant2, variant3) %} A common use case is to build attribute mappings conditionally by merging multiple parts based on conditions. To make this conditional merging more convenient, filter arguments that are ``false``, ``null`` or empty arrays are ignored: .. code-block:: twig {% set button_attrs = { type: 'button', class: ['btn'] }|html_attr_merge( variant == 'primary' ? { class: ['btn-primary'] }, variant == 'secondary' ? { class: ['btn-secondary'] }, size == 'large' ? { class: ['btn-lg'] }, size == 'small' ? { class: ['btn-sm'] }, disabled ? { disabled: true, class: ['btn-disabled'] }, loading ? { 'aria-busy': 'true', class: ['btn-loading'] }, ) %} {# Example with variant='primary', size='large', disabled=false, loading=true: The false values (secondary variant, small size, disabled state) are ignored. button_attrs is: { type: 'button', class: ['btn', 'btn-primary', 'btn-lg', 'btn-loading'], 'aria-busy': 'true' } #} Merging Rules ------------- The filter follows these rules when merging attribute values: **Scalar values**: Later values override earlier ones. .. code-block:: twig {% set result = {id: 'old'}|html_attr_merge({id: 'new'}) %} {# result: {id: 'new'} #} **Array values**: Arrays are merged like in PHP's ``array_merge`` function - numeric keys are appended, non-numeric keys replace. .. code-block:: twig {# Numeric keys (appended): #} {% set result = {class: ['btn']}|html_attr_merge({class: ['btn-primary']}) %} {# result: {class: ['btn', 'btn-primary']} #} {# Non-numeric keys (replaced): #} {% set result = {class: {base: 'btn', size: 'small'}}|html_attr_merge({class: {variant: 'primary', size: 'large'}}) %} {# result: {class: {base: 'btn', size: 'large', variant: 'primary'}} #} .. note:: Remember, attribute mappings passed to or returned from this filter are regular Twig mappings after all. If you want to completely replace an attribute value that is an iterable with another value, you can use the :doc:`merge <../filters/merge>` filter to do that. **``MergeableInterface`` implementations**: For advanced use cases, attribute values can be objects that implement the ``MergeableInterface``. These objects can define their own, custom merge behavior that takes precedence over the default rules. See the docblocks in that interface for details. .. note:: The ``html_attr_merge`` filter is part of the ``HtmlExtension`` which is not installed by default. Install it first: .. code-block:: bash $ composer require twig/html-extra Then, on Symfony projects, install the ``twig/extra-bundle``: .. code-block:: bash $ composer require twig/extra-bundle Otherwise, add the extension explicitly on the Twig environment:: use Twig\Extra\Html\HtmlExtension; $twig = new \Twig\Environment(...); $twig->addExtension(new HtmlExtension()); Arguments --------- The filter accepts a variadic list of arguments to merge. Each argument can be: * A map of attributes * ``false`` or ``null`` (ignored, useful for conditional merging) * An empty string ``''`` (ignored, to support implicit else in ternary operators) .. seealso:: :ref:`html_attr`, :doc:`html_attr_type` ================================================ FILE: doc/filters/html_attr_type.rst ================================================ ``html_attr_type`` ================== .. _html_attr_type: .. versionadded:: 3.24 The ``html_attr_type`` filter was added in Twig 3.24. The ``html_attr_type`` filter converts arrays into specialized attribute value objects that implement custom rendering logic. It is designed for use with the :ref:`html_attr` function for attributes where the attribute value follows special formatting rules. .. code-block:: html+twig {# Output: #} Available Types --------------- Space-Separated Token List (``sst``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Used for attributes that expect space-separated values, like ``class`` or ``aria-labelledby``: .. code-block:: html+twig {% set classes = ['btn', 'btn-primary']|html_attr_type('sst') %} {# Output: #} This is the default type used when the :ref:`html_attr` function encounters an array value (except for ``style`` attributes). Comma-Separated Token List (``cst``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Used for attributes that expect comma-separated values, like ``srcset`` or ``sizes``: .. code-block:: html+twig {# Output: #} Inline Style (``style``) ~~~~~~~~~~~~~~~~~~~~~~~~ Used for style attributes. Handles both maps (property - value pairs) and sequences (CSS declarations): .. code-block:: html+twig {# Associative array #} {% set styles = {color: 'red', 'font-size': '14px'}|html_attr_type('style') %}

Styled content
{# Output:
Styled content
#} {# Numeric array #} {% set styles = ['color: red', 'font-size: 14px']|html_attr_type('style') %}
Styled content
{# Output:
Styled content
#} The ``style`` type is automatically applied by the :ref:`html_attr` function when it encounters an array value for the ``style`` attribute. .. note:: The ``html_attr_type`` filter is part of the ``HtmlExtension`` which is not installed by default. Install it first: .. code-block:: bash $ composer require twig/html-extra Then, on Symfony projects, install the ``twig/extra-bundle``: .. code-block:: bash $ composer require twig/extra-bundle Otherwise, add the extension explicitly on the Twig environment:: use Twig\Extra\Html\HtmlExtension; $twig = new \Twig\Environment(...); $twig->addExtension(new HtmlExtension()); Arguments --------- * ``value``: The sequence of attributes to convert * ``type``: The attribute type. One of: * ``sst`` (default): Space-separated token list * ``cst``: Comma-separated token list * ``style``: Inline CSS styles .. seealso:: :ref:`html_attr`, :ref:`html_attr_merge` ================================================ FILE: doc/filters/html_to_markdown.rst ================================================ ``html_to_markdown`` ==================== The ``html_to_markdown`` filter converts a block of HTML to Markdown: .. code-block:: html+twig {% apply html_to_markdown %}

Hello!

{% endapply %} You can also use the filter on an entire template which you ``include``: .. code-block:: twig {{ include('some_template.html.twig')|html_to_markdown }} .. note:: The ``html_to_markdown`` filter is part of the ``MarkdownExtension`` which is not installed by default. Install it first: .. code-block:: bash $ composer require twig/markdown-extra On Symfony projects, you can automatically enable it by installing the ``twig/extra-bundle``: .. code-block:: bash $ composer require twig/extra-bundle Or add the extension explicitly on the Twig environment:: use Twig\Extra\Markdown\MarkdownExtension; $twig = new \Twig\Environment(...); $twig->addExtension(new MarkdownExtension()); If you are not using Symfony, you must also register the extension runtime:: use Twig\Extra\Markdown\DefaultMarkdown; use Twig\Extra\Markdown\MarkdownRuntime; use Twig\RuntimeLoader\RuntimeLoaderInterface; $twig->addRuntimeLoader(new class implements RuntimeLoaderInterface { public function load($class) { if (MarkdownRuntime::class === $class) { return new MarkdownRuntime(new DefaultMarkdown()); } } }); ``html_to_markdown`` is just a frontend; the actual conversion is done by one of the following compatible libraries, from which you can choose: * `league/html-to-markdown`_ * `michelf/php-markdown`_ * `erusev/parsedown`_ Depending on the library, you can also add some options by passing them as an argument to the filter. Example for ``league/html-to-markdown``: .. code-block:: html+twig {% apply html_to_markdown({hard_break: false}) %}

Hello!

{% endapply %} .. _league/html-to-markdown: https://github.com/thephpleague/html-to-markdown .. _michelf/php-markdown: https://github.com/michelf/php-markdown .. _erusev/parsedown: https://github.com/erusev/parsedown ================================================ FILE: doc/filters/index.rst ================================================ Filters ======= .. toctree:: :maxdepth: 1 abs batch capitalize column convert_encoding country_name currency_name currency_symbol data_uri date date_modify default escape filter find first format format_currency format_date format_datetime format_number format_time html_attr_merge html_attr_type html_to_markdown inline_css inky_to_html invoke join json_encode keys language_name last length locale_name lower map markdown_to_html merge nl2br number_format plural raw reduce replace reverse round shuffle singular slice slug sort spaceless split striptags timezone_name title trim u upper url_encode ================================================ FILE: doc/filters/inky_to_html.rst ================================================ ``inky_to_html`` ================ The ``inky_to_html`` filter processes an `inky email template `_: .. code-block:: html+twig {% apply inky_to_html %} {% endapply %} You can also use the filter on an included file: .. code-block:: twig {{ include('some_template.inky.twig')|inky_to_html }} .. note:: The ``inky_to_html`` filter is part of the ``InkyExtension`` which is not installed by default. Install it first: .. code-block:: bash $ composer require twig/inky-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\Inky\InkyExtension; $twig = new \Twig\Environment(...); $twig->addExtension(new InkyExtension()); ================================================ FILE: doc/filters/inline_css.rst ================================================ ``inline_css`` ============== The ``inline_css`` filter inlines CSS styles in HTML documents: .. code-block:: html+twig {% apply inline_css %}

Hello CSS!

{% endapply %} You can also add some stylesheets by passing them as arguments to the filter: .. code-block:: html+twig {% apply inline_css(source("some_styles.css"), source("another.css")) %}

Hello CSS!

{% endapply %} Styles loaded via the filter override the styles defined in the `` {% endblock %} {% block content %}

Index

Welcome on my awesome homepage.

{% endblock %} The ``extends`` tag is the key here. It tells the template engine that this template "extends" another template. When the template system evaluates this template, first it locates the parent. The extends tag should be the first tag in the template. Note that since the child template doesn't define the ``footer`` block, the value from the parent template is used instead. You can't define multiple ``block`` tags with the same name in the same template. This limitation exists because a block tag works in "both" directions. That is, a block tag doesn't just provide a hole to fill - it also defines the content that fills the hole in the *parent*. If there were two similarly-named ``block`` tags in a template, that template's parent wouldn't know which one of the blocks' content to use. If you want to print a block multiple times you can however use the ``block`` function: .. code-block:: html+twig {% block title %}{% endblock %}

{{ block('title') }}

{% block body %}{% endblock %} Parent Blocks ------------- It's possible to render the contents of the parent block by using the :doc:`parent<../functions/parent>` function. This gives back the results of the parent block: .. code-block:: html+twig {% block sidebar %}

Table Of Contents

... {{ parent() }} {% endblock %} Named Block End-Tags -------------------- Twig allows you to put the name of the block after the end tag for better readability (the name after the ``endblock`` word must match the block name): .. code-block:: twig {% block sidebar %} {% block inner_sidebar %} ... {% endblock inner_sidebar %} {% endblock sidebar %} Block Nesting and Scope ----------------------- Blocks can be nested for more complex layouts. Per default, blocks have access to variables from outer scopes: .. code-block:: html+twig {% for item in seq %}
  • {% block loop_item %}{{ item }}{% endblock %}
  • {% endfor %} Block Shortcuts --------------- For blocks with little content, it's possible to use a shortcut syntax. The following constructs do the same thing: .. code-block:: twig {% block title %} {{ page_title|title }} {% endblock %} .. code-block:: twig {% block title page_title|title %} Dynamic Inheritance ------------------- Twig supports dynamic inheritance by using a variable as the base template: .. code-block:: twig {% extends some_var %} If the variable evaluates to a ``\Twig\Template`` or a ``\Twig\TemplateWrapper`` instance, Twig will use it as the parent template:: // {% extends layout %} $layout = $twig->load('some_layout_template.html.twig'); $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.twig', 'base_layout.html.twig'] %} Conditional Inheritance ----------------------- As the template name for the parent can be any valid Twig expression, it's possible to make the inheritance mechanism conditional: .. code-block:: twig {% extends standalone ? "minimum.html.twig" : "base.html.twig" %} 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? ------------------- A block provides a way to change how a certain part of a template is rendered but it does not interfere in any way with the logic around it. Let's take the following example to illustrate how a block works and more importantly, how it does not work: .. code-block:: html+twig {# base.html.twig #} {% for post in posts %} {% block post %}

    {{ post.title }}

    {{ post.body }}

    {% endblock %} {% endfor %} If you render this template, the result would be exactly the same with or without the ``block`` tag. The ``block`` inside the ``for`` loop is just a way to make it overridable by a child template: .. code-block:: html+twig {# child.html.twig #} {% extends "base.html.twig" %} {% block post %}
    {{ post.title }}
    {{ post.text }}
    {% endblock %} Now, when rendering the child template, the loop is going to use the block defined in the child template instead of the one defined in the base one; the executed template is then equivalent to the following one: .. code-block:: html+twig {% for post in posts %}
    {{ post.title }}
    {{ post.text }}
    {% endfor %} Let's take another example: a block included within an ``if`` statement: .. code-block:: html+twig {% if posts is empty %} {% block head %} {{ parent() }} {% endblock head %} {% endif %} Contrary to what you might think, this template does not define a block conditionally; it just makes overridable by a child template the output of what will be rendered when the condition is ``true``. If you want the output to be displayed conditionally, use the following instead: .. code-block:: html+twig {% block head %} {{ parent() }} {% if posts is empty %} {% endif %} {% endblock head %} .. seealso:: :doc:`block<../functions/block>`, :doc:`block<../tags/block>`, :doc:`parent<../functions/parent>`, :doc:`use<../tags/use>` ================================================ FILE: doc/tags/flush.rst ================================================ ``flush`` ========= The ``flush`` tag tells Twig to flush the output buffer: .. code-block:: twig {% flush %} .. note:: Internally, Twig uses the PHP `flush`_ function. .. _`flush`: https://www.php.net/flush ================================================ FILE: doc/tags/for.rst ================================================ ``for`` ======= 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

    Members

      {% for user in users %}
    • {{ user.username|e }}
    • {% endfor %}
    .. note:: 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: .. code-block:: twig {% for i in 0..10 %} * {{ i }} {% endfor %} The above snippet of code would print all numbers from 0 to 10. It can be also useful with letters: .. code-block:: twig {% for letter in 'a'..'z' %} * {{ letter }} {% endfor %} The ``..`` operator can take any expression at both sides: .. code-block:: twig {% for letter in 'a'|upper..'z'|upper %} * {{ letter }} {% endfor %} .. tip:: If you need a step different from 1, you can use the ``range`` function instead. The ``loop`` variable --------------------- Inside of a ``for`` loop block you can access some special variables: ===================== ============================================================= Variable Description ===================== ============================================================= ``loop.index`` The current iteration of the loop. (1 indexed) ``loop.index0`` The current iteration of the loop. (0 indexed) ``loop.revindex`` The number of iterations from the end of the loop (1 indexed) ``loop.revindex0`` The number of iterations from the end of the loop (0 indexed) ``loop.first`` True if first iteration ``loop.last`` True if last iteration ``loop.length`` The number of items in the sequence ``loop.parent`` The parent context ===================== ============================================================= .. code-block:: twig {% for user in users %} {{ loop.index }} - {{ user.username }} {% endfor %} .. note:: The ``loop.length``, ``loop.revindex``, ``loop.revindex0``, and ``loop.last`` variables are only available for PHP arrays, or objects that implement the ``Countable`` interface. The ``else`` Clause ------------------- If no iteration took place because the sequence was empty, you can render a replacement block by using ``else``: .. code-block:: html+twig
      {% for user in users %}
    • {{ user.username|e }}
    • {% else %}
    • no user found
    • {% endfor %}
    Iterating over Keys ------------------- By default, a loop iterates over the values of the sequence. You can iterate on keys by using the ``keys`` filter: .. code-block:: html+twig

    Members

      {% for key in users|keys %}
    • {{ key }}
    • {% endfor %}
    Iterating over Keys and Values ------------------------------ You can also access both keys and values: .. code-block:: html+twig

    Members

      {% for key, user in users %}
    • {{ key }}: {{ user.username|e }}
    • {% endfor %}
    Iterating over a Subset ----------------------- You might want to iterate over a subset of values. This can be achieved using the :doc:`slice <../filters/slice>` filter: .. code-block:: html+twig

    Top Ten Members

      {% for user in users|slice(0, 10) %}
    • {{ 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 %}
    ================================================ FILE: doc/tags/from.rst ================================================ ``from`` ======== The ``from`` tag imports :doc:`macro<../tags/macro>` names into the current namespace. The tag is documented in detail in the documentation for the :doc:`macro<../tags/macro>` tag. ================================================ FILE: doc/tags/guard.rst ================================================ ``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 %} ================================================ FILE: doc/tags/if.rst ================================================ ``if`` ====== The ``if`` statement in Twig is comparable with the if statements of PHP. In the simplest form you can use it to test if an expression evaluates to ``true``: .. code-block:: html+twig {% if online == false %}

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

    {% endif %} You can also test if a sequence or a mapping is not empty: .. code-block:: html+twig {% if users %}
      {% for user in users %}
    • {{ user.username|e }}
    • {% endfor %}
    {% endif %} .. note:: 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``: .. code-block:: html+twig {% if not user.subscribed %}

    You are not subscribed to our mailing list.

    {% endif %} For multiple conditions, ``and`` and ``or`` can be used: .. code-block:: html+twig {% if temperature > 18 and temperature < 27 %}

    It's a nice day for a walk in the park.

    {% endif %} For multiple branches ``elseif`` and ``else`` can be used like in PHP. You can use more complex ``expressions`` there too: .. code-block:: twig {% if product.stock > 10 %} Available {% elseif product.stock > 0 %} Only {{ product.stock }} left! {% else %} Sold-out! {% endif %} .. note:: The rules to determine if an expression is ``true`` or ``false`` are the same as in PHP; here are the edge cases rules: ====================== ==================== Value Boolean evaluation ====================== ==================== empty string false numeric zero false NAN (Not A Number) true INF (Infinity) true whitespace-only string true string "0" or '0' false empty sequence false empty mapping false null false non-empty sequence true non-empty mapping true object true ====================== ==================== ================================================ FILE: doc/tags/import.rst ================================================ ``import`` ========== The ``import`` tag imports :doc:`macro<../tags/macro>` names in a local variable. The tag is documented in detail in the documentation for the :doc:`macro<../tags/macro>` tag. ================================================ FILE: doc/tags/include.rst ================================================ ``include`` =========== The ``include`` statement includes a template and outputs the rendered content of that file: .. code-block:: twig {% include 'header.html.twig' %} Body {% include 'footer.html.twig' %} .. note:: It is recommended to use the :doc:`include<../functions/include>` function instead as it provides the same features with a bit more flexibility: * The ``include`` function is semantically more "correct" (including a template outputs its rendered contents in the current scope; a tag should not display anything); * The ``include`` function is more "composable": .. code-block:: twig {# Store a rendered template in a variable #} {% set content %} {% include 'template.html.twig' %} {% endset %} {# vs #} {% set content = include('template.html.twig') %} {# Apply filter on a rendered template #} {% apply upper %} {% include 'template.html.twig' %} {% endapply %} {# vs #} {{ include('template.html.twig')|upper }} * The ``include`` function does not impose any specific order for arguments thanks to :ref:`named arguments `. Included templates have access to the variables of the active context. If you are using the filesystem loader, the templates are looked for in the paths defined by it. You can add additional variables by passing them after the ``with`` keyword: .. code-block:: twig {# 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 = {'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 name variable will be accessible #} {% include 'template.html.twig' with {'name': 'Fabien'} only %} .. code-block:: twig {# no variables will be accessible #} {% 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 Sandbox<../sandbox>` chapter. The template name can be any valid Twig expression: .. code-block:: twig {% include some_var %} {% 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.html.twig'); $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 placed just after the template name. Here some valid examples: .. code-block:: twig {% 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.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. ================================================ FILE: doc/tags/index.rst ================================================ Tags ==== .. toctree:: :maxdepth: 1 apply autoescape block cache deprecated do embed extends flush for from guard if import include macro sandbox set types use verbatim with ================================================ FILE: doc/tags/macro.rst ================================================ ``macro`` ========= Macros are comparable with functions in regular programming languages. They 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.twig``): .. code-block:: html+twig {% macro input(name, value, type = "text", size = 20) %} {% endmacro %} {% macro textarea(name, value, rows = 10, cols = 40) %} {% endmacro %} Each macro argument can have a default value (here ``text`` is the default value for ``type`` if not provided in the call). Macros differ from native PHP functions in a few ways: * Arguments of a macro are always optional. * If extra positional arguments are passed to a macro, they end up in the special ``varargs`` variable as a list of values. But as with PHP functions, macros don't have access to the current template variables. .. tip:: You can pass the whole context as an argument by using the special ``_context`` variable. Importing Macros ---------------- There are two ways to import macros. You can import the complete template containing the macros into a local variable (via the ``import`` tag) or only import specific macros from the template (via the ``from`` tag). To import all macros from a template into a local variable, use the ``import`` tag: .. code-block:: twig {% import "forms.html.twig" as forms %} 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: .. code-block:: html+twig

    {{ 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.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 import the macros as they are automatically available under the special ``_self`` variable: .. code-block:: html+twig

    {{ _self.input('password', '', 'password') }}

    {% macro input(name, value, type = "text", size = 20) %} {% endmacro %} Macros Scoping -------------- The scoping rules are the same whether you imported macros via ``import`` or ``from``. Imported macros are always **local** to the current template. It means that macros are available in all blocks and other macros defined in the current template, but they are not available in included templates or child templates; you need to explicitly re-import macros in each template. 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 shadow macros defined at the template level with the same names. Checking if a Macro is defined ------------------------------ You can check if a macro is defined via the ``defined`` test: .. code-block:: twig {% import "macros.html.twig" as macros %} {% from "macros.html.twig" import hello %} {% if macros.hello is defined -%} OK {% endif %} {% if hello is defined -%} OK {% endif %} Named Macro End-Tags -------------------- Twig allows you to put the name of the macro after the end tag for better readability (the name after the ``endmacro`` word must match the macro name): .. code-block:: twig {% macro input() %} ... {% endmacro input %} ================================================ FILE: doc/tags/sandbox.rst ================================================ ``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.twig' %} {% endsandbox %} .. warning:: The ``sandbox`` tag is only available when the sandbox extension is enabled (see the :doc:`Twig for Developers<../api>` chapter). .. note:: The ``sandbox`` tag can only be used to sandbox an include tag and it cannot be used to sandbox a section of a template. The following example won't work: .. code-block:: twig {% sandbox %} {% for i in 1..2 %} {{ i }} {% endfor %} {% endsandbox %} ================================================ FILE: doc/tags/set.rst ================================================ ``set`` ======= 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 ``Fabien`` value to the ``name`` variable: .. code-block:: twig {% set name = 'Fabien' %} After the ``set`` call, the ``name`` variable is available in the template like any other ones: .. code-block:: twig {# displays Fabien #} {{ name }} The assigned value can be any valid :ref:`Twig expression `: .. code-block:: twig {% set numbers = [1, 2] %} {% set user = {'name': 'Fabien'} %} {% set name = 'Fabien' ~ ' ' ~ 'Potencier' %} .. tip:: To assign a value within an expression, use the :ref:`= operator `: .. code-block:: twig {# use assignment within a larger expression #} {{ (result = fetch_data()) ? result : 'default' }} Several variables can be assigned in one block: .. code-block:: twig {% set first, last = 'Fabien', 'Potencier' %} {# is equivalent to #} {% set first = 'Fabien' %} {% set last = 'Potencier' %} The ``set`` tag can also be used to "capture" chunks of text: .. code-block:: html+twig {% set content %} {% endset %} .. caution:: If you enable automatic output escaping, Twig will only consider the content to be safe when capturing chunks of text. .. note:: Note that loops are scoped in Twig; therefore a variable declared inside a ``for`` loop is not accessible outside the loop itself: .. code-block:: twig {% for item in items %} {% set value = item %} {% endfor %} {# value is NOT available #} If you want to access the variable, just declare it before the loop: .. code-block:: twig {% set value = "" %} {% for item in items %} {% set value = item %} {% endfor %} {# value is available #} ================================================ FILE: doc/tags/types.rst ================================================ ``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. ================================================ FILE: doc/tags/use.rst ================================================ ``use`` ======= .. note:: Horizontal reuse is an advanced Twig feature that is hardly ever needed in regular templates. It is mainly used by projects that need to make template blocks reusable without using inheritance. Template inheritance is one of the most powerful features of Twig but it is limited to single inheritance; a template can only extend one other template. This limitation makes template inheritance simple to understand and easy to debug: .. code-block:: twig {% extends "base.html.twig" %} {% block title %}{% endblock %} {% block content %}{% endblock %} Horizontal reuse is a way to achieve the same goal as multiple inheritance, but without the associated complexity: .. code-block:: twig {% extends "base.html.twig" %} {% use "blocks.html.twig" %} {% block title %}{% endblock %} {% block content %}{% endblock %} The ``use`` statement tells Twig to import the blocks defined in ``blocks.html.twig`` into the current template (it's like macros, but for blocks): .. code-block:: twig {# blocks.html.twig #} {% block sidebar %}{% endblock %} In this example, the ``use`` statement imports the ``sidebar`` block into the main template. The code is mostly equivalent to the following one (the imported blocks are not outputted automatically): .. code-block:: twig {% extends "base.html.twig" %} {% block sidebar %}{% endblock %} {% block title %}{% endblock %} {% block content %}{% endblock %} .. note:: The ``use`` tag only imports a template if it does not extend another template, if it does not define macros, and if the body is empty. But it can *use* other templates. .. note:: Because ``use`` statements are resolved independently of the context 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.twig`` is ignored. To avoid name conflicts, you can rename imported blocks: .. code-block:: twig {% extends "base.html.twig" %} {% use "blocks.html.twig" with sidebar as base_sidebar, title as base_title %} {% block sidebar %}{% endblock %} {% block title %}{% endblock %} {% block content %}{% endblock %} The ``parent()`` function automatically determines the correct inheritance tree, so it can be used when overriding a block defined in an imported template: .. code-block:: twig {% extends "base.html.twig" %} {% use "blocks.html.twig" %} {% block sidebar %} {{ parent() }} {% endblock %} {% block title %}{% endblock %} {% block content %}{% endblock %} In this example, ``parent()`` will correctly call the ``sidebar`` block from the ``blocks.html.twig`` template. .. tip:: Renaming allows you to simulate inheritance by calling the "parent" block: .. code-block:: twig {% extends "base.html.twig" %} {% use "blocks.html.twig" with sidebar as parent_sidebar %} {% block sidebar %} {{ block('parent_sidebar') }} {% endblock %} .. note:: You can use as many ``use`` statements as you want in any given template. If two imported templates define the same block, the latest one wins. ================================================ FILE: doc/tags/verbatim.rst ================================================ ``verbatim`` ============ The ``verbatim`` tag marks sections as being raw text that should not be parsed. For example to put Twig syntax as example into a template you can use this snippet: .. code-block:: html+twig {% verbatim %}
      {% for item in seq %}
    • {{ item }}
    • {% endfor %}
    {% endverbatim %} ================================================ FILE: doc/tags/with.rst ================================================ ``with`` ======== Use the ``with`` tag to create a new inner scope. Variables set within this scope are not visible outside of the scope: .. code-block:: twig {% with %} {% set value = 42 %} {{ value }} {# value is 42 here #} {% endwith %} value is not visible here any longer Instead of defining variables at the beginning of the scope, you can pass a mapping of variables you want to define in the ``with`` tag; the previous example is equivalent to the following one: .. code-block:: twig {% with {value: 42} %} {{ value }} {# value is 42 here #} {% endwith %} value is not visible here any longer {# it works with any expression that resolves to a mapping #} {% set vars = {value: 42} %} {% with vars %} ... {% endwith %} By default, the inner scope has access to the outer scope context; you can disable this behavior by appending the ``only`` keyword: .. code-block:: twig {% set zero = 0 %} {% with {value: 42} only %} {# only value is defined #} {# zero is not defined #} {% endwith %} ================================================ FILE: doc/templates.rst ================================================ Twig for Template Designers =========================== This document describes the syntax and semantics of the template engine and will be most useful as reference to those creating Twig templates. Synopsis -------- A template is a regular text file. It can generate any text-based format (HTML, XML, CSV, LaTeX, etc.). It doesn't have a specific extension, ``.html`` or ``.xml`` are just fine. A template contains **variables** or **expressions**, which get replaced with values when the template is evaluated, and **tags**, which control the template's logic. Below is a minimal template that illustrates a few basics. We will cover further details later on: .. code-block:: html+twig My Webpage

    My Webpage

    {{ a_variable }} 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. .. 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 `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`_ * *GtkSourceView* via the `Twig language definition`_ (used by gedit and other projects) * *Coda* and *SubEthaEdit* via the `Twig syntax mode`_ * *Coda 2* via the `other Twig syntax mode`_ * *Komodo* and *Komodo Edit* via the Twig highlight/syntax check mode * *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`_, `Modern Twig`_ or `Twiggy`_ 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 --------- 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 {{ user.name }} .. note:: It's important to know that the curly braces are *not* part of the variable but the print statement. When accessing variables inside tags, don't put the braces around them. If a variable or attribute does not exist, the behavior depends on the ``strict_variables`` option value (see :ref:`environment options `): * When ``false``, it returns ``null``; * When ``true``, it throws an exception. Learn more about the :ref:`dot operator `. Global Variables ~~~~~~~~~~~~~~~~ The following variables are always available in templates: * ``_self``: references the current template name; * ``_context``: references the current context; * ``_charset``: references the current charset. Setting Variables ~~~~~~~~~~~~~~~~~ You can assign values to variables inside code blocks using either the :doc:`set` tag or the :ref:`= operator `: .. code-block:: twig {% set name = 'Fabien' %} {% set numbers = [1, 2] %} {% set map = {'city': 'Paris'} %} {% set first, last = 'Fabien', 'Potencier' %} {# or #} {% do name = 'Fabien' %} {% do numbers = [1, 2] %} {% do map = {'city': 'Paris'} %} {% do [first, last] = ['Fabien', 'Potencier'] %} The ``set`` tag can also be used to capture template content into a variable: .. code-block:: html+twig {% set content %} {% endset %} See the :doc:`set` tag documentation for more details. Filters ------- 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: .. code-block:: twig {{ name|striptags|title }} Filters that accept arguments have parentheses around the arguments. This example joins the elements of a list by commas: .. code-block:: twig {{ list|join(', ') }} To apply a filter on a section of code, wrap it with the :doc:`apply` tag: .. code-block:: twig {% apply upper %} This text becomes uppercase {% endapply %} 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 --------- Functions can be called to generate content. Functions are called by their name followed by parentheses (``()``) and may have arguments. For instance, the ``range`` function returns a list containing an arithmetic progression of integers: .. code-block:: twig {% for i in range(0, 3) %} {{ i }}, {% endfor %} Go to the :doc:`functions` page to learn more about the built-in functions. .. _named-arguments: 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) %} {{ i }}, {% endfor %} Using named arguments makes your templates more explicit about the meaning of the values you pass as arguments: .. code-block:: twig {{ data|convert_encoding('UTF-8', 'iso-2022-jp') }} {# versus #} {{ 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: .. code-block:: twig {# the first argument is the date format, which defaults to the global date format if null is passed #} {{ "now"|date(null, "Europe/Paris") }} {# or skip the format value by using a named argument for the time zone #} {{ "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") }} .. tip:: Each function, filter, and test documentation page has a section where the names of all supported arguments are listed. Control Structure ----------------- A control structure refers to all those things that control the flow of a program - conditionals (i.e. ``if``/``elseif``/``else``), ``for``-loops, as well as things like blocks. Control structures appear inside ``{% ... %}`` blocks. For example, to display a list of users provided in a variable called ``users``, use the :doc:`for` tag: .. code-block:: html+twig

    Members

      {% for user in users %}
    • {{ user.username|e }}
    • {% endfor %}
    The :doc:`if` tag can be used to test an expression: .. code-block:: html+twig {% if users|length > 0 %}
      {% for user in users %}
    • {{ user.username|e }}
    • {% endfor %}
    {% endif %} Go to the :doc:`tags` page to learn more about the built-in tags. Comments -------- 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 {# note: disabled template because we no longer use this {% for user in users %} ... {% 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 ------------------------- The :doc:`include` function is useful to include a template and return the rendered content of that template into the current one: .. code-block:: twig {{ 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 will be available in the included template too: .. code-block:: twig {% for box in boxes %} {{ include('render_box.html.twig') }} {% endfor %} 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 filename. You can access templates in subdirectories with a slash: .. code-block:: twig {{ include('sections/articles/sidebar.html.twig') }} This behavior depends on the application embedding Twig. Template Inheritance -------------------- The most powerful part of Twig is template inheritance. Template inheritance allows you to build a base "skeleton" template that contains all the common elements of your site and defines **blocks** that child templates can override. It's easier to understand the concept by starting with an example. 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 {% block head %} {% block title %}{% endblock %} - My Webpage {% endblock %}
    {% block content %}{% endblock %}
    In this example, the :doc:`block` tags define four blocks that child templates can fill in. All the ``block`` tag does is to tell the template engine that a child template may override those portions of the template. A child template might look like this: .. code-block:: html+twig {% extends "base.html.twig" %} {% block title %}Index{% endblock %} {% block head %} {{ parent() }} {% endblock %} {% block content %}

    Index

    Welcome to my awesome homepage.

    {% endblock %} The :doc:`extends` tag is the key here. It tells the template engine that this template "extends" another template. When the template system evaluates this template, first it locates the parent. The extends tag should be the first tag in the template. Note that since the child template doesn't define the ``footer`` block, the value from the parent template is used instead. It's possible to render the contents of the parent block by using the :doc:`parent` function. This gives back the results of the parent block: .. code-block:: html+twig {% block sidebar %}

    Table Of Contents

    ... {{ parent() }} {% endblock %} .. tip:: The documentation page for the :doc:`extends` tag describes more advanced features like block nesting, scope, dynamic inheritance, and conditional inheritance. .. note:: Twig also supports multiple inheritance via "horizontal reuse" with the help of the :doc:`use` tag. HTML Escaping ------------- When generating HTML from templates, there's always a risk that a variable will include characters that affect the resulting HTML. There are two approaches: manually escaping each variable or automatically escaping everything by default. Twig supports both, automatic escaping is enabled by default. The automatic escaping strategy can be configured via the :ref:`autoescape` option and defaults to ``html``. Working with Manual Escaping ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If manual escaping is enabled, it is **your** responsibility to escape variables if needed. What to escape? Any variable that comes from an untrusted source. Escaping works by using the :doc:`escape` or ``e`` filter: .. code-block:: twig {{ user.username|e }} By default, the ``escape`` filter uses the ``html`` strategy, but depending on the escaping context, you might want to explicitly use another strategy: .. code-block:: twig {{ user.username|e('js') }} {{ user.username|e('css') }} {{ user.username|e('url') }} {{ user.username|e('html_attr') }} Working with Automatic Escaping ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Whether automatic escaping is enabled or not, you can mark a section of a template to be escaped or not by using the :doc:`autoescape` tag: .. code-block:: twig {% autoescape %} Everything will be automatically escaped in this block (using the HTML strategy) {% endautoescape %} By default, auto-escaping uses the ``html`` escaping strategy. If you output variables in other contexts, you need to explicitly escape them with the appropriate escaping strategy: .. code-block:: twig {% autoescape 'js' %} Everything will be automatically escaped in this block (using the JS strategy) {% endautoescape %} Escaping -------- It is sometimes desirable or even necessary to have Twig ignore parts it would otherwise handle as variables or blocks. For example if the default syntax is used and you want to use ``{{`` as raw string in the template and not start a variable you have to use a trick. The easiest way is to output the variable delimiter (``{{``) by using a variable expression: .. code-block:: twig {{ '{{' }} For bigger sections it makes sense to mark a block :doc:`verbatim`. Macros ------ Macros are comparable with functions in regular programming languages. They are useful to reuse HTML fragments to not repeat yourself. They are described in the :doc:`macro` tag documentation. .. _twig-expressions: Expressions ----------- Twig allows expressions everywhere. Literals ~~~~~~~~ The simplest form of expressions are literals. Literals are representations for PHP types such as strings, numbers, and arrays. The following literals 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). 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. Underscores can be used as digits separator to improve readability (``-3_141.592_65`` is equivalent to ``-3141.59265``). * ``["first_name", "last_name"]``: Sequences are defined by a sequence of expressions separated by a comma (``,``) and wrapped with squared brackets (``[]``). * ``{"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 #} {'name': 'Fabien', 'city': 'Paris'} {# keys as names (equivalent to the previous mapping) #} {name: 'Fabien', city: 'Paris'} {# keys as integer #} {2: 'Twig', 4: 'Symfony'} {# keys can be omitted if it is the same as the variable name #} {Paris} {# is equivalent to the following #} {'Paris': Paris} {# keys as expressions (the expression must be enclosed into parentheses) #} {% set key = 'name' %} {(key): 'Fabien', (1 + 1): 2, ('ci' ~ 'ty'): 'city'} * ``true`` / ``false``: ``true`` represents the true value, ``false`` represents the false value. * ``null``: ``null`` represents no specific value. This is the value returned when a variable does not exist. ``none`` is an alias for ``null``. Sequences and mappings can be nested: .. code-block:: twig {% set complex = [1, {"name": "Fabien"}] %} .. tip:: Using double-quoted or single-quoted strings has no impact on performance 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 ~~~~ Twig allows you to do math in templates; the following operators are supported: * ``+``: Adds two numbers together (the operands are casted to numbers). ``{{ 1 + 1 }}`` is ``2``. * ``-``: Subtracts the second number from the first one. ``{{ 3 - 2 }}`` is ``1``. * ``/``: Divides two numbers. The returned value will be a floating point number. ``{{ 1 / 2 }}`` is ``{{ 0.5 }}``. * ``%``: Calculates the remainder of an integer division. ``{{ 11 % 7 }}`` is ``4``. * ``//``: Divides two numbers and returns the floored integer result. ``{{ 20 // 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``. Be careful as the ``**`` operator is right associative, which means that ``{{ -1**0 }}`` is equivalent to ``{{ -(1**0) }}`` and not ``{{ (-1)**0 }}``. .. _template_logic: Logic ~~~~~ 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. * ``(expr)``: Groups an expression. .. note:: Twig also supports bitwise operators (``b-and``, ``b-xor``, and ``b-or``). .. note:: Operators are case sensitive. Comparisons ~~~~~~~~~~~ The following mathematical comparison operators are supported in any expression: ``==``, ``!=``, ``<``, ``>``, ``>=``, and ``<=``. In addition, the ``===`` and ``!==`` strict comparison operators are supported (they are equivalent to the ``same as`` and ``not same as`` tests). Spaceship Operator ~~~~~~~~~~~~~~~~~~ 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. .. note:: Read more about in the `PHP spaceship operator documentation`_. Iterable Operators ~~~~~~~~~~~~~~~~~~ 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: .. code-block:: twig {% set sizes = [34, 36, 38, 40, 42] %} {% set hasOnlyOver38 = sizes has every v => v > 38 %} {# hasOnlyOver38 is false #} {% 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: .. code-block:: twig {# returns true #} {{ 1 in [1, 2, 3] }} {{ 'cd' in 'abcde' }} .. tip:: 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: .. code-block:: twig {% if 1 not in [1, 2, 3] %} {# 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 ~~~~~~~~~~~~~ The ``is`` operator performs tests. Tests can be used to test a variable against a common expression. The right operand is name of the test: .. code-block:: twig {# find out if a variable is odd #} {{ name is odd }} Tests can accept arguments too: .. code-block:: twig {% if post.status is constant('Post::PUBLISHED') %} Tests can be negated by using the ``is not`` operator: .. code-block:: twig {% if post.status is not constant('Post::PUBLISHED') %} {# is equivalent to #} {% if not (post.status is constant('Post::PUBLISHED')) %} Go to the :doc:`tests` page to learn more about the built-in tests. Other Operators ~~~~~~~~~~~~~~~ The following operators don't fit into any of the other categories: * ``|``: Applies a filter. * ``..``: Creates a sequence based on the operand before and after the operator (this is syntactic sugar for the :doc:`range` function): .. code-block:: twig {% for i in 1..5 %}{{ i }}{% endfor %} {# 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(', ') }} * ``~``: 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 }} The null-safe operator (``?.``) works like the dot operator but returns ``null`` instead of throwing an exception when the left operand is ``null``. If the operand is part of a chain, the rest of the chain is skipped: .. code-block:: twig {{ user?.name }} {# returns null if user is null, otherwise returns user.name #} {{ user?.address?.city }} {# can be chained for safe navigation through potentially null values #} {{ user?.address.city }} {# returns null if user is null, the rest of the chain is skipped (address.city is not evaluated) #} .. versionadded:: 3.23 The null-safe operator was added in Twig 3.23. After the ``.`` or ``?.``, you can use any expression by wrapping it with parenthesis ``()``. One use case is when the attribute contains special characters (like ``-`` that would be interpreted as the minus operator): .. code-block:: twig {# equivalent to the non-working user.first-name #} {{ user.('first-name') }} {{ user?.('first-name') }} Another use case is when the attribute is "dynamic" (defined via a variable): .. code-block:: twig {{ user.(name) }} {{ user.('get' ~ name) }} {{ user?.(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 an ArrayObject/ArrayAccess object and ``name`` a valid element; * if not, and if ``user`` is a PHP object, check that ``name`` is a valid property; * if not, and if ``user`` is a PHP object, check that ``name`` is a class constant; * 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 checks if ``user`` is ``null`` first: * if ``user`` is ``null``, return ``null``; * otherwise, use the same algorithm as for ``user.name``. To resolve ``user['name']`` to a PHP call, Twig uses the following algorithm at runtime: * 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 an object and has the ``name()``, ``getName()``, ``isName()``, or ``hasName()`` method; * if not, and if ``strict_variables`` is ``false``, return ``null``; * if not, throw an exception. * ``?:``: The ternary operator: .. code-block:: twig {{ 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 result if it is defined and not null, 'no' otherwise #} {{ result ?? 'no' }} * ``...``: The spread operator can be used to expand sequences or mappings or to expand the arguments of a function call: .. code-block:: twig {% 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. .. _templates-assignment-operator: * ``=``: The assignment operator assigns a value to a variable within an expression: .. code-block:: twig {# assign #} {% do b = 1 + 3 %} {# assign and output the result #} {{ b = 1 + 3 }} {# assignments can be chained #} {% do a = b = 'foo' %} {# assignment can be used inside other expressions #} {% do a = (b = 4) + 5 %} The assignment operator also supports :ref:`destructuring `. .. versionadded:: 3.23 The ``=`` assignment operator was added in Twig 3.23. * ``=>``: The arrow operator allows the creation of functions. A function is made of arguments (use parentheses for multiple arguments) and an arrow (``=>``) followed by an expression to execute. The expression has access to 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 highest-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 {% set greeting = 'Hello ' %} {% set name = 'Fabien' %} {{ greeting ~ name|lower }} {# Hello fabien #} {# use parenthesis to change precedence #} {{ (greeting ~ name)|lower }} {# hello fabien #} .. _templates-destructuring: Destructuring ------------- .. versionadded:: 3.23 Destructuring was added in Twig 3.23. Destructuring allows you to extract values from sequences and assign them to variables in a single operation using the ``=`` :ref:`assignment operator `. Like in PHP, destructuring expressions return the right-hand side value, not the extracted values: .. code-block:: twig {# returns the full user object, allowing chained access #} {{ ({name} = user).email }} Sequence Destructuring ~~~~~~~~~~~~~~~~~~~~~~ Use square brackets on the left side of an assignment to destructure a sequence: .. code-block:: twig {% do [first, last] = ['Fabien', 'Potencier'] %} {{ first }} {# Fabien #} {{ last }} {# Potencier #} If there are more variables than values, the extra variables are set to ``null``: .. code-block:: twig {# extra will be null #} {% do [first, last, extra] = ['Fabien', 'Potencier'] %} You can skip values by leaving a slot empty: .. code-block:: twig {# only assign the second value #} {% do [, last] = ['Fabien', 'Potencier'] %} Object Destructuring ~~~~~~~~~~~~~~~~~~~~ Use curly braces on the left side of an assignment to destructure an object or mapping by extracting values based on property/key names: .. code-block:: twig {% do {name, email} = user %} {{ name }} {# user.name #} {{ email }} {# user.email #} You can rename variables during destructuring by using the ``key: variable`` syntax, where the key is the property to extract and the variable is the name to assign to: .. code-block:: twig {% do {name: userName, email: userEmail} = user %} {{ userName }} {# user.name #} {{ userEmail }} {# user.email #} This is especially useful when you need to destructure multiple objects that share the same property names: .. code-block:: twig {% do {data: product, error: productError} = loadProduct() %} {% do {data: stock, error: stockError} = loadStock() %} {{ product }} {# loadProduct().data #} {{ productError }} {# loadProduct().error #} {{ stock }} {# loadStock().data #} {{ stockError }} {# loadStock().error #} .. note:: Object destructuring uses the :ref:`dot operator ` to access values, so ``{name} = user`` is equivalent to ``name = user.name`` or ``name = user["name"]`` depending on the type of the variable. .. _templates-whitespace-control: Whitespace Control ------------------ The first newline after a template tag is removed automatically (like in PHP). Whitespace is not further modified by the template engine, so each whitespace (spaces, tabs, newlines etc.) is returned unchanged. You can also control whitespace on a per tag level. By using the whitespace control modifiers on your tags, you can trim leading and or trailing whitespace. Twig supports two modifiers: * *Whitespace trimming* via the ``-`` modifier: Removes all whitespace (including newlines); * *Line whitespace trimming* via the ``~`` modifier: Removes all whitespace (excluding newlines). Using this modifier on the right disables the default removal of the first newline inherited from PHP. The modifiers can be used on either side of the tags like in ``{%-`` or ``-%}`` and they consume all whitespace for that side of the tag. It is possible to use the modifiers on one side of a tag or on both sides: .. code-block:: html+twig {% set value = 'no spaces' %} {#- No leading/trailing whitespace -#} {%- if true -%} {{- value -}} {%- endif -%} {# output 'no spaces' #}
  • {{ value }}
  • {# outputs '
  • \n no spaces
  • ' #}
  • {{- value }}
  • {# outputs '
  • no spaces
  • ' #}
  • {{~ value }}
  • {# outputs '
  • \nno spaces
  • ' #} 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/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 ================================================ FILE: doc/tests/constant.rst ================================================ ``constant`` ============ ``constant`` checks if a variable has the exact same value as a constant. You can use either global constants or class constants: .. code-block:: twig {% if post.status is constant('Post::PUBLISHED') %} the status attribute is exactly the same as Post::PUBLISHED {% endif %} You can test constants from object instances as well: .. code-block:: twig {% if post.status is constant('PUBLISHED', post) %} the status attribute is exactly the same as Post::PUBLISHED {% endif %} ================================================ FILE: doc/tests/defined.rst ================================================ ``defined`` =========== ``defined`` checks if a variable is defined in the current context. This is very useful if you use the ``strict_variables`` option: .. code-block:: twig {# defined works with variable names #} {% if user is defined %} ... {% endif %} {# and attributes on variables names #} {% if user.name is defined %} ... {% endif %} {% if user['name'] is defined %} ... {% endif %} When using the ``defined`` test on an expression that uses variables in some method calls, be sure that they are all defined first: .. code-block:: twig {% if var is defined and user.name(var) is defined %} ... {% endif %} ================================================ FILE: doc/tests/divisibleby.rst ================================================ ``divisible by`` ================ ``divisible by`` checks if a variable is divisible by a number: .. code-block:: twig {% if loop.index is divisible by(3) %} ... {% endif %} ================================================ FILE: doc/tests/empty.rst ================================================ ``empty`` ========= ``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. For objects that implement the ``__toString()`` magic method (and not ``Countable``), it will check if an empty string is returned. .. code-block:: twig {% if user is empty %} ... {% endif %} ================================================ FILE: doc/tests/even.rst ================================================ ``even`` ======== ``even`` returns ``true`` if the given number is even: .. code-block:: twig {{ var is even }} .. seealso:: :doc:`odd<../tests/odd>` ================================================ FILE: doc/tests/index.rst ================================================ Tests ===== .. toctree:: :maxdepth: 1 constant defined divisibleby empty even iterable mapping null odd sameas sequence ================================================ FILE: doc/tests/iterable.rst ================================================ ``iterable`` ============ ``iterable`` checks if a variable is an array or a traversable object: .. code-block:: twig {# evaluates to true if the users variable is iterable #} {% if users is iterable %} {% for user in users %} Hello {{ user }}! {% endfor %} {% else %} {# users is probably a string #} Hello {{ users }}! {% endif %} ================================================ FILE: doc/tests/mapping.rst ================================================ ``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 %} ================================================ FILE: doc/tests/null.rst ================================================ ``null`` ======== ``null`` returns ``true`` if the variable is ``null``: .. code-block:: twig {{ var is null }} .. note:: ``none`` is an alias for ``null``. ================================================ FILE: doc/tests/odd.rst ================================================ ``odd`` ======= ``odd`` returns ``true`` if the given number is odd: .. code-block:: twig {{ var is odd }} .. seealso:: :doc:`even<../tests/even>` ================================================ FILE: doc/tests/sameas.rst ================================================ ``same as`` =========== ``same as`` checks if a variable is the same as another variable. This is equivalent to ``===`` in PHP: .. code-block:: twig {% if user.name is same as(false) %} the user attribute is the 'false' PHP value {% endif %} ================================================ FILE: doc/tests/sequence.rst ================================================ ``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 %} ================================================ FILE: extra/cache-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/cache-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/cache-extra/CacheExtension.php ================================================ cache = $cache; } public function getCache(): CacheInterface { return $this->cache; } } ================================================ FILE: extra/cache-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/cache-extra/Node/CacheNode.php ================================================ setAttribute('raw', true); $nodes = ['key' => $key, 'body' => $body]; if (null !== $ttl) { $nodes['ttl'] = $ttl; } if (null !== $tags) { $nodes['tags'] = $tags; } parent::__construct($nodes, [], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->raw('$this->env->getRuntime(\'Twig\Extra\Cache\CacheRuntime\')->getCache()->get(') ->subcompile($this->getNode('key')) ->raw(", function (\Symfony\Contracts\Cache\ItemInterface \$item) use (\$context, \$macros, \$blocks) {\n") ->indent() ; if ($this->hasNode('tags')) { $compiler ->write('$item->tag(') ->subcompile($this->getNode('tags')) ->raw(");\n") ; } if ($this->hasNode('ttl')) { $compiler ->write('$item->expiresAfter(') ->subcompile($this->getNode('ttl')) ->raw(");\n") ; } $compiler ->write('return ') ->subcompile($this->getNode('body')) ->raw(";\n") ->outdent() ->write("})\n") ; } } ================================================ FILE: extra/cache-extra/README.md ================================================ Cache Extension =============== This package is a Twig extension that provides integration with the Symfony Cache component. It provides a single [`cache`][1] tag that allows to cache template fragments. [1]: https://twig.symfony.com/cache ================================================ FILE: extra/cache-extra/Tests/Fixtures/cache.test ================================================ --TEST-- "cache" tag --TEMPLATE-- {% set foo = "bar" %} {% set value1 %} {% cache "test;v1" ttl(3) %} {% set foo = "bar1" %} {{ random(1, 1000000) }} {% endcache %} {% endset %} {% set value2 %} {% cache "test;v1" ttl(3) %} {{ random(1, 1000000) }} {% endcache %} {% endset %} {{ value1 == value2 ? 'OK' : 'KO' }} {{ foo == "bar" ? 'OK' : 'KO' }} --DATA-- return [] --EXPECT-- OK OK ================================================ FILE: extra/cache-extra/Tests/Fixtures/cache_complex.test ================================================ --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 ================================================ FILE: extra/cache-extra/Tests/Fixtures/cache_with_blocks.test ================================================ --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 ================================================ FILE: extra/cache-extra/Tests/Fixtures/macro.test ================================================ --TEST-- macro call inside "cache" tag --TEMPLATE-- {% macro macro_out(bar) %}{{ bar }}{% endmacro %} 1 {% cache "testmacro1" ttl(3) %} {%~ macro macro_in(bar) %}{{ bar }}{% endmacro %} 2 {{ _self.macro_out(3) }} {{ _self.macro_in(4) }} {% endcache %} 5 {{ _self.macro_in(6) }} --DATA-- return [] --EXPECT-- 1 2 3 4 5 6 ================================================ FILE: extra/cache-extra/Tests/FunctionalTest.php ================================================ createEnvironment(['index' => '{% cache "city;v1" %}{{- city -}}{% endcache %}'], $cache); $this->assertSame('Paris', $twig->render('index', ['city' => 'Paris'])); $value = $cache->get('city;v1', static function () { throw new \RuntimeException('Key should be in the cache'); }); $this->assertSame('Paris', $value); } public function testTtlNoArgs() { $twig = $this->createEnvironment(['index' => '{% cache "ttl_no_args" ttl() %}{% endcache %}']); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('The "ttl" modifier takes exactly one argument (0 given) in "index" at line 1.'); $twig->render('index'); } public function testTtlTooManyArgs() { $twig = $this->createEnvironment(['index' => '{% cache "ttl_too_many_args" ttl(0, 1) %}{% endcache %}']); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('The "ttl" modifier takes exactly one argument (2 given) in "index" at line 1.'); $twig->render('index'); } public function testTagsNoArgs() { $twig = $this->createEnvironment(['index' => '{% cache "tags_no_args" tags() %}{% endcache %}']); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('The "tags" modifier takes exactly one argument (0 given) in "index" at line 1.'); $twig->render('index'); } public function testTagsTooManyArgs() { $twig = $this->createEnvironment(['index' => '{% cache "tags_too_many_args" tags(["foo"], 1) %}{% endcache %}']); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('The "tags" modifier takes exactly one argument (2 given) in "index" at line 1.'); $twig->render('index'); } private function createEnvironment(array $templates, ?ArrayAdapter $cache = null): Environment { $twig = new Environment(new ArrayLoader($templates)); $cache = $cache ?? new ArrayAdapter(); $twig->addExtension(new CacheExtension()); $twig->addRuntimeLoader(new class($cache) implements RuntimeLoaderInterface { private $cache; public function __construct(CacheInterface $cache) { $this->cache = $cache; } public function load(string $class): ?object { return CacheRuntime::class === $class ? new CacheRuntime($this->cache) : null; } }); return $twig; } } ================================================ FILE: extra/cache-extra/Tests/IntegrationTest.php ================================================ parser->getStream(); $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(); $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, ')'); if ('ttl' === $k) { $ttl = $arg; } elseif ('tags' === $k) { $tags = $arg; } } $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideCacheEnd'], true); $stream->expect(Token::BLOCK_END_TYPE); $body = new CacheNode($key, $ttl, $tags, $body, $token->getLine()); return new PrintNode(new RawFilter($body), $token->getLine()); } public function decideCacheEnd(Token $token): bool { return $token->test('endcache'); } public function getTag(): string { return 'cache'; } } ================================================ FILE: extra/cache-extra/composer.json ================================================ { "name": "twig/cache-extra", "type": "library", "description": "A Twig extension for Symfony Cache", "keywords": ["twig", "html", "cache"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "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": "^6.4|^7.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\Cache\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] } } ================================================ FILE: extra/cache-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/cssinliner-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/cssinliner-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/cssinliner-extra/CssInlinerExtension.php ================================================ ['all']]), ]; } /** * @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)); } } ================================================ FILE: extra/cssinliner-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/cssinliner-extra/README.md ================================================ Twig CssInliner Extension ========================= This package is a Twig extension that provides the following: * [`inline_css`][1] filter: inlines CSS styles in HTML documents. [1]: https://twig.symfony.com/inline_css ================================================ FILE: extra/cssinliner-extra/Resources/functions.php ================================================

    Great!

    {% endapply %} {% apply inline_css(source('css')) %}

    Great!

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

    Great!

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

    Great!

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

    Great!

    --TEMPLATE(css)-- p { color: red } --TEMPLATE(more_css)-- p { color: blue } --DATA-- return [] --EXPECT--

    Great!

    Great!

    Great!

    Great!

    Great!

    ================================================ FILE: extra/cssinliner-extra/Tests/IntegrationTest.php ================================================ assertSame(CssInlinerExtension::inlineCss('

    body

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

    body

    ', 'p { color: red }')); } } ================================================ FILE: extra/cssinliner-extra/composer.json ================================================ { "name": "twig/cssinliner-extra", "type": "library", "description": "A Twig extension to allow inlining CSS", "keywords": ["twig", "css", "inlining"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "php": ">=8.1.0", "symfony/deprecation-contracts": "^2.5|^3", "tijsverkoyen/css-to-inline-styles": "^2.0", "twig/twig": "^3.13|^4.0" }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\CssInliner\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] } } ================================================ FILE: extra/cssinliner-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/html-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/html-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/html-extra/Cva.php ================================================ */ 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'] ?? []); } } ================================================ FILE: extra/html-extra/HtmlAttr/AttributeValueInterface.php ================================================ */ interface AttributeValueInterface { /** * Returns the string representation of the attribute value. The returned value * will automatically be escaped for the HTML attribute context. * * @return string|null the attribute value as a string, or null to omit the attribute */ public function getValue(): ?string; } ================================================ FILE: extra/html-extra/HtmlAttr/InlineStyle.php ================================================ */ final class InlineStyle implements MergeableInterface, AttributeValueInterface { private readonly array $value; public function __construct(mixed $value) { if (!is_iterable($value)) { throw new RuntimeError('InlineStyle can only be created from iterable values.'); } $this->value = [...$value]; } public function mergeInto(mixed $previous): mixed { if ($previous instanceof self) { return new self([...$previous->value, ...$this->value]); } if (is_iterable($previous)) { return new self([...$previous, ...$this->value]); } throw new RuntimeError('Attributes using InlineStyle can only be merged with iterables or other InlineStyle instances.'); } public function appendFrom(mixed $newValue): mixed { if (!is_iterable($newValue)) { throw new RuntimeError('Only iterable values can be appended to InlineStyle.'); } return new self([...$this->value, ...$newValue]); } public function getValue(): ?string { $style = ''; foreach ($this->value as $name => $value) { if (empty($value) || true === $value) { continue; } if (is_numeric($name)) { $style .= trim($value, '; ').'; '; } else { $style .= $name.': '.$value.'; '; } } return trim($style) ?: null; } } ================================================ FILE: extra/html-extra/HtmlAttr/MergeableInterface.php ================================================ */ interface MergeableInterface { /** * Merge value from $this with another, previous value. In the merge arguments list, $this is on the right * hand side of $previous. This is the preferred merge method, so that newer (right) values have control * over the result. * * The `$previous` value is whatever value was present for a particular attribute before. Implementations * are free to completely ignore this value, effectively implementing "override only" merge behavior. * They can merge it with their own value, resulting in array-like merge behavior. Or they could throw * an exception when only particular types can be merged. * * Returns the new, resulting value as a new instance. Does not modify either $this not $previous. */ public function mergeInto(mixed $previous): mixed; /** * Merge value from $this with a new value. In the merge arguments list, $this is on the left hand side of * $newValue, but $newValue does not implement this interface itself. * * The `$newValue` value is whatever was given in the right hand side merge argument. Implementations are * free to return either the new value, implementing "override" merge behavior. They could also return * a value that represents a merge result between their current and the new value. Or they could throw * an exception when only particular types can be merged. * * Returns the new, resulting set as a new instance. Does not modify $this. */ public function appendFrom(mixed $newValue): mixed; } ================================================ FILE: extra/html-extra/HtmlAttr/SeparatedTokenList.php ================================================ */ final class SeparatedTokenList implements AttributeValueInterface, MergeableInterface { private readonly array $value; public function __construct(mixed $value, private readonly string $separator = ' ') { if (is_iterable($value)) { $this->value = [...$value]; } elseif (\is_scalar($value)) { $this->value = [$value]; } else { throw new RuntimeError('SeparatedTokenList can only be constructed from iterable or scalar values.'); } } public function mergeInto(mixed $previous): mixed { if ($previous instanceof self && $previous->separator === $this->separator) { return new self([...$previous->value, ...$this->value], $this->separator); } if (is_iterable($previous)) { return new self([...$previous, ...$this->value], $this->separator); } throw new RuntimeError('SeparatedTokenList can only be merged with iterables or other SeparatedTokenList instances using the same separator.'); } public function appendFrom(mixed $newValue): mixed { if (!is_iterable($newValue)) { throw new RuntimeError('Only iterable values can be appended to SeparatedTokenList.'); } return new self([...$this->value, ...$newValue], $this->separator); } public function getValue(): ?string { $filtered = array_filter($this->value, static fn ($v) => null !== $v && false !== $v); // Omit attribute if list contains only false or null values if (!$filtered) { return null; } // true values are not printed, but result in the attribute not being omitted return trim(implode($this->separator, array_filter($filtered, static fn ($v) => true !== $v))); } } ================================================ FILE: extra/html-extra/HtmlExtension.php ================================================ mimeTypes = $mimeTypes; } public function getFilters(): array { return [ new TwigFilter('data_uri', [$this, 'dataUri']), new TwigFilter('html_attr_merge', [self::class, 'htmlAttrMerge']), new TwigFilter('html_attr_type', [self::class, 'htmlAttrType']), ]; } public function getFunctions(): array { return [ new TwigFunction('html_classes', [self::class, 'htmlClasses']), new TwigFunction('html_cva', [self::class, 'htmlCva']), new TwigFunction('html_attr', [self::class, 'htmlAttr'], ['needs_environment' => true, 'is_safe' => ['html']]), ]; } /** * Creates a data URI (RFC 2397). * * Length validation is not performed on purpose, validation should * be done before calling this filter. * * @return string The generated data URI * * @internal */ public function dataUri(string $data, ?string $mime = null, array $parameters = []): string { $repr = 'data:'; if (null === $mime) { if (null === $this->mimeTypes) { $this->mimeTypes = new MimeTypes(); } $tmp = tempnam(sys_get_temp_dir(), 'mime'); file_put_contents($tmp, $data); try { if (null === $mime = $this->mimeTypes->guessMimeType($tmp)) { $mime = 'text/plain'; } } finally { @unlink($tmp); } } $repr .= $mime; foreach ($parameters as $key => $value) { $repr .= ';'.$key.'='.rawurlencode($value); } if (str_starts_with($mime, 'text/')) { $repr .= ','.rawurlencode($data); } else { $repr .= ';base64,'.base64_encode($data); } return $repr; } /** * @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; } } 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))); } } return implode(' ', array_unique(array_filter($classes, static function ($v) { return '' !== $v; }))); } /** * @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); } /** @internal */ public static function htmlAttrType(mixed $value, string $type = 'sst'): AttributeValueInterface { return match ($type) { 'sst' => new SeparatedTokenList($value, ' '), 'cst' => new SeparatedTokenList($value, ', '), 'style' => new InlineStyle($value), default => throw new RuntimeError(\sprintf('Unknown attribute type "%s" The only supported types are "sst", "cst" and "style".', $type)), }; } /** @internal */ public static function htmlAttrMerge(iterable|string|false|null ...$arrays): array { $result = []; foreach ($arrays as $array) { if (!$array) { continue; } if (\is_string($array)) { throw new RuntimeError('Only empty strings may be passed as string arguments to html_attr_merge. This is to support the implicit else clause for ternary operators.'); } foreach ($array as $key => $value) { if (!isset($result[$key])) { $result[$key] = $value; continue; } $existing = $result[$key]; switch (true) { case $value instanceof MergeableInterface: $result[$key] = $value->mergeInto($existing); break; case $existing instanceof MergeableInterface: $result[$key] = $existing->appendFrom($value); break; case is_iterable($existing) && is_iterable($value): $result[$key] = [...$existing, ...$value]; break; case (\is_scalar($existing) || \is_object($existing)) && (\is_scalar($value) || \is_object($value)): $result[$key] = $value; break; default: throw new RuntimeError(\sprintf('Cannot merge incompatible values for key "%s".', $key)); } } } return $result; } /** @internal */ public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): string { $attr = self::htmlAttrMerge(...$args); $result = ''; $runtime = $env->getRuntime(EscaperRuntime::class); foreach ($attr as $name => $value) { if (str_starts_with($name, 'aria-')) { // For aria-*, convert booleans to "true" and "false" strings if (true === $value) { $value = 'true'; } elseif (false === $value) { $value = 'false'; } } if (str_starts_with($name, 'data-')) { if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) { // ... encode non-null non-scalars as JSON try { $value = json_encode($value, \JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e); } } elseif (true === $value) { // ... and convert boolean true to a 'true' string. $value = 'true'; } } // Convert iterable values to token lists if (!$value instanceof AttributeValueInterface && is_iterable($value)) { if ('style' === $name) { $value = new InlineStyle($value); } else { $value = new SeparatedTokenList($value); } } if ($value instanceof AttributeValueInterface) { $value = $value->getValue(); } // In general, ... if (true === $value) { // ... use attribute="" for boolean true, // which is XHTML compliant and indicates the "empty value default", see // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and // https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes $value = ''; } if (null === $value || false === $value) { // omit null-valued and false attributes completely (note aria-* has been processed before) continue; } if (\is_object($value) && !$value instanceof \Stringable) { throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value))); } $result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape((string) $value).'" '; } return trim($result); } } ================================================ FILE: extra/html-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/html-extra/README.md ================================================ Twig HTML Extension =================== This package is a Twig extension that provides the following: * [`data_uri`][1] filter: generates a URL using the data scheme as defined in RFC 2397; * [`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 ================================================ FILE: extra/html-extra/Resources/functions.php ================================================ 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', ]; } } ================================================ FILE: extra/html-extra/Tests/Fixtures/data_uri.test ================================================ --TEST-- "data_uri" filter --TEMPLATE-- {{ 'foobar#'|data_uri(parameters={charset: "utf-8", foo: "$bar"}) }} {{ 'foobar'|data_uri(mime="text/html", parameters={charset: "ascii"}) }} --DATA-- return [ 'sf_logo' => base64_decode('iVBORw0KGgoAAAANSUhEUgAAAG8AAAAlCAQAAADmD3/hAAAE70lEQVRoBd3BAUTdiQMH8C+PR0TEOCIiRsQ4xvgTY4zHGDHGOCIeMY7HccQYESPG8YhjRETEY4wYcYwYMUbEI0bEiEc8Pv/f+9X+3brtrduM9f98ks9QVbNk07b3eGPbit/dyOXnujVHPq2tYSCXlXGrvuSdGZVcPuYcu5hXRnKZqHrq33jnei4LVS/8Wx03czn4w0Xt2rbnxHvj+fGpu5hlE2YtqLqjo+etofzYjDn2ZcfuGrSD3RQsOdHMj82Ki5hJPNazkYI/neiayI/LNRfRSgw41NP1h6YzrXySW37X1HAtX0XVkre6buTrWfNPx7Y0vdJ14r2RRM3nTOQcVRs4tm3b7XwV81h3XzVfy7CO81pGVdwxbcyKnsUUPNKza8N5CznHLB7lm9i2r5JvYdp5W6qJZT2H7nmoaywFL/RcTaz72E7OsYKBfBNtr/JtPHVeLQW7PlhwLSUHeu4lXjrvSj5iGbfzPxrmVVNS8ZuZxIIbRv1h25/GEzUbWuZVDWhqOvJOU9N4YkjDqpbHxlIy54Gqhk3rGiqJOY9VU1LRUI+XzhtPwawz91IwbNu2Z0Yt+qepfMR1XR0NAylp4H5K7uKXRNsre15Zd6xtTkfLX1hX0bLt2JFtm35y3b5jmzYcOnIvBS2HXti36jXWErOop6SG2dhzXi0lT3wwmoJxs+Y07fmUX3KOKbvYN5OCEV2tlKzoGEy0sZiCeT23UrCJ0RS0baWgqu3YVApG7Toykmhh23AKNjFhWMfzlCzrGI73znuRkqpXevZS8rt+HuYfDJh3hDWVxAsdA4kBHaspaDs2mILr+CslDUyloG0rBffxJKfcw2KihVpKZnA/sa5rOFFxaD2J9868s+w95lIyoedZSlb108gnGfYSs4kHuJe4i9spaHubkkk8SUkdtRS0baXgEWo5ZRCbiRaGUnIH9cRd/JK4jbtJvHPmRuIBuqZTcoCHKXmjn3o+wxWsJgZ0rCRWHKikoG0nJZNYSEkdtRS0baXgEe7klGFsJlrklBrqiapDrUTToYEkXjsznpjW814lMegYN1IwpKufO/kMA1hLwTNHhnUspaRtJyWTWEhJHbUUtG2l4D4e55QaFhItckoN9RQ0dVxxqJkeq84sJFb1tFPwUM9QCm7pbyKfYREPU3ALy7iekradlExiISV11FLQtpWCqtcOTaZg2GtHRhItckoN9RTcwDKm0uNXfzfvmqcWTSRmdPA2JUv6OVTJR9Ts2LDiAFuqKajYx9uc0vY6JZNYSEkdtRS0baXkmj1H1j3zzpHpFLTIKTXUU7KHdk6Y9LFDK55oeuvESkra+lnPOa5Z8pdtTQ9UcsoS5nPKglpKRjRNpGTKIwMpWHA3pwyZt2HToqspmfMwp0x6aiQlj/A4H3ijn3oKpvQ3nQvxRNdYvivzuJoPPNTP1RQ818+BgVyASUdW810Zd+h5zhh04HPaKZjS32/5IiNe6to3lu/GsE1d70zk79T93Z663xzqaSSGtPWzazBfZFTTr37Kd+QnTQ2j+ZiKl87cTME8DgyqWNNP16386Fyx54OJRNWmjuuq1vTXyGVg0pETGwb97IlJN+3o71kuC1MOnDiw4Zm2L/lTNZeHUTsuqquRy8agp4592Rs3czkZs6KftplcbkbMea7rY/ueuq2S/w+qfvYfs2ZN+9lYLp3/AoptWBePkE2wAAAAAElFTkSuQmCC'), ] --EXPECT-- data:text/plain;charset=utf-8;foo=%24bar,foobar%23 data:text/html;charset=ascii,%3Cb%3Efoobar%3C%2Fb%3E ================================================ FILE: extra/html-extra/Tests/Fixtures/html_attr.test ================================================ --TEST-- "html_attr" function --TEMPLATE-- Simple attributes: Relaxed attribute name escaping: Appropriate escaping: &\'"' }) }}/> Empty attribute list: Using a short ternary: boolean true attribute: boolean false attribute: null attribute value: empty string attribute value: ARIA attribute boolean conversion: data-* attribute handling : array value concatenation ex. 1: array value concatenation ex. 2: inline styles: style with a plain value: using a "comma separated token list" attribute: merging a "comma separated token list" value with more array values: --DATA-- return [] --EXPECT-- Simple attributes: Relaxed attribute name escaping: Appropriate escaping: Empty attribute list: Using a short ternary: boolean true attribute: boolean false attribute: null attribute value: empty string attribute value: ARIA attribute boolean conversion: data-* attribute handling : array value concatenation ex. 1: array value concatenation ex. 2: inline styles: style with a plain value: using a "comma separated token list" attribute: merging a "comma separated token list" value with more array values: ================================================ FILE: extra/html-extra/Tests/Fixtures/html_attr_merge.test ================================================ --TEST-- "html_attr_merge" filter --TEMPLATE-- {% autoescape false %} {{ { foo: 'bar' } | html_attr_merge({ bar: 'baz', foo: 'qux' }, { foo: 'quux' }) | json_encode }} {% endautoescape %} --DATA-- return [] --EXPECT-- {"foo":"quux","bar":"baz"} ================================================ FILE: extra/html-extra/Tests/Fixtures/html_classes.test ================================================ --TEST-- "html_classes" function --TEMPLATE-- {{ html_classes('a', {'b': true, 'c': false}, 'd', false ? 'e', true ? 'f', '0') }} {% set class_a = 'a' %} {%- 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 f 0 a b a b ================================================ FILE: extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_arg.test ================================================ --TEST-- "html_classes" function --TEMPLATE-- {{ html_classes(true) }} --DATA-- return [] --EXCEPTION-- 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. ================================================ FILE: extra/html-extra/Tests/Fixtures/html_classes_with_unsupported_key.test ================================================ --TEST-- "html_classes" function --TEMPLATE-- {{ html_classes(['foo']) }} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: The "html_classes" function argument 0 (key 0) should be a string, got "int" in "index.twig" at line 2. ================================================ FILE: extra/html-extra/Tests/Fixtures/html_cva.test ================================================ --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 ================================================ FILE: extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test ================================================ --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 ================================================ FILE: extra/html-extra/Tests/HtmlAttrMergeTest.php ================================================ [ ['id' => 'some-id', 'label' => 'some-label'], [ ['id' => 'some-id'], ['label' => 'some-label'], ], ]; yield 'merging different attributes from three arrays' => [ ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], [ ['id' => 'some-id'], ['label' => 'some-label'], ['role' => 'main'], ], ]; yield 'merging different attributes from Traversables' => [ ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], [ new \ArrayIterator(['id' => 'some-id']), new \ArrayIterator(['label' => 'some-label']), new \ArrayIterator(['role' => 'main']), ], ]; yield 'later keys override previous ones' => [ ['key' => 'other'], [ ['key' => 'this'], ['key' => 'that'], ['key' => 'other'], ], ]; yield 'later keys override previous ones - as before, but there is no magic in attribute names like "id" or "class"' => [ ['class' => 'other'], [ ['class' => 'this'], ['class' => 'that'], ['class' => 'other'], ], ]; yield 'in "merge array" mode, array_merge semantics will override non-numerical keys, but combine numerical ones' => [ ['something' => ['first' => 'baz', 'second' => 'bar', 0 => 'other', 1 => 'more']], [ ['something' => ['first' => 'foo']], ['something' => ['second' => 'bar']], ['something' => ['first' => 'baz']], ['something' => ['other']], ['something' => ['more']], ], ]; yield 'ignore empty arrays, null or false values passed as arguments' => [ ['something' => 'foo'], [ ['something' => 'foo'], [], null, false, ], ]; yield 'there is no special handling for scalars like true, false or null' => [ ['this' => true, 'that' => false, 'other' => null], [ ['this' => true], ['that' => false], ['other' => null], ], ]; yield 'inline style values with numerical keys are merely collected' => [ ['style' => ['font-weight: light', 'color: green', 'font-weight: bold']], [ ['style' => ['font-weight: light']], ['style' => ['color: green', 'font-weight: bold']], ], ]; yield 'inline style values can be overridden when they use names (array keys)' => [ ['style' => ['font-weight' => 'bold', 'color' => 'red']], [ ['style' => ['font-weight' => 'light']], ['style' => ['color' => 'green', 'font-weight' => 'bold']], ['style' => ['color' => 'red']], ], ]; yield 'no merging happens when mixing numerically indexed inline styles with named ones' => [ ['style' => ['color: green', 'color' => 'red']], [ ['style' => ['color: green']], ['style' => ['color' => 'red']], ], ]; // MergeableInterface yield 'MergeableInterface mergeInto is called when new value implements interface' => [ ['class' => 'merged: old + new'], [ ['class' => 'old'], ['class' => new MergeableStub('new')], ], ]; yield 'MergeableInterface appendFrom is called when existing value implements interface' => [ ['class' => 'appended: old + new'], [ ['class' => new MergeableStub('old')], ['class' => 'new'], ], ]; yield 'MergeableInterface mergeInto is called when both implement interface' => [ ['class' => 'merged: value1 + value2'], [ ['class' => new MergeableStub('value1')], ['class' => new MergeableStub('value2')], ], ]; yield 'MergeableInterface with array value' => [ ['class' => 'appended: base + extra1, extra2'], [ ['class' => new MergeableStub('base')], ['class' => ['extra1', 'extra2']], ], ]; // Scalar and object merging yield 'string replaces object' => [ ['value' => 'new-string'], [ ['value' => new \stdClass()], ['value' => 'new-string'], ], ]; yield 'object replaces string' => [ ['value' => new \stdClass()], [ ['value' => 'old-string'], ['value' => new \stdClass()], ], ]; } public function testIncompatibleValuesMergeThrowsException() { $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Cannot merge incompatible values for key "test"'); HtmlExtension::htmlAttrMerge( ['test' => ['array']], ['test' => 'scalar'] ); } } class MergeableStub implements MergeableInterface { public function __construct(private readonly mixed $value) { } public function mergeInto(mixed $previous): mixed { $previousValue = $previous instanceof self ? $previous->value : $previous; return new self("merged: {$previousValue} + {$this->value}"); } public function appendFrom(mixed $newValue): mixed { if (\is_array($newValue)) { $newValue = implode(', ', $newValue); } elseif ($newValue instanceof self) { $newValue = $newValue->value; } return new self("appended: {$this->value} + {$newValue}"); } public function __toString(): string { return (string) $this->value; } } ================================================ FILE: extra/html-extra/Tests/HtmlAttrTest.php ================================================ [ 'id="some-id" label="some-label" role="main"', [ ['id' => 'some-id'], null, '', false, ['label' => 'some-label'], ['role' => 'main'], ], ]; // Boolean attribute handling yield 'boolean true renders as empty string, except for aria-* and data-* it uses "true"' => [ 'required="" aria-disabled="true" data-yes="true"', [ ['required' => true, 'aria-disabled' => true, 'data-yes' => true], ], ]; yield 'boolean false omits attribute, except for aria-* it uses "false"' => [ 'aria-disabled="false"', [ ['disabled' => false, 'aria-disabled' => false, 'data-gone' => false], ], ]; yield 'null value omits attribute, also for special cases' => [ '', [ ['title' => null, 'style' => null, 'aria-gone' => null, 'data-nil' => null], ], ]; yield 'empty string renders as empty attribute value' => [ 'title=""', [ ['title' => ''], ], ]; // Data attributes yield 'data attribute with array is JSON encoded' => [ 'data-config="{"theme":"dark"}"', [ ['data-config' => ['theme' => 'dark']], ], ]; // In general, array values are printed as space-separated token lists yield 'array value renders as space-separated token list' => [ 'class="btn btn-primary btn-lg"', [ ['class' => ['btn', 'btn-primary', 'btn-lg']], ], ]; yield 'arrays with just an empty string produce the empty attribute' => [ 'foo=""', [ ['foo' => ['']], ], ]; yield 'arrays with just a true value produce the empty attribute' => [ 'foo=""', [ ['foo' => [true]], ], ]; yield 'arrays with just a null value are not printed' => [ '', [ ['foo' => [null]], ], ]; // Style attributes yield 'style with plain string value' => [ 'style="color: red;"', [ ['style' => 'color: red;'], ], ]; yield 'style with associative array' => [ 'style="color: red; font-size: 16px;"', [ ['style' => ['color' => 'red', 'font-size' => '16px']], ], ]; yield 'style with numeric array' => [ 'style="color: red; font-size: 16px;"', [ ['style' => ['color: red', 'font-size: 16px']], ], ]; yield 'merging style attributes overrides by key' => [ 'style="color: blue; font-size: 14px;"', [ ['style' => ['color' => 'red', 'font-size' => '14px']], ['style' => ['color' => 'blue']], ], ]; // Escaping yield 'attribute name is escaped' => [ 'data-user id="123"', [ ['data-user id' => '123'], ], ]; yield 'attribute value is escaped' => [ 'title="<script>alert("xss")</script>"', [ ['title' => ''], ], ]; // Variadic merging scenarios yield 'scalar value overrides from left to right' => [ 'id="final"', [ ['id' => 'first'], ['id' => 'second'], ['id' => 'final'], ], ]; yield 'variadic with mixed false and null values' => [ 'id="test"', [ ['id' => 'test'], null, false, null, ], ]; yield 'variadic with empty arrays' => [ 'id="test"', [ [], ['id' => 'test'], [], ], ]; yield 'variadic with empty string values' => [ 'id="test"', [ '', ['id' => 'test'], '', ], ]; // AttributeValueInterface yield 'AttributeValueInterface with string value' => [ 'custom="custom-value"', [ ['custom' => new AttributeValueStub('custom-value')], ], ]; yield 'AttributeValueInterface with null value omits attribute' => [ '', [ ['custom' => new AttributeValueStub(null)], ], ]; yield 'AttributeValueInterface wins over special case handling for style and data-*' => [ 'style="some style" data-custom="not JSON"', [ ['style' => new AttributeValueStub('some style'), 'data-custom' => new AttributeValueStub('not JSON')], ], ]; // Edge cases yield 'numeric attribute value' => [ 'tabindex="0"', [ ['tabindex' => 0], ], ]; yield 'zero is not treated as falsy' => [ 'data-count="0"', [ ['data-count' => 0], ], ]; // Scalar and object merging in rendering yield 'string replaces object in rendering' => [ 'value="new-string"', [ ['value' => new \stdClass()], ['value' => 'new-string'], ], ]; yield 'object replaces string in rendering uses __toString if available' => [ 'value="stringable-object"', [ ['value' => 'old-string'], ['value' => new StringableStub('stringable-object')], ], ]; } public function testIterableObjectCastedToArray() { /* This test case demonstrates how objects could e. g. implement helper logic to construct more complex attribute combinations and sets, and be passed as one argument to html_attr as well. */ $object = new class implements \IteratorAggregate { public function getIterator(): \Traversable { return new \ArrayIterator([ 'data-controller' => new SeparatedTokenList(['dropdown', 'tooltip']), 'data-action' => new SeparatedTokenList(['click->dropdown#toggle', 'mouseover->tooltip#show']), ]); } }; $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object); self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', $result); } public function testDataAttributeWithNonJsonEncodableValueThrowsRuntimeError() { $this->expectException(RuntimeError::class); $this->expectExceptionMessage('The "data-bad" attribute value cannot be JSON encoded.'); HtmlExtension::htmlAttr( new Environment(new ArrayLoader()), ['data-bad' => [\INF]] // INF cannot be JSON-encoded ); } public function testNonStringableObjectAsAttributeValueThrowsRuntimeError() { $this->expectException(RuntimeError::class); $this->expectExceptionMessage('The "title" attribute value should be a scalar, an iterable, or an object implementing "Stringable"'); HtmlExtension::htmlAttr( new Environment(new ArrayLoader()), ['title' => new \stdClass()] ); } } class StringableStub implements \Stringable { public function __construct(private readonly string $value) { } public function __toString(): string { return $this->value; } } class AttributeValueStub implements AttributeValueInterface { public function __construct(private readonly ?string $value) { } public function getValue(): ?string { return $this->value; } } ================================================ FILE: extra/html-extra/Tests/IntegrationTest.php ================================================ assertSame(HtmlExtension::htmlClasses(['charset' => 'utf-8']), twig_html_classes(['charset' => 'utf-8'])); } } ================================================ FILE: extra/html-extra/composer.json ================================================ { "name": "twig/html-extra", "type": "library", "description": "A Twig extension for HTML", "keywords": ["twig", "html"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "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": "^6.4|^7.0" }, "autoload": { "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\Html\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] } } ================================================ FILE: extra/html-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/inky-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/inky-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/inky-extra/InkyExtension.php ================================================ ['html']]), ]; } /** * @internal */ public static function inky(string $body): string { return false === ($html = Pinky\transformString($body)->saveHTML()) ? '' : $html; } } ================================================ FILE: extra/inky-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/inky-extra/README.md ================================================ Twig Inky Extension =================== This package is a Twig extension that provides the following: * [`inky_to_html`][1] filter: processes an [inky email template](https://github.com/zurb/inky). [1]: https://twig.symfony.com/inky_to_html ================================================ FILE: extra/inky-extra/Resources/functions.php ================================================ {{ var }} {%- endapply %} {{ include("inky")|inky_to_html }} --TEMPLATE(inky)-- {{ var }} --DATA-- return ['var' => 'value
    '] --EXPECT--
    value<br />
    value<br />
    ================================================ FILE: extra/inky-extra/Tests/IntegrationTest.php ================================================ assertSame(InkyExtension::inky('

    Foo

    '), twig_inky('

    Foo

    ')); } } ================================================ FILE: extra/inky-extra/composer.json ================================================ { "name": "twig/inky-extra", "type": "library", "description": "A Twig extension for the inky email templating engine", "keywords": ["twig", "inky", "email", "emails"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "php": ">=8.1.0", "symfony/deprecation-contracts": "^2.5|^3", "lorenzo/pinky": "^1.0.5", "twig/twig": "^3.13|^4.0" }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { "files": [ "Resources/functions.php" ], "psr-4" : { "Twig\\Extra\\Inky\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] } } ================================================ FILE: extra/inky-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/intl-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/intl-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/intl-extra/IntlExtension.php ================================================ \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, 'long' => \IntlDateFormatter::LONG, 'full' => \IntlDateFormatter::FULL, ]; private const NUMBER_TYPES = [ 'default' => \NumberFormatter::TYPE_DEFAULT, 'int32' => \NumberFormatter::TYPE_INT32, 'int64' => \NumberFormatter::TYPE_INT64, 'double' => \NumberFormatter::TYPE_DOUBLE, ]; private const NUMBER_STYLES = [ 'decimal' => \NumberFormatter::DECIMAL, 'currency' => \NumberFormatter::CURRENCY, 'percent' => \NumberFormatter::PERCENT, 'scientific' => \NumberFormatter::SCIENTIFIC, 'spellout' => \NumberFormatter::SPELLOUT, 'ordinal' => \NumberFormatter::ORDINAL, 'duration' => \NumberFormatter::DURATION, ]; private const NUMBER_ATTRIBUTES = [ 'grouping_used' => \NumberFormatter::GROUPING_USED, 'decimal_always_shown' => \NumberFormatter::DECIMAL_ALWAYS_SHOWN, 'max_integer_digit' => \NumberFormatter::MAX_INTEGER_DIGITS, 'min_integer_digit' => \NumberFormatter::MIN_INTEGER_DIGITS, 'integer_digit' => \NumberFormatter::INTEGER_DIGITS, 'max_fraction_digit' => \NumberFormatter::MAX_FRACTION_DIGITS, 'min_fraction_digit' => \NumberFormatter::MIN_FRACTION_DIGITS, 'fraction_digit' => \NumberFormatter::FRACTION_DIGITS, 'multiplier' => \NumberFormatter::MULTIPLIER, 'grouping_size' => \NumberFormatter::GROUPING_SIZE, 'rounding_mode' => \NumberFormatter::ROUNDING_MODE, 'rounding_increment' => \NumberFormatter::ROUNDING_INCREMENT, 'format_width' => \NumberFormatter::FORMAT_WIDTH, 'padding_position' => \NumberFormatter::PADDING_POSITION, 'secondary_grouping_size' => \NumberFormatter::SECONDARY_GROUPING_SIZE, 'significant_digits_used' => \NumberFormatter::SIGNIFICANT_DIGITS_USED, 'min_significant_digits_used' => \NumberFormatter::MIN_SIGNIFICANT_DIGITS, 'max_significant_digits_used' => \NumberFormatter::MAX_SIGNIFICANT_DIGITS, 'lenient_parse' => \NumberFormatter::LENIENT_PARSE, ]; private const NUMBER_ROUNDING_ATTRIBUTES = [ 'ceiling' => \NumberFormatter::ROUND_CEILING, 'floor' => \NumberFormatter::ROUND_FLOOR, 'down' => \NumberFormatter::ROUND_DOWN, 'up' => \NumberFormatter::ROUND_UP, 'halfeven' => \NumberFormatter::ROUND_HALFEVEN, 'halfdown' => \NumberFormatter::ROUND_HALFDOWN, 'halfup' => \NumberFormatter::ROUND_HALFUP, ]; private const NUMBER_PADDING_ATTRIBUTES = [ 'before_prefix' => \NumberFormatter::PAD_BEFORE_PREFIX, 'after_prefix' => \NumberFormatter::PAD_AFTER_PREFIX, 'before_suffix' => \NumberFormatter::PAD_BEFORE_SUFFIX, 'after_suffix' => \NumberFormatter::PAD_AFTER_SUFFIX, ]; private const NUMBER_TEXT_ATTRIBUTES = [ 'positive_prefix' => \NumberFormatter::POSITIVE_PREFIX, 'positive_suffix' => \NumberFormatter::POSITIVE_SUFFIX, 'negative_prefix' => \NumberFormatter::NEGATIVE_PREFIX, 'negative_suffix' => \NumberFormatter::NEGATIVE_SUFFIX, 'padding_character' => \NumberFormatter::PADDING_CHARACTER, 'currency_code' => \NumberFormatter::CURRENCY_CODE, 'default_ruleset' => \NumberFormatter::DEFAULT_RULESET, 'public_rulesets' => \NumberFormatter::PUBLIC_RULESETS, ]; private const NUMBER_SYMBOLS = [ 'decimal_separator' => \NumberFormatter::DECIMAL_SEPARATOR_SYMBOL, 'grouping_separator' => \NumberFormatter::GROUPING_SEPARATOR_SYMBOL, 'pattern_separator' => \NumberFormatter::PATTERN_SEPARATOR_SYMBOL, 'percent' => \NumberFormatter::PERCENT_SYMBOL, 'zero_digit' => \NumberFormatter::ZERO_DIGIT_SYMBOL, 'digit' => \NumberFormatter::DIGIT_SYMBOL, 'minus_sign' => \NumberFormatter::MINUS_SIGN_SYMBOL, 'plus_sign' => \NumberFormatter::PLUS_SIGN_SYMBOL, 'currency' => \NumberFormatter::CURRENCY_SYMBOL, 'intl_currency' => \NumberFormatter::INTL_CURRENCY_SYMBOL, 'monetary_separator' => \NumberFormatter::MONETARY_SEPARATOR_SYMBOL, 'exponential' => \NumberFormatter::EXPONENTIAL_SYMBOL, 'permill' => \NumberFormatter::PERMILL_SYMBOL, 'pad_escape' => \NumberFormatter::PAD_ESCAPE_SYMBOL, 'infinity' => \NumberFormatter::INFINITY_SYMBOL, 'nan' => \NumberFormatter::NAN_SYMBOL, 'significant_digit' => \NumberFormatter::SIGNIFICANT_DIGIT_SYMBOL, 'monetary_grouping_separator' => \NumberFormatter::MONETARY_GROUPING_SEPARATOR_SYMBOL, ]; private $dateFormatters = []; private $numberFormatters = []; private $dateFormatterPrototype; private $numberFormatterPrototype; public function __construct(?\IntlDateFormatter $dateFormatterPrototype = null, ?\NumberFormatter $numberFormatterPrototype = null) { $this->dateFormatterPrototype = $dateFormatterPrototype; $this->numberFormatterPrototype = $numberFormatterPrototype; } public function getFilters(): array { return [ // internationalized names new TwigFilter('country_name', [$this, 'getCountryName']), new TwigFilter('currency_name', [$this, 'getCurrencyName']), new TwigFilter('currency_symbol', [$this, 'getCurrencySymbol']), new TwigFilter('language_name', [$this, 'getLanguageName']), new TwigFilter('locale_name', [$this, 'getLocaleName']), new TwigFilter('timezone_name', [$this, 'getTimezoneName']), // localized formatters new TwigFilter('format_currency', [$this, 'formatCurrency']), new TwigFilter('format_number', [$this, 'formatNumber']), new TwigFilter('format_*_number', [$this, 'formatNumberStyle']), new TwigFilter('format_datetime', [$this, 'formatDateTime'], ['needs_environment' => true]), new TwigFilter('format_date', [$this, 'formatDate'], ['needs_environment' => true]), new TwigFilter('format_time', [$this, 'formatTime'], ['needs_environment' => true]), ]; } 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 { if (null === $country) { return ''; } try { return Countries::getName($country, $locale); } catch (MissingResourceException $exception) { return $country; } } public function getCurrencyName(?string $currency, ?string $locale = null): string { if (null === $currency) { return ''; } try { return Currencies::getName($currency, $locale); } catch (MissingResourceException $exception) { return $currency; } } public function getCurrencySymbol(?string $currency, ?string $locale = null): string { if (null === $currency) { return ''; } try { return Currencies::getSymbol($currency, $locale); } catch (MissingResourceException $exception) { return $currency; } } public function getLanguageName(?string $language, ?string $locale = null): string { if (null === $language) { return ''; } try { return Languages::getName($language, $locale); } catch (MissingResourceException $exception) { return $language; } } public function getLocaleName(?string $data, ?string $locale = null): string { if (null === $data) { return ''; } try { return Locales::getName($data, $locale); } catch (MissingResourceException $exception) { return $data; } } public function getTimezoneName(?string $timezone, ?string $locale = null): string { if (null === $timezone) { return ''; } try { return Timezones::getName($timezone, $locale); } catch (MissingResourceException $exception) { return $timezone; } } public function getCountryTimezones(string $country): array { try { return Timezones::forCountryCode($country); } catch (MissingResourceException $exception) { return []; } } 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); if (false === $ret = $formatter->formatCurrency($amount, $currency)) { throw new RuntimeError('Unable to format the given number as a currency.'); } return $ret; } 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)))); } $formatter = $this->createNumberFormatter($locale, $style, $attrs); if (false === $ret = $formatter->format($number, self::NUMBER_TYPES[$type])) { throw new RuntimeError('Unable to format the given number.'); } return $ret; } public function formatNumberStyle(string $style, $number, array $attrs = [], string $type = 'default', ?string $locale = null): string { return $this->formatNumber($number, $attrs, $style, $type, $locale); } /** * @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 { $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.'); } return $ret; } /** * @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 { return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale); } /** * @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 { 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 { $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::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) { if ($this->dateFormatterPrototype) { $locale = $this->dateFormatterPrototype->getLocale(); } $locale = $locale ?: \Locale::getDefault(); } $calendar = 'gregorian' === $calendar ? \IntlDateFormatter::GREGORIAN : \IntlDateFormatter::TRADITIONAL; $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->getTimeZone()->toDateTimeZone(); $calendar = $calendar ?: $this->dateFormatterPrototype->getCalendar(); $pattern = $pattern ?: $this->dateFormatterPrototype->getPattern(); } $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); } return $this->dateFormatters[$hash]; } 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)))); } if (null === $locale) { $locale = \Locale::getDefault(); } // textAttrs and symbols can only be set on the prototype as there is probably no // use case for setting it on each call. $textAttrs = []; $symbols = []; if ($this->numberFormatterPrototype) { foreach (self::NUMBER_ATTRIBUTES as $name => $const) { if (!isset($attrs[$name])) { $value = $this->numberFormatterPrototype->getAttribute($const); if ('rounding_mode' === $name) { $value = array_flip(self::NUMBER_ROUNDING_ATTRIBUTES)[$value]; } elseif ('padding_position' === $name) { $value = array_flip(self::NUMBER_PADDING_ATTRIBUTES)[$value]; } $attrs[$name] = $value; } } foreach (self::NUMBER_TEXT_ATTRIBUTES as $name => $const) { $textAttrs[$name] = $this->numberFormatterPrototype->getTextAttribute($const); } foreach (self::NUMBER_SYMBOLS as $name => $const) { $symbols[$name] = $this->numberFormatterPrototype->getSymbol($const); } } ksort($attrs); $hash = $locale.'|'.$style.'|'.json_encode($attrs).'|'.json_encode($textAttrs).'|'.json_encode($symbols); if (!isset($this->numberFormatters[$hash])) { $this->numberFormatters[$hash] = new \NumberFormatter($locale, self::NUMBER_STYLES[$style]); } 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)))); } 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)))); } $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)))); } $value = self::NUMBER_PADDING_ATTRIBUTES[$value]; } $this->numberFormatters[$hash]->setAttribute(self::NUMBER_ATTRIBUTES[$name], $value); } foreach ($textAttrs as $name => $value) { $this->numberFormatters[$hash]->setTextAttribute(self::NUMBER_TEXT_ATTRIBUTES[$name], $value); } foreach ($symbols as $name => $value) { $this->numberFormatters[$hash]->setSymbol(self::NUMBER_SYMBOLS[$name], $value); } return $this->numberFormatters[$hash]; } } ================================================ FILE: extra/intl-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/intl-extra/README.md ================================================ Twig Intl Extension =================== This package is a Twig extension that provides the following: * [`country_name`][1] filter: returns the country name given its two-letter/five-letter code; * [`currency_name`][2] filter: returns the currency name given its three-letter code; * [`currency_symbol`][3] filter: returns the currency symbol given its three-letter code; * [`language_name`][4] filter: returns the language name given its two-letter/five-letter code; * [`locale_name`][5] filter: returns the language name given its two-letter/five-letter code; * [`timezone_name`][6] filter: returns the timezone name given its identifier; * [`country_timezones`][7] filter: returns the timezone identifiers of the given country code; * [`format_currency`][8] filter: formats a number as a currency; * [`format_number`][9] filter: formats a number; * [`format_datetime`][10] filter: formats a date time; * [`format_date`][11] filter: formats a date; * [`format_time`][12] filter: formats a time. [1]: https://twig.symfony.com/country_name [2]: https://twig.symfony.com/currency_name [3]: https://twig.symfony.com/currency_symbol [4]: https://twig.symfony.com/language_name [5]: https://twig.symfony.com/locale_name [6]: https://twig.symfony.com/timezone_name [7]: https://twig.symfony.com/country_timezones [8]: https://twig.symfony.com/format_currency [9]: https://twig.symfony.com/format_number [10]: https://twig.symfony.com/format_datetime [11]: https://twig.symfony.com/format_date [12]: https://twig.symfony.com/format_time ================================================ FILE: extra/intl-extra/Tests/Fixtures/country_name.test ================================================ --TEST-- "country_name" filter --TEMPLATE-- {{ 'UNKNOWN'|country_name }} {{ null|country_name }} {{ 'FR'|country_name }} {{ 'US'|country_name }} {{ 'US'|country_name('fr') }} {{ 'CH'|country_name('fr_CA') }} --DATA-- return []; --EXPECT-- UNKNOWN France United States États-Unis Suisse ================================================ FILE: extra/intl-extra/Tests/Fixtures/country_names.test ================================================ --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 ================================================ FILE: extra/intl-extra/Tests/Fixtures/country_timezones.test ================================================ --TEST-- "country_timezones" function --TEMPLATE-- {{ country_timezones('UNKNOWN')|length }} {{ country_timezones('FR')|join(', ') }} {{ country_timezones('US')[0:2]|join(', ') }} --DATA-- return []; --EXPECT-- 0 Europe/Paris America/Adak, America/Anchorage ================================================ FILE: extra/intl-extra/Tests/Fixtures/currency_name.test ================================================ --TEST-- "currency_name" filter --TEMPLATE-- {{ 'UNKNOWN'|currency_name }} {{ null|currency_name }} {{ 'EUR'|currency_name }} {{ 'JPY'|currency_name }} {{ 'EUR'|currency_name('fr') }} {{ 'JPY'|currency_name('fr_FR') }} --DATA-- return []; --EXPECT-- UNKNOWN Euro Japanese Yen euro yen japonais ================================================ FILE: extra/intl-extra/Tests/Fixtures/currency_names.test ================================================ --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 ================================================ FILE: extra/intl-extra/Tests/Fixtures/currency_symbol.test ================================================ --TEST-- "currency_symbol" filter --TEMPLATE-- {{ 'UNKNOWN'|currency_symbol }} {{ null|currency_symbol }} {{ 'EUR'|currency_symbol }} {{ 'JPY'|currency_symbol }} --DATA-- return []; --EXPECT-- UNKNOWN € ¥ ================================================ FILE: extra/intl-extra/Tests/Fixtures/format_currency.test ================================================ --TEST-- "format_currency" filter --TEMPLATE-- {{ '1000000'|format_currency('EUR') }} {{ '1000000'|format_currency('EUR', locale='de') }} {{ '1000000'|format_currency('EUR', {fraction_digit: 2}) }} {{ '12.345'|format_currency('EUR', {rounding_mode: 'floor'}) }} {{ '125000'|format_currency('YEN') }} --DATA-- return []; --EXPECT-- €1,000,000.00 1.000.000,00 € €1,000,000.00 €12.34 YEN 125,000.00 ================================================ FILE: extra/intl-extra/Tests/Fixtures/format_date.test ================================================ --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 ================================================ FILE: extra/intl-extra/Tests/Fixtures/format_date_ICU72.test ================================================ --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 ================================================ FILE: extra/intl-extra/Tests/Fixtures/format_date_php8.test ================================================ --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é ================================================ FILE: extra/intl-extra/Tests/Fixtures/format_date_php8_ICU72.test ================================================ --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é ================================================ FILE: extra/intl-extra/Tests/Fixtures/format_number.test ================================================ --TEST-- "format_number" filter --TEMPLATE-- {{ '12.345'|format_number }} {{ '12.345'|format_number(locale='fr') }} {{ '12.345'|format_number(style='percent') }} {{ '12.345'|format_number(style='spellout') }} {{ '12.345'|format_percent_number }} {{ '12.345'|format_spellout_number }} {{ '80.345'|format_spellout_number(locale='fr_FR') }} {{ '80.345'|format_spellout_number(locale='fr_CH') }} {{ '12'|format_duration_number }} {{ '0.12'|format_percent_number({fraction_digit: 1}) }} {{ '0.12345'|format_percent_number({rounding_mode: 'ceiling'}) }} --DATA-- return []; --EXPECT-- 12.345 12,345 1,234% twelve point three four five 1,234% twelve point three four five quatre-vingts virgule trois quatre cinq huitante virgule trois quatre cinq 12 sec. 12.0% 13% ================================================ FILE: extra/intl-extra/Tests/Fixtures/language_name.test ================================================ --TEST-- "language_name" filter --TEMPLATE-- {{ 'UNKNOWN'|language_name }} {{ null|language_name }} {{ 'de'|language_name }} {{ 'fr'|language_name }} {{ 'de'|language_name('fr') }} {{ 'fr'|language_name('fr_FR') }} {{ 'fr_CA'|language_name('fr_FR') }} --DATA-- return []; --EXPECT-- UNKNOWN German French allemand français français canadien ================================================ FILE: extra/intl-extra/Tests/Fixtures/language_names.test ================================================ --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 ================================================ FILE: extra/intl-extra/Tests/Fixtures/locale_name.test ================================================ --TEST-- "locale_name" filter --TEMPLATE-- {{ 'UNKNOWN'|locale_name }} {{ null|locale_name }} {{ 'de'|locale_name }} {{ 'fr'|locale_name }} {{ 'de'|locale_name('fr') }} {{ 'fr'|locale_name('fr_FR') }} {{ 'fr_CA'|locale_name('fr_FR') }} --DATA-- return []; --EXPECT-- UNKNOWN German French allemand français français (Canada) ================================================ FILE: extra/intl-extra/Tests/Fixtures/locale_names.test ================================================ --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 ================================================ FILE: extra/intl-extra/Tests/Fixtures/script_names.test ================================================ --TEST-- "script_names" function --TEMPLATE-- {{ script_names('UNKNOWN')|length }} {{ script_names()|length > 200 ? 'more than 200' : 'less than 200' }} {{ script_names('fr')|length > 200 ? 'more than 200' : 'less than 200' }} {{ script_names()['Marc'] }} {{ script_names('fr')['Marc'] }} --DATA-- return []; --EXPECT-- 0 more than 200 more than 200 Marchen Marchen ================================================ FILE: extra/intl-extra/Tests/Fixtures/timezone_name.test ================================================ --TEST-- "timezone_name" filter --TEMPLATE-- {{ 'UNKNOWN'|timezone_name }} {{ null|timezone_name }} {{ 'Europe/Paris'|timezone_name }} {{ 'America/Los_Angeles'|timezone_name }} {{ 'America/Los_Angeles'|timezone_name('fr') }} --DATA-- return []; --EXPECT-- UNKNOWN Central European Time (Paris) Pacific Time (Los Angeles) heure du Pacifique nord-américain (Los Angeles) ================================================ FILE: extra/intl-extra/Tests/Fixtures/timezone_names.test ================================================ --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) ================================================ FILE: extra/intl-extra/Tests/IntegrationTest.php ================================================ 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, 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') ); } } ================================================ FILE: extra/intl-extra/composer.json ================================================ { "name": "twig/intl-extra", "type": "library", "description": "A Twig extension for Intl", "keywords": ["twig", "intl"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "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": "^6.4|^7.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\Intl\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] } } ================================================ FILE: extra/intl-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/markdown-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/markdown-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/markdown-extra/DefaultMarkdown.php ================================================ converter = new LeagueMarkdown(); } elseif (class_exists(MarkdownExtra::class)) { $this->converter = new MichelfMarkdown(); } 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".'); } } public function convert(string $body): string { return $this->converter->convert($body); } } ================================================ FILE: extra/markdown-extra/ErusevMarkdown.php ================================================ converter = $converter ?: new \Parsedown(); } public function convert(string $body): string { return $this->converter->text($body); } } ================================================ FILE: extra/markdown-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/markdown-extra/LeagueMarkdown.php ================================================ converter = $converter ?: new CommonMarkConverter(); $this->legacySupport = !method_exists($this->converter, 'convert'); } public function convert(string $body): string { if ($this->legacySupport) { return $this->converter->convertToHtml($body); } return $this->converter->convert($body); } } ================================================ FILE: extra/markdown-extra/MarkdownExtension.php ================================================ ['all']]), new TwigFilter('html_to_markdown', [self::class, 'htmlToMarkdown'], ['is_safe' => ['all']]), ]; } /** * @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".'); } $options += [ 'hard_break' => true, 'strip_tags' => true, 'remove_nodes' => 'head style', ]; if (!isset($converters[$key = serialize($options)])) { $converters[$key] = new HtmlConverter($options); } return $converters[$key]->convert($body); } } ================================================ FILE: extra/markdown-extra/MarkdownInterface.php ================================================ converter = $converter; } public function convert(string $body): string { // remove indentation if ($white = substr($body, 0, strspn($body, " \t\r\n\0\x0B"))) { $body = preg_replace("{^$white}m", '', $body); } return $this->converter->convert($body); } } ================================================ FILE: extra/markdown-extra/MichelfMarkdown.php ================================================ hard_wrap = true; } $this->converter = $converter; } public function convert(string $body): string { return $this->converter->transform($body); } } ================================================ FILE: extra/markdown-extra/README.md ================================================ Twig Markdown Extension ======================= This package is a Twig extension that provides the following: * [`markdown_to_html`][1] filter: generates HTML from a Markdown block; * [`html_to_markdown`][2] filter: generates Markdown from an HTML block. [1]: https://twig.symfony.com/markdown_to_html [2]: https://twig.symfony.com/html_to_markdown ================================================ FILE: extra/markdown-extra/Resources/functions.php ================================================

    Hello

    Great!

    {% endapply %} {% apply html_to_markdown({hard_break: false}) %} Great
    Break {% endapply %} {{ include('html')|html_to_markdown }} --TEMPLATE(html)--

    Hello

    Great!

    --DATA-- return [] --EXPECT-- Hello ===== **Great!** Great Break Hello ===== **Great!** ================================================ FILE: extra/markdown-extra/Tests/FunctionalTest.php ================================================ $template, 'html' => <<addExtension(new MarkdownExtension()); $twig->addRuntimeLoader(new class($class) implements RuntimeLoaderInterface { private $class; public function __construct(string $class) { $this->class = $class; } public function load(string $c): ?object { return MarkdownRuntime::class === $c ? new $c(new $this->class()) : null; } }); $this->assertMatchesRegularExpression('{'.$expected.'}m', trim($twig->render('index'))); } } public static function getMarkdownTests() { return [ [<<Hello\n+

    Great!

    "], [<<Hello\n+

    Great!

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

    Hello

    \n+

    Great!

    "], ]; } } ================================================ FILE: extra/markdown-extra/Tests/IntegrationTest.php ================================================ assertSame(MarkdownExtension::htmlToMarkdown('

    foo

    '), html_to_markdown('

    foo

    ')); } } ================================================ FILE: extra/markdown-extra/composer.json ================================================ { "name": "twig/markdown-extra", "type": "library", "description": "A Twig extension for Markdown", "keywords": ["twig", "html", "markdown"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "php": ">=8.1.0", "symfony/deprecation-contracts": "^2.5|^3", "twig/twig": "^3.13|^4.0" }, "require-dev": { "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/" ] } } ================================================ FILE: extra/markdown-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/string-extra/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/string-extra/.gitignore ================================================ vendor/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/string-extra/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/string-extra/README.md ================================================ String Extension ================ This package is a Twig extension that provides integration with the Symfony String component. It provides the following filters: * [`u`][1]: Wraps a text in a `UnicodeString` object to give access to [methods of the class][2]. * [`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 ================================================ FILE: extra/string-extra/StringExtension.php ================================================ slugger = $slugger ?: new AsciiSlugger(); } 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']), ]; } public function createUnicodeString(?string $text): UnicodeString { return new UnicodeString($text ?? ''); } public function createSlug(string $string, string $separator = '-', ?string $locale = null): AbstractUnicodeString { 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)); } } } ================================================ FILE: extra/string-extra/Tests/Fixtures/plural-invalid-language.test ================================================ --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. ================================================ FILE: extra/string-extra/Tests/Fixtures/plural.test ================================================ --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 ================================================ FILE: extra/string-extra/Tests/Fixtures/plural_es.test ================================================ --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 ================================================ FILE: extra/string-extra/Tests/Fixtures/singular-invalid-language.test ================================================ --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. ================================================ FILE: extra/string-extra/Tests/Fixtures/singular.test ================================================ --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 ================================================ FILE: extra/string-extra/Tests/Fixtures/singular_es.test ================================================ --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 ================================================ FILE: extra/string-extra/Tests/Fixtures/slug.test ================================================ --TEST-- "slug" filter --TEMPLATE-- {{ 'Wôrķšƥáçè ~~sèťtïñğš~~'|slug }} {{ 'Wôrķšƥáçè ~~sèťtïñğš~~'|slug('/') }} --DATA-- return [] --EXPECT-- Workspace-settings Workspace/settings ================================================ FILE: extra/string-extra/Tests/Fixtures/string.test ================================================ --TEST-- "u" filter --TEMPLATE-- {{ 'Symfony String + Twig = <3'|u|raw }} {{ 'Symfony String + Twig = <3'|u.wordwrap(5).upper|raw }} {{ 'SymfonyStringWithTwig'|u.snake }} {{ 'symfony_string with twig'|u.camel.title }} {{ 'Lorem ipsum'|u.truncate(8, '...') }} --DATA-- return [] --EXPECT-- Symfony String + Twig = <3 SYMFONY STRING + TWIG = <3 symfony_string_with_twig SymfonyStringWithTwig Lorem... ================================================ FILE: extra/string-extra/Tests/IntegrationTest.php ================================================ =8.1.0", "symfony/string": "^5.4|^6.4|^7.0|^8.0", "symfony/translation-contracts": "^1.1|^2|^3", "twig/twig": "^3.13|^4.0" }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0" }, "autoload": { "psr-4" : { "Twig\\Extra\\String\\" : "" }, "exclude-from-classmap": [ "/Tests/" ] } } ================================================ FILE: extra/string-extra/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: extra/twig-extra-bundle/.gitattributes ================================================ /Tests export-ignore /.git* export-ignore /phpunit.xml.dist export-ignore ================================================ FILE: extra/twig-extra-bundle/.gitignore ================================================ vendor/ var/ composer.lock phpunit.xml .phpunit.result.cache ================================================ FILE: extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php ================================================ 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']]) ; } } } 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']]) ; } } } ================================================ FILE: extra/twig-extra-bundle/DependencyInjection/Configuration.php ================================================ getRootNode(); foreach (Extensions::getClasses() as $name => $class) { $rootNode ->children() ->arrayNode($name) ->{class_exists($class) ? 'canBeDisabled' : 'canBeEnabled'}() ->end() ->end() ; } $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(); } } ================================================ FILE: extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php ================================================ 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 { 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); $config = $this->processConfiguration($configuration, $configs); if ($container->getParameter('kernel.debug')) { $loader->load('suggestor.php'); } foreach (array_keys(Extensions::getClasses()) as $extension) { if ($this->isConfigEnabled($container, $config[$extension])) { $loader->load($extension.'.php'); 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'] ?? []); } } } } } } ================================================ FILE: extra/twig-extra-bundle/Extensions.php ================================================ [ 'name' => 'cache', 'class' => CacheExtension::class, 'class_name' => 'CacheExtension', 'package' => 'twig/cache-extra', 'filters' => [], 'functions' => [], 'tags' => ['cache'], ], 'html' => [ 'name' => 'html', 'class' => HtmlExtension::class, 'class_name' => 'HtmlExtension', 'package' => 'twig/html-extra', 'filters' => ['data_uri'], 'functions' => ['html_classes'], 'tags' => [], ], 'markdown' => [ 'name' => 'markdown', 'class' => MarkdownExtension::class, 'class_name' => 'MarkdownExtension', 'package' => 'twig/markdown-extra', 'filters' => ['html_to_markdown', 'markdown_to_html'], 'functions' => [], 'tags' => [], ], 'intl' => [ 'name' => 'intl', 'class' => IntlExtension::class, 'class_name' => 'IntlExtension', 'package' => 'twig/intl-extra', 'filters' => ['country_name', 'currency_name', 'currency_symbol', 'language_name', 'locale_name', 'timezone_name', 'format_currency', 'format_number', 'format_decimal_number', 'format_currency_number', 'format_percent_number', 'format_scientific_number', 'format_spellout_number', 'format_ordinal_number', 'format_duration_number', 'format_date', 'format_datetime', 'format_time', ], 'functions' => ['country_timezones'], 'tags' => [], ], 'cssinliner' => [ 'name' => 'cssinliner', 'class' => CssInlinerExtension::class, 'class_name' => 'CssInlinerExtension', 'package' => 'twig/cssinliner-extra', 'filters' => ['inline_css'], 'functions' => [], 'tags' => [], ], 'inky' => [ 'name' => 'inky', 'class' => InkyExtension::class, 'class_name' => 'InkyExtension', 'package' => 'twig/inky-extra', 'filters' => ['inky_to_html'], 'functions' => [], 'tags' => [], ], 'string' => [ 'name' => 'string', 'class' => StringExtension::class, 'class_name' => 'StringExtension', 'package' => 'twig/string-extra', 'filters' => ['u'], 'functions' => [], 'tags' => [], ], ]; public static function getClasses(): array { return array_column(self::EXTENSIONS, 'class', 'name'); } public static function getFilter(string $name): array { foreach (self::EXTENSIONS as $extension) { if (\in_array($name, $extension['filters'], true)) { return [$extension['class_name'], $extension['package']]; } } return []; } public static function getFunction(string $name): array { foreach (self::EXTENSIONS as $extension) { if (\in_array($name, $extension['functions'], true)) { return [$extension['class_name'], $extension['package']]; } } return []; } public static function getTag(string $name): array { foreach (self::EXTENSIONS as $extension) { if (\in_array($name, $extension['tags'], true)) { return [$extension['class_name'], $extension['package']]; } } return []; } } ================================================ FILE: extra/twig-extra-bundle/LICENSE ================================================ 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: extra/twig-extra-bundle/LeagueCommonMarkConverterFactory.php ================================================ extensions = $extensions; $this->config = $config; } public function __invoke(): CommonMarkConverter { $converter = new CommonMarkConverter($this->config); foreach ($this->extensions as $extension) { $converter->getEnvironment()->addExtension($extension); } return $converter; } } ================================================ FILE: extra/twig-extra-bundle/MissingExtensionSuggestor.php ================================================ services() ->set('twig.extension.cache', CacheExtension::class) ->tag('twig.extension') ->set('twig.runtime.cache', CacheRuntime::class) ->args([ $service('twig.cache'), ]) ->tag('twig.runtime') ->set('twig.cache', TagAwareAdapter::class) ->args([ $service('.twig.cache.inner'), ]) ->set('.twig.cache.inner') ->parent('cache.app') ->tag('cache.pool', ['name' => 'twig.cache']) ->alias(TagAwareCacheInterface::class.' $twigCache', 'twig.cache') ->alias(CacheInterface::class.' $twigCache', '.twig.cache.inner') ->alias(CacheItemPoolInterface::class.' $twigCache', '.twig.cache.inner') ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/cssinliner.php ================================================ services() ->set('twig.extension.cssinliner', CssInlinerExtension::class) ->tag('twig.extension') ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/html.php ================================================ services() ->set('twig.extension.html', HtmlExtension::class) ->tag('twig.extension') ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/inky.php ================================================ services() ->set('twig.extension.inky', InkyExtension::class) ->tag('twig.extension') ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/intl.php ================================================ services() ->set('twig.extension.intl', IntlExtension::class) ->tag('twig.extension') ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/markdown.php ================================================ services() ->set('twig.extension.markdown', MarkdownExtension::class) ->tag('twig.extension') ->set('twig.runtime.markdown', MarkdownRuntime::class) ->args([ $service('twig.markdown.default'), ]) ->tag('twig.runtime') ->set('twig.markdown.default', DefaultMarkdown::class) ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/markdown_league.php ================================================ services() ->set('twig.markdown.league_common_mark_converter_factory', LeagueCommonMarkConverterFactory::class) ->args([new TaggedIteratorArgument('twig.markdown.league_extension')]) ->set('twig.markdown.league_common_mark_converter', CommonMarkConverter::class) ->factory($service('twig.markdown.league_common_mark_converter_factory')) ->set('twig.markdown.default', LeagueMarkdown::class) ->args([$service('twig.markdown.league_common_mark_converter')]) ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/string.php ================================================ services() ->set('twig.extension.string', StringExtension::class) ->tag('twig.extension') ; }; ================================================ FILE: extra/twig-extra-bundle/Resources/config/suggestor.php ================================================ services() ->set('twig.missing_extension_suggestor', MissingExtensionSuggestor::class) ; }; ================================================ FILE: extra/twig-extra-bundle/Tests/DependencyInjection/TwigExtraExtensionTest.php ================================================ false, ])); $container->registerExtension(new TwigExtraExtension()); $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([]); $container->compile(); foreach (Extensions::getClasses() as $name => $class) { $this->assertEquals($class, $container->getDefinition('twig.extension.'.$name)->getClass()); } $this->assertSame(LeagueMarkdown::class, $container->getDefinition('twig.markdown.default')->getClass()); $commonmarkConverterFactory = $container->getDefinition('twig.markdown.league_common_mark_converter')->getFactory(); $this->assertSame('twig.markdown.league_common_mark_converter_factory', (string) $commonmarkConverterFactory[0]); $this->assertSame('__invoke', $commonmarkConverterFactory[1]); } } ================================================ FILE: extra/twig-extra-bundle/Tests/Fixture/Kernel.php ================================================ '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', ]); $c->register(StrikethroughExtension::class)->addTag('twig.markdown.league_extension'); } protected function configureRoutes($routes): void { } } ================================================ FILE: extra/twig-extra-bundle/Tests/Fixture/views/markdown_to_html.html.twig ================================================ {% apply markdown_to_html %} # Hello ~~World~~ {% endapply %} ================================================ FILE: extra/twig-extra-bundle/Tests/IntegrationTest.php ================================================ get('twig')->render('markdown_to_html.html.twig'); $this->assertStringContainsString('

    Hello World

    ', $rendered); } } ================================================ FILE: extra/twig-extra-bundle/TwigExtraBundle.php ================================================ addCompilerPass(new MissingExtensionSuggestorPass()); } } } else { class TwigExtraBundle extends Bundle { /** @return void */ public function build(ContainerBuilder $container) { parent::build($container); $container->addCompilerPass(new MissingExtensionSuggestorPass()); } } } ================================================ FILE: extra/twig-extra-bundle/composer.json ================================================ { "name": "twig/extra-bundle", "type": "symfony-bundle", "description": "A Symfony bundle for extra Twig extensions", "keywords": ["twig", "extra", "bundle"], "homepage": "https://twig.symfony.com", "license": "MIT", "minimum-stability": "dev", "authors": [ { "name": "Fabien Potencier", "email": "fabien@symfony.com", "homepage": "http://fabien.potencier.org", "role": "Lead Developer" } ], "require": { "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": "^2.7", "symfony/phpunit-bridge": "^6.4|^7.0", "twig/cache-extra": "^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/" ] } } ================================================ FILE: extra/twig-extra-bundle/phpunit.xml.dist ================================================ ./ ./Tests ./vendor ./Tests/ ================================================ FILE: phpstan-baseline.neon ================================================ 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 ================================================ FILE: phpstan.neon.dist ================================================ includes: - phpstan-baseline.neon parameters: level: 3 paths: - src excludePaths: - src/Test ================================================ FILE: phpunit.xml.dist ================================================ ./tests/ ================================================ FILE: splitsh.json ================================================ { "subtrees": { "twig-extra-bundle": "extra/twig-extra-bundle", "cache-extra": "extra/cache-extra", "cssinliner-extra": "extra/cssinliner-extra", "html-extra": "extra/html-extra", "inky-extra": "extra/inky-extra", "intl-extra": "extra/intl-extra", "markdown-extra": "extra/markdown-extra", "string-extra": "extra/string-extra" }, "defaults": { "git_constraint": "<1.8.2" } } ================================================ FILE: src/AbstractTwigCallable.php ================================================ */ 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); } } ================================================ FILE: src/Attribute/AsTwigFilter.php ================================================ */ interface CacheInterface { /** * Generates a cache key for the given template class name. */ public function generateKey(string $name, string $className): string; /** * Writes the compiled template to cache. * * @param string $content The template representation as a PHP class */ public function write(string $key, string $content): void; /** * Loads a template from the cache. */ public function load(string $key): void; /** * Returns the modification timestamp of a key. */ public function getTimestamp(string $key): int; } ================================================ FILE: src/Cache/ChainCache.php ================================================ */ 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)); } } ================================================ FILE: src/Cache/FilesystemCache.php ================================================ */ class FilesystemCache implements CacheInterface, RemovableCacheInterface { public const FORCE_BYTECODE_INVALIDATION = 1; private $directory; private $options; public function __construct(string $directory, int $options = 0) { $this->directory = rtrim($directory, '\/').'/'; $this->options = $options; } public function generateKey(string $name, string $className): string { $hash = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $className); return $this->directory.$hash[0].$hash[1].'/'.$hash.'.php'; } public function load(string $key): void { if (is_file($key)) { @include_once $key; } } public function write(string $key, string $content): void { $dir = \dirname($key); if (!is_dir($dir)) { 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)); } } } elseif (!is_writable($dir)) { throw new \RuntimeException(\sprintf('Unable to write in the cache directory (%s).', $dir)); } $tmpFile = tempnam($dir, basename($key)); if (false !== @file_put_contents($tmpFile, $content) && @rename($tmpFile, $key)) { @chmod($key, 0666 & ~umask()); 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)) { @opcache_invalidate($key, true); } elseif (\function_exists('apc_compile_file')) { apc_compile_file($key); } } return; } 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 { if (!is_file($key)) { return 0; } return (int) @filemtime($key); } } ================================================ FILE: src/Cache/NullCache.php ================================================ */ final class NullCache implements CacheInterface, RemovableCacheInterface { public function generateKey(string $name, string $className): string { return ''; } public function write(string $key, string $content): void { } public function load(string $key): void { } public function getTimestamp(string $key): int { return 0; } public function remove(string $name, string $cls): void { } } ================================================ FILE: src/Cache/ReadOnlyFilesystemCache.php ================================================ */ class ReadOnlyFilesystemCache extends FilesystemCache { public function write(string $key, string $content): void { // Do nothing with the content, it's a read-only filesystem. } } ================================================ FILE: src/Cache/RemovableCacheInterface.php ================================================ */ interface RemovableCacheInterface { public function remove(string $name, string $cls): void; } ================================================ FILE: src/Compiler.php ================================================ */ class Compiler { private $lastLine; private $source; private $indentation; private $debugInfo = []; private $sourceOffset; private $sourceLine; private $varNameSalt = 0; private $didUseEcho = false; private $didUseEchoStack = []; public function __construct( private Environment $env, ) { } public function getEnvironment(): Environment { return $this->env; } public function getSource(): string { return $this->source; } /** * @return $this */ public function reset(int $indentation = 0) { $this->lastLine = null; $this->source = ''; $this->debugInfo = []; $this->sourceOffset = 0; // source code starts at 1 (as we then increment it when we encounter new lines) $this->sourceLine = 1; $this->indentation = $indentation; $this->varNameSalt = 0; 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 (!$raw) { $this->source .= str_repeat(' ', $this->indentation * 4); } $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); } } /** * Adds a raw string to the compiled code. * * @return $this */ public function raw(string $string) { $this->checkForEcho($string); $this->source .= $string; return $this; } /** * Writes a string to the compiled code by adding indentation. * * @return $this */ public function write(...$strings) { foreach ($strings as $string) { $this->checkForEcho($string); $this->source .= str_repeat(' ', $this->indentation * 4).$string; } return $this; } /** * Adds a quoted string to the compiled code. * * @return $this */ public function string(string $value) { $this->source .= \sprintf('"%s"', addcslashes($value, "\0\t\"\$\\")); return $this; } /** * Returns a PHP representation of a given value. * * @return $this */ public function repr($value) { if (\is_int($value) || \is_float($value)) { if (false !== $locale = setlocale(\LC_NUMERIC, '0')) { setlocale(\LC_NUMERIC, 'C'); } $this->raw(var_export($value, true)); if (false !== $locale) { setlocale(\LC_NUMERIC, $locale); } } elseif (null === $value) { $this->raw('null'); } elseif (\is_bool($value)) { $this->raw($value ? 'true' : 'false'); } elseif (\is_array($value)) { $this->raw('['); $first = true; foreach ($value as $key => $v) { if (!$first) { $this->raw(', '); } $first = false; $this->repr($key); $this->raw(' => '); $this->repr($v); } $this->raw(']'); } else { $this->string($value); } return $this; } /** * @return $this */ public function addDebugInfo(Node $node) { if ($node->getTemplateLine() != $this->lastLine) { $this->write(\sprintf("// line %d\n", $node->getTemplateLine())); $this->sourceLine += substr_count($this->source, "\n", $this->sourceOffset); $this->sourceOffset = \strlen($this->source); $this->debugInfo[$this->sourceLine] = $node->getTemplateLine(); $this->lastLine = $node->getTemplateLine(); } return $this; } public function getDebugInfo(): array { ksort($this->debugInfo); return $this->debugInfo; } /** * @return $this */ public function indent(int $step = 1) { $this->indentation += $step; return $this; } /** * @return $this * * @throws \LogicException When trying to outdent too much so the indentation would become negative */ public function outdent(int $step = 1) { // can't outdent by more steps than the current indentation level if ($this->indentation < $step) { throw new \LogicException('Unable to call outdent() as the indentation would become negative.'); } $this->indentation -= $step; return $this; } public function getVarName(): string { 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; } } ================================================ FILE: src/DeprecatedCallableInfo.php ================================================ */ 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); } } ================================================ FILE: src/Environment.php ================================================ */ class Environment { public const VERSION = '3.24.1-DEV'; public const VERSION_ID = 32401; public const MAJOR_VERSION = 3; public const MINOR_VERSION = 24; public const RELEASE_VERSION = 1; public const EXTRA_VERSION = 'DEV'; private $charset; private $loader; private $debug; private $autoReload; private $cache; private $lexer; private $parser; private $compiler; /** @var array */ private $globals = []; private $resolvedGlobals; private $loadedTemplates; private $strictVariables; private $originalCache; private $extensionSet; private $runtimeLoaders = []; private $runtimes = []; private $optionsHash; /** @var bool */ private $useYield; private $defaultRuntimeLoader; private array $hotCache = []; /** * Constructor. * * Available options: * * * debug: When set to true, it automatically set "auto_reload" to true as * well (default to false). * * * charset: The charset used by the templates (default to UTF-8). * * * cache: An absolute path where to store the compiled templates, * a \Twig\Cache\CacheInterface implementation, * or false to disable compilation cache (default). * * * auto_reload: Whether to reload the template if the original source changed. * If you don't provide the auto_reload option, it will be * determined automatically based on the debug value. * * * strict_variables: Whether to ignore invalid variables in templates * (default to false). * * * autoescape: Whether to enable auto-escaping (default to html): * * false: disable auto-escaping * * html, js: set the autoescaping to one of the supported strategies * * name: set the autoescaping strategy based on the template name extension * * PHP callback: a PHP callback that returns an escaping strategy based on the template "name" * * * 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, array $options = []) { $this->setLoader($loader); $options = array_merge([ 'debug' => false, 'charset' => 'UTF-8', 'strict_variables' => false, 'autoescape' => 'html', '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()); $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() { $this->debug = true; $this->updateOptionsHash(); } /** * Disables debugging mode. * * @return void */ public function disableDebug() { $this->debug = false; $this->updateOptionsHash(); } /** * Checks if debug mode is enabled. * * @return bool true if debug mode is enabled, false otherwise */ public function isDebug() { return $this->debug; } /** * Enables the auto_reload option. * * @return void */ public function enableAutoReload() { $this->autoReload = true; } /** * Disables the auto_reload option. * * @return void */ public function disableAutoReload() { $this->autoReload = false; } /** * Checks if the auto_reload option is enabled. * * @return bool true if auto_reload is enabled, false otherwise */ public function isAutoReload() { return $this->autoReload; } /** * Enables the strict_variables option. * * @return void */ public function enableStrictVariables() { $this->strictVariables = true; $this->updateOptionsHash(); } /** * Disables the strict_variables option. * * @return void */ public function disableStrictVariables() { $this->strictVariables = false; $this->updateOptionsHash(); } /** * Checks if the strict_variables option is enabled. * * @return bool true if strict_variables is enabled, false otherwise */ 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. * * @param bool $original Whether to return the original cache option or the real cache instance * * @return CacheInterface|string|false A Twig\Cache\CacheInterface implementation, * an absolute path to the compiled templates, * or false to disable cache */ public function getCache($original = true) { return $original ? $this->originalCache : $this->cache; } /** * Sets the current cache implementation. * * @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) { if (\is_string($cache)) { $this->originalCache = $cache; $this->cache = new FilesystemCache($cache, $this->autoReload ? FilesystemCache::FORCE_BYTECODE_INVALIDATION : 0); } elseif (false === $cache) { $this->originalCache = $cache; $this->cache = new NullCache(); } elseif ($cache instanceof CacheInterface) { $this->originalCache = $this->cache = $cache; } else { throw new \LogicException('Cache can only be a string, false, or a \Twig\Cache\CacheInterface implementation.'); } } /** * Gets the template class associated with the given string. * * The generated template class is based on the following parameters: * * * The cache key for the given template; * * The currently enabled extensions; * * PHP version; * * Twig version; * * Options with what environment was created. * * @param string $name The name for which to calculate the template class name * @param int|null $index The index if it is an embedded template * * @internal */ public function getTemplateClass(string $name, ?int $index = null): string { $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash; return '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index); } /** * Renders a template. * * @param string|TemplateWrapper $name The template name * * @throws LoaderError When the template cannot be found * @throws SyntaxError When an error occurred during compilation * @throws RuntimeError When an error occurred during rendering */ public function render($name, array $context = []): string { return $this->load($name)->render($context); } /** * Displays a template. * * @param string|TemplateWrapper $name The template name * * @throws LoaderError When the template cannot be found * @throws SyntaxError When an error occurred during compilation * @throws RuntimeError When an error occurred during rendering */ public function display($name, array $context = []): void { $this->load($name)->display($context); } /** * Loads a template. * * @param string|TemplateWrapper $name The template name * * @throws LoaderError When the template cannot be found * @throws RuntimeError When a previously generated cache is corrupted * @throws SyntaxError When an error occurred during compilation */ 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)); } /** * Loads a template internal representation. * * This method is for internal use only and should never be called * directly. * * @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 * @throws SyntaxError When an error occurred during compilation * * @internal */ public function loadTemplate(string $cls, string $name, ?int $index = null): Template { $mainCls = $cls; if (null !== $index) { $cls .= '___'.$index; } if (isset($this->loadedTemplates[$cls])) { return $this->loadedTemplates[$cls]; } if (!class_exists($cls, false)) { $key = $this->cache->generateKey($name, $mainCls); if (!$this->isAutoReload() || $this->isTemplateFresh($name, $this->cache->getTimestamp($key))) { $this->cache->load($key); } if (!class_exists($cls, false)) { $source = $this->getLoader()->getSourceContext($name); $content = $this->compileSource($source); 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, * $this->cache is implemented as a no-op or we have a race condition * where the cache was cleared between the above calls to write to and load from * the cache. */ eval('?>'.$content); } 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); } } } $this->extensionSet->initRuntime(); return $this->loadedTemplates[$cls] = new $cls($this); } /** * Creates a template from source. * * This method should not be used as a generic way to load templates. * * @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 { $hash = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $template, false); if (null !== $name) { $name = \sprintf('%s (string template %s)', $name, $hash); } else { $name = \sprintf('__string_template__%s', $hash); } $loader = new ChainLoader([ new ArrayLoader([$name => $template]), $current = $this->getLoader(), ]); $this->setLoader($loader); try { return new TemplateWrapper($this, $this->loadTemplate($this->getTemplateClass($name), $name)); } finally { $this->setLoader($current); } } /** * Returns true if the template is still fresh. * * Besides checking the loader for freshness information, * this method also checks if the enabled extensions have * not changed. * * @param int $time The last modification time of the cached template */ public function isTemplateFresh(string $name, int $time): bool { return $this->extensionSet->getLastModified() <= $time && $this->getLoader()->isFresh($name, $time); } /** * Tries to load a template consecutively from an array. * * 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 * * @throws LoaderError When none of the templates can be found * @throws SyntaxError When an error occurred during compilation */ public function resolveTemplate($names): TemplateWrapper { if (!\is_array($names)) { return $this->load($names); } $count = \count($names); foreach ($names as $name) { if ($name instanceof Template) { 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; } if (1 !== $count && !$this->getLoader()->exists($name)) { continue; } return $this->load($name); } 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; } /** * @throws SyntaxError When the code is syntactically wrong */ public function tokenize(Source $source): TokenStream { if (null === $this->lexer) { $this->lexer = new Lexer($this); } return $this->lexer->tokenize($source); } /** * @return void */ public function setParser(Parser $parser) { $this->parser = $parser; } /** * Converts a token stream to a node tree. * * @throws SyntaxError When the token stream is syntactically or semantically wrong */ public function parse(TokenStream $stream): ModuleNode { if (null === $this->parser) { $this->parser = new Parser($this); } return $this->parser->parse($stream); } /** * @return void */ public function setCompiler(Compiler $compiler) { $this->compiler = $compiler; } /** * Compiles a node and returns the PHP code. */ public function compile(Node $node): string { if (null === $this->compiler) { $this->compiler = new Compiler($this); } return $this->compiler->compile($node)->getSource(); } /** * Compiles a template source code. * * @throws SyntaxError When there was an error during tokenizing, parsing or compiling */ public function compileSource(Source $source): string { try { return $this->compile($this->parse($this->tokenize($source))); } catch (Error $e) { $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); } } /** * @return void */ public function setLoader(LoaderInterface $loader) { $this->loader = $loader; } public function getLoader(): LoaderInterface { return $this->loader; } /** * @return void */ public function setCharset(string $charset) { if ('UTF8' === $charset = strtoupper($charset ?: '')) { // iconv on Windows requires "UTF-8" instead of "UTF8" $charset = 'UTF-8'; } $this->charset = $charset; } public function getCharset(): string { return $this->charset; } public function hasExtension(string $class): bool { return $this->extensionSet->hasExtension($class); } /** * @return void */ public function addRuntimeLoader(RuntimeLoaderInterface $loader) { $this->runtimeLoaders[] = $loader; } /** * @template TExtension of ExtensionInterface * * @param class-string $class * * @return TExtension */ public function getExtension(string $class): ExtensionInterface { return $this->extensionSet->getExtension($class); } /** * Returns the runtime implementation of a Twig element (filter/function/tag/test). * * @template TRuntime of object * * @param class-string $class A runtime class name * * @return TRuntime The runtime implementation * * @throws RuntimeError When the template cannot be found */ public function getRuntime(string $class) { if (isset($this->runtimes[$class])) { return $this->runtimes[$class]; } foreach ($this->runtimeLoaders as $loader) { if (null !== $runtime = $loader->load($class)) { return $this->runtimes[$class] = $runtime; } } 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); $this->updateOptionsHash(); } /** * @param ExtensionInterface[] $extensions An array of extensions * * @return void */ public function setExtensions(array $extensions) { $this->extensionSet->setExtensions($extensions); $this->updateOptionsHash(); } /** * @return ExtensionInterface[] An array of extensions (keys are for internal usage only and should not be relied on) */ public function getExtensions(): array { return $this->extensionSet->getExtensions(); } /** * @return void */ public function addTokenParser(TokenParserInterface $parser) { $this->extensionSet->addTokenParser($parser); } /** * @return TokenParserInterface[] * * @internal */ public function getTokenParsers(): array { return $this->extensionSet->getTokenParsers(); } /** * @internal */ 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); } /** * @return NodeVisitorInterface[] * * @internal */ public function getNodeVisitors(): array { return $this->extensionSet->getNodeVisitors(); } /** * @return void */ public function addFilter(TwigFilter $filter) { $this->extensionSet->addFilter($filter); } /** * @internal */ 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); } /** * Gets the registered Filters. * * Be warned that this method cannot return filters defined with registerUndefinedFilterCallback. * * @return TwigFilter[] * * @see registerUndefinedFilterCallback * * @internal */ public function getFilters(): array { return $this->extensionSet->getFilters(); } /** * @return void */ public function addTest(TwigTest $test) { $this->extensionSet->addTest($test); } /** * @return TwigTest[] * * @internal */ public function getTests(): array { return $this->extensionSet->getTests(); } /** * @internal */ 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); } /** * @internal */ 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); } /** * Gets registered functions. * * Be warned that this method cannot return functions defined with registerUndefinedFunctionCallback. * * @return TwigFunction[] * * @see registerUndefinedFunctionCallback * * @internal */ public function getFunctions(): array { return $this->extensionSet->getFunctions(); } /** * Registers a Global. * * New globals can be added before compiling or rendering a template; * 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)); } if (null !== $this->resolvedGlobals) { $this->resolvedGlobals[$name] = $value; } else { $this->globals[$name] = $value; } } /** * @return array */ public function getGlobals(): array { if ($this->extensionSet->isInitialized()) { if (null === $this->resolvedGlobals) { $this->resolvedGlobals = array_merge($this->extensionSet->getGlobals(), $this->globals); } return $this->resolvedGlobals; } return array_merge($this->extensionSet->getGlobals(), $this->globals); } public function resetGlobals(): void { $this->resolvedGlobals = null; $this->extensionSet->resetGlobals(); } /** * @deprecated since Twig 3.14 */ public function mergeGlobals(array $context): array { trigger_deprecation('twig/twig', '3.14', 'The "%s" method is deprecated.', __METHOD__); return $context + $this->getGlobals(); } /** * @internal */ public function getExpressionParsers(): ExpressionParsers { return $this->extensionSet->getExpressionParsers(); } private function updateOptionsHash(): void { $this->optionsHash = implode(':', [ $this->extensionSet->getSignature(), \PHP_MAJOR_VERSION, \PHP_MINOR_VERSION, self::VERSION, (int) $this->debug, (int) $this->strictVariables, $this->useYield ? '1' : '0', ]); } } ================================================ FILE: src/Error/Error.php ================================================ */ class Error extends \Exception { private $lineno; private $rawMessage; private ?Source $source; private string $phpFile; private int $phpLine; /** * Constructor. * * By default, automatic guessing is enabled. * * @param string $message The error message * @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, ?\Throwable $previous = null) { parent::__construct('', 0, $previous); $this->phpFile = $this->getFile(); $this->phpLine = $this->getLine(); $this->lineno = $lineno; $this->source = $source; $this->rawMessage = $message; $this->updateRepr(); } public function getRawMessage(): string { return $this->rawMessage; } public function getTemplateLine(): int { return $this->lineno; } public function setTemplateLine(int $lineno): void { $this->lineno = $lineno; $this->updateRepr(); } public function getSourceContext(): ?Source { return $this->source; } public function setSourceContext(?Source $source = null): void { $this->source = $source; $this->updateRepr(); } public function guess(): void { if ($this->lineno > -1) { return; } $this->guessTemplateInfo(); $this->updateRepr(); } public function appendMessage($rawMessage): void { $this->rawMessage .= $rawMessage; $this->updateRepr(); } private function updateRepr(): void { 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 { $this->line = -1; } } $this->message = $this->rawMessage; $last = substr($this->message, -1); if ($punctuation = '.' === $last || '?' === $last ? $last : '') { $this->message = substr($this->message, 0, -1); } if ($this->source && $this->source->getName()) { $this->message .= \sprintf(' in "%s"', $this->source->getName()); } if ($this->lineno > 0) { $this->message .= \sprintf(' at line %d', $this->lineno); } if ($punctuation) { $this->message .= $punctuation; } } private function guessTemplateInfo(): void { // $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 && $this->source->getName() === $trace['object']->getTemplateName()) { $template = $trace['object']; break; } } if (null === $template) { return; // Impossible to guess the info as the template was not found in the backtrace } $r = new \ReflectionObject($template); $file = $r->getFileName(); $exceptions = [$e = $this]; while ($e = $e->getPrevious()) { $exceptions[] = $e; } while ($e = array_pop($exceptions)) { $traces = $e->getTrace(); 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; } foreach ($template->getDebugInfo() as $codeLine => $templateLine) { if ($codeLine <= $trace['line']) { // update template line $this->lineno = $templateLine; return; } } } } } } ================================================ FILE: src/Error/LoaderError.php ================================================ */ class LoaderError extends Error { } ================================================ FILE: src/Error/RuntimeError.php ================================================ */ class RuntimeError extends Error { } ================================================ FILE: src/Error/SyntaxError.php ================================================ */ class SyntaxError extends Error { /** * Tweaks the error message to include suggestions. * * @param string $name The original name of the item that does not exist * @param array $items An array of possible items */ public function addSuggestions(string $name, array $items): void { $alternatives = []; foreach ($items as $item) { $lev = levenshtein($name, $item); if ($lev <= \strlen($name) / 3 || str_contains($item, $name)) { $alternatives[$item] = $lev; } } if (!$alternatives) { return; } asort($alternatives); $this->appendMessage(\sprintf(' Did you mean "%s"?', implode('", "', array_keys($alternatives)))); } } ================================================ FILE: src/ExpressionParser/AbstractExpressionParser.php ================================================ value, $this->getName()); } public function getPrecedenceChange(): ?PrecedenceChange { return null; } public function getAliases(): array { return []; } public function getOperatorTokens(): array { return [$this->getName(), ...$this->getAliases()]; } } ================================================ FILE: src/ExpressionParser/ExpressionParserDescriptionInterface.php ================================================ getOperatorTokens() Returns the operator token strings that this expression parser handles. * These are the strings that should be recognized as operator tokens by the Lexer, * and used to look up the parser in the registry. * For most parsers, this returns the name and aliases. Parsers that don't handle * operator tokens (like LiteralExpressionParser) should return an empty array. * This method will be added to the interface in Twig 4.0. */ interface ExpressionParserInterface { public function __toString(): string; public function getName(): string; public function getPrecedence(): int; public function getPrecedenceChange(): ?PrecedenceChange; /** * @return array */ public function getAliases(): array; } ================================================ FILE: src/ExpressionParser/ExpressionParserType.php ================================================ * * @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->parsersByClass[$parser::class] = $parser; foreach (self::getOperatorTokensFor($parser) as $token) { $this->parsersByName[$interface][$token] = $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 { $seen = []; foreach ($this->parsersByName as $parsers) { foreach ($parsers as $parser) { $id = spl_object_id($parser); if (!isset($seen[$id])) { $seen[$id] = true; yield $parser; } } } foreach ($this->parsersByClass as $parser) { $id = spl_object_id($parser); if (!isset($seen[$id])) { $seen[$id] = true; yield $parser; } } } /** * @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; } /** * @internal * * @return array */ public static function getOperatorTokensFor(ExpressionParserInterface $parser): array { if (method_exists($parser, 'getOperatorTokens')) { return $parser->getOperatorTokens(); } trigger_deprecation('twig/twig', '3.24', 'Not implementing the "getOperatorTokens()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $parser::class, ExpressionParserInterface::class); return [$parser->getName(), ...$parser->getAliases()]; } } ================================================ FILE: src/ExpressionParser/Infix/ArgumentsTrait.php ================================================ 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 ($value instanceof SetBinary) { $name = $value->getNode('left')->getAttribute('name'); $value = $value->getNode('right'); } elseif (($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); } } ================================================ FILE: src/ExpressionParser/Infix/ArrowExpressionParser.php ================================================ 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; } } ================================================ FILE: src/ExpressionParser/Infix/AssignmentExpressionParser.php ================================================ getLine(), $parser->getStream()->getSourceContext()); } $right = $parser->parseExpression(InfixAssociativity::Left === $this->getAssociativity() ? $this->getPrecedence() + 1 : $this->getPrecedence()); $right = match ($this->getName()) { '=' => $right, default => throw new \LogicException(\sprintf('Unknown operator: %s.', $this->getName())), }; if ($left instanceof ArrayExpression) { if ($left->isSequence()) { return new SequenceDestructuringSetBinary($left, $right, $token->getLine()); } return new ObjectDestructuringSetBinary($left, $right, $token->getLine()); } return new SetBinary($left, $right, $token->getLine()); } public function getDescription(): string { return 'Assignment operator'; } } ================================================ FILE: src/ExpressionParser/Infix/BinaryOperatorExpressionParser.php ================================================ */ 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; } } ================================================ FILE: src/ExpressionParser/Infix/ConditionalTernaryExpressionParser.php ================================================ 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; } } ================================================ FILE: src/ExpressionParser/Infix/DotExpressionParser.php ================================================ getValue(); $stream = $parser->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, $nullSafe); } public function getName(): string { return '.'; } public function getAliases(): array { 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; } } ================================================ FILE: src/ExpressionParser/Infix/FilterExpressionParser.php ================================================ 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; } } ================================================ FILE: src/ExpressionParser/Infix/FunctionExpressionParser.php ================================================ 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; } } ================================================ FILE: src/ExpressionParser/Infix/IsExpressionParser.php ================================================ 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; } } ================================================ FILE: src/ExpressionParser/Infix/IsNotExpressionParser.php ================================================ getLine()); } public function getName(): string { return 'is not'; } } ================================================ FILE: src/ExpressionParser/Infix/SquareBracketExpressionParser.php ================================================ 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; } } ================================================ FILE: src/ExpressionParser/InfixAssociativity.php ================================================ */ 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; } } ================================================ FILE: src/ExpressionParser/Prefix/GroupingExpressionParser.php ================================================ getStream(); $expr = $parser->parseExpression($this->getPrecedence()); if ($stream->nextIf(Token::PUNCTUATION_TYPE, ')')) { if (!$stream->test(Token::OPERATOR_TYPE, '=>')) { return $expr->setExplicitParentheses(); } return new ListExpression([self::toAssignContextVariable($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(array_map(self::toAssignContextVariable(...), $names), $token->getLine()); } private static function toAssignContextVariable(AbstractExpression $expr): AssignContextVariable { if (!$expr instanceof ContextVariable) { throw new SyntaxError('A list must only contain variables.', $expr->getTemplateLine(), $expr->getSourceContext()); } return $expr instanceof AssignContextVariable ? $expr : new AssignContextVariable($expr->getAttribute('name'), $expr->getTemplateLine()); } public function getName(): string { return '('; } public function getDescription(): string { return 'Explicit group expression (a)'; } public function getPrecedence(): int { return 0; } } ================================================ FILE: src/ExpressionParser/Prefix/LiteralExpressionParser.php ================================================ getStream(); switch (true) { case $token->test(Token::NAME_TYPE): $stream->next(); switch ($token->getValue()) { case 'true': case 'TRUE': return new ConstantExpression(true, $token->getLine()); case 'false': case 'FALSE': return new ConstantExpression(false, $token->getLine()); case 'none': case 'NONE': case 'null': case 'NULL': return new ConstantExpression(null, $token->getLine()); default: return new ContextVariable($token->getValue(), $token->getLine()); } // no break case $token->test(Token::NUMBER_TYPE): $stream->next(); return new ConstantExpression($token->getValue(), $token->getLine()); case $token->test(Token::STRING_TYPE): case $token->test(Token::INTERPOLATION_START_TYPE): 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(); return new ContextVariable($token->getValue(), $token->getLine()); } // 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 'literal'; } public function getOperatorTokens(): array { return []; } 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) { $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; // Check for empty slots (comma with no expression) if ($stream->test(Token::PUNCTUATION_TYPE, ',')) { $node->addElement(new EmptyExpression($stream->getCurrent()->getLine())); } else { $node->addElement($parser->parseExpression()); } } $stream->expect(Token::PUNCTUATION_TYPE, ']', 'An opened sequence is not properly closed'); return $node; } private function parseMappingExpression(Parser $parser) { $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; } } ================================================ FILE: src/ExpressionParser/Prefix/UnaryOperatorExpressionParser.php ================================================ */ private string $nodeClass, private string $name, private int $precedence, private ?PrecedenceChange $precedenceChange = null, private ?string $description = null, private array $aliases = [], private ?int $operandPrecedence = null, ) { } /** * @return AbstractUnary */ public function parse(Parser $parser, Token $token): AbstractExpression { return new ($this->nodeClass)($parser->parseExpression($this->operandPrecedence ?? $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; } } ================================================ FILE: src/ExpressionParser/PrefixExpressionParserInterface.php ================================================ * * @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; 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) { if (\func_num_args() > 1) { trigger_deprecation('twig/twig', '3.15', 'Passing a second argument ($allowArrow) to "%s()" is deprecated.', __METHOD__); } trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated, use "Parser::parseExpression()" instead.', __METHOD__); return $this->parser->parseExpression((int) $precedence); } /** * @deprecated since Twig 3.21 */ public function parsePrimaryExpression() { trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); return $this->parseExpression(); } /** * @deprecated since Twig 3.21 */ public function parseStringExpression() { trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); return $this->parseExpression(); } /** * @deprecated since Twig 3.11, use parseExpression() instead */ public function parseArrayExpression() { trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); return $this->parseExpression(); } /** * @deprecated since Twig 3.21 */ public function parseSequenceExpression() { trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); return $this->parseExpression(); } /** * @deprecated since Twig 3.11, use parseExpression() instead */ public function parseHashExpression() { trigger_deprecation('twig/twig', '3.11', 'Calling "%s()" is deprecated, use "parseExpression()" instead.', __METHOD__); return $this->parseExpression(); } /** * @deprecated since Twig 3.21 */ public function parseMappingExpression() { trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); 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->test(Token::PUNCTUATION_TYPE)) { if ('.' == $token->getValue() || '[' == $token->getValue()) { $node = $this->parseSubscriptExpression($node); } elseif ('|' == $token->getValue()) { $node = $this->parseFilterExpression($node); } else { break; } } else { break; } } return $node; } /** * @deprecated since Twig 3.21 */ public function parseSubscriptExpression($node) { trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); $parsers = new \ReflectionProperty($this->parser, 'parsers'); if ('.' === $this->parser->getStream()->next()->getValue()) { return $parsers->getValue($this->parser)->getByClass(DotExpressionParser::class)->parse($this->parser, $node, $this->parser->getCurrentToken()); } 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); } /** * @deprecated since Twig 3.21 */ public function parseFilterExpressionRaw($node) { trigger_deprecation('twig/twig', '3.21', 'The "%s()" method is deprecated.', __METHOD__); $parsers = new \ReflectionProperty($this->parser, 'parsers'); $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(); } return $node; } /** * Parses arguments. * * @return Node * * @throws SyntaxError * * @deprecated since Twig 3.19 Use Twig\ExpressionParser\Infix\ArgumentsTrait::parseNamedArguments() instead */ 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::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; } } if ($definition) { $token = $stream->expect(Token::NAME_TYPE, null, 'An argument must be a name'); $value = new ContextVariable($token->getValue(), $this->parser->getCurrentToken()->getLine()); } else { 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, '=')) || (!$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 = $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, a sequence, or a mapping).', $token->getLine(), $stream->getSourceContext()); } } else { $value = $this->parseExpression(); } } if ($definition) { if (null === $name) { $name = $value->getAttribute('name'); $value = new ConstantExpression(null, $this->parser->getCurrentToken()->getLine()); $value->setAttribute('is_implicit', true); } $args[$name] = $value; } else { 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); } /** * @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) && 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, 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); } /** * @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, ',')) { break; } } 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 || $node instanceof NegUnary || $node instanceof PosUnary )) { return false; } foreach ($node as $n) { if (!$this->checkConstantExpression($n)) { return false; } } 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(); } } ================================================ FILE: src/Extension/AbstractExtension.php ================================================ 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; } } ================================================ FILE: src/Extension/AttributeExtension.php ================================================ */ 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(); } } ================================================ FILE: src/Extension/CoreExtension.php ================================================ dateFormats[0] = $format; } if (null !== $dateIntervalFormat) { $this->dateFormats[1] = $dateIntervalFormat; } } /** * Gets the default format to be used by the date filter. * * @return array The default date format string and the default date interval format string */ public function getDateFormat() { return $this->dateFormats; } /** * Sets the default timezone to be used by the date filter. * * @param \DateTimeZone|string $timezone The default timezone string or a \DateTimeZone object */ public function setTimezone($timezone) { $this->timezone = $timezone instanceof \DateTimeZone ? $timezone : new \DateTimeZone($timezone); } /** * Gets the default timezone to be used by the date filter. * * @return \DateTimeZone The default timezone currently in use */ public function getTimezone() { if (null === $this->timezone) { $this->timezone = new \DateTimeZone(date_default_timezone_get()); } return $this->timezone; } /** * Sets the default format to be used by the number_format filter. * * @param int $decimal the number of decimal places to use * @param string $decimalPoint the character(s) to use for the decimal point * @param string $thousandSep the character(s) to use for the thousands separator */ public function setNumberFormat($decimal, $decimalPoint, $thousandSep) { $this->numberFormat = [$decimal, $decimalPoint, $thousandSep]; } /** * Get the default format used by the number_format filter. * * @return array The arguments for number_format() */ public function getNumberFormat() { return $this->numberFormat; } public function getTokenParsers(): array { return [ new ApplyTokenParser(), new ForTokenParser(), new IfTokenParser(), new ExtendsTokenParser(), new IncludeTokenParser(), new BlockTokenParser(), new UseTokenParser(), new MacroTokenParser(), new ImportTokenParser(), new FromTokenParser(), new SetTokenParser(), new TypesTokenParser(), new FlushTokenParser(), new DoTokenParser(), new EmbedTokenParser(), new WithTokenParser(), new DeprecatedTokenParser(), new GuardTokenParser(), ]; } public function getFilters(): array { return [ // formatting filters 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', [self::class, 'round']), // encoding new TwigFilter('url_encode', [self::class, 'urlencode']), new TwigFilter('json_encode', 'json_encode'), new TwigFilter('convert_encoding', [self::class, 'convertEncoding']), // string filters 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', [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', [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', [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', [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]), ]; } public function getTests(): array { return [ new TwigTest('even', null, ['node_class' => EvenTest::class]), new TwigTest('odd', null, ['node_class' => OddTest::class]), new TwigTest('defined', null, ['node_class' => DefinedTest::class]), new TwigTest('same as', null, ['node_class' => SameasTest::class, 'one_mandatory_argument' => true]), new TwigTest('none', null, ['node_class' => NullTest::class]), 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', [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 []; } public function getExpressionParsers(): array { return [ // unary operators new UnaryOperatorExpressionParser(NotUnary::class, 'not', 50, new PrecedenceChange('twig/twig', '3.15', 70)), new UnaryOperatorExpressionParser(SpreadUnary::class, '...', 512, description: 'Spread operator', operandPrecedence: 0), 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(SameAsBinary::class, '===', 20), new BinaryOperatorExpressionParser(NotSameAsBinary::class, '!==', 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(), // assignment operator new AssignmentExpressionParser('='), // 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(), ]; } /** * 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.'); } if (!is_countable($values)) { // To be uncommented in 4.0 // throw new RuntimeError('The "cycle" function expects a countable sequence as first argument.'); trigger_deprecation('twig/twig', '3.12', 'Passing a non-countable sequence of values to "%s()" is deprecated.', __METHOD__); $values = self::toArray($values, false); } } if (!$count = \count($values)) { throw new RuntimeError('The "cycle" function expects a non-empty sequence.'); } return $values[$position % $count]; } /** * 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); } 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; } return mt_rand((int) $min, (int) $max); } if (\is_string($values)) { if ('' === $values) { return ''; } if ('UTF-8' !== $charset) { $values = self::convertEncoding($values, 'UTF-8', $charset); } // 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] = self::convertEncoding($value, $charset, 'UTF-8'); } } } 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)]; } /** * 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); } return $this->convertDate($date, $timezone)->format($format); } /** * 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); } /** * Returns a formatted string. * * @param string|null $format * * @internal */ public static function sprintf($format, ...$values): string { return \sprintf($format ?? '', ...$values); } /** * @internal */ public static function dateConverter(Environment $env, $date, $format = null, $timezone = null): string { return $env->getExtension(self::class)->formatDate($date, $format, $timezone); } /** * 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); } } // immutable dates if ($date instanceof \DateTimeImmutable) { return false !== $timezone ? $date->setTimezone($timezone) : $date; } if ($date instanceof \DateTime) { $date = clone $date; if (false !== $timezone) { $date->setTimezone($timezone); } return $date; } if (null === $date || 'now' === $date) { if (null === $date) { $date = 'now'; } return new \DateTime($date, false !== $timezone ? $timezone : $this->getTimezone()); } $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 (false !== $timezone) { $date->setTimezone($timezone); } return $date; } /** * 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 strtr($str ?? '', self::toArray($from)); } /** * 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 ('common' === $method) { return round($value, $precision); } if ('ceil' !== $method && 'floor' !== $method) { throw new RuntimeError('The "round" filter only supports the "common", "ceil", and "floor" methods.'); } return $method($value * 10 ** $precision) / 10 ** $precision; } /** * 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]; } if (null === $decimalPoint) { $decimalPoint = $defaults[1]; } if (null === $thousandSep) { $thousandSep = $defaults[2]; } return number_format((float) $number, $decimal, $decimalPoint, $thousandSep); } /** * 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); } return rawurlencode($url ?? ''); } /** * 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)); } return $result; } /** * 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(); } 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); } /** * 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); return \is_string($elements) ? $elements : current($elements); } /** * 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); } /** * 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; } $value = self::toArray($value, false); if (0 === \count($value)) { return ''; } if (null === $and || $and === $glue) { return implode($glue, $value); } if (1 === \count($value)) { return $value[0]; } return implode($glue, \array_slice($value, 0, -1)).$and.$value[\count($value) - 1]; } /** * 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 ?? ''; if ('' !== $delimiter) { return null === $limit ? explode($delimiter, $value) : explode($delimiter, $value, $limit); } if ($limit <= 1) { return preg_split('/(?getIterator(); } $keys = []; if ($array instanceof \Iterator) { $array->rewind(); while ($array->valid()) { $keys[] = $array->key(); $array->next(); } return $keys; } foreach ($array as $key => $item) { $keys[] = $key; } return $keys; } if (!\is_array($array)) { return []; } return array_keys($array); } /** * Invokes a callable. * * @internal */ public static function invoke(\Closure $arrow, ...$arguments): mixed { return $arrow(...$arguments); } /** * 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; } /** * 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('/(? 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; } 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; } return (float) $aTrim <=> (float) $b; } // 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; } 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; } /** * @throws RuntimeError When an invalid pattern is used * * @internal */ public static function matches(string $regexp, ?string $str): int { set_error_handler(static 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(); } } /** * 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; } $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".'), }; // 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; } /** * Inserts HTML line breaks before all newlines in a string. * * @param string|null $string * * @internal */ public static function nl2br($string): string { return nl2br($string ?? ''); } /** * Removes whitespaces between HTML tags. * * @param string|null $content * * @internal */ public static function spaceless($content): string { return trim(preg_replace('/>\s+<', $content ?? '')); } /** * @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.'); } return iconv($from, $to, $string ?? ''); } /** * 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; } if (\is_scalar($thing)) { return mb_strlen($thing, $charset); } if ($thing instanceof \Countable || \is_array($thing) || $thing instanceof \SimpleXMLElement) { return \count($thing); } if ($thing instanceof \Traversable) { return iterator_count($thing); } if ($thing instanceof \Stringable) { return mb_strlen((string) $thing, $charset); } return 1; } /** * 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); } /** * 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); } /** * 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); } /** * 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); } /** * 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); } } throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($method, \strlen('macro_')), $template->getTemplateName()), $lineno, $source); } return $template->$method(...$args); } /** * @template TSequence * * @param TSequence $seq * * @return ($seq is iterable ? TSequence : array{}) * * @internal */ public static function ensureTraversable($seq) { if (is_iterable($seq)) { return $seq; } return []; } /** * @internal */ public static function toArray($seq, $preserveKeys = true) { if ($seq instanceof \Traversable) { return iterator_to_array($seq, $preserveKeys); } if (!\is_array($seq)) { return $seq; } 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 * * @internal */ public static function testEmpty($value): bool { if ($value instanceof \Countable) { return 0 === \count($value); } if ($value instanceof \Traversable) { return !iterator_count($value); } if ($value instanceof \Stringable) { return '' === (string) $value; } return '' === $value || false === $value || null === $value || [] === $value; } /** * 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(); } if ($value instanceof \Traversable) { $value = iterator_to_array($value); } return \is_array($value) && array_is_list($value); } /** * 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(); } if ($value instanceof \Traversable) { $value = iterator_to_array($value); } return (\is_array($value) && !array_is_list($value)) || \is_object($value); } /** * 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); } if ($isSandboxed = $sandboxed && $env->hasExtension(SandboxExtension::class)) { $sandbox = $env->getExtension(SandboxExtension::class); if (!$alreadySandboxed = $sandbox->isSandboxed()) { $sandbox->enableSandbox(); } } try { $loaded = null; try { $loaded = $env->resolveTemplate($template); } catch (LoaderError $e) { if (!$ignoreMissing) { throw $e; } return ''; } if ($isSandboxed) { $loaded->unwrap()->checkSecurity(); } return $loaded->render($variables); } finally { if ($isSandboxed && !$alreadySandboxed) { $sandbox->disableSandbox(); } } } /** * 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; } return ''; } } /** * 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)); } return $enum::cases(); } /** * 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)); } if (!$cases = $enum::cases()) { throw new RuntimeError(\sprintf('"%s" is an empty enum.', $enum)); } return $cases[0]; } /** * 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; } $constant = $object::class.'::'.$constant; } if (!\defined($constant)) { if ($checkDefined) { return false; } 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)); } return $checkDefined ? true : \constant($constant); } /** * 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))); } $size = (int) ceil($size); $result = array_chunk(self::toArray($items, $preserveKeys), $size, $preserveKeys); if (null !== $fill && $result) { $last = \count($result) - 1; if ($fillCount = $size - \count($result[$last])) { for ($i = 0; $i < $fillCount; ++$i) { $result[$last][] = $fill; } } } return $result; } /** * 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; // array if (Template::METHOD_CALL !== $type) { $arrayItem = \is_bool($item) || \is_float($item) ? (int) $item : $item; 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; } } 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; } return $object[$arrayItem]; } if (Template::ARRAY_CALL === $type || !\is_object($object)) { if ($isDefinedTest) { return false; } 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); } throw new RuntimeError($message, $lineno, $source); } } $item = (string) $item; if (!\is_object($object)) { if ($isDefinedTest) { return false; } if ($ignoreStrictCheck || !$env->isStrictVariables()) { return; } 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); } if ($object instanceof Template) { throw new RuntimeError('Accessing \Twig\Template attributes is forbidden.', $lineno, $source); } // object property if (Template::METHOD_CALL !== $type) { if ($sandboxed) { try { $env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $item, $lineno, $source); } catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) { goto methodCheck; } } static $propertyCheckers = []; if ($object instanceof \Closure && '__invoke' === $item) { return $isDefinedTest ? true : $object(); } if (isset($object->$item) || ($propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item))($object, $item) ) { if ($isDefinedTest) { return true; } return $object->$item; } if ($object instanceof \DateTimeInterface && \in_array($item, ['date', 'timezone', 'timezone_type'], true)) { if ($isDefinedTest) { return true; } return ((array) $object)[$item]; } if (\defined($object::class.'::'.$item)) { if ($isDefinedTest) { return true; } return \constant($object::class.'::'.$item); } } methodCheck: static $cache = []; $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; } // 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; } $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; } 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; } if ($propertyNotAllowedError) { throw $propertyNotAllowedError; } throw $e; } } if ($isDefinedTest) { 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())) { return; } throw $e; } 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))); } if ($array instanceof \Traversable) { $array = iterator_to_array($array); } return array_column($array, $name, $index); } /** * @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))); } self::checkArrow($env, $arrow, 'filter', 'filter'); if (\is_array($array)) { return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH); } // the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow); } /** * @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))); } self::checkArrow($env, $arrow, 'find', 'filter'); foreach ($array as $k => $v) { if ($arrow($v, $k)) { return $v; } } return null; } /** * @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); } return $r; } /** * @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))); } self::checkArrow($env, $arrow, 'reduce', 'filter'); $accumulator = $initial; foreach ($array as $key => $value) { $accumulator = $arrow($accumulator, $value, $key); } return $accumulator; } /** * @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; } /** * @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; } /** * @internal */ public static function checkArrow(Environment $env, $arrow, $thing, $type) { if ($arrow instanceof \Closure) { return; } 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); } /** * @internal to be removed in Twig 4 */ public static function captureOutput(iterable $body): string { $level = ob_get_level(); ob_start(); try { foreach ($body as $data) { echo $data; } } catch (\Throwable $e) { while (ob_get_level() > $level) { ob_end_clean(); } throw $e; } return ob_get_clean(); } /** * @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()); } 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); } /** * @internal */ public static function parseBlockFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression { $fakeFunction = new TwigFunction('block', static fn ($name, $template = null) => null); $args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args); return new BlockReferenceExpression($args[0], $args[1] ?? null, $line); } /** * @internal */ public static function parseAttributeFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression { $fakeFunction = new TwigFunction('attribute', static fn ($variable, $attribute, $arguments = null) => null); $args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args); /* 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); */ return new GetAttrExpression($args[0], $args[1], $args[2] ?? null, Template::ANY_CALL, $line); } private static function getPropertyChecker(string $class, string $property): \Closure { static $classReflectors = []; $class = $classReflectors[$class] ??= new \ReflectionClass($class); if (!$class->hasProperty($property)) { static $propertyExists; return $propertyExists ??= \Closure::fromCallable('property_exists'); } $property = $class->getProperty($property); if (!$property->isPublic() || $property->isStatic()) { static $false; return $false ??= static fn () => false; } return static fn ($object) => $property->isInitialized($object); } } ================================================ FILE: src/Extension/DebugExtension.php ================================================ $isDumpOutputHtmlSafe ? ['html'] : [], 'needs_context' => true, 'needs_environment' => true, 'is_variadic' => true]), ]; } /** * @internal */ public static function dump(Environment $env, $context, ...$vars) { if (!$env->isDebug()) { return; } ob_start(); 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); } return ob_get_clean(); } } ================================================ FILE: src/Extension/EscaperExtension.php ================================================ setDefaultStrategy($defaultStrategy); } public function getTokenParsers(): array { return [new AutoEscapeTokenParser()]; } public function getNodeVisitors(): array { return [new EscaperNodeVisitor()]; } public function getFilters(): array { return [ 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(string $templateName): string $defaultStrategy An escaping strategy */ public function setDefaultStrategy($defaultStrategy): void { if ('name' === $defaultStrategy) { $defaultStrategy = [FileExtensionEscapingStrategy::class, 'guess']; } $this->defaultStrategy = $defaultStrategy; } /** * Gets the default strategy to use when not defined by the user. * * @param string $name The template name * * @return string|false The default strategy to use for the template */ public function getDefaultStrategy(string $name) { // disable string callables to avoid calling a function named html or js, // or any other upcoming escaping strategy if (!\is_string($this->defaultStrategy) && false !== $this->defaultStrategy) { return \call_user_func($this->defaultStrategy, $name); } return $this->defaultStrategy; } /** * 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(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 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 = []) { trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::setSafeClasses()" method instead.', __METHOD__); if (!isset($this->escaper)) { throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__)); } $this->escaper->setSafeClasses($safeClasses); } /** * @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__); if (!isset($this->escaper)) { throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__)); } $this->escaper->addSafeClass($class, $strategies); } /** * @internal * * @return array */ public static function escapeFilterIsSafe(Node $filterArgs) { foreach ($filterArgs as $arg) { if ($arg instanceof ConstantExpression) { return [$arg->getAttribute('value')]; } return []; } return ['html']; } } ================================================ FILE: src/Extension/ExtensionInterface.php ================================================ * * @method array getExpressionParsers() */ interface ExtensionInterface { /** * Returns the token parser instances to add to the existing list. * * @return TokenParserInterface[] */ public function getTokenParsers(); /** * Returns the node visitor instances to add to the existing list. * * @return NodeVisitorInterface[] */ public function getNodeVisitors(); /** * Returns a list of filters to add to the existing list. * * @return TwigFilter[] */ public function getFilters(); /** * Returns a list of tests to add to the existing list. * * @return TwigTest[] */ public function getTests(); /** * Returns a list of functions to add to the existing list. * * @return TwigFunction[] */ public function getFunctions(); /** * Returns a list of operators to add to the existing list. * * @return array * * @psalm-return array{ * array}>, * array, associativity: ExpressionParser::OPERATOR_*}> * } */ public function getOperators(); } ================================================ FILE: src/Extension/GlobalsInterface.php ================================================ */ interface GlobalsInterface { /** * @return array */ public function getGlobals(): array; } ================================================ FILE: src/Extension/LastModifiedExtensionInterface.php ================================================ optimizers)]; } } ================================================ FILE: src/Extension/ProfilerExtension.php ================================================ actives[] = $profile; } /** * @return void */ public function enter(Profile $profile) { $this->actives[0]->addProfile($profile); array_unshift($this->actives, $profile); } /** * @return void */ public function leave(Profile $profile) { $profile->leave(); array_shift($this->actives); if (1 === \count($this->actives)) { $this->actives[0]->leave(); } } public function getNodeVisitors(): array { return [new ProfilerNodeVisitor(static::class)]; } } ================================================ FILE: src/Extension/RuntimeExtensionInterface.php ================================================ */ interface RuntimeExtensionInterface { } ================================================ FILE: src/Extension/SandboxExtension.php ================================================ policy = $policy; $this->sandboxedGlobally = $sandboxed; $this->sourcePolicy = $sourcePolicy; } public function getTokenParsers(): array { return [new SandboxTokenParser()]; } public function getNodeVisitors(): array { return [new SandboxNodeVisitor()]; } public function enableSandbox(): void { $this->sandboxed = true; } public function disableSandbox(): void { $this->sandboxed = false; } public function isSandboxed(?Source $source = null): bool { return $this->sandboxedGlobally || $this->sandboxed || $this->isSourceSandboxed($source); } public function isSandboxedGlobally(): bool { return $this->sandboxedGlobally; } 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; } public function getSecurityPolicy(): SecurityPolicyInterface { return $this->policy; } public function checkSecurity($tags, $filters, $functions, ?Source $source = null): void { if ($this->isSandboxed($source)) { $this->policy->checkSecurity($tags, $filters, $functions); } } public function checkMethodAllowed($obj, $method, int $lineno = -1, ?Source $source = null): void { if ($this->isSandboxed($source)) { try { $this->policy->checkMethodAllowed($obj, $method); } catch (SecurityNotAllowedMethodError $e) { $e->setSourceContext($source); $e->setTemplateLine($lineno); throw $e; } } } public function checkPropertyAllowed($obj, $property, int $lineno = -1, ?Source $source = null): void { if ($this->isSandboxed($source)) { try { $this->policy->checkPropertyAllowed($obj, $property); } catch (SecurityNotAllowedPropertyError $e) { $e->setSourceContext($source); $e->setTemplateLine($lineno); throw $e; } } } /** * @throws SecurityNotAllowedMethodError */ public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null) { 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) { $e->setSourceContext($source); $e->setTemplateLine($lineno); throw $e; } } 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); } } } ================================================ FILE: src/Extension/StagingExtension.php ================================================ * * @internal */ final class StagingExtension extends AbstractExtension { private $functions = []; private $filters = []; private $visitors = []; private $tokenParsers = []; private $tests = []; public function addFunction(TwigFunction $function): void { if (isset($this->functions[$function->getName()])) { throw new \LogicException(\sprintf('Function "%s" is already registered.', $function->getName())); } $this->functions[$function->getName()] = $function; } public function getFunctions(): array { return $this->functions; } public function addFilter(TwigFilter $filter): void { if (isset($this->filters[$filter->getName()])) { throw new \LogicException(\sprintf('Filter "%s" is already registered.', $filter->getName())); } $this->filters[$filter->getName()] = $filter; } public function getFilters(): array { return $this->filters; } public function addNodeVisitor(NodeVisitorInterface $visitor): void { $this->visitors[] = $visitor; } public function getNodeVisitors(): array { return $this->visitors; } public function addTokenParser(TokenParserInterface $parser): void { if (isset($this->tokenParsers[$parser->getTag()])) { throw new \LogicException(\sprintf('Tag "%s" is already registered.', $parser->getTag())); } $this->tokenParsers[$parser->getTag()] = $parser; } public function getTokenParsers(): array { return $this->tokenParsers; } public function addTest(TwigTest $test): void { if (isset($this->tests[$test->getName()])) { throw new \LogicException(\sprintf('Test "%s" is already registered.', $test->getName())); } $this->tests[$test->getName()] = $test; } public function getTests(): array { return $this->tests; } } ================================================ FILE: src/Extension/StringLoaderExtension.php ================================================ true]), ]; } /** * 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); } } ================================================ FILE: src/Extension/YieldNotReadyExtension.php ================================================ useYield)]; } } ================================================ FILE: src/ExtensionSet.php ================================================ * * @internal */ final class ExtensionSet { private $extensions; private $initialized = false; private $runtimeInitialized = false; private $staging; private $parsers; 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 $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; public function __construct() { $this->staging = new StagingExtension(); } /** * @return void */ public function initRuntime() { $this->runtimeInitialized = true; } public function hasExtension(string $class): bool { return isset($this->extensions[ltrim($class, '\\')]); } 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)); } return $this->extensions[$class]; } /** * @param ExtensionInterface[] $extensions */ public function setExtensions(array $extensions): void { foreach ($extensions as $extension) { $this->addExtension($extension); } } /** * @return ExtensionInterface[] */ public function getExtensions(): array { return $this->extensions; } public function getSignature(): string { return json_encode(array_keys($this->extensions)); } public function isInitialized(): bool { return $this->initialized || $this->runtimeInitialized; } public function getLastModified(): int { if (0 !== $this->lastModified) { return $this->lastModified; } $lastModified = 0; foreach ($this->extensions as $extension) { 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 = $lastModified; } public function addExtension(ExtensionInterface $extension): void { 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)); } if (isset($this->extensions[$class])) { throw new \LogicException(\sprintf('Unable to register extension "%s" as it is already registered.', $class)); } $this->extensions[$class] = $extension; } 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())); } $this->staging->addFunction($function); } /** * @return TwigFunction[] */ public function getFunctions(): array { if (!$this->initialized) { $this->initExtensions(); } return $this->functions; } public function getFunction(string $name): ?TwigFunction { if (!$this->initialized) { $this->initExtensions(); } if (isset($this->functions[$name])) { return $this->functions[$name]; } foreach ($this->dynamicFunctions as $pattern => $function) { if (preg_match($pattern, $name, $matches)) { array_shift($matches); return $function->withDynamicArguments($name, $function->getName(), $matches); } } foreach ($this->functionCallbacks as $callback) { if (false !== $function = $callback($name)) { return $function; } } return null; } /** * @param callable(string): (TwigFunction|false) $callable */ public function registerUndefinedFunctionCallback(callable $callable): void { $this->functionCallbacks[] = $callable; } 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())); } $this->staging->addFilter($filter); } /** * @return TwigFilter[] */ public function getFilters(): array { if (!$this->initialized) { $this->initExtensions(); } return $this->filters; } public function getFilter(string $name): ?TwigFilter { if (!$this->initialized) { $this->initExtensions(); } if (isset($this->filters[$name])) { return $this->filters[$name]; } foreach ($this->dynamicFilters as $pattern => $filter) { if (preg_match($pattern, $name, $matches)) { array_shift($matches); return $filter->withDynamicArguments($name, $filter->getName(), $matches); } } foreach ($this->filterCallbacks as $callback) { if (false !== $filter = $callback($name)) { return $filter; } } return null; } /** * @param callable(string): (TwigFilter|false) $callable */ public function registerUndefinedFilterCallback(callable $callable): void { $this->filterCallbacks[] = $callable; } public function addNodeVisitor(NodeVisitorInterface $visitor): void { if ($this->initialized) { throw new \LogicException('Unable to add a node visitor as extensions have already been initialized.'); } $this->staging->addNodeVisitor($visitor); } /** * @return NodeVisitorInterface[] */ public function getNodeVisitors(): array { if (!$this->initialized) { $this->initExtensions(); } return $this->visitors; } public function addTokenParser(TokenParserInterface $parser): void { if ($this->initialized) { throw new \LogicException('Unable to add a token parser as extensions have already been initialized.'); } $this->staging->addTokenParser($parser); } /** * @return TokenParserInterface[] */ public function getTokenParsers(): array { if (!$this->initialized) { $this->initExtensions(); } return $this->parsers; } public function getTokenParser(string $name): ?TokenParserInterface { if (!$this->initialized) { $this->initExtensions(); } if (isset($this->parsers[$name])) { return $this->parsers[$name]; } foreach ($this->parserCallbacks as $callback) { if (false !== $parser = $callback($name)) { return $parser; } } return null; } /** * @param callable(string): (TokenParserInterface|false) $callable */ public function registerUndefinedTokenParserCallback(callable $callable): void { $this->parserCallbacks[] = $callable; } /** * @return array */ public function getGlobals(): array { if (null !== $this->globals) { return $this->globals; } $globals = []; foreach ($this->extensions as $extension) { if (!$extension instanceof GlobalsInterface) { continue; } $globals = array_merge($globals, $extension->getGlobals()); } if ($this->initialized) { $this->globals = $globals; } 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())); } $this->staging->addTest($test); } /** * @return TwigTest[] */ public function getTests(): array { if (!$this->initialized) { $this->initExtensions(); } return $this->tests; } public function getTest(string $name): ?TwigTest { if (!$this->initialized) { $this->initExtensions(); } if (isset($this->tests[$name])) { return $this->tests[$name]; } foreach ($this->dynamicTests as $pattern => $test) { if (preg_match($pattern, $name, $matches)) { array_shift($matches); return $test->withDynamicArguments($name, $test->getName(), $matches); } } foreach ($this->testCallbacks as $callback) { if (false !== $test = $callback($name)) { return $test; } } return null; } /** * @param callable(string): (TwigTest|false) $callable */ public function registerUndefinedTestCallback(callable $callable): void { $this->testCallbacks[] = $callable; } public function getExpressionParsers(): ExpressionParsers { if (!$this->initialized) { $this->initExtensions(); } return $this->expressionParsers; } private function initExtensions(): void { $this->parsers = []; $this->filters = []; $this->functions = []; $this->tests = []; $this->dynamicFilters = []; $this->dynamicFunctions = []; $this->dynamicTests = []; $this->visitors = []; $this->expressionParsers = new ExpressionParsers(); foreach ($this->extensions as $extension) { $this->initExtension($extension); } $this->initExtension($this->staging); // Done at the end only, so that an exception during initialization does not mark the environment as initialized when catching the exception $this->initialized = true; } private function initExtension(ExtensionInterface $extension): void { // filters foreach ($extension->getFilters() as $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[$name = $function->getName()] = $function; if (str_contains($name, '*')) { $this->dynamicFunctions['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $function; } } // tests foreach ($extension->getTests() as $test) { $this->tests[$name = $test->getName()] = $test; if (str_contains($name, '*')) { $this->dynamicTests['#^'.str_replace('\\*', '(.*?)', preg_quote($name, '#')).'$#'] = $test; } } // token parsers foreach ($extension->getTokenParsers() as $parser) { if (!$parser instanceof TokenParserInterface) { throw new \LogicException('getTokenParsers() must return an array of \Twig\TokenParser\TokenParserInterface.'); } $this->parsers[$parser->getTag()] = $parser; } // node visitors foreach ($extension->getNodeVisitors() as $visitor) { $this->visitors[] = $visitor; } // 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.', $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->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); } }; } } ================================================ FILE: src/FileExtensionEscapingStrategy.php ================================================ */ class FileExtensionEscapingStrategy { /** * Guesses the best autoescaping strategy based on the file name. * * @param string $name The template name * * @return string|false The escaping strategy name to use or false to disable */ public static function guess(string $name) { if (\in_array(substr($name, -1), ['/', '\\'], true)) { return 'html'; // return html for directories } if (str_ends_with($name, '.twig')) { $name = substr($name, 0, -5); } $extension = pathinfo($name, \PATHINFO_EXTENSION); switch ($extension) { case 'js': case 'json': return 'js'; case 'css': return 'css'; case 'txt': return false; default: return 'html'; } } } ================================================ FILE: src/Lexer.php ================================================ */ class Lexer { private $isInitialized = false; private $tokens; private $code; private $cursor; private $lineno; private $end; private $state; private $states; private $brackets; private $env; private $source; private $options; private $regexes; private $position; private $positions; private $currentVarBlockLine; private array $openingBrackets = ['{', '(', '[']; private array $closingBrackets = ['}', ')', ']']; public const STATE_DATA = 0; public const STATE_BLOCK = 1; public const STATE_VAR = 2; public const STATE_STRING = 3; public const STATE_INTERPOLATION = 4; public const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/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; $this->options = array_merge([ 'tag_comment' => ['{#', '#}'], 'tag_block' => ['{%', '%}'], 'tag_variable' => ['{{', '}}'], 'whitespace_trim' => '-', 'whitespace_line_trim' => '~', '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 = [ // }} 'lex_var' => '{ \s* (?:'. preg_quote($this->options['whitespace_trim'].$this->options['tag_variable'][1], '#').'\s*'. // -}}\s* '|'. preg_quote($this->options['whitespace_line_trim'].$this->options['tag_variable'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~}}[ \t\0\x0B]* '|'. preg_quote($this->options['tag_variable'][1], '#'). // }} ') }Ax', // %} 'lex_block' => '{ \s* (?:'. preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*\n?'. // -%}\s*\n? '|'. preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]* '|'. preg_quote($this->options['tag_block'][1], '#').'\n?'. // %}\n? ') }Ax', // {% endverbatim %} 'lex_raw_data' => '{'. preg_quote($this->options['tag_block'][0], '#'). // {% '('. $this->options['whitespace_trim']. // - '|'. $this->options['whitespace_line_trim']. // ~ ')?\s*endverbatim\s*'. '(?:'. preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*'. // -%} '|'. preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]* '|'. preg_quote($this->options['tag_block'][1], '#'). // %} ') }sx', 'operator' => $this->getOperatorRegex(), // #} 'lex_comment' => '{ (?:'. preg_quote($this->options['whitespace_trim'].$this->options['tag_comment'][1], '#').'\s*\n?'. // -#}\s*\n? '|'. preg_quote($this->options['whitespace_line_trim'].$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~#}[ \t\0\x0B]* '|'. preg_quote($this->options['tag_comment'][1], '#').'\n?'. // #}\n? ') }sx', // verbatim %} 'lex_block_raw' => '{ \s*verbatim\s* (?:'. preg_quote($this->options['whitespace_trim'].$this->options['tag_block'][1], '#').'\s*'. // -%}\s* '|'. preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]* '|'. preg_quote($this->options['tag_block'][1], '#'). // %} ') }Asx', 'lex_block_line' => '{\s*line\s+(\d+)\s*'.preg_quote($this->options['tag_block'][1], '#').'}As', // {{ or {% or {# 'lex_tokens_start' => '{ ('. preg_quote($this->options['tag_variable'][0], '#'). // {{ '|'. preg_quote($this->options['tag_block'][0], '#'). // {% '|'. preg_quote($this->options['tag_comment'][0], '#'). // {# ')('. preg_quote($this->options['whitespace_trim'], '#'). // - '|'. preg_quote($this->options['whitespace_line_trim'], '#'). // ~ ')? }sx', '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; $this->lineno = 1; $this->end = \strlen($this->code); $this->tokens = []; $this->state = self::STATE_DATA; $this->states = []; $this->brackets = []; $this->position = -1; // find all token starts in one go preg_match_all($this->regexes['lex_tokens_start'], $this->code, $matches, \PREG_OFFSET_CAPTURE); $this->positions = $matches; while ($this->cursor < $this->end) { // dispatch to the lexing functions depending // on the current state switch ($this->state) { case self::STATE_DATA: $this->lexData(); break; case self::STATE_BLOCK: $this->lexBlock(); break; case self::STATE_VAR: $this->lexVar(); break; case self::STATE_STRING: $this->lexString(); break; case self::STATE_INTERPOLATION: $this->lexInterpolation(); break; } } $this->pushToken(Token::EOF_TYPE); 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); } 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, substr($this->code, $this->cursor)); $this->cursor = $this->end; return; } // Find the first token after the current cursor $position = $this->positions[0][++$this->position]; while ($position[1] < $this->cursor) { if ($this->position == \count($this->positions[0]) - 1) { return; } $position = $this->positions[0][++$this->position]; } // push the template text first $text = $textContent = substr($this->code, $this->cursor, $position[1] - $this->cursor); // trim? if (isset($this->positions[2][$this->position][0])) { if ($this->options['whitespace_trim'] === $this->positions[2][$this->position][0]) { // whitespace_trim detected ({%-, {{- or {#-) $text = rtrim($text); } elseif ($this->options['whitespace_line_trim'] === $this->positions[2][$this->position][0]) { // whitespace_line_trim detected ({%~, {{~ or {#~) // don't trim \r and \n $text = rtrim($text, " \t\0\x0B"); } } $this->pushToken(Token::TEXT_TYPE, $text); $this->moveCursor($textContent.$position[0]); switch ($this->positions[1][$this->position][0]) { case $this->options['tag_comment'][0]: $this->lexComment(); break; case $this->options['tag_block'][0]: // raw data? if (preg_match($this->regexes['lex_block_raw'], $this->code, $match, 0, $this->cursor)) { $this->moveCursor($match[0]); $this->lexRawData(); // {% line \d+ %} } elseif (preg_match($this->regexes['lex_block_line'], $this->code, $match, 0, $this->cursor)) { $this->moveCursor($match[0]); $this->lineno = (int) $match[1]; } else { $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); $this->pushState(self::STATE_VAR); $this->currentVarBlockLine = $this->lineno; break; } } private function lexBlock(): void { 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 { $this->lexExpression(); } } private function lexVar(): void { 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 { $this->lexExpression(); } } private function lexExpression(): void { // whitespace if (preg_match('/\s+/A', $this->code, $match, 0, $this->cursor)) { $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); } } // operators 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, $match[0]); $this->moveCursor($match[0]); } // numbers elseif (preg_match(self::REGEX_NUMBER, $this->code, $match, 0, $this->cursor)) { $this->pushToken(Token::NUMBER_TYPE, 0 + str_replace('_', '', $match[0])); $this->moveCursor($match[0]); } // punctuation 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, $this->stripcslashes(substr($match[0], 1, -1), substr($match[0], 0, 1))); $this->moveCursor($match[0]); } // opening double quoted string elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, 0, $this->cursor)) { $this->brackets[] = ['"', $this->lineno]; $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); } } 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 { if (!preg_match($this->regexes['lex_raw_data'], $this->code, $match, \PREG_OFFSET_CAPTURE, $this->cursor)) { throw new SyntaxError('Unexpected end of file: Unclosed "verbatim" block.', $this->lineno, $this->source); } $text = substr($this->code, $this->cursor, $match[0][1] - $this->cursor); $this->moveCursor($text.$match[0][0]); // trim? if (isset($match[1][0])) { if ($this->options['whitespace_trim'] === $match[1][0]) { // whitespace_trim detected ({%-, {{- or {#-) $text = rtrim($text); } else { // whitespace_line_trim detected ({%~, {{~ or {#~) // don't trim \r and \n $text = rtrim($text, " \t\0\x0B"); } } $this->pushToken(Token::TEXT_TYPE, $text); } private function lexComment(): void { if (!preg_match($this->regexes['lex_comment'], $this->code, $match, \PREG_OFFSET_CAPTURE, $this->cursor)) { throw new SyntaxError('Unclosed comment.', $this->lineno, $this->source); } $this->moveCursor(substr($this->code, $this->cursor, $match[0][1] - $this->cursor).$match[0][0]); } 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); $this->moveCursor($match[0]); $this->pushState(self::STATE_INTERPOLATION); } 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)) { [$expect, $lineno] = array_pop($this->brackets); if ('"' != $this->code[$this->cursor]) { 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); } } 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); $this->moveCursor($match[0]); $this->popState(); } else { $this->lexExpression(); } } private function pushToken($type, $value = ''): void { // do not push empty text tokens if (Token::TEXT_TYPE === $type && '' === $value) { return; } $this->tokens[] = new Token($type, $value, $this->lineno); } private function moveCursor($text): void { $this->cursor += \strlen($text); $this->lineno += substr_count($text, "\n"); } private function getOperatorRegex(): string { $expressionParsers = []; foreach ($this->env->getExpressionParsers() as $expressionParser) { $expressionParsers = array_merge($expressionParsers, ExpressionParsers::getOperatorTokensFor($expressionParser)); } $expressionParsers = array_combine($expressionParsers, array_map('strlen', $expressionParsers)); arsort($expressionParsers); $regex = []; 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($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($expressionParser[0])) { $r = '(?states[] = $this->state; $this->state = $state; } private function popState(): void { if (0 === \count($this->states)) { throw new \LogicException('Cannot pop state without a previous state.'); } $this->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); } } } } ================================================ FILE: src/Loader/ArrayLoader.php ================================================ */ final class ArrayLoader implements LoaderInterface { /** * @param array $templates An array of templates (keys are the names, and values are the source code) */ public function __construct( private array $templates = [], ) { } public function setTemplate(string $name, string $template): void { $this->templates[$name] = $template; } public function getSourceContext(string $name): Source { if (!isset($this->templates[$name])) { throw new LoaderError(\sprintf('Template "%s" is not defined.', $name)); } return new Source($this->templates[$name], $name); } public function exists(string $name): bool { return isset($this->templates[$name]); } public function getCacheKey(string $name): string { if (!isset($this->templates[$name])) { throw new LoaderError(\sprintf('Template "%s" is not defined.', $name)); } return $name.':'.$this->templates[$name]; } public function isFresh(string $name, int $time): bool { if (!isset($this->templates[$name])) { throw new LoaderError(\sprintf('Template "%s" is not defined.', $name)); } return true; } } ================================================ FILE: src/Loader/ChainLoader.php ================================================ */ final class ChainLoader implements LoaderInterface { /** * @var array */ private $hasSourceCache = []; /** * @param iterable $loaders */ public function __construct( private iterable $loaders = [], ) { } public function addLoader(LoaderInterface $loader): void { $current = $this->loaders; $this->loaders = (static function () use ($current, $loader): \Generator { yield from $current; yield $loader; })(); $this->hasSourceCache = []; } /** * @return LoaderInterface[] */ 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->getLoaders() as $loader) { if (!$loader->exists($name)) { continue; } try { return $loader->getSourceContext($name); } catch (LoaderError $e) { $exceptions[] = $e->getMessage(); } } throw new LoaderError(\sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); } public function exists(string $name): bool { if (isset($this->hasSourceCache[$name])) { return $this->hasSourceCache[$name]; } foreach ($this->getLoaders() as $loader) { if ($loader->exists($name)) { return $this->hasSourceCache[$name] = true; } } return $this->hasSourceCache[$name] = false; } public function getCacheKey(string $name): string { $exceptions = []; foreach ($this->getLoaders() as $loader) { if (!$loader->exists($name)) { continue; } try { return $loader->getCacheKey($name); } catch (LoaderError $e) { $exceptions[] = $loader::class.': '.$e->getMessage(); } } 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->getLoaders() as $loader) { if (!$loader->exists($name)) { continue; } try { return $loader->isFresh($name, $time); } catch (LoaderError $e) { $exceptions[] = $loader::class.': '.$e->getMessage(); } } throw new LoaderError(\sprintf('Template "%s" is not defined%s.', $name, $exceptions ? ' ('.implode(', ', $exceptions).')' : '')); } } ================================================ FILE: src/Loader/FilesystemLoader.php ================================================ */ class FilesystemLoader implements LoaderInterface { /** Identifier of the main namespace. */ public const MAIN_NAMESPACE = '__main__'; /** * @var array> */ protected $paths = []; protected $cache = []; protected $errorCache = []; private $rootPath; /** * @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) { $this->rootPath = ($rootPath ?? getcwd()).\DIRECTORY_SEPARATOR; if (null !== $rootPath && false !== ($realPath = realpath($rootPath))) { $this->rootPath = $realPath.\DIRECTORY_SEPARATOR; } if ($paths) { $this->setPaths($paths); } } /** * Returns the paths to the templates. * * @return list */ public function getPaths(string $namespace = self::MAIN_NAMESPACE): array { return $this->paths[$namespace] ?? []; } /** * Returns the path namespaces. * * The main namespace is always defined. * * @return list */ public function getNamespaces(): array { return array_keys($this->paths); } /** * @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 { if (!\is_array($paths)) { $paths = [$paths]; } $this->paths[$namespace] = []; foreach ($paths as $path) { $this->addPath($path, $namespace); } } /** * @throws LoaderError */ public function addPath(string $path, string $namespace = self::MAIN_NAMESPACE): void { // invalidate the cache $this->cache = $this->errorCache = []; $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)); } $this->paths[$namespace][] = rtrim($path, '/\\'); } /** * @throws LoaderError */ public function prependPath(string $path, string $namespace = self::MAIN_NAMESPACE): void { // invalidate the cache $this->cache = $this->errorCache = []; $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)); } $path = rtrim($path, '/\\'); if (!isset($this->paths[$namespace])) { $this->paths[$namespace][] = $path; } else { array_unshift($this->paths[$namespace], $path); } } public function getSourceContext(string $name): Source { if (null === $path = $this->findTemplate($name)) { return new Source('', $name, ''); } return new Source(file_get_contents($path), $name, $path); } public function getCacheKey(string $name): string { if (null === $path = $this->findTemplate($name)) { return ''; } $len = \strlen($this->rootPath); if (0 === strncmp($this->rootPath, $path, $len)) { return substr($path, $len); } return $path; } /** * @return bool */ public function exists(string $name) { $name = $this->normalizeName($name); if (isset($this->cache[$name])) { return true; } return null !== $this->findTemplate($name, false); } public function isFresh(string $name, int $time): bool { // false support to be removed in 3.0 if (null === $path = $this->findTemplate($name)) { return false; } return filemtime($path) < $time; } /** * @return string|null */ protected function findTemplate(string $name, bool $throw = true) { $name = $this->normalizeName($name); if (isset($this->cache[$name])) { return $this->cache[$name]; } if (isset($this->errorCache[$name])) { if (!$throw) { return null; } throw new LoaderError($this->errorCache[$name]); } try { [$namespace, $shortname] = $this->parseName($name); $this->validateName($shortname); } catch (LoaderError $e) { if (!$throw) { return null; } throw $e; } if (!isset($this->paths[$namespace])) { $this->errorCache[$name] = \sprintf('There are no registered paths for namespace "%s".', $namespace); if (!$throw) { return null; } throw new LoaderError($this->errorCache[$name]); } foreach ($this->paths[$namespace] as $path) { if (!$this->isAbsolutePath($path)) { $path = $this->rootPath.$path; } if (is_file($path.'/'.$shortname)) { if (false !== $realpath = realpath($path.'/'.$shortname)) { return $this->cache[$name] = $realpath; } return $this->cache[$name] = $path.'/'.$shortname; } } $this->errorCache[$name] = \sprintf('Unable to find template "%s" (looked into: %s).', $name, implode(', ', $this->paths[$namespace])); if (!$throw) { return null; } throw new LoaderError($this->errorCache[$name]); } private function normalizeName(string $name): string { return preg_replace('#/{2,}#', '/', str_replace('\\', '/', $name)); } private function parseName(string $name, string $default = self::MAIN_NAMESPACE): array { 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)); } $namespace = substr($name, 1, $pos - 1); $shortname = substr($name, $pos + 1); return [$namespace, $shortname]; } return [$default, $name]; } private function validateName(string $name): void { if (str_contains($name, "\0")) { throw new LoaderError('A template name cannot contain NUL bytes.'); } $name = ltrim($name, '/'); $parts = explode('/', $name); $level = 0; foreach ($parts as $part) { if ('..' === $part) { --$level; } elseif ('.' !== $part) { ++$level; } if ($level < 0) { throw new LoaderError(\sprintf('Looks like you try to load a template outside configured directories (%s).', $name)); } } } private function isAbsolutePath(string $file): bool { return strspn($file, '/\\', 0, 1) || (\strlen($file) > 3 && ctype_alpha($file[0]) && ':' === $file[1] && strspn($file, '/\\', 2, 1) ) || null !== parse_url($file, \PHP_URL_SCHEME) ; } } ================================================ FILE: src/Loader/LoaderInterface.php ================================================ */ interface LoaderInterface { /** * Returns the source context for a given template logical name. * * @throws LoaderError When $name is not found */ public function getSourceContext(string $name): Source; /** * Gets the cache key to use for the cache for a given template name. * * @throws LoaderError When $name is not found */ public function getCacheKey(string $name): string; /** * @param int $time Timestamp of the last modification time of the cached template * * @throws LoaderError When $name is not found */ public function isFresh(string $name, int $time): bool; /** * @return bool */ public function exists(string $name); } ================================================ FILE: src/Markup.php ================================================ */ class Markup implements \Countable, \JsonSerializable, \Stringable { private $content; private ?string $charset; public function __construct($content, $charset) { $this->content = (string) $content; $this->charset = $charset; } public function __toString(): string { return $this->content; } public function getCharset(): string { return $this->charset; } /** * @return int */ #[\ReturnTypeWillChange] public function count() { return mb_strlen($this->content, $this->charset); } /** * @return mixed */ #[\ReturnTypeWillChange] public function jsonSerialize() { return $this->content; } } ================================================ FILE: src/Node/AutoEscapeNode.php ================================================ */ #[YieldReady] class AutoEscapeNode extends Node { public function __construct($value, Node $body, int $lineno) { parent::__construct(['body' => $body], ['value' => $value], $lineno); } public function compile(Compiler $compiler): void { $compiler->subcompile($this->getNode('body')); } } ================================================ FILE: src/Node/BlockNode.php ================================================ */ #[YieldReady] class BlockNode extends Node { public function __construct(string $name, Node $body, int $lineno) { parent::__construct(['body' => $body], ['name' => $name], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->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") ; } } ================================================ FILE: src/Node/BlockReferenceNode.php ================================================ */ #[YieldReady] class BlockReferenceNode extends Node implements NodeOutputInterface { public function __construct(string $name, int $lineno) { parent::__construct([], ['name' => $name], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->write(\sprintf("yield from \$this->unwrap()->yieldBlock('%s', \$context, \$blocks);\n", $this->getAttribute('name'))) ; } } ================================================ FILE: src/Node/BodyNode.php ================================================ */ #[YieldReady] class BodyNode extends Node { } ================================================ FILE: src/Node/CaptureNode.php ================================================ */ #[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(';'); } } } ================================================ FILE: src/Node/CheckSecurityCallNode.php ================================================ */ #[YieldReady] class CheckSecurityCallNode extends Node { /** * @return void */ public function compile(Compiler $compiler) { $compiler ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n") ->write("\$this->checkSecurity();\n") ; } } ================================================ FILE: src/Node/CheckSecurityNode.php ================================================ */ #[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; $this->usedTags = $usedTags; $this->usedFunctions = $usedFunctions; parent::__construct(); } public function compile(Compiler $compiler): void { $compiler ->write("\n") ->write("public function checkSecurity()\n") ->write("{\n") ->indent() ->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(!$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() ->write("} catch (SecurityError \$e) {\n") ->indent() ->write("\$e->setSourceContext(\$this->source);\n\n") ->write("if (\$e instanceof SecurityNotAllowedTagError && isset(\$tags[\$e->getTagName()])) {\n") ->indent() ->write("\$e->setTemplateLine(\$tags[\$e->getTagName()]);\n") ->outdent() ->write("} elseif (\$e instanceof SecurityNotAllowedFilterError && isset(\$filters[\$e->getFilterName()])) {\n") ->indent() ->write("\$e->setTemplateLine(\$filters[\$e->getFilterName()]);\n") ->outdent() ->write("} elseif (\$e instanceof SecurityNotAllowedFunctionError && isset(\$functions[\$e->getFunctionName()])) {\n") ->indent() ->write("\$e->setTemplateLine(\$functions[\$e->getFunctionName()]);\n") ->outdent() ->write("}\n\n") ->write("throw \$e;\n") ->outdent() ->write("}\n\n") ->outdent() ->write("}\n") ; } } ================================================ FILE: src/Node/CheckToStringNode.php ================================================ */ #[YieldReady] class CheckToStringNode extends AbstractExpression { public function __construct(AbstractExpression $expr) { parent::__construct(['expr' => $expr], [], $expr->getTemplateLine()); } public function compile(Compiler $compiler): void { $expr = $this->getNode('expr'); $compiler ->raw('$this->sandbox->ensureToStringAllowed(') ->subcompile($expr) ->raw(', ') ->repr($expr->getTemplateLine()) ->raw(', $this->source)') ; } } ================================================ FILE: src/Node/DeprecatedNode.php ================================================ */ #[YieldReady] class DeprecatedNode extends Node { public function __construct(AbstractExpression $expr, int $lineno) { parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $expr = $this->getNode('expr'); if (!$expr instanceof ConstantExpression) { $varName = $compiler->getVarName(); $compiler ->write(\sprintf('$%s = ', $varName)) ->subcompile($expr) ->raw(";\n") ; } $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(' in "%s" at line %d.', $this->getTemplateName(), $this->getTemplateLine())) ->raw(");\n") ; } } ================================================ FILE: src/Node/DoNode.php ================================================ */ #[YieldReady] class DoNode extends Node { public function __construct(AbstractExpression $expr, int $lineno) { parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->write('') ->subcompile($this->getNode('expr')) ->raw(";\n") ; } } ================================================ FILE: src/Node/EmbedNode.php ================================================ */ #[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) { parent::__construct(new ConstantExpression('not_used', $lineno), $variables, $only, $ignoreMissing, $lineno); $this->setAttribute('name', $name); $this->setAttribute('index', $index); } protected function addGetTemplate(Compiler $compiler, string $template = ''): void { $compiler ->raw('$this->load(') ->string($this->getAttribute('name')) ->raw(', ') ->repr($this->getTemplateLine()) ->raw(', ') ->repr($this->getAttribute('index')) ->raw(')') ; if ($this->getAttribute('ignore_missing')) { $compiler ->raw(";\n") ->write(\sprintf("\$%s->getParent(\$context);\n", $template)) ; } } } ================================================ FILE: src/Node/EmptyNode.php ================================================ */ #[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.'); } } ================================================ FILE: src/Node/Expression/AbstractExpression.php ================================================ */ 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'); } } ================================================ FILE: src/Node/Expression/ArrayExpression.php ================================================ index = -1; foreach ($this->getKeyValuePairs() as $pair) { if ($pair['key'] instanceof ConstantExpression && ctype_digit((string) $pair['key']->getAttribute('value')) && $pair['key']->getAttribute('value') > $this->index) { $this->index = $pair['key']->getAttribute('value'); } } } public function getKeyValuePairs(): array { $pairs = []; foreach (array_chunk($this->nodes, 2) as $pair) { $pairs[] = [ 'key' => $pair[0], 'value' => $pair[1], ]; } return $pairs; } public function hasElement(AbstractExpression $key): bool { foreach ($this->getKeyValuePairs() as $pair) { // we compare the string representation of the keys // to avoid comparing the line numbers which are not relevant here. if ((string) $key === (string) $pair['key']) { return true; } } return false; } /** * Checks if the array is a sequence (keys are sequential integers starting from 0). * * @internal */ public function isSequence(): bool { foreach ($this->getKeyValuePairs() as $i => $pair) { $key = $pair['key']; if ($key instanceof TempNameExpression) { $keyValue = $key->getAttribute('name'); } elseif ($key instanceof ConstantExpression) { $keyValue = $key->getAttribute('value'); } else { return false; } if ($keyValue !== $i) { return false; } } return true; } public function addElement(AbstractExpression $value, ?AbstractExpression $key = null): void { if (null === $key) { $key = new ConstantExpression(++$this->index, $value->getTemplateLine()); } array_push($this->nodes, $key, $value); } public function compile(Compiler $compiler): void { if ($this->definedTest) { $compiler->repr(true); return; } // Check for empty expressions which are only allowed in destructuring foreach ($this->getKeyValuePairs() as $pair) { if ($pair['value'] instanceof EmptyExpression) { throw new SyntaxError('Empty array elements are only allowed in destructuring assignments.', $pair['value']->getTemplateLine(), $this->getSourceContext()); } } $compiler->raw('['); $isSequence = true; foreach ($this->getKeyValuePairs() as $i => $pair) { if (0 !== $i) { $compiler->raw(', '); } $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(']'); } } ================================================ FILE: src/Node/Expression/ArrowFunctionExpression.php ================================================ */ class ArrowFunctionExpression extends AbstractExpression { public function __construct(AbstractExpression $expr, Node $names, $lineno) { if ($names instanceof ContextVariable) { $names = new ListExpression([new AssignContextVariable($names->getAttribute('name'), $names->getTemplateLine())], $lineno); } if (!$names instanceof ListExpression) { throw new SyntaxError('The arrow function argument must be a list of variables or a single variable.', $names->getTemplateLine(), $names->getSourceContext()); } parent::__construct(['expr' => $expr, 'names' => $names], [], $lineno); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->raw('function (') ->subcompile($this->getNode('names')) ->raw(') use ($context, $macros) { ') ; foreach ($this->getNode('names') as $name) { $compiler ->raw('$context["') ->raw($name->getAttribute('name')) ->raw('"] = $__') ->raw($name->getAttribute('name')) ->raw('__; ') ; } $compiler ->raw('return ') ->subcompile($this->getNode('expr')) ->raw('; }') ; } } ================================================ FILE: src/Node/Expression/AssignNameExpression.php ================================================ raw('$context[') ->string($this->getAttribute('name')) ->raw(']') ; } } ================================================ FILE: src/Node/Expression/Binary/AbstractBinary.php ================================================ $left, 'right' => $right], [], $lineno); } public function compile(Compiler $compiler): void { $compiler ->raw('(') ->subcompile($this->getNode('left')) ->raw(' ') ; $this->operator($compiler); $compiler ->raw(' ') ->subcompile($this->getNode('right')) ->raw(')') ; } abstract public function operator(Compiler $compiler): Compiler; } ================================================ FILE: src/Node/Expression/Binary/AddBinary.php ================================================ raw('+'); } } ================================================ FILE: src/Node/Expression/Binary/AndBinary.php ================================================ raw('&&'); } } ================================================ FILE: src/Node/Expression/Binary/BinaryInterface.php ================================================ raw('&'); } } ================================================ FILE: src/Node/Expression/Binary/BitwiseOrBinary.php ================================================ raw('|'); } } ================================================ FILE: src/Node/Expression/Binary/BitwiseXorBinary.php ================================================ raw('^'); } } ================================================ FILE: src/Node/Expression/Binary/ConcatBinary.php ================================================ raw('.'); } } ================================================ FILE: src/Node/Expression/Binary/DivBinary.php ================================================ raw('/'); } } ================================================ FILE: src/Node/Expression/Binary/ElvisBinary.php ================================================ 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']; } } ================================================ FILE: src/Node/Expression/Binary/EndsWithBinary.php ================================================ getVarName(); $right = $compiler->getVarName(); $compiler ->raw(\sprintf('(is_string($%s = ', $left)) ->subcompile($this->getNode('left')) ->raw(\sprintf(') && is_string($%s = ', $right)) ->subcompile($this->getNode('right')) ->raw(\sprintf(') && str_ends_with($%1$s, $%2$s))', $left, $right)) ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw(''); } } ================================================ FILE: src/Node/Expression/Binary/EqualBinary.php ================================================ = 80000) { parent::compile($compiler); return; } $compiler ->raw('(0 === CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw('))') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('=='); } } ================================================ FILE: src/Node/Expression/Binary/FloorDivBinary.php ================================================ raw('(int) floor('); parent::compile($compiler); $compiler->raw(')'); } public function operator(Compiler $compiler): Compiler { return $compiler->raw('/'); } } ================================================ FILE: src/Node/Expression/Binary/GreaterBinary.php ================================================ = 80000) { parent::compile($compiler); return; } $compiler ->raw('(1 === CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw('))') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('>'); } } ================================================ FILE: src/Node/Expression/Binary/GreaterEqualBinary.php ================================================ = 80000) { parent::compile($compiler); return; } $compiler ->raw('(0 <= CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw('))') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('>='); } } ================================================ FILE: src/Node/Expression/Binary/HasEveryBinary.php ================================================ raw('CoreExtension::arrayEvery($this->env, ') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw(')') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw(''); } } ================================================ FILE: src/Node/Expression/Binary/HasSomeBinary.php ================================================ raw('CoreExtension::arraySome($this->env, ') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw(')') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw(''); } } ================================================ FILE: src/Node/Expression/Binary/InBinary.php ================================================ raw('CoreExtension::inFilter(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw(')') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('in'); } } ================================================ FILE: src/Node/Expression/Binary/LessBinary.php ================================================ = 80000) { parent::compile($compiler); return; } $compiler ->raw('(-1 === CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw('))') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('<'); } } ================================================ FILE: src/Node/Expression/Binary/LessEqualBinary.php ================================================ = 80000) { parent::compile($compiler); return; } $compiler ->raw('(0 >= CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw('))') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('<='); } } ================================================ FILE: src/Node/Expression/Binary/MatchesBinary.php ================================================ 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('CoreExtension::matches(') ->subcompile($this->getNode('right')) ->raw(', ') ->subcompile($this->getNode('left')) ->raw(')') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw(''); } } ================================================ FILE: src/Node/Expression/Binary/ModBinary.php ================================================ raw('%'); } } ================================================ FILE: src/Node/Expression/Binary/MulBinary.php ================================================ raw('*'); } } ================================================ FILE: src/Node/Expression/Binary/NotEqualBinary.php ================================================ = 80000) { parent::compile($compiler); return; } $compiler ->raw('(0 !== CoreExtension::compare(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw('))') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('!='); } } ================================================ FILE: src/Node/Expression/Binary/NotInBinary.php ================================================ raw('!CoreExtension::inFilter(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw(')') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('not in'); } } ================================================ FILE: src/Node/Expression/Binary/NotSameAsBinary.php ================================================ raw('!=='); } } ================================================ FILE: src/Node/Expression/Binary/NullCoalesceBinary.php ================================================ 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']; } } ================================================ FILE: src/Node/Expression/Binary/ObjectDestructuringSetBinary.php ================================================ */ private array $mappings = []; /** * @param ArrayExpression $left The array expression containing object/mapping destructuring properties * @param AbstractExpression $right The expression providing values for assignment */ public function __construct(Node $left, Node $right, int $lineno) { if (!$left instanceof ArrayExpression) { throw new \LogicException('Left side must be ArrayExpression for object/mapping destructuring.'); } foreach ($left->getKeyValuePairs() as $pair) { if (!$pair['value'] instanceof ContextVariable) { throw new SyntaxError(\sprintf('Cannot assign to "%s", only variables can be assigned in object/mapping destructuring.', $pair['value']::class), $lineno); } $this->mappings[] = [ 'property' => $pair['key']->getAttribute('value'), 'variable' => $pair['value']->getAttribute('name'), ]; } parent::__construct($left, $right, $lineno); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $compiler->raw('['); foreach ($this->mappings as $i => $mapping) { if ($i) { $compiler->raw(', '); } $compiler->raw('$context[')->repr($mapping['variable'])->raw(']'); } $compiler->raw('] = ['); foreach ($this->mappings as $i => $mapping) { if ($i) { $compiler->raw(', '); } $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($mapping['property'])->raw(', [], \\Twig\\Template::ANY_CALL, false, false, false, ')->repr($this->getNode('right')->getTemplateLine())->raw(')'); } $compiler->raw(']'); } public function operator(Compiler $compiler): Compiler { return $compiler->raw('='); } } ================================================ FILE: src/Node/Expression/Binary/OrBinary.php ================================================ raw('||'); } } ================================================ FILE: src/Node/Expression/Binary/PowerBinary.php ================================================ raw('**'); } } ================================================ FILE: src/Node/Expression/Binary/RangeBinary.php ================================================ raw('range(') ->subcompile($this->getNode('left')) ->raw(', ') ->subcompile($this->getNode('right')) ->raw(')') ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw('..'); } } ================================================ FILE: src/Node/Expression/Binary/SameAsBinary.php ================================================ raw('==='); } } ================================================ FILE: src/Node/Expression/Binary/SequenceDestructuringSetBinary.php ================================================ getKeyValuePairs() as $pair) { if ($pair['value'] instanceof EmptyExpression) { $this->variables[] = null; } elseif ($pair['value'] instanceof ContextVariable) { $this->variables[] = $pair['value']->getAttribute('name'); } else { throw new SyntaxError(\sprintf('Cannot assign to "%s", only variables can be assigned in sequence destructuring.', $pair['value']::class), $lineno); } } parent::__construct($left, $right, $lineno); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $compiler->raw('['); foreach ($this->variables as $i => $name) { if ($i) { $compiler->raw(', '); } if (null !== $name) { $compiler->raw('$context[')->repr($name)->raw(']'); } } $compiler->raw('] = array_pad(')->subcompile($this->getNode('right'))->raw(', ')->repr(\count($this->variables))->raw(', null)'); } public function operator(Compiler $compiler): Compiler { return $compiler->raw('='); } } ================================================ FILE: src/Node/Expression/Binary/SetBinary.php ================================================ */ class SetBinary extends AbstractBinary { /** * @param ContextVariable $left * @param AbstractExpression $right */ public function __construct(Node $left, Node $right, int $lineno) { $name = $left->getAttribute('name'); if (!\is_string($name)) { throw new \LogicException('The "name" attribute must be a string.'); } $left = new AssignContextVariable($name, $left->getTemplateLine()); parent::__construct($left, $right, $lineno); } public function operator(Compiler $compiler): Compiler { return $compiler->raw('='); } } ================================================ FILE: src/Node/Expression/Binary/SpaceshipBinary.php ================================================ raw('<=>'); } } ================================================ FILE: src/Node/Expression/Binary/StartsWithBinary.php ================================================ getVarName(); $right = $compiler->getVarName(); $compiler ->raw(\sprintf('(is_string($%s = ', $left)) ->subcompile($this->getNode('left')) ->raw(\sprintf(') && is_string($%s = ', $right)) ->subcompile($this->getNode('right')) ->raw(\sprintf(') && str_starts_with($%1$s, $%2$s))', $left, $right)) ; } public function operator(Compiler $compiler): Compiler { return $compiler->raw(''); } } ================================================ FILE: src/Node/Expression/Binary/SubBinary.php ================================================ raw('-'); } } ================================================ FILE: src/Node/Expression/Binary/XorBinary.php ================================================ raw('xor'); } } ================================================ FILE: src/Node/Expression/BlockReferenceExpression.php ================================================ */ class BlockReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface { 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, ['output' => false], $lineno); } public function compile(Compiler $compiler): void { if ($this->definedTest) { $this->compileTemplateCall($compiler, 'hasBlock'); } else { if ($this->getAttribute('output')) { $compiler->addDebugInfo($this); $compiler->write('yield from '); $this ->compileTemplateCall($compiler, 'yieldBlock') ->raw(";\n"); } else { $this->compileTemplateCall($compiler, 'renderBlock'); } } } private function compileTemplateCall(Compiler $compiler, string $method): Compiler { if (!$this->hasNode('template')) { $compiler->write('$this'); } else { $compiler ->write('$this->load(') ->subcompile($this->getNode('template')) ->raw(', ') ->repr($this->getTemplateLine()) ->raw(')') ; } $compiler->raw(\sprintf('->unwrap()->%s', $method)); return $this->compileBlockArguments($compiler); } private function compileBlockArguments(Compiler $compiler): Compiler { $compiler ->raw('(') ->subcompile($this->getNode('name')) ->raw(', $context'); if (!$this->hasNode('template')) { $compiler->raw(', $blocks'); } return $compiler->raw(')'); } } ================================================ FILE: src/Node/Expression/CallExpression.php ================================================ getTwigCallable(); $callable = $twigCallable->getCallable(); if (\is_string($callable) && !str_contains($callable, '::')) { $compiler->raw($callable); } else { $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])); } else { $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)); } else { $compiler->raw(\sprintf('$this->extensions[\'%s\']', ltrim($class, '\\'))); } $compiler->raw(\sprintf('->%s', $callable[1])); } else { $compiler->raw(\sprintf('$this->env->get%s(\'%s\')->getCallable()', ucfirst($this->getAttribute('type')), $twigCallable->getDynamicName())); } } $this->compileArguments($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; $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 ($twigCallable->needsContext()) { if (!$first) { $compiler->raw(', '); } $compiler->raw('$context'); $first = false; } foreach ($twigCallable->getArguments() as $argument) { if (!$first) { $compiler->raw(', '); } $compiler->string($argument); $first = false; } if ($this->hasNode('node')) { if (!$first) { $compiler->raw(', '); } $compiler->subcompile($this->getNode('node')); $first = false; } if ($this->hasNode('arguments')) { $arguments = (new CallableArgumentsExtractor($this, $this->getTwigCallable()))->extractArguments($this->getNode('arguments')); foreach ($arguments as $node) { if (!$first) { $compiler->raw(', '); } $compiler->subcompile($node); $first = false; } } $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'); $parameters = []; $named = false; foreach ($arguments as $name => $node) { if (!\is_int($name)) { $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()); } $parameters[$name] = $node; } $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); } else { $message = \sprintf('Arbitrary positional arguments are not supported for %s "%s".', $callType, $callName); } throw new \LogicException($message); } [$callableParameters, $isPhpVariadic] = $this->getCallableParameters($callable, $isVariadic); $arguments = []; $names = []; $missingArguments = []; $optionalArguments = []; $pos = 0; foreach ($callableParameters as $callableParameter) { $name = $this->normalizeName($callableParameter->name); if (\PHP_VERSION_ID >= 80000 && 'range' === $callable) { if ('start' === $name) { $name = 'low'; } elseif ('end' === $name) { $name = 'high'; } } $names[] = $name; 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()); } 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".', $name, $callType, $callName, implode(', ', $names), \count($missingArguments) > 1 ? 's' : '', implode('", "', $missingArguments) ), $this->getTemplateLine(), $this->getSourceContext()); } $arguments = array_merge($arguments, $optionalArguments); $arguments[] = $parameters[$name]; unset($parameters[$name]); $optionalArguments = []; } elseif (\array_key_exists($pos, $parameters)) { $arguments = array_merge($arguments, $optionalArguments); $arguments[] = $parameters[$pos]; unset($parameters[$pos]); $optionalArguments = []; ++$pos; } elseif ($callableParameter->isDefaultValueAvailable()) { $optionalArguments[] = new ConstantExpression($callableParameter->getDefaultValue(), -1); } elseif ($callableParameter->isOptional()) { if (!$parameters) { break; } $missingArguments[] = $name; } else { throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext()); } } if ($isVariadic) { $arbitraryArguments = $isPhpVariadic ? new VariadicExpression([], -1) : new ArrayExpression([], -1); foreach ($parameters as $key => $value) { if (\is_int($key)) { $arbitraryArguments->addElement($value); } else { $arbitraryArguments->addElement($value, new ConstantExpression($key, -1)); } unset($parameters[$key]); } if ($arbitraryArguments->count()) { $arguments = array_merge($arguments, $optionalArguments); $arguments[] = $arbitraryArguments; } } if ($parameters) { $unknownParameter = null; foreach ($parameters as $parameter) { if ($parameter instanceof Node) { $unknownParameter = $parameter; break; } } throw new SyntaxError( \sprintf( 'Unknown argument%s "%s" for %s "%s(%s)".', \count($parameters) > 1 ? 's' : '', implode('", "', array_keys($parameters)), $callType, $callName, implode(', ', $names) ), $unknownParameter ? $unknownParameter->getTemplateLine() : $this->getTemplateLine(), $unknownParameter ? $unknownParameter->getSourceContext() : $this->getSourceContext() ); } 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 { $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 ($twigCallable->needsCharset()) { array_shift($parameters); } if ($twigCallable->needsEnvironment()) { 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() && $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'), $twigCallable->getName())); } } return [$parameters, $isPhpVariadic]; } private function reflectCallable(TwigCallableInterface $callable): ReflectionCallable { if (!$this->reflector) { $this->reflector = new ReflectionCallable($callable); } return $this->reflector; } /** * 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'); } } ================================================ FILE: src/Node/Expression/ConditionalExpression.php ================================================ $expr1, 'expr2' => $expr2, 'expr3' => $expr3], [], $lineno); } public function compile(Compiler $compiler): void { // 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']; } } ================================================ FILE: src/Node/Expression/ConstantExpression.php ================================================ $value], $lineno); } public function compile(Compiler $compiler): void { $compiler->repr($this->definedTest ? true : $this->getAttribute('value')); } } ================================================ FILE: src/Node/Expression/EmptyExpression.php ================================================ */ class DefaultFilter extends FilterExpression { /** * @param AbstractExpression $node */ #[FirstClassTwigCallableReady] public function __construct(Node $node, TwigFilter|ConstantExpression $filter, 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); } 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' === $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 ConditionalTernary($test, $default, $false, $node->getTemplateLine()); } else { $node = $default; } parent::__construct($node, $filter, $arguments, $lineno); } public function compile(Compiler $compiler): void { $compiler->subcompile($this->getNode('node')); } } ================================================ FILE: src/Node/Expression/Filter/RawFilter.php ================================================ */ 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')); } } ================================================ FILE: src/Node/Expression/FilterExpression.php ================================================ 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', 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); } } ================================================ FILE: src/Node/Expression/FunctionExpression.php ================================================ 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 { if ('constant' === $this->getAttribute('name')) { $this->definedTest = true; } } /** * @return void */ public function compile(Compiler $compiler) { $name = $this->getAttribute('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 "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->compileCallable($compiler); } } ================================================ FILE: src/Node/Expression/FunctionNode/EnumCasesFunction.php ================================================ 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)); } } ================================================ FILE: src/Node/Expression/FunctionNode/EnumFunction.php ================================================ 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)); } } ================================================ FILE: src/Node/Expression/GetAttrExpression.php ================================================ $node, 'attribute' => $attribute]; if (null !== $arguments) { $nodes['arguments'] = $arguments; } 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' => !$nullSafe, 'null_safe' => $nullSafe, 'is_short_circuited' => false, 'var_name' => null], $lineno); } public function enableDefinedTest(): void { $this->definedTest = true; $this->changeIgnoreStrictCheck($this); } public function compile(Compiler $compiler): void { $env = $compiler->getEnvironment(); $arrayAccessSandbox = false; $nullSafe = $this->getAttribute('null_safe'); // optimize array calls if ( $this->getAttribute('optimizable') && (!$env->isStrictVariables() || $this->getAttribute('ignore_strict_check')) && !$this->definedTest && Template::ARRAY_CALL === $this->getAttribute('type') ) { $var = '$'.$compiler->getVarName(); $compiler ->raw('(('.$var.' = ') ->subcompile($this->getNode('node')) ->raw(') && is_array(') ->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 && in_array(') ->raw($var.'::class') ->raw(', CoreExtension::ARRAY_LIKE_CLASSES, true) ? (') ->raw($var) ->raw('[') ->subcompile($this->getNode('attribute')) ->raw('] ?? null) : ') ; } if ($this->getAttribute('ignore_strict_check')) { $this->getNode('node')->setAttribute('ignore_strict_check', true); } if (null === $nullSafeNode = $nullSafe ? $this : null) { $node = $this->getNode('node'); while ($node instanceof self) { if ($node->getAttribute('null_safe')) { $nullSafeNode = $node; break; } $node = $node->getNode('node'); } } $isShortCircuited = false; if (null !== $nullSafeNode && !$nullSafeNode->isShortCircuited()) { $compiler ->raw('((null === ('.$nullSafeNode->getVarName($compiler).' = ') ->subcompile($nullSafeNode->getNode('node')) ->raw(')) ? null : '); $nullSafeNode->markAsShortCircuited(); $isShortCircuited = true; } $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, '); if ($nullSafe) { $compiler->raw($this->getVarName($compiler)); } else { $compiler->subcompile($this->getNode('node')); } $compiler ->raw(', ') ->subcompile($this->getNode('attribute')) ; if ($this->hasNode('arguments')) { $compiler->raw(', ')->subcompile($this->getNode('arguments')); } else { $compiler->raw(', []'); } $compiler->raw(', ') ->repr($this->getAttribute('type')) ->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(')'); } if ($isShortCircuited) { $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')); } } private function markAsShortCircuited(): void { $this->setAttribute('is_short_circuited', true); } private function isShortCircuited(): bool { return $this->getAttribute('is_short_circuited'); } private function getVarName(Compiler $compiler): string { if (null === $this->getAttribute('var_name')) { $this->setAttribute('var_name', $compiler->getVarName()); } return '$'.$this->getAttribute('var_name'); } } ================================================ FILE: src/Node/Expression/InlinePrint.php ================================================ $node], [], $lineno); } public function compile(Compiler $compiler): void { $compiler ->raw('yield ') ->subcompile($this->getNode('node')) ; } } ================================================ FILE: src/Node/Expression/ListExpression.php ================================================ $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('__') ; } } } ================================================ FILE: src/Node/Expression/MacroReferenceExpression.php ================================================ */ 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 __clone() { // The template node must not be deep-cloned because its name is // lazily generated during compilation and must stay in sync with // the AssignTemplateVariable that populates the $macros array. $template = $this->nodes['template']; parent::__clone(); $this->nodes['template'] = $template; } 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(')') ; } } ================================================ FILE: src/Node/Expression/MethodCallExpression.php ================================================ $node, 'arguments' => $arguments], ['method' => $method, 'safe' => false], $lineno); if ($node instanceof ContextVariable) { $node->setAttribute('always_defined', true); } } public function compile(Compiler $compiler): void { if ($this->definedTest) { $compiler ->raw('method_exists($macros[') ->repr($this->getNode('node')->getAttribute('name')) ->raw('], ') ->repr($this->getAttribute('method')) ->raw(')') ; return; } $compiler ->raw('CoreExtension::callMacro($macros[') ->repr($this->getNode('node')->getAttribute('name')) ->raw('], ') ->repr($this->getAttribute('method')) ->raw(', ') ->subcompile($this->getNode('arguments')) ->raw(', ') ->repr($this->getTemplateLine()) ->raw(', $context, $this->getSourceContext())'); } } ================================================ FILE: src/Node/Expression/NameExpression.php ================================================ '$this->getTemplateName()', '_context' => '$context', '_charset' => '$this->env->getCharset()', ]; 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, ContextVariable::class); } parent::__construct([], ['name' => $name, 'ignore_strict_check' => false, 'always_defined' => false], $lineno); } public function compile(Compiler $compiler): void { $name = $this->getAttribute('name'); $compiler->addDebugInfo($this); if ($this->definedTest) { if (isset($this->specialVars[$name]) || $this->getAttribute('always_defined')) { $compiler->repr(true); } elseif (\PHP_VERSION_ID >= 70400) { $compiler ->raw('array_key_exists(') ->string($name) ->raw(', $context)') ; } else { $compiler ->raw('(isset($context[') ->string($name) ->raw(']) || array_key_exists(') ->string($name) ->raw(', $context))') ; } } elseif (isset($this->specialVars[$name])) { $compiler->raw($this->specialVars[$name]); } elseif ($this->getAttribute('always_defined')) { $compiler ->raw('$context[') ->string($name) ->raw(']') ; } else { if ($this->getAttribute('ignore_strict_check') || !$compiler->getEnvironment()->isStrictVariables()) { $compiler ->raw('($context[') ->string($name) ->raw('] ?? null)') ; } else { $compiler ->raw('(isset($context[') ->string($name) ->raw(']) || array_key_exists(') ->string($name) ->raw(', $context) ? $context[') ->string($name) ->raw('] : (function () { throw new RuntimeError(\'Variable ') ->string($name) ->raw(' does not exist.\', ') ->repr($this->lineno) ->raw(', $this->source); })()') ->raw(')') ; } } } /** * @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() { 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; } } ================================================ FILE: src/Node/Expression/NullCoalesceExpression.php ================================================ 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() ); } parent::__construct($test, $left, $right, $lineno); } public function compile(Compiler $compiler): void { /* * This optimizes only one case. PHP 7 also supports more complex expressions * that can return null. So, for instance, if log is defined, log("foo") ?? "..." works, * but log($a["foo"]) ?? "..." does not if $a["foo"] is not defined. More advanced * 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 ContextVariable) { $this->getNode('expr2')->setAttribute('always_defined', true); $compiler ->raw('((') ->subcompile($this->getNode('expr2')) ->raw(') ?? (') ->subcompile($this->getNode('expr3')) ->raw('))') ; } else { parent::compile($compiler); } } } ================================================ FILE: src/Node/Expression/OperatorEscapeInterface.php ================================================ 1. * * @author Fabien Potencier */ interface OperatorEscapeInterface { /** * @return string[] */ public function getOperandNamesToEscape(): array; } ================================================ FILE: src/Node/Expression/ParentExpression.php ================================================ */ class ParentExpression extends AbstractExpression { public function __construct(string $name, int $lineno) { parent::__construct([], ['output' => false, 'name' => $name], $lineno); } public function compile(Compiler $compiler): void { if ($this->getAttribute('output')) { $compiler ->addDebugInfo($this) ->write('yield from $this->yieldParentBlock(') ->string($this->getAttribute('name')) ->raw(", \$context, \$blocks);\n") ; } else { $compiler ->raw('$this->renderParentBlock(') ->string($this->getAttribute('name')) ->raw(', $context, $blocks)') ; } } } ================================================ FILE: src/Node/Expression/ReturnArrayInterface.php ================================================ */ 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); } } } ================================================ FILE: src/Node/Expression/SupportDefinedTestInterface.php ================================================ */ interface SupportDefinedTestInterface { public function enableDefinedTest(): void; public function isDefinedTestEnabled(): bool; } ================================================ FILE: src/Node/Expression/SupportDefinedTestTrait.php ================================================ definedTest = true; } public function isDefinedTestEnabled(): bool { return $this->definedTest; } } ================================================ FILE: src/Node/Expression/TempNameExpression.php ================================================ $name], $lineno); } public function compile(Compiler $compiler): void { if (null === $this->getAttribute('name')) { $this->setAttribute('name', $compiler->getVarName()); } $compiler->raw('$'.$this->getAttribute('name')); } } ================================================ FILE: src/Node/Expression/Ternary/ConditionalTernary.php ================================================ 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']; } } ================================================ FILE: src/Node/Expression/Test/ConstantTest.php ================================================ */ class ConstantTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(') ->subcompile($this->getNode('node')) ->raw(' === constant(') ; if ($this->getNode('arguments')->hasNode('1')) { $compiler ->raw('get_class(') ->subcompile($this->getNode('arguments')->getNode('1')) ->raw(')."::".') ; } $compiler ->subcompile($this->getNode('arguments')->getNode('0')) ->raw('))') ; } } ================================================ FILE: src/Node/Expression/Test/DefinedTest.php ================================================ */ class DefinedTest extends TestExpression { /** * @param AbstractExpression $node */ #[FirstClassTwigCallableReady] public function __construct(Node $node, TwigTest|string $name, ?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); } if (!$node instanceof SupportDefinedTestInterface) { throw new SyntaxError('The "defined" test only works with simple variables.', $lineno); } $node->enableDefinedTest(); 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 { $compiler->subcompile($this->getNode('node')); } } ================================================ FILE: src/Node/Expression/Test/DivisiblebyTest.php ================================================ */ class DivisiblebyTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(0 == ') ->subcompile($this->getNode('node')) ->raw(' % ') ->subcompile($this->getNode('arguments')->getNode('0')) ->raw(')') ; } } ================================================ FILE: src/Node/Expression/Test/EvenTest.php ================================================ */ class EvenTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(') ->subcompile($this->getNode('node')) ->raw(' % 2 == 0') ->raw(')') ; } } ================================================ FILE: src/Node/Expression/Test/NullTest.php ================================================ */ class NullTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(null === ') ->subcompile($this->getNode('node')) ->raw(')') ; } } ================================================ FILE: src/Node/Expression/Test/OddTest.php ================================================ */ class OddTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(') ->subcompile($this->getNode('node')) ->raw(' % 2 != 0') ->raw(')') ; } } ================================================ FILE: src/Node/Expression/Test/SameasTest.php ================================================ */ class SameasTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(') ->subcompile($this->getNode('node')) ->raw(' === ') ->subcompile($this->getNode('arguments')->getNode('0')) ->raw(')') ; } } ================================================ FILE: src/Node/Expression/Test/TrueTest.php ================================================ */ class TrueTest extends TestExpression { public function compile(Compiler $compiler): void { $compiler ->raw('(($tmp = ') ->subcompile($this->getNode('node')) ->raw(') && $tmp instanceof Markup ? (string) $tmp : $tmp)') ; } } ================================================ FILE: src/Node/Expression/TestExpression.php ================================================ $node]; if (null !== $arguments) { $nodes['arguments'] = $arguments; } 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'); 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'); } } if (!$this->hasAttribute('twig_callable')) { $this->setAttribute('twig_callable', $compiler->getEnvironment()->getTest($this->getAttribute('name'))); } $this->compileCallable($compiler); } } ================================================ FILE: src/Node/Expression/Unary/AbstractUnary.php ================================================ $node], ['with_parentheses' => false], $lineno); } public function compile(Compiler $compiler): void { 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; } ================================================ FILE: src/Node/Expression/Unary/NegUnary.php ================================================ raw('-'); } } ================================================ FILE: src/Node/Expression/Unary/NotUnary.php ================================================ raw('!'); } } ================================================ FILE: src/Node/Expression/Unary/PosUnary.php ================================================ raw('+'); } } ================================================ FILE: src/Node/Expression/Unary/SpreadUnary.php ================================================ raw('...'); } } ================================================ FILE: src/Node/Expression/Unary/StringCastUnary.php ================================================ raw('(string)'); } } ================================================ FILE: src/Node/Expression/Unary/UnaryInterface.php ================================================ $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('] = ') ; } } } ================================================ FILE: src/Node/Expression/Variable/ContextVariable.php ================================================ 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(']') ; } } } ================================================ FILE: src/Node/Expression/VariadicExpression.php ================================================ raw('...'); parent::compile($compiler); } } ================================================ FILE: src/Node/FlushNode.php ================================================ */ #[YieldReady] class FlushNode extends Node { public function __construct(int $lineno) { parent::__construct([], [], $lineno); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); if ($compiler->getEnvironment()->useYield()) { $compiler->write("yield '';\n"); } $compiler->write("flush();\n"); } } ================================================ FILE: src/Node/ForElseNode.php ================================================ */ #[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") ; } } ================================================ FILE: src/Node/ForLoopNode.php ================================================ */ #[YieldReady] class ForLoopNode extends Node { public function __construct(int $lineno) { parent::__construct([], ['with_loop' => false, 'ifexpr' => false, 'else' => false], $lineno); } public function compile(Compiler $compiler): void { if ($this->getAttribute('else')) { $compiler->write("\$context['_iterated'] = true;\n"); } if ($this->getAttribute('with_loop')) { $compiler ->write("++\$context['loop']['index0'];\n") ->write("++\$context['loop']['index'];\n") ->write("\$context['loop']['first'] = false;\n") ->write("if (isset(\$context['loop']['revindex0'], \$context['loop']['revindex'])) {\n") ->indent() ->write("--\$context['loop']['revindex0'];\n") ->write("--\$context['loop']['revindex'];\n") ->write("\$context['loop']['last'] = 0 === \$context['loop']['revindex0'];\n") ->outdent() ->write("}\n") ; } } } ================================================ FILE: src/Node/ForNode.php ================================================ */ #[YieldReady] class ForNode extends Node { private $loop; public function __construct(AssignContextVariable $keyTarget, AssignContextVariable $valueTarget, AbstractExpression $seq, ?Node $ifexpr, Node $body, ?Node $else, int $lineno) { $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); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->write("\$context['_parent'] = \$context;\n") ->write("\$context['_seq'] = CoreExtension::ensureTraversable(") ->subcompile($this->getNode('seq')) ->raw(");\n") ; if ($this->hasNode('else')) { $compiler->write("\$context['_iterated'] = false;\n"); } if ($this->getAttribute('with_loop')) { $compiler ->write("\$context['loop'] = [\n") ->write(" 'parent' => \$context['_parent'],\n") ->write(" 'index0' => 0,\n") ->write(" 'index' => 1,\n") ->write(" 'first' => true,\n") ->write("];\n") ->write("if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) {\n") ->indent() ->write("\$length = count(\$context['_seq']);\n") ->write("\$context['loop']['revindex0'] = \$length - 1;\n") ->write("\$context['loop']['revindex'] = \$length;\n") ->write("\$context['loop']['length'] = \$length;\n") ->write("\$context['loop']['last'] = 1 === \$length;\n") ->outdent() ->write("}\n") ; } $this->loop->setAttribute('else', $this->hasNode('else')); $this->loop->setAttribute('with_loop', $this->getAttribute('with_loop')); $compiler ->write("foreach (\$context['_seq'] as ") ->subcompile($this->getNode('key_target')) ->raw(' => ') ->subcompile($this->getNode('value_target')) ->raw(") {\n") ->indent() ->subcompile($this->getNode('body')) ->outdent() ->write("}\n") ; if ($this->hasNode('else')) { $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[\''.$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"); } } ================================================ FILE: src/Node/IfNode.php ================================================ */ #[YieldReady] class IfNode extends Node { 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); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); for ($i = 0, $count = \count($this->getNode('tests')); $i < $count; $i += 2) { if ($i > 0) { $compiler ->outdent() ->write('} elseif (') ; } else { $compiler ->write('if (') ; } $compiler ->subcompile($this->getNode('tests')->getNode((string) $i)) ->raw(") {\n") ->indent() ; // 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')) { $compiler ->outdent() ->write("} else {\n") ->indent() ->subcompile($this->getNode('else')) ; } $compiler ->outdent() ->write("}\n"); } } ================================================ FILE: src/Node/ImportNode.php ================================================ */ #[YieldReady] class ImportNode extends Node { public function __construct(AbstractExpression $expr, AbstractExpression|AssignTemplateVariable $var, int $lineno) { 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->subcompile($this->getNode('var')); if ($this->getNode('expr') instanceof ContextVariable && '_self' === $this->getNode('expr')->getAttribute('name')) { $compiler->raw('$this'); } else { $compiler ->raw('$this->load(') ->subcompile($this->getNode('expr')) ->raw(', ') ->repr($this->getTemplateLine()) ->raw(')->unwrap()') ; } $compiler->raw(";\n"); } } ================================================ FILE: src/Node/IncludeNode.php ================================================ */ #[YieldReady] class IncludeNode extends Node implements NodeOutputInterface { 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); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); if ($this->getAttribute('ignore_missing')) { $template = $compiler->getVarName(); $compiler ->write("try {\n") ->indent() ->write(\sprintf('$%s = ', $template)) ; $this->addGetTemplate($compiler, $template); $compiler ->raw(";\n") ->outdent() ->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)) ->indent() ->write(\sprintf('yield from $%s->unwrap()->yield(', $template)) ; $this->addTemplateArguments($compiler); $compiler ->raw(");\n") ->outdent() ->write("}\n") ; } else { $compiler->write('yield from '); $this->addGetTemplate($compiler); $compiler->raw('->unwrap()->yield('); $this->addTemplateArguments($compiler); $compiler->raw(");\n"); } } /** * @return void */ protected function addGetTemplate(Compiler $compiler/* , string $template = '' */) { $compiler ->raw('$this->load(') ->subcompile($this->getNode('expr')) ->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('CoreExtension::merge($context, ') ->subcompile($this->getNode('variables')) ->raw(')') ; } else { $compiler->raw('CoreExtension::toArray('); $compiler->subcompile($this->getNode('variables')); $compiler->raw(')'); } } } ================================================ FILE: src/Node/MacroNode.php ================================================ */ #[YieldReady] class MacroNode extends Node { public const VARARGS_NAME = 'varargs'; /** * @param BodyNode $body * @param ArrayExpression $arguments */ public function __construct(string $name, Node $body, Node $arguments, int $lineno) { 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); } public function compile(Compiler $compiler): void { $compiler ->addDebugInfo($this) ->write(\sprintf('public function macro_%s(', $this->getAttribute('name'))) ; /** @var ArrayExpression $arguments */ $arguments = $this->getNode('arguments'); foreach ($arguments->getKeyValuePairs() as $pair) { $name = $pair['key']; $default = $pair['value']; $compiler ->subcompile($name) ->raw(' = ') ->subcompile($default) ->raw(', ') ; } $compiler ->raw('...$varargs') ->raw("): string|Markup\n") ->write("{\n") ->indent() ->write("\$macros = \$this->macros;\n") ->write("\$context = [\n") ->indent() ; 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($var) ->raw(' => ') ->subcompile($name) ->raw(",\n") ; } $node = new CaptureNode($this->getNode('body'), $this->getNode('body')->lineno); $compiler ->write('') ->string(self::VARARGS_NAME) ->raw(' => ') ->raw("\$varargs,\n") ->outdent() ->write("] + \$this->env->getGlobals();\n\n") ->write("\$blocks = [];\n\n") ->write('return ') ->subcompile($node) ->raw("\n") ->outdent() ->write("}\n\n") ; } } ================================================ FILE: src/Node/ModuleNode.php ================================================ */ #[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 Nodes(), 'display_end' => new Nodes(), 'constructor_start' => new Nodes(), 'constructor_end' => new Nodes(), 'class_end' => new Nodes(), ]; if (null !== $parent) { $nodes['parent'] = $parent; } // embedded templates are set as attributes so that they are only visited once by the visitors parent::__construct($nodes, [ 'index' => null, 'embedded_templates' => $embeddedTemplates, ], 1); // populate the template name of all node children $this->setSourceContext($source); } /** * @return void */ public function setIndex($index) { $this->setAttribute('index', $index); } public function compile(Compiler $compiler): void { $this->compileTemplate($compiler); foreach ($this->getAttribute('embedded_templates') as $template) { $compiler->subcompile($template); } } /** * @return void */ protected function compileTemplate(Compiler $compiler) { if (!$this->getAttribute('index')) { $compiler->write('compileClassHeader($compiler); $this->compileConstructor($compiler); $this->compileGetParent($compiler); $this->compileDisplay($compiler); $compiler->subcompile($this->getNode('blocks')); $this->compileMacros($compiler); $this->compileGetTemplateName($compiler); $this->compileIsTraitable($compiler); $this->compileDebugInfo($compiler); $this->compileGetSourceContext($compiler); $this->compileClassFooter($compiler); } /** * @return void */ protected function compileGetParent(Compiler $compiler) { if (!$this->hasNode('parent')) { return; } $parent = $this->getNode('parent'); $compiler ->write("protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper\n", "{\n") ->indent() ->addDebugInfo($parent) ->write('return ') ; if ($parent instanceof ConstantExpression) { $compiler->subcompile($parent); } else { $compiler ->raw('$this->load(') ->subcompile($parent) ->raw(', ') ->repr($parent->getTemplateLine()) ->raw(')') ; } $compiler ->raw(";\n") ->outdent() ->write("}\n\n") ; } /** * @return void */ protected function compileClassHeader(Compiler $compiler) { $compiler ->write("\n\n") ; if (!$this->getAttribute('index')) { $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") ->write("use Twig\Sandbox\SecurityNotAllowedTagError;\n") ->write("use Twig\Sandbox\SecurityNotAllowedFilterError;\n") ->write("use Twig\Sandbox\SecurityNotAllowedFunctionError;\n") ->write("use Twig\Source;\n") ->write("use Twig\Template;\n") ->write("use Twig\TemplateWrapper;\n") ->write("\n") ; } $compiler // if the template name contains */, add a blank to avoid a PHP parse error ->write('/* '.str_replace('*/', '* /', $this->getSourceContext()->getName())." */\n") ->write('class '.$compiler->getEnvironment()->getTemplateClass($this->getSourceContext()->getName(), $this->getAttribute('index'))) ->raw(" extends Template\n") ->write("{\n") ->indent() ->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 ->write("public function __construct(Environment \$env)\n", "{\n") ->indent() ->subcompile($this->getNode('constructor_start')) ->write("parent::__construct(\$env);\n\n") ->write("\$this->source = \$this->getSourceContext();\n\n") ; // parent if (!$this->hasNode('parent')) { $compiler->write("\$this->parent = false;\n\n"); } $countTraits = \count($this->getNode('traits')); if ($countTraits) { // traits foreach ($this->getNode('traits') as $i => $trait) { $node = $trait->getNode('template'); $compiler ->addDebugInfo($node) ->write(\sprintf('$_trait_%s = $this->load(', $i)) ->subcompile($node) ->raw(', ') ->repr($node->getTemplateLine()) ->raw(");\n") ->write(\sprintf("if (!\$_trait_%s->unwrap()->isTraitable()) {\n", $i)) ->indent() ->write("throw new RuntimeError('Template \"'.") ->subcompile($trait->getNode('template')) ->raw(".'\" cannot be used as a trait.', ") ->repr($node->getTemplateLine()) ->raw(", \$this->source);\n") ->outdent() ->write("}\n") ->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)) ->string($key) ->raw("])) {\n") ->indent() ->write("throw new RuntimeError('Block ") ->string($key) ->raw(' is not defined in trait ') ->subcompile($trait->getNode('template')) ->raw(".', ") ->repr($node->getTemplateLine()) ->raw(", \$this->source);\n") ->outdent() ->write("}\n\n") ->write(\sprintf('$_trait_%s_blocks[', $i)) ->subcompile($value) ->raw(\sprintf('] = $_trait_%s_blocks[', $i)) ->string($key) ->raw(\sprintf(']; unset($_trait_%s_blocks[', $i)) ->string($key) ->raw(']); $this->traitAliases[') ->subcompile($value) ->raw('] = ') ->string($key) ->raw(";\n\n") ; } } if ($countTraits > 1) { $compiler ->write("\$this->traits = array_merge(\n") ->indent() ; for ($i = 0; $i < $countTraits; ++$i) { $compiler ->write(\sprintf('$_trait_%s_blocks'.($i == $countTraits - 1 ? '' : ',')."\n", $i)) ; } $compiler ->outdent() ->write(");\n\n") ; } else { $compiler ->write("\$this->traits = \$_trait_0_blocks;\n\n") ; } $compiler ->write("\$this->blocks = array_merge(\n") ->indent() ->write("\$this->traits,\n") ->write("[\n") ; } else { $compiler ->write("\$this->blocks = [\n") ; } // blocks $compiler ->indent() ; foreach ($this->getNode('blocks') as $name => $node) { $compiler ->write(\sprintf("'%s' => [\$this, 'block_%s'],\n", $name, $name)) ; } if ($countTraits) { $compiler ->outdent() ->write("]\n") ->outdent() ->write(");\n") ; } else { $compiler ->outdent() ->write("];\n") ; } $compiler ->subcompile($this->getNode('constructor_end')) ->outdent() ->write("}\n\n") ; } /** * @return void */ protected function compileDisplay(Compiler $compiler) { $compiler ->write("protected function doDisplay(array \$context, array \$blocks = []): iterable\n", "{\n") ->indent() ->write("\$macros = \$this->macros;\n") ->subcompile($this->getNode('display_start')) ->subcompile($this->getNode('body')) ; if ($this->hasNode('parent')) { $parent = $this->getNode('parent'); $compiler->addDebugInfo($parent); if ($parent instanceof ConstantExpression) { $compiler ->write('$this->parent = $this->load(') ->subcompile($parent) ->raw(', ') ->repr($parent->getTemplateLine()) ->raw(");\n") ; } $compiler->write('yield from '); if ($parent instanceof ConstantExpression) { $compiler->raw('$this->parent'); } else { $compiler->raw('$this->getParent($context)'); } $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 ->outdent() ->write("}\n\n") ; } /** * @return void */ protected function compileClassFooter(Compiler $compiler) { $compiler ->subcompile($this->getNode('class_end')) ->outdent() ->write("}\n") ; } /** * @return void */ protected function compileMacros(Compiler $compiler) { $compiler->subcompile($this->getNode('macros')); } /** * @return void */ protected function compileGetTemplateName(Compiler $compiler) { $compiler ->write("/**\n") ->write(" * @codeCoverageIgnore\n") ->write(" */\n") ->write("public function getTemplateName(): string\n", "{\n") ->indent() ->write('return ') ->repr($this->getSourceContext()->getName()) ->raw(";\n") ->outdent() ->write("}\n\n") ; } /** * @return void */ protected function compileIsTraitable(Compiler $compiler) { // A template can be used as a trait if: // * it has no parent // * it has no macros // * it has no body // // Put another way, a template can be used as a trait if it // only contains blocks and use statements. $traitable = !$this->hasNode('parent') && 0 === \count($this->getNode('macros')); if ($traitable) { if ($this->getNode('body') instanceof BodyNode) { $nodes = $this->getNode('body')->getNode('0'); } else { $nodes = $this->getNode('body'); } if (!\count($nodes)) { $nodes = new Nodes([$nodes]); } foreach ($nodes as $node) { if (!\count($node)) { continue; } $traitable = false; break; } } if ($traitable) { return; } $compiler ->write("/**\n") ->write(" * @codeCoverageIgnore\n") ->write(" */\n") ->write("public function isTraitable(): bool\n", "{\n") ->indent() ->write("return false;\n") ->outdent() ->write("}\n\n") ; } /** * @return void */ protected function compileDebugInfo(Compiler $compiler) { $compiler ->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)))) ->outdent() ->write("}\n\n") ; } /** * @return void */ protected function compileGetSourceContext(Compiler $compiler) { $compiler ->write("public function getSourceContext(): Source\n", "{\n") ->indent() ->write('return new Source(') ->string($compiler->getEnvironment()->isDebug() ? $this->getSourceContext()->getCode() : '') ->raw(', ') ->string($this->getSourceContext()->getName()) ->raw(', ') ->string($this->getSourceContext()->getPath()) ->raw(");\n") ->outdent() ->write("}\n") ; } } ================================================ FILE: src/Node/NameDeprecation.php ================================================ */ 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; } } ================================================ FILE: src/Node/Node.php ================================================ * * @implements \IteratorAggregate */ #[YieldReady] class Node implements \Countable, \IteratorAggregate { /** * @var array */ protected $nodes; protected $attributes; protected $lineno; protected $tag; 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 */ 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.', get_debug_type($node), $name, static::class)); } } $this->nodes = $nodes; $this->attributes = $attributes; $this->lineno = $lineno; 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(): string { $repr = static::class; if ($this->tag) { $repr .= \sprintf("\n tag: %s", $this->tag); } $attributes = []; foreach ($this->attributes as $name => $value) { 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); } 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) + 6; $noderepr = []; foreach (explode("\n", (string) $node) as $line) { $noderepr[] = str_repeat(' ', $len).$line; } $repr .= \sprintf("\n %s: %s", $name, ltrim(implode("\n", $noderepr))); } } return $repr; } public function __clone() { foreach ($this->nodes as $name => $node) { $this->nodes[$name] = clone $node; } } /** * @return void */ public function compile(Compiler $compiler) { foreach ($this->nodes as $node) { $compiler->subcompile($node); } } public function getTemplateLine(): int { return $this->lineno; } 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); } 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)); } $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]; } 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)); } $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 */ #[\ReturnTypeWillChange] public function count() { return \count($this->nodes); } public function getIterator(): \Traversable { return new \ArrayIterator($this->nodes); } public function getTemplateName(): ?string { return $this->sourceContext ? $this->sourceContext->getName() : null; } public function setSourceContext(Source $source): void { $this->sourceContext = $source; foreach ($this->nodes as $node) { $node->setSourceContext($source); } } public function getSourceContext(): ?Source { return $this->sourceContext; } } ================================================ FILE: src/Node/NodeCaptureInterface.php ================================================ */ interface NodeCaptureInterface { } ================================================ FILE: src/Node/NodeOutputInterface.php ================================================ */ interface NodeOutputInterface { } ================================================ FILE: src/Node/Nodes.php ================================================ */ #[YieldReady] final class Nodes extends Node { public function __construct(array $nodes = [], int $lineno = 0) { parent::__construct($nodes, [], $lineno); } } ================================================ FILE: src/Node/PrintNode.php ================================================ */ #[YieldReady] class PrintNode extends Node implements NodeOutputInterface { public function __construct(AbstractExpression $expr, int $lineno) { parent::__construct(['expr' => $expr], [], $lineno); } public function compile(Compiler $compiler): void { /** @var AbstractExpression */ $expr = $this->getNode('expr'); $compiler ->addDebugInfo($this) ->write($expr->isGenerator() ? 'yield from ' : 'yield ') ->subcompile($expr) ->raw(";\n") ; } } ================================================ FILE: src/Node/SandboxNode.php ================================================ */ #[YieldReady] class SandboxNode 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 (!\$alreadySandboxed = \$this->sandbox->isSandboxed()) {\n") ->indent() ->write("\$this->sandbox->enableSandbox();\n") ->outdent() ->write("}\n") ->write("try {\n") ->indent() ->subcompile($this->getNode('body')) ->outdent() ->write("} finally {\n") ->indent() ->write("if (!\$alreadySandboxed) {\n") ->indent() ->write("\$this->sandbox->disableSandbox();\n") ->outdent() ->write("}\n") ->outdent() ->write("}\n") ; } } ================================================ FILE: src/Node/SetNode.php ================================================ */ #[YieldReady] class SetNode extends Node implements NodeCaptureInterface { public function __construct(bool $capture, Node $names, Node $values, int $lineno) { /* * 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"); */ $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 { $compiler->addDebugInfo($this); if (\count($this->getNode('names')) > 1) { $compiler->write('['); foreach ($this->getNode('names') as $idx => $node) { if ($idx) { $compiler->raw(', '); } $compiler->subcompile($node); } $compiler->raw(']'); } else { $compiler->subcompile($this->getNode('names'), false); } $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) { if ($idx) { $compiler->raw(', '); } $compiler->subcompile($value); } $compiler->raw(']'); } else { if ($this->getAttribute('safe')) { 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"); } } ================================================ FILE: src/Node/TextNode.php ================================================ */ #[YieldReady] class TextNode extends Node implements NodeOutputInterface { public function __construct(string $data, int $lineno) { parent::__construct([], ['data' => $data], $lineno); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $compiler ->write('yield ') ->string($this->getAttribute('data')) ->raw(";\n") ; } } ================================================ FILE: src/Node/TypesNode.php ================================================ */ #[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. } } ================================================ FILE: src/Node/WithNode.php ================================================ */ #[YieldReady] class WithNode extends Node { 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); } public function compile(Compiler $compiler): void { $compiler->addDebugInfo($this); $parentContextName = $compiler->getVarName(); $compiler->write(\sprintf("\$%s = \$context;\n", $parentContextName)); if ($this->hasNode('variables')) { $node = $this->getNode('variables'); $varsName = $compiler->getVarName(); $compiler ->write(\sprintf('$%s = ', $varsName)) ->subcompile($node) ->raw(";\n") ->write(\sprintf("if (!is_iterable(\$%s)) {\n", $varsName)) ->indent() ->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 = CoreExtension::toArray(\$%s);\n", $varsName, $varsName)) ; if ($this->getAttribute('only')) { $compiler->write("\$context = [];\n"); } $compiler->write(\sprintf("\$context = \$%s + \$context + \$this->env->getGlobals();\n", $varsName)); } $compiler ->subcompile($this->getNode('body')) ->write(\sprintf("\$context = \$%s;\n", $parentContextName)) ; } } ================================================ FILE: src/NodeTraverser.php ================================================ */ final class NodeTraverser { private $env; private $visitors = []; /** * @param NodeVisitorInterface[] $visitors */ public function __construct(Environment $env, array $visitors = []) { $this->env = $env; foreach ($visitors as $visitor) { $this->addVisitor($visitor); } } public function addVisitor(NodeVisitorInterface $visitor): void { $this->visitors[$visitor->getPriority()][] = $visitor; } /** * Traverses a node and calls the registered visitors. */ public function traverse(Node $node): Node { ksort($this->visitors); foreach ($this->visitors as $visitors) { foreach ($visitors as $visitor) { $node = $this->traverseForVisitor($visitor, $node); } } return $node; } private function traverseForVisitor(NodeVisitorInterface $visitor, Node $node): ?Node { $node = $visitor->enterNode($node, $this->env); foreach ($node as $k => $n) { if (null !== $m = $this->traverseForVisitor($visitor, $n)) { if ($m !== $n) { $node->setNode($k, $m); } } else { $node->removeNode($k); } } return $visitor->leaveNode($node, $this->env); } } ================================================ FILE: src/NodeVisitor/AbstractNodeVisitor.php ================================================ * * @deprecated since Twig 3.9 (to be removed in 4.0) */ abstract class AbstractNodeVisitor implements NodeVisitorInterface { final public function enterNode(Node $node, Environment $env): Node { return $this->doEnterNode($node, $env); } final public function leaveNode(Node $node, Environment $env): ?Node { return $this->doLeaveNode($node, $env); } /** * Called before child nodes are visited. * * @return Node The modified node */ abstract protected function doEnterNode(Node $node, Environment $env); /** * Called after child nodes are visited. * * @return Node|null The modified node or null if the node must be removed */ abstract protected function doLeaveNode(Node $node, Environment $env); } ================================================ FILE: src/NodeVisitor/EscaperNodeVisitor.php ================================================ * * @internal */ final class EscaperNodeVisitor implements NodeVisitorInterface { private $statusStack = []; private $blocks = []; private $safeAnalysis; private $traverser; private $defaultStrategy = false; private $safeVars = []; public function __construct() { $this->safeAnalysis = new SafeAnalysisNodeVisitor(); } public function enterNode(Node $node, Environment $env): Node { if ($node instanceof ModuleNode) { if ($env->hasExtension(EscaperExtension::class) && $defaultStrategy = $env->getExtension(EscaperExtension::class)->getDefaultStrategy($node->getTemplateName())) { $this->defaultStrategy = $defaultStrategy; } $this->safeVars = []; $this->blocks = []; } elseif ($node instanceof AutoEscapeNode) { $this->statusStack[] = $node->getAttribute('value'); } elseif ($node instanceof BlockNode) { $this->statusStack[] = $this->blocks[$node->getAttribute('name')] ?? $this->needEscaping(); } elseif ($node instanceof ImportNode) { $this->safeVars[] = $node->getNode('var')->getNode('var')->getAttribute('name'); } return $node; } public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ModuleNode) { $this->defaultStrategy = false; $this->safeVars = []; $this->blocks = []; } elseif ($node instanceof FilterExpression) { return $this->preEscapeFilterNode($node, $env); } elseif ($node instanceof PrintNode && false !== $type = $this->needEscaping()) { $expression = $node->getNode('expr'); if ($expression instanceof OperatorEscapeInterface) { $this->escapeConditional($expression, $env, $type); } else { $node->setNode('expr', $this->escapeExpression($expression, $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(); } return $node; } /** * @param AbstractExpression&OperatorEscapeInterface $expression */ private function escapeConditional($expression, Environment $env, string $type): void { 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)); } } } private function escapeExpression(AbstractExpression $expression, Environment $env, string $type): AbstractExpression { return $this->isSafeFor($type, $expression, $env) ? $expression : $this->getEscaperFilter($env, $type, $expression); } private function preEscapeFilterNode(FilterExpression $filter, Environment $env): FilterExpression { 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(); } 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($env, $type, $node)); return $filter; } private function isSafeFor(string $type, AbstractExpression $expression, Environment $env): bool { $safe = $this->safeAnalysis->getSafe($expression); if (!$safe) { if (null === $this->traverser) { $this->traverser = new NodeTraverser($env, [$this->safeAnalysis]); } $this->safeAnalysis->setSafeVars($this->safeVars); $this->traverser->traverse($expression); $safe = $this->safeAnalysis->getSafe($expression); } return \in_array($type, $safe, true) || \in_array('all', $safe, true); } /** * @return string|false */ private function needEscaping(): string|bool { if (\count($this->statusStack)) { return $this->statusStack[\count($this->statusStack) - 1]; } return $this->defaultStrategy ?: false; } private function getEscaperFilter(Environment $env, string $type, AbstractExpression $node): FilterExpression { $line = $node->getTemplateLine(); $filter = $env->getFilter('escape'); $args = new Nodes([new ConstantExpression($type, $line), new ConstantExpression(null, $line), new ConstantExpression(true, $line)]); return new FilterExpression($node, $filter, $args, $line); } public function getPriority(): int { return 0; } } ================================================ FILE: src/NodeVisitor/NodeVisitorInterface.php ================================================ */ interface NodeVisitorInterface { /** * Called before child nodes are visited. * * @return Node The modified node */ public function enterNode(Node $node, Environment $env): Node; /** * Called after child nodes are visited. * * @return Node|null The modified node or null if the node must be removed */ public function leaveNode(Node $node, Environment $env): ?Node; /** * Returns the priority for this visitor. * * Priority should be between -10 and 10 (0 is the default). * * @return int The priority level */ public function getPriority(); } ================================================ FILE: src/NodeVisitor/OptimizerNodeVisitor.php ================================================ * * @internal */ final class OptimizerNodeVisitor implements NodeVisitorInterface { public const OPTIMIZE_ALL = -1; 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 = []; /** * @param int $optimizers The optimizer mode */ 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.'); } 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); } return $node; } public function leaveNode(Node $node, Environment $env): ?Node { if (self::OPTIMIZE_FOR === (self::OPTIMIZE_FOR & $this->optimizers)) { $this->leaveOptimizeFor($node); } $node = $this->optimizePrintNode($node); return $node; } /** * Optimizes print nodes. * * It replaces: * * * "echo $this->render(Parent)Block()" with "$this->display(Parent)Block()" */ 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->setAttribute('output', true); return $exprNode; } return $node; } /** * Optimizes "for" tag by removing the "loop" variable creation whenever possible. */ private function enterOptimizeFor(Node $node): void { if ($node instanceof ForNode) { // disable the loop variable by default $node->setAttribute('with_loop', false); array_unshift($this->loops, $node); array_unshift($this->loopsTargets, $node->getNode('value_target')->getAttribute('name')); array_unshift($this->loopsTargets, $node->getNode('key_target')->getAttribute('name')); } elseif (!$this->loops) { // we are outside a loop return; } // when do we need to add the loop variable back? // the loop variable is referenced for the current loop elseif ($node instanceof ContextVariable && 'loop' === $node->getAttribute('name')) { $node->setAttribute('always_defined', true); $this->addLoopToCurrent(); } // optimize access to loop targets elseif ($node instanceof ContextVariable && \in_array($node->getAttribute('name'), $this->loopsTargets, true)) { $node->setAttribute('always_defined', true); } // block reference elseif ($node instanceof BlockReferenceNode || $node instanceof BlockReferenceExpression) { $this->addLoopToCurrent(); } // include without the only attribute elseif ($node instanceof IncludeNode && !$node->getAttribute('only')) { $this->addLoopToAll(); } // include function without the with_context=false parameter elseif ($node instanceof FunctionExpression && 'include' === $node->getAttribute('name') && (!$node->getNode('arguments')->hasNode('with_context') || false !== $node->getNode('arguments')->getNode('with_context')->getAttribute('value') ) ) { $this->addLoopToAll(); } // the loop variable is referenced via an attribute 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 ContextVariable && 'loop' === $node->getNode('node')->getAttribute('name') ) ) ) { $this->addLoopToAll(); } } /** * Optimizes "for" tag by removing the "loop" variable creation whenever possible. */ private function leaveOptimizeFor(Node $node): void { if ($node instanceof ForNode) { array_shift($this->loops); array_shift($this->loopsTargets); array_shift($this->loopsTargets); } } private function addLoopToCurrent(): void { $this->loops[0]->setAttribute('with_loop', true); } private function addLoopToAll(): void { foreach ($this->loops as $loop) { $loop->setAttribute('with_loop', true); } } public function getPriority(): int { return 255; } } ================================================ FILE: src/NodeVisitor/SafeAnalysisNodeVisitor.php ================================================ safeVars = $safeVars; } /** * @return array */ public function getSafe(Node $node) { $hash = spl_object_id($node); if (!isset($this->data[$hash])) { return []; } foreach ($this->data[$hash] as $bucket) { if ($bucket['key'] !== $node) { continue; } if (\in_array('html_attr', $bucket['value'], true)) { $bucket['value'][] = 'html'; $bucket['value'][] = 'html_attr_relaxed'; } if (\in_array('html_attr_relaxed', $bucket['value'], true)) { $bucket['value'][] = 'html'; } return $bucket['value']; } return []; } private function setSafe(Node $node, array $safe): void { $hash = spl_object_id($node); if (isset($this->data[$hash])) { foreach ($this->data[$hash] as &$bucket) { if ($bucket['key'] === $node) { $bucket['value'] = $safe; return; } } } $this->data[$hash][] = [ 'key' => $node, 'value' => $safe, ]; } public function enterNode(Node $node, Environment $env): Node { return $node; } public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ConstantExpression) { // constants are marked safe for all $this->setSafe($node, ['all']); } elseif ($node instanceof BlockReferenceExpression) { // blocks are safe by definition $this->setSafe($node, ['all']); } elseif ($node instanceof ParentExpression) { // parent block is safe by definition $this->setSafe($node, ['all']); } 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 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); } } elseif ($node instanceof FunctionExpression) { // function expression is safe when the function is safe if ($node->hasAttribute('twig_callable')) { $function = $node->getAttribute('twig_callable'); } else { // legacy $function = $env->getFunction($node->getAttribute('name')); } 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 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, true)) { $this->setSafe($node, ['all']); } } return $node; } private function intersectSafe(array $a, array $b): array { if (!$a || !$b) { return []; } if (\in_array('all', $a, true)) { return $b; } if (\in_array('all', $b, true)) { return $a; } return array_intersect($a, $b); } public function getPriority(): int { return 0; } } ================================================ FILE: src/NodeVisitor/SandboxNodeVisitor.php ================================================ * * @internal */ final class SandboxNodeVisitor implements NodeVisitorInterface { private $inAModule = false; /** @var array */ private $tags; /** @var array */ private $filters; /** @var array */ private $functions; private $needsToStringWrap = false; public function enterNode(Node $node, Environment $env): Node { if ($node instanceof ModuleNode) { $this->inAModule = true; $this->tags = []; $this->filters = []; $this->functions = []; return $node; } elseif ($this->inAModule) { // look for tags if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()])) { $this->tags[$node->getNodeTag()] = $node->getTemplateLine(); } // look for filters 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->getTemplateLine(); } // the .. operator is equivalent to the range() function if ($node instanceof RangeBinary && !isset($this->functions['range'])) { $this->functions['range'] = $node->getTemplateLine(); } if ($node instanceof PrintNode) { $this->needsToStringWrap = true; $this->wrapNode($node, 'expr'); } if ($node instanceof SetNode && !$node->getAttribute('capture')) { $this->needsToStringWrap = true; } // wrap outer nodes that can implicitly call __toString() if ($this->needsToStringWrap) { if ($node instanceof ConcatBinary) { $this->wrapNode($node, 'left'); $this->wrapNode($node, 'right'); } if ($node instanceof FilterExpression) { $this->wrapNode($node, 'node'); $this->wrapArrayNode($node, 'arguments'); } if ($node instanceof FunctionExpression) { $this->wrapArrayNode($node, 'arguments'); } } } return $node; } public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ModuleNode) { $this->inAModule = false; $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; } } return $node; } private function wrapNode(Node $node, string $name): void { $expr = $node->getNode($name); 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); } } } private function wrapArrayNode(Node $node, string $name): void { $args = $node->getNode($name); foreach ($args as $name => $_) { $this->wrapNode($args, $name); } } public function getPriority(): int { return 0; } } ================================================ FILE: src/NodeVisitor/YieldNotReadyNodeVisitor.php ================================================ 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; } } ================================================ FILE: src/OperatorPrecedenceChange.php ================================================ * * @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); } } ================================================ FILE: src/Parser.php ================================================ */ class Parser { private $stack = []; private ?\WeakMap $expressionRefs = null; private $stream; private $parent; private $visitors; private $expressionParser; private $blocks; private $blockStack; private $macros; private $importedSymbols; private $traits; private $embeddedTemplates = []; private $varNameSalt = 0; private $ignoreUnknownTwigCallables = false; private ExpressionParsers $parsers; public function __construct( private Environment $env, ) { $this->parsers = $env->getExpressionParsers(); } public function getEnvironment(): Environment { return $this->env; } public function getVarName(): string { trigger_deprecation('twig/twig', '3.15', 'The "%s()" method is deprecated.', __METHOD__); return \sprintf('__internal_parse_%d', $this->varNameSalt++); } /** * @throws SyntaxError */ public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode { $vars = get_object_vars($this); unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['expressionParser'], $vars['reservedMacroNames'], $vars['varNameSalt']); $this->stack[] = $vars; // node visitors if (null === $this->visitors) { $this->visitors = $this->env->getNodeVisitors(); } $this->stream = $stream; $this->parent = null; $this->blocks = []; $this->macros = []; $this->traits = []; $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 EmptyNode(); } } catch (SyntaxError $e) { if (!$e->getSourceContext()) { $e->setSourceContext($this->stream->getSourceContext()); } if (!$e->getTemplateLine()) { $e->setTemplateLine($this->getCurrentToken()->getLine()); } throw $e; } finally { $this->expressionRefs = null; } $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 foreach (array_pop($this->stack) as $key => $val) { $this->$key = $val; } 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; } } /** * @throws SyntaxError */ public function subparse($test, bool $dropNeedle = false): Node { $lineno = $this->getCurrentToken()->getLine(); $rv = []; while (!$this->stream->isEOF()) { switch (true) { case $this->stream->getCurrent()->test(Token::TEXT_TYPE): $token = $this->stream->next(); $rv[] = new TextNode($token->getValue(), $token->getLine()); break; case $this->stream->getCurrent()->test(Token::VAR_START_TYPE): $token = $this->stream->next(); $expr = $this->parseExpression(); $this->stream->expect(Token::VAR_END_TYPE); $rv[] = new PrintNode($expr, $token->getLine()); break; case $this->stream->getCurrent()->test(Token::BLOCK_START_TYPE): $this->stream->next(); $token = $this->getCurrentToken(); if (!$token->test(Token::NAME_TYPE)) { throw new SyntaxError('A block must start with a tag name.', $token->getLine(), $this->stream->getSourceContext()); } if (null !== $test && $test($token)) { if ($dropNeedle) { $this->stream->next(); } if (1 === \count($rv)) { return $rv[0]; } 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()); $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->addSuggestions($token->getValue(), array_keys($this->env->getTokenParsers())); } throw $e; } $this->stream->next(); $subparser->setParser($this); $node = $subparser->parse($token); 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('The lexer or the parser ended up in an unsupported state.', $this->getCurrentToken()->getLine(), $this->stream->getSourceContext()); } } if (1 === \count($rv)) { return $rv[0]; } 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; } public function popBlockStack(): void { array_pop($this->blockStack); } public function pushBlockStack($name): void { $this->blockStack[] = $name; } 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]); } public function setMacro(string $name, MacroNode $node): void { $this->macros[$name] = $node; } public function addTrait($trait): void { $this->traits[] = $trait; } 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()); $this->embeddedTemplates[] = $template; } public function addImportedSymbol(string $type, string $alias, ?string $name = null, AbstractExpression|AssignTemplateVariable|null $internalRef = null): void { 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) return $this->importedSymbols[0][$type][$alias] ?? ($this->importedSymbols[\count($this->importedSymbols) - 1][$type][$alias] ?? null); } public function isMainScope(): bool { return 1 === \count($this->importedSymbols); } public function pushLocalScope(): void { array_unshift($this->importedSymbols, []); } 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 !== $parent && !$parent instanceof AbstractExpression) { trigger_deprecation('twig/twig', '3.24', 'Passing a "%s" instance to "%s()" is deprecated, pass an "AbstractExpression" instance instead.', $parent::class, __METHOD__); } if (null !== $this->parent) { throw new SyntaxError('Multiple extends tags are forbidden.', $parent->getTemplateLine(), $parent->getSourceContext()); } $this->parent = $parent; } public function getStream(): TokenStream { return $this->stream; } 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, static 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, static 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, static 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) ) { 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 return null; } } throw new SyntaxError('A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag?', $node->getTemplateLine(), $this->stream->getSourceContext()); } // bypass nodes that "capture" the output if ($node instanceof NodeCaptureInterface) { // a "block" tag in such a node will serve as a block definition AND be displayed in place as well return $node; } // "block" tags that are not captured (see above) are only used for defining // the content of the block. In such a case, nesting it does not work as // expected as the definition is not part of the default template code flow. if ($nested && $node instanceof BlockReferenceNode) { throw new SyntaxError('A block definition cannot be nested under non-capturing nodes.', $node->getTemplateLine(), $this->stream->getSourceContext()); } if ($node instanceof NodeOutputInterface) { return null; } // here, $nested means "being at the root level of a child template" // we need to discard the wrapping "Node" for the "body" 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); } } 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())); } } } } } ================================================ FILE: src/Profiler/Dumper/BaseDumper.php ================================================ */ abstract class BaseDumper { private $root; public function dump(Profile $profile): string { return $this->dumpProfile($profile); } abstract protected function formatTemplate(Profile $profile, $prefix): string; abstract protected function formatNonTemplate(Profile $profile, $prefix): string; abstract protected function formatTime(Profile $profile, $percent): string; private function dumpProfile(Profile $profile, $prefix = '', $sibling = false): string { if ($profile->isRoot()) { $this->root = $profile->getDuration(); $start = $profile->getName(); } else { if ($profile->isTemplate()) { $start = $this->formatTemplate($profile, $prefix); } else { $start = $this->formatNonTemplate($profile, $prefix); } $prefix .= $sibling ? '│ ' : ' '; } $percent = $this->root ? $profile->getDuration() / $this->root * 100 : 0; if ($profile->getDuration() * 1000 < 1) { $str = $start."\n"; } else { $str = \sprintf("%s %s\n", $start, $this->formatTime($profile, $percent)); } $nCount = \count($profile->getProfiles()); foreach ($profile as $i => $p) { $str .= $this->dumpProfile($p, $prefix, $i + 1 !== $nCount); } return $str; } } ================================================ FILE: src/Profiler/Dumper/BlackfireDumper.php ================================================ */ final class BlackfireDumper { public function dump(Profile $profile): string { $data = []; $this->dumpProfile('main()', $profile, $data); $this->dumpChildren('main()', $profile, $data); $start = \sprintf('%f', microtime(true)); $str = << $values) { $str .= "$name//{$values['ct']} {$values['wt']} {$values['mu']} {$values['pmu']}\n"; } return $str; } private function dumpChildren(string $parent, Profile $profile, &$data): void { foreach ($profile as $p) { if ($p->isTemplate()) { $name = $p->getTemplate(); } else { $name = \sprintf('%s::%s(%s)', $p->getTemplate(), $p->getType(), $p->getName()); } $this->dumpProfile(\sprintf('%s==>%s', $parent, $name), $p, $data); $this->dumpChildren($name, $p, $data); } } private function dumpProfile(string $edge, Profile $profile, &$data): void { if (isset($data[$edge])) { ++$data[$edge]['ct']; $data[$edge]['wt'] += floor($profile->getDuration() * 1000000); $data[$edge]['mu'] += $profile->getMemoryUsage(); $data[$edge]['pmu'] += $profile->getPeakMemoryUsage(); } else { $data[$edge] = [ 'ct' => 1, 'wt' => floor($profile->getDuration() * 1000000), 'mu' => $profile->getMemoryUsage(), 'pmu' => $profile->getPeakMemoryUsage(), ]; } } } ================================================ FILE: src/Profiler/Dumper/HtmlDumper.php ================================================ */ final class HtmlDumper extends BaseDumper { private static $colors = [ 'block' => '#dfd', 'macro' => '#ddf', 'template' => '#ffd', 'big' => '#d44', ]; public function dump(Profile $profile): string { return '
    '.parent::dump($profile).'
    '; } protected function formatTemplate(Profile $profile, $prefix): string { 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(), 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); } } ================================================ FILE: src/Profiler/Dumper/TextDumper.php ================================================ */ final class TextDumper extends BaseDumper { protected function formatTemplate(Profile $profile, $prefix): string { 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()); } protected function formatTime(Profile $profile, $percent): string { return \sprintf('%.2fms/%.0f%%', $profile->getDuration() * 1000, $percent); } } ================================================ FILE: src/Profiler/Node/EnterProfileNode.php ================================================ */ #[YieldReady] class EnterProfileNode extends Node { public function __construct(string $extensionName, string $type, string $name, string $varName) { parent::__construct([], ['extension_name' => $extensionName, 'name' => $name, 'type' => $type, 'var_name' => $varName]); } public function compile(Compiler $compiler): void { $compiler ->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')) ->repr($this->getAttribute('type')) ->raw(', ') ->repr($this->getAttribute('name')) ->raw("));\n\n") ; } } ================================================ FILE: src/Profiler/Node/LeaveProfileNode.php ================================================ */ #[YieldReady] class LeaveProfileNode extends Node { public function __construct(string $varName) { parent::__construct([], ['var_name' => $varName]); } 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')) ; } } ================================================ FILE: src/Profiler/NodeVisitor/ProfilerNodeVisitor.php ================================================ */ final class ProfilerNodeVisitor implements NodeVisitorInterface { private $varName; 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 { return $node; } public function leaveNode(Node $node, Environment $env): ?Node { if ($node instanceof ModuleNode) { $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), $node->getNode('body'), new LeaveProfileNode($this->varName), ])); } elseif ($node instanceof MacroNode) { $node->setNode('body', new BodyNode([ new EnterProfileNode($this->extensionName, Profile::MACRO, $node->getAttribute('name'), $this->varName), $node->getNode('body'), new LeaveProfileNode($this->varName), ])); } return $node; } public function getPriority(): int { return 0; } } ================================================ FILE: src/Profiler/Profile.php ================================================ */ final class Profile implements \IteratorAggregate, \Serializable { public const ROOT = 'ROOT'; public const BLOCK = 'block'; public const TEMPLATE = 'template'; public const MACRO = 'macro'; private $starts = []; private $ends = []; private $profiles = []; 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(); } public function getTemplate(): string { return $this->template; } public function getType(): string { return $this->type; } public function getName(): string { return $this->name; } public function isRoot(): bool { return self::ROOT === $this->type; } public function isTemplate(): bool { return self::TEMPLATE === $this->type; } public function isBlock(): bool { return self::BLOCK === $this->type; } public function isMacro(): bool { return self::MACRO === $this->type; } /** * @return Profile[] */ public function getProfiles(): array { return $this->profiles; } public function addProfile(self $profile): void { $this->profiles[] = $profile; } /** * Returns the duration in microseconds. */ public function getDuration(): float { if ($this->isRoot() && $this->profiles) { // for the root node with children, duration is the sum of all child durations $duration = 0; foreach ($this->profiles as $profile) { $duration += $profile->getDuration(); } return $duration; } 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. */ public function getMemoryUsage(): int { return isset($this->ends['mu']) && isset($this->starts['mu']) ? $this->ends['mu'] - $this->starts['mu'] : 0; } /** * Returns the peak memory usage in bytes. */ public function getPeakMemoryUsage(): int { return isset($this->ends['pmu']) && isset($this->starts['pmu']) ? $this->ends['pmu'] - $this->starts['pmu'] : 0; } /** * Starts the profiling. */ public function enter(): void { $this->starts = [ 'wt' => microtime(true), 'mu' => memory_get_usage(), 'pmu' => memory_get_peak_usage(), ]; } /** * Stops the profiling. */ public function leave(): void { $this->ends = [ 'wt' => microtime(true), 'mu' => memory_get_usage(), 'pmu' => memory_get_peak_usage(), ]; } public function reset(): void { $this->starts = $this->ends = $this->profiles = []; $this->enter(); } public function getIterator(): \Traversable { return new \ArrayIterator($this->profiles); } public function serialize(): string { return serialize($this->__serialize()); } public function unserialize($data): void { $this->__unserialize(unserialize($data)); } /** * @internal */ public function __serialize(): array { return [$this->template, $this->name, $this->type, $this->starts, $this->ends, $this->profiles]; } /** * @internal */ public function __unserialize(array $data): void { [$this->template, $this->name, $this->type, $this->starts, $this->ends, $this->profiles] = $data; } } ================================================ FILE: src/Resources/core.php ================================================ 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); } ================================================ FILE: src/Resources/debug.php ================================================ 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); } ================================================ FILE: src/Resources/string_loader.php ================================================ */ 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', 'html_attr_relaxed', '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', static 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', static 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': case 'html_attr_relaxed': 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.'); } $regex = match ($strategy) { 'html_attr' => '#[^a-zA-Z0-9,\.\-_]#Su', 'html_attr_relaxed' => '#[^a-zA-Z0-9,\.\-_:@\[\]]#Su', }; $string = preg_replace_callback($regex, static 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[0]); /* * 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', 'html_attr_relaxed'], 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); } } ================================================ FILE: src/RuntimeLoader/ContainerRuntimeLoader.php ================================================ * @author Robin Chalas */ class ContainerRuntimeLoader implements RuntimeLoaderInterface { public function __construct( private ContainerInterface $container, ) { } public function load(string $class) { return $this->container->has($class) ? $this->container->get($class) : null; } } ================================================ FILE: src/RuntimeLoader/FactoryRuntimeLoader.php ================================================ */ class FactoryRuntimeLoader implements RuntimeLoaderInterface { /** * @param array $map An array where keys are class names and values factory callables */ public function __construct( private array $map = [], ) { } public function load(string $class) { if (!isset($this->map[$class])) { return null; } $runtimeFactory = $this->map[$class]; return $runtimeFactory(); } } ================================================ FILE: src/RuntimeLoader/RuntimeLoaderInterface.php ================================================ */ interface RuntimeLoaderInterface { /** * Creates the runtime implementation of a Twig element (filter/function/test). * * @return object|null The runtime instance or null if the loader does not know how to create the runtime for this class */ public function load(string $class); } ================================================ FILE: src/Sandbox/SecurityError.php ================================================ */ class SecurityError extends Error { } ================================================ FILE: src/Sandbox/SecurityNotAllowedFilterError.php ================================================ */ final class SecurityNotAllowedFilterError extends SecurityError { private string $filterName; public function __construct(string $message, string $functionName) { parent::__construct($message); $this->filterName = $functionName; } public function getFilterName(): string { return $this->filterName; } } ================================================ FILE: src/Sandbox/SecurityNotAllowedFunctionError.php ================================================ */ final class SecurityNotAllowedFunctionError extends SecurityError { private string $functionName; public function __construct(string $message, string $functionName) { parent::__construct($message); $this->functionName = $functionName; } public function getFunctionName(): string { return $this->functionName; } } ================================================ FILE: src/Sandbox/SecurityNotAllowedMethodError.php ================================================ */ final class SecurityNotAllowedMethodError extends SecurityError { private string $className; private string $methodName; public function __construct(string $message, string $className, string $methodName) { parent::__construct($message); $this->className = $className; $this->methodName = $methodName; } public function getClassName(): string { return $this->className; } public function getMethodName(): string { return $this->methodName; } } ================================================ FILE: src/Sandbox/SecurityNotAllowedPropertyError.php ================================================ */ final class SecurityNotAllowedPropertyError extends SecurityError { private string $className; private string $propertyName; public function __construct(string $message, string $className, string $propertyName) { parent::__construct($message); $this->className = $className; $this->propertyName = $propertyName; } public function getClassName(): string { return $this->className; } public function getPropertyName(): string { return $this->propertyName; } } ================================================ FILE: src/Sandbox/SecurityNotAllowedTagError.php ================================================ */ final class SecurityNotAllowedTagError extends SecurityError { private string $tagName; public function __construct(string $message, string $tagName) { parent::__construct($message); $this->tagName = $tagName; } public function getTagName(): string { return $this->tagName; } } ================================================ FILE: src/Sandbox/SecurityPolicy.php ================================================ */ final class SecurityPolicy implements SecurityPolicyInterface { private $allowedTags; private $allowedFilters; private $allowedMethods; private $allowedProperties; private $allowedFunctions; public function __construct(array $allowedTags = [], array $allowedFilters = [], array $allowedMethods = [], array $allowedProperties = [], array $allowedFunctions = []) { $this->allowedTags = $allowedTags; $this->allowedFilters = $allowedFilters; $this->setAllowedMethods($allowedMethods); $this->allowedProperties = $allowedProperties; $this->allowedFunctions = $allowedFunctions; } public function setAllowedTags(array $tags): void { $this->allowedTags = $tags; } public function setAllowedFilters(array $filters): void { $this->allowedFilters = $filters; } public function setAllowedMethods(array $methods): void { $this->allowedMethods = []; foreach ($methods as $class => $m) { $this->allowedMethods[$class] = array_map('strtolower', \is_array($m) ? $m : [$m]); } } public function setAllowedProperties(array $properties): void { $this->allowedProperties = $properties; } public function setAllowedFunctions(array $functions): void { $this->allowedFunctions = $functions; } public function checkSecurity($tags, $filters, $functions): void { foreach ($tags as $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, true)) { throw new SecurityNotAllowedFilterError(\sprintf('Filter "%s" is not allowed.', $filter), $filter); } } foreach ($functions as $function) { if (!\in_array($function, $this->allowedFunctions, true)) { throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function); } } } public function checkMethodAllowed($obj, $method): void { if ($obj instanceof Template || $obj instanceof Markup) { return; } $allowed = false; $method = strtolower($method); foreach ($this->allowedMethods as $class => $methods) { if ($obj instanceof $class && \in_array($method, $methods, true)) { $allowed = true; break; } } if (!$allowed) { $class = $obj::class; throw new SecurityNotAllowedMethodError(\sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method); } } public function checkPropertyAllowed($obj, $property): void { $allowed = false; foreach ($this->allowedProperties as $class => $properties) { if ($obj instanceof $class && \in_array($property, \is_array($properties) ? $properties : [$properties], true)) { $allowed = true; break; } } if (!$allowed) { $class = $obj::class; throw new SecurityNotAllowedPropertyError(\sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property); } } } ================================================ FILE: src/Sandbox/SecurityPolicyInterface.php ================================================ */ interface SecurityPolicyInterface { /** * @param string[] $tags * @param string[] $filters * @param string[] $functions * * @throws SecurityError */ public function checkSecurity($tags, $filters, $functions): void; /** * @param object $obj * @param string $method * * @throws SecurityNotAllowedMethodError */ public function checkMethodAllowed($obj, $method): void; /** * @param object $obj * @param string $property * * @throws SecurityNotAllowedPropertyError */ public function checkPropertyAllowed($obj, $property): void; } ================================================ FILE: src/Sandbox/SourcePolicyInterface.php ================================================ */ final class Source { /** * @param string $code The template source code * @param string $name The template logical name * @param string $path The filesystem path of the template if any */ public function __construct( private string $code, private string $name, private string $path = '', ) { } public function getCode(): string { return $this->code; } public function getName(): string { return $this->name; } public function getPath(): string { return $this->path; } } ================================================ FILE: src/Template.php ================================================ load() * instead, which returns an instance of \Twig\TemplateWrapper. * * @author Fabien Potencier * * @internal */ abstract class Template { public const ANY_CALL = 'any'; public const ARRAY_CALL = 'array'; public const METHOD_CALL = 'method'; protected $parent; protected $parents = []; protected $blocks = []; protected $traits = []; protected $traitAliases = []; protected $extensions = []; protected $sandbox; private $useYield; public function __construct( protected Environment $env, ) { $this->useYield = $env->useYield(); $this->extensions = $env->getExtensions(); } /** * Returns the template name. */ abstract public function getTemplateName(): string; /** * Returns debug information about the template. * * @return array Debug information */ abstract public function getDebugInfo(): array; /** * Returns information about the original template source code. */ abstract public function getSourceContext(): Source; /** * Returns the parent template. * * This method is for internal use only and should never be called * directly. * * @return self|TemplateWrapper|false The parent template or false if there is no parent */ public function getParent(array $context): self|TemplateWrapper|false { if (null !== $this->parent) { return $this->parent; } if (!$parent = $this->doGetParent($context)) { return false; } if ($parent instanceof self || $parent instanceof TemplateWrapper) { return $this->parents[$parent->getSourceContext()->getName()] = $parent; } if (!isset($this->parents[$parent])) { $this->parents[$parent] = $this->load($parent, -1); } return $this->parents[$parent]; } protected function doGetParent(array $context): bool|string|self|TemplateWrapper { return false; } public function isTraitable(): bool { return true; } /** * Displays 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 */ public function displayParentBlock($name, array $context, array $blocks = []): void { foreach ($this->yieldParentBlock($name, $context, $blocks) as $data) { echo $data; } } /** * Displays a block. * * This method is for internal use only and should never be called * directly. * * @param string $name The block name to display * @param array $context The context * @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): void { foreach ($this->yieldBlock($name, $context, $blocks, $useBlocks, $templateContext) as $data) { echo $data; } } /** * Renders a parent block. * * This method is for internal use only and should never be called * directly. * * @param string $name The block name to render from the parent * @param array $context The context * @param array $blocks The current set of blocks * * @return string The rendered block */ public function renderParentBlock($name, array $context, array $blocks = []): string { if (!$this->useYield) { if ($this->env->isDebug()) { ob_start(); } else { ob_start(static function () { return ''; }); } $this->displayParentBlock($name, $context, $blocks); return ob_get_clean(); } $content = ''; foreach ($this->yieldParentBlock($name, $context, $blocks) as $data) { $content .= $data; } return $content; } /** * Renders a block. * * This method is for internal use only and should never be called * directly. * * @param string $name The block name to render * @param array $context The context * @param array $blocks The current set of blocks * @param bool $useBlocks Whether to use the current set of blocks * * @return string The rendered block */ public function renderBlock($name, array $context, array $blocks = [], $useBlocks = true): string { if (!$this->useYield) { $level = ob_get_level(); if ($this->env->isDebug()) { ob_start(); } else { ob_start(static 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; } return $content; } /** * Returns whether a block exists or not in the current context of the template. * * This method checks blocks defined in the current template * or defined in "used" traits or defined in parent templates. * * @param string $name The block name * @param array $context The context * @param array $blocks The current set of blocks * * @return bool true if the block exists, false otherwise */ public function hasBlock($name, array $context, array $blocks = []): bool { if (isset($blocks[$name])) { return $blocks[$name][0] instanceof self; } if (isset($this->blocks[$name])) { return true; } if ($parent = $this->getParent($context)) { return $parent->hasBlock($name, $context); } return false; } /** * Returns all block names in the current context of the template. * * This method checks blocks defined in the current template * or defined in "used" traits or defined in parent templates. * * @param array $context The context * @param array $blocks The current set of blocks * * @return array An array of block names */ public function getBlockNames(array $context, array $blocks = []): array { $names = array_merge(array_keys($blocks), array_keys($this->blocks)); if ($parent = $this->getParent($context)) { $names = array_merge($names, $parent->getBlockNames($context)); } return array_unique($names); } /** * @param string|TemplateWrapper|array $template */ protected function load(string|TemplateWrapper|array $template, int $line, ?int $index = null): self { try { if (\is_array($template)) { return $this->env->resolveTemplate($template)->unwrap(); } if ($template instanceof TemplateWrapper) { return $template->unwrap(); } if ($template === $this->getTemplateName()) { $class = static::class; if (false !== $pos = strrpos($class, '___', -1)) { $class = substr($class, 0, $pos); } } else { $class = $this->env->getTemplateClass($template); } return $this->env->loadTemplate($class, $template, $index); } catch (Error $e) { if (!$e->getSourceContext()) { $e->setSourceContext($this->getSourceContext()); } if ($e->getTemplateLine() > 0) { throw $e; } if (-1 === $line) { $e->guess(); } else { $e->setTemplateLine($line); } throw $e; } } /** * @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 $this */ public function unwrap(): self { return $this; } /** * Returns all blocks. * * This method is for internal use only and should never be called * directly. * * @return array An array of blocks */ public function getBlocks(): array { return $this->blocks; } public function display(array $context, array $blocks = []): void { foreach ($this->yield($context, $blocks) as $data) { echo $data; } } public function render(array $context): string { if (!$this->useYield) { $level = ob_get_level(); if ($this->env->isDebug()) { ob_start(); } else { ob_start(static function () { return ''; }); } try { $this->display($context); } catch (\Throwable $e) { while (ob_get_level() > $level) { ob_end_clean(); } throw $e; } return ob_get_clean(); } $content = ''; foreach ($this->yield($context) as $data) { $content .= $data; } return $content; } /** * @return iterable */ public function yield(array $context, array $blocks = []): iterable { $context += $this->env->getGlobals(); $blocks = array_merge($this->blocks, $blocks); try { yield from $this->doDisplay($context, $blocks); } catch (Error $e) { if (!$e->getSourceContext()) { $e->setSourceContext($this->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, $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 = []): iterable; } ================================================ FILE: src/TemplateWrapper.php ================================================ */ final class TemplateWrapper { /** * This method is for internal use only and should never be called * directly (use Twig\Environment::load() instead). * * @internal */ public function __construct( private Environment $env, private Template $template, ) { } /** * @return iterable */ public function stream(array $context = []): iterable { 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 { return $this->template->render($context); } /** * @return void */ public function display(array $context = []) { // using func_get_args() allows to not expose the blocks argument // as it should only be used by internal code $this->template->display($context, \func_get_args()[1] ?? []); } public function hasBlock(string $name, array $context = []): bool { return $this->template->hasBlock($name, $context); } /** * @return string[] An array of defined template block names */ public function getBlockNames(array $context = []): array { return $this->template->getBlockNames($context); } public function renderBlock(string $name, array $context = []): string { return $this->template->renderBlock($name, $context + $this->env->getGlobals()); } /** * @return void */ public function displayBlock(string $name, array $context = []) { $context += $this->env->getGlobals(); foreach ($this->template->yieldBlock($name, $context) as $data) { echo $data; } } public function getSourceContext(): Source { return $this->template->getSourceContext(); } public function getTemplateName(): string { return $this->template->getTemplateName(); } /** * @internal * * @return Template */ public function unwrap() { return $this->template; } } ================================================ FILE: src/Test/IntegrationTestCase.php ================================================ * @author Karma Dordrak */ abstract class IntegrationTestCase extends TestCase { /** * @deprecated since Twig 3.13, use getFixturesDirectory() instead. * * @return string */ protected function getFixturesDir() { throw new \BadMethodCallException('Not implemented.'); } protected static function getFixturesDirectory(): string { throw new \BadMethodCallException('Not implemented.'); } /** * @return RuntimeLoaderInterface[] */ protected function getRuntimeLoaders() { return []; } /** * @return ExtensionInterface[] */ protected function getExtensions() { return []; } /** * @return TwigFilter[] */ protected function getTwigFilters() { return []; } /** * @return TwigFunction[] */ protected function getTwigFunctions() { return []; } /** * @return TwigTest[] */ 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 = '') { $this->doIntegrationTest($file, $message, $condition, $templates, $exception, $outputs, $deprecation); } /** * @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) { 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) { if (!preg_match('/\.test$/', $file)) { continue; } if ($legacyTests xor str_contains($file->getRealpath(), '.legacy.test')) { continue; } $test = file_get_contents($file->getRealpath()); if (preg_match('/--TEST--\s*(.*?)\s*(?:--CONDITION--\s*(.*))?\s*(?:--DEPRECATION--\s*(.*?))?\s*((?:--TEMPLATE(?:\(.*?\))?--(?:.*?))+)\s*(?:--DATA--\s*(.*))?\s*--EXCEPTION--\s*(.*)/sx', $test, $match)) { $message = $match[1]; $condition = $match[2]; $deprecation = $match[3]; $templates = self::parseTemplates($match[4]); $exception = $match[6]; $outputs = [[null, $match[5], null, '']]; } elseif (preg_match('/--TEST--\s*(.*?)\s*(?:--CONDITION--\s*(.*))?\s*(?:--DEPRECATION--\s*(.*?))?\s*((?:--TEMPLATE(?:\(.*?\))?--(?:.*?))+)--DATA--.*?--EXPECT--.*/s', $test, $match)) { $message = $match[1]; $condition = $match[2]; $deprecation = $match[3]; $templates = self::parseTemplates($match[4]); $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))); } $tests[str_replace($fixturesDir.'/', '', $file)] = [str_replace($fixturesDir.'/', '', $file), $message, $condition, $templates, $exception, $outputs, $deprecation]; } if ($legacyTests && !$tests) { // add a dummy test to avoid a PHPUnit message return [['not', '-', '', [], '', []]]; } 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) { $this->markTestSkipped('no tests to run'); } if ($condition) { $ret = ''; eval('$ret = '.$condition.';'); if (!$ret) { $this->markTestSkipped($condition); } } 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) { $twig->addRuntimeLoader($runtimeLoader); } foreach ($this->getExtensions() as $extension) { $twig->addExtension($extension); } foreach ($this->getTwigFilters() as $filter) { $twig->addFilter($filter); } foreach ($this->getTwigTests() as $test) { $twig->addTest($test); } foreach ($this->getTwigFunctions() as $function) { $twig->addFunction($function); } 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 { $prevHandler = set_error_handler(static function ($type, $msg, $file, $line, $context = []) use (&$deprecations, &$prevHandler) { if (\E_USER_DEPRECATED === $type) { $deprecations[] = $msg; return true; } return $prevHandler ? $prevHandler($type, $msg, $file, $line, $context) : false; }); $template = $twig->load('index.twig'); } catch (\Exception $e) { if (false !== $exception) { $message = $e->getMessage(); $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', $e::class, $e->getMessage()), -1, null, $e); } finally { restore_error_handler(); } $this->assertSame($deprecation, implode("\n", $deprecations)); try { $output = trim($template->render(eval($match[1].';')), "\n "); } catch (\Exception $e) { if (false !== $exception) { $this->assertStringMatchesFormat(trim($exception), trim(\sprintf('%s: %s', $e::class, $e->getMessage()))); return; } $e = new Error(\sprintf('%s: %s', $e::class, $e->getMessage()), -1, null, $e); $output = trim(\sprintf('%s: %s', $e::class, $e->getMessage())); } if (false !== $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)); } $expected = trim($match[3], "\n "); if ($expected !== $output) { printf("Compiled templates that failed on case %d:\n", $i + 1); foreach (array_keys($templates) as $name) { echo "Template: $name\n"; echo $twig->compile($twig->parse($twig->tokenize($twig->getLoader()->getSourceContext($name)))); } } $this->assertEquals($expected, $output, $message.' (in '.$file.')'); } } /** * @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]; } return $templates; } } ================================================ FILE: src/Test/NodeTestCase.php ================================================ */ 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); } /** * @return void */ public function assertNodeCompilation($source, Node $node, ?Environment $environment = null, $isPattern = false) { $compiler = $this->getCompiler($environment); $compiler->compile($node); if ($isPattern) { $this->assertStringMatchesFormat($source, trim($compiler->getSource())); } else { $this->assertEquals($source, trim($compiler->getSource())); } } /** * @return Compiler */ protected function getCompiler(?Environment $environment = null) { return new Compiler($environment ?? $this->getEnvironment()); } /** * @return Environment * * @final since Twig 3.13 */ protected function getEnvironment() { 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 string * * @deprecated since Twig 3.13, use createAttributeGetter() instead. */ protected function getAttributeGetter() { 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); } } } ================================================ FILE: src/Token.php ================================================ */ final class Token { public const EOF_TYPE = -1; public const TEXT_TYPE = 0; public const BLOCK_START_TYPE = 1; public const VAR_START_TYPE = 2; public const BLOCK_END_TYPE = 3; public const VAR_END_TYPE = 4; public const NAME_TYPE = 5; public const NUMBER_TYPE = 6; public const STRING_TYPE = 7; public const OPERATOR_TYPE = 8; 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( 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(): string { return \sprintf('%s(%s)', self::typeToString($this->type, true), $this->value); } /** * Tests the current token for a type and/or a value. * * Parameters may be: * * just type * * type and value (or array of possible values) * * just value (or array of possible values) (NAME_TYPE is used as type) * * @param array|string|int $type The type to test * @param array|string|null $values The token value */ public function test($type, $values = null): bool { if (null === $values && !\is_int($type)) { $values = $type; $type = self::NAME_TYPE; } 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 ); } 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; } 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) { case self::EOF_TYPE: $name = 'EOF_TYPE'; break; case self::TEXT_TYPE: $name = 'TEXT_TYPE'; break; case self::BLOCK_START_TYPE: $name = 'BLOCK_START_TYPE'; break; case self::VAR_START_TYPE: $name = 'VAR_START_TYPE'; break; case self::BLOCK_END_TYPE: $name = 'BLOCK_END_TYPE'; break; case self::VAR_END_TYPE: $name = 'VAR_END_TYPE'; break; case self::NAME_TYPE: $name = 'NAME_TYPE'; break; case self::NUMBER_TYPE: $name = 'NUMBER_TYPE'; break; case self::STRING_TYPE: $name = 'STRING_TYPE'; break; case self::OPERATOR_TYPE: $name = 'OPERATOR_TYPE'; break; case self::PUNCTUATION_TYPE: $name = 'PUNCTUATION_TYPE'; break; case self::INTERPOLATION_START_TYPE: $name = 'INTERPOLATION_START_TYPE'; break; case self::INTERPOLATION_END_TYPE: $name = 'INTERPOLATION_END_TYPE'; break; 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)); } return $short ? $name : 'Twig\Token::'.$name; } public static function typeToEnglish(int $type): string { switch ($type) { case self::EOF_TYPE: return 'end of template'; case self::TEXT_TYPE: return 'text'; case self::BLOCK_START_TYPE: return 'begin of statement block'; case self::VAR_START_TYPE: return 'begin of print statement'; case self::BLOCK_END_TYPE: return 'end of statement block'; case self::VAR_END_TYPE: return 'end of print statement'; case self::NAME_TYPE: return 'name'; case self::NUMBER_TYPE: return 'number'; case self::STRING_TYPE: return 'string'; case self::OPERATOR_TYPE: return 'operator'; case self::PUNCTUATION_TYPE: return 'punctuation'; case self::INTERPOLATION_START_TYPE: return 'begin of string interpolation'; case self::INTERPOLATION_END_TYPE: 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)); } } } ================================================ FILE: src/TokenParser/AbstractTokenParser.php ================================================ */ abstract class AbstractTokenParser implements TokenParserInterface { /** * @var Parser */ protected $parser; 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); } } ================================================ FILE: src/TokenParser/ApplyTokenParser.php ================================================ getLine(); $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 Nodes([ new SetNode(true, $ref, $body, $lineno), new PrintNode($filter, $lineno), ], $lineno); } public function decideApplyEnd(Token $token): bool { return $token->test('endapply'); } public function getTag(): string { return 'apply'; } } ================================================ FILE: src/TokenParser/AutoEscapeTokenParser.php ================================================ getLine(); $stream = $this->parser->getStream(); if ($stream->test(Token::BLOCK_END_TYPE)) { $value = 'html'; } else { $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); $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); $stream->expect(Token::BLOCK_END_TYPE); return new AutoEscapeNode($value, $body, $lineno); } public function decideBlockEnd(Token $token): bool { return $token->test('endautoescape'); } public function getTag(): string { return 'autoescape'; } } ================================================ FILE: src/TokenParser/BlockTokenParser.php ================================================ * {% block title %}{% endblock %} - My Webpage * {% endblock %} * * @internal */ final class BlockTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); $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)) { $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); 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()); } } } else { $body = new Nodes([ new PrintNode($this->parser->parseExpression(), $lineno), ]); } $stream->expect(Token::BLOCK_END_TYPE); $block->setNode('body', $body); $this->parser->popBlockStack(); $this->parser->popLocalScope(); return new BlockReferenceNode($name, $lineno); } public function decideBlockEnd(Token $token): bool { return $token->test('endblock'); } public function getTag(): string { return 'block'; } } ================================================ FILE: src/TokenParser/DeprecatedTokenParser.php ================================================ * * @internal */ final class DeprecatedTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { $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()); } } $stream->expect(Token::BLOCK_END_TYPE); return $node; } public function getTag(): string { return 'deprecated'; } } ================================================ FILE: src/TokenParser/DoTokenParser.php ================================================ parser->parseExpression(); $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); return new DoNode($expr, $token->getLine()); } public function getTag(): string { return 'do'; } } ================================================ FILE: src/TokenParser/EmbedTokenParser.php ================================================ parser->getStream(); $parent = $this->parser->parseExpression(); [$variables, $only, $ignoreMissing] = $this->parseArguments(); $parentToken = $fakeParentToken = new Token(Token::STRING_TYPE, '__parent__', $token->getLine()); if ($parent instanceof ConstantExpression) { $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, '', $token->getLine()), new Token(Token::NAME_TYPE, 'extends', $token->getLine()), $parentToken, new Token(Token::BLOCK_END_TYPE, '', $token->getLine()), ]); $module = $this->parser->parse($stream, [$this, 'decideBlockEnd'], true); // override the parent with the correct one if ($fakeParentToken === $parentToken) { $module->setNode('parent', $parent); } $this->parser->embedTemplate($module); $stream->expect(Token::BLOCK_END_TYPE); return new EmbedNode($module->getTemplateName(), $module->getAttribute('index'), $variables, $only, $ignoreMissing, $token->getLine()); } public function decideBlockEnd(Token $token): bool { return $token->test('endembed'); } public function getTag(): string { return 'embed'; } } ================================================ FILE: src/TokenParser/ExtendsTokenParser.php ================================================ parser->getStream(); if ($this->parser->peekBlockStack()) { throw new SyntaxError('Cannot use "extend" in a block.', $token->getLine(), $stream->getSourceContext()); } elseif (!$this->parser->isMainScope()) { throw new SyntaxError('Cannot use "extend" in a macro.', $token->getLine(), $stream->getSourceContext()); } $this->parser->setParent($this->parser->parseExpression()); $stream->expect(Token::BLOCK_END_TYPE); return new EmptyNode($token->getLine()); } public function getTag(): string { return 'extends'; } } ================================================ FILE: src/TokenParser/FlushTokenParser.php ================================================ parser->getStream()->expect(Token::BLOCK_END_TYPE); return new FlushNode($token->getLine()); } public function getTag(): string { return 'flush'; } } ================================================ FILE: src/TokenParser/ForTokenParser.php ================================================ * {% for user in users %} *
  • {{ user.username|e }}
  • * {% endfor %} * * * @internal */ final class ForTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); $targets = $this->parseAssignmentExpression(); $stream->expect(Token::OPERATOR_TYPE, 'in'); $seq = $this->parser->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideForFork']); if ('else' == $stream->next()->getValue()) { $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); if (\count($targets) > 1) { $keyTarget = $targets->getNode('0'); $keyTarget = new AssignContextVariable($keyTarget->getAttribute('name'), $keyTarget->getTemplateLine()); $valueTarget = $targets->getNode('1'); } else { $keyTarget = new AssignContextVariable('_key', $lineno); $valueTarget = $targets->getNode('0'); } $valueTarget = new AssignContextVariable($valueTarget->getAttribute('name'), $valueTarget->getTemplateLine()); return new ForNode($keyTarget, $valueTarget, $seq, null, $body, $else, $lineno); } public function decideForFork(Token $token): bool { return $token->test(['else', 'endfor']); } public function decideForEnd(Token $token): bool { return $token->test('endfor'); } public function getTag(): string { return 'for'; } } ================================================ FILE: src/TokenParser/FromTokenParser.php ================================================ parser->parseExpression(); $stream = $this->parser->getStream(); $stream->expect(Token::NAME_TYPE, 'import'); $targets = []; while (true) { $name = $stream->expect(Token::NAME_TYPE)->getValue(); if ($stream->nextIf('as')) { $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, ',')) { break; } } $stream->expect(Token::BLOCK_END_TYPE); $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->getAttribute('name'), 'macro_'.$name, $internalRef); } return $node; } public function getTag(): string { return 'from'; } } ================================================ FILE: src/TokenParser/GuardTokenParser.php ================================================ 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'; } } ================================================ FILE: src/TokenParser/IfTokenParser.php ================================================ * {% for user in users %} *
  • {{ user.username|e }}
  • * {% endfor %} * * {% endif %} * * @internal */ final class IfTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { $lineno = $token->getLine(); $expr = $this->parser->parseExpression(); $stream = $this->parser->getStream(); $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideIfFork']); $tests = [$expr, $body]; $else = null; $end = false; while (!$end) { switch ($stream->next()->getValue()) { case 'else': $stream->expect(Token::BLOCK_END_TYPE); $else = $this->parser->subparse([$this, 'decideIfEnd']); break; case 'elseif': $expr = $this->parser->parseExpression(); $stream->expect(Token::BLOCK_END_TYPE); $body = $this->parser->subparse([$this, 'decideIfFork']); $tests[] = $expr; $tests[] = $body; break; case 'endif': $end = true; 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()); } } $stream->expect(Token::BLOCK_END_TYPE); return new IfNode(new Nodes($tests), $else, $lineno); } public function decideIfFork(Token $token): bool { return $token->test(['elseif', 'else', 'endif']); } public function decideIfEnd(Token $token): bool { return $token->test(['endif']); } public function getTag(): string { return 'if'; } } ================================================ FILE: src/TokenParser/ImportTokenParser.php ================================================ 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); return new ImportNode($macro, $var, $token->getLine()); } public function getTag(): string { return 'import'; } } ================================================ FILE: src/TokenParser/IncludeTokenParser.php ================================================ parser->parseExpression(); [$variables, $only, $ignoreMissing] = $this->parseArguments(); 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, 'ignore')) { $stream->expect(Token::NAME_TYPE, 'missing'); $ignoreMissing = true; } $variables = null; if ($stream->nextIf(Token::NAME_TYPE, 'with')) { $variables = $this->parser->parseExpression(); } $only = false; if ($stream->nextIf(Token::NAME_TYPE, 'only')) { $only = true; } $stream->expect(Token::BLOCK_END_TYPE); return [$variables, $only, $ignoreMissing]; } public function getTag(): string { return 'include'; } } ================================================ FILE: src/TokenParser/MacroTokenParser.php ================================================ * {% endmacro %} * * @internal */ final class MacroTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { $lineno = $token->getLine(); $stream = $this->parser->getStream(); $name = $stream->expect(Token::NAME_TYPE)->getValue(); $arguments = $this->parseDefinition(); $stream->expect(Token::BLOCK_END_TYPE); $this->parser->pushLocalScope(); $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); 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()); } } $this->parser->popLocalScope(); $stream->expect(Token::BLOCK_END_TYPE); $this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno)); return new EmptyNode($lineno); } public function decideBlockEnd(Token $token): bool { return $token->test('endmacro'); } 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; } } ================================================ FILE: src/TokenParser/SandboxTokenParser.php ================================================ parser->getStream(); 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); // in a sandbox tag, only include tags are allowed if (!$body instanceof IncludeNode) { foreach ($body as $node) { if ($node instanceof TextNode && ctype_space($node->getAttribute('data'))) { continue; } if (!$node instanceof IncludeNode) { throw new SyntaxError('Only "include" tags are allowed within a "sandbox" section.', $node->getTemplateLine(), $stream->getSourceContext()); } } } return new SandboxNode($body, $token->getLine()); } public function decideBlockEnd(Token $token): bool { return $token->test('endsandbox'); } public function getTag(): string { return 'sandbox'; } } ================================================ FILE: src/TokenParser/SetTokenParser.php ================================================ getLine(); $stream = $this->parser->getStream(); $names = $this->parseAssignmentExpression(); $capture = false; if ($stream->nextIf(Token::OPERATOR_TYPE, '=')) { $values = $this->parseMultitargetExpression(); $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()); } } else { $capture = true; if (\count($names) > 1) { 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); $values = $this->parser->subparse([$this, 'decideBlockEnd'], true); $stream->expect(Token::BLOCK_END_TYPE); } return new SetNode($capture, $names, $values, $lineno); } public function decideBlockEnd(Token $token): bool { return $token->test('endset'); } 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); } } ================================================ FILE: src/TokenParser/TokenParserInterface.php ================================================ */ interface TokenParserInterface { /** * Sets the parser associated with this token parser. */ public function setParser(Parser $parser): void; /** * Parses a token and returns a node. * * @return Node * * @throws SyntaxError */ public function parse(Token $token); /** * Gets the tag name associated with this token parser. * * @return string */ public function getTag(); } ================================================ FILE: src/TokenParser/TypesTokenParser.php ================================================ * * @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'; } } ================================================ FILE: src/TokenParser/UseTokenParser.php ================================================ parser->parseExpression(); $stream = $this->parser->getStream(); if (!$template instanceof ConstantExpression) { throw new SyntaxError('The template references in a "use" statement must be a string.', $stream->getCurrent()->getLine(), $stream->getSourceContext()); } $targets = []; if ($stream->nextIf('with')) { while (true) { $name = $stream->expect(Token::NAME_TYPE)->getValue(); $alias = $name; if ($stream->nextIf('as')) { $alias = $stream->expect(Token::NAME_TYPE)->getValue(); } $targets[$name] = new ConstantExpression($alias, -1); if (!$stream->nextIf(Token::PUNCTUATION_TYPE, ',')) { break; } } } $stream->expect(Token::BLOCK_END_TYPE); $this->parser->addTrait(new Nodes(['template' => $template, 'targets' => new Nodes($targets)])); return new EmptyNode($token->getLine()); } public function getTag(): string { return 'use'; } } ================================================ FILE: src/TokenParser/WithTokenParser.php ================================================ * * @internal */ final class WithTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { $stream = $this->parser->getStream(); $variables = null; $only = false; 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); $body = $this->parser->subparse([$this, 'decideWithEnd'], true); $stream->expect(Token::BLOCK_END_TYPE); return new WithNode($body, $variables, $only, $token->getLine()); } public function decideWithEnd(Token $token): bool { return $token->test('endwith'); } public function getTag(): string { return 'with'; } } ================================================ FILE: src/TokenStream.php ================================================ */ final class TokenStream { private $current = 0; 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(): 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)); } /** * Sets the pointer to the next token and returns the old one. */ public function next(): Token { if (!isset($this->tokens[++$this->current])) { throw new SyntaxError('Unexpected end of template.', $this->tokens[$this->current - 1]->getLine(), $this->source); } return $this->tokens[$this->current - 1]; } /** * Tests a token, sets the pointer to the next one and returns it or throws a syntax error. * * @return Token|null The next token if the condition is true, null otherwise */ public function nextIf($primary, $secondary = null) { 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 { $token = $this->tokens[$this->current]; if (!$token->test($type, $value)) { $line = $token->getLine(); throw new SyntaxError(\sprintf('%sUnexpected token "%s"%s ("%s" expected%s).', $message ? $message.'. ' : '', $token->toEnglish(), $token->getValue() ? \sprintf(' of value "%s"', $token->getValue()) : '', Token::typeToEnglish($type), $value ? \sprintf(' with value "%s"', $value) : ''), $line, $this->source ); } $this->next(); return $token; } /** * Looks at the next token. */ public function look(int $number = 1): Token { if (!isset($this->tokens[$this->current + $number])) { throw new SyntaxError('Unexpected end of template.', $this->tokens[$this->current + $number - 1]->getLine(), $this->source); } return $this->tokens[$this->current + $number]; } /** * Tests the current token. */ public function test($primary, $secondary = null): bool { return $this->tokens[$this->current]->test($primary, $secondary); } /** * Checks if end of stream was reached. */ public function isEOF(): bool { return $this->tokens[$this->current]->test(Token::EOF_TYPE); } public function getCurrent(): Token { return $this->tokens[$this->current]; } public function getSourceContext(): Source { return $this->source; } } ================================================ FILE: src/TwigCallableInterface.php ================================================ */ 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; } ================================================ FILE: src/TwigFilter.php ================================================ * * @see https://twig.symfony.com/doc/templates.html#filters */ final class TwigFilter extends AbstractTwigCallable { /** * @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 = []) { parent::__construct($name, $callable, $options); $this->options = array_merge([ 'is_safe' => null, 'is_safe_callback' => null, 'pre_escape' => null, 'preserves_safety' => null, 'node_class' => FilterExpression::class, ], $this->options); } public function getType(): string { return 'filter'; } public function getSafe(Node $filterArgs): ?array { if (null !== $this->options['is_safe']) { return $this->options['is_safe']; } if (null !== $this->options['is_safe_callback']) { return $this->options['is_safe_callback']($filterArgs); } return []; } public function getPreservesSafety(): array { return $this->options['preserves_safety'] ?? []; } public function getPreEscape(): ?string { return $this->options['pre_escape']; } public function getMinimalNumberOfRequiredArguments(): int { return parent::getMinimalNumberOfRequiredArguments() + 1; } } ================================================ FILE: src/TwigFunction.php ================================================ * * @see https://twig.symfony.com/doc/templates.html#functions */ final class TwigFunction extends AbstractTwigCallable { /** * @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 = []) { parent::__construct($name, $callable, $options); $this->options = array_merge([ 'is_safe' => null, 'is_safe_callback' => null, 'node_class' => FunctionExpression::class, 'parser_callable' => null, ], $this->options); } public function getType(): string { return 'function'; } public function getParserCallable(): ?callable { return $this->options['parser_callable']; } public function getSafe(Node $functionArgs): ?array { if (null !== $this->options['is_safe']) { return $this->options['is_safe']; } if (null !== $this->options['is_safe_callback']) { return $this->options['is_safe_callback']($functionArgs); } return []; } } ================================================ FILE: src/TwigTest.php ================================================ * * @see https://twig.symfony.com/doc/templates.html#test-operator */ final class TwigTest extends AbstractTwigCallable { /** * @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 = []) { parent::__construct($name, $callable, $options); $this->options = array_merge([ 'node_class' => TestExpression::class, 'one_mandatory_argument' => false, ], $this->options); } public function getType(): string { return 'test'; } public function needsCharset(): bool { return false; } public function needsEnvironment(): bool { return false; } public function needsContext(): bool { return false; } public function hasOneMandatoryArgument(): bool { return (bool) $this->options['one_mandatory_argument']; } public function getMinimalNumberOfRequiredArguments(): int { return parent::getMinimalNumberOfRequiredArguments() + 1; } } ================================================ FILE: src/Util/CallableArgumentsExtractor.php ================================================ * * @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]; } } ================================================ FILE: src/Util/DeprecationCollector.php ================================================ */ final class DeprecationCollector { public function __construct( private Environment $twig, ) { } /** * Returns deprecations for templates contained in a directory. * * @param string $dir A directory where templates are stored * @param string $ext Limit the loaded templates by extension * * @return array An array of deprecations */ public function collectDir(string $dir, string $ext = '.twig'): array { $iterator = new \RegexIterator( new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir), \RecursiveIteratorIterator::LEAVES_ONLY ), '{'.preg_quote($ext).'$}' ); return $this->collect(new TemplateDirIterator($iterator)); } /** * Returns deprecations for passed templates. * * @param \Traversable $iterator An iterator of templates (where keys are template names and values the contents of the template) * * @return array An array of deprecations */ public function collect(\Traversable $iterator): array { $deprecations = []; set_error_handler(static function ($type, $msg) use (&$deprecations) { if (\E_USER_DEPRECATED === $type) { $deprecations[] = $msg; } return false; }); foreach ($iterator as $name => $contents) { try { $this->twig->parse($this->twig->tokenize(new Source($contents, $name))); } catch (SyntaxError $e) { // ignore templates containing syntax errors } } restore_error_handler(); return $deprecations; } } ================================================ FILE: src/Util/ReflectionCallable.php ================================================ * * @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; } } ================================================ FILE: src/Util/TemplateDirIterator.php ================================================ */ class TemplateDirIterator extends \IteratorIterator { /** * @return string */ #[\ReturnTypeWillChange] public function current() { return file_get_contents(parent::current()); } /** * @return string */ #[\ReturnTypeWillChange] public function key() { return (string) parent::key(); } } ================================================ FILE: tests/Cache/ChainTest.php ================================================ 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, ]); } } ================================================ FILE: tests/Cache/FilesystemTest.php ================================================ className = '__Twig_Tests_Cache_FilesystemTest_Template_'.$nonce; $this->directory = sys_get_temp_dir().'/twig-test'; $this->cache = new FilesystemCache($this->directory); } protected function tearDown(): void { if (file_exists($this->directory)) { FilesystemHelper::removeDir($this->directory); } } public function testLoad() { $key = $this->directory.'/cache/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->assertFileExists($this->directory); $this->assertFileExists($key); $this->assertSame(file_get_contents($key), $content); } public function testWriteFailMkdir() { if (\defined('PHP_WINDOWS_VERSION_BUILD')) { $this->markTestSkipped('Read-only directories not possible on Windows.'); } $key = $this->directory.'/cache/cachefile.php'; $content = $this->generateSource(); $this->assertFileDoesNotExist($key); // Create read-only root directory. @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() { if (\defined('PHP_WINDOWS_VERSION_BUILD')) { $this->markTestSkipped('Read-only directories not possible on Windows.'); } $key = $this->directory.'/cache/cachefile.php'; $content = $this->generateSource(); $this->assertFileDoesNotExist($key); // Create root directory. @mkdir($this->directory, 0777, true); // Create read-only subdirectory. @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() { $key = $this->directory.'/cache/cachefile.php'; $content = $this->generateSource(); $this->assertFileDoesNotExist($key); // Create a directory in the place of the cache file. @mkdir($key, 0777, true); $this->assertDirectoryExists($key); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Failed to write cache file'); $this->cache->write($key, $content); } 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 FilesystemCache($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, ]); } } ================================================ FILE: tests/Cache/ReadOnlyFilesystemTest.php ================================================ 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, ]); } } ================================================ FILE: tests/CompilerTest.php ================================================ markTestSkipped('Your platform does not support locales.'); } $required_locales = ['fr_FR.UTF-8', 'fr_FR.UTF8', 'fr_FR.utf-8', 'fr_FR.utf8', 'French_France.1252']; if (false === setlocale(\LC_NUMERIC, $required_locales)) { $this->markTestSkipped('Could not set any of required locales: '.implode(', ', $required_locales)); } $this->assertEquals('1.2', $compiler->repr(1.2)->getSource()); $this->assertStringContainsString('fr', strtolower(setlocale(\LC_NUMERIC, '0'))); setlocale(\LC_NUMERIC, $locale); } } ================================================ FILE: tests/ContainerRuntimeLoaderTest.php ================================================ createMock(ContainerInterface::class); $container->expects($this->once())->method('has')->with('stdClass')->willReturn(true); $container->expects($this->once())->method('get')->with('stdClass')->willReturn(new \stdClass()); $loader = new ContainerRuntimeLoader($container); $this->assertInstanceOf('stdClass', $loader->load('stdClass')); } public function testLoadUnknownRuntimeReturnsNull() { $container = $this->createMock(ContainerInterface::class); $container->expects($this->once())->method('has')->with('Foo'); $container->expects($this->never())->method('get'); $this->assertNull((new ContainerRuntimeLoader($container))->load('Foo')); } } ================================================ FILE: tests/CustomExtensionTest.php ================================================ addExtension($extension); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expectedExceptionMessage); $env->getExpressionParsers(); } public static function provideInvalidExtensions() { return [ [new InvalidOperatorExtension([1, 2, 3]), '"Twig\Tests\InvalidOperatorExtension::getOperators()" must return an array of 2 elements, got 3.'], ]; } } class InvalidOperatorExtension implements ExtensionInterface { private $operators; public function __construct($operators) { $this->operators = $operators; } public function getTokenParsers(): array { return []; } public function getNodeVisitors(): array { return []; } public function getFilters(): array { return []; } public function getTests(): array { return []; } public function getFunctions(): array { return []; } public function getOperators(): array { return $this->operators; } } ================================================ FILE: tests/DeprecatedCallableInfoTest.php ================================================ setType('function'); $info->setName('foo'); $deprecations = []; try { set_error_handler(static 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(static 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); } } ================================================ FILE: tests/DummyBackedEnum.php ================================================ 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([ 'html' => '{{ foo }} {{ foo }}', 'js' => '{{ bar }} {{ bar }}', ]); $twig = new Environment($loader, [ 'debug' => true, 'cache' => false, 'autoescape' => [$this, 'escapingStrategyCallback'], ]); $this->assertEquals('foo<br/ > foo<br/ >', $twig->render('html', ['foo' => 'foo
    '])); $this->assertEquals('foo\u003Cbr\/\u0020\u003E foo\u003Cbr\/\u0020\u003E', $twig->render('js', ['bar' => 'foo
    '])); } public function escapingStrategyCallback($name) { return $name; } public function testGlobals() { $loader = $this->createMock(LoaderInterface::class); $loader->expects($this->any())->method('getSourceContext')->willReturn(new Source('', '')); // globals can be added after calling getGlobals $twig = new Environment($loader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->addGlobal('foo', 'bar'); $globals = $twig->getGlobals(); $this->assertEquals('bar', $globals['foo']); // globals can be modified after a template has been loaded $twig = new Environment($loader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->load('index'); $twig->addGlobal('foo', 'bar'); $globals = $twig->getGlobals(); $this->assertEquals('bar', $globals['foo']); // globals can be modified after extensions init $twig = new Environment($loader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->getFunctions(); $twig->addGlobal('foo', 'bar'); $globals = $twig->getGlobals(); $this->assertEquals('bar', $globals['foo']); // globals can be modified after extensions and a template has been loaded $arrayLoader = new ArrayLoader(['index' => '{{foo}}']); $twig = new Environment($arrayLoader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->getFunctions(); $twig->load('index'); $twig->addGlobal('foo', 'bar'); $globals = $twig->getGlobals(); $this->assertEquals('bar', $globals['foo']); $twig = new Environment($arrayLoader); $twig->getGlobals(); $twig->addGlobal('foo', 'bar'); $template = $twig->load('index'); $this->assertEquals('bar', $template->render([])); // globals cannot be added after a template has been loaded $twig = new Environment($loader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->load('index'); try { $twig->addGlobal('bar', 'bar'); $this->fail(); } catch (\LogicException $e) { $this->assertArrayNotHasKey('bar', $twig->getGlobals()); } // globals cannot be added after extensions init $twig = new Environment($loader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->getFunctions(); try { $twig->addGlobal('bar', 'bar'); $this->fail(); } catch (\LogicException $e) { $this->assertArrayNotHasKey('bar', $twig->getGlobals()); } // globals cannot be added after extensions and a template has been loaded $twig = new Environment($loader); $twig->addGlobal('foo', 'foo'); $twig->getGlobals(); $twig->getFunctions(); $twig->load('index'); try { $twig->addGlobal('bar', 'bar'); $this->fail(); } catch (\LogicException $e) { $this->assertArrayNotHasKey('bar', $twig->getGlobals()); } // test adding globals after a template has been loaded without call to getGlobals $twig = new Environment($loader); $twig->load('index'); try { $twig->addGlobal('bar', 'bar'); $this->fail(); } catch (\LogicException $e) { $this->assertArrayNotHasKey('bar', $twig->getGlobals()); } } public function testExtensionsAreNotInitializedWhenRenderingACompiledTemplate() { $cache = new FilesystemCache($dir = sys_get_temp_dir().'/twig'); $options = ['cache' => $cache, 'auto_reload' => false, 'debug' => false]; // 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 = new Environment($loader, $options); $extension->throw = true; $twig->addExtension($extension); // render template $output = $twig->render('index', ['foo' => 'bar']); $this->assertEquals('bar', $output); FilesystemHelper::removeDir($dir); } public function testAutoReloadCacheMiss() { $templateName = __FUNCTION__; $templateContent = __FUNCTION__; $cache = $this->createMock(CacheInterface::class); $loader = $this->getMockLoader($templateName, $templateContent); $twig = new Environment($loader, ['cache' => $cache, 'auto_reload' => true, 'debug' => false]); // Cache miss: getTimestamp returns 0 and as a result the load() is // skipped. $cache->expects($this->once()) ->method('generateKey') ->willReturn('key'); $cache->expects($this->once()) ->method('getTimestamp') ->willReturn(0); $loader->expects($this->never()) ->method('isFresh'); $cache->expects($this->once()) ->method('write'); $cache->expects($this->once()) ->method('load'); $twig->load($templateName); } public function testAutoReloadCacheHit() { $templateName = __FUNCTION__; $templateContent = __FUNCTION__; $cache = $this->createMock(CacheInterface::class); $loader = $this->getMockLoader($templateName, $templateContent); $twig = new Environment($loader, ['cache' => $cache, 'auto_reload' => true, 'debug' => false]); $now = time(); // Cache hit: getTimestamp returns something > extension timestamps and // the loader returns true for isFresh(). $cache->expects($this->once()) ->method('generateKey') ->willReturn('key'); $cache->expects($this->once()) ->method('getTimestamp') ->willReturn($now); $loader->expects($this->once()) ->method('isFresh') ->willReturn(true); $cache->expects($this->atLeastOnce()) ->method('load'); $twig->load($templateName); } public function testAutoReloadOutdatedCacheHit() { $templateName = __FUNCTION__; $templateContent = __FUNCTION__; $cache = $this->createMock(CacheInterface::class); $loader = $this->getMockLoader($templateName, $templateContent); $twig = new Environment($loader, ['cache' => $cache, 'auto_reload' => true, 'debug' => false]); $now = time(); $cache->expects($this->once()) ->method('generateKey') ->willReturn('key'); $cache->expects($this->once()) ->method('getTimestamp') ->willReturn($now); $loader->expects($this->once()) ->method('isFresh') ->willReturn(false); $cache->expects($this->once()) ->method('write'); $cache->expects($this->once()) ->method('load'); $twig->load($templateName); } public function testHasGetExtensionByClassName() { $twig = new Environment(new ArrayLoader()); $twig->addExtension($ext = new EnvironmentTest_Extension()); $this->assertSame($ext, $twig->getExtension(EnvironmentTest_Extension::class)); $this->assertSame($ext, $twig->getExtension(EnvironmentTest_Extension::class)); } public function testAddExtension() { $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->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; foreach ($visitors as $visitor) { if ($visitor instanceof EnvironmentTest_NodeVisitor) { $found = true; } } $this->assertTrue($found); } public function testAddMockExtension() { $extension = $this->createMock(ExtensionInterface::class); $loader = new ArrayLoader(['page' => 'hey']); $twig = new Environment($loader); $twig->addExtension($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->addExtension(new EnvironmentTest_Extension()); } public function testAddRuntimeLoader() { $runtimeLoader = $this->createMock(RuntimeLoaderInterface::class); $runtimeLoader->expects($this->any())->method('load')->willReturn(new EnvironmentTest_Runtime()); $loader = new ArrayLoader([ 'func_array' => '{{ from_runtime_array("foo") }}', 'func_array_default' => '{{ from_runtime_array() }}', 'func_array_named_args' => '{{ from_runtime_array(name="foo") }}', 'func_string' => '{{ from_runtime_string("foo") }}', 'func_string_default' => '{{ from_runtime_string() }}', 'func_string_named_args' => '{{ from_runtime_string(name="foo") }}', ]); $twig = new Environment($loader, ['autoescape' => false]); $twig->addExtension(new EnvironmentTest_ExtensionWithoutRuntime()); $twig->addRuntimeLoader($runtimeLoader); $this->assertEquals('foo', $twig->render('func_array')); $this->assertEquals('bar', $twig->render('func_array_default')); $this->assertEquals('foo', $twig->render('func_array_named_args')); $this->assertEquals('foo', $twig->render('func_string')); $this->assertEquals('bar', $twig->render('func_string_default')); $this->assertEquals('foo', $twig->render('func_string_named_args')); } 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".'); $twig->loadTemplate($twig->getTemplateClass($template), $template, 112233); } public function testUndefinedFunctionCallback() { $twig = new Environment(new ArrayLoader()); $twig->registerUndefinedFunctionCallback(static function (string $name) { if ('dynamic' === $name) { return new TwigFunction('dynamic', static function () { return 'dynamic'; }); } return false; }); $this->assertNull($twig->getFunction('does_not_exist')); $this->assertInstanceOf(TwigFunction::class, $function = $twig->getFunction('dynamic')); $this->assertSame('dynamic', $function->getName()); } public function testUndefinedFilterCallback() { $twig = new Environment(new ArrayLoader()); $twig->registerUndefinedFilterCallback(static function (string $name) { if ('dynamic' === $name) { return new TwigFilter('dynamic', static function () { return 'dynamic'; }); } return false; }); $this->assertNull($twig->getFilter('does_not_exist')); $this->assertInstanceOf(TwigFilter::class, $filter = $twig->getFilter('dynamic')); $this->assertSame('dynamic', $filter->getName()); } public function testUndefinedTestCallback() { $twig = new Environment(new ArrayLoader()); $twig->registerUndefinedTestCallback(static function (string $name) { if ('dynamic' === $name) { return new TwigTest('dynamic', static 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(new ArrayLoader()); $twig->registerUndefinedTokenParserCallback(function (string $name) { if ('dynamic' === $name) { $parser = $this->createMock(TokenParserInterface::class); $parser->expects($this->once())->method('getTag')->willReturn('dynamic'); return $parser; } return false; }); $this->assertNull($twig->getTokenParser('does_not_exist')); $this->assertInstanceOf(TokenParserInterface::class, $parser = $twig->getTokenParser('dynamic')); $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); $loader->expects($this->any()) ->method('getSourceContext') ->with($templateName) ->willReturn(new Source($templateContent, $templateName)); $loader->expects($this->any()) ->method('getCacheKey') ->with($templateName) ->willReturn($templateName); 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 { public function getGlobals() { return [ 'foo_global' => 'foo_global', ]; } } class EnvironmentTest_Extension extends AbstractExtension implements GlobalsInterface { public function getTokenParsers(): array { return [ new EnvironmentTest_TokenParser(), ]; } public function getNodeVisitors(): array { return [ new EnvironmentTest_NodeVisitor(), ]; } public function getFilters(): array { return [ new TwigFilter('foo_filter'), ]; } public function getTests(): array { return [ new TwigTest('foo_test'), ]; } public function getFunctions(): array { return [ new TwigFunction('foo_function'), ]; } public function getExpressionParsers(): array { return [ new UnaryOperatorExpressionParser('', 'foo_unary', 0), new BinaryOperatorExpressionParser('', 'foo_binary', 0), ]; } public function getGlobals(): array { return [ 'foo_global' => 'foo_global', ]; } } 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 { return 'test'; } } class EnvironmentTest_NodeVisitor implements NodeVisitorInterface { public function enterNode(Node $node, Environment $env): Node { return $node; } public function leaveNode(Node $node, Environment $env): ?Node { return $node; } public function getPriority(): int { return 0; } } class EnvironmentTest_ExtensionWithoutRuntime extends AbstractExtension { public function getFunctions(): array { return [ new TwigFunction('from_runtime_array', ['Twig\Tests\EnvironmentTest_Runtime', 'fromRuntime']), new TwigFunction('from_runtime_string', 'Twig\Tests\EnvironmentTest_Runtime::fromRuntime'), ]; } } class EnvironmentTest_Runtime { public function fromRuntime($name = 'bar') { return $name; } } class EnvironmentTest_LegacyEchoingNode extends Node { public function compile($compiler) { $compiler ->addDebugInfo($this) ->write('echo "bar";') ; } } ================================================ FILE: tests/ErrorTest.php ================================================ setSourceContext(new Source('', new \SplFileInfo(__FILE__))); $this->assertStringContainsString('tests'.\DIRECTORY_SEPARATOR.'ErrorTest.php', $error->getMessage()); } public function testTwigExceptionGuessWithMissingVarAndArrayLoader() { $loader = new ArrayLoader([ 'base.html' => '{% block content %}{% endblock %}', 'index.html' => << true, 'debug' => true, 'cache' => false]); $template = $twig->load('index.html'); try { $template->render([]); $this->fail(); } catch (RuntimeError $e) { $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()); } } public function testTwigExceptionGuessWithExceptionAndArrayLoader() { $loader = new ArrayLoader([ 'base.html' => '{% block content %}{% endblock %}', 'index.html' => << true, 'debug' => true, 'cache' => false]); $template = $twig->load('index.html'); try { $template->render(['foo' => new ErrorTest_Foo()]); $this->fail(); } catch (RuntimeError $e) { $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()); } } public function testTwigExceptionGuessWithMissingVarAndFilesystemLoader() { $loader = new FilesystemLoader(__DIR__.'/Fixtures/errors'); $twig = new Environment($loader, ['strict_variables' => true, 'debug' => true, 'cache' => false]); $template = $twig->load('index.html'); try { $template->render([]); $this->fail(); } catch (RuntimeError $e) { $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()); $this->assertEquals(strtr(__DIR__.'/Fixtures/errors/index.html', '/', \DIRECTORY_SEPARATOR), $e->getFile()); } } public function testTwigExceptionGuessWithExceptionAndFilesystemLoader() { $loader = new FilesystemLoader(__DIR__.'/Fixtures/errors'); $twig = new Environment($loader, ['strict_variables' => true, 'debug' => true, 'cache' => false]); $template = $twig->load('index.html'); try { $template->render(['foo' => new ErrorTest_Foo()]); $this->fail(); } catch (RuntimeError $e) { $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()); $this->assertEquals(strtr(__DIR__.'/Fixtures/errors/index.html', '/', \DIRECTORY_SEPARATOR), $e->getFile()); } } /** * @dataProvider getErroredTemplates */ public function testTwigExceptionAddsFileAndLine($templates, $name, $line) { $loader = new ArrayLoader($templates); $twig = new Environment($loader, ['strict_variables' => true, 'debug' => true, 'cache' => false]); $template = $twig->load('index'); try { $template->render([]); $this->fail(); } catch (RuntimeError $e) { $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()); } try { $template->render(['foo' => new ErrorTest_Foo()]); $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($line, $e->getTemplateLine()); $this->assertEquals($name, $e->getSourceContext()->getName()); } } public function testTwigArrayFilterThrowsRuntimeExceptions() { $loader = new ArrayLoader([ 'filter-null.html' => << x > 3) %} This list contains {{n}}. {% endfor %} EOHTML, ]); $twig = new Environment($loader, ['debug' => true, 'cache' => false]); $template = $twig->load('filter-null.html'); $out = $template->render(['variable' => [1, 2, 3, 4]]); $this->assertEquals('This list contains 4.', trim($out)); try { $template->render(['variable' => null]); $this->fail(); } catch (RuntimeError $e) { $this->assertEquals(2, $e->getTemplateLine()); $this->assertEquals('filter-null.html', $e->getSourceContext()->getName()); } } public function testTwigArrayMapThrowsRuntimeExceptions() { $loader = new ArrayLoader([ 'map-null.html' => << x * 3) %} {{- n -}} {% endfor %} EOHTML, ]); $twig = new Environment($loader, ['debug' => true, 'cache' => false]); $template = $twig->load('map-null.html'); $out = $template->render(['variable' => [1, 2, 3, 4]]); $this->assertEquals('36912', trim($out)); try { $template->render(['variable' => null]); $this->fail(); } catch (RuntimeError $e) { $this->assertEquals(2, $e->getTemplateLine()); $this->assertEquals('map-null.html', $e->getSourceContext()->getName()); } } public function testTwigArrayReduceThrowsRuntimeExceptions() { $loader = new ArrayLoader([ 'reduce-null.html' => << carry + x) }} EOHTML, ]); $twig = new Environment($loader, ['debug' => true, 'cache' => false]); $template = $twig->load('reduce-null.html'); $out = $template->render(['variable' => [1, 2, 3, 4]]); $this->assertEquals('10', trim($out)); try { $template->render(['variable' => null]); $this->fail(); } catch (RuntimeError $e) { $this->assertEquals(2, $e->getTemplateLine()); $this->assertEquals('reduce-null.html', $e->getSourceContext()->getName()); } } 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 [ [ 'index' => "\n\n{{ foo.bar }}\n\n\n{{ 'foo' }}", ], 'index', 3, ], // error occurs in an included template [ [ 'index' => "{% include 'partial' %}", 'partial' => '{{ foo.bar }}', ], 'partial', 1, ], // error occurs in a parent block when called via parent() [ [ 'index' => "{% extends 'base' %} {% block content %} {{ parent() }} {% endblock %}", 'base' => '{% block content %}{{ foo.bar }}{% endblock %}', ], 'base', 1, ], // error occurs in a block from the child [ [ 'index' => "{% extends 'base' %} {% block content %} {{ foo.bar }} {% endblock %} {% block foo %} {{ foo.bar }} {% endblock %}", 'base' => '{% block content %}{% endblock %}', ], '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 testErrorFromArrayLoader() { $templates = [ 'index.twig' => '{% include "include.twig" %}', 'include.twig' => $include = << 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 testErrorFromFilesystemLoader() { $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()); } } } class ErrorTest_Foo { public function bar() { throw new \Exception('Runtime error...'); } } ================================================ FILE: tests/ExpressionParserTest.php ================================================ false, 'autoescape' => false]); $parser = new Parser($env); $this->expectException(SyntaxError::class); $parser->parse($env->tokenize(new Source($template, 'index'))); } public static function getFailingTestsForAssignment() { return [ ['{% set false = "foo" %}'], ['{% set FALSE = "foo" %}'], ['{% set true = "foo" %}'], ['{% set TRUE = "foo" %}'], ['{% set none = "foo" %}'], ['{% set NONE = "foo" %}'], ['{% set null = "foo" %}'], ['{% set NULL = "foo" %}'], ['{% set 3 = "foo" %}'], ['{% set 1 + 2 = "foo" %}'], ['{% set "bar" = "foo" %}'], ['{% set %}{% endset %}'], ]; } /** * @dataProvider getTestsForSequence */ public function testSequenceExpression($template, $expected) { $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')); } /** * @dataProvider getFailingTestsForSequence */ public function testSequenceSyntaxError($template) { $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 static function getFailingTestsForSequence() { return [ ['{{ [1, "a": "b"] }}'], ['{{ {"a": "b", 2} }}'], ['{{ {"a"} }}'], ]; } public static function getTestsForSequence() { return [ // simple sequence ['{{ [1, 2] }}', new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression(1, 1), new ConstantExpression(1, 1), new ConstantExpression(2, 1), ], 1), ], // sequence with trailing , ['{{ [1, 2, ] }}', new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression(1, 1), new ConstantExpression(1, 1), new ConstantExpression(2, 1), ], 1), ], // simple mapping ['{{ {"a": "b", "b": "c"} }}', new ArrayExpression([ new ConstantExpression('a', 1), new ConstantExpression('b', 1), new ConstantExpression('b', 1), new ConstantExpression('c', 1), ], 1), ], // mapping with trailing , ['{{ {"a": "b", "b": "c", } }}', new ArrayExpression([ new ConstantExpression('a', 1), new ConstantExpression('b', 1), new ConstantExpression('b', 1), new ConstantExpression('c', 1), ], 1), ], // mapping in a sequence ['{{ [1, {"a": "b", "b": "c"}] }}', new ArrayExpression([ new ConstantExpression(0, 1), new ConstantExpression(1, 1), new ConstantExpression(1, 1), new ArrayExpression([ new ConstantExpression('a', 1), new ConstantExpression('b', 1), new ConstantExpression('b', 1), new ConstantExpression('c', 1), ], 1), ], 1), ], // 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), ], 1), ], ['{{ {a, b} }}', new ArrayExpression([ new ConstantExpression('a', 1), new ContextVariable('a', 1), new ConstantExpression('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() { $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); } public function testSequenceCompilationError() { $env = new Environment(new ArrayLoader(['index' => '{{ [1,,2] }}']), ['cache' => false, 'autoescape' => false]); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Empty array elements are only allowed in destructuring assignments'); $env->compileSource(new Source('{{ [1,,2] }}', 'index')); } /** * @dataProvider getTestsForString */ public function testStringExpression($template, $expected) { $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')); } public static function getTestsForString() { return [ [ '{{ "foo #{bar}" }}', new ConcatBinary( new ConstantExpression('foo ', 1), new ContextVariable('bar', 1), 1 ), ], [ '{{ "foo #{bar} baz" }}', new ConcatBinary( new ConcatBinary( new ConstantExpression('foo ', 1), new ContextVariable('bar', 1), 1 ), new ConstantExpression(' baz', 1), 1 ), ], [ '{{ "foo #{"foo #{bar} baz"} baz" }}', new ConcatBinary( new ConcatBinary( new ConstantExpression('foo ', 1), new ConcatBinary( new ConcatBinary( new ConstantExpression('foo ', 1), new ContextVariable('bar', 1), 1 ), new ConstantExpression(' baz', 1), 1 ), 1 ), new ConstantExpression(' baz', 1), 1 ), ], ]; } /** * @dataProvider getTestsForNullSafeOperator */ public function testNullSafeOperator($template, $data, $expected) { $env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]); $this->assertSame($expected, $env->render('template', $data)); } public static function getTestsForNullSafeOperator() { return [ [ '{{ foo?.bar }}', ['foo' => (object) ['bar' => 'baz']], 'baz', ], [ '{{ foo?.bar }}', ['foo' => null], '', ], [ '{{ foo?.bar?.baz }}', ['foo' => (object) ['bar' => (object) ['baz' => 'qux']]], 'qux', ], [ '{{ foo?.bar?.baz }}', ['foo' => (object) ['bar' => null]], '', ], [ '{{ foo?.bar?.baz }}', ['foo' => null], '', ], [ '{{ foo?.bar?.baz ?? "qux" }}', ['foo' => null], 'qux', ], [ '{{ foo?.bar ?? "qux" }}', ['foo' => (object) ['bar' => 0]], '0', ], [ '{{ foo?.bar ?? "qux" }}', ['foo' => (object) ['bar' => false]], '', ], // short-circuiting [ '{{ foo?.bar.baz }}', ['foo' => null], '', ], [ '{{ foo?.bar.baz?.qux.corge }}', ['foo' => null], '', ], [ '{{ foo?.bar.baz?.qux.corge }}', ['foo' => (object) ['bar' => (object) ['baz' => null]]], '', ], ]; } /** * @dataProvider getTestForInvalidNullSafeOperatorShortCircuiting */ public function testInvalidNullSafeOperatorShortCircuiting(string $template, array $data, string $expectedMessage) { $env = new Environment(new ArrayLoader(['template' => $template]), ['strict_variables' => true]); $this->expectException(RuntimeError::class); $this->expectExceptionMessage($expectedMessage); $env->render('template', $data); } public static function getTestForInvalidNullSafeOperatorShortCircuiting() { yield [ '{{ foo?.bar.baz }}', ['foo' => (object) ['bar' => null]], 'Impossible to access an attribute ("baz") on a null variable in "template" at line 1.', ]; yield [ '{{ foo?.bar.baz?.qux.corge }}', ['foo' => (object) ['bar' => (object) ['baz' => (object) ['qux' => null]]]], 'Impossible to access an attribute ("corge") on a null variable in "template" at line 1.', ]; } public function testMacroDefinitionDoesNotSupportNonNameVariableName() { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); $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.'); $parser->parse($env->tokenize(new Source('{% macro foo("a") %}{% endmacro %}', 'index'))); } /** * @dataProvider getMacroDefinitionDoesNotSupportNonConstantDefaultValues */ public function testMacroDefinitionDoesNotSupportNonConstantDefaultValues($template) { $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 static function getMacroDefinitionDoesNotSupportNonConstantDefaultValues() { return [ ['{% macro foo(name = "a #{foo} a") %}{% endmacro %}'], ['{% macro foo(name = [["b", "a #{foo} a"]]) %}{% endmacro %}'], ]; } /** * @dataProvider getMacroDefinitionSupportsConstantDefaultValues */ public function testMacroDefinitionSupportsConstantDefaultValues($template) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $parser = new Parser($env); $parser->parse($env->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 // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public static function getMacroDefinitionSupportsConstantDefaultValues() { return [ ['{% macro foo(name = "aa") %}{% endmacro %}'], ['{% macro foo(name = 12) %}{% endmacro %}'], ['{% macro foo(name = true) %}{% endmacro %}'], ['{% macro foo(name = ["a"]) %}{% endmacro %}'], ['{% macro foo(name = [["a"]]) %}{% endmacro %}'], ['{% macro foo(name = {a: "a"}) %}{% endmacro %}'], ['{% macro foo(name = {a: {b: "a"}}) %}{% endmacro %}'], ]; } 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?'); $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.'); $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?'); $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.'); $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'); $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.'); $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', static 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', static 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', static 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('{{ 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;")]; // = stronger than anything else yield '= same as literal' => ['{% do c = "a" %}{{ c }}', '{% do c = ("a") %}{{ c }}', eval("return 'a';")]; yield '= stronger than .' => ['{% do c = a.b %}{{ c }}', '{% do c = (a.b) %}{{ c }}', eval("\$a = ['b' => 1]; return \$a['b'];"), $context]; yield '= stronger than math' => ['{% do a = 1 + 3 %}{{ a }}', '{% do a = (1 + 3) %}{{ a }}', eval('$a = 1 + 3; return $a;')]; yield '= stronger than logical' => ['{% do a = false or true %}{{ a }}', '{% do a = (false or true) %}{{ a }}', eval('$a = false || true; return $a;')]; yield '= stronger than ternary' => ['{% do c = 4 ? 0 : -1 %}{{ c }}', '{% do c = (4 ? 0 : -1) %}{{ c }}', eval('return 4 ? 0 : -1;')]; } public function testLiteralExpressionParserGetOperatorTokensReturnsEmptyArray() { $env = new Environment(new ArrayLoader()); $parser = $env->getExpressionParsers()->getByClass(LiteralExpressionParser::class); $this->assertSame([], $parser->getOperatorTokens()); $this->assertSame('literal', $parser->getName()); } public function testExpressionParserGetOperatorTokensDefaultBehavior() { $env = new Environment(new ArrayLoader()); foreach ($env->getExpressionParsers() as $parser) { if ($parser instanceof LiteralExpressionParser) { continue; } $expected = [$parser->getName(), ...$parser->getAliases()]; $this->assertSame($expected, $parser->getOperatorTokens(), \sprintf('getOperatorTokens() for %s should return name + aliases.', $parser::class)); } } public function testLiteralIsNotRegisteredAsOperator() { // Ensure "literal" is not in the operator registry $env = new Environment(new ArrayLoader()); $this->assertNull($env->getExpressionParsers()->getByName(PrefixExpressionParserInterface::class, 'literal')); $this->assertNull($env->getExpressionParsers()->getByName(InfixExpressionParserInterface::class, 'literal')); } } 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); } } ================================================ FILE: tests/Extension/AttributeExtensionTest.php ================================================ 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); } } ================================================ FILE: tests/Extension/CoreTest.php ================================================ 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) { for ($i = 0; $i < 100; ++$i) { $this->assertTrue(\in_array(CoreExtension::random('UTF-8', $value1, $value2), $expectedInArray, true)); // assertContains() would not consider the type } } public static function getRandomFunctionTestData() { return [ 'array' => [ ['apple', 'orange', 'citrus'], ['apple', 'orange', 'citrus'], ], 'Traversable' => [ ['apple', 'orange', 'citrus'], new \ArrayObject(['apple', 'orange', 'citrus']), ], 'unicode string' => [ ['Ä', '€', 'é'], 'Ä€é', ], 'numeric but string' => [ ['1', '2', '3'], '123', ], 'integer' => [ range(0, 5, 1), 5, ], 'float' => [ range(0, 5, 1), 5.9, ], 'negative' => [ [0, -1, -2], -2, ], 'min max int' => [ range(50, 100), 50, 100, ], 'min max float' => [ range(-10, 10), -9.5, 9.5, ], 'min null' => [ range(0, 100), null, 100, ], ]; } public function testRandomFunctionWithoutParameter() { $max = mt_getrandmax(); for ($i = 0; $i < 100; ++$i) { $val = CoreExtension::random('UTF-8'); $this->assertTrue(\is_int($val) && $val >= 0 && $val <= $max); } } public function testRandomFunctionReturnsAsIs() { $this->assertSame('', CoreExtension::random('UTF-8', '')); $instance = new \stdClass(); $this->assertSame($instance, CoreExtension::random('UTF-8', $instance)); } public function testRandomFunctionOfEmptyArrayThrowsException() { $this->expectException(RuntimeError::class); CoreExtension::random('UTF-8', []); } public function testRandomFunctionOnNonUTF8String() { $text = iconv('UTF-8', 'ISO-8859-1', 'Äé'); for ($i = 0; $i < 30; ++$i) { $rand = CoreExtension::random('ISO-8859-1', $text); $this->assertTrue(\in_array(iconv('ISO-8859-1', 'UTF-8', $rand), ['Ä', 'é'], true)); } } public function testReverseFilterOnNonUTF8String() { $input = iconv('UTF-8', 'ISO-8859-1', 'Äé'); $output = iconv('ISO-8859-1', 'UTF-8', CoreExtension::reverse('ISO-8859-1', $input)); $this->assertEquals($output, 'éÄ'); } /** * @dataProvider provideTwigFirstCases */ public function testTwigFirst($expected, $input) { $this->assertSame($expected, CoreExtension::first('UTF-8', $input)); } public static function provideTwigFirstCases() { $i = [1 => 'a', 2 => 'b', 3 => 'c']; return [ ['a', 'abc'], [1, [1, 2, 3]], ['', null], ['', ''], ['a', new CoreTestIterator($i, array_keys($i), true, 3)], ]; } /** * @dataProvider provideTwigLastCases */ public function testTwigLast($expected, $input) { $this->assertSame($expected, CoreExtension::last('UTF-8', $input)); } public static function provideTwigLastCases() { $i = [1 => 'a', 2 => 'b', 3 => 'c']; return [ ['c', 'abc'], [3, [1, 2, 3]], ['', null], ['', ''], ['c', new CoreTestIterator($i, array_keys($i), true)], ]; } /** * @dataProvider provideArrayKeyCases */ public function testArrayKeysFilter(array $expected, $input) { $this->assertSame($expected, CoreExtension::keys($input)); } public static function provideArrayKeyCases() { $array = ['a' => 'a1', 'b' => 'b1', 'c' => 'c1']; $keys = array_keys($array); return [ [$keys, $array], [$keys, new CoreTestIterator($array, $keys)], [$keys, new CoreTestIteratorAggregate($array, $keys)], [$keys, new CoreTestIteratorAggregateAggregate($array, $keys)], [[], null], [['a'], new \SimpleXMLElement('')], ]; } /** * @dataProvider provideInFilterCases */ public function testInFilter($expected, $value, $compare) { $this->assertSame($expected, CoreExtension::inFilter($value, $compare)); } public static function provideInFilterCases() { $array = [1, 2, 'a' => 3, 5, 6, 7]; $keys = array_keys($array); return [ [true, 1, $array], [true, '3', $array], [true, '3', 'abc3def'], [true, 1, new CoreTestIterator($array, $keys, true, 1)], [true, '3', new CoreTestIterator($array, $keys, true, 3)], [true, '3', new CoreTestIteratorAggregateAggregate($array, $keys, true, 3)], [false, 4, $array], [false, 4, new CoreTestIterator($array, $keys, true)], [false, 4, new CoreTestIteratorAggregateAggregate($array, $keys, true)], [false, 1, 1], [true, 'b', new \SimpleXMLElement('b')], ]; } /** * @dataProvider provideSliceFilterCases */ public function testSliceFilter($expected, $input, $start, $length = null, $preserveKeys = false) { $this->assertSame($expected, CoreExtension::slice('UTF-8', $input, $start, $length, $preserveKeys)); } public static function provideSliceFilterCases() { $i = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; $keys = array_keys($i); return [ [['a' => 1], $i, 0, 1, true], [['a' => 1], $i, 0, 1, false], [['b' => 2, 'c' => 3], $i, 1, 2], [[1], [1, 2, 3, 4], 0, 1], [[2, 3], [1, 2, 3, 4], 1, 2], [[2, 3], new CoreTestIterator($i, $keys, true), 1, 2], [['c' => 3, 'd' => 4], new CoreTestIteratorAggregate($i, $keys, true), 2, null, true], [$i, new CoreTestIterator($i, $keys, true), 0, \count($keys) + 10, true], [[], new CoreTestIterator($i, $keys, true), \count($keys) + 10], ['de', 'abcdef', 3, 2], [[], new \SimpleXMLElement('12'), 3], [[], new \ArrayIterator([1, 2]), 3], ]; } /** * @dataProvider provideCompareCases */ public function testCompare($expected, $a, $b) { $this->assertSame($expected, CoreExtension::compare($a, $b)); $this->assertSame($expected, -CoreExtension::compare($b, $a)); } public function testCompareNAN() { $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 static function provideCompareCases() { return [ [0, 'a', 'a'], // from https://wiki.php.net/rfc/string_to_number_comparison [0, 0, '0'], [0, 0, '0.0'], [-1, 0, 'foo'], [1, 0, ''], [0, 42, ' 42'], [-1, 42, '42foo'], [0, '0', '0'], [0, '0', '0.0'], [-1, '0', 'foo'], [1, '0', ''], [0, '42', ' 42'], [-1, '42', '42foo'], [0, 42, '000042'], [0, 42, '42.0'], [0, 42.0, '+42.0E0'], [0, 0, '0e214987142012'], [0, '42', '000042'], [0, '42', '42.0'], [0, '42.0', '+42.0E0'], [0, '0', '0e214987142012'], [0, 42, ' 42'], [0, 42, '42 '], [-1, 42, '42abc'], [-1, 42, 'abc42'], [-1, 0, 'abc42'], [0, 42.0, ' 42.0'], [0, 42.0, '42.0 '], [-1, 42.0, '42.0abc'], [-1, 42.0, 'abc42.0'], [-1, 0.0, 'abc42.0'], [0, \INF, 'INF'], [0, -\INF, '-INF'], [0, \INF, '1e1000'], [0, -\INF, '-1e1000'], [-1, 10, 20], [-1, '10', 20], [-1, 10, '20'], [1, 42, ' foo'], [0, 42, "42\f"], [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()); } /** * @group legacy */ public function testCycleWithArrayAccessAndTraversableButNotCountable() { $this->expectDeprecation('Since twig/twig 3.12: Passing a non-countable sequence of values to "Twig\Extension\CoreExtension::cycle()" is deprecated.'); $seq = new class implements \ArrayAccess, \IteratorAggregate { public function offsetExists($offset): bool { return true; } public function offsetGet($offset): mixed { return 'val'; } public function offsetSet($offset, $value): void { } public function offsetUnset($offset): void { } public function getIterator(): \Traversable { yield 'odd'; yield 'even'; } }; $result = CoreExtension::cycle($seq, 0); $this->assertEquals('odd', $result, 'cycle should return the first item from the traversable sequence, not the sequence itself.'); } } final class CoreTestIteratorAggregate implements \IteratorAggregate { private $iterator; public function __construct(array $array, array $keys, $allowAccess = false, $maxPosition = false) { $this->iterator = new CoreTestIterator($array, $keys, $allowAccess, $maxPosition); } public function getIterator(): \Traversable { return $this->iterator; } } final class CoreTestIteratorAggregateAggregate implements \IteratorAggregate { private $iterator; public function __construct(array $array, array $keys, $allowValueAccess = false, $maxPosition = false) { $this->iterator = new CoreTestIteratorAggregate($array, $keys, $allowValueAccess, $maxPosition); } public function getIterator(): \Traversable { return $this->iterator; } } final class CoreTestIterator implements \Iterator { private $position; private $array; private $arrayKeys; private $allowValueAccess; private $maxPosition; public function __construct(array $values, array $keys, $allowValueAccess = false, $maxPosition = false) { $this->array = $values; $this->arrayKeys = $keys; $this->position = 0; $this->allowValueAccess = $allowValueAccess; $this->maxPosition = false === $maxPosition ? \count($values) + 1 : $maxPosition; } public function rewind(): void { $this->position = 0; } #[\ReturnTypeWillChange] public function current() { if ($this->allowValueAccess) { return $this->array[$this->key()]; } throw new \LogicException('Code should only use the keys, not the values provided by iterator.'); } #[\ReturnTypeWillChange] public function key() { return $this->arrayKeys[$this->position]; } public function next(): void { ++$this->position; if ($this->position === $this->maxPosition) { throw new \LogicException(\sprintf('Code should not iterate beyond %d.', $this->maxPosition)); } } public function valid(): bool { return isset($this->arrayKeys[$this->position]); } } ================================================ FILE: tests/Extension/EscaperTest.php ================================================ getExtension(EscaperExtension::class); $escaperExt->setEscaper('foo', 'Twig\Tests\legacy_escaper'); $this->assertSame($expected, $twig->getRuntime(EscaperRuntime::class)->escape($string, $strategy, 'ISO-8859-1')); } public static function provideCustomEscaperCases() { return [ ['foo**ISO-8859-1**UTF-8', 'foo', 'foo'], ['**ISO-8859-1**UTF-8', null, 'foo'], ['42**ISO-8859-1**UTF-8', 42, 'foo'], ]; } /** * @dataProvider provideCustomEscaperCases * * @group legacy */ public function testCustomEscaperWithoutCallingSetEscaperRuntime($expected, $string, $strategy) { $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')); } /** * @group legacy */ public function testCustomEscapersOnMultipleEnvs() { $env1 = new Environment(new ArrayLoader()); $escaperExt1 = $env1->getExtension(EscaperExtension::class); $escaperExt1->setEscaper('foo', 'Twig\Tests\legacy_escaper'); $env2 = new Environment(new ArrayLoader()); $escaperExt2 = $env2->getExtension(EscaperExtension::class); $escaperExt2->setEscaper('foo', 'Twig\Tests\legacy_escaper_again'); $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 testLastModified() { $this->assertGreaterThan(1000000000, (new EscaperExtension())->getLastModified()); } } function legacy_escaper(Environment $twig, $string, $charset) { return $string.'**'.$charset.'**'.$twig->getCharset(); } function legacy_escaper_again(Environment $twig, $string, $charset) { return $string.'**'.$charset.'**'.$twig->getCharset().'**again'; } ================================================ FILE: tests/Extension/Fixtures/ExtensionWithAttributes.php ================================================ assertSame(DebugExtension::dump($env, 'Foo'), twig_var_dump($env, 'Foo')); } } ================================================ FILE: tests/Extension/LegacyStringLoaderFunctionsTest.php ================================================ assertSame(StringLoaderExtension::templateFromString($env, 'Foo')->render(), twig_template_from_string($env, 'Foo')->render()); } } ================================================ FILE: tests/Extension/SandboxTest.php ================================================ '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 }}', '1_basic2' => '{{ name|upper }}', '1_basic3' => '{% if name %}foo{% endif %}', '1_basic4' => '{{ obj.bar }}', '1_basic5' => '{{ obj }}', '1_basic7' => '{{ cycle(["foo","bar"], 1) }}', '1_basic8' => '{{ obj.getfoobar }}{{ obj.getFooBar }}', '1_basic9' => '{{ obj.foobar }}{{ obj.fooBar }}', '1_basic' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}', '1_layout' => '{% block content %}{% endblock %}', '1_child' => "{% extends \"1_layout\" %}\n{% block content %}\n{{ \"a\"|json_encode }}\n{% endblock %}", '1_include' => '{{ include("1_basic1", sandboxed=true) }}', '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_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->load('1_child')->render([]); } public function testSandboxGloballySet() { $twig = $this->getEnvironment(false, [], self::$templates); $this->assertEquals('FOO', $twig->load('1_basic')->render(self::$params), 'Sandbox does nothing if it is disabled globally'); } public function testSandboxUnallowedPropertyAccessor() { $twig = $this->getEnvironment(true, [], self::$templates); try { $twig->load('1_basic1')->render(['obj' => new MagicObject()]); $this->fail('Sandbox throws a SecurityError exception if an unallowed method is called'); } 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()); } } public function testIfSandBoxIsDisabledAfterSyntaxError() { $twig = $this->getEnvironment(false, [], self::$templates); try { $twig->load('1_syntax_error_wrapper')->render(self::$params); } catch (SyntaxError $e) { /** @var SandboxExtension $sandbox */ $sandbox = $twig->getExtension(SandboxExtension::class); $this->assertFalse($sandbox->isSandboxed()); } } public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromStringSandboxed() { $twig = $this->getEnvironment(false, [], self::$templates); $twig->addExtension(new StringLoaderExtension()); 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 (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStringSandboxed() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['include', 'template_from_string']); $twig->addExtension(new StringLoaderExtension()); 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 (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } public function testSandboxGloballyFalseUnallowedFilterWithIncludeTemplateFromStringNotSandboxed() { $twig = $this->getEnvironment(false, [], self::$templates); $twig->addExtension(new StringLoaderExtension()); $this->assertSame('FABIEN', $twig->load('1_basic2_include_template_from_string')->render(self::$params)); } public function testSandboxGloballyTrueUnallowedFilterWithIncludeTemplateFromStringNotSandboxed() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['include', 'template_from_string']); $twig->addExtension(new StringLoaderExtension()); 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 (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } public function testSandboxUnallowedFilter() { $twig = $this->getEnvironment(true, [], self::$templates); try { $twig->load('1_basic2')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if an unallowed filter is called'); } catch (SecurityNotAllowedFilterError $e) { $this->assertEquals('upper', $e->getFilterName(), 'Exception should be raised on the "upper" filter'); } } public function testSandboxUnallowedTag() { $twig = $this->getEnvironment(true, [], self::$templates); 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 (SecurityNotAllowedTagError $e) { $this->assertEquals('if', $e->getTagName(), 'Exception should be raised on the "if" tag'); } } public function testSandboxUnallowedProperty() { $twig = $this->getEnvironment(true, [], self::$templates); 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 (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'); } } /** * @dataProvider getSandboxUnallowedToStringTests */ public function testSandboxUnallowedToString($template) { $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()" 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 static function getSandboxUnallowedToStringTests() { return [ 'simple' => ['{{ obj }}'], 'object_from_array' => ['{{ arr.obj }}'], 'object_chain' => ['{{ obj.anotherFooObject }}'], 'filter' => ['{{ obj|upper }}'], 'filter_from_array' => ['{{ arr.obj|upper }}'], 'function' => ['{{ random(obj) }}'], 'function_from_array' => ['{{ random(arr.obj) }}'], 'function_and_filter' => ['{{ random(obj|upper) }}'], 'function_and_filter_from_array' => ['{{ random(arr.obj|upper) }}'], 'object_chain_and_filter' => ['{{ obj.anotherFooObject|upper }}'], '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(", ") }}'], ]; } /** * @dataProvider getSandboxAllowedToStringTests */ public function testSandboxAllowedToString($template, $output) { $twig = $this->getEnvironment(true, [], ['index' => $template], ['set'], [], ['Twig\Tests\Extension\FooObject' => ['foo', 'getAnotherFooObject']]); $this->assertEquals($output, $twig->load('index')->render(self::$params)); } public static function getSandboxAllowedToStringTests() { return [ 'constant_test' => ['{{ obj is constant("PHP_INT_MAX") }}', ''], 'set_object' => ['{% set a = obj.anotherFooObject %}{{ a.foo }}', 'foo'], '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'], 'is_sameas_from_array' => ['{{ arr.obj is same as(arr.obj) }}', '1'], 'is_sameas_from_array_no_brackets' => ['{{ arr.obj is same as arr.obj }}', '1'], 'is_sameas_from_another_method' => ['{{ obj.anotherFooObject is same as(obj.anotherFooObject) }}', ''], 'is_sameas_from_another_method_no_brackets' => ['{{ obj.anotherFooObject is same as obj.anotherFooObject }}', ''], ]; } public function testSandboxAllowMethodToString() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['Twig\Tests\Extension\FooObject' => '__toString']); FooObject::reset(); $this->assertEquals('foo', $twig->load('1_basic5')->render(self::$params), 'Sandbox allow some methods'); $this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once'); } public function testSandboxAllowMethodToStringDisabled() { $twig = $this->getEnvironment(false, [], self::$templates); FooObject::reset(); $this->assertEquals('foo', $twig->load('1_basic5')->render(self::$params), 'Sandbox allows __toString when sandbox disabled'); $this->assertEquals(1, FooObject::$called['__toString'], 'Sandbox only calls method once'); } public function testSandboxUnallowedFunction() { $twig = $this->getEnvironment(true, [], self::$templates); 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 (SecurityNotAllowedFunctionError $e) { $this->assertEquals('cycle', $e->getFunctionName(), 'Exception should be raised on the "cycle" function'); } } public function testSandboxUnallowedRangeOperator() { $twig = $this->getEnvironment(true, [], self::$templates); try { $twig->load('1_range_operator')->render(self::$params); $this->fail('Sandbox throws a SecurityError exception if the unallowed range operator is called'); } catch (SecurityNotAllowedFunctionError $e) { $this->assertEquals('range', $e->getFunctionName(), 'Exception should be raised on the "range" function'); } } public function testSandboxAllowMethodFoo() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['Twig\Tests\Extension\FooObject' => 'foo']); FooObject::reset(); $this->assertEquals('foo', $twig->load('1_basic1')->render(self::$params), 'Sandbox allow some methods'); $this->assertEquals(1, FooObject::$called['foo'], 'Sandbox only calls method once'); } public function testSandboxAllowFilter() { $twig = $this->getEnvironment(true, [], self::$templates, [], ['upper']); $this->assertEquals('FABIEN', $twig->load('1_basic2')->render(self::$params), 'Sandbox allow some filters'); } public function testSandboxAllowTag() { $twig = $this->getEnvironment(true, [], self::$templates, ['if']); $this->assertEquals('foo', $twig->load('1_basic3')->render(self::$params), 'Sandbox allow some tags'); } public function testSandboxAllowProperty() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], ['Twig\Tests\Extension\FooObject' => 'bar']); $this->assertEquals('bar', $twig->load('1_basic4')->render(self::$params), 'Sandbox allow some properties'); } public function testSandboxAllowFunction() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['cycle']); $this->assertEquals('bar', $twig->load('1_basic7')->render(self::$params), 'Sandbox allow some functions'); } public function testSandboxAllowRangeOperator() { $twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['range']); $this->assertEquals('1', $twig->load('1_range_operator')->render(self::$params), 'Sandbox allow the range operator'); } public function testSandboxAllowMethodsCaseInsensitive() { foreach (['getfoobar', 'getFoobar', 'getFooBar'] as $name) { $twig = $this->getEnvironment(true, [], self::$templates, [], [], ['Twig\Tests\Extension\FooObject' => $name]); FooObject::reset(); $this->assertEquals('foobarfoobar', $twig->load('1_basic8')->render(self::$params), 'Sandbox allow methods in a case-insensitive way'); $this->assertEquals(2, FooObject::$called['getFooBar'], 'Sandbox only calls method once'); $this->assertEquals('foobarfoobar', $twig->load('1_basic9')->render(self::$params), 'Sandbox allow methods via shortcut names (ie. without get/set)'); } } public function testSandboxLocallySetForAnInclude() { self::$templates = [ '2_basic' => '{{ obj.foo }}{% include "2_included" %}{{ obj.foo }}', '2_included' => '{% if obj.foo %}{{ obj.foo|upper }}{% endif %}', ]; $twig = $this->getEnvironment(false, [], self::$templates); $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' => '{{ include("3_included", sandboxed: true) }}', '3_included' => '{% if true %}{{ "foo"|upper }}{% endif %}', ]; $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 (SecurityNotAllowedTagError $e) { $this->assertEquals('if', $e->getTagName()); } } public function testMacrosInASandbox() { $twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => <<{{ text }}

    {% endmacro %} {{- macros.test('username') }} EOF ], ['macro', 'import'], ['escape']); $this->assertEquals('

    username

    ', $twig->load('index')->render([])); } public function testSandboxDisabledAfterIncludeFunctionError() { $twig = $this->getEnvironment(false, [], self::$templates); $e = null; try { $twig->load('1_include')->render(self::$params); } catch (\Throwable $e) { } if (null === $e) { $this->fail('An exception should be thrown for this test to be valid.'); } $this->assertFalse($twig->getExtension(SandboxExtension::class)->isSandboxed(), 'Sandboxed include() function call should not leave Sandbox enabled when an error occurs.'); } public function testSandboxWithNoClosureFilter() { $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([]); } public function testSandboxWithClosureFilter() { $twig = $this->getEnvironment(true, ['autoescape' => 'html'], ['index' => << v != "")|join(", ") }} EOF ], [], ['escape', 'filter', 'join']); $this->assertSame('foo, bar', $twig->load('index')->render([])); } 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, $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 { public static $called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0]; public $bar = 'bar'; public static function reset() { self::$called = ['__toString' => 0, 'foo' => 0, 'getFooBar' => 0]; } public function __toString() { ++self::$called['__toString']; return 'foo'; } public function foo() { ++self::$called['foo']; return 'foo'; } public function getFooBar() { ++self::$called['getFooBar']; return 'foobar'; } 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.'); } } ================================================ FILE: tests/Extension/StringLoaderExtensionTest.php ================================================ addExtension(new StringLoaderExtension()); $this->assertSame('something', CoreExtension::include($twig, [], StringLoaderExtension::templateFromString($twig, 'something'))); } } ================================================ FILE: tests/FactoryRuntimeLoaderTest.php ================================================ '\Twig\Tests\getRuntime']); $this->assertInstanceOf('stdClass', $loader->load('stdClass')); } public function testLoadReturnsNullForUnmappedRuntime() { $loader = new FactoryRuntimeLoader(); $this->assertNull($loader->load('stdClass')); } } function getRuntime() { return new \stdClass(); } ================================================ FILE: tests/FileExtensionEscapingStrategyTest.php ================================================ assertSame($strategy, FileExtensionEscapingStrategy::guess($filename)); } public static function getGuessData() { return [ // default ['html', 'foo.html'], ['html', 'foo.html.twig'], ['html', 'foo'], ['html', 'foo.bar.twig'], ['html', 'foo.txt/foo'], ['html', 'foo.txt/foo.js/'], // css ['css', 'foo.css'], ['css', 'foo.css.twig'], ['css', 'foo.twig.css'], ['css', 'foo.js.css'], ['css', 'foo.js.css.twig'], // js ['js', 'foo.js'], ['js', 'foo.js.twig'], ['js', 'foo.txt/foo.js'], ['js', 'foo.txt.twig/foo.js'], // txt [false, 'foo.txt'], [false, 'foo.txt.twig'], ]; } } ================================================ FILE: tests/FilesystemHelper.php ================================================ $fileInfo) { if ($fileInfo->isDir()) { rmdir($filename); } else { unlink($filename); } } rmdir($dir); } } ================================================ FILE: tests/Fixtures/autoescape/block.test ================================================ --TEST-- blocks and autoescape --TEMPLATE-- {{ include('unrelated.txt.twig') -}} {{ include('template.html.twig') -}} --TEMPLATE(unrelated.txt.twig)-- {% block content %}{% endblock %} --TEMPLATE(template.html.twig)-- {% extends 'parent.html.twig' %} {% block content %} {{ br -}} {% endblock %} --TEMPLATE(parent.html.twig)-- {% set _content = block('content')|raw %} {{ _content|raw }} --DATA-- return ['br' => '
    '] --CONFIG-- return ['autoescape' => 'name'] --EXPECT-- <br /> ================================================ FILE: tests/Fixtures/autoescape/name.test ================================================ --TEST-- "name" autoescape strategy --TEMPLATE-- {{ br -}} {{ include('index.js.twig') -}} {{ include('index.html.twig') -}} {{ include('index.txt.twig') -}} --TEMPLATE(index.js.twig)-- {{ br -}} --TEMPLATE(index.html.twig)-- {{ br -}} --TEMPLATE(index.txt.twig)-- {{ br -}} --DATA-- return ['br' => '
    '] --CONFIG-- return ['autoescape' => 'name'] --EXPECT-- <br /> \u003Cbr\u0020\/\u003E <br />
    ================================================ FILE: tests/Fixtures/errors/base.html ================================================ {% block content %}{% endblock %} ================================================ FILE: tests/Fixtures/errors/extends/include.twig ================================================ {% extends 'invalid.twig' %} ================================================ FILE: tests/Fixtures/errors/extends/index.twig ================================================ {% include "include.twig" %} ================================================ FILE: tests/Fixtures/errors/index.html ================================================ {% extends 'base.html' %} {% block content %} {{ foo.bar }} {% endblock %} {% block foo %} {{ foo.bar }} {% endblock %} ================================================ FILE: tests/Fixtures/errors/no_line_and_context_exception.twig ================================================ {{ include('no_line_and_context_exception_include_line_' ~ line ~ '.twig') }} ================================================ FILE: tests/Fixtures/errors/no_line_and_context_exception_include_line_1.twig ================================================ {% foo %} ================================================ FILE: tests/Fixtures/errors/no_line_and_context_exception_include_line_5.twig ================================================ {% foo %} ================================================ FILE: tests/Fixtures/exceptions/child_contents_outside_blocks.test ================================================ --TEST-- Exception for child templates defining content outside blocks defined by parent --TEMPLATE-- {% extends 'base.twig' %} Content outside a block. {% block sidebar %} Content inside a block. {% endblock %} --TEMPLATE(base.twig)-- {% block sidebar %} {% endblock %} --EXCEPTION-- Twig\Error\SyntaxError: A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag in "index.twig" at line 3? ================================================ FILE: tests/Fixtures/exceptions/exception_in_extension_extends.test ================================================ --TEST-- Exception thrown from a child for an extension error --TEMPLATE-- {% extends 'base.twig' %} --TEMPLATE(base.twig)-- {{ random([]) }} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: The "random" function cannot pick from an empty sequence or mapping in "base.twig" at line 4. ================================================ FILE: tests/Fixtures/exceptions/exception_in_extension_include.test ================================================ --TEST-- Exception thrown from an include for an extension error --TEMPLATE-- {% include 'content.twig' %} --TEMPLATE(content.twig)-- {{ random([]) }} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: The "random" function cannot pick from an empty sequence or mapping in "content.twig" at line 4. ================================================ FILE: tests/Fixtures/exceptions/multiline_array_with_undefined_variable.test ================================================ --TEST-- Exception for multiline array with undefined variable --TEMPLATE-- {% set foo = { foo: 'foo', bar: 'bar', foobar: foobar, foo2: foo2, } %} --DATA-- return ['foobar' => 'foobar'] --EXCEPTION-- Twig\Error\RuntimeError: Variable "foo2" does not exist in "index.twig" at line 11. ================================================ FILE: tests/Fixtures/exceptions/multiline_array_with_undefined_variable_again.test ================================================ --TEST-- Exception for multiline array with undefined variable --TEMPLATE-- {% set foo = { foo: 'foo', bar: 'bar', foobar: foobar, foo2: foo2, } %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "foobar" does not exist in "index.twig" at line 7. ================================================ FILE: tests/Fixtures/exceptions/multiline_function_with_undefined_variable.test ================================================ --TEST-- Exception for multile function with undefined variable --TEMPLATE-- {{ include('foo', with_context=with_context ) }} --TEMPLATE(foo)-- Foo --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "with_context" does not exist in "index.twig" at line 3. ================================================ FILE: tests/Fixtures/exceptions/multiline_function_with_unknown_argument.test ================================================ --TEST-- Exception for multiline function with unknown argument --TEMPLATE-- {{ include('foo', with_context=True, invalid=False ) }} --EXCEPTION-- Twig\Error\SyntaxError: Unknown argument "invalid" for function "include(template, variables, with_context, ignore_missing, sandboxed)" in "index.twig" at line 4. ================================================ FILE: tests/Fixtures/exceptions/multiline_tag_with_undefined_variable.test ================================================ --TEST-- Exception for multiline tag with undefined variable --TEMPLATE-- {% include 'foo' with vars %} --TEMPLATE(foo)-- Foo --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "vars" does not exist in "index.twig" at line 3. ================================================ FILE: tests/Fixtures/exceptions/syntax_error_in_reused_template.test ================================================ --TEST-- Exception for syntax error in reused template --TEMPLATE-- {% use 'foo.twig' %} --TEMPLATE(foo.twig)-- {% block bar %} {% do node.data 5 %} {% endblock %} --EXCEPTION-- Twig\Error\SyntaxError: Unexpected token "number" of value "5" ("end of statement block" expected) in "foo.twig" at line 3. ================================================ FILE: tests/Fixtures/exceptions/unclosed_tag.test ================================================ --TEST-- Exception for an unclosed tag --TEMPLATE-- {% block foo %} {% if foo %} {% for i in fo %} {% endfor %} {% endblock %} --EXCEPTION-- Twig\Error\SyntaxError: Unexpected "endblock" tag (expecting closing tag for the "if" tag defined near line 4) in "index.twig" at line 16. ================================================ FILE: tests/Fixtures/exceptions/undefined_parent.test ================================================ --TEST-- Exception for an undefined parent --TEMPLATE-- {% extends 'foo.html' %} {% set foo = "foo" %} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "foo.html" is not defined in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/exceptions/undefined_template_in_child_template.test ================================================ --TEST-- Exception for an undefined template in a child template --TEMPLATE-- {% extends 'base.twig' %} {% block sidebar %} {{ include('include.twig') }} {% endblock %} --TEMPLATE(base.twig)-- {% block sidebar %} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "include.twig" is not defined in "index.twig" at line 5. ================================================ FILE: tests/Fixtures/exceptions/undefined_trait.test ================================================ --TEST-- Exception for an undefined trait --TEMPLATE-- {% use 'foo' with foobar as bar %} --TEMPLATE(foo)-- {% block bar %} {% endblock %} --EXCEPTION-- Twig\Error\RuntimeError: Block "foobar" is not defined in trait "foo" in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/expressions/_self.test ================================================ --TEST-- _self returns the template name --TEMPLATE-- {{ _self }} --DATA-- return [] --EXPECT-- index.twig ================================================ FILE: tests/Fixtures/expressions/array.test ================================================ --TEST-- Twig supports array notation --TEMPLATE-- {# empty array #} {{ []|join(',') }} {{ [1, 2]|join(',') }} {{ ['foo', "bar"]|join(',') }} {{ {0: 1, 'foo': 'bar'}|join(',') }} {{ {0: 1, 'foo': 'bar'}|keys|join(',') }} {{ {0: 1, foo: 'bar'}|join(',') }} {{ {0: 1, foo: 'bar'}|keys|join(',') }} {# nested arrays #} {% set a = [1, 2, [1, 2], {'foo': {'foo': 'bar'}}] %} {{ a[2]|join(',') }} {{ a[3]["foo"]|join(',') }} {# works even if [] is used inside the array #} {{ [foo[bar]]|join(',') }} {# elements can be any expression #} {{ ['foo'|upper, bar|upper, bar == foo]|join(',') }} {# arrays can have a trailing , like in PHP #} {{ [ 1, 2, ]|join(',') }} {# keys can be any expression #} {% set a = 1 %} {% set b = "foo" %} {% 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-- $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 1,bar 0,foo 1,bar 0,foo 1,2 bar bar FOO,BAR, 1,2 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']), 'object' => new stdClass(), 'indices_1' => [ 1 => 'first', 0 => 'second' ], 'indices_2' => [ 1 => 'first', 'foo' => 'second', 2 => 'third' ], ] --CONFIG-- return ['strict_variables' => false] --EXPECT-- 1,2 foo,bar 1,bar 0,foo 1,bar 0,foo 1,2 bar bar FOO,BAR, 1,2 1,foo,c,1foo,fooe a,b,c,d,e b bar ok ok ok OK OK OK OK OK OK ================================================ FILE: tests/Fixtures/expressions/array_call.test ================================================ --TEST-- Twig supports method calls --TEMPLATE-- {{ items.foo }} {{ items['foo'] }} {{ items[foo] }} {{ items[items[foo]] }} --DATA-- return ['foo' => 'bar', 'items' => ['foo' => 'bar', 'bar' => 'foo']] --EXPECT-- bar bar foo bar ================================================ FILE: tests/Fixtures/expressions/attributes.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/binary.test ================================================ --TEST-- Twig supports binary operations (+, -, *, /, ~, %, and, xor, or) --TEMPLATE-- {{ 1 + 1 }} {{ 2 - 1 }} {{ 2 * 2 }} {{ 2 / 2 }} {{ 3 % 2 }} {{ 1 and 1 }} {{ 1 and 0 }} {{ 0 and 1 }} {{ 0 and 0 }} {{ 1 or 1 }} {{ 1 or 0 }} {{ 0 or 1 }} {{ 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 }} {{ foo ~ bar }} {{ 20 // 7 }} --DATA-- return ['foo' => 'bar', 'bar' => 'foo'] --EXPECT-- 2 1 4 1 1 1 1 1 1 1 1 1 1 foobar barbar foofoo barfoo 2 ================================================ FILE: tests/Fixtures/expressions/bitwise.test ================================================ --TEST-- Twig supports bitwise operations --TEMPLATE-- {{ 1 b-and 5 }} {{ 1 b-or 5 }} {{ 1 b-xor 5 }} {{ (1 and 0 b-or 0) is same as(1 and (0 b-or 0)) ? 'ok' : 'ko' }} --DATA-- return [] --EXPECT-- 1 5 4 ok ================================================ FILE: tests/Fixtures/expressions/call_argument_defined_twice.test ================================================ --TEST-- Argument is defined twice in a call --TEMPLATE-- {{ date(987654, date = 123456) }} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Argument "date" is defined twice for function "date" in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/expressions/call_argument_unpacking.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/call_argument_unpacking_before_normal.test ================================================ --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. ================================================ FILE: tests/Fixtures/expressions/call_positional_arg_after_named_arg.test ================================================ --TEST-- Positional arguments after named arguments in a call --TEMPLATE-- {{ date(date = 123456, 'Y-m-d') }} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Positional arguments cannot be used after named arguments for function "date" in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/expressions/comparison.test ================================================ --TEST-- Twig supports comparison operators (==, !=, <, >, >=, <=) --TEMPLATE-- {{ 1 > 2 }}/{{ 1 > 1 }}/{{ 1 >= 2 }}/{{ 1 >= 1 }} {{ 1 < 2 }}/{{ 1 < 1 }}/{{ 1 <= 2 }}/{{ 1 <= 1 }} {{ 1 == 1 }}/{{ 1 == 2 }} {{ 1 != 1 }}/{{ 1 != 2 }} --DATA-- return [] --EXPECT-- ///1 1//1/1 1/ /1 ================================================ FILE: tests/Fixtures/expressions/comparison_precedence.test ================================================ --TEST-- Twig comparison operators precendence --TEMPLATE-- {{ not(1 > 2) }}/{{ not(1 > 1) }}/{{ not(1 >= 2) }}/{{ not(1 >= 1) }} {{ not(1 < 2) }}/{{ not(1 < 1) }}/{{ not(1 <= 2) }}/{{ not(1 <= 1) }} {{ not(1 == 1) }}/{{ not(1 == 2) }} {{ not(1 != 1) }}/{{ not(1 != 2) }} --DATA-- return [] --EXPECT-- 1/1/1/ /1// /1 1/ ================================================ FILE: tests/Fixtures/expressions/const.test ================================================ --TEST-- Twig supports accessing constants --TEMPLATE-- {{ foo.BAR_NAME }} --DATA-- return ['foo' => new Twig\Tests\TwigTestFoo()] --CONFIG-- return ['strict_variables' => false] --EXPECT-- bar ================================================ FILE: tests/Fixtures/expressions/divisibleby.test ================================================ --TEST-- Twig supports the "divisible by" operator --TEMPLATE-- {{ 8 is divisible by(2) ? 'OK' }} {{ 8 is divisible by 2 ? 'OK' }} {{ 8 is not divisible by(3) ? 'OK' }} {{ 8 is divisible by (2) ? 'OK' }} {{ 8 is divisible by 2 ? 'OK' }} {{ 8 is not divisible by (3) ? 'OK' }} --DATA-- return [] --EXPECT-- OK OK OK OK OK OK ================================================ FILE: tests/Fixtures/expressions/dot_as_concatenation.test ================================================ --TEST-- Twig does not support using . for concatenation --TEMPLATE-- {{ 'a'.'b' }} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Expected name or number, got value "b" of type "string" in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/expressions/dotdot.test ================================================ --TEST-- Twig supports the .. operator --TEMPLATE-- {% for i in 0..10 %}{{ i }} {% endfor %} {% for letter in 'a'..'z' %}{{ letter }} {% endfor %} {% for letter in 'a'|upper..'z'|upper %}{{ letter }} {% endfor %} {% for i in foo[0]..foo[1] %}{{ i }} {% endfor %} {% for i in 0 + 1 .. 10 - 1 %}{{ i }} {% endfor %} --DATA-- return ['foo' => [1, 10]] --EXPECT-- 0 1 2 3 4 5 6 7 8 9 10 a b c d e f g h i j k l m n o p q r s t u v w x y z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 ================================================ FILE: tests/Fixtures/expressions/dynamic_attribute.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/ends_with.test ================================================ --TEST-- Twig supports the "ends with" operator --TEMPLATE-- {{ 'foo' ends with 'o' ? 'OK' : 'KO' }} {{ not ('foo' ends with 'f') ? 'OK' : 'KO' }} {{ not ('foo' ends with 'foowaytoolong') ? 'OK' : 'KO' }} {{ 'foo' ends with '' ? 'OK' : 'KO' }} {{ '1' ends with true ? 'OK' : 'KO' }} {{ 1 ends with true ? 'OK' : 'KO' }} {{ 0 ends with false ? 'OK' : 'KO' }} {{ '' ends with false ? 'OK' : 'KO' }} {{ false ends with false ? 'OK' : 'KO' }} {{ false ends with '' ? 'OK' : 'KO' }} --DATA-- return [] --EXPECT-- OK OK OK OK KO KO KO KO KO KO ================================================ FILE: tests/Fixtures/expressions/exponential_numbers.test ================================================ --TEST-- Twig manages exponentiel numbers correctly --TEMPLATE-- {{ 1.99E+3 }} --DATA-- return [] --EXPECT-- 1990 ================================================ FILE: tests/Fixtures/expressions/floats.test ================================================ --TEST-- Twig compiles floats properly --TEMPLATE-- {% set val2 = 0.0 %} {{ val is same as (0.0) ? 'Yes' : 'No' }} {{ val2 is same as (0.0) ? 'Yes' : 'No' }} {{ val is same as (val2) ? 'Yes' : 'No' }} --DATA-- return ['val' => 0.0] --EXPECT-- Yes Yes Yes ================================================ FILE: tests/Fixtures/expressions/grouping.test ================================================ --TEST-- Twig supports grouping of expressions --TEMPLATE-- {{ (2 + 2) / 2 }} --DATA-- return [] --EXPECT-- 2 ================================================ FILE: tests/Fixtures/expressions/has_every.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/has_some.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/literals.test ================================================ --TEST-- Twig supports literals --TEMPLATE-- 1 {{ true }} 2 {{ TRUE }} 3 {{ false }} 4 {{ FALSE }} 5 {{ none }} 6 {{ NONE }} 7 {{ null }} 8 {{ NULL }} --DATA-- return [] --EXPECT-- 1 1 2 1 3 4 5 6 7 8 ================================================ FILE: tests/Fixtures/expressions/magic_call.test ================================================ --TEST-- Twig supports __call() for attributes --TEMPLATE-- {{ foo.foo }} {{ foo.bar }} --DATA-- class TestClassForMagicCallAttributes { public function getBar() { return 'bar_from_getbar'; } public function __call($method, $arguments) { if ('foo' === $method) { return 'foo_from_call'; } return false; } } return ['foo' => new TestClassForMagicCallAttributes()] --EXPECT-- foo_from_call bar_from_getbar ================================================ FILE: tests/Fixtures/expressions/matches.test ================================================ --TEST-- 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 ['markup' => new \Twig\Markup('test', 'UTF-8')] --EXPECT-- OK OK OK OK OK 0 OK ================================================ FILE: tests/Fixtures/expressions/matches_error_compilation.test ================================================ --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. ================================================ FILE: tests/Fixtures/expressions/matches_error_runtime.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/method_call.test ================================================ --TEST-- Twig supports method calls --TEMPLATE-- {{ items.foo.foo }} {{ items.foo.getFoo() }} {{ 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 }} {{ items.foo.in }} {{ items.foo.not }} --DATA-- return ['foo' => 'bar', 'items' => ['foo' => new Twig\Tests\TwigTestFoo(), 'bar' => 'foo']] --CONFIG-- return ['strict_variables' => false] --EXPECT-- foo foo bar bar_a-43 bar_a-43 bar_a-43 bar_a-43 bar_bar foo is in not ================================================ FILE: tests/Fixtures/expressions/negative_numbers.test ================================================ --TEST-- Twig manages negative numbers correctly --TEMPLATE-- {{ -1 }} {{ - 1 }} {{ 5 - 1 }} {{ 5-1 }} {{ 5 + -1 }} {{ 5 + - 1 }} --DATA-- return [] --EXPECT-- -1 -1 4 4 4 4 ================================================ FILE: tests/Fixtures/expressions/not.test ================================================ --TEST-- not --TEMPLATE-- {{ (not false) ? 'OK' : 'KO' }} {{ not false ? 'OK' : 'KO' }} {{ (not false)?'OK':'KO' }} {{ not false?'OK':'KO' }} {{not true ? 'KO' : 'OK'}} --DATA-- return [] --EXPECT-- OK OK OK OK OK ================================================ FILE: tests/Fixtures/expressions/not_arrow_fn.test ================================================ --TEST-- A string in parentheses cannot be confused with an arrow function --TEMPLATE-- {{ ["foo", "bar"]|join(("f")) }} --DATA-- return [] --EXPECT-- foofbar ================================================ FILE: tests/Fixtures/expressions/operators_as_variables.test ================================================ --TEST-- Twig allows to use named operators as variable names --TEMPLATE-- {% for match in matches %} {{- match }} {% endfor %} {{ in }} {{ is }} --DATA-- return ['matches' => [1, 2, 3], 'in' => 'in', 'is' => 'is'] --EXPECT-- 1 2 3 in is ================================================ FILE: tests/Fixtures/expressions/postfix.test ================================================ --TEST-- Twig parses postfix expressions --TEMPLATE-- {% import _self as macros %} {% macro foo() %}foo{% endmacro %} {{ 'a' }} {{ 'a'|upper }} {{ ('a')|upper }} {{ (-1)|abs }} {{ macros.foo() }} {{ (macros).foo() }} --DATA-- return [] --EXPECT-- a A A 1 foo foo ================================================ FILE: tests/Fixtures/expressions/power.test ================================================ --TEST-- Twig parses power expressions --TEMPLATE-- {{ 2**3 }} {{ (-2)**3 }} {{ (-2)**(-3) }} {{ a ** a }} {{ a ** b }} {{ b ** a }} {{ b ** b }} {{ -1**0 }} {{ (-1)**0 }} {{ -a**0 }} {{ (-a)**0 }} --DATA-- return ['a' => 4, 'b' => -2] --EXPECT-- 8 -8 -0.125 256 0.0625 16 0.25 -1 1 -1 1 ================================================ FILE: tests/Fixtures/expressions/sameas.test ================================================ --TEST-- Twig supports the "same as" operator --TEMPLATE-- {{ 1 is same as(1) ? 'OK' }} {{ 1 is same as 1 ? 'OK' }} {{ 1 === 1 ? 'OK' }} {{ 1 is not same as(true) ? 'OK' }} {{ 1 is not same as true ? 'OK' }} {{ 1 !== true ? 'OK' }} {{ 1 is same as (1) ? 'OK' }} {{ 1 is same as 1 ? 'OK' }} {{ 1 is not same as '1' ? 'OK' }} {{ 1 is not same as (true) ? 'OK' }} --DATA-- return [] --EXPECT-- OK OK OK OK OK OK OK OK OK OK ================================================ FILE: tests/Fixtures/expressions/set.test ================================================ --TEST-- Twig supports the "=" operator (assignment) --TEMPLATE-- {# stores #} {% do b = 1 + 3 %}{{ b }} {# stores and displays #} {{ b = 1 + 3 }}{{ b }} {# stores and returns #} {% do (c = 4) ? 0 : -1 %}{{ c }} {# = can be chained #} {% do c = d = "a" %}{{ c }}{{ d }} {% do a = (b = 4) + 5 %}{{ a }}{{ b }} # Array destructuring {% do [first, last] = ['Fabien', 'Potencier'] %}{{ first }} {{ last }} {% do [a, b] = [b, a] %}{{ a }}{{ b }} {% do [, second] = ['first', 'second'] %}{{ second }} {% do [x, y, z] = ['one', 'two'] %}{{ x }} {{ y }} {{ z is same as(null) ? 'null' : z }} # Object destructuring {% do {name, email} = user %}{{ name }} {{ email }} {% do {first_name, last_name} = user_map %}{{ first_name }} {{ last_name }} {% do {name} = user_obj %}{{ name }} # Object destructuring with renaming {% do {name: userName, email: userEmail} = user %}{{ userName }} {{ userEmail }} {% do {first_name: first, last_name: last} = user_map %}{{ first }} {{ last }} {% do {name: objName} = user_obj %}{{ objName }} {% do {name: n1} = user %}{% do {name: n2} = user_obj %}{{ n1 }} {{ n2 }} --DATA-- return [ 'user' => (object)['name' => 'Fabien', 'email' => 'fabien@example.com'], 'user_map' => ['first_name' => 'Fabien', 'last_name' => 'Potencier'], 'user_obj' => new class { public function getName() { return 'Fabien'; } }, 'null_obj' => null ] --EXPECT-- 4 44 4 aa 94 # Array destructuring Fabien Potencier 49 second one two null # Object destructuring Fabien fabien@example.com Fabien Potencier Fabien # Object destructuring with renaming Fabien fabien@example.com Fabien Potencier Fabien Fabien Fabien ================================================ FILE: tests/Fixtures/expressions/spread_array_operator.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/spread_mapping_operator.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/spread_ternary_precedence.test ================================================ --TEST-- Twig spread operator precedence with ternary operator --TEMPLATE-- {{ [...true ? ['a'] : ['b']]|join }} {{ [...false ? ['a'] : ['b']]|join }} {{ [...(true ? ['a'] : ['b'])]|join }} {{ {a: 1, ...true ? {b: 2} : {c: 3}}|keys|join(',') }} --DATA-- return [] --EXPECT-- a b a a,b ================================================ FILE: tests/Fixtures/expressions/starts_with.test ================================================ --TEST-- Twig supports the "starts with" operator --TEMPLATE-- {{ 'foo' starts with 'f' ? 'OK' : 'KO' }} {{ not ('foo' starts with 'oo') ? 'OK' : 'KO' }} {{ not ('foo' starts with 'foowaytoolong') ? 'OK' : 'KO' }} {{ 'foo' starts with 'f' ? 'OK' : 'KO' }} {{ 'foo' starts with 'f' ? 'OK' : 'KO' }} {{ 'foo' starts with '' ? 'OK' : 'KO' }} {{ '1' starts with true ? 'OK' : 'KO' }} {{ '' starts with false ? 'OK' : 'KO' }} {{ 'a' starts with false ? 'OK' : 'KO' }} {{ false starts with '' ? 'OK' : 'KO' }} --DATA-- return [] --EXPECT-- OK OK OK OK OK OK KO KO KO KO ================================================ FILE: tests/Fixtures/expressions/string_operator_as_var_assignment.test ================================================ --TEST-- Twig supports the string operators as variable names in assignments --TEMPLATE-- {% for matches in [1, 2] %} {{- matches }} {% endfor %} {% set matches = [1, 2] %} OK --DATA-- return [] --EXPECT-- 1 2 OK ================================================ FILE: tests/Fixtures/expressions/strings.test ================================================ --TEST-- Twig supports string interpolation --TEMPLATE-- {{ "foo #{"foo #{bar} baz"} baz" }} {{ "foo #{bar}#{bar} baz" }} --DATA-- return ['bar' => 'BAR'] --EXPECT-- foo foo BAR baz baz foo BARBAR baz ================================================ FILE: tests/Fixtures/expressions/ternary_operator.test ================================================ --TEST-- Twig supports the ternary operator --TEMPLATE-- {{ 1 ? 'YES' : 'NO' }} {{ 0 ? 'YES' : 'NO' }} {{ 0 ? 'YES' : (1 ? 'YES1' : 'NO1') }} {{ 0 ? 'YES' : (0 ? 'YES1' : 'NO1') }} {{ 1 == 1 ? 'foo
    ' : '' }} {{ foo ~ (bar ? ('-' ~ bar) : '') }} {{ true ? tag : 'KO' }} --DATA-- return ['foo' => 'foo', 'bar' => 'bar', 'tag' => '
    '] --EXPECT-- YES NO YES1 NO1 foo
    foo-bar <br> ================================================ FILE: tests/Fixtures/expressions/ternary_operator_noelse.test ================================================ --TEST-- Twig supports the ternary operator --TEMPLATE-- {{ 1 ? 'YES' }} {{ 0 ? 'YES' }} {{ tag ? tag }} --DATA-- return ['tag' => '
    '] --EXPECT-- YES <br> ================================================ FILE: tests/Fixtures/expressions/ternary_operator_nothen.test ================================================ --TEST-- Twig supports the ternary operator --TEMPLATE-- {{ 'YES' ?: 'NO' }} {{ 0 ?: 'NO' }} {{ 'YES' ? : 'NO' }} {{ 0 ? : 'NO' }} {{ 'YES' ? : 'NO' }} {{ 0 ? : 'NO' }} {{ tag ?: 'KO' }} --DATA-- return ['tag' => '
    '] --EXPECT-- YES NO YES NO YES NO <br> ================================================ FILE: tests/Fixtures/expressions/two_word_operators_as_variables.test ================================================ --TEST-- Twig does not allow to use two-word named operators as variable names --TEMPLATE-- {{ starts with }} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unexpected token "operator" of value "starts with" in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/expressions/unary.test ================================================ --TEST-- Twig supports unary operators (not, -, +) --TEMPLATE-- {{ not 1 }}/{{ not 0 }} {{ +1 + 1 }}/{{ -1 - 1 }} {{ not (false or true) }} --DATA-- return [] --EXPECT-- /1 2/-2 ================================================ FILE: tests/Fixtures/expressions/unary_macro_arguments.test ================================================ --TEST-- Twig manages negative numbers as default parameters --TEMPLATE-- {% import _self as macros %} {{ macros.negative_number1() }} {{ macros.negative_number2() }} {{ macros.negative_number3() }} {{ macros.positive_number1() }} {{ macros.positive_number2() }} {% macro negative_number1(nb=-1) %}{{ nb }}{% endmacro %} {% macro negative_number2(nb = --1) %}{{ nb }}{% endmacro %} {% macro negative_number3(nb = - 1) %}{{ nb }}{% endmacro %} {% macro positive_number1(nb = +1) %}{{ nb }}{% endmacro %} {% macro positive_number2(nb = ++1) %}{{ nb }}{% endmacro %} --DATA-- return [] --EXPECT-- -1 1 -1 1 1 ================================================ FILE: tests/Fixtures/expressions/unary_precedence.test ================================================ --TEST-- Twig unary operators precedence --TEMPLATE-- {{ -1 - 1 }} {{ -1 - -1 }} {{ -1 * -1 }} {{ 4 / -1 * 5 }} --DATA-- return [] --EXPECT-- -2 0 1 -20 ================================================ FILE: tests/Fixtures/expressions/underscored_numbers.test ================================================ --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 ================================================ FILE: tests/Fixtures/expressions/underscored_numbers_error.test ================================================ --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. ================================================ FILE: tests/Fixtures/extensions/anonymous_functions.test ================================================ --TEST-- use an anonymous function as a function --TEMPLATE-- {{ anon_foo('bar') }} {{ 'bar'|anon_foo }} --DATA-- return [] --EXPECT-- *bar* *bar* ================================================ FILE: tests/Fixtures/filters/abs.test ================================================ --TEST-- "abs" filter --TEMPLATE-- {{ (-5.5)|abs }} {{ (-5)|abs }} {{ (-0)|abs }} {{ 0|abs }} {{ 5|abs }} {{ 5.5|abs }} {{ number1|abs }} {{ number2|abs }} {{ number3|abs }} {{ number4|abs }} {{ number5|abs }} {{ number6|abs }} --DATA-- return ['number1' => -5.5, 'number2' => -5, 'number3' => -0, 'number4' => 0, 'number5' => 5, 'number6' => 5.5] --EXPECT-- 5.5 5 0 0 5 5.5 5.5 5 0 0 5 5.5 ================================================ FILE: tests/Fixtures/filters/arrow_reserved_names.test ================================================ --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. ================================================ FILE: tests/Fixtures/filters/batch.test ================================================ --TEST-- "batch" filter --TEMPLATE-- {% for row in items|batch(3) %}
    {% for column in row %}
    {{ column }}
    {% endfor %}
    {% endfor %} --DATA-- return ['items' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']] --EXPECT--
    a
    b
    c
    d
    e
    f
    g
    h
    i
    j
    ================================================ FILE: tests/Fixtures/filters/batch_float.test ================================================ --TEST-- "batch" filter --TEMPLATE-- {% for row in items|batch(3.1) %}
    {% for column in row %}
    {{ column }}
    {% endfor %}
    {% endfor %} --DATA-- return ['items' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']] --EXPECT--
    a
    b
    c
    d
    e
    f
    g
    h
    i
    j
    ================================================ FILE: tests/Fixtures/filters/batch_with_empty_fill.test ================================================ --TEST-- "batch" filter --TEMPLATE-- {% for row in items|batch(3, '') %} {% for column in row %} {% endfor %} {% endfor %}
    {{ column }}
    --DATA-- return ['items' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']] --EXPECT--
    a b c
    d e f
    g h i
    j
    ================================================ FILE: tests/Fixtures/filters/batch_with_exact_elements.test ================================================ --TEST-- "batch" filter --TEMPLATE-- {% for row in items|batch(3, 'fill') %}
    {% for column in row %}
    {{ column }}
    {% endfor %}
    {% endfor %} --DATA-- return ['items' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l']] --EXPECT--
    a
    b
    c
    d
    e
    f
    g
    h
    i
    j
    k
    l
    ================================================ FILE: tests/Fixtures/filters/batch_with_fill.test ================================================ --TEST-- "batch" filter --TEMPLATE-- {% for row in items|batch(3, 'fill') %} {% for column in row %} {% endfor %} {% endfor %}
    {{ column }}
    --DATA-- return ['items' => ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']] --EXPECT--
    a b c
    d e f
    g h i
    j fill fill
    ================================================ FILE: tests/Fixtures/filters/batch_with_keys.test ================================================ --TEST-- "batch" filter preserves array keys --TEMPLATE-- {{ {'foo': 'bar', 'key': 'value'}|batch(4)|first|keys|join(',') }} {{ {'foo': 'bar', 'key': 'value'}|batch(4, 'fill')|first|keys|join(',') }} --DATA-- return [] --EXPECT-- foo,key foo,key,0,1 ================================================ FILE: tests/Fixtures/filters/batch_with_more_elements.test ================================================ --TEST-- "batch" filter --TEMPLATE-- {% for row in items|batch(3, 'fill') %}
    {% for key, column in row %}
    {{ column }}
    {% endfor %}
    {% endfor %} --DATA-- return ['items' => ['a' => 'a', 'b' => 'b', 'c' => 'c', 'd' => 'd', '123' => 'e']] --EXPECT--
    a
    b
    c
    d
    e
    fill
    ================================================ FILE: tests/Fixtures/filters/batch_with_zero_elements.test ================================================ --TEST-- "batch" filter with zero elements --TEMPLATE-- {{ []|batch(3)|length }} {{ []|batch(3, 'fill')|length }} --DATA-- return [] --EXPECT-- 0 0 ================================================ FILE: tests/Fixtures/filters/capitalize.test ================================================ --TEST-- "capitalize" filter --TEMPLATE-- {{ "super helpful"|capitalize }} {{ "a"|capitalize }} *{{ ""|capitalize }}* *{{ null|capitalize }}* --DATA-- return [] --EXPECT-- Super helpful A ** ** ================================================ FILE: tests/Fixtures/filters/column.test ================================================ --TEST-- "column" filter --TEMPLATE-- {{ array|column('foo')|join }} {{ traversable|column('foo')|join }} --DATA-- $items = [['bar' => 'foo', 'foo' => 'bar'], ['foo' => 'foo', 'bar' => 'bar']]; return ['array' => $items, 'traversable' => new ArrayIterator($items)]; --EXPECT-- barfoo barfoo ================================================ FILE: tests/Fixtures/filters/convert_encoding.test ================================================ --TEST-- "convert_encoding" filter --TEMPLATE-- {{ "愛していますか?"|convert_encoding('ISO-2022-JP', 'UTF-8')|convert_encoding('UTF-8', 'ISO-2022-JP') }} *{{ ""|convert_encoding('ISO-2022-JP', 'UTF-8')|convert_encoding('UTF-8', 'ISO-2022-JP') }}* *{{ null|convert_encoding('ISO-2022-JP', 'UTF-8')|convert_encoding('UTF-8', 'ISO-2022-JP') }}* --DATA-- return [] --EXPECT-- 愛していますか? ** ** ================================================ FILE: tests/Fixtures/filters/date.test ================================================ --TEST-- "date" filter --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'); 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 13: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 Europe/Paris 04/10/2010 13:45:00 October 4, 2010 13:45 04/10/2010 04/10/2010 19:45:00 04/10/2010 07:45:00 04/10/2010 13:45:00 October 4, 2010 13:45 04/10/2010 October 4, 2010 15:45 04/10/2010 January 2, 1964 04: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 12: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 ================================================ FILE: tests/Fixtures/filters/date_default_format.test ================================================ --TEST-- "date" filter --TEMPLATE-- {{ date1|date }} {{ date1|date('d/m/Y') }} --DATA-- date_default_timezone_set('UTC'); $twig->getExtension(\Twig\Extension\CoreExtension::class)->setDateFormat('Y-m-d', '%d days %h hours'); return [ 'date1' => mktime(13, 45, 0, 10, 4, 2010), ] --EXPECT-- 2010-10-04 04/10/2010 ================================================ FILE: tests/Fixtures/filters/date_default_format_interval.test ================================================ --TEST-- "date" filter (interval support) --TEMPLATE-- {{ date2|date }} {{ date2|date('%d days') }} --DATA-- date_default_timezone_set('UTC'); $twig->getExtension(\Twig\Extension\CoreExtension::class)->setDateFormat('Y-m-d', '%d days %h hours'); return [ 'date2' => new \DateInterval('P2D'), ] --EXPECT-- 2 days 0 hours 2 days ================================================ FILE: tests/Fixtures/filters/date_immutable.test ================================================ --TEST-- "date" filter --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', timezone1) }} {{ date1|date('d/m/Y H:i:s') }} {{ date1|date_modify('+1 hour')|date('d/m/Y H:i:s') }} {{ date2|date('d/m/Y H:i:s P', 'Europe/Paris') }} {{ date2|date('d/m/Y H:i:s P', 'Asia/Hong_Kong') }} {{ date2|date('d/m/Y H:i:s P', false) }} {{ date2|date('e', 'Europe/Paris') }} {{ date2|date('e', false) }} --DATA-- date_default_timezone_set('Europe/Paris'); return [ 'date1' => new \DateTimeImmutable('2010-10-04 13:45'), 'date2' => new \DateTimeImmutable('2010-10-04 13:45', new \DateTimeZone('America/New_York')), 'timezone1' => new \DateTimeZone('America/New_York'), ] --EXPECT-- October 4, 2010 13:45 04/10/2010 04/10/2010 19:45:00 04/10/2010 07:45:00 04/10/2010 13:45:00 04/10/2010 14:45:00 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 ================================================ FILE: tests/Fixtures/filters/date_interval.test ================================================ --TEST-- "date" filter (interval support) --TEMPLATE-- {{ date1|date }} {{ date1|date('%d days %h hours') }} {{ date1|date('%d days %h hours', timezone1) }} --DATA-- date_default_timezone_set('UTC'); return [ 'date1' => new \DateInterval('P2D'), // This should have no effect on \DateInterval formatting 'timezone1' => new \DateTimeZone('America/New_York'), ] --EXPECT-- 2 days 2 days 0 hours 2 days 0 hours ================================================ FILE: tests/Fixtures/filters/date_modify.test ================================================ --TEST-- "date_modify" filter --TEMPLATE-- {{ date1|date_modify('-1day')|date('Y-m-d H:i:s') }} {{ date2|date_modify('-1day')|date('Y-m-d H:i:s') }} --DATA-- date_default_timezone_set('UTC'); return [ 'date1' => '2010-10-04 13:45', 'date2' => new \DateTime('2010-10-04 13:45'), ] --EXPECT-- 2010-10-03 13:45:00 2010-10-03 13:45:00 ================================================ FILE: tests/Fixtures/filters/date_namedargs.test ================================================ --TEST-- "date" filter --TEMPLATE-- {{ date|date(format='d/m/Y H:i:s P', timezone='America/Chicago') }} {{ date|date(timezone='America/Chicago', format='d/m/Y H:i:s P') }} {{ date|date('d/m/Y H:i:s P', timezone='America/Chicago') }} --DATA-- date_default_timezone_set('UTC'); return ['date' => mktime(13, 45, 0, 10, 4, 2010)] --EXPECT-- 04/10/2010 08:45:00 -05:00 04/10/2010 08:45:00 -05:00 04/10/2010 08:45:00 -05:00 ================================================ FILE: tests/Fixtures/filters/date_time_zone_conversion.test ================================================ --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 ================================================ FILE: tests/Fixtures/filters/default.test ================================================ --TEST-- "default" filter --TEMPLATE-- Variable: {{ definedVar |default('default') is same as('default') ? 'ko' : 'ok' }} {{ zeroVar |default('default') is same as('default') ? 'ko' : 'ok' }} {{ emptyVar |default('default') is same as('default') ? 'ok' : 'ko' }} {{ nullVar |default('default') is same as('default') ? 'ok' : 'ko' }} {{ undefinedVar |default('default') is same as('default') ? 'ok' : 'ko' }} Array access: {{ nested.definedVar |default('default') is same as('default') ? 'ko' : 'ok' }} {{ nested['definedVar'] |default('default') is same as('default') ? 'ko' : 'ok' }} {{ nested.zeroVar |default('default') is same as('default') ? 'ko' : 'ok' }} {{ nested.emptyVar |default('default') is same as('default') ? 'ok' : 'ko' }} {{ nested.nullVar |default('default') is same as('default') ? 'ok' : 'ko' }} {{ nested.undefinedVar |default('default') is same as('default') ? 'ok' : 'ko' }} {{ nested['undefinedVar'] |default('default') is same as('default') ? 'ok' : 'ko' }} {{ undefined['undefined'] |default('default') is same as('default') ? 'ok' : 'ko' }} {{ undefinedVar.foo |default('default') is same as('default') ? 'ok' : 'ko' }} Plain values: {{ 'defined' |default('default') is same as('default') ? 'ko' : 'ok' }} {{ 0 |default('default') is same as('default') ? 'ko' : 'ok' }} {{ '' |default('default') is same as('default') ? 'ok' : 'ko' }} {{ null |default('default') is same as('default') ? 'ok' : 'ko' }} Precedence: {{ 'o' ~ nullVar |default('k') }} {{ 'o' ~ nested.nullVar |default('k') }} Object methods: {{ object.foo |default('default') is same as('default') ? 'ko' : 'ok' }} {{ object.undefinedMethod |default('default') is same as('default') ? 'ok' : 'ko' }} {{ object.getFoo() |default('default') is same as('default') ? 'ko' : 'ok' }} {{ object.getFoo('a') |default('default') is same as('default') ? 'ko' : 'ok' }} {{ object.undefinedMethod() |default('default') is same as('default') ? 'ok' : 'ko' }} {{ object.undefinedMethod('a') |default('default') is same as('default') ? 'ok' : 'ko' }} Deep nested: {{ nested.undefinedVar.foo.bar |default('default') is same as('default') ? 'ok' : 'ko' }} {{ nested.definedArray.0 |default('default') is same as('default') ? 'ko' : 'ok' }} {{ nested['definedArray'][0] |default('default') is same as('default') ? 'ko' : 'ok' }} {{ nested['undefinedVar'][0] |default('default') is same as('default') ? 'ok' : 'ko' }} {{ undefined['undefined'][0] |default('default') is same as('default') ? 'ok' : 'ko' }} {{ object.self.foo |default('default') is same as('default') ? 'ko' : 'ok' }} {{ object.self.undefinedMethod |default('default') is same as('default') ? 'ok' : 'ko' }} {{ object.undefinedMethod.self |default('default') is same as('default') ? 'ok' : 'ko' }} --DATA-- return [ 'definedVar' => 'defined', 'zeroVar' => 0, 'emptyVar' => '', 'nullVar' => null, 'nested' => [ 'definedVar' => 'defined', 'zeroVar' => 0, 'emptyVar' => '', 'nullVar' => null, 'definedArray' => [0], ], 'object' => new Twig\Tests\TwigTestFoo(), ] --CONFIG-- return ['strict_variables' => false] --EXPECT-- Variable: ok ok ok ok ok Array access: ok ok ok ok ok ok ok ok ok Plain values: ok ok ok ok Precedence: ok ok Object methods: ok ok ok ok ok ok Deep nested: ok ok ok ok ok ok ok ok --DATA-- return [ 'definedVar' => 'defined', 'zeroVar' => 0, 'emptyVar' => '', 'nullVar' => null, 'nested' => [ 'definedVar' => 'defined', 'zeroVar' => 0, 'emptyVar' => '', 'nullVar' => null, 'definedArray' => [0], ], 'object' => new Twig\Tests\TwigTestFoo(), ] --CONFIG-- return ['strict_variables' => true] --EXPECT-- Variable: ok ok ok ok ok Array access: ok ok ok ok ok ok ok ok ok Plain values: ok ok ok ok Precedence: ok ok Object methods: ok ok ok ok ok ok Deep nested: ok ok ok ok ok ok ok ok ================================================ FILE: tests/Fixtures/filters/dynamic_filter.test ================================================ --TEST-- dynamic filter --TEMPLATE-- {{ 'bar'|foo_path }} {{ 'bar'|bar_path }} {{ 'bar'|a_foo_b_bar }} --DATA-- return [] --EXPECT-- foo/bar bar/bar a/b/bar ================================================ FILE: tests/Fixtures/filters/escape.test ================================================ --TEST-- "escape" filter --TEMPLATE-- {{ "foo
    "|e }} *{{ ""|e }}* *{{ null|e }}* --DATA-- return [] --EXPECT-- foo <br /> ** ** ================================================ FILE: tests/Fixtures/filters/escape_html_attr.test ================================================ --TEST-- "escape" filter does not escape with the html strategy when using the html_attr strategy --TEMPLATE-- {{ '
    '|escape('html_attr') }} --DATA-- return [] --EXPECT-- <br /> ================================================ FILE: tests/Fixtures/filters/escape_html_attr_relaxed.test ================================================ --TEST-- "escape" filter does not additionally apply the html strategy when the html_attr_relaxed strategy has been applied "escape" filter does not additionally apply the html_attr_relaxed strategy when the html_attr strategy has been applied --TEMPLATE-- {% autoescape 'html' %} {{ 'v:bind@click="foo"'|escape('html_attr_relaxed') }} {% endautoescape %} {% autoescape 'html_attr_relaxed' %} {{ 'v:bind@click="foo"' | escape('html_attr') }} {% endautoescape %} --DATA-- return [] --EXPECT-- v:bind@click="foo" v:bind@click="foo" ================================================ FILE: tests/Fixtures/filters/escape_javascript.test ================================================ --TEST-- "escape" filter --TEMPLATE-- {{ "é ♜ 𝌆"|e('js') }} --DATA-- return [] --EXPECT-- \u00E9\u0020\u265C\u0020\uD834\uDF06 ================================================ FILE: tests/Fixtures/filters/escape_non_supported_charset.test ================================================ --TEST-- "escape" filter --TEMPLATE-- {{ "愛していますか?
    "|e }} --DATA-- return [] --EXPECT-- 愛していますか? <br /> ================================================ FILE: tests/Fixtures/filters/filter.test ================================================ --TEST-- "filter" filter --TEMPLATE-- {% set offset = 3 %} {% for k, v in [1, 5, 3, 4, 5]|filter((v) => v > offset) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in {a: 1, b: 2, c: 5, d: 8}|filter(v => v > offset) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in {a: 1, b: 2, c: 5, d: 8}|filter((v, k) => (v > offset) and (k != "d")) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in [1, 5, 3, 4, 5]|filter(v => v > offset) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in it|filter((v) => v > offset) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in ita|filter(v => v > offset) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in xml|filter(x => true) %} {{ k }}/{{ v }} {% endfor %} {# we can iterate more than once #} {% for k, v in xml|filter(x => true) %} {{ k }}/{{ v }} {% endfor %} {% set coll = ['a', 'b']|filter(v => v is same as('a')) %} {% if coll|length > 0 %} {{- coll|join(', ') }} {% endif %} --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-- 1 = 5 3 = 4 4 = 5 c = 5 d = 8 c = 5 1 = 5 3 = 4 4 = 5 c = 5 d = 8 c = 5 d = 8 elem/foo elem/bar elem/baz elem/foo elem/bar elem/baz a ================================================ FILE: tests/Fixtures/filters/find.test ================================================ --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 ================================================ FILE: tests/Fixtures/filters/first.test ================================================ --TEST-- "first" filter --TEMPLATE-- {{ [1, 2, 3, 4]|first }} {{ {a: 1, b: 2, c: 3, d: 4}|first }} {{ '1234'|first }} {{ arr|first }} {{ 'Ä€é'|first }} *{{ ''|first }}* *{{ null|first }}* --DATA-- return ['arr' => new \ArrayObject([1, 2, 3, 4])] --EXPECT-- 1 1 1 1 Ä ** ** ================================================ FILE: tests/Fixtures/filters/force_escape.test ================================================ --TEST-- "escape" filter --TEMPLATE-- {% set foo %} foo
    {% endset %} {{ foo|e('html') -}} {{ foo|e('js') }} {% autoescape true %} {{ foo }} {% endautoescape %} --DATA-- return [] --EXPECT-- foo<br /> \u0020\u0020\u0020\u0020foo\u003Cbr\u0020\/\u003E\n foo
    ================================================ FILE: tests/Fixtures/filters/format.test ================================================ --TEST-- "format" filter --TEMPLATE-- {{ string|format(foo, 3) }} *{{ ""|format(foo, 3) }}* *{{ null|format(foo, 3) }}* --DATA-- return ['string' => '%s/%d', 'foo' => 'bar'] --EXPECT-- bar/3 ** ** ================================================ FILE: tests/Fixtures/filters/invoke.test ================================================ --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 ================================================ FILE: tests/Fixtures/filters/join.test ================================================ --TEST-- "join" filter --TEMPLATE-- {{ ["foo", "bar"]|join(', ') }} {{ foo|join(', ') }} {{ bar|join(', ') }} {{ ["foo", "bar"]|join(', ', ' and ') }} {{ foo|join(', ', ' and ') }} {{ bar|join(', ', ' and ') }} {{ ["one", "two", "three"]|join(', ', ' and ') }} {{ ["a", "b", "c"]|join('','-') }} {{ ["a", "b", "c"]|join('-','-') }} {{ ["a", "b", "c"]|join('-','') }} {{ ["hello"]|join('|','-') }} {{ {"a": "w", "b": "x", "c": "y", "d": "z"}|join }} {{ {"a": "w", "b": "x", "c": "y", "d": "z"}|join(',') }} {{ {"a": "w", "b": "x", "c": "y", "d": "z"}|join(',','-') }} --DATA-- return ['foo' => new Twig\Tests\TwigTestFoo(), 'bar' => new \ArrayObject([3, 4])] --EXPECT-- foo, bar 1, 2 3, 4 foo and bar 1 and 2 3 and 4 one, two and three ab-c a-b-c a-bc hello wxyz w,x,y,z w,x,y-z ================================================ FILE: tests/Fixtures/filters/json_encode.test ================================================ --TEST-- "json_encode" filter --TEMPLATE-- {{ "foo"|json_encode|raw }} {{ foo|json_encode|raw }} {{ [foo, "foo"]|json_encode|raw }} --DATA-- return ['foo' => new \Twig\Markup('foo', 'UTF-8')] --EXPECT-- "foo" "foo" ["foo","foo"] ================================================ FILE: tests/Fixtures/filters/last.test ================================================ --TEST-- "last" filter --TEMPLATE-- {{ [1, 2, 3, 4]|last }} {{ {a: 1, b: 2, c: 3, d: 4}|last }} {{ '1234'|last }} {{ arr|last }} {{ 'Ä€é'|last }} *{{ ''|last }}* *{{ null|last }}* --DATA-- return ['arr' => new \ArrayObject([1, 2, 3, 4])] --EXPECT-- 4 4 4 4 é ** ** ================================================ FILE: tests/Fixtures/filters/length.test ================================================ --TEST-- "length" filter --TEMPLATE-- {{ array|length }} {{ string|length }} {{ number|length }} {{ to_string_able|length }} {{ countable|length }} {{ iterator_aggregate|length }} {{ ""|length }} {{ null|length }} {{ magic|length }} {{ non_countable|length }} {{ simple_xml_element|length }} {{ iterator|length }} --DATA-- return [ 'array' => [1, 4], 'string' => 'foo', 'number' => 1000, 'to_string_able' => new Twig\Tests\ToStringStub('foobar'), 'countable' => new Twig\Tests\CountableStub(42), /* also asserts we do *not* call __toString() */ 'iterator_aggregate' => new Twig\Tests\IteratorAggregateStub(['a', 'b', 'c']), /* also asserts we do *not* call __toString() */ 'null' => null, 'magic' => new Twig\Tests\MagicCallStub(), /* used to assert we do *not* call __call */ 'non_countable' => new \StdClass(), 'simple_xml_element' => new \SimpleXMLElement(''), 'iterator' => new Twig\Tests\SimpleIteratorForTesting() ] --EXPECT-- 2 3 4 6 42 3 0 0 1 1 2 7 ================================================ FILE: tests/Fixtures/filters/length_utf8.test ================================================ --TEST-- "length" filter --TEMPLATE-- {{ string|length }} {{ markup|length }} --DATA-- return ['string' => 'été', 'markup' => new \Twig\Markup('foo', 'UTF-8')] --EXPECT-- 3 3 ================================================ FILE: tests/Fixtures/filters/lower.test ================================================ --TEST-- "lower" filter --TEMPLATE-- {{ "I like Twig."|lower }} *{{ ""|lower }}* *{{ null|lower }}* --DATA-- return [] --EXPECT-- i like twig. ** ** ================================================ FILE: tests/Fixtures/filters/map.test ================================================ --TEST-- "map" filter --TEMPLATE-- {% set offset = 3 %} {% for k, v in [1, 2]|map((item) => item + 2 ) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in {a: 1, b: 2}|map((item) => item ~ "*" ) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in {a: 1, b: 2}|map((item, k) => item ~ "*" ~ k ) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in [1, 2]|map(item => item + 2 ) -%} {{ k }} = {{ v }} {% endfor %} {% for k, v in it|map(item => item + 2 ) -%} {{ k }} = {{ v }} {% endfor %} {% macro local_lower(string) %} {{- string|lower }} {% endmacro %} {{ ['A']|map(val => _self.local_lower(val))|join }} {%- from _self import local_lower as renamed_lower %} {{ ['A']|map(val => renamed_lower(val))|join }} --DATA-- return ['it' => new \ArrayIterator([1, 2])] --EXPECT-- 0 = 3 1 = 4 a = 1* b = 2* a = 1*a b = 2*b 0 = 3 1 = 4 0 = 3 1 = 4 a a ================================================ FILE: tests/Fixtures/filters/merge.test ================================================ --TEST-- "merge" filter --TEMPLATE-- {{ items|merge({'bar': 'foo'})|join }} {{ items|merge({'bar': 'foo'})|keys|join }} {{ {'bar': 'foo'}|merge(items)|join }} {{ {'bar': 'foo'}|merge(items)|keys|join }} {{ numerics|merge([4, 5, 6])|join }} {{ traversable.a|merge(traversable.b)|join }} --DATA-- return [ 'items' => ['foo' => 'bar'], 'numerics' => [1, 2, 3], 'traversable' => [ 'a' => new \ArrayObject([0 => 1, 1 => 2, 2 => 3]), 'b' => new \ArrayObject(['a' => 'b']) ] ] --EXPECT-- barfoo foobar foobar barfoo 123456 123b ================================================ FILE: tests/Fixtures/filters/nl2br.test ================================================ --TEST-- "nl2br" filter --TEMPLATE-- {{ "I like Twig.\nYou will like it too.\n\nEverybody like it!"|nl2br }} {{ text|nl2br }} *{{ ''|nl2br }}* *{{ null|nl2br }}* --DATA-- return ['text' => "If you have some HTML\nit will be escaped."] --EXPECT-- I like Twig.
    You will like it too.

    Everybody like it! If you have some <strong>HTML</strong>
    it will be escaped. ** ** ================================================ FILE: tests/Fixtures/filters/number_format.test ================================================ --TEST-- "number_format" filter --TEMPLATE-- {{ 20|number_format }} {{ 20.25|number_format }} {{ 20.25|number_format(2) }} {{ 20.25|number_format(2, ',') }} {{ 1020.25|number_format(2, ',') }} {{ 1020.25|number_format(2, ',', '.') }} {{ '1020.25'|number_format(2, ',', '.') }} {{ ''|number_format(2, ',', '.') }} {{ null|number_format(2, ',', '.') }} --DATA-- return [] --EXPECT-- 20 20 20.25 20,25 1,020,25 1.020,25 1.020,25 0,00 0,00 ================================================ FILE: tests/Fixtures/filters/number_format_default.test ================================================ --TEST-- "number_format" filter with defaults. --TEMPLATE-- {{ 20|number_format }} {{ 20.25|number_format }} {{ 20.25|number_format(1) }} {{ 20.25|number_format(2, ',') }} {{ 1020.25|number_format }} {{ 1020.25|number_format(2, ',') }} {{ 1020.25|number_format(2, ',', '.') }} --DATA-- $twig->getExtension(\Twig\Extension\CoreExtension::class)->setNumberFormat(2, '!', '='); return [] --EXPECT-- 20!00 20!25 20!3 20,25 1=020!25 1=020,25 1.020,25 ================================================ FILE: tests/Fixtures/filters/raw.test ================================================ --TEST-- "raw" filter excludes a variable from being escaped --TEMPLATE-- {{ br|raw }} --DATA-- return ['br' => '
    '] --EXPECT--
    ================================================ FILE: tests/Fixtures/filters/reduce.test ================================================ --TEST-- "reduce" filter --TEMPLATE-- {% set offset = 3 %} {{ [1, -1, 4]|reduce((carry, item) => carry + item + offset, 10) }} {{ it|reduce((carry, item) => carry + item + offset, 10) }} --DATA-- return ['it' => new \ArrayIterator([1, -1, 4])] --EXPECT-- 23 23 ================================================ FILE: tests/Fixtures/filters/reduce_key.test ================================================ --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 ================================================ FILE: tests/Fixtures/filters/replace.test ================================================ --TEST-- "replace" filter --TEMPLATE-- {{ "I liké %this% and %that%."|replace({'%this%': "foo", '%that%': "bar"}) }} {{ 'I like single replace operation only %that%'|replace({'%that%' : '%that%1'}) }} {{ 'I like %this% and %that%.'|replace(traversable) }} *{{ ''|replace(traversable) }}* *{{ null|replace(traversable) }}* --DATA-- return ['traversable' => new \ArrayObject(['%this%' => 'foo', '%that%' => 'bar'])] --EXPECT-- I liké foo and bar. I like single replace operation only %that%1 I like foo and bar. ** ** ================================================ FILE: tests/Fixtures/filters/replace_invalid_arg.test ================================================ --TEST-- Exception for invalid argument type in replace call --TEMPLATE-- {{ 'test %foo%'|replace(stdClass) }} --DATA-- return ['stdClass' => new \stdClass()] --EXCEPTION-- Twig\Error\RuntimeError: The "replace" filter expects a sequence or a mapping, got "stdClass" in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/filters/reverse.test ================================================ --TEST-- "reverse" filter --TEMPLATE-- {{ [1, 2, 3, 4]|reverse|join('') }} {{ '1234évènement'|reverse }} {{ arr|reverse|join('') }} {{ {'a': 'c', 'b': 'a'}|reverse()|join(',') }} {{ {'a': 'c', 'b': 'a'}|reverse(preserveKeys=true)|join(glue=',') }} {{ {'a': 'c', 'b': 'a'}|reverse(preserve_keys=true)|join(glue=',') }} *{{ ''|reverse }}* *{{ null|reverse }}* --DATA-- return ['arr' => new \ArrayObject([1, 2, 3, 4])] --EXPECT-- 4321 tnemenèvé4321 4321 a,c a,c a,c ** ** ================================================ FILE: tests/Fixtures/filters/round.test ================================================ --TEST-- "round" filter --TEMPLATE-- {{ 2.7|round }} {{ 2.1|round }} {{ 2.1234|round(3, 'floor') }} {{ 2.1|round(0, 'ceil') }} {{ 21.3|round(-1)}} {{ 21.3|round(-1, 'ceil')}} {{ 21.3|round(-1, 'floor')}} {{ '21.3'|round(-1, 'floor')}} {{ ''|round(-1, 'floor')}} {{ null|round(-1, 'floor')}} {{ null|round }} {{ null|round(2, 'ceil') }} --DATA-- return [] --EXPECT-- 3 2 2.123 3 20 30 20 20 0 0 0 0 ================================================ FILE: tests/Fixtures/filters/shuffle.test ================================================ --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 ================================================ FILE: tests/Fixtures/filters/slice.test ================================================ --TEST-- "slice" filter --TEMPLATE-- {{ [1, 2, 3, 4][1:2]|join('') }} {{ {a: 1, b: 2, c: 3, d: 4}[1:2]|join('') }} {{ [1, 2, 3, 4][start:length]|join('') }} {{ [1, 2, 3, 4]|slice(1, 2)|join('') }} {{ [1, 2, 3, 4]|slice(1, 2)|keys|join('') }} {{ [1, 2, 3, 4]|slice(1, 2, true)|keys|join('') }} {{ {a: 1, b: 2, c: 3, d: 4}|slice(1, 2)|join('') }} {{ {a: 1, b: 2, c: 3, d: 4}|slice(1, 2)|keys|join('') }} {{ '1234'|slice(1, 2) }} {{ '1234'[1:2] }} {{ arr|slice(1, 2)|join('') }} {{ arr[1:2]|join('') }} {{ arr[4:1]|join('') }} {{ arr[3:2]|join('') }} {{ [1, 2, 3, 4]|slice(1)|join('') }} {{ [1, 2, 3, 4][1:]|join('') }} {{ '1234'|slice(1) }} {{ '1234'[1:] }} {{ '1234'[:1] }} {{ arr|slice(3)|join('') }} {{ arr[2:]|join('') }} {{ xml|slice(1)|join('')}} --DATA-- return ['start' => 1, 'length' => 2, 'arr' => new \ArrayObject([1, 2, 3, 4]), 'xml' => new \SimpleXMLElement('12')] --EXPECT-- 23 23 23 23 01 12 23 bc 23 23 23 23 4 234 234 234 234 1 4 34 2 ================================================ FILE: tests/Fixtures/filters/sort.test ================================================ --TEST-- "sort" filter --TEMPLATE-- {{ array1|sort|join }} {{ array2|sort|join }} {{ traversable|sort|join }} --DATA-- return ['array1' => [4, 1], 'array2' => ['foo', 'bar'], 'traversable' => new \ArrayObject([0 => 3, 1 => 2, 2 => 1])] --EXPECT-- 14 barfoo 123 ================================================ FILE: tests/Fixtures/filters/sort_with_arrow.test ================================================ --TEST-- "sort" filter --TEMPLATE-- {{ fruits|sort((a, b) => a.quantity == b.quantity ? 0 : (a.quantity > b.quantity ? 1 : -1))|column('name')|join(', ') }} {{ fruits|sort((a, b) => a.quantity <=> b.quantity)|column('name')|join(', ') }} --DATA-- return [ 'fruits' => [ [ 'name' => 'Apples', 'quantity' => 5 ], [ 'name' => 'Oranges', 'quantity' => 2 ], [ 'name' => 'Grapes', 'quantity' => 4 ], ], ] --EXPECT-- Oranges, Grapes, Apples Oranges, Grapes, Apples ================================================ FILE: tests/Fixtures/filters/spaceless.legacy.test ================================================ --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
    ** ** ================================================ FILE: tests/Fixtures/filters/special_chars.test ================================================ --TEST-- "§" custom filter --TEMPLATE-- {{ 'foo'|§ }} --DATA-- return [] --EXPECT-- §foo§ ================================================ FILE: tests/Fixtures/filters/split.test ================================================ --TEST-- "split" filter --TEMPLATE-- {{ "one,two,three,four,five"|split(',')|join('-') }} {{ foo|split(',')|join('-') }} {{ foo|split(',', 3)|join('-') }} {{ baz|split('')|join('-') }} {{ baz|split('', 1)|join('-') }} {{ baz|split('', 2)|join('-') }} {{ foo|split(',', -2)|join('-') }} {{ "hello0world"|split('0')|join('-') }} *{{ ""|split(',')|join('-') }}* *{{ null|split(',')|join('-') }}* --DATA-- return ['foo' => "one,two,three,four,five", 'baz' => '12345',] --EXPECT-- one-two-three-four-five one-two-three-four-five one-two-three,four,five 1-2-3-4-5 1-2-3-4-5 12-34-5 one-two-three hello-world ** ** ================================================ FILE: tests/Fixtures/filters/split_utf8.test ================================================ --TEST-- "split" filter --TEMPLATE-- {{ "é"|split('', 10)|join('-') }} {{ foo|split(',')|join('-') }} {{ foo|split(',', 1)|join('-') }} {{ foo|split(',', 2)|join('-') }} {{ foo|split(',', 3)|join('-') }} {{ baz|split('')|join('-') }} {{ baz|split('', 1)|join('-') }} {{ baz|split('', 2)|join('-') }} --DATA-- return ['foo' => 'Ä,é,Äほ', 'baz' => 'éÄßごa',] --EXPECT-- é Ä-é-Äほ Ä,é,Äほ Ä-é,Äほ Ä-é-Äほ é-Ä-ß-ご-a é-Ä-ß-ご-a éÄ-ßご-a ================================================ FILE: tests/Fixtures/filters/static_calls.test ================================================ --TEST-- Filters as static method calls --TEMPLATE-- {{ 'foo'|static_call_string }} {{ 'foo'|static_call_array }} --DATA-- return ['foo' => 'foo'] --EXPECT-- *foo* *foo* ================================================ FILE: tests/Fixtures/filters/striptags.test ================================================ --TEST-- "striptags" filter --TEMPLATE-- {{ "Hello, World!"|striptags }} {{ text|striptags }} {{ text|striptags('')|raw }} *{{ ''|striptags }}* *{{ null|striptags }}* --DATA-- return ['text' => "

    Hello, World!

    "] --EXPECT-- Hello, World! Hello, World! Hello, World! ** ** ================================================ FILE: tests/Fixtures/filters/title.test ================================================ --TEST-- "title" filter --TEMPLATE-- {{ "I like Twig."|title }} *{{ ""|title }}* *{{ null|title }}* --DATA-- return [] --EXPECT-- I Like Twig. ** ** ================================================ FILE: tests/Fixtures/filters/trailing_commas.test ================================================ --TEST-- filters allow trailing commas in their argument list --TEMPLATE-- {{ 42.55|round(1, 'floor',) }} --DATA-- return [] --EXPECT-- 42.5 ================================================ FILE: tests/Fixtures/filters/trim.test ================================================ --TEST-- "trim" filter --TEMPLATE-- {{ " 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("x", "right") }} {{ "/ foo/"|trim("/", "left") }} {{ "/ foo/"|trim(character_mask: "/", side: "left") }} {{ " do nothing. "|trim("", "right") }} *{{ ""|trim }}* *{{ ""|trim("", "left") }}* *{{ ""|trim("", "right") }}* *{{ 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-- I like Twig. If you have some <strong>HTML</strong> it will be escaped. foo I like Twig.xxx xxxI like Twig. xxxI like Twig. foo/ foo/ do nothing. ** ** ** ** ** ** Here is
    my HTML I <3 u Here is<br>my HTML ================================================ FILE: tests/Fixtures/filters/upper.test ================================================ --TEST-- "upper" filter --TEMPLATE-- {{ "I like Twig."|upper }} *{{ ""|upper }}* *{{ null|upper }}* --DATA-- return [] --EXPECT-- I LIKE TWIG. ** ** ================================================ FILE: tests/Fixtures/filters/urlencode.test ================================================ --TEST-- "url_encode" filter --TEMPLATE-- {{ {foo: "bar", number: 3, "spéßi%l": "e%c0d@d", "spa ce": ""}|url_encode }} {{ {foo: "bar", number: 3, "spéßi%l": "e%c0d@d", "spa ce": ""}|url_encode|raw }} {{ {}|url_encode|default("default") }} {{ 'spéßi%le%c0d@dspa ce'|url_encode }} *{{ ''|url_encode }}* *{{ null|url_encode }}* --DATA-- return [] --EXPECT-- foo=bar&number=3&sp%C3%A9%C3%9Fi%25l=e%25c0d%40d&spa%20ce= foo=bar&number=3&sp%C3%A9%C3%9Fi%25l=e%25c0d%40d&spa%20ce= default sp%C3%A9%C3%9Fi%25le%25c0d%40dspa%20ce ** ** ================================================ FILE: tests/Fixtures/functions/attribute.legacy.test ================================================ --TEST-- "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 ================================================ FILE: tests/Fixtures/functions/attribute_with_wrong_args.legacy.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/block.test ================================================ --TEST-- "block" function --TEMPLATE-- {% extends 'base.twig' %} {% 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-- BARBARBAR ================================================ FILE: tests/Fixtures/functions/block_with_template.test ================================================ --TEST-- "block" function with a template argument --TEMPLATE-- {{ block('foo', 'included.twig') }} {{ block('foo', included_loaded) }} {{ 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 %} --DATA-- return [ 'included_loaded' => $twig->load('included.twig'), 'included_loaded_internal' => $twig->load('included.twig'), ] --EXPECT-- FOO FOO FOO FOO FOO FOO NOT FOO ================================================ FILE: tests/Fixtures/functions/block_without_name.test ================================================ --TEST-- "block" function without arguments --TEMPLATE-- {% extends 'base.twig' %} {% block bar %}BAR{% endblock %} --TEMPLATE(base.twig)-- {% block foo %}{{ block() }}{% endblock %} {% block bar %}BAR_BASE{% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Value for argument "name" is required for function "block" in "base.twig" at line 2. ================================================ FILE: tests/Fixtures/functions/block_without_parent.test ================================================ --TEST-- "block" calling parent() with no definition in parent template --TEMPLATE-- {% extends "parent.twig" %} {% block label %}{{ parent() }}{% endblock %} --TEMPLATE(parent.twig)-- {{ block('label') }} --DATA-- 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. ================================================ FILE: tests/Fixtures/functions/constant.test ================================================ --TEST-- "constant" function --TEMPLATE-- {{ 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 ================================================ FILE: tests/Fixtures/functions/cycle.test ================================================ --TEST-- "cycle" function --TEMPLATE-- {% for i in 0..6 %} {{ cycle(array1, i) }}-{{ cycle(array2, i) }}-{{ cycle(array3, i) }} {% endfor %} --DATA-- return [ 'array1' => ['odd', 'even'], 'array2' => ['apple', 'orange', 'citrus'], 'array3' => [1, 2, false, null], ]; --EXPECT-- odd-apple-1 even-orange-2 odd-citrus- even-apple- odd-orange-1 even-citrus-2 odd-apple- ================================================ FILE: tests/Fixtures/functions/cycle_empty_mapping.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/cycle_empty_sequence.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/cycle_without_enough_args.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/date.test ================================================ --TEST-- "date" function --TEMPLATE-- {{ date().format('r') == date('now').format('r') ? 'OK' : 'KO' }} {{ date(date1) == date('2010-10-04 13:45') ? 'OK' : 'KO' }} {{ date(date2) == date('2010-10-04 13:45') ? 'OK' : 'KO' }} {{ date(date3) == date('2010-10-04 13:45') ? 'OK' : 'KO' }} {{ date(date4) == date('2010-10-04 13:45') ? 'OK' : 'KO' }} {{ date(date5) == date('1964-01-02 03:04') ? 'OK' : 'KO' }} {{ date() > date('-1day') ? 'OK' : 'KO' }} --DATA-- date_default_timezone_set('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(), ] --EXPECT-- OK OK OK OK OK OK OK ================================================ FILE: tests/Fixtures/functions/date_namedargs.test ================================================ --TEST-- "date" function --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 ================================================ FILE: tests/Fixtures/functions/deprecated.test ================================================ --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 ================================================ FILE: tests/Fixtures/functions/dump.test ================================================ --TEST-- "dump" function --CONDITION-- !extension_loaded('xdebug') --TEMPLATE-- {{ dump('foo') }} {{ dump('foo', 'bar') }} --DATA-- return ['foo' => 'foo', 'bar' => 'bar'] --CONFIG-- return ['debug' => true, 'autoescape' => false] --EXPECT-- string(3) "foo" string(3) "foo" string(3) "bar" ================================================ FILE: tests/Fixtures/functions/dump_array.test ================================================ --TEST-- "dump" function, xdebug is not loaded or xdebug <2.2-dev is loaded --CONDITION-- !extension_loaded('xdebug') || (($r = new \ReflectionExtension('xdebug')) && version_compare($r->getVersion(), '2.2-dev', '<')) --TEMPLATE-- {{ dump() }} --DATA-- return ['foo' => 'foo', 'bar' => 'bar'] --CONFIG-- return ['debug' => true, 'autoescape' => false] --EXPECT-- array(3) { ["foo"]=> string(3) "foo" ["bar"]=> string(3) "bar" ["global"]=> string(6) "global" } ================================================ FILE: tests/Fixtures/functions/dynamic_function.test ================================================ --TEST-- dynamic function --TEMPLATE-- {{ foo_path('bar') }} {{ bar_path('bar') }} {{ a_foo_b_bar('bar') }} --DATA-- return [] --EXPECT-- foo/bar bar/bar a/b/bar ================================================ FILE: tests/Fixtures/functions/enum/invalid_dynamic_enum.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum/invalid_enum.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum/invalid_enum_escaping.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum/invalid_literal_type.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum/valid.test ================================================ --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 ================================================ FILE: tests/Fixtures/functions/enum_cases/invalid_dynamic_enum.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum_cases/invalid_enum.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum_cases/invalid_enum_escaping.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum_cases/invalid_literal_type.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/enum_cases/valid.test ================================================ --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 ================================================ FILE: tests/Fixtures/functions/include/assignment.test ================================================ --TEST-- "include" function --TEMPLATE-- {% set tmp = include("foo.twig") %} FOO{{ tmp }}BAR --TEMPLATE(foo.twig)-- FOOBAR --DATA-- return [] --EXPECT-- FOO FOOBARBAR ================================================ FILE: tests/Fixtures/functions/include/autoescaping.test ================================================ --TEST-- "include" function is safe for auto-escaping --TEMPLATE-- {{ include("foo.twig") }} --TEMPLATE(foo.twig)--

    Test

    --DATA-- return [] --EXPECT--

    Test

    ================================================ FILE: tests/Fixtures/functions/include/basic.test ================================================ --TEST-- "include" function --TEMPLATE-- FOO {{ include("foo.twig") }} BAR --TEMPLATE(foo.twig)-- FOOBAR --DATA-- return [] --EXPECT-- FOO FOOBAR BAR ================================================ FILE: tests/Fixtures/functions/include/expression.test ================================================ --TEST-- "include" function allows expressions for the template to include --TEMPLATE-- FOO {{ include(foo) }} BAR --TEMPLATE(foo.twig)-- FOOBAR --DATA-- return ['foo' => 'foo.twig'] --EXPECT-- FOO FOOBAR BAR ================================================ FILE: tests/Fixtures/functions/include/ignore_missing.test ================================================ --TEST-- "include" function --TEMPLATE-- {{ include(["foo.twig", "bar.twig"], ignore_missing = true) }} {{ include("foo.twig", ignore_missing = true) }} {{ include("foo.twig", ignore_missing = true, variables = {}) }} {{ include("foo.twig", ignore_missing = true, variables = {}, with_context = true) }} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/functions/include/ignore_missing_exists.test ================================================ --TEST-- "include" function --TEMPLATE-- {{ include("included.twig", ignore_missing = true) }} NOT DISPLAYED --TEMPLATE(included.twig)-- {{ include("DOES NOT EXIST") }} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "DOES NOT EXIST" is not defined in "included.twig" at line 2. ================================================ FILE: tests/Fixtures/functions/include/include_missing_extends.test ================================================ --TEST-- "include" function --TEMPLATE-- {{ include(['bad.twig', 'good.twig'], ignore_missing = true) }} NOT DISPLAYED --TEMPLATE(bad.twig)-- {% extends 'DOES NOT EXIST' %} --TEMPLATE(good.twig)-- NOT DISPLAYED --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "DOES NOT EXIST" is not defined in "bad.twig" at line 2. ================================================ FILE: tests/Fixtures/functions/include/missing.test ================================================ --TEST-- "include" function --TEMPLATE-- {{ include("foo.twig") }} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "foo.twig" is not defined in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/functions/include/missing_nested.test ================================================ --TEST-- "include" function --TEMPLATE-- {% extends "base.twig" %} {% block content %} {{ parent() }} {% endblock %} --TEMPLATE(base.twig)-- {% block content %} {{ include("foo.twig") }} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "foo.twig" is not defined in "base.twig" at line 3. ================================================ FILE: tests/Fixtures/functions/include/sandbox.test ================================================ --TEST-- "include" tag sandboxed --TEMPLATE-- {{ include("foo.twig", sandboxed = true) }} --TEMPLATE(foo.twig)-- {{ 'foo'|e }} {{ 'foo'|e }} --DATA-- return [] --EXCEPTION-- Twig\Sandbox\SecurityNotAllowedFilterError: Filter "e" is not allowed in "foo.twig" at line 4. ================================================ FILE: tests/Fixtures/functions/include/sandbox_disabling.test ================================================ --TEST-- "include" tag sandboxed --TEMPLATE-- {{ include("foo.twig", sandboxed = true) }} {{ include("bar.twig") }} --TEMPLATE(foo.twig)-- foo --TEMPLATE(bar.twig)-- {{ foo|e }} --DATA-- return ['foo' => 'bar
    '] --EXPECT-- foo bar<br /> ================================================ FILE: tests/Fixtures/functions/include/sandbox_disabling_ignore_missing.test ================================================ --TEST-- "include" tag sandboxed --TEMPLATE-- {{ include("unknown.twig", sandboxed = true, ignore_missing = true) }} {{ include("bar.twig") }} --TEMPLATE(bar.twig)-- {{ foo|e }} --DATA-- return ['foo' => 'bar
    '] --EXPECT-- bar<br /> ================================================ FILE: tests/Fixtures/functions/include/template_instance.test ================================================ --TEST-- "include" function accepts Twig\Template instance --TEMPLATE-- {{ include(foo) }} FOO --TEMPLATE(foo.twig)-- BAR --DATA-- return ['foo' => $twig->load('foo.twig')] --EXPECT-- BAR FOO ================================================ FILE: tests/Fixtures/functions/include/templates_as_array.test ================================================ --TEST-- "include" function --TEMPLATE-- {{ include(["foo.twig", "bar.twig"]) }} {{- include(["bar.twig", "foo.twig"]) }} --TEMPLATE(foo.twig)-- foo --DATA-- return [] --EXPECT-- foo foo ================================================ FILE: tests/Fixtures/functions/include/with_context.test ================================================ --TEST-- "include" function accept variables and with_context --TEMPLATE-- {{ include("foo.twig") }} {{- include("foo.twig", with_context = false) }} {{- include("foo.twig", {'foo1': 'bar'}) }} {{- include("foo.twig", {'foo1': 'bar'}, with_context = false) }} --TEMPLATE(foo.twig)-- {% for k, v in _context %}{{ k }},{% endfor %} --DATA-- return ['foo' => 'bar'] --EXPECT-- foo,global,_parent, global,_parent, foo,global,foo1,_parent, foo1,global,_parent, ================================================ FILE: tests/Fixtures/functions/include/with_variables.test ================================================ --TEST-- "include" function accept variables --TEMPLATE-- {{ include("foo.twig", {'foo': 'bar'}) }} {{- include("foo.twig", vars) }} --TEMPLATE(foo.twig)-- {{ foo }} --DATA-- return ['vars' => ['foo' => 'bar']] --EXPECT-- bar bar ================================================ FILE: tests/Fixtures/functions/include_template_from_string.test ================================================ --TEST-- "template_from_string" function works in an "include" --TEMPLATE-- {% set embed = '{% embed "embed.twig" %}{% endembed %}' %} {{ include(template_from_string(embed)) }} --TEMPLATE(embed.twig)-- Cool --DATA-- return [] --EXPECT-- Cool ================================================ FILE: tests/Fixtures/functions/magic_call.test ================================================ --TEST-- __call calls --TEMPLATE-- {{ 'foo'|magic_call }} {{ 'foo'|magic_call_closure }} --DATA-- return [] --EXPECT-- magic_foo magic_foo ================================================ FILE: tests/Fixtures/functions/magic_static_call.test ================================================ --TEST-- __staticCall calls --TEMPLATE-- {{ 'foo'|magic_call_string }} {{ 'foo'|magic_call_array }} --DATA-- return [] --EXPECT-- static_magic_foo static_magic_foo ================================================ FILE: tests/Fixtures/functions/max.test ================================================ --TEST-- "max" function --TEMPLATE-- {{ max([2, 1, 3, 5, 4]) }} {{ max(2, 1, 3, 5, 4) }} {{ max({2:"two", 1:"one", 3:"three", 5:"five", 4:"for"}) }} --DATA-- return [] --EXPECT-- 5 5 two ================================================ FILE: tests/Fixtures/functions/max_without_args.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/min.test ================================================ --TEST-- "min" function --TEMPLATE-- {{ min(2, 1, 3, 5, 4) }} {{ min([2, 1, 3, 5, 4]) }} {{ min({2:"two", 1:"one", 3:"three", 5:"five", 4:"for"}) }} --DATA-- return [] --EXPECT-- 1 1 five ================================================ FILE: tests/Fixtures/functions/parent_in_condition.test ================================================ --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 ================================================ FILE: tests/Fixtures/functions/parent_outside_of_a_block.test ================================================ --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. ================================================ FILE: tests/Fixtures/functions/range.test ================================================ --TEST-- "range" function --TEMPLATE-- {{ range(low=0+1, high=10+0, step=2)|join(',') }} --DATA-- return [] --EXPECT-- 1,3,5,7,9 ================================================ FILE: tests/Fixtures/functions/recursive_block_with_inheritance.test ================================================ --TEST-- "block" function recursively called in a parent template --TEMPLATE-- {% extends "ordered_menu.twig" %} {% block label %}"{{ parent() }}"{% endblock %} {% block list %}{% set class = 'b' %}{{ parent() }}{% endblock %} --TEMPLATE(ordered_menu.twig)-- {% extends "menu.twig" %} {% block list %}{% set class = class|default('a') %}
      {{ block('children') }}
    {% endblock %} --TEMPLATE(menu.twig)-- {% extends "base.twig" %} {% block list %}
      {{ block('children') }}
    {% endblock %} {% block children %}{% set currentItem = item %}{% for item in currentItem %}{{ block('item') }}{% endfor %}{% set item = currentItem %}{% endblock %} {% block item %}
  • {% if item is not iterable %}{{ block('label') }}{% else %}{{ block('list') }}{% endif %}
  • {% endblock %} {% block label %}{{ item }}{% endblock %} --TEMPLATE(base.twig)-- {{ block('list') }} --DATA-- return ['item' => ['1', '2', ['3.1', ['3.2.1', '3.2.2'], '3.4']]] --EXPECT--
    1. "1"
    2. "2"
      1. "3.1"
        1. "3.2.1"
        2. "3.2.2"
      2. "3.4"
    ================================================ FILE: tests/Fixtures/functions/source.test ================================================ --TEST-- "source" function --TEMPLATE-- FOO {{ source("foo.twig") }} BAR --TEMPLATE(foo.twig)-- {{ foo }}
    --DATA-- return [] --EXPECT-- FOO {{ foo }}
    BAR ================================================ FILE: tests/Fixtures/functions/special_chars.test ================================================ --TEST-- "§" custom function --TEMPLATE-- {{ §('foo') }} --DATA-- return [] --EXPECT-- §foo§ ================================================ FILE: tests/Fixtures/functions/static_calls.test ================================================ --TEST-- Functions as static method calls --TEMPLATE-- {{ static_call_string('foo') }} {{ static_call_array('foo') }} --DATA-- return ['foo' => 'foo'] --EXPECT-- *foo* *foo* ================================================ FILE: tests/Fixtures/functions/template_from_string.test ================================================ --TEST-- "template_from_string" function --TEMPLATE-- {% include template_from_string(template) %} {% include template_from_string("Hello {{ name }}") %} {% include template_from_string('{% extends "parent.twig" %}{% block content %}Hello {{ name }}{% endblock %}') %} --TEMPLATE(parent.twig)-- {% block content %}{% endblock %} --DATA-- return ['name' => 'Fabien', 'template' => "Hello {{ name }}"] --EXPECT-- Hello Fabien Hello Fabien Hello Fabien ================================================ FILE: tests/Fixtures/functions/template_from_string_error.test ================================================ --TEST-- "template_from_string" function --CONDITION-- PHP_VERSION_ID >= 80100 --TEMPLATE-- {% include template_from_string("{{ not a Twig template ", "foo.twig") %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unclosed "variable" in "foo.twig (string template 85e7b092afbbcd36f11981c2ef8f1569)" at line 1. ================================================ FILE: tests/Fixtures/functions/template_from_string_error_php80.test ================================================ --TEST-- "template_from_string" function --CONDITION-- PHP_VERSION_ID < 80100 --TEMPLATE-- {% include template_from_string("{{ not a Twig template ", "foo.twig") %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unclosed "variable" in "foo.twig (string template 4900163d56b1af4b704c6b0afee7f98ba53418ce7a93d37a3af1882735baf9cd)" at line 1. ================================================ FILE: tests/Fixtures/functions/trailing_commas.test ================================================ --TEST-- functions allow trailing commas in their argument list --TEMPLATE-- {{ max(1, 2, 3,) }} --DATA-- return [] --EXPECT-- 3 ================================================ FILE: tests/Fixtures/functions/undefined_block.test ================================================ --TEST-- "block" function with undefined block --TEMPLATE-- {% extends "base.twig" %} {% block foo %} {{ parent() }} {{ block('unknown') }} {{ block('bar') }} {% endblock %} --TEMPLATE(base.twig)-- {% block foo %}Foo{% endblock %} {% block bar %}Bar{% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Block "unknown" on template "base.twig" does not exist in "index.twig" at line 5. ================================================ FILE: tests/Fixtures/functions/undefined_block_deep.test ================================================ --TEST-- "block" function with undefined block on deep inheritance --TEMPLATE-- {% extends "base.twig" %} {% block foo %} {{ parent() }} {{ block('unknown') }} {{ block('bar') }} {% endblock %} --TEMPLATE(base.twig)-- {% extends "layout.twig" %} {% block foo %}Foo{% endblock %} {% block bar %}Bar{% endblock %} --TEMPLATE(layout.twig)-- {% block foo %}Foo{% endblock %} {% block bar %}Bar{% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Block "unknown" on template "layout.twig" does not exist in "index.twig" at line 5. ================================================ FILE: tests/Fixtures/macros/arrow_as_arg.test ================================================ --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 ================================================ FILE: tests/Fixtures/macros/default_values.test ================================================ --TEST-- macro --TEMPLATE-- {% from _self import test %} {% macro test(a, b = 'bar') -%} {{ a }}{{ b }} {%- endmacro %} {{ test('foo') }} {{ test('bar', 'foo') }} --DATA-- return [] --EXPECT-- foobar barfoo ================================================ FILE: tests/Fixtures/macros/macro_with_capture.test ================================================ --TEST-- macro --TEMPLATE-- {{ _self.some_macro() }} {% macro some_macro() %} {% apply upper %} {% if true %}foo{% endif %} {% endapply %} {% endmacro %} --DATA-- return [] --EXPECT-- FOO ================================================ FILE: tests/Fixtures/macros/nested_calls.test ================================================ --TEST-- macro --TEMPLATE-- {% import _self as macros %} {% macro foo(data) %} {{ data }} {% endmacro %} {% macro bar() %}
    {% endmacro %} {{ macros.foo(macros.bar()) }} --DATA-- return [] --EXPECT--
    ================================================ FILE: tests/Fixtures/macros/reserved_variables.test ================================================ --TEST-- macro --TEMPLATE-- {% from _self import test %} {% macro test(this) -%} {{ this }} {%- endmacro %} {{ test(this) }} --DATA-- return ['this' => 'foo'] --EXPECT-- foo ================================================ FILE: tests/Fixtures/macros/simple.test ================================================ --TEST-- macro --TEMPLATE-- {% import _self as test %} {% from _self import test %} {% macro test(a, b) -%} {{ a|default('a') }}
    {{- b|default('b') }}
    {%- endmacro %} {{ test.test() }} {{ test() }} {{ test.test(1, "c") }} {{ test(1, "c") }} --DATA-- return [] --EXPECT-- a
    b
    a
    b
    1
    c
    1
    c
    ================================================ FILE: tests/Fixtures/macros/trailing_commas.test ================================================ --TEST-- macros allow trailing commas in their argument and parameter list --TEMPLATE-- {% import _self as test %} {% macro test(a, b,) -%} {{ a|default('a') }}
    {{- b|default('b') }}
    {%- endmacro %} {% macro test2(a, b) -%} {{ a|default('a') }}
    {{- b|default('b') }}
    {%- endmacro %} {{ test.test(1, 2,) }} {{ test.test(3, 4) }} {{ test.test2(5, 6,) }} {{ test.test2(7, 8) }} --DATA-- return [] --EXPECT-- 1
    2
    3
    4
    5
    6
    7
    8
    ================================================ FILE: tests/Fixtures/macros/unknown_macro.test ================================================ --TEST-- macro --TEMPLATE-- {% import _self as macros %} {{ macros.unknown() }} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Macro "unknown" is not defined in template "index.twig" in "index.twig" at line 4. ================================================ FILE: tests/Fixtures/macros/unknown_macro_different_template.test ================================================ --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. ================================================ FILE: tests/Fixtures/macros/varargs.test ================================================ --TEST-- macro with arbitrary arguments --TEMPLATE-- {% from _self import test1, test2 %} {% macro test1(var) %} {{- var }}: {{ varargs|join(", ") }} {% endmacro %} {% macro test2() %} {{- varargs|join(", ") }} {% endmacro %} {{ test1("foo", "bar", "foobar") }} {{ test2("foo", "bar", "foobar") }} --DATA-- return [] --EXPECT-- foo: bar, foobar foo, bar, foobar ================================================ FILE: tests/Fixtures/macros/varargs_argument.test ================================================ --TEST-- macro with varargs argument --TEMPLATE-- {% macro test(varargs) %} {% endmacro %} --EXCEPTION-- Twig\Error\SyntaxError: The argument "varargs" in macro "test" cannot be defined because the variable "varargs" is reserved for arbitrary arguments in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/macros/with_filters.test ================================================ --TEST-- macro with a filter --TEMPLATE-- {% import _self as test %} {% macro test() %} {% apply escape %}foo
    {% endapply %} {% endmacro %} {{ test.test() }} --DATA-- return [] --EXPECT-- foo<br /> ================================================ FILE: tests/Fixtures/operators/concat_vs_add_sub.test ================================================ --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 ================================================ FILE: tests/Fixtures/operators/contat_vs_add_sub.legacy.test ================================================ --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 ================================================ FILE: tests/Fixtures/operators/minus_vs_pipe.legacy.test ================================================ --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 ================================================ FILE: tests/Fixtures/operators/not_precedence.legacy.test ================================================ --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-- ================================================ FILE: tests/Fixtures/operators/not_precedence.test ================================================ --TEST-- *, /, //, and % will have a higher precedence over not in Twig 4.0 --TEMPLATE-- {{ (not 1) * 2 }} {{ not (1 * 2) }} --DATA-- return [] --EXPECT-- 0 ================================================ FILE: tests/Fixtures/regression/4029-iterator_to_array.test ================================================ --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 ================================================ FILE: tests/Fixtures/regression/4033-missing-unwrap.test ================================================ --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 ================================================ FILE: tests/Fixtures/regression/4701-block-inheritance-issue.test ================================================ --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 ================================================ FILE: tests/Fixtures/regression/block_names_unicity.test ================================================ --TEST-- Block names are unique per template --TEMPLATE-- {% extends 'layout' %} {% block content -%} {% apply title -%} second {% endapply %} {% endblock %} --TEMPLATE(layout)-- {% apply title -%} first {% endapply %} {% block content %}{% endblock %} --DATA-- return [] --EXPECT-- First Second ================================================ FILE: tests/Fixtures/regression/combined_debug_info.test ================================================ --TEST-- Exception with bad line number --TEMPLATE-- {% block content %} {{ foo }} {{ include("foo") }} {% endblock %} index --TEMPLATE(foo)-- foo {{ foo.bar }} --DATA-- return ['foo' => 'foo'] --EXCEPTION-- Twig\Error\RuntimeError: Impossible to access an attribute ("bar") on a string variable ("foo") in "foo" at line 3. ================================================ FILE: tests/Fixtures/regression/empty_token.test ================================================ --TEST-- Twig outputs 0 nodes correctly --TEMPLATE-- {{ foo }}0{{ foo }} --DATA-- return ['foo' => 'foo'] --EXPECT-- foo0foo ================================================ FILE: tests/Fixtures/regression/markup_test.test ================================================ --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 ================================================ FILE: tests/Fixtures/regression/multi_word_tests.test ================================================ --TEST-- Twig allows multi-word tests without a custom node class --TEMPLATE-- {{ 'foo' is multi word ? 'yes' : 'no' }} {{ 'foo bar' is multi word ? 'yes' : 'no' }} --DATA-- return [] --EXPECT-- no yes ================================================ FILE: tests/Fixtures/regression/simple_xml_element.test ================================================ --TEST-- Twig is able to deal with SimpleXMLElement instances as variables --CONDITION-- version_compare(phpversion(), '8.0', '<') --TEMPLATE-- Hello '{{ images.image.0.group }}'! {{ images.image.0.group.attributes.myattr }} {{ images.children().image.count() }} {% for image in images %} - {{ image.group }} {% endfor %} --DATA-- return ['images' => new \SimpleXMLElement('foobar')] --EXPECT-- Hello 'foo'! example 2 - foo - bar ================================================ FILE: tests/Fixtures/regression/strings_like_numbers.test ================================================ --TEST-- Twig does not confuse strings with integers in getAttribute() --TEMPLATE-- {{ mapping['2e2'] }} --DATA-- return ['mapping' => ['2e2' => 'works']] --EXPECT-- works ================================================ FILE: tests/Fixtures/tags/apply/basic.test ================================================ --TEST-- "apply" tag applies a filter on its children --TEMPLATE-- {% apply upper %} Some text with a {{ var }} {% endapply %} --DATA-- return ['var' => 'var'] --EXPECT-- SOME TEXT WITH A VAR ================================================ FILE: tests/Fixtures/tags/apply/json_encode.test ================================================ --TEST-- "apply" tag applies a filter on its children --TEMPLATE-- {% apply json_encode|raw %}test{% endapply %} --DATA-- return [] --EXPECT-- "test" ================================================ FILE: tests/Fixtures/tags/apply/multiple.test ================================================ --TEST-- "apply" tags accept multiple chained filters --TEMPLATE-- {% apply lower|title %} {{ var }} {% endapply %} --DATA-- return ['var' => 'VAR'] --EXPECT-- Var ================================================ FILE: tests/Fixtures/tags/apply/nested.test ================================================ --TEST-- "apply" tags can be nested at will --TEMPLATE-- {% apply lower|title %} {{ var }} {% apply upper %} {{ var }} {% endapply %} {{ var }} {% endapply %} --DATA-- return ['var' => 'var'] --EXPECT-- Var Var Var ================================================ FILE: tests/Fixtures/tags/apply/scope.test ================================================ --TEST-- "apply" tag does not create a new scope --TEMPLATE-- {% set foo = 'baz' %} {% apply upper %} {% set foo = 'foo' %} {% set bar = 'bar' %} {% endapply %} {{ 'foo' == foo ? 'OK ' ~ foo : 'KO' }} {{ 'bar' == bar ? 'OK ' ~ bar : 'KO' }} --DATA-- return [] --EXPECT-- OK foo OK bar ================================================ FILE: tests/Fixtures/tags/apply/with_for_tag.test ================================================ --TEST-- "apply" tag applies the filter on "for" tags --TEMPLATE-- {% apply upper %} {% for item in items %} {{ item }} {% endfor %} {% endapply %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- A B ================================================ FILE: tests/Fixtures/tags/apply/with_if_tag.test ================================================ --TEST-- "apply" tag applies the filter on "if" tags --TEMPLATE-- {% apply upper %} {% if items %} {{ items|join(', ') }} {% endif %} {% if items.3 is defined %} FOO {% else %} {{ items.1 }} {% endif %} {% if items.3 is defined %} FOO {% elseif items.1 %} {{ items.0 }} {% endif %} {% endapply %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- A, B B A ================================================ FILE: tests/Fixtures/tags/autoescape/basic.test ================================================ --TEST-- "autoescape" tag applies escaping on its children --TEMPLATE-- {% autoescape %} {{ var }}
    {% endautoescape %} {% autoescape 'html' %} {{ var }}
    {% endautoescape %} {% autoescape false %} {{ var }}
    {% endautoescape %} {% autoescape false %} {{ var }}
    {% endautoescape %} --DATA-- return ['var' => '
    '] --EXPECT-- <br />
    <br />




    ================================================ FILE: tests/Fixtures/tags/autoescape/blocks.test ================================================ --TEST-- "autoescape" tag applies escaping on embedded blocks --TEMPLATE-- {% autoescape 'html' %} {% block foo %} {{ var }} {% endblock %} {% endautoescape %} --DATA-- return ['var' => '
    '] --EXPECT-- <br /> ================================================ FILE: tests/Fixtures/tags/autoescape/double_escaping.test ================================================ --TEST-- "autoescape" tag does not double-escape --TEMPLATE-- {% autoescape 'html' %} {{ var|escape }} {% endautoescape %} --DATA-- return ['var' => '
    '] --EXPECT-- <br /> ================================================ FILE: tests/Fixtures/tags/autoescape/functions.test ================================================ --TEST-- "autoescape" tag applies escaping after calling functions --TEMPLATE-- autoescape false {% autoescape false %} safe_br {{ safe_br() }} unsafe_br {{ unsafe_br() }} {% endautoescape %} autoescape 'html' {% autoescape 'html' %} safe_br {{ safe_br() }} unsafe_br {{ unsafe_br() }} unsafe_br()|raw {{ (unsafe_br())|raw }} safe_br()|escape {{ (safe_br())|escape }} safe_br()|raw {{ (safe_br())|raw }} unsafe_br()|escape {{ (unsafe_br())|escape }} {% endautoescape %} autoescape js {% autoescape 'js' %} safe_br {{ safe_br() }} {% endautoescape %} --DATA-- return [] --EXPECT-- autoescape false safe_br
    unsafe_br
    autoescape 'html' safe_br
    unsafe_br <br /> unsafe_br()|raw
    safe_br()|escape <br /> safe_br()|raw
    unsafe_br()|escape <br /> autoescape js safe_br \u003Cbr\u0020\/\u003E ================================================ FILE: tests/Fixtures/tags/autoescape/literal.test ================================================ --TEST-- "autoescape" tag does not apply escaping on literals --TEMPLATE-- {% autoescape 'html' %} 1. Simple literal {{ "
    " }} 2. Conditional expression with only literals {{ true ? "
    " : "
    " }} 3. Conditional expression with a variable {{ true ? "
    " : someVar }} {{ false ? "
    " : someVar }} {{ true ? someVar : "
    " }} {{ false ? someVar : "
    " }} 4. Nested conditionals with only literals {{ true ? (true ? "
    " : "
    ") : "\n" }} 5. Nested conditionals with a variable {{ true ? (true ? "
    " : someVar) : "\n" }} {{ true ? (false ? "
    " : someVar) : "\n" }} {{ true ? (true ? someVar : "
    ") : "\n" }} {{ true ? (false ? someVar : "
    ") : "\n" }} {{ false ? "\n" : (true ? someVar : "
    ") }} {{ false ? "\n" : (false ? someVar : "
    ") }} 6. Nested conditionals with a variable marked safe {{ true ? (true ? "
    " : someVar|raw) : "\n" }} {{ true ? (false ? "
    " : someVar|raw) : "\n" }} {{ true ? (true ? someVar|raw : "
    ") : "\n" }} {{ true ? (false ? someVar|raw : "
    ") : "\n" }} {{ false ? "\n" : (true ? someVar|raw : "
    ") }} {{ false ? "\n" : (false ? someVar|raw : "
    ") }} 7. Without then clause {{ "
    " ?: someVar }} {{ someFalseVar ?: "
    " }} 8. NullCoalesce {{ aaaa ?? "
    " }} {{ "
    " ?? someVar }} {% endautoescape %} --DATA-- return ['someVar' => '
    ', 'someFalseVar' => false] --EXPECT-- 1. Simple literal
    2. Conditional expression with only literals
    3. Conditional expression with a variable
    <br /> <br />
    4. Nested conditionals with only literals
    5. Nested conditionals with a variable
    <br /> <br />
    <br />
    6. Nested conditionals with a variable marked safe





    7. Without then clause

    8. NullCoalesce

    ================================================ FILE: tests/Fixtures/tags/autoescape/nested.test ================================================ --TEST-- "autoescape" tags can be nested at will --TEMPLATE-- {{ var }} {% autoescape 'html' %} {{ var }} {% autoescape false %} {{ var }} {% autoescape 'html' %} {{ var }} {% endautoescape %} {{ var }} {% endautoescape %} {{ var }} {% endautoescape %} {{ var }} --DATA-- return ['var' => '
    '] --EXPECT-- <br /> <br />
    <br />
    <br /> <br /> ================================================ FILE: tests/Fixtures/tags/autoescape/objects.test ================================================ --TEST-- "autoescape" tag applies escaping to object method calls --TEMPLATE-- {% autoescape 'html' %} {{ user.name }} {{ user.name|lower }} {{ user }} {% endautoescape %} --DATA-- class UserForAutoEscapeTest { public function getName() { return 'Fabien
    '; } public function __toString() { return 'Fabien
    '; } } return ['user' => new UserForAutoEscapeTest()] --EXPECT-- Fabien<br /> fabien<br /> Fabien<br /> ================================================ FILE: tests/Fixtures/tags/autoescape/raw.test ================================================ --TEST-- "autoescape" tag does not escape when raw is used as a filter --TEMPLATE-- {% autoescape 'html' %} {{ var|raw }} {% endautoescape %} --DATA-- return ['var' => '
    '] --EXPECT--
    ================================================ FILE: tests/Fixtures/tags/autoescape/strategy.test ================================================ --TEST-- "autoescape" tag accepts an escaping strategy --TEMPLATE-- {% autoescape 'js' %}{{ var }}{% endautoescape %} {% autoescape 'html' %}{{ var }}{% endautoescape %} --DATA-- return ['var' => '
    "'] --EXPECT-- \u003Cbr\u0020\/\u003E\u0022 <br />" ================================================ FILE: tests/Fixtures/tags/autoescape/type.test ================================================ --TEST-- escape types --TEMPLATE-- 1. autoescape 'html' |escape('js') {% autoescape 'html' %} {% endautoescape %} 2. autoescape 'html' |escape('js') {% autoescape 'html' %} {% endautoescape %} 3. autoescape 'js' |escape('js') {% autoescape 'js' %} {% endautoescape %} 4. no escape {% autoescape false %} {% endautoescape %} 5. |escape('js')|escape('html') {% autoescape false %} {% endautoescape %} 6. autoescape 'html' |escape('js')|escape('html') {% autoescape 'html' %} {% endautoescape %} --DATA-- return ['msg' => "<>\n'\""] --EXPECT-- 1. autoescape 'html' |escape('js') 2. autoescape 'html' |escape('js') 3. autoescape 'js' |escape('js') 4. no escape 5. |escape('js')|escape('html') 6. autoescape 'html' |escape('js')|escape('html') ================================================ FILE: tests/Fixtures/tags/autoescape/with_filters.test ================================================ --TEST-- "autoescape" tag applies escaping after calling filters --TEMPLATE-- {% autoescape 'html' %} (escape_and_nl2br is an escaper filter) 1. Don't escape escaper filter output ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is not escaped ) {{ var|escape_and_nl2br }} 2. Don't escape escaper filter output ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is not escaped, |raw is redundant ) {{ var|escape_and_nl2br|raw }} 3. Explicit escape ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is explicitly escaped by |escape ) {{ var|escape_and_nl2br|escape }} 4. Escape non-escaper filter output ( var is upper-cased by |upper, the output is auto-escaped ) {{ var|upper }} 5. Escape if last filter is not an escaper ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is upper-cased by |upper, the output is auto-escaped as |upper is not an escaper ) {{ var|escape_and_nl2br|upper }} 6. Don't escape escaper filter output ( var is upper cased by upper, the output is escaped by |escape_and_nl2br, line-breaks are added, the output is not escaped as |escape_and_nl2br is an escaper ) {{ var|upper|escape_and_nl2br }} 7. Escape if last filter is not an escaper ( the output of |format is "" ~ var ~ "", the output is auto-escaped ) {{ "%s"|format(var) }} 8. Escape if last filter is not an escaper ( the output of |format is "" ~ var ~ "", |raw is redundant, the output is auto-escaped ) {{ "%s"|raw|format(var) }} 9. Don't escape escaper filter output ( the output of |format is "" ~ var ~ "", the output is not escaped due to |raw filter at the end ) {{ "%s"|format(var)|raw }} 10. Don't escape escaper filter output ( the output of |format is "" ~ var ~ "", the output is not escaped due to |raw filter at the end, the |raw filter on var is redundant ) {{ "%s"|format(var|raw)|raw }} {% endautoescape %} --DATA-- return ['var' => "\nTwig"] --EXPECT-- (escape_and_nl2br is an escaper filter) 1. Don't escape escaper filter output ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is not escaped ) <Fabien>
    Twig 2. Don't escape escaper filter output ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is not escaped, |raw is redundant ) <Fabien>
    Twig 3. Explicit escape ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is explicitly escaped by |escape ) &lt;Fabien&gt;<br /> Twig 4. Escape non-escaper filter output ( var is upper-cased by |upper, the output is auto-escaped ) <FABIEN> TWIG 5. Escape if last filter is not an escaper ( var is escaped by |escape_and_nl2br, line-breaks are added, the output is upper-cased by |upper, the output is auto-escaped as |upper is not an escaper ) &LT;FABIEN&GT;<BR /> TWIG 6. Don't escape escaper filter output ( var is upper cased by upper, the output is escaped by |escape_and_nl2br, line-breaks are added, the output is not escaped as |escape_and_nl2br is an escaper ) <FABIEN>
    TWIG 7. Escape if last filter is not an escaper ( the output of |format is "" ~ var ~ "", the output is auto-escaped ) <b><Fabien> Twig</b> 8. Escape if last filter is not an escaper ( the output of |format is "" ~ var ~ "", |raw is redundant, the output is auto-escaped ) <b><Fabien> Twig</b> 9. Don't escape escaper filter output ( the output of |format is "" ~ var ~ "", the output is not escaped due to |raw filter at the end ) Twig 10. Don't escape escaper filter output ( the output of |format is "" ~ var ~ "", the output is not escaped due to |raw filter at the end, the |raw filter on var is redundant ) Twig ================================================ FILE: tests/Fixtures/tags/autoescape/with_filters_arguments.test ================================================ --TEST-- "autoescape" tag do not applies escaping on filter arguments --TEMPLATE-- {% autoescape 'html' %} {{ var|nl2br("
    ") }} {{ var|nl2br("
    "|escape) }} {{ var|nl2br(sep) }} {{ var|nl2br(sep|raw) }} {{ var|nl2br(sep|escape) }} {% endautoescape %} --DATA-- return ['var' => "\nTwig", 'sep' => '
    '] --EXPECT-- <Fabien>
    Twig <Fabien><br /> Twig <Fabien>
    Twig <Fabien>
    Twig <Fabien><br /> Twig ================================================ FILE: tests/Fixtures/tags/autoescape/with_pre_escape_filters.test ================================================ --TEST-- "autoescape" tag applies escaping after calling filters, and before calling pre_escape filters --TEMPLATE-- {% autoescape 'html' %} (nl2br is pre_escaped for "html" and declared safe for "html") 1. Pre-escape and don't post-escape ( var|escape|nl2br ) {{ var|nl2br }} 2. Don't double-pre-escape ( var|escape|nl2br ) {{ var|escape|nl2br }} 3. Don't escape safe values ( var|raw|nl2br ) {{ var|raw|nl2br }} 4. Don't escape safe values ( var|escape|nl2br|nl2br ) {{ var|nl2br|nl2br }} 5. Re-escape values that are escaped for an other contexts ( var|escape_something|escape|nl2br ) {{ var|escape_something|nl2br }} 6. Still escape when using filters not declared safe ( var|escape|nl2br|upper|escape ) {{ var|nl2br|upper }} {% endautoescape %} --DATA-- return ['var' => "\nTwig"] --EXPECT-- (nl2br is pre_escaped for "html" and declared safe for "html") 1. Pre-escape and don't post-escape ( var|escape|nl2br ) <Fabien>
    Twig 2. Don't double-pre-escape ( var|escape|nl2br ) <Fabien>
    Twig 3. Don't escape safe values ( var|raw|nl2br )
    Twig 4. Don't escape safe values ( var|escape|nl2br|nl2br ) <Fabien>

    Twig 5. Re-escape values that are escaped for an other contexts ( var|escape_something|escape|nl2br ) <FABIEN>
    TWIG 6. Still escape when using filters not declared safe ( var|escape|nl2br|upper|escape ) &LT;FABIEN&GT;<BR /> TWIG ================================================ FILE: tests/Fixtures/tags/autoescape/with_preserves_safety_filters.test ================================================ --TEST-- "autoescape" tag handles filters preserving the safety --TEMPLATE-- {% autoescape 'html' %} (preserves_safety is preserving safety for "html") 1. Unsafe values are still unsafe ( var|preserves_safety|escape ) {{ var|preserves_safety }} 2. Safe values are still safe ( var|escape|preserves_safety ) {{ var|escape|preserves_safety }} 3. Re-escape values that are escaped for an other contexts ( var|escape_something|preserves_safety|escape ) {{ var|escape_something|preserves_safety }} 4. Still escape when using filters not declared safe ( var|escape|preserves_safety|replace({'FABIEN': 'FABPOT'})|escape ) {{ var|escape|preserves_safety|replace({'FABIEN': 'FABPOT'}) }} {% endautoescape %} --DATA-- return ['var' => "\nTwig"] --EXPECT-- (preserves_safety is preserving safety for "html") 1. Unsafe values are still unsafe ( var|preserves_safety|escape ) <FABIEN> TWIG 2. Safe values are still safe ( var|escape|preserves_safety ) <FABIEN> TWIG 3. Re-escape values that are escaped for an other contexts ( var|escape_something|preserves_safety|escape ) <FABIEN> TWIG 4. Still escape when using filters not declared safe ( var|escape|preserves_safety|replace({'FABIEN': 'FABPOT'})|escape ) &LT;FABPOT&GT; TWIG ================================================ FILE: tests/Fixtures/tags/block/basic.test ================================================ --TEST-- "block" tag --TEMPLATE-- {% block title1 %}FOO{% endblock %} {% block title2 foo|lower %} --TEMPLATE(foo.twig)-- {% block content %}{% endblock %} --DATA-- return ['foo' => 'bar'] --EXPECT-- FOObar ================================================ FILE: tests/Fixtures/tags/block/block_unique_name.test ================================================ --TEST-- "block" tag --TEMPLATE-- {% block content %} {% block content %} {% endblock %} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: The block 'content' has already been defined line 2 in "index.twig" at line 3. ================================================ FILE: tests/Fixtures/tags/block/conditional_block.test ================================================ --TEST-- conditional "block" tag --TEMPLATE-- {% if false %}{% block foo %}FOO{% endblock %}{% endif %} {% if true %}{% block bar %}BAR{% endblock %}{% endif %} --DATA-- return [] --EXPECT-- BAR ================================================ FILE: tests/Fixtures/tags/block/special_chars.test ================================================ --TEST-- "§" special chars in a block name --TEMPLATE-- {% block § %} § {% endblock § %} --DATA-- return [] --EXPECT-- § ================================================ FILE: tests/Fixtures/tags/deprecated/block.legacy.test ================================================ --TEST-- Deprecating a block with "deprecated" tag --TEMPLATE-- {% use 'greeting.twig' %} {{ block('welcome') }} --TEMPLATE(greeting.twig)-- {% block welcome %} {% deprecated 'The "welcome" block is deprecated, use "hello" instead.' %} {{ block('hello') }} {% endblock %} {% block hello %} Hello Fabien {% endblock %} --DATA-- return [] --EXPECT-- Hello Fabien ================================================ FILE: tests/Fixtures/tags/deprecated/macro.legacy.test ================================================ --TEST-- Deprecating a macro with "deprecated" tag --TEMPLATE-- {% import 'greeting.twig' as greeting %} {{ greeting.welcome('Fabien') }} --TEMPLATE(greeting.twig)-- {% macro welcome(name) %} {% deprecated 'The "welcome" macro is deprecated, use "hello" instead.' %} {% import _self as self %} {{ self.hello(name) }} {% endmacro %} {% macro hello(name) %} Hello {{ name }} {% endmacro %} --DATA-- return [] --EXPECT-- Hello Fabien ================================================ FILE: tests/Fixtures/tags/deprecated/template.legacy.test ================================================ --TEST-- Deprecating a template with "deprecated" tag --TEMPLATE-- {% extends 'greeting.twig' %} {% deprecated 'The "index.twig" template is deprecated, use "greeting.twig" instead.' %} --TEMPLATE(greeting.twig)-- Hello Fabien --DATA-- return [] --EXPECT-- Hello Fabien ================================================ FILE: tests/Fixtures/tags/deprecated/with_package.legacy.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/deprecated/with_package_version.legacy.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/embed/basic.test ================================================ --TEST-- "embed" tag --TEMPLATE-- FOO {% embed "foo.twig" %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} BAR --TEMPLATE(foo.twig)-- A {% block c1 %} block1 {% endblock %} B {% block c2 %} block2 {% endblock %} C --DATA-- return [] --EXPECT-- FOO A block1 block1extended B block2 C BAR ================================================ FILE: tests/Fixtures/tags/embed/complex_dynamic_parent.test ================================================ --TEST-- "embed" tag --TEMPLATE-- FOO {% embed foo ~ ".twig" %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} BAR --TEMPLATE(foo.twig)-- A {% block c1 %} block1 {% endblock %} B {% block c2 %} block2 {% endblock %} C --DATA-- return ['foo' => 'foo'] --EXPECT-- FOO A block1 block1extended B block2 C BAR ================================================ FILE: tests/Fixtures/tags/embed/dynamic_parent.test ================================================ --TEST-- "embed" tag --TEMPLATE-- FOO {% embed foo %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} BAR --TEMPLATE(foo.twig)-- A {% block c1 %} block1 {% endblock %} B {% block c2 %} block2 {% endblock %} C --DATA-- return ['foo' => 'foo.twig'] --EXPECT-- FOO A block1 block1extended B block2 C BAR ================================================ FILE: tests/Fixtures/tags/embed/embed_ignore_missing.test ================================================ --TEST-- "embed" tag --TEMPLATE-- {% set x = 'bad' %} {% embed x ~ 'ger.twig' ignore missing %}{% endembed %} HERE --DATA-- return [] --EXPECT-- HERE ================================================ FILE: tests/Fixtures/tags/embed/error_line.test ================================================ --TEST-- "embed" tag --TEMPLATE(index.twig)-- FOO {% embed "foo.twig" %} {% block c1 %} {{ nothing }} {% endblock %} {% endembed %} BAR --TEMPLATE(foo.twig)-- {% block c1 %}{% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "nothing" does not exist in "index.twig" at line 5. ================================================ FILE: tests/Fixtures/tags/embed/multiple.test ================================================ --TEST-- "embed" tag --TEMPLATE-- FOO {% embed "foo.twig" %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} {% embed "foo.twig" %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} BAR --TEMPLATE(foo.twig)-- A {% block c1 %} block1 {% endblock %} B {% block c2 %} block2 {% endblock %} C --DATA-- return [] --EXPECT-- FOO A block1 block1extended B block2 C A block1 block1extended B block2 C BAR ================================================ FILE: tests/Fixtures/tags/embed/nested.test ================================================ --TEST-- "embed" tag --TEMPLATE-- {% embed "foo.twig" %} {% block c1 %} {{ parent() }} {% embed "foo.twig" %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} {% endblock %} {% endembed %} --TEMPLATE(foo.twig)-- A {% block c1 %} block1 {% endblock %} B {% block c2 %} block2 {% endblock %} C --DATA-- return [] --EXPECT-- A block1 A block1 block1extended B block2 C B block2 C ================================================ FILE: tests/Fixtures/tags/embed/with_extends.test ================================================ --TEST-- "embed" tag --TEMPLATE-- {% extends "base.twig" %} {% block c1 %} {{ parent() }} blockc1baseextended {% endblock %} {% block c2 %} {{ parent() }} {% embed "foo.twig" %} {% block c1 %} {{ parent() }} block1extended {% endblock %} {% endembed %} {{ parent() }} {% endblock %} --TEMPLATE(base.twig)-- A {% block c1 %} blockc1base {% endblock %} {% block c2 %} blockc2base {% endblock %} B --TEMPLATE(foo.twig)-- A {% block c1 %} block1 {% endblock %} B {% block c2 %} block2 {% endblock %} C --DATA-- return [] --EXPECT-- A blockc1base blockc1baseextended blockc2base A block1 block1extended B block2 C blockc2base B ================================================ FILE: tests/Fixtures/tags/for/context.test ================================================ --TEST-- "for" tag keeps the context safe --TEMPLATE-- {% for item in items %} {% for item in items %} * {{ item }} {% endfor %} * {{ item }} {% endfor %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- * a * b * a * a * b * b ================================================ FILE: tests/Fixtures/tags/for/else.test ================================================ --TEST-- "for" tag can use an "else" clause --TEMPLATE-- {% for item in items %} * {{ item }} {% else %} no item {% endfor %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- * a * b --DATA-- return ['items' => []] --EXPECT-- no item --DATA-- return [] --CONFIG-- return ['strict_variables' => false] --EXPECT-- no item ================================================ FILE: tests/Fixtures/tags/for/for_on_strings.test ================================================ --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-- -諺- -/- -こ-と-わ-ざ. ================================================ FILE: tests/Fixtures/tags/for/inner_variables.test ================================================ --TEST-- "for" tag does not reset inner variables --TEMPLATE-- {% for i in 1..2 %} {% for j in 0..2 %} {{k}}{% set k = k+1 %} {{ loop.parent.loop.index }} {% endfor %} {% endfor %} --DATA-- return ['k' => 0] --EXPECT-- 0 1 1 1 2 1 3 2 4 2 5 2 ================================================ FILE: tests/Fixtures/tags/for/keys.test ================================================ --TEST-- "for" tag can iterate over keys --TEMPLATE-- {% for key in items|keys %} * {{ key }} {% endfor %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- * 0 * 1 ================================================ FILE: tests/Fixtures/tags/for/keys_and_values.test ================================================ --TEST-- "for" tag can iterate over keys and values --TEMPLATE-- {% for key, item in items %} * {{ key }}/{{ item }} {% endfor %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- * 0/a * 1/b ================================================ FILE: tests/Fixtures/tags/for/loop_context.test ================================================ --TEST-- "for" tag adds a loop variable to the context --TEMPLATE-- {% for item in items %} * {{ loop.index }}/{{ loop.index0 }} * {{ loop.revindex }}/{{ loop.revindex0 }} * {{ loop.first }}/{{ loop.last }}/{{ loop.length }} {% endfor %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- * 1/0 * 2/1 * 1//2 * 2/1 * 1/0 * /1/2 ================================================ FILE: tests/Fixtures/tags/for/loop_context_local.test ================================================ --TEST-- "for" tag adds a loop variable to the context locally --TEMPLATE-- {% for item in items %} {% endfor %} {% if loop is not defined %}WORKS{% endif %} --DATA-- return ['items' => []] --EXPECT-- WORKS ================================================ FILE: tests/Fixtures/tags/for/nested_else.test ================================================ --TEST-- "for" tag can use an "else" clause --TEMPLATE-- {% for item in items %} {% for item in items1 %} * {{ item }} {% else %} no {{ item }} {% endfor %} {% else %} no item1 {% endfor %} --DATA-- return ['items' => ['a', 'b'], 'items1' => []] --EXPECT-- no a no b ================================================ FILE: tests/Fixtures/tags/for/objects.test ================================================ --TEST-- "for" tag iterates over iterable objects --TEMPLATE-- {% for item in items %} * {{ item }} * {{ loop.index }}/{{ loop.index0 }} * {{ loop.first }} {% endfor %} {% for key, value in items %} * {{ key }}/{{ value }} {% endfor %} {% for key in items|keys %} * {{ key }} {% endfor %} --DATA-- class ItemsIterator implements \Iterator { protected $values = ['foo' => 'bar', 'bar' => 'foo']; #[\ReturnTypeWillChange] public function current() { return current($this->values); } #[\ReturnTypeWillChange] public function key() { return key($this->values); } public function next(): void { next($this->values); } public function rewind(): void { reset($this->values); } public function valid(): bool { return false !== current($this->values); } } return ['items' => new ItemsIterator()] --EXPECT-- * bar * 1/0 * 1 * foo * 2/1 * * foo/bar * bar/foo * foo * bar ================================================ FILE: tests/Fixtures/tags/for/objects_countable.test ================================================ --TEST-- "for" tag iterates over iterable and countable objects --TEMPLATE-- {% for item in items %} * {{ item }} * {{ loop.index }}/{{ loop.index0 }} * {{ loop.revindex }}/{{ loop.revindex0 }} * {{ loop.first }}/{{ loop.last }}/{{ loop.length }} {% endfor %} {% for key, value in items %} * {{ key }}/{{ value }} {% endfor %} {% for key in items|keys %} * {{ key }} {% endfor %} --DATA-- class ItemsIteratorCountable implements \Iterator, \Countable { protected $values = ['foo' => 'bar', 'bar' => 'foo']; #[\ReturnTypeWillChange] public function current() { return current($this->values); } #[\ReturnTypeWillChange] public function key() { return key($this->values); } public function next(): void { next($this->values); } public function rewind(): void { reset($this->values); } public function valid(): bool { return false !== current($this->values); } public function count(): int { return count($this->values); } } return ['items' => new ItemsIteratorCountable()] --EXPECT-- * bar * 1/0 * 2/1 * 1//2 * foo * 2/1 * 1/0 * /1/2 * foo/bar * bar/foo * foo * bar ================================================ FILE: tests/Fixtures/tags/for/recursive.test ================================================ --TEST-- "for" tags can be nested --TEMPLATE-- {% for key, item in items %} * {{ key }} ({{ loop.length }}): {% for value in item %} * {{ value }} ({{ loop.length }}) {% endfor %} {% endfor %} --DATA-- return ['items' => ['a' => ['a1', 'a2', 'a3'], 'b' => ['b1']]] --EXPECT-- * a (2): * a1 (3) * a2 (3) * a3 (3) * b (2): * b1 (1) ================================================ FILE: tests/Fixtures/tags/for/reserved_names.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/for/values.test ================================================ --TEST-- "for" tag iterates over item values --TEMPLATE-- {% for item in items %} * {{ item }} {% endfor %} --DATA-- return ['items' => ['a', 'b']] --EXPECT-- * a * b ================================================ FILE: tests/Fixtures/tags/from.test ================================================ --TEST-- global variables --TEMPLATE-- {% include "included.twig" %} {% from "included.twig" import foobar %} {{ foobar() }} --TEMPLATE(included.twig)-- {% macro foobar() %} called foobar {% endmacro %} --DATA-- return [] --EXPECT-- called foobar ================================================ FILE: tests/Fixtures/tags/guard/basic.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/guard/exception.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/guard/nested.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/guard/throwing_handler.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/if/basic.test ================================================ --TEST-- "if" creates a condition --TEMPLATE-- {% if a is defined %} {{ a }} {% elseif b is defined %} {{ b }} {% else %} NOTHING {% endif %} --DATA-- return ['a' => 'a'] --EXPECT-- a --DATA-- return ['b' => 'b'] --EXPECT-- b --DATA-- return [] --EXPECT-- NOTHING ================================================ FILE: tests/Fixtures/tags/if/empty_body.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/if/expression.test ================================================ --TEST-- "if" takes an expression as a test --TEMPLATE-- {% if a < 2 %} A1 {% elseif a > 10 %} A2 {% else %} A3 {% endif %} --DATA-- return ['a' => 1] --EXPECT-- A1 --DATA-- return ['a' => 12] --EXPECT-- A2 --DATA-- return ['a' => 7] --EXPECT-- A3 ================================================ FILE: tests/Fixtures/tags/include/basic.test ================================================ --TEST-- "include" tag --TEMPLATE-- FOO {% include "foo.twig" %} BAR --TEMPLATE(foo.twig)-- FOOBAR --DATA-- return [] --EXPECT-- FOO FOOBAR BAR ================================================ FILE: tests/Fixtures/tags/include/expression.test ================================================ --TEST-- "include" tag allows expressions for the template to include --TEMPLATE-- FOO {% include foo %} BAR --TEMPLATE(foo.twig)-- FOOBAR --DATA-- return ['foo' => 'foo.twig'] --EXPECT-- FOO FOOBAR BAR ================================================ FILE: tests/Fixtures/tags/include/ignore_missing.test ================================================ --TEST-- "include" tag --TEMPLATE-- {% include ["foo.twig", "bar.twig"] ignore missing %} {% include "foo.twig" ignore missing %} {% include "foo.twig" ignore missing with {} %} {% include "foo.twig" ignore missing with {} only %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/include/ignore_missing_exists.test ================================================ --TEST-- "include" tag --TEMPLATE-- {% include "included.twig" ignore missing %} NOT DISPLAYED --TEMPLATE(included.twig)-- {% include "DOES NOT EXIST" %} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "DOES NOT EXIST" is not defined in "included.twig" at line 2. ================================================ FILE: tests/Fixtures/tags/include/include_missing_extends.test ================================================ --TEST-- "include" tag --TEMPLATE-- {% include ['bad.twig', 'good.twig'] ignore missing %} NOT DISPLAYED --TEMPLATE(bad.twig)-- {% extends 'DOES NOT EXIST' %} --TEMPLATE(good.twig)-- NOT DISPLAYED --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "DOES NOT EXIST" is not defined in "bad.twig" at line 2. ================================================ FILE: tests/Fixtures/tags/include/missing.test ================================================ --TEST-- "include" tag --TEMPLATE-- {% include "foo.twig" %} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "foo.twig" is not defined in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/tags/include/missing_nested.test ================================================ --TEST-- "include" tag --TEMPLATE-- {% extends "base.twig" %} {% block content %} {{ parent() }} {% endblock %} --TEMPLATE(base.twig)-- {% block content %} {% include "foo.twig" %} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\LoaderError: Template "foo.twig" is not defined in "base.twig" at line 3. ================================================ FILE: tests/Fixtures/tags/include/only.test ================================================ --TEST-- "include" tag accept variables and only --TEMPLATE-- {% include "foo.twig" %} {% include "foo.twig" only %} {% include "foo.twig" with vars1 %} {% include "foo.twig" with vars1 only %} {% include "foo.twig" with vars2 %} {% include "foo.twig" with vars2 only %} --TEMPLATE(foo.twig)-- {% for k, v in _context %}{{ k }},{% endfor %} --DATA-- return ['vars1' => ['foo1' => 'bar'], 'vars2' => new ArrayObject(['foo2' => 'bar'])] --EXPECT-- vars1,vars2,global,_parent, global,_parent, vars1,vars2,global,foo1,_parent, foo1,global,_parent, vars1,vars2,global,foo2,_parent, foo2,global,_parent, ================================================ FILE: tests/Fixtures/tags/include/template_instance.test ================================================ --TEST-- "include" tag accepts \Twig\TemplateWrapper instance --TEMPLATE-- {% include foo %} FOO --TEMPLATE(foo.twig)-- BAR --DATA-- return ['foo' => $twig->load('foo.twig')] --EXPECT-- BAR FOO ================================================ FILE: tests/Fixtures/tags/include/templates_as_array.test ================================================ --TEST-- "include" tag --TEMPLATE-- {% include ["foo.twig", "bar.twig"] %} {% include ["bar.twig", "foo.twig"] %} --TEMPLATE(foo.twig)-- foo --DATA-- return [] --EXPECT-- foo foo ================================================ FILE: tests/Fixtures/tags/include/with_variables.test ================================================ --TEST-- "include" tag accept variables --TEMPLATE-- {% include "foo.twig" with {'foo': 'bar'} %} {% include "foo.twig" with vars1 %} {% include "foo.twig" with vars2 %} --TEMPLATE(foo.twig)-- {{ foo }} --DATA-- return ['vars1' => ['foo' => 'bar'], 'vars2' => new ArrayObject(['foo' => 'bar'])] --EXPECT-- bar bar bar ================================================ FILE: tests/Fixtures/tags/inheritance/basic.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "foo.twig" %} {% block content %} FOO {% endblock %} --TEMPLATE(foo.twig)-- {% block content %}{% endblock %} --DATA-- return [] --EXPECT-- FOO ================================================ FILE: tests/Fixtures/tags/inheritance/block_expr.test ================================================ --TEST-- block_expr --TEMPLATE-- {% extends "base.twig" %} {% block element -%} Element: {{- parent() -}} {% endblock %} --TEMPLATE(base.twig)-- {% block element -%}
    {%- if item.children is defined %} {%- for item in item.children %} {{- block('element') -}} {% endfor %} {%- endif -%}
    {%- endblock %} --DATA-- return [ 'item' => [ 'children' => [ null, null, ] ] ] --EXPECT-- Element:
    Element:
    Element:
    ================================================ FILE: tests/Fixtures/tags/inheritance/block_expr2.test ================================================ --TEST-- block_expr2 --TEMPLATE-- {% extends "base2.twig" %} {% block element -%} Element: {{- parent() -}} {% endblock %} --TEMPLATE(base2.twig)-- {% extends "base.twig" %} --TEMPLATE(base.twig)-- {% block element -%}
    {%- if item.children is defined %} {%- for item in item.children %} {{- block('element') -}} {% endfor %} {%- endif -%}
    {%- endblock %} --DATA-- return [ 'item' => [ 'children' => [ null, null, ] ] ] --EXPECT-- Element:
    Element:
    Element:
    ================================================ FILE: tests/Fixtures/tags/inheritance/capturing_block.test ================================================ --TEST-- capturing "block" tag with "extends" tag --TEMPLATE-- {% extends "layout.twig" %} {% set foo %} {%- block content %}FOO{% endblock %} {% endset %} {% block content1 %}BAR{{ foo }}{% endblock %} --TEMPLATE(layout.twig)-- {% block content %}{% endblock %} {% block content1 %}{% endblock %} --DATA-- return [] --EXPECT-- FOOBARFOO ================================================ FILE: tests/Fixtures/tags/inheritance/conditional.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends standalone ? foo : 'bar.twig' %} {% block content %}{{ parent() }}FOO{% endblock %} --TEMPLATE(foo.twig)-- {% block content %}FOO{% endblock %} --TEMPLATE(bar.twig)-- {% block content %}BAR{% endblock %} --DATA-- return ['foo' => 'foo.twig', 'standalone' => true] --EXPECT-- FOOFOO ================================================ FILE: tests/Fixtures/tags/inheritance/conditional_block.test ================================================ --TEST-- conditional "block" tag with "extends" tag --TEMPLATE-- {% extends "layout.twig" %} {% if false %} {% block content %}FOO{% endblock %} {% endif %} --TEMPLATE(layout.twig)-- {% block content %}{% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: A block definition cannot be nested under non-capturing nodes in "index.twig" at line 5. ================================================ FILE: tests/Fixtures/tags/inheritance/conditional_block_nested.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/inheritance/dynamic.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends foo %} {% block content %} FOO {% endblock %} --TEMPLATE(foo.twig)-- {% block content %}{% endblock %} --DATA-- return ['foo' => 'foo.twig'] --EXPECT-- FOO ================================================ FILE: tests/Fixtures/tags/inheritance/dynamic_parent_from_include.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/inheritance/empty.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "foo.twig" %} --TEMPLATE(foo.twig)-- {% block content %}FOO{% endblock %} --DATA-- return [] --EXPECT-- FOO ================================================ FILE: tests/Fixtures/tags/inheritance/extends_as_array.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends ["foo.twig", "bar.twig"] %} --TEMPLATE(bar.twig)-- {% block content %} foo {% endblock %} --DATA-- return [] --EXPECT-- foo ================================================ FILE: tests/Fixtures/tags/inheritance/extends_as_array_with_empty_name.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends ["", "bar.twig"] %} --TEMPLATE(bar.twig)-- {% block content %} foo {% endblock %} --DATA-- return [] --EXPECT-- foo ================================================ FILE: tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/inheritance/extends_in_block.test ================================================ --TEST-- "extends" tag in a block --TEMPLATE-- {% block foo %} {% extends "foo.twig" %} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Cannot use "extend" in a block in "index.twig" at line 3. ================================================ FILE: tests/Fixtures/tags/inheritance/extends_in_macro.test ================================================ --TEST-- "extends" tag in a macro --TEMPLATE-- {% macro foo() %} {% extends "foo.twig" %} {% endmacro %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Cannot use "extend" in a macro in "index.twig" at line 3. ================================================ FILE: tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test ================================================ --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 ================================================ FILE: tests/Fixtures/tags/inheritance/multiple.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "layout.twig" %}{% block content %}{{ parent() }}index {% endblock %} --TEMPLATE(layout.twig)-- {% extends "base.twig" %}{% block content %}{{ parent() }}layout {% endblock %} --TEMPLATE(base.twig)-- {% block content %}base {% endblock %} --DATA-- return [] --EXPECT-- base layout index ================================================ FILE: tests/Fixtures/tags/inheritance/multiple_dynamic.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% set foo = 1 %} {{ include('parent.twig') }} {{ include('parent.twig') }} {% set foo = 2 %} {{ include('parent.twig') }} --TEMPLATE(parent.twig)-- {% extends foo~'_parent.twig' %}{% block content %}{{ parent() }} parent{% endblock %} --TEMPLATE(1_parent.twig)-- {% block content %}1{% endblock %} --TEMPLATE(2_parent.twig)-- {% block content %}2{% endblock %} --DATA-- return [] --EXPECT-- 1 parent 1 parent 2 parent ================================================ FILE: tests/Fixtures/tags/inheritance/nested_blocks.test ================================================ --TEST-- "block" tag --TEMPLATE-- {% extends "foo.twig" %} {% block content %} {% block subcontent %} {% block subsubcontent %} SUBSUBCONTENT {% endblock %} {% endblock %} {% endblock %} --TEMPLATE(foo.twig)-- {% block content %} {% block subcontent %} SUBCONTENT {% endblock %} {% endblock %} --DATA-- return [] --EXPECT-- SUBSUBCONTENT ================================================ FILE: tests/Fixtures/tags/inheritance/nested_blocks_parent_only.test ================================================ --TEST-- "block" tag --TEMPLATE-- {% block content %} CONTENT {%- block subcontent -%} SUBCONTENT {%- endblock -%} ENDCONTENT {% endblock %} --TEMPLATE(foo.twig)-- --DATA-- return [] --EXPECT-- CONTENTSUBCONTENTENDCONTENT ================================================ FILE: tests/Fixtures/tags/inheritance/nested_inheritance.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "layout.twig" %} {% block inside %}INSIDE{% endblock inside %} --TEMPLATE(layout.twig)-- {% extends "base.twig" %} {% block body %} {% block inside '' %} {% endblock body %} --TEMPLATE(base.twig)-- {% block body '' %} --DATA-- return [] --EXPECT-- INSIDE ================================================ FILE: tests/Fixtures/tags/inheritance/parent.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "foo.twig" %} {% block content %}{{ parent() }}FOO{{ parent() }}{% endblock %} --TEMPLATE(foo.twig)-- {% block content %}BAR{% endblock %} --DATA-- return [] --EXPECT-- BARFOOBAR ================================================ FILE: tests/Fixtures/tags/inheritance/parent_as_template_wrapper.test ================================================ --TEST-- "extends" tag with a parent as a Twig\TemplateWrapper instance --TEMPLATE-- {% extends foo %} {% block content %}New{% endblock %} --TEMPLATE(foo.twig)-- {% block content %}Default{% endblock %} --DATA-- return ['foo' => $twig->load('foo.twig')] --EXPECT-- New ================================================ FILE: tests/Fixtures/tags/inheritance/parent_change.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends foo ? 'foo.twig' : 'bar.twig' %} --TEMPLATE(foo.twig)-- FOO --TEMPLATE(bar.twig)-- BAR --DATA-- return ['foo' => true] --EXPECT-- FOO --DATA-- return ['foo' => false] --EXPECT-- BAR ================================================ FILE: tests/Fixtures/tags/inheritance/parent_isolation.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "base.twig" %} {% block content %}{% include "included.twig" %}{% endblock %} {% block footer %}Footer{% endblock %} --TEMPLATE(included.twig)-- {% extends "base.twig" %} {% block content %}Included Content{% endblock %} --TEMPLATE(base.twig)-- {% block content %}Default Content{% endblock %} {% block footer %}Default Footer{% endblock %} --DATA-- return [] --EXPECT-- Included Content Default Footer Footer ================================================ FILE: tests/Fixtures/tags/inheritance/parent_nested.test ================================================ --TEST-- "extends" tag --TEMPLATE-- {% extends "foo.twig" %} {% block content %} {% block inside %} INSIDE OVERRIDDEN {% endblock %} BEFORE {{ parent() }} AFTER {% endblock %} --TEMPLATE(foo.twig)-- {% block content %} BAR {% endblock %} --DATA-- return [] --EXPECT-- INSIDE OVERRIDDEN BEFORE BAR AFTER ================================================ FILE: tests/Fixtures/tags/inheritance/parent_without_extends.test ================================================ --TEST-- "parent" tag --TEMPLATE-- {% block content %} {{ parent() }} {% endblock %} --EXCEPTION-- 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. ================================================ FILE: tests/Fixtures/tags/inheritance/parent_without_extends_but_traits.test ================================================ --TEST-- "parent" tag --TEMPLATE-- {% use 'foo.twig' %} {% block content %} {{ parent() }} {% endblock %} --TEMPLATE(foo.twig)-- {% block content %}BAR{% endblock %} --DATA-- return [] --EXPECT-- BAR ================================================ FILE: tests/Fixtures/tags/inheritance/template_instance.test ================================================ --TEST-- "extends" tag accepts Twig\Template instance --TEMPLATE-- {% extends foo %} {% block content %} {{ parent() }}FOO {% endblock %} --TEMPLATE(foo.twig)-- {% block content %}BAR{% endblock %} --DATA-- return ['foo' => $twig->load('foo.twig')] --EXPECT-- BARFOO ================================================ FILE: tests/Fixtures/tags/inheritance/use.test ================================================ --TEST-- "parent" function --TEMPLATE-- {% extends "parent.twig" %} {% use "use1.twig" %} {% use "use2.twig" %} {% block content_parent %} {{ parent() }} {% endblock %} {% block content_use1 %} {{ parent() }} {% endblock %} {% block content_use2 %} {{ parent() }} {% endblock %} {% block content %} {{ block('content_use1_only') }} {{ block('content_use2_only') }} {% endblock %} --TEMPLATE(parent.twig)-- {% block content_parent 'content_parent' %} {% block content_use1 'content_parent' %} {% block content_use2 'content_parent' %} {% block content '' %} --TEMPLATE(use1.twig)-- {% block content_use1 'content_use1' %} {% block content_use2 'content_use1' %} {% block content_use1_only 'content_use1_only' %} --TEMPLATE(use2.twig)-- {% block content_use2 'content_use2' %} {% block content_use2_only 'content_use2_only' %} --DATA-- return [] --EXPECT-- content_parent content_use1 content_use2 content_use1_only content_use2_only ================================================ FILE: tests/Fixtures/tags/macro/argument_reserved_names.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/macro/auto_import.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {{ _self.hello('Fabien') }} {% macro hello(name) -%} Hello {{ _self.up(name) }} {% endmacro %} {% macro up(name) -%} {{ name|upper }} {% endmacro %} --DATA-- return [] --EXPECT-- Hello FABIEN ================================================ FILE: tests/Fixtures/tags/macro/auto_import_blocks.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% block content %} {{ _self.hello('Fabien') }} {% endblock %} {% macro hello(name) -%} Hello {{ _self.up(name) }} {% endmacro %} {% macro up(name) -%} {{ name|upper }} {% endmacro %} --DATA-- return [] --EXPECT-- Hello FABIEN ================================================ FILE: tests/Fixtures/tags/macro/auto_import_without_blocks.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import 'macros' as macro %} {{ macro.foo() }} --TEMPLATE(macros)-- {% macro foo() %} foo {{- _self.bar() }} {% endmacro %} {% macro bar() -%} bar {% endmacro %} --DATA-- return [] --EXPECT-- foobar ================================================ FILE: tests/Fixtures/tags/macro/basic.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import _self as macros %} {{ macros.input('username') }} {{ macros.input('password', null, 'password', 1) }} {% macro input(name, value, type, size) %} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/colon_not_supported_as_default_separator.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/macro/endmacro_name.test ================================================ --TEST-- "macro" tag supports name for endmacro --TEMPLATE-- {% import _self as macros %} {{ macros.foo() }} {{ macros.bar() }} {% macro foo() %}foo{% endmacro %} {% macro bar() %}bar{% endmacro bar %} --DATA-- return [] --EXPECT-- foo bar ================================================ FILE: tests/Fixtures/tags/macro/external.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import 'forms.twig' as forms %} {{ forms.input('username') }} {{ forms.input('password', null, 'password', 1) }} --TEMPLATE(forms.twig)-- {% macro input(name, value, type, size) %} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/from.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% from 'forms.twig' import foo %} {% from 'forms.twig' import foo as foobar, bar %} {{ foo('foo') }} {{ foobar('foo') }} {{ bar('foo') }} --TEMPLATE(forms.twig)-- {% macro foo(name) %}foo{{ name }}{% endmacro %} {% macro bar(name) %}bar{{ name }}{% endmacro %} --DATA-- return [] --EXPECT-- foofoo foofoo barfoo ================================================ FILE: tests/Fixtures/tags/macro/from_embed_with_global_macro.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% from _self import input %} {% embed 'embed' %} {% block foo %} {{ input("username") }} {% endblock %} {% endembed %} {% macro input(name) -%} {% endmacro %} --TEMPLATE(embed)-- {% block foo %} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unknown "input" function in "index.twig" at line 6. ================================================ FILE: tests/Fixtures/tags/macro/from_in_block_is_local.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% block foo %} {%- from _self import input as linput %} {% endblock %} {% block bar %} {{- linput('username') }} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unknown "linput" function in "index.twig" at line 7. ================================================ FILE: tests/Fixtures/tags/macro/from_local_override.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {%- from _self import input %} {% block foo %} {%- from "macros" import input %} {{- input('username') }} {% endblock %} {% block bar %} {{- input('username') }} {% endblock %} {% macro input(name) -%} {% endmacro %} --TEMPLATE(macros)-- {% macro input(name) %} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/from_macro_in_a_macro.test ================================================ --TEST-- "from" tag with syntax error --TEMPLATE-- {% from _self import another, foo %} {{ foo() }} {% macro foo() %} {{ another() }} {% endmacro %} {% macro another() %} OK {% endmacro %} --DATA-- return [] --EXPECT-- OK ================================================ FILE: tests/Fixtures/tags/macro/from_macros_in_parent.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% from "macros" import hello %} {{ hello() }} --TEMPLATE(macros)-- {% extends "parent" %} --TEMPLATE(parent)-- {% macro hello() %} Test {% endmacro %} --DATA-- return [] --EXPECT-- Test ================================================ FILE: tests/Fixtures/tags/macro/from_nested_blocks.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% block foo %} {%- from _self import input as linput %} {% block bar %} {{- linput('username') }} {% endblock %} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unknown "linput" function in "index.twig" at line 6. ================================================ FILE: tests/Fixtures/tags/macro/from_nested_blocks_with_global_macro.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {%- from _self import input %} {% block foo %} {% block bar %} {{- input('username') }} {% endblock %} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/from_recursive.test ================================================ --TEST-- "import" tag --TEMPLATE-- {% from _self import recursive_macro %} {{ recursive_macro(10) }} {% macro recursive_macro(n) %} {% if n > 0 %} {{- recursive_macro(n - 1) -}} {% endif %} {{- n }} {% endmacro %} --DATA-- return [] --EXPECT-- 0 1 2 3 4 5 6 7 8 9 10 ================================================ FILE: tests/Fixtures/tags/macro/from_reserved_names.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/macro/from_self_parent.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% extends "parent" %} {% block test %} {{ _self.hello() }} {% endblock test %} --TEMPLATE(parent)-- {% block test %} Hello {% endblock test %} {% macro hello() %} Test {% endmacro %} --DATA-- return [] --EXPECT-- Test ================================================ FILE: tests/Fixtures/tags/macro/from_syntax_error.test ================================================ --TEST-- "from" tag with syntax error --TEMPLATE-- {% from 'forms.twig' %} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unexpected token "end of statement block" ("name" expected with value "import") in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/tags/macro/global.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% from 'forms.twig' import foo %} {{ foo('foo') }} {{ foo() }} --TEMPLATE(forms.twig)-- {% macro foo(name) %}{{ name|default('foo') }}{{ global }}{% endmacro %} --DATA-- return [] --EXPECT-- fooglobal fooglobal ================================================ FILE: tests/Fixtures/tags/macro/import_and_blocks.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import _self as macros %} {% from _self import input %} {% block foo %} {{- macros.input('username') }} {{- input('username') }} {%- import _self as lmacros %} {%- from _self import input as linput %} {{- lmacros.input('username') }} {{- linput('username') }} {% endblock %} {% block bar %} {{- macros.input('username') }} {{- input('username') }} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/import_embed_with_global_macro.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import _self as macros %} {% embed 'embed' %} {% block foo %} {{ macros.input("username") }} {% endblock %} {% endembed %} {% macro input(name) -%} {% endmacro %} --TEMPLATE(embed)-- {% block foo %} {% endblock %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "macros" does not exist in "index.twig" at line 6. ================================================ FILE: tests/Fixtures/tags/macro/import_from_string_template.test ================================================ --TEST-- "import" tag with a template as string --TEMPLATE-- {% import template_from_string("{% macro test() %}ok{% endmacro %}") as m %} {{ m.test() }} --TEMPLATE(forms.twig)-- --DATA-- return [] --EXPECT-- ok ================================================ FILE: tests/Fixtures/tags/macro/import_in_block_is_local.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% block foo %} {%- import _self as lmacros %} {% endblock %} {% block bar %} {{- lmacros.input('username') }} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "lmacros" does not exist in "index.twig" at line 7. ================================================ FILE: tests/Fixtures/tags/macro/import_local_override.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {%- import _self as macros %} {% block foo %} {%- import "macros" as macros %} {{- macros.input('username') }} {% endblock %} {% block bar %} {{- macros.input('username') }} {% endblock %} {% macro input(name) -%} {% endmacro %} --TEMPLATE(macros)-- {% macro input(name) %} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/import_macro_in_a_macro.test ================================================ --TEST-- "import" tag with syntax error --TEMPLATE-- {% import _self as foo %} {{ foo.foo() }} {% macro foo() %} {{ foo.another() }} {% endmacro %} {% macro another() %} OK {% endmacro %} --DATA-- return [] --EXPECT-- OK ================================================ FILE: tests/Fixtures/tags/macro/import_macros_in_parent.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import "macros" as m %} {{ m.hello() }} --TEMPLATE(macros)-- {% extends "parent" %} --TEMPLATE(parent)-- {% macro hello() %} Test {% endmacro %} --DATA-- return [] --EXPECT-- Test ================================================ FILE: tests/Fixtures/tags/macro/import_nested_blocks.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% block foo %} {%- import _self as lmacros %} {% block bar %} {{- lmacros.input('username') }} {% endblock %} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXCEPTION-- Twig\Error\RuntimeError: Variable "lmacros" does not exist in "index.twig" at line 6. ================================================ FILE: tests/Fixtures/tags/macro/import_nested_blocks_with_global_macro.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {%- import _self as macros %} {% block foo %} {% block bar %} {{- macros.input('username') }} {% endblock %} {% endblock %} {% macro input(name) -%} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/import_reserved_names.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/macro/import_same_parent_and_child.test ================================================ --TEST-- "import" tag --TEMPLATE-- {% extends "parent" %} {% macro anotherThing() -%} Do it too {% endmacro %} {% import _self as macros %} {% block content %} {{ parent() }} {{ macros.anotherThing() }} {% endblock %} --TEMPLATE(parent)-- {% macro thing() %} Do it {% endmacro %} {% import _self as macros %} {% block content %} {{ macros.thing() }} {% endblock %} --DATA-- return [] --EXPECT-- Do it Do it too ================================================ FILE: tests/Fixtures/tags/macro/import_self_parent.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% extends "parent" %} {% import _self as me %} {% block test %} {{ me.hello() }} {% endblock test %} --TEMPLATE(parent)-- {% import _self as me %} {% block test %} Hello {% endblock test %} {% macro hello() %} Test {% endmacro %} --DATA-- return [] --EXPECT-- Test ================================================ FILE: tests/Fixtures/tags/macro/import_syntax_error.test ================================================ --TEST-- "import" tag with reserved name --TEMPLATE-- {% import 'forms.twig' %} {{ macros.parent() }} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: Unexpected token "end of statement block" ("name" expected with value "as") in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/tags/macro/named_arguments.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import _self as forms %} {{ forms.input(size: 10, name: 'username') }} {% macro input(name, value, type, size) %} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/self_import.test ================================================ --TEST-- "macro" tag --TEMPLATE-- {% import _self as forms %} {{ forms.input('username') }} {{ forms.input('password', null, 'password', 1) }} {% macro input(name, value, type, size) %} {% endmacro %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/macro/special_chars.test ================================================ --TEST-- "§" as a macro name --TEMPLATE-- {% import _self as macros %} {{ macros.§('foo') }} {% macro §(foo) %} §{{ foo }}§ {% endmacro %} --DATA-- return [] --EXPECT-- §foo§ ================================================ FILE: tests/Fixtures/tags/macro/super_globals.test ================================================ --TEST-- Super globals as macro arguments --TEMPLATE-- {% import _self as macros %} {{ macros.foo('foo') }} {% macro foo(GET) %} {{ GET }} {% endmacro %} --DATA-- return [] --EXPECT-- foo ================================================ FILE: tests/Fixtures/tags/sandbox/array.legacy.test ================================================ --TEST-- sandbox tag --DEPRECATION-- Since twig/twig 3.15: The "sandbox" tag is deprecated in "index.twig" at line 2. --TEMPLATE-- {%- sandbox %} {%- include "foo.twig" %} {%- endsandbox %} --TEMPLATE(foo.twig)-- {{ [a][0] }} {{ dump([a][0]) }} --DATA-- return ['a' => 'b'] --CONFIG-- return ['autoescape' => false, 'debug' => true] --EXPECT-- b string(1) "b" ================================================ FILE: tests/Fixtures/tags/sandbox/not_valid1.legacy.test ================================================ --TEST-- sandbox tag --TEMPLATE-- {%- sandbox %} {%- include "foo.twig" %} a {%- endsandbox %} --TEMPLATE(foo.twig)-- foo --EXCEPTION-- Twig\Error\SyntaxError: Only "include" tags are allowed within a "sandbox" section in "index.twig" at line 4. ================================================ FILE: tests/Fixtures/tags/sandbox/not_valid2.legacy.test ================================================ --TEST-- sandbox tag --TEMPLATE-- {%- sandbox %} {%- include "foo.twig" %} {% if 1 %} {%- include "foo.twig" %} {% endif %} {%- endsandbox %} --TEMPLATE(foo.twig)-- foo --EXCEPTION-- Twig\Error\SyntaxError: Only "include" tags are allowed within a "sandbox" section in "index.twig" at line 5. ================================================ FILE: tests/Fixtures/tags/sandbox/simple.legacy.test ================================================ --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" %} {%- endsandbox %} {%- sandbox %} {%- include "foo.twig" %} {%- include "foo.twig" %} {%- endsandbox %} {%- sandbox %}{% include "foo.twig" %}{% endsandbox %} --TEMPLATE(foo.twig)-- foo --DATA-- return [] --EXPECT-- foo foo foo foo ================================================ FILE: tests/Fixtures/tags/set/basic.test ================================================ --TEST-- "set" tag --TEMPLATE-- {% set foo = 'foo' %} {% set bar = 'foo
    ' %} {{ foo }} {{ bar }} {% set foo, bar = 'foo', 'bar' %} {{ foo }}{{ bar }} --DATA-- return [] --EXPECT-- foo foo<br /> foobar ================================================ FILE: tests/Fixtures/tags/set/capture-empty.test ================================================ --TEST-- "set" tag block empty capture --TEMPLATE-- {% set foo %}{% endset %} {% if foo %}FAIL{% endif %} --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/set/capture.test ================================================ --TEST-- "set" tag block capture --TEMPLATE-- {% set foo %}f
    o
    o{% endset %} {{ foo }} --DATA-- return [] --EXPECT-- f
    o
    o ================================================ FILE: tests/Fixtures/tags/set/capture_scope.test ================================================ --TEST-- "set" tag block capture --TEMPLATE-- {% set foo %}{{ foo }}{% endset %} {{ foo }} --DATA-- return ['foo' => 'foo'] --EXPECT-- foo ================================================ FILE: tests/Fixtures/tags/set/expression.test ================================================ --TEST-- "set" tag --TEMPLATE-- {% set foo, bar = 'foo' ~ 'bar', 'bar' ~ 'foo' %} {{ foo }} {{ bar }} --DATA-- return [] --EXPECT-- foobar barfoo ================================================ FILE: tests/Fixtures/tags/set/inheritance.test ================================================ --TEST-- "set" tag with inheritance --TEMPLATE-- {% extends "layout.twig" %} {% set bar %}bar{% endset %} {% block var_from_child %} {{- bar -}} {% endblock %} --TEMPLATE(layout.twig)-- {% set foo %}foo{% endset %} {% block var_from_layout %} {{- foo -}} {% endblock %} {% block var_from_child %} {% endblock %} --DATA-- return [] --EXPECT-- foo bar ================================================ FILE: tests/Fixtures/tags/set/inheritance_overriding.test ================================================ --TEST-- "set" tag with inheritance --TEMPLATE-- {% extends "layout.twig" %} {% set foo %}bar{% endset %} {% block var_from_child %} {{- foo -}} {% endblock %} --TEMPLATE(layout.twig)-- {% set foo %}foo{% endset %} {% block var_from_layout %} {{- foo -}} {% endblock %} {% block var_from_child %} {% endblock %} --DATA-- return [] --EXPECT-- foo foo ================================================ FILE: tests/Fixtures/tags/set/mutating.test ================================================ --TEST-- "set" tag --TEMPLATE-- {% set foo = "foo" %} {% set bar %} {%- set foo = "bar" -%} bar {% endset %} {{ foo }} {{ bar }} --DATA-- return [] --EXPECT-- bar bar ================================================ FILE: tests/Fixtures/tags/set/reserved_names.test ================================================ --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. ================================================ FILE: tests/Fixtures/tags/special_chars.test ================================================ --TEST-- "§" custom tag --TEMPLATE-- {% § %} --DATA-- return [] --EXPECT-- § ================================================ FILE: tests/Fixtures/tags/use/aliases.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "blocks.twig" with content as foo %} {{ block('foo') }} --TEMPLATE(blocks.twig)-- {% block content 'foo' %} --DATA-- return [] --EXPECT-- foo ================================================ FILE: tests/Fixtures/tags/use/basic.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "blocks.twig" %} {{ block('content') }} --TEMPLATE(blocks.twig)-- {% block content 'foo' %} --DATA-- return [] --EXPECT-- foo ================================================ FILE: tests/Fixtures/tags/use/deep.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "foo.twig" %} {{ block('content') }} {{ block('foo') }} {{ block('bar') }} --TEMPLATE(foo.twig)-- {% use "bar.twig" %} {% block content 'foo' %} {% block foo 'foo' %} --TEMPLATE(bar.twig)-- {% block content 'bar' %} {% block bar 'bar' %} --DATA-- return [] --EXPECT-- foo foo bar ================================================ FILE: tests/Fixtures/tags/use/deep_empty.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "foo.twig" %} --TEMPLATE(foo.twig)-- {% use "bar.twig" %} --TEMPLATE(bar.twig)-- --DATA-- return [] --EXPECT-- ================================================ FILE: tests/Fixtures/tags/use/inheritance.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "parent.twig" %} {{ block('container') }} --TEMPLATE(parent.twig)-- {% use "ancestor.twig" %} {% block sub_container %}
    overridden sub_container
    {% endblock %} --TEMPLATE(ancestor.twig)-- {% block container %}
    {{ block('sub_container') }}
    {% endblock %} {% block sub_container %}
    sub_container
    {% endblock %} --DATA-- return [] --EXPECT--
    overridden sub_container
    ================================================ FILE: tests/Fixtures/tags/use/inheritance2.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "ancestor.twig" %} {% use "parent.twig" %} {{ block('container') }} --TEMPLATE(parent.twig)-- {% block sub_container %}
    overridden sub_container
    {% endblock %} --TEMPLATE(ancestor.twig)-- {% block container %}
    {{ block('sub_container') }}
    {% endblock %} {% block sub_container %}
    sub_container
    {% endblock %} --DATA-- return [] --EXPECT--
    overridden sub_container
    ================================================ FILE: tests/Fixtures/tags/use/multiple.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "foo.twig" %} {% use "bar.twig" %} {{ block('content') }} {{ block('foo') }} {{ block('bar') }} --TEMPLATE(foo.twig)-- {% block content 'foo' %} {% block foo 'foo' %} --TEMPLATE(bar.twig)-- {% block content 'bar' %} {% block bar 'bar' %} --DATA-- return [] --EXPECT-- bar foo bar ================================================ FILE: tests/Fixtures/tags/use/multiple_aliases.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use "foo.twig" with content as foo_content %} {% use "bar.twig" %} {{ block('content') }} {{ block('foo') }} {{ block('bar') }} {{ block('foo_content') }} --TEMPLATE(foo.twig)-- {% block content 'foo' %} {% block foo 'foo' %} --TEMPLATE(bar.twig)-- {% block content 'bar' %} {% block bar 'bar' %} --DATA-- return [] --EXPECT-- bar foo bar foo ================================================ FILE: tests/Fixtures/tags/use/parent_block.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use 'file2.html.twig' with foobar as base_base_foobar %} {% block foobar %} {{- block('base_base_foobar') -}} Content of block (second override) {% endblock foobar %} --TEMPLATE(file2.html.twig)-- {% use 'file1.html.twig' with foobar as base_foobar %} {% block foobar %} {{- block('base_foobar') -}} Content of block (first override) {% endblock foobar %} --TEMPLATE(file1.html.twig)-- {% block foobar -%} Content of block {% endblock foobar %} --DATA-- return [] --EXPECT-- Content of block Content of block (first override) Content of block (second override) ================================================ FILE: tests/Fixtures/tags/use/parent_block2.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use 'file2.html.twig'%} {% block foobar %} {{- parent() -}} Content of block (second override) {% endblock foobar %} --TEMPLATE(file2.html.twig)-- {% use 'file1.html.twig' %} {% block foobar %} {{- parent() -}} Content of block (first override) {% endblock foobar %} --TEMPLATE(file1.html.twig)-- {% block foobar -%} Content of block {% endblock foobar %} --DATA-- return [] --EXPECT-- Content of block Content of block (first override) Content of block (second override) ================================================ FILE: tests/Fixtures/tags/use/parent_block3.test ================================================ --TEST-- "use" tag --TEMPLATE-- {% use 'file2.html.twig' %} {% use 'file1.html.twig' with foo %} {% block foo %} {{- parent() -}} Content of foo (second override) {% endblock foo %} {% block bar %} {{- parent() -}} Content of bar (second override) {% endblock bar %} --TEMPLATE(file2.html.twig)-- {% use 'file1.html.twig' %} {% block foo %} {{- parent() -}} Content of foo (first override) {% endblock foo %} {% block bar %} {{- parent() -}} Content of bar (first override) {% endblock bar %} --TEMPLATE(file1.html.twig)-- {% block foo -%} Content of foo {% endblock foo %} {% block bar -%} Content of bar {% endblock bar %} --DATA-- return [] --EXPECT-- Content of foo Content of foo (first override) Content of foo (second override) Content of bar Content of bar (second override) ================================================ FILE: tests/Fixtures/tags/use/use_aliased_block_overridden.test ================================================ --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+ ================================================ FILE: tests/Fixtures/tags/use/use_with_parent.test ================================================ --TEST-- "use" tag with a parent block --TEMPLATE-- {% extends "parent.twig" %} {% use 'blocks.twig' %} {% block body %} {{ parent() -}} CHILD {{ block('content') }} {% endblock %} --TEMPLATE(parent.twig)-- {% block body %} PARENT {% endblock %} --TEMPLATE(blocks.twig)-- {% block content 'BLOCK' %} --DATA-- return [] --EXPECT-- PARENT CHILD BLOCK ================================================ FILE: tests/Fixtures/tags/verbatim/basic.test ================================================ --TEST-- "verbatim" tag --TEMPLATE-- {% verbatim %} {{ foo }} {% endverbatim %} --DATA-- return [] --EXPECT-- {{ foo }} ================================================ FILE: tests/Fixtures/tags/verbatim/whitespace_control.test ================================================ --TEST-- "verbatim" tag --TEMPLATE-- 1*** {%- verbatim %} {{ 'bla' }} {% endverbatim %} 1*** 2*** {%- verbatim -%} {{ 'bla' }} {% endverbatim %} 2*** 3*** {%- verbatim -%} {{ 'bla' }} {% endverbatim -%} 3*** 4*** {%- verbatim -%} {{ 'bla' }} {%- endverbatim %} 4*** 5*** {%- verbatim -%} {{ 'bla' }} {%- endverbatim -%} 5*** --DATA-- return [] --EXPECT-- 1*** {{ 'bla' }} 1*** 2***{{ 'bla' }} 2*** 3***{{ 'bla' }} 3*** 4***{{ 'bla' }} 4*** 5***{{ 'bla' }}5*** ================================================ FILE: tests/Fixtures/tags/with/basic.test ================================================ --TEST-- "with" tag --TEMPLATE-- {% with %} {% set bar = 'BAZ' %} {{ foo }}{{ bar }} {% endwith %} {{ foo }}{{ bar }} --DATA-- return ['foo' => 'foo', 'bar' => 'bar'] --EXPECT-- fooBAZ foobar ================================================ FILE: tests/Fixtures/tags/with/expression.test ================================================ --TEST-- "with" tag with expression --TEMPLATE-- {% with {foo: 'foo', bar: 'BAZ'} %} {{ foo }}{{ bar }} {% endwith %} --DATA-- return ['foo' => 'baz'] --EXPECT-- fooBAZ ================================================ FILE: tests/Fixtures/tags/with/globals.test ================================================ --TEST-- "with" tag --TEMPLATE-- {% with [] only %} {{ global }} {% endwith %} --DATA-- return [] --EXPECT-- global ================================================ FILE: tests/Fixtures/tags/with/iterable.test ================================================ --TEST-- "with" tag with an iterable expression --TEMPLATE-- {% with vars %} {{ foo }}{{ bar }} {% endwith %} --DATA-- return ['vars' => new ArrayObject(['foo' => 'baz', 'bar' => 'qux'])] --EXPECT-- bazqux ================================================ FILE: tests/Fixtures/tags/with/nested.test ================================================ --TEST-- nested "with" tags --TEMPLATE-- {% set foo, bar = 'foo', 'bar' %} {% with {bar: 'BAZ'} %} {% with {foo: 'FOO'} %} {{ foo }}{{ bar }} {% endwith %} {% endwith %} {{ foo }}{{ bar }} --DATA-- return [] --EXPECT-- FOOBAZ foobar ================================================ FILE: tests/Fixtures/tags/with/with_no_mapping.test ================================================ --TEST-- "with" tag with an expression that is not a mapping --TEMPLATE-- {% with vars %} {{ foo }}{{ bar }} {% endwith %} --DATA-- return ['vars' => 'no-mapping'] --EXCEPTION-- Twig\Error\RuntimeError: Variables passed to the "with" tag must be a mapping in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/tags/with/with_only.test ================================================ --TEST-- "with" tag with expression and only --TEMPLATE-- {% with {foo: 'foo', bar: 'BAZ'} only %} {{ foo }}{{ bar }}{{ baz }} {% endwith %} --DATA-- return ['foo' => 'baz', 'baz' => 'baz'] --EXCEPTION-- Twig\Error\RuntimeError: Variable "baz" does not exist in "index.twig" at line 3. ================================================ FILE: tests/Fixtures/tests/array.test ================================================ --TEST-- array index test --TEMPLATE-- {% for key, value in days %} {{ key }} {% endfor %} --DATA-- return ['days' => [ 1 => ['money' => 9], 2 => ['money' => 21], 3 => ['money' => 38], 4 => ['money' => 6], 18 => ['money' => 6], 19 => ['money' => 3], 31 => ['money' => 11], ]] --EXPECT-- 1 2 3 4 18 19 31 ================================================ FILE: tests/Fixtures/tests/constant.test ================================================ --TEST-- "const" test --TEMPLATE-- {{ 8 is constant('E_NOTICE') ? 'ok' : 'no' }} {{ 'bar' is constant('Twig\\Tests\\TwigTestFoo::BAR_NAME') ? 'ok' : 'no' }} {{ value is constant('Twig\\Tests\\TwigTestFoo::BAR_NAME') ? 'ok' : 'no' }} {{ 2 is constant('ARRAY_AS_PROPS', object) ? 'ok' : 'no' }} --DATA-- return ['value' => 'bar', 'object' => new \ArrayObject(['hi'])] --EXPECT-- ok ok ok ok ================================================ FILE: tests/Fixtures/tests/defined.test ================================================ --TEST-- "defined" test --TEMPLATE-- {{ definedVar is defined ? 'ok' : 'ko' }} {{ definedVar is not defined ? 'ko' : 'ok' }} {{ undefinedVar is defined ? 'ko' : 'ok' }} {{ undefinedVar is not defined ? 'ok' : 'ko' }} {{ zeroVar is defined ? 'ok' : 'ko' }} {{ nullVar is defined ? 'ok' : 'ko' }} {{ nested.definedVar is defined ? 'ok' : 'ko' }} {{ nested['definedVar'] is defined ? 'ok' : 'ko' }} {{ nested.definedVar is not defined ? 'ko' : 'ok' }} {{ nested.undefinedVar is defined ? 'ko' : 'ok' }} {{ nested['undefinedVar'] is defined ? 'ko' : 'ok' }} {{ nested.undefinedVar is not defined ? 'ok' : 'ko' }} {{ nested.zeroVar is defined ? 'ok' : 'ko' }} {{ nested.nullVar is defined ? 'ok' : 'ko' }} {{ nested.definedArray.0 is defined ? 'ok' : 'ko' }} {{ nested['definedArray'][0] is defined ? 'ok' : 'ko' }} {{ object.foo is defined ? 'ok' : 'ko' }} {{ object.undefinedMethod is defined ? 'ko' : 'ok' }} {{ object.getFoo() is defined ? 'ok' : 'ko' }} {{ object.getFoo('a') is defined ? 'ok' : 'ko' }} {{ object.undefinedMethod() is defined ? 'ko' : 'ok' }} {{ object.undefinedMethod('a') is defined ? 'ko' : 'ok' }} {{ object.self.foo is defined ? 'ok' : 'ko' }} {{ object.self.undefinedMethod is defined ? 'ko' : 'ok' }} {{ object.undefinedMethod.self is defined ? 'ko' : 'ok' }} {{ 0 is defined ? 'ok' : 'ko' }} {{ "foo" is defined ? 'ok' : 'ko' }} {{ true is defined ? 'ok' : 'ko' }} {{ false is defined ? 'ok' : 'ko' }} {{ null is defined ? 'ok' : 'ko' }} {{ [1, 2] is defined ? 'ok' : 'ko' }} {{ { foo: "bar" } is defined ? 'ok' : 'ko' }} --DATA-- return [ 'definedVar' => 'defined', 'zeroVar' => 0, 'nullVar' => null, 'nested' => [ 'definedVar' => 'defined', 'zeroVar' => 0, 'nullVar' => null, 'definedArray' => [0], ], 'object' => new Twig\Tests\TwigTestFoo(), ] --EXPECT-- ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok --DATA-- return [ 'definedVar' => 'defined', 'zeroVar' => 0, 'nullVar' => null, 'nested' => [ 'definedVar' => 'defined', 'zeroVar' => 0, 'nullVar' => null, 'definedArray' => [0], ], 'object' => new Twig\Tests\TwigTestFoo(), ] --CONFIG-- return ['strict_variables' => false] --EXPECT-- ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ================================================ FILE: tests/Fixtures/tests/defined_for_attribute.legacy.test ================================================ --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 ================================================ FILE: tests/Fixtures/tests/defined_for_attribute.test ================================================ --TEST-- "defined" support for dynamic attribute --TEMPLATE-- {{ 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' => [ '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 ================================================ FILE: tests/Fixtures/tests/defined_for_blocks.test ================================================ --TEST-- "defined" support for blocks --TEMPLATE-- {% extends 'parent' %} {% block icon %}icon{% endblock %} {% block body %} {{ parent() }} {{ block('foo') is defined ? 'ok' : 'ko' }} {{ block('footer') is defined ? 'ok' : 'ko' }} {{ block('icon') is defined ? 'ok' : 'ko' }} {{ block('block1') is defined ? 'ok' : 'ko' }} {%- embed 'embed' %} {% block content %}content{% endblock %} {% endembed %} {% endblock %} {% use 'blocks' %} --TEMPLATE(parent)-- {% block body %} {{ block('icon') is defined ? 'ok' : 'ko' -}} {% endblock %} {% block footer %}{% endblock %} --TEMPLATE(embed)-- {{ block('icon') is defined ? 'ok' : 'ko' }} {{ block('content') is defined ? 'ok' : 'ko' }} {{ block('block1') is defined ? 'ok' : 'ko' }} --TEMPLATE(blocks)-- {% block block1 %}{%endblock %} --DATA-- return [] --EXPECT-- ok ko ok ok ok ko ok ko ================================================ FILE: tests/Fixtures/tests/defined_for_blocks_with_template.test ================================================ --TEST-- "defined" support for blocks with a template argument --TEMPLATE-- {{ block('foo', 'included.twig') is defined ? 'ok' : 'ko' }} {{ block('foo', included_loaded) is defined ? 'ok' : 'ko' }} {{ block('foo', included_loaded_internal) is defined ? 'ok' : 'ko' }} --TEMPLATE(included.twig)-- {% block foo %}FOO{% endblock %} --DATA-- return [ 'included_loaded' => $twig->load('included.twig'), 'included_loaded_internal' => $twig->load('included.twig'), ] --EXPECT-- ok ok ok ================================================ FILE: tests/Fixtures/tests/defined_for_constants.test ================================================ --TEST-- "defined" support for constants --TEMPLATE-- {{ constant('DATE_W3C') is defined ? 'ok' : 'ko' }} {{ constant('ARRAY_AS_PROPS', object) is defined ? 'ok' : 'ko' }} {{ constant('FOOBAR') is not defined ? 'ok' : 'ko' }} {{ constant('FOOBAR', object) is not defined ? 'ok' : 'ko' }} --DATA-- return ['expect' => DATE_W3C, 'object' => new \ArrayObject(['hi'])] --EXPECT-- ok ok ok ok ================================================ FILE: tests/Fixtures/tests/defined_for_macros.test ================================================ --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 %} {% 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' }} {{~ _self.hello is defined ? 'OK' : 'KO' }} {{~ _self.hello() is defined ? 'OK' : 'KO' }} {{~ _self.bar is not defined ? 'OK' : 'KO' }} {{~ _self.bar() is not defined ? 'OK' : 'KO' }} {{~ _self.lol is defined ? 'OK' : 'KO' }} {{~ _self.lol() is defined ? 'OK' : 'KO' }} {% endblock %} {% macro hello(name) %}{% endmacro %} --TEMPLATE(macros.twig)-- {% block content %} {% endblock %} {% 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 ================================================ FILE: tests/Fixtures/tests/defined_on_complex_expr.test ================================================ --TEST-- "defined" support for "complex" expressions --TEMPLATE-- {{ (1 + 2) is defined ? 'ok' : 'ko' }} --DATA-- return [] --EXCEPTION-- Twig\Error\SyntaxError: The "defined" test only works with simple variables in "index.twig" at line 2. ================================================ FILE: tests/Fixtures/tests/dynamic_test.test ================================================ --TEST-- dynamic test --TEMPLATE-- {{ 'bar' is test_bar ? '1' :'0' }} {{ 'foo' is test_foo ? '1' :'0' }} {{ 'bar' is test_foo ? '1' :'0' }} {{ 'foo' is test_bar ? '1' :'0' }} --DATA-- return [] --EXPECT-- 1 1 0 0 ================================================ FILE: tests/Fixtures/tests/empty.test ================================================ --TEST-- "empty" test --TEMPLATE-- {{ string_empty is empty ? 'ok' : 'ko' }} {{ string_zero is empty ? 'ko' : 'ok' }} {{ value_null is empty ? 'ok' : 'ko' }} {{ value_false is empty ? 'ok' : 'ko' }} {{ value_int_zero is empty ? 'ko' : 'ok' }} {{ array_empty is empty ? 'ok' : 'ko' }} {{ array_not_empty is empty ? 'ko' : 'ok' }} {{ magically_callable is empty ? 'ko' : 'ok' }} {{ countable_empty is empty ? 'ok' : 'ko' }} {{ countable_not_empty is empty ? 'ko' : 'ok' }} {{ tostring_empty is empty ? 'ok' : 'ko' }} {{ tostring_not_empty is empty ? 'ko' : 'ok' }} {{ markup_empty is empty ? 'ok' : 'ko' }} {{ markup_not_empty is empty ? 'ko' : 'ok' }} {{ iterator is empty ? 'ko' : 'ok' }} {{ empty_iterator is empty ? 'ok' : 'ko' }} {{ callback_iterator is empty ? 'ko' : 'ok' }} {{ empty_callback_iterator is empty ? 'ok' : 'ko' }} --DATA-- return [ 'string_empty' => '', 'string_zero' => '0', 'value_null' => null, 'value_false' => false, 'value_int_zero' => 0, 'array_empty' => [], 'array_not_empty' => [1, 2], 'magically_callable' => new \Twig\Tests\MagicCallStub(), 'countable_empty' => new \Twig\Tests\CountableStub(0), 'countable_not_empty' => new \Twig\Tests\CountableStub(2), 'tostring_empty' => new \Twig\Tests\ToStringStub(''), 'tostring_not_empty' => new \Twig\Tests\ToStringStub('0' /* edge case of using "0" as the string */), 'markup_empty' => new \Twig\Markup('', 'UTF-8'), 'markup_not_empty' => new \Twig\Markup('test', 'UTF-8'), 'iterator' => $iter = new \ArrayIterator(['bar', 'foo']), 'empty_iterator' => new \ArrayIterator(), 'callback_iterator' => new \CallbackFilterIterator($iter, function ($el) { return true; }), 'empty_callback_iterator' => new \CallbackFilterIterator($iter, function ($el) { return false; }), ] --EXPECT-- ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ok ================================================ FILE: tests/Fixtures/tests/even.test ================================================ --TEST-- "even" test --TEMPLATE-- {{ 1 is even ? 'ko' : 'ok' }} {{ 2 is even ? 'ok' : 'ko' }} {{ 1 is not even ? 'ok' : 'ko' }} {{ 2 is not even ? 'ko' : 'ok' }} --DATA-- return [] --EXPECT-- ok ok ok ok ================================================ FILE: tests/Fixtures/tests/in.test ================================================ --TEST-- Twig supports the in operator --TEMPLATE-- {{ bar in foo ? 'OK' : 'KO' }} {{ not (bar in foo) ? 'KO' : 'OK' }} {{ bar not in foo ? 'KO' : 'OK' }} {{ 'a' in bar ? 'OK' : 'KO' }} {{ 'c' not in bar ? 'OK' : 'KO' }} {{ '' in bar ? 'OK' : 'KO' }} {{ '' in '' ? 'OK' : 'KO' }} {{ '0' not in '' ? 'OK' : 'KO' }} {{ 'a' not in '0' ? 'OK' : 'KO' }} {{ '0' in '0' ? 'OK' : 'KO' }} {{ false in [0, 1] ? 'OK' : 'KO' }} {{ true in [0, 1] ? 'OK' : 'KO' }} {{ '0' in [0, 1] ? 'OK' : 'KO' }} {{ '0' in [1, 0] ? 'OK' : 'KO' }} {{ '' in [0, 1] ? 'KO' : 'OK' }} {{ '' in [1, 0] ? 'KO' : 'OK' }} {{ 0 in ['', 1] ? 'KO' : 'OK' }} {{ 0 in [1, ''] ? 'KO' : 'OK' }} {{ '' in 'foo' ? 'OK' : 'KO' }} {{ 0 in 'foo' ? 'KO' : 'OK' }} {{ false in 'foo' ? 'KO' : 'OK' }} {{ false in '100' ? 'KO' : 'OK' }} {{ true in '100' ? 'KO' : 'OK' }} {{ [] in [true, false] ? 'OK' : 'KO' }} {{ [] in [true, ''] ? 'KO' : 'OK' }} {{ [] in [true, []] ? 'OK' : 'KO' }} {{ resource ? 'OK' : 'KO' }} {{ resource in 'foo'~resource ? 'KO' : 'OK' }} {{ object in 'stdClass' ? 'KO' : 'OK' }} {{ [] in 'Array' ? 'KO' : 'OK' }} {{ dir_object in 'foo'~dir_object ? 'KO' : 'OK' }} {{ ''~resource in resource ? 'KO' : 'OK' }} {{ 'stdClass' in object ? 'KO' : 'OK' }} {{ 'Array' in [] ? 'KO' : 'OK' }} {{ ''~dir_object in dir_object ? 'KO' : 'OK' }} {{ resource in [''~resource] ? 'KO' : 'OK' }} {{ dir_object in [''~dir_object] ? 'KO' : 'OK' }} {{ 5 in 125 ? 'KO' : 'OK' }} {{ 5 in '125' ? 'OK' : 'KO' }} {{ '5' in 125 ? 'KO' : 'OK' }} {{ '5' in '125' ? 'OK' : 'KO' }} {{ 5.5 in 125.5 ? 'KO' : 'OK' }} {{ 5.5 in '125.5' ? 'OK' : 'KO' }} {{ '5.5' in 125.5 ? 'KO' : 'OK' }} {{ safe in ['foo', 'bar'] ? 'OK' : 'KO' }} {{ 'fo' in safe ? 'OK' : 'KO' }} {{ foo.not in ['not'] ? 'OK' : 'KO' }} {{ 'value'|not in ['not value'] ? 'OK' : 'KO' }} {{ foo.not not in ['not'] ? 'KO' : 'OK' }} {{ 'value'|not not in ['not value'] ? 'KO' : 'OK' }} {{ 'value'not in['not value'] ? 'OK' : 'KO' }} --DATA-- return ['bar' => 'bar', 'foo' => ['bar' => 'bar', 'not' => 'not'], 'dir_object' => new \SplFileInfo(__DIR__), 'object' => new \stdClass(), 'resource' => opendir(__DIR__), 'safe' => new \Twig\Markup('foo', 'UTF-8')] --EXPECT-- OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK OK ================================================ FILE: tests/Fixtures/tests/in_with_iterator.test ================================================ --TEST-- Twig supports the in operator when using iterators --TEMPLATE-- {{ foo in iter ? 'OK' : 'KO' }} --DATA-- $foo = new Twig\Tests\TwigTestFoo(); $bar = new Twig\Tests\TwigTestFoo(); $foo->position = $bar; $bar->position = $foo; return ['foo' => $foo, 'iter' => new \ArrayIterator([$bar, $foo])] --EXPECT-- OK ================================================ FILE: tests/Fixtures/tests/in_with_objects.test ================================================ --TEST-- Twig supports the in operator when using objects --TEMPLATE-- {% if object in object_list %} TRUE {% endif %} --DATA-- $foo = new Twig\Tests\TwigTestFoo(); $foo1 = new Twig\Tests\TwigTestFoo(); $foo->position = $foo1; $foo1->position = $foo; return [ 'object' => $foo, 'object_list' => [$foo1, $foo], ] --EXPECT-- TRUE ================================================ FILE: tests/Fixtures/tests/iterable.test ================================================ --TEST-- "iterable" test --TEMPLATE-- {{ foo is iterable ? 'ok' : 'ko' }} {{ traversable is iterable ? 'ok' : 'ko' }} {{ obj is iterable ? 'ok' : 'ko' }} {{ val is iterable ? 'ok' : 'ko' }} --DATA-- return [ 'foo' => [], 'traversable' => new \ArrayIterator([]), 'obj' => new \stdClass(), 'val' => 'test', ] --EXPECT-- ok ok ko ko ================================================ FILE: tests/Fixtures/tests/mapping.test ================================================ --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 ================================================ FILE: tests/Fixtures/tests/null_coalesce.legacy.test ================================================ --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 ================================================ FILE: tests/Fixtures/tests/null_coalesce.test ================================================ --TEST-- Twig supports the ?? operator --TEMPLATE-- {{ 'OK' ?? 'KO' }} {{ null ?? 'OK' }} {{ bar ?? 'KO' }} {{ baz ?? 'OK' }} {{ foo.bar ?? 'KO' }} {{ foo.missing ?? 'OK' }} {{ foo.bar.baz.missing ?? 'OK' }} {{ foo['bar'] ?? 'KO' }} {{ foo['missing'] ?? 'OK' }} {{ 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'], 'obj' => new Twig\Tests\TwigTestFoo(), 'tag' => '
    '] --EXPECT-- OK OK OK OK OK OK OK OK OK OK 3 6 OK <br> ================================================ FILE: tests/Fixtures/tests/null_coalesce_block.test ================================================ --TEST-- Twig supports the ?? operator with blocks --TEMPLATE-- {% block foo %}OK{% endblock %} {{ block('foo') ?? 'KO' }} --DATA-- return [] --EXPECT-- OKOK ================================================ FILE: tests/Fixtures/tests/odd.test ================================================ --TEST-- "odd" test --TEMPLATE-- {{ 1 is odd ? 'ok' : 'ko' }} {{ 2 is odd ? 'ko' : 'ok' }} {{ -1 is odd ? 'ok' : 'ko' }} --DATA-- return [] --EXPECT-- ok ok ok ================================================ FILE: tests/Fixtures/tests/sequence.test ================================================ --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 ================================================ FILE: tests/Fixtures/whitespace/trim_block.test ================================================ --TEST-- Whitespace trimming on tags. --TEMPLATE-- Trim on control tag: {% for i in range(1, 9) -%} {{ i }} {%- endfor %} Trim on output tag: {% for i in range(1, 9) %} {{- i -}} {% endfor %} Trim comments: {#- Invisible -#} After the comment. Trim leading space: {% if leading %} {{- leading }} {% endif %} {%- if leading %} {{- leading }} {%- endif %} Trim trailing space: {% if trailing -%} {{ trailing -}} {% endif -%} Combined: {%- if both -%}
    • {{- both -}}
    {%- endif -%} end --DATA-- return ['leading' => 'leading space', 'trailing' => 'trailing space', 'both' => 'both'] --EXPECT-- Trim on control tag: 123456789 Trim on output tag: 123456789 Trim comments:After the comment. Trim leading space: leading space leading space Trim trailing space: trailing spaceCombined:
    • both
    end ================================================ FILE: tests/Fixtures/whitespace/trim_delimiter_as_strings.test ================================================ --TEST-- Whitespace trimming as strings. --TEMPLATE-- {{ 5 * '{#-'|length }} {{ '{{-'|length * 5 + '{%-'|length }} --DATA-- return [] --EXPECT-- 15 18 ================================================ FILE: tests/Fixtures/whitespace/trim_left.test ================================================ --TEST-- Whitespace trimming on tags (left side). --TEMPLATE-- **{% if true %} foo {%- endif %}** ** {{- 'foo' }}** ** {#- comment #}** **{% verbatim %} foo {%- endverbatim %}** --DATA-- return [] --EXPECT-- **foo** **foo** **** ** foo** ================================================ FILE: tests/Fixtures/whitespace/trim_line_left.test ================================================ --TEST-- Line whitespace trimming on tags (left side). --TEMPLATE-- **{% if true %} foo {%~ endif %}** ** {{~ 'foo' }}** ** {#~ comment #}** **{% verbatim %} foo {%~ endverbatim %}** --DATA-- return [] --EXPECT-- **foo ** ** foo** ** ** ** foo ** ================================================ FILE: tests/Fixtures/whitespace/trim_line_right.test ================================================ --TEST-- Line whitespace trimming on tags (right side). --TEMPLATE-- **{% if true ~%} foo{% endif %}** **{{ 'foo' ~}} foo ** **{# comment ~#} foo ** **{% verbatim ~%} foo{% endverbatim %}** --DATA-- return [] --EXPECT-- ** foo** **foo foo ** ** foo ** ** foo** ================================================ FILE: tests/Fixtures/whitespace/trim_right.test ================================================ --TEST-- Whitespace trimming on tags (right side). --TEMPLATE-- **{% if true -%} foo{% endif %}** **{{ 'foo' -}} ** **{# comment -#} ** **{% verbatim -%} foo{% endverbatim %}** --DATA-- return [] --EXPECT-- **foo** **foo** **** **foo** ================================================ FILE: tests/IntegrationTest.php ================================================ ''); } 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/'; } } function test_foo($value = 'foo') { return $value; } class TwigTestFoo implements \Iterator { public const BAR_NAME = 'bar'; public $position = 0; public $array = [1, 2]; public static $foo = 'Foo'; public function bar($param1 = null, $param2 = null) { return 'bar'.($param1 ? '_'.$param1 : '').($param2 ? '-'.$param2 : ''); } public function getFoo() { return 'foo'; } public function getEmpty() { return ''; } public function getNull() { return null; } public function getSelf() { return $this; } public function is() { return 'is'; } public function in() { return 'in'; } public function not() { return 'not'; } public function strToLower($value) { return strtolower($value); } public function rewind(): void { $this->position = 0; } #[\ReturnTypeWillChange] public function current() { return $this->array[$this->position]; } #[\ReturnTypeWillChange] public function key() { return 'a'; } public function next(): void { ++$this->position; } public function valid(): bool { return isset($this->array[$this->position]); } } class TwigTestTokenParser_§ extends AbstractTokenParser { public function parse(Token $token): Node { $this->parser->getStream()->expect(Token::BLOCK_END_TYPE); return new PrintNode(new ConstantExpression('§', -1), -1); } public function getTag(): string { return '§'; } } class TwigTestExtension extends AbstractExtension { public function getTokenParsers(): array { return [ new TwigTestTokenParser_§(), ]; } public function getFilters(): array { return [ new TwigFilter('§', [$this, '§Filter']), new TwigFilter('escape_and_nl2br', [$this, 'escape_and_nl2br'], ['needs_environment' => true, 'is_safe' => ['html']]), new TwigFilter('nl2br', [$this, 'nl2br'], ['pre_escape' => 'html', 'is_safe' => ['html']]), new TwigFilter('escape_something', [$this, 'escape_something'], ['is_safe' => ['something']]), new TwigFilter('preserves_safety', [$this, 'preserves_safety'], ['preserves_safety' => ['html']]), new TwigFilter('static_call_string', 'Twig\Tests\TwigTestExtension::staticCall'), new TwigFilter('static_call_array', ['Twig\Tests\TwigTestExtension', 'staticCall']), new TwigFilter('magic_call', [$this, 'magicCall']), new TwigFilter('magic_call_closure', \Closure::fromCallable([$this, 'magicCall'])), new TwigFilter('magic_call_string', 'Twig\Tests\TwigTestExtension::magicStaticCall'), new TwigFilter('magic_call_array', ['Twig\Tests\TwigTestExtension', 'magicStaticCall']), new TwigFilter('*_path', [$this, 'dynamic_path']), new TwigFilter('*_foo_*_bar', [$this, 'dynamic_foo']), new TwigFilter('not', [$this, 'notFilter']), new TwigFilter('anon_foo', static function ($name) { return '*'.$name.'*'; }), ]; } public function getFunctions(): array { return [ new TwigFunction('§', [$this, '§Function']), new TwigFunction('safe_br', [$this, 'br'], ['is_safe' => ['html']]), new TwigFunction('unsafe_br', [$this, 'br']), new TwigFunction('static_call_string', 'Twig\Tests\TwigTestExtension::staticCall'), new TwigFunction('static_call_array', ['Twig\Tests\TwigTestExtension', 'staticCall']), new TwigFunction('*_path', [$this, 'dynamic_path']), new TwigFunction('*_foo_*_bar', [$this, 'dynamic_foo']), new TwigFunction('anon_foo', static function ($name) { return '*'.$name.'*'; }), new TwigFunction('deprecated_function', static function () { return 'foo'; }, ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1', 'not_deprecated_function')]), ]; } public function getTests(): array { return [ new TwigTest('multi word', [$this, 'is_multi_word']), new TwigTest('test_*', [$this, 'dynamic_test']), ]; } public function notFilter($value) { return 'not '.$value; } public function §Filter($value) { return "§{$value}§"; } public function §Function($value) { return "§{$value}§"; } /** * nl2br which also escapes, for testing escaper filters. */ public function escape_and_nl2br($env, $value, $sep = '
    ') { return $this->nl2br($env->getRuntime(EscaperRuntime::class)->escape($value, 'html'), $sep); } /** * nl2br only, for testing filters with pre_escape. */ public function nl2br($value, $sep = '
    ') { // not secure if $value contains html tags (not only entities) // don't use return str_replace("\n", "$sep\n", $value ?? ''); } public function dynamic_path($element, $item) { return $element.'/'.$item; } public function dynamic_foo($foo, $bar, $item) { return $foo.'/'.$bar.'/'.$item; } public function dynamic_test($element, $item) { return $element === $item; } public function escape_something($value) { return strtoupper($value); } public function preserves_safety($value) { return strtoupper($value); } public static function staticCall($value) { return "*$value*"; } public function br() { return '
    '; } public function is_multi_word($value) { return str_contains($value, ' '); } public function __call($method, $arguments) { if ('magicCall' !== $method) { throw new \BadMethodCallException('Unexpected call to __call.'); } return 'magic_'.$arguments[0]; } public static function __callStatic($method, $arguments) { if ('magicStaticCall' !== $method) { throw new \BadMethodCallException('Unexpected call to __callStatic.'); } return 'static_magic_'.$arguments[0]; } } /** * This class is used in tests for the "length" filter and "empty" test. It asserts that __call is not * used to convert such objects to strings. */ class MagicCallStub { public function __call($name, $args) { throw new \Exception('__call shall not be called.'); } } class ToStringStub { /** * @var string */ private $string; public function __construct($string) { $this->string = $string; } public function __toString() { return $this->string; } } /** * This class is used in tests for the length filter and empty test to show * that when \Countable is implemented, it is preferred over the __toString() * method. */ class CountableStub implements \Countable { private $count; public function __construct($count) { $this->count = $count; } public function count(): int { return $this->count; } public function __toString() { throw new \Exception('__toString shall not be called on \Countables.'); } } /** * This class is used in tests for the length filter. */ class IteratorAggregateStub implements \IteratorAggregate { private $data; public function __construct(array $data) { $this->data = $data; } public function getIterator(): \Traversable { return new \ArrayIterator($this->data); } } class SimpleIteratorForTesting implements \Iterator { private $data = [1, 2, 3, 4, 5, 6, 7]; private $key = 0; #[\ReturnTypeWillChange] public function current() { return $this->key; } public function next(): void { ++$this->key; } #[\ReturnTypeWillChange] public function key() { return $this->key; } public function valid(): bool { return isset($this->data[$this->key]); } public function rewind(): void { $this->key = 0; } public function __toString() { // for testing, make sure string length returned is not the same as the `iterator_count` return str_repeat('X', iterator_count($this) + 10); } } ================================================ FILE: tests/LexerTest.php ================================================ tokenize(new Source($template, 'index')); $stream->expect(Token::BLOCK_START_TYPE); $this->assertSame('§', $stream->expect(Token::NAME_TYPE)->getValue()); } public function testNameLabelForFunction() { $template = '{{ §() }}'; $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $this->assertSame('§', $stream->expect(Token::NAME_TYPE)->getValue()); } public function testBracketsNesting() { $template = '{{ {"a":{"b":"c"}} }}'; $this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '{')); $this->assertEquals(2, $this->countToken($template, Token::PUNCTUATION_TYPE, '}')); } protected function countToken($template, $type, $value = null) { $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $count = 0; while (!$stream->isEOF()) { $token = $stream->next(); if ($token->test($type)) { if (null === $value || $value === $token->getValue()) { ++$count; } } } return $count; } public function testLineDirective() { $template = "foo\n" ."bar\n" ."{% line 10 %}\n" ."{{\n" ."baz\n" ."}}\n"; $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); // foo\nbar\n $this->assertSame(1, $stream->expect(Token::TEXT_TYPE)->getLine()); // \n (after {% line %}) $this->assertSame(10, $stream->expect(Token::TEXT_TYPE)->getLine()); // {{ $this->assertSame(11, $stream->expect(Token::VAR_START_TYPE)->getLine()); // baz $this->assertSame(12, $stream->expect(Token::NAME_TYPE)->getLine()); } public function testLineDirectiveInline() { $template = "foo\n" ."bar{% line 10 %}{{\n" ."baz\n" ."}}\n"; $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); // foo\nbar $this->assertSame(1, $stream->expect(Token::TEXT_TYPE)->getLine()); // {{ $this->assertSame(10, $stream->expect(Token::VAR_START_TYPE)->getLine()); // baz $this->assertSame(11, $stream->expect(Token::NAME_TYPE)->getLine()); } public function testLongComments() { $template = '{# '.str_repeat('*', 100000).' #}'; $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 // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testLongVerbatim() { $template = '{% verbatim %}'.str_repeat('*', 100000).'{% endverbatim %}'; $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 // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testLongVar() { $template = '{{ '.str_repeat('x', 100000).' }}'; $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 // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testLongBlock() { $template = '{% '.str_repeat('x', 100000).' %}'; $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 // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testBigNumbers() { $template = '{{ 922337203685477580700 }}'; $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->next(); $node = $stream->next(); $this->assertEquals('922337203685477580700', $node->getValue()); } /** * @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() { 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, ]; } /** * @group legacy * * @dataProvider getStringWithEscapedDelimiterProducingDeprecation */ public function testStringWithEscapedDelimiterProducingDeprecation(string $template, string $expected, string $expectedDeprecation) { $this->expectDeprecation($expectedDeprecation); $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(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::TEXT_TYPE, 'foo '); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::STRING_TYPE, 'bar '); $stream->expect(Token::INTERPOLATION_START_TYPE); $stream->expect(Token::NAME_TYPE, 'baz'); $stream->expect(Token::OPERATOR_TYPE, '+'); $stream->expect(Token::NUMBER_TYPE, '1'); $stream->expect(Token::INTERPOLATION_END_TYPE); $stream->expect(Token::VAR_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testStringWithEscapedInterpolation() { $template = '{{ "bar \#{baz+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, 'bar #{baz+1}'); $stream->expect(Token::VAR_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testStringWithHash() { $template = '{{ "bar # baz" }}'; $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'); $stream->expect(Token::VAR_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testStringWithUnterminatedInterpolation() { $template = '{{ "bar #{x" }}'; $lexer = new Lexer(new Environment(new ArrayLoader())); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unclosed """'); $lexer->tokenize(new Source($template, 'index')); } public function testStringWithNestedInterpolations() { $template = '{{ "bar #{ "foo#{bar}" }" }}'; $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 '); $stream->expect(Token::INTERPOLATION_START_TYPE); $stream->expect(Token::STRING_TYPE, 'foo'); $stream->expect(Token::INTERPOLATION_START_TYPE); $stream->expect(Token::NAME_TYPE, 'bar'); $stream->expect(Token::INTERPOLATION_END_TYPE); $stream->expect(Token::INTERPOLATION_END_TYPE); $stream->expect(Token::VAR_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testStringWithNestedInterpolationsInBlock() { $template = '{% foo "bar #{ "foo#{bar}" }" %}'; $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'); $stream->expect(Token::STRING_TYPE, 'bar '); $stream->expect(Token::INTERPOLATION_START_TYPE); $stream->expect(Token::STRING_TYPE, 'foo'); $stream->expect(Token::INTERPOLATION_START_TYPE); $stream->expect(Token::NAME_TYPE, 'bar'); $stream->expect(Token::INTERPOLATION_END_TYPE); $stream->expect(Token::INTERPOLATION_END_TYPE); $stream->expect(Token::BLOCK_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions $this->addToAssertionCount(1); } public function testOperatorEndingWithALetterAtTheEndOfALine() { $template = "{{ 1 and\n0}}"; $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); $stream->expect(Token::OPERATOR_TYPE, 'and'); // 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 function testFilterAndAttributeNamedAfterOperator() { // Ensure that filters/attributes aren't mistaken for operators when their names conflict // (see https://github.com/twigphp/Twig/issues/4767) $template = '{{ \'foo\'|and }}' .'{{ \'bar\' | and }}' .'{{ foo.and }}' .'{{ bar . and }}' .'{{ foo and bar }}'; $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); foreach (['foo', 'bar'] as $value) { $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::STRING_TYPE, $value); $stream->expect(Token::OPERATOR_TYPE, '|'); $stream->expect(Token::NAME_TYPE, 'and'); $stream->expect(Token::VAR_END_TYPE); } foreach (['foo', 'bar'] as $value) { $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::NAME_TYPE, $value); $stream->expect(Token::OPERATOR_TYPE, '.'); $stream->expect(Token::NAME_TYPE, 'and'); $stream->expect(Token::VAR_END_TYPE); } $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::NAME_TYPE, 'foo'); $stream->expect(Token::OPERATOR_TYPE, 'and'); $stream->expect(Token::NAME_TYPE, 'bar'); $stream->expect(Token::VAR_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions } public function testLiteralIsNotAnOperator() { // "literal" is the name of the LiteralExpressionParser but should not be treated as an operator token $template = '{{ literal }}'; $lexer = new Lexer(new Environment(new ArrayLoader())); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::NAME_TYPE, 'literal'); $stream->expect(Token::VAR_END_TYPE); $this->addToAssertionCount(1); } public function testUnterminatedVariable() { $template = ' {{ bar '; $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() { $template = ' {% bar '; $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(new ArrayLoader()), [ 'tag_comment' => ['[#', '#]'], 'tag_block' => ['/#', '#/'], 'tag_variable' => ['{#', '#}'], ]); $stream = $lexer->tokenize(new Source($template, 'index')); $stream->expect(Token::VAR_START_TYPE); $stream->expect(Token::NAME_TYPE, 'variable'); $stream->expect(Token::VAR_END_TYPE); $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, 'true'); $stream->expect(Token::BLOCK_START_TYPE); $stream->expect(Token::NAME_TYPE, 'endif'); $stream->expect(Token::BLOCK_END_TYPE); // add a dummy assertion here to satisfy PHPUnit, the only thing we want to test is that the code above // can be executed without throwing any exceptions $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)) }}', ')']; } } ================================================ FILE: tests/Loader/ArrayTest.php ================================================ expectException(LoaderError::class); $loader->getSourceContext('foo'); } public function testGetCacheKey() { $loader = new ArrayLoader(['foo' => 'bar']); $this->assertEquals('foo:bar', $loader->getCacheKey('foo')); } public function testGetCacheKeyWhenTemplateHasDuplicateContent() { $loader = new ArrayLoader([ 'foo' => 'bar', 'baz' => 'bar', ]); $this->assertEquals('foo:bar', $loader->getCacheKey('foo')); $this->assertEquals('baz:bar', $loader->getCacheKey('baz')); } public function testGetCacheKeyIsProtectedFromEdgeCollisions() { $loader = new ArrayLoader([ 'foo__' => 'bar', 'foo' => '__bar', ]); $this->assertEquals('foo__:bar', $loader->getCacheKey('foo__')); $this->assertEquals('foo:__bar', $loader->getCacheKey('foo')); } public function testGetCacheKeyWhenTemplateDoesNotExist() { $loader = new ArrayLoader(); $this->expectException(LoaderError::class); $loader->getCacheKey('foo'); } public function testSetTemplate() { $loader = new ArrayLoader(); $loader->setTemplate('foo', 'bar'); $this->assertEquals('bar', $loader->getSourceContext('foo')->getCode()); } public function testIsFresh() { $loader = new ArrayLoader(['foo' => 'bar']); $this->assertTrue($loader->isFresh('foo', time())); } public function testIsFreshWhenTemplateDoesNotExist() { $loader = new ArrayLoader(); $this->expectException(LoaderError::class); $loader->isFresh('foo', time()); } } ================================================ FILE: tests/Loader/ChainTest.php ================================================ 'bar']), new ArrayLoader(['errors/index.html' => 'baz']), new FilesystemLoader([$path]), ]); $this->assertEquals('foo', $loader->getSourceContext('foo')->getName()); $this->assertSame('', $loader->getSourceContext('foo')->getPath()); $this->assertEquals('errors/index.html', $loader->getSourceContext('errors/index.html')->getName()); $this->assertSame('', $loader->getSourceContext('errors/index.html')->getPath()); $this->assertEquals('baz', $loader->getSourceContext('errors/index.html')->getCode()); $this->assertEquals('errors/base.html', $loader->getSourceContext('errors/base.html')->getName()); $this->assertEquals(realpath($path.'/errors/base.html'), realpath($loader->getSourceContext('errors/base.html')->getPath())); $this->assertNotEquals('baz', $loader->getSourceContext('errors/base.html')->getCode()); } public function testGetSourceContextWhenTemplateDoesNotExist() { $loader = new ChainLoader([]); $this->expectException(LoaderError::class); $loader->getSourceContext('foo'); } public function testGetCacheKey() { $loader = new ChainLoader([ new ArrayLoader(['foo' => 'bar']), new ArrayLoader(['foo' => 'foobar', 'bar' => 'foo']), ]); $this->assertEquals('foo:bar', $loader->getCacheKey('foo')); $this->assertEquals('bar:foo', $loader->getCacheKey('bar')); } public function testGetCacheKeyWhenTemplateDoesNotExist() { $loader = new ChainLoader([]); $this->expectException(LoaderError::class); $loader->getCacheKey('foo'); } public function testAddLoader() { $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() { $loader1 = $this->createMock(LoaderInterface::class); $loader1->expects($this->once())->method('exists')->willReturn(false); $loader1->expects($this->never())->method('getSourceContext'); $loader2 = $this->createMock(LoaderInterface::class); $loader2->expects($this->once())->method('exists')->willReturn(true); $loader2->expects($this->never())->method('getSourceContext'); $loader = new ChainLoader(); $loader->addLoader($loader1); $loader->addLoader($loader2); $this->assertTrue($loader->exists('foo')); } } ================================================ FILE: tests/Loader/FilesystemTest.php ================================================ assertEquals('errors/index.html', $loader->getSourceContext('errors/index.html')->getName()); $this->assertEquals(realpath($path.'/errors/index.html'), realpath($loader->getSourceContext('errors/index.html')->getPath())); } /** * @dataProvider getSecurityTests */ public function testSecurity($template) { $loader = new FilesystemLoader([__DIR__.'/../Fixtures']); $loader->addPath(__DIR__.'/../Fixtures', 'foo'); try { $loader->getCacheKey($template); $this->fail(); } catch (LoaderError $e) { $this->assertStringNotContainsString('Unable to find template', $e->getMessage()); } } public static function getSecurityTests() { return [ ["AutoloaderTest\0.php"], ['..\\AutoloaderTest.php'], ['..\\\\\\AutoloaderTest.php'], ['../AutoloaderTest.php'], ['..////AutoloaderTest.php'], ['./../AutoloaderTest.php'], ['.\\..\\AutoloaderTest.php'], ['././././././../AutoloaderTest.php'], ['.\\./.\\./.\\./../AutoloaderTest.php'], ['foo/../../AutoloaderTest.php'], ['foo\\..\\..\\AutoloaderTest.php'], ['foo/../bar/../../AutoloaderTest.php'], ['foo/bar/../../../AutoloaderTest.php'], ['filters/../../AutoloaderTest.php'], ['filters//..//..//AutoloaderTest.php'], ['filters\\..\\..\\AutoloaderTest.php'], ['filters\\\\..\\\\..\\\\AutoloaderTest.php'], ['filters\\//../\\/\\..\\AutoloaderTest.php'], ['/../AutoloaderTest.php'], ['@__main__/../AutoloaderTest.php'], ['@foo/../AutoloaderTest.php'], ['@__main__/../../AutoloaderTest.php'], ['@foo/../../AutoloaderTest.php'], ]; } /** * @dataProvider getBasePaths */ public function testPaths($basePath, $cacheKey, $rootPath) { $loader = new FilesystemLoader([$basePath.'/normal', $basePath.'/normal_bis'], $rootPath); $loader->setPaths([$basePath.'/named', $basePath.'/named_bis'], 'named'); $loader->addPath($basePath.'/named_ter', 'named'); $loader->addPath($basePath.'/normal_ter'); $loader->prependPath($basePath.'/normal_final'); $loader->prependPath($basePath.'/named/../named_quater', 'named'); $loader->prependPath($basePath.'/named_final', 'named'); $this->assertEquals([ $basePath.'/normal_final', $basePath.'/normal', $basePath.'/normal_bis', $basePath.'/normal_ter', ], $loader->getPaths()); $this->assertEquals([ $basePath.'/named_final', $basePath.'/named/../named_quater', $basePath.'/named', $basePath.'/named_bis', $basePath.'/named_ter', ], $loader->getPaths('named')); // do not use realpath here as it would make the test unuseful $this->assertEquals($cacheKey, str_replace('\\', '/', $loader->getCacheKey('@named/named_absolute.html'))); $this->assertEquals("path (final)\n", $loader->getSourceContext('index.html')->getCode()); $this->assertEquals("path (final)\n", $loader->getSourceContext('@__main__/index.html')->getCode()); $this->assertEquals("named path (final)\n", $loader->getSourceContext('@named/index.html')->getCode()); } public static function getBasePaths() { return [ [ __DIR__.'/Fixtures', 'tests/Loader/Fixtures/named_quater/named_absolute.html', null, ], [ __DIR__.'/Fixtures/../Fixtures', 'tests/Loader/Fixtures/named_quater/named_absolute.html', null, ], [ 'tests/Loader/Fixtures', 'tests/Loader/Fixtures/named_quater/named_absolute.html', getcwd(), ], [ 'Fixtures', 'Fixtures/named_quater/named_absolute.html', getcwd().'/tests/Loader', ], [ 'Fixtures', 'Fixtures/named_quater/named_absolute.html', getcwd().'/tests/../tests/Loader', ], ]; } public function testEmptyConstructor() { $loader = new FilesystemLoader(); $this->assertEquals([], $loader->getPaths()); } public function testGetNamespaces() { $loader = new FilesystemLoader(sys_get_temp_dir()); $this->assertEquals([FilesystemLoader::MAIN_NAMESPACE], $loader->getNamespaces()); $loader->addPath(sys_get_temp_dir(), 'named'); $this->assertEquals([FilesystemLoader::MAIN_NAMESPACE, 'named'], $loader->getNamespaces()); } public function testFindTemplateExceptionNamespace() { $basePath = __DIR__.'/Fixtures'; $loader = new FilesystemLoader([$basePath.'/normal']); $loader->addPath($basePath.'/named', 'named'); try { $loader->getSourceContext('@named/nowhere.html'); } catch (\Exception $e) { $this->assertInstanceOf(LoaderError::class, $e); $this->assertStringContainsString('Unable to find template "@named/nowhere.html"', $e->getMessage()); } } public function testFindTemplateWithCache() { $basePath = __DIR__.'/Fixtures'; $loader = new FilesystemLoader([$basePath.'/normal']); $loader->addPath($basePath.'/named', 'named'); // prime the cache for index.html in the named namespace $namedSource = $loader->getSourceContext('@named/index.html')->getCode(); $this->assertEquals("named path\n", $namedSource); // get index.html from the main namespace $this->assertEquals("path\n", $loader->getSourceContext('index.html')->getCode()); } public function testLoadTemplateAndRenderBlockWithCache() { $loader = new FilesystemLoader([]); $loader->addPath(__DIR__.'/Fixtures/themes/theme2'); $loader->addPath(__DIR__.'/Fixtures/themes/theme1'); $loader->addPath(__DIR__.'/Fixtures/themes/theme1', 'default_theme'); $twig = new Environment($loader); $template = $twig->load('blocks.html.twig'); $this->assertSame('block from theme 1', $template->renderBlock('b1', [])); $template = $twig->load('blocks.html.twig'); $this->assertSame('block from theme 2', $template->renderBlock('b2', [])); } public static function getArrayInheritanceTests() { return [ 'valid array inheritance' => ['array_inheritance_valid_parent.html.twig'], 'array inheritance with empty first template' => ['array_inheritance_empty_parent.html.twig'], 'array inheritance with non-existent first template' => ['array_inheritance_nonexistent_parent.html.twig'], ]; } /** * @dataProvider getArrayInheritanceTests */ public function testArrayInheritance(string $templateName) { $loader = new FilesystemLoader([]); $loader->addPath(__DIR__.'/Fixtures/inheritance'); $twig = new Environment($loader); $template = $twig->load($templateName); $this->assertSame('VALID Child', $template->renderBlock('body', [])); } public function testLoadTemplateFromPhar() { $loader = new FilesystemLoader([]); // phar-sample.phar was created with the following script: // $f = new Phar('phar-test.phar'); // $f->addFromString('hello.twig', 'hello from phar'); $loader->addPath('phar://'.__DIR__.'/Fixtures/phar/phar-sample.phar'); $this->assertSame('hello from phar', $loader->getSourceContext('hello.twig')->getCode()); } public function testTemplateExistsAlwaysReturnsBool() { $loader = new FilesystemLoader([]); $this->assertFalse($loader->exists("foo\0.twig")); $this->assertFalse($loader->exists('../foo.twig')); $this->assertFalse($loader->exists('@foo')); $this->assertFalse($loader->exists('foo')); $this->assertFalse($loader->exists('@foo/bar.twig')); $loader->addPath(__DIR__.'/Fixtures/normal'); $this->assertTrue($loader->exists('index.html')); $loader->addPath(__DIR__.'/Fixtures/normal', 'foo'); $this->assertTrue($loader->exists('@foo/index.html')); } } ================================================ FILE: tests/Loader/Fixtures/inheritance/array_inheritance_empty_parent.html.twig ================================================ {% extends ['','parent.html.twig'] %} {% block body %}{{ parent() }} Child{% endblock %} ================================================ FILE: tests/Loader/Fixtures/inheritance/array_inheritance_nonexistent_parent.html.twig ================================================ {% extends ['nonexistent.html.twig','parent.html.twig'] %} {% block body %}{{ parent() }} Child{% endblock %} ================================================ FILE: tests/Loader/Fixtures/inheritance/array_inheritance_valid_parent.html.twig ================================================ {% extends ['parent.html.twig','spare_parent.html.twig'] %} {% block body %}{{ parent() }} Child{% endblock %} ================================================ FILE: tests/Loader/Fixtures/inheritance/parent.html.twig ================================================ {% block body %}VALID{% endblock %} ================================================ FILE: tests/Loader/Fixtures/inheritance/spare_parent.html.twig ================================================ {% block body %}SPARE PARENT{% endblock %} ================================================ FILE: tests/Loader/Fixtures/named/index.html ================================================ named path ================================================ FILE: tests/Loader/Fixtures/named_bis/index.html ================================================ named path (bis) ================================================ FILE: tests/Loader/Fixtures/named_final/index.html ================================================ named path (final) ================================================ FILE: tests/Loader/Fixtures/named_quater/named_absolute.html ================================================ named path (quater) ================================================ FILE: tests/Loader/Fixtures/named_ter/index.html ================================================ named path (ter) ================================================ FILE: tests/Loader/Fixtures/normal/index.html ================================================ path ================================================ FILE: tests/Loader/Fixtures/normal_bis/index.html ================================================ path (bis) ================================================ FILE: tests/Loader/Fixtures/normal_final/index.html ================================================ path (final) ================================================ FILE: tests/Loader/Fixtures/normal_ter/index.html ================================================ path (ter) ================================================ FILE: tests/Loader/Fixtures/themes/theme1/blocks.html.twig ================================================ {% block b1 %}block from theme 1{% endblock %} {% block b2 %}block from theme 1{% endblock %} ================================================ FILE: tests/Loader/Fixtures/themes/theme2/blocks.html.twig ================================================ {% use '@default_theme/blocks.html.twig' %} {% block b2 %}block from theme 2{% endblock %} ================================================ FILE: tests/Node/AutoEscapeTest.php ================================================ assertEquals($body, $node->getNode('body')); $this->assertTrue($node->getAttribute('value')); } public static function provideTests(): iterable { $body = new Nodes([new TextNode('foo', 1)]); $node = new AutoEscapeNode(true, $body, 1); return [ [$node, "// line 1\nyield \"foo\";"], ]; } } ================================================ FILE: tests/Node/BlockReferenceTest.php ================================================ assertEquals('foo', $node->getAttribute('name')); } public static function provideTests(): iterable { return [ [new BlockReferenceNode('foo', 1), <<<'EOF' // line 1 yield from $this->unwrap()->yieldBlock('foo', $context, $blocks); EOF ], ]; } } ================================================ FILE: tests/Node/BlockTest.php ================================================ assertEquals($body, $node->getNode('body')); $this->assertEquals('foo', $node->getAttribute('name')); } public static function provideTests(): iterable { $tests = []; $tests[] = [new BlockNode('foo', new TextNode('foo', 1), 1), << */ public function block_foo(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; yield "foo"; yield from []; } EOF, new Environment(new ArrayLoader()), ]; return $tests; } } ================================================ FILE: tests/Node/DeprecatedTest.php ================================================ assertEquals($expr, $node->getNode('expr')); } public static function provideTests(): iterable { $tests = []; $expr = new ConstantExpression('This section is deprecated', 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)); $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, <<addFunction($function = new TwigFunction('foo', 'Twig\Tests\Node\foo', [])); $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 = new Compiler($environment); $varName = $compiler->getVarName(); $tests[] = [$node, <<assertEquals($expr, $node->getNode('expr')); } public static function provideTests(): iterable { $tests = []; $expr = new ConstantExpression('foo', 1); $node = new DoNode($expr, 1); $tests[] = [$node, "// line 1\n\"foo\";"]; return $tests; } } ================================================ FILE: tests/Node/EmbedTest.php ================================================ 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; } } ================================================ FILE: tests/Node/Expression/ArrayTest.php ================================================ assertEquals($foo, $node->getNode('1')); } public static function provideTests(): iterable { $elements = [ new ConstantExpression('foo', 1), new ConstantExpression('bar', 1), new ConstantExpression('bar', 1), new ConstantExpression('foo', 1), ]; $node = new ArrayExpression($elements, 1); return [ [$node, '["foo" => "bar", "bar" => "foo"]'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/AddTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new AddBinary($left, $right, 1); return [ [$node, '(1 + 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/AndTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new AndBinary($left, $right, 1); return [ [$node, '(1 && 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/ConcatTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new ConcatBinary($left, $right, 1); return [ [$node, '(1 . 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/DivTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new DivBinary($left, $right, 1); return [ [$node, '(1 / 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/FloorDivTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new FloorDivBinary($left, $right, 1); return [ [$node, '(int) floor((1 / 2))'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/ModTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new ModBinary($left, $right, 1); return [ [$node, '(1 % 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/MulTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new MulBinary($left, $right, 1); return [ [$node, '(1 * 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/NullCoalesceTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new OrBinary($left, $right, 1); return [ [$node, '(1 || 2)'], ]; } } ================================================ FILE: tests/Node/Expression/Binary/SubTest.php ================================================ assertEquals($left, $node->getNode('left')); $this->assertEquals($right, $node->getNode('right')); } public static function provideTests(): iterable { $left = new ConstantExpression(1, 1); $right = new ConstantExpression(2, 1); $node = new SubBinary($left, $right, 1); return [ [$node, '(1 - 2)'], ]; } } ================================================ FILE: tests/Node/Expression/CallTest.php ================================================ 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".'); $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".'); $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)".'); $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)".'); $this->getArguments($node, ['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.'); } $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".'); $this->getArguments($node, ['substr_compare', ['abcd', 'bc', 'offset' => 1, 'case_sensitivity' => true]]); } public function testResolveArgumentsOnlyNecessaryArgumentsForCustomFunction() { $node = $this->createFunctionExpression('custom_function', [$this, 'customFunction']); $this->assertEquals(['arg1'], $this->getArguments($node, [[$this, 'customFunction'], ['arg1' => 'arg1']])); } public function testGetArgumentsForStaticMethod() { $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 = []".'); $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.'); $this->getArguments($node, ['', []]); } 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 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(), []]); } public static function customStaticFunction($arg1, $arg2 = 'default', $arg3 = []) { } public function customFunction($arg1, $arg2 = 'default', $arg3 = []) { } public function customFunctionWithArbitraryArguments() { } private function getArguments($call, $args) { $m = new \ReflectionMethod($call, 'getArguments'); return $m->invokeArgs($call, $args); } private function createFunctionExpression($name, $callable, $isVariadic = false): Node_Expression_Call { return new Node_Expression_Call(new TwigFunction($name, $callable, ['is_variadic' => $isVariadic]), new EmptyNode(), 0); } } class Node_Expression_Call extends FunctionExpression { } class CallableTestClass { public function __invoke($required) { } } function custom_call_test_function($required) { } ================================================ FILE: tests/Node/Expression/ConditionalTest.php ================================================ assertEquals($expr1, $node->getNode('expr1')); $this->assertEquals($expr2, $node->getNode('expr2')); $this->assertEquals($expr3, $node->getNode('expr3')); } public static function provideTests(): iterable { $tests = []; $expr1 = new ConstantExpression(1, 1); $expr2 = new ConstantExpression(2, 1); $expr3 = new ConstantExpression(3, 1); $node = new ConditionalExpression($expr1, $expr2, $expr3, 1); $tests[] = [$node, '((1) ? (2) : (3))']; return $tests; } } ================================================ FILE: tests/Node/Expression/ConstantTest.php ================================================ assertEquals('foo', $node->getAttribute('value')); } public static function provideTests(): iterable { $tests = []; $node = new ConstantExpression('foo', 1); $tests[] = [$node, '"foo"']; return $tests; } } ================================================ FILE: tests/Node/Expression/Filter/RawTest.php ================================================ 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"'], ]; } } ================================================ FILE: tests/Node/Expression/FilterTest.php ================================================ assertEquals($expr, $node->getNode('node')); $this->assertEquals($name, $node->getAttribute('name')); $this->assertEquals($args, $node->getNode('arguments')); } public static function provideTests(): iterable { $environment = static::createEnvironment(); $tests = []; $expr = new ConstantExpression('foo', 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, '$this->extensions[\'Twig\Extension\CoreExtension\']->formatNumber(Twig\Extension\CoreExtension::upper($this->env->getCharset(), "foo"), 2, ".", ",")']; // named arguments $date = new ConstantExpression(0, 1); $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, '$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 = self::createFilter($environment, $date, 'date', [ 'timezone' => new ConstantExpression('America/Chicago', 1), ]); $tests[] = [$node, '$this->extensions[\'Twig\Extension\CoreExtension\']->formatDate(0, null, "America/Chicago")']; // underscores vs camelCase for named arguments $string = new ConstantExpression('abc', 1); $node = self::createFilter($environment, $string, 'reverse', [ 'preserve_keys' => new ConstantExpression(true, 1), ]); $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\Extension\CoreExtension::reverse($this->env->getCharset(), "abc", true)']; // filter as an anonymous function $node = self::createFilter($environment, new ConstantExpression('foo', 1), 'anonymous'); $tests[] = [$node, '$this->env->getFilter(\'anonymous\')->getCallable()("foo")']; // needs environment $node = self::createFilter($environment, $string, 'bar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_dummy($this->env, "abc")', $environment]; $node = self::createFilter($environment, $string, 'bar_closure'); $tests[] = [$node, twig_tests_filter_dummy::class.'($this->env, "abc")', $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 = self::createFilter($environment, $string, 'barbar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_barbar($context, "abc")', $environment]; $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 = 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]; 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", ["3", "foo" => "bar"])', $environment]; // from extension $node = self::createFilter($environment, $string, 'foo'); $tests[] = [$node, \sprintf('$this->extensions[\'%s\']->foo("abc")', \get_class(self::createExtension())), $environment]; $node = self::createFilter($environment, $string, 'foobar'); $tests[] = [$node, '$this->env->getFilter(\'foobar\')->getCallable()("abc")', $environment]; $node = self::createFilter($environment, $string, 'magic_static'); $tests[] = [$node, 'Twig\Tests\Node\Expression\ChildMagicCallStub::magicStaticCall("abc")', $environment]; return $tests; } public function testCompileWithWrongNamedArgumentName() { $date = new ConstantExpression(0, 1); $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() { $value = new ConstantExpression(0, 1); $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); } private static function createFilter(Environment $env, $node, $name, array $arguments = []): FilterExpression { return new FilterExpression($node, $env->getFilter($name), new Nodes($arguments), 1); } protected static function createEnvironment(): Environment { $env = new Environment(new ArrayLoader()); $env->addFilter(new TwigFilter('anonymous', static function () {})); $env->addFilter(new TwigFilter('bar', 'Twig\Tests\Node\Expression\twig_tests_filter_dummy', ['needs_environment' => true])); $env->addFilter(new TwigFilter('bar_closure', \Closure::fromCallable(twig_tests_filter_dummy::class), ['needs_environment' => true])); $env->addFilter(new TwigFilter('barbar', 'Twig\Tests\Node\Expression\twig_tests_filter_barbar', ['needs_context' => true, 'is_variadic' => true])); $env->addFilter(new TwigFilter('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() { } function twig_tests_filter_barbar($context, $string, $arg1 = null, $arg2 = null, array $args = []) { } class ChildMagicCallStub extends ParentMagicCallStub { public static function identifier() { return 'child'; } } class ParentMagicCallStub { public static function identifier() { throw new \Exception('Identifier has not been defined.'); } public static function __callStatic($method, $arguments) { if ('magicStaticCall' !== $method) { throw new \BadMethodCallException('Unexpected call to __callStatic.'); } return 'inherited_static_magic_'.static::identifier().'_'.$arguments[0]; } } ================================================ FILE: tests/Node/Expression/FilterTestExtension.php ================================================ objectMethod(...)), ]; } public static function staticMethod() { } public function objectMethod() { } } ================================================ FILE: tests/Node/Expression/FunctionTest.php ================================================ assertEquals($name, $node->getAttribute('name')); $this->assertEquals($args, $node->getNode('arguments')); } public static function provideTests(): iterable { $environment = static::createEnvironment(); $tests = []; $node = self::createFunction($environment, 'foo'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy()', $environment]; $node = self::createFunction($environment, 'foo_closure'); $tests[] = [$node, twig_tests_function_dummy::class.'()', $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 = self::createFunction($environment, 'bar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($this->env)', $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 = self::createFunction($environment, 'foofoo'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($context)', $environment]; $node = self::createFunction($environment, 'foofoo', [new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($context, "bar")', $environment]; $node = self::createFunction($environment, 'foobar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_dummy($this->env, $context)', $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 = self::createFunction($environment, 'date', [ 'timezone' => new ConstantExpression('America/Chicago', 1), 'date' => new ConstantExpression(0, 1), ]); $tests[] = [$node, '$this->extensions[\'Twig\Extension\CoreExtension\']->convertDate(0, "America/Chicago")']; // arbitrary named arguments $node = self::createFunction($environment, 'barbar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar()', $environment]; $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 = self::createFunction($environment, 'barbar', ['arg2' => new ConstantExpression('bar', 1)]); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_function_barbar(null, "bar")', $environment]; $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", ["3", "foo" => "bar"])', $environment]; // function as an anonymous function $node = self::createFunction($environment, 'anonymous', [new ConstantExpression('foo', 1)]); $tests[] = [$node, '$this->env->getFunction(\'anonymous\')->getCallable()("foo")']; return $tests; } private static function createFunction(Environment $env, $name, array $arguments = []): FunctionExpression { return new FunctionExpression($env->getFunction($name), new Nodes($arguments), 1); } protected static function createEnvironment(): Environment { $env = new Environment(new ArrayLoader()); $env->addFunction(new TwigFunction('anonymous', static function () {})); $env->addFunction(new TwigFunction('foo', 'Twig\Tests\Node\Expression\twig_tests_function_dummy', [])); $env->addFunction(new TwigFunction('foo_closure', \Closure::fromCallable(twig_tests_function_dummy::class), [])); $env->addFunction(new TwigFunction('bar', 'Twig\Tests\Node\Expression\twig_tests_function_dummy', ['needs_environment' => true])); $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; } } function twig_tests_function_dummy() { } function twig_tests_function_barbar($arg1 = null, $arg2 = null, array $args = []) { } ================================================ FILE: tests/Node/Expression/GetAttrTest.php ================================================ addElement(new ContextVariable('foo', 1)); $args->addElement(new ConstantExpression('bar', 1)); $node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1); $this->assertEquals($expr, $node->getNode('node')); $this->assertEquals($attr, $node->getNode('attribute')); $this->assertEquals($args, $node->getNode('arguments')); $this->assertEquals(Template::ARRAY_CALL, $node->getAttribute('type')); $this->assertFalse($node->getAttribute('null_safe')); } public static function provideTests(): iterable { $tests = []; $expr = new ContextVariable('foo', 1); $attr = new ConstantExpression('bar', 1); $attr2 = new ConstantExpression('baz', 1); $attr3 = new ConstantExpression('qux', 1); $attr4 = new ConstantExpression('corge', 1); $args = new ArrayExpression([], 1); $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1); $tests[] = [$node, \sprintf('%s%s, "bar", [], "any", false, false, false, 1)', self::createAttributeGetter(), self::createVariableGetter('foo', 1))]; $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1))', null, true]; $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); $node = new GetAttrExpression($node, $attr2, $args, Template::METHOD_CALL, 1); $tests[] = [$node, '((null === ($_v%s = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v%s, "bar", [], "any", false, false, false, 1), "baz", [], "method", false, false, false, 1))', null, true]; $node = new GetAttrExpression($expr, $attr, $args, Template::ANY_CALL, 1, true); $node = new GetAttrExpression($node, $attr2, $args, Template::ANY_CALL, 1); $node = new GetAttrExpression($node, $attr3, $args, Template::METHOD_CALL, 1, true); $node = new GetAttrExpression($node, $attr4, $args, Template::ANY_CALL, 1); $tests[] = [$node, '((null === ($_v0 = ((null === ($_v1 = // line 1'."\n".'($context["foo"] ?? null))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v1, "bar", [], "any", false, false, false, 1), "baz", [], "any", false, false, false, 1)))) ? null : '.self::createAttributeGetter().self::createAttributeGetter().'$_v0, "qux", [], "method", false, false, false, 1), "corge", [], "any", false, false, false, 1))', null]; $node = new GetAttrExpression($expr, $attr, $args, Template::ARRAY_CALL, 1); $tests[] = [$node, '(($_v%s = // line 1'."\n". '($context["foo"] ?? null)) && is_array($_v%s) || $_v%s instanceof ArrayAccess ? ($_v%s["bar"] ?? null) : null)', null, true, ]; $args = new ArrayExpression([], 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", [%s, "bar"], "method", false, false, false, 1)', self::createAttributeGetter(), self::createVariableGetter('foo', 1), self::createVariableGetter('foo'))]; return $tests; } } ================================================ FILE: tests/Node/Expression/NullCoalesceTest.php ================================================ assertEquals('foo', $node->getAttribute('name')); } public static function provideTests(): iterable { $tests = []; $tests[] = [new ParentExpression('foo', 1), '$this->renderParentBlock("foo", $context, $blocks)']; return $tests; } } ================================================ FILE: tests/Node/Expression/Ternary/ConditionalTernaryTest.php ================================================ 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; } } ================================================ FILE: tests/Node/Expression/TestTest.php ================================================ assertEquals($expr, $node->getNode('node')); $this->assertEquals($args, $node->getNode('arguments')); $this->assertEquals($name, $node->getAttribute('name')); } public static function provideTests(): iterable { $environment = static::createEnvironment(); $tests = []; $expr = new ConstantExpression('foo', 1); $node = new NullTest($expr, $environment->getTest('null'), new EmptyNode(), 1); $tests[] = [$node, '(null === "foo")']; // test as an anonymous function $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 = self::createTest($environment, $string, 'barbar'); $tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_test_barbar("abc")', $environment]; $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 = 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 = 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", ["3", "foo" => "bar"])', $environment]; return $tests; } private static function createTest(Environment $env, $node, $name, array $arguments = []): TestExpression { return new TestExpression($node, $env->getTest($name), new Nodes($arguments), 1); } protected static function createEnvironment(): Environment { $env = new Environment(new ArrayLoader()); $env->addTest(new TwigTest('anonymous', static function () {})); $env->addTest(new TwigTest('barbar', 'Twig\Tests\Node\Expression\twig_tests_test_barbar', ['is_variadic' => true, 'need_context' => true])); return $env; } } function twig_tests_test_barbar($string, $arg1 = null, $arg2 = null, array $args = []) { } ================================================ FILE: tests/Node/Expression/Unary/NegTest.php ================================================ assertEquals($expr, $node->getNode('node')); } public static function provideTests(): iterable { $node = new ConstantExpression(1, 1); $node = new NegUnary($node, 1); return [ [$node, '-1'], [new NegUnary($node, 1), '- -1'], ]; } } ================================================ FILE: tests/Node/Expression/Unary/NotTest.php ================================================ assertEquals($expr, $node->getNode('node')); } public static function provideTests(): iterable { $node = new ConstantExpression(1, 1); $node = new NotUnary($node, 1); return [ [$node, '!1'], ]; } } ================================================ FILE: tests/Node/Expression/Unary/PosTest.php ================================================ assertEquals($expr, $node->getNode('node')); } public static function provideTests(): iterable { $node = new ConstantExpression(1, 1); $node = new PosUnary($node, 1); return [ [$node, '+1'], ]; } } ================================================ FILE: tests/Node/Expression/Variable/AssignContextVariableTest.php ================================================ assertEquals('foo', $node->getAttribute('name')); } public static function provideTests(): iterable { $node = new AssignContextVariable('foo', 1); return [ [$node, '$context["foo"]'], ]; } } ================================================ FILE: tests/Node/Expression/Variable/ContextVariableTest.php ================================================ 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]; } } ================================================ FILE: tests/Node/ForTest.php ================================================ setAttribute('with_loop', false); $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->assertFalse($node->hasNode('else')); $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 static function provideTests(): iterable { $tests = []; $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, << \$context["item"]) { yield $fooGetter; } \$_parent = \$context['_parent']; unset(\$context['_seq'], \$context['key'], \$context['item'], \$context['_parent']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; $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); $tests[] = [$node, << \$context['_parent'], 'index0' => 0, 'index' => 1, 'first' => true, ]; if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) { \$length = count(\$context['_seq']); \$context['loop']['revindex0'] = \$length - 1; \$context['loop']['revindex'] = \$length; \$context['loop']['length'] = \$length; \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { yield $fooGetter; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; 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['k'], \$context['v'], \$context['_parent'], \$context['loop']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; $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); $tests[] = [$node, << \$context['_parent'], 'index0' => 0, 'index' => 1, 'first' => true, ]; if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) { \$length = count(\$context['_seq']); \$context['loop']['revindex0'] = \$length - 1; \$context['loop']['revindex'] = \$length; \$context['loop']['length'] = \$length; \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { yield $fooGetter; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; 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['k'], \$context['v'], \$context['_parent'], \$context['loop']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; $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, << \$context['_parent'], 'index0' => 0, 'index' => 1, 'first' => true, ]; if (is_array(\$context['_seq']) || (is_object(\$context['_seq']) && \$context['_seq'] instanceof \Countable)) { \$length = count(\$context['_seq']); \$context['loop']['revindex0'] = \$length - 1; \$context['loop']['revindex'] = \$length; \$context['loop']['length'] = \$length; \$context['loop']['last'] = 1 === \$length; } foreach (\$context['_seq'] as \$context["k"] => \$context["v"]) { yield $fooGetter; \$context['_iterated'] = true; ++\$context['loop']['index0']; ++\$context['loop']['index']; \$context['loop']['first'] = false; 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']) { // line 6 yield $fooGetter; } \$_parent = \$context['_parent']; unset(\$context['_seq'], \$context['k'], \$context['v'], \$context['_parent'], \$context['_iterated'], \$context['loop']); \$context = array_intersect_key(\$context, \$_parent) + \$_parent; EOF ]; return $tests; } } ================================================ FILE: tests/Node/IfTest.php ================================================ assertEquals($t, $node->getNode('tests')); $this->assertFalse($node->hasNode('else')); $else = new PrintNode(new ContextVariable('bar', 1), 1); $node = new IfNode($t, $else, 1); $this->assertEquals($else, $node->getNode('else')); } public static function provideTests(): iterable { $tests = []; $t = new Nodes([ new ConstantExpression(true, 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, <<assertEquals($macro, $node->getNode('expr')); $this->assertEquals('macro', $node->getNode('var')->getNode('var')->getAttribute('name')); } public static function provideTests(): iterable { $tests = []; $macro = new ConstantExpression('foo.twig', 1); $node = new ImportNode($macro, new AssignTemplateVariable(new TemplateVariable('macro', 1), true), 1); $tests[] = [$node, <<macros["macro"] = \$this->load("foo.twig", 1)->unwrap(); EOF ]; return $tests; } } ================================================ FILE: tests/Node/IncludeTest.php ================================================ assertFalse($node->hasNode('variables')); $this->assertEquals($expr, $node->getNode('expr')); $this->assertFalse($node->getAttribute('only')); $vars = new ArrayExpression([new ConstantExpression('foo', 1), new ConstantExpression(true, 1)], 1); $node = new IncludeNode($expr, $vars, true, false, 1); $this->assertEquals($vars, $node->getNode('variables')); $this->assertTrue($node->getAttribute('only')); } public static function provideTests(): iterable { $tests = []; $expr = new ConstantExpression('foo.twig', 1); $node = new IncludeNode($expr, null, false, false, 1); $tests[] = [$node, <<<'EOF' // line 1 yield from $this->load("foo.twig", 1)->unwrap()->yield($context); EOF ]; $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, <<<'EOF' // line 1 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, <<<'EOF' // line 1 yield from $this->load("foo.twig", 1)->unwrap()->yield(CoreExtension::merge($context, ["foo" => true])); EOF ]; $node = new IncludeNode($expr, $vars, true, false, 1); $tests[] = [$node, <<<'EOF' // line 1 yield from $this->load("foo.twig", 1)->unwrap()->yield(CoreExtension::toArray(["foo" => true])); EOF ]; $node = new IncludeNode($expr, $vars, true, true, 1); $tests[] = [$node, <<load("foo.twig", 1); } catch (LoaderError \$e) { // ignore missing template \$_v%s = null; } if (\$_v%s) { yield from \$_v%s->unwrap()->yield(CoreExtension::toArray(["foo" => true])); } EOF, null, true]; return $tests; } } ================================================ FILE: tests/Node/MacroTest.php ================================================ assertEquals($body, $node->getNode('body')); $this->assertEquals($arguments, $node->getNode('arguments')); $this->assertEquals('foo', $node->getAttribute('name')); } public static function provideTests(): iterable { $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); yield 'with use_yield = true' => [$node, <<macros; \$context = [ "foo" => \$foo, "bar" => \$bar, "_underscore" => \$_underscore, "varargs" => \$varargs, ] + \$this->env->getGlobals(); \$blocks = []; 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]), ]; 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]), ]; } } ================================================ FILE: tests/Node/ModuleTest.php ================================================ assertEquals($body, $node->getNode('body')); $this->assertEquals($blocks, $node->getNode('blocks')); $this->assertEquals($macros, $node->getNode('macros')); $this->assertEquals($parent, $node->getNode('parent')); $this->assertEquals($source->getName(), $node->getTemplateName()); } public static function provideTests(): iterable { $twig = new Environment(new ArrayLoader(['foo.twig' => '{{ foo }}'])); $tests = []; $body = new BodyNode([new TextNode('foo', 1)]); $extends = null; $blocks = new EmptyNode(); $macros = new EmptyNode(); $traits = new EmptyNode(); $source = new Source('{{ foo }}', 'foo.twig'); $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new EmptyNode(), $source); $tests[] = [$node, << */ private array \$macros = []; public function __construct(Environment \$env) { parent::__construct(\$env); \$this->source = \$this->getSourceContext(); \$this->parent = false; \$this->blocks = [ ]; } protected function doDisplay(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; // line 1 yield "foo"; yield from []; } /** * @codeCoverageIgnore */ public function getTemplateName(): string { return "foo.twig"; } /** * @codeCoverageIgnore */ public function getDebugInfo(): array { return array ( 42 => 1,); } public function getSourceContext(): Source { return new Source("", "foo.twig", ""); } } EOF, $twig, true]; $import = new ImportNode(new ConstantExpression('foo.twig', 1), new AssignTemplateVariable(new TemplateVariable('macro', 2), true), 2); $body = new BodyNode([$import]); $extends = new ConstantExpression('layout.twig', 1); $node = new ModuleNode($body, $extends, $blocks, $macros, $traits, new EmptyNode(), $source); $tests[] = [$node, << */ private array \$macros = []; public function __construct(Environment \$env) { parent::__construct(\$env); \$this->source = \$this->getSourceContext(); \$this->blocks = [ ]; } protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper { // line 1 return "layout.twig"; } protected function doDisplay(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; // line 2 \$macros["macro"] = \$this->macros["macro"] = \$this->load("foo.twig", 2)->unwrap(); // line 1 \$this->parent = \$this->load("layout.twig", 1); yield from \$this->parent->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks)); } /** * @codeCoverageIgnore */ public function getTemplateName(): string { return "foo.twig"; } /** * @codeCoverageIgnore */ public function isTraitable(): bool { return false; } /** * @codeCoverageIgnore */ public function getDebugInfo(): array { return array ( 48 => 1, 46 => 2, 39 => 1,); } public function getSourceContext(): Source { return new Source("", "foo.twig", ""); } } EOF, $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) { parent::__construct(\$env); \$this->source = \$this->getSourceContext(); \$this->blocks = [ ]; } protected function doGetParent(array \$context): bool|string|Template|TemplateWrapper { // line 2 return \$this->load(((true) ? ("foo") : ("foo")), 2); } protected function doDisplay(array \$context, array \$blocks = []): iterable { \$macros = \$this->macros; // line 4 \$context["foo"] = "foo"; // line 2 yield from \$this->getParent(\$context)->unwrap()->yield(\$context, array_merge(\$this->blocks, \$blocks)); } /** * @codeCoverageIgnore */ public function getTemplateName(): string { return "foo.twig"; } /** * @codeCoverageIgnore */ public function isTraitable(): bool { return false; } /** * @codeCoverageIgnore */ public function getDebugInfo(): array { return array ( 48 => 2, 46 => 4, 39 => 2,); } public function getSourceContext(): Source { return new Source("{{ foo }}", "foo.twig", ""); } } EOF, $twig, true]; return $tests; } } ================================================ FILE: tests/Node/NodeTest.php ================================================ static 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 { } ================================================ FILE: tests/Node/PrintTest.php ================================================ assertEquals($expr, $node->getNode('expr')); } public static function provideTests(): iterable { $tests = []; $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; } } ================================================ FILE: tests/Node/SandboxTest.php ================================================ assertEquals($body, $node->getNode('body')); } public static function provideTests(): iterable { $tests = []; $body = new TextNode('foo', 1); $node = new SandboxNode($body, 1); $tests[] = [$node, <<sandbox->isSandboxed()) { \$this->sandbox->enableSandbox(); } try { yield "foo"; } finally { if (!\$alreadySandboxed) { \$this->sandbox->disableSandbox(); } } EOF ]; return $tests; } } ================================================ FILE: tests/Node/SetTest.php ================================================ assertEquals($names, $node->getNode('names')); $this->assertEquals($values, $node->getNode('values')); $this->assertFalse($node->getAttribute('capture')); } public static function provideTests(): iterable { $tests = []; $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()); 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 Nodes([new AssignContextVariable('foo', 1)], 1); $values = new TextNode('foo', 1); $node = new SetNode(true, $names, $values, 1); $tests[] = [$node, <<env->getCharset()); EOF ]; $names = new Nodes([new AssignContextVariable('foo', 1)], 1); $values = new TextNode('', 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 ]; return $tests; } } ================================================ FILE: tests/Node/TextTest.php ================================================ assertEquals('foo', $node->getAttribute('data')); } public static function provideTests(): iterable { $tests = []; $tests[] = [new TextNode('foo', 1), "// line 1\nyield \"foo\";"]; return $tests; } } ================================================ FILE: tests/Node/TypesTest.php ================================================ [ '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), '', ], ]; } } ================================================ FILE: tests/NodeVisitor/OptimizerTest.php ================================================ expectNotToPerformAssertions(); new OptimizerNodeVisitor(OptimizerNodeVisitor::OPTIMIZE_FOR); } public function testRenderBlockOptimizer() { $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'); $this->assertInstanceOf(BlockReferenceExpression::class, $node); $this->assertTrue($node->getAttribute('output')); } public function testRenderParentBlockOptimizer() { $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'); $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 getTestsForForLoopOptimizer */ public function testForLoopOptimizer($template, $expected) { $env = new Environment(new ArrayLoader(), ['cache' => false]); $stream = $env->parse($env->tokenize(new Source($template, 'index'))); foreach ($expected as $target => $withLoop) { $this->assertTrue($this->checkForLoopConfiguration($stream, $target, $withLoop), \sprintf('variable %s is %soptimized', $target, $withLoop ? 'not ' : '')); } } public static function getTestsForForLoopOptimizer() { return [ ['{% for i in foo %}{% endfor %}', ['i' => false]], ['{% for i in foo %}{{ loop.index }}{% endfor %}', ['i' => true]], ['{% for i in foo %}{% for j in foo %}{% endfor %}{% endfor %}', ['i' => false, 'j' => false]], ['{% for i in foo %}{% include "foo" %}{% endfor %}', ['i' => true]], ['{% for i in foo %}{% include "foo" only %}{% endfor %}', ['i' => false]], ['{% for i in foo %}{% include "foo" with { "foo": "bar" } only %}{% endfor %}', ['i' => false]], ['{% for i in foo %}{% include "foo" with { "foo": loop.index } only %}{% endfor %}', ['i' => true]], ['{% for i in foo %}{% for j in foo %}{{ loop.index }}{% endfor %}{% endfor %}', ['i' => false, 'j' => true]], ['{% for i in foo %}{% for j in foo %}{{ loop.parent.loop.index }}{% endfor %}{% endfor %}', ['i' => true, 'j' => true]], ['{% for i in foo %}{% set l = loop %}{% for j in foo %}{{ l.index }}{% endfor %}{% endfor %}', ['i' => true, 'j' => false]], ['{% for i in foo %}{% for j in foo %}{{ foo.parent.loop.index }}{% endfor %}{% endfor %}', ['i' => false, 'j' => false]], ['{% for i in foo %}{% for j in foo %}{{ loop["parent"].loop.index }}{% endfor %}{% endfor %}', ['i' => true, 'j' => true]], ['{% for i in foo %}{{ include("foo") }}{% endfor %}', ['i' => true]], ['{% for i in foo %}{{ include("foo", with_context = false) }}{% endfor %}', ['i' => false]], ['{% for i in foo %}{{ include("foo", with_context = true) }}{% endfor %}', ['i' => true]], ['{% for i in foo %}{{ include("foo", { "foo": "bar" }, with_context = false) }}{% endfor %}', ['i' => false]], ['{% for i in foo %}{{ include("foo", { "foo": loop.index }, with_context = false) }}{% endfor %}', ['i' => true]], ]; } public function checkForLoopConfiguration(Node $node, $target, $withLoop) { foreach ($node as $n) { if ($n instanceof ForNode) { if ($target === $n->getNode('value_target')->getAttribute('name')) { return $withLoop == $n->getAttribute('with_loop'); } } $ret = $this->checkForLoopConfiguration($n, $target, $withLoop); if (null !== $ret) { return $ret; } } } } ================================================ FILE: tests/NodeVisitor/SandboxTest.php ================================================ 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'))); } } ================================================ FILE: tests/ParserTest.php ================================================ expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "foo" tag. Did you mean "for" at line 1?'); $parser->parse($stream); } public function testUnknownTagWithoutSuggestions() { $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), ], new Source('', '')); $parser = new Parser(new Environment(new ArrayLoader())); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Unknown "foobar" tag at line 1.'); $parser->parse($stream); } /** * @dataProvider getFilterBodyNodesData */ public function testFilterBodyNodes($input, $expected) { $parser = $this->getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); $this->assertEquals($expected, $m->invoke($parser, $input)); } public static function getFilterBodyNodesData() { return [ [ new Nodes([new TextNode(' ', 1)]), new Nodes([]), ], [ $input = new Nodes([new SetNode(false, new EmptyNode(), new EmptyNode(), 1)]), $input, ], [ $input = new Nodes([new SetNode(true, new EmptyNode(), new Nodes([new Nodes([new TextNode('foo', 1)])]), 1)]), $input, ], ]; } /** * @dataProvider getFilterBodyNodesDataThrowsException */ public function testFilterBodyNodesThrowsException($input) { $parser = $this->getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); $this->expectException(SyntaxError::class); $m->invoke($parser, $input); } public static function getFilterBodyNodesDataThrowsException() { return [ [new TextNode('foo', 1)], [new Nodes([new Nodes([new TextNode('foo', 1)])])], ]; } /** * @dataProvider getFilterBodyNodesWithBOMData */ public function testFilterBodyNodesWithBOM($emptyNode) { $parser = $this->getParser(); $m = new \ReflectionMethod($parser, 'filterBodyNodes'); $this->assertNull($m->invoke($parser, new TextNode(\chr(0xEF).\chr(0xBB).\chr(0xBF).$emptyNode, 1))); } public static function getFilterBodyNodesWithBOMData() { return [ [' '], ["\t"], ["\n"], ["\n\t\n "], ]; } public function testParseIsReentrant() { $twig = new Environment(new ArrayLoader(), [ 'autoescape' => false, 'optimizations' => 0, ]); $twig->addTokenParser(new TestTokenParser()); $parser = new Parser($twig); $parser->parse(new TokenStream([ new Token(Token::BLOCK_START_TYPE, '', 1), new Token(Token::NAME_TYPE, 'test', 1), new Token(Token::BLOCK_END_TYPE, '', 1), new Token(Token::VAR_START_TYPE, '', 1), new Token(Token::NAME_TYPE, 'foo', 1), new Token(Token::VAR_END_TYPE, '', 1), new Token(Token::EOF_TYPE, '', 1), ], new Source('', ''))); $p = new \ReflectionProperty($parser, 'parent'); $this->assertNull($p->getValue($parser)); } public function testGetVarName() { $twig = new Environment(new ArrayLoader(), [ 'autoescape' => false, 'optimizations' => 0, ]); $twig->parse($twig->tokenize(new Source(<<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(new ArrayLoader())); $parser->setParent(new ConstantExpression('base.html', 1)); $p = new \ReflectionProperty($parser, 'stream'); $p->setValue($parser, new TokenStream([], new Source('', ''))); return $parser; } } class TestTokenParser extends AbstractTokenParser { public function parse(Token $token): Node { // simulate the parsing of another template right in the middle of the parsing of the current template $this->parser->parse(new TokenStream([ new Token(Token::BLOCK_START_TYPE, '', 1), new Token(Token::NAME_TYPE, 'extends', 1), 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 EmptyNode(1); } public function getTag(): string { return 'test'; } } ================================================ FILE: tests/Profiler/Dumper/BlackfireTest.php ================================================ assertStringMatchesFormat(<<index.twig//1 %d %d %d index.twig==>embedded.twig::block(body)//1 %d %d 0 index.twig==>embedded.twig//2 %d %d %d embedded.twig==>included.twig//2 %d %d %d index.twig==>index.twig::macro(foo)//1 %d %d %d EOF, $dumper->dump($this->getProfile())); } } ================================================ FILE: tests/Profiler/Dumper/HtmlTest.php ================================================ assertStringMatchesFormat(<<main %d.%dms/%d%index.twig %d.%dms/%d% └ embedded.twig::block(body) └ embedded.twig │ └ included.twig └ index.twig::macro(foo) └ embedded.twigincluded.twig EOF, $dumper->dump($this->getProfile())); } } ================================================ FILE: tests/Profiler/Dumper/ProfilerTestCase.php ================================================ getIndexProfile( [ $this->getEmbeddedBlockProfile(), $this->getEmbeddedTemplateProfile( [ $this->getIncludedTemplateProfile(), ] ), $this->getMacroProfile(), $this->getEmbeddedTemplateProfile( [ $this->getIncludedTemplateProfile(), ] ), ] ), ]; $p = new \ReflectionProperty($profile, 'profiles'); $p->setValue($profile, $subProfiles); return $profile; } private function getIndexProfile(array $subProfiles = []) { return $this->generateProfile('main', 1, 'template', 'index.twig', $subProfiles); } private function getEmbeddedBlockProfile(array $subProfiles = []) { return $this->generateProfile('body', 0.0001, 'block', 'embedded.twig', $subProfiles); } private function getEmbeddedTemplateProfile(array $subProfiles = []) { return $this->generateProfile('main', 0.0001, 'template', 'embedded.twig', $subProfiles); } private function getIncludedTemplateProfile(array $subProfiles = []) { return $this->generateProfile('main', 0.0001, 'template', 'included.twig', $subProfiles); } private function getMacroProfile(array $subProfiles = []) { return $this->generateProfile('foo', 0.0001, 'macro', 'index.twig', $subProfiles); } /** * @param string $name * @param float $duration * @param string $type * @param string $templateName * * @return Profile */ private function generateProfile($name, $duration, $type, $templateName, array $subProfiles = []) { $profile = new Profile($templateName, $type, $name); $p = new \ReflectionProperty($profile, 'profiles'); $p->setValue($profile, $subProfiles); $starts = new \ReflectionProperty($profile, 'starts'); $starts->setValue($profile, [ 'wt' => 0, 'mu' => 0, 'pmu' => 0, ]); $ends = new \ReflectionProperty($profile, 'ends'); $ends->setValue($profile, [ 'wt' => $duration, 'mu' => 0, 'pmu' => 0, ]); return $profile; } } ================================================ FILE: tests/Profiler/Dumper/TextTest.php ================================================ assertStringMatchesFormat(<<dump($this->getProfile())); } } ================================================ FILE: tests/Profiler/ProfileTest.php ================================================ assertEquals('template', $profile->getTemplate()); $this->assertEquals('type', $profile->getType()); $this->assertEquals('name', $profile->getName()); } public function testIsRoot() { $profile = new Profile('template', Profile::ROOT); $this->assertTrue($profile->isRoot()); $profile = new Profile('template', Profile::TEMPLATE); $this->assertFalse($profile->isRoot()); } public function testIsTemplate() { $profile = new Profile('template', Profile::TEMPLATE); $this->assertTrue($profile->isTemplate()); $profile = new Profile('template', Profile::ROOT); $this->assertFalse($profile->isTemplate()); } public function testIsBlock() { $profile = new Profile('template', Profile::BLOCK); $this->assertTrue($profile->isBlock()); $profile = new Profile('template', Profile::ROOT); $this->assertFalse($profile->isBlock()); } public function testIsMacro() { $profile = new Profile('template', Profile::MACRO); $this->assertTrue($profile->isMacro()); $profile = new Profile('template', Profile::ROOT); $this->assertFalse($profile->isMacro()); } public function testGetAddProfile() { $profile = new Profile(); $profile->addProfile($a = new Profile()); $profile->addProfile($b = new Profile()); $this->assertSame([$a, $b], $profile->getProfiles()); $this->assertSame([$a, $b], iterator_to_array($profile)); } public function testGetDuration() { $profile = new Profile(); usleep(1); $profile->leave(); $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() { $profile = new Profile('template', 'type', 'name'); $profile1 = new Profile('template1', 'type1', 'name1'); $profile->addProfile($profile1); $profile->leave(); $profile1->leave(); $profile2 = unserialize(serialize($profile)); $profiles = $profile->getProfiles(); $this->assertCount(1, $profiles); $profile3 = $profiles[0]; $this->assertEquals($profile->getTemplate(), $profile2->getTemplate()); $this->assertEquals($profile->getType(), $profile2->getType()); $this->assertEquals($profile->getName(), $profile2->getName()); $this->assertEquals($profile->getDuration(), $profile2->getDuration()); $this->assertEquals($profile1->getTemplate(), $profile3->getTemplate()); $this->assertEquals($profile1->getType(), $profile3->getType()); $this->assertEquals($profile1->getName(), $profile3->getName()); } public function testReset() { $profile = new Profile(); usleep(1); $profile->leave(); $profile->reset(); $this->assertEquals(0, $profile->getDuration()); } } ================================================ FILE: tests/Runtime/EscaperRuntimeTest.php ================================================ ''', '"' => '"', '<' => '<', '>' => '>', '&' => '&', ]; 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 testHtmlAttributeRelaxedEscapingConvertsSpecialChars() { foreach ($this->htmlAttrSpecialChars as $key => $value) { $this->assertEquals($value, (new EscaperRuntime())->escape($key, 'html_attr_relaxed'), 'Failed to escape: '.$key); } } public function testJavascriptEscapingConvertsSpecialChars() { foreach ($this->jsSpecialChars as $key => $value) { $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 testHtmlAttributeRelaxedEscapingEscapesOwaspRecommendedRanges() { $immune = [',', '.', '-', '_', ':', '@', '[', ']']; // Exceptions to escaping ranges for ($chr = 0; $chr < 0xFF; ++$chr) { if ($chr >= 0x30 && $chr <= 0x39 || $chr >= 0x41 && $chr <= 0x5A || $chr >= 0x61 && $chr <= 0x7A) { $literal = $this->codepointToUtf8($chr); $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed')); } else { $literal = $this->codepointToUtf8($chr); if (\in_array($literal, $immune)) { $this->assertEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed')); } else { $this->assertNotEquals($literal, (new EscaperRuntime())->escape($literal, 'html_attr_relaxed'), "$literal should be escaped!"); } } } } public function testCssEscapingEscapesOwaspRecommendedRanges() { // 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 '
    '; } } ================================================ FILE: tests/TemplateTest.php ================================================ expectException(\LogicException::class); $template->displayBlock('foo', [], ['foo' => [new \stdClass(), 'foo']]); } /** * @dataProvider getAttributeExceptions */ public function testGetAttributeExceptions($template, $message) { $templates = ['index' => $template]; $env = new Environment(new ArrayLoader($templates), ['strict_variables' => true]); $template = $env->load('index'); $context = [ 'string' => 'foo', 'null' => null, 'empty_array' => [], 'array' => ['foo' => 'foo'], 'array_access' => new TemplateArrayAccessObject(), 'magic_exception' => new TemplateMagicPropertyObjectWithException(), 'object' => new \stdClass(), ]; try { $template->render($context); $this->fail('Accessing an invalid attribute should throw an exception.'); } catch (RuntimeError $e) { $this->assertSame(\sprintf($message, 'index'), $e->getMessage()); } } 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 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 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.'], ['{{ object["a"] }}', 'Impossible to access a key "a" on an object of class "stdClass" that does not implement ArrayAccess interface in "%s" at line 1.'], ]; } /** * @dataProvider getGetAttributeWithSandbox */ public function testGetAttributeWithSandbox($object, $item, $allowed) { $twig = new Environment(new ArrayLoader()); $policy = new SecurityPolicy([], [], [/* method */], [/* prop */], []); $twig->addExtension(new SandboxExtension($policy, !$allowed)); $template = new TemplateForTest($twig); try { CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, [], 'any', false, false, true); if (!$allowed) { $this->fail(); } else { $this->addToAssertionCount(1); } } catch (SecurityError $e) { if ($allowed) { $this->fail(); } else { $this->addToAssertionCount(1); } $this->assertStringContainsString('is not allowed', $e->getMessage()); } } public static function getGetAttributeWithSandbox() { return [ [new TemplatePropertyObject(), 'defined', false], [new TemplatePropertyObject(), 'defined', true], [new TemplateMethodObject(), 'defined', false], [new TemplateMethodObject(), 'defined', true], ]; } /** * @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 %}'], ]; } /** * @dataProvider getNullCoalesceWithImportedMacroData */ public function testNullCoalesceWithImportedMacro(array $templates, string $expected) { $twig = new Environment(new ArrayLoader($templates)); $this->assertSame($expected, trim($twig->render('index.twig'))); } public static function getNullCoalesceWithImportedMacroData(): array { return [ 'from import' => [ [ 'index.twig' => '{% from "helper.twig" import foo %}{{ foo("bar") ?? "" }}', 'helper.twig' => '{% macro foo(param) %}{{ param }}{% endmacro %}', ], 'bar', ], 'from import with undefined macro falls back' => [ [ 'index.twig' => '{% from "helper.twig" import foo, nonexistent %}{{ nonexistent("bar") ?? "fallback" }}', 'helper.twig' => '{% macro foo(param) %}{{ param }}{% endmacro %}', ], 'fallback', ], 'from import used multiple times' => [ [ 'index.twig' => '{% from "helper.twig" import foo %}{{ foo("a") ?? "" }}-{{ foo("b") ?? "" }}', 'helper.twig' => '{% macro foo(param) %}{{ param }}{% endmacro %}', ], 'a-b', ], ]; } public function testRenderBlockWithUndefinedBlock() { $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig, 'index.twig'); $this->expectException(RuntimeError::class); $this->expectExceptionMessage('Block "unknown" on template "index.twig" does not exist in "index.twig".'); $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".'); $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"'); $template->displayBlock('foo', [], ['foo' => [new TemplateForTest($twig, 'index.twig'), 'block_foo']], false); } public function testGetAttributeOnArrayWithConfusableKey() { $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); $array = ['Zero', 'One', -1 => 'MinusOne', '' => 'EmptyString', '1.5' => 'FloatButString', '01' => 'IntegerButStringWithLeadingZeros']; $this->assertSame('Zero', $array[false]); $this->assertSame('One', $array[true]); if (\PHP_VERSION_ID < 80100) { // This line will trigger a deprecation warning on PHP 8.1. $this->assertSame('One', $array[1.5]); } $this->assertSame('One', $array['1']); if (\PHP_VERSION_ID < 80100) { // This line will trigger a deprecation warning on PHP 8.1. $this->assertSame('MinusOne', $array[-1.5]); } $this->assertSame('FloatButString', $array['1.5']); $this->assertSame('IntegerButStringWithLeadingZeros', $array['01']); $this->assertSame('EmptyString', $array['']); $this->assertSame('Zero', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, false), 'false is treated as 0 when accessing a sequence/mapping (equals PHP behavior)'); $this->assertSame('One', CoreExtension::getAttribute($twig, $template->getSourceContext(), $array, true), 'true is treated as 1 when accessing a sequence/mapping (equals PHP behavior)'); $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)'); } /** * @dataProvider getGetAttributeTests */ public function testGetAttribute($defined, $value, $object, $item, $arguments, $type) { $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); $this->assertEquals($value, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); } /** * @dataProvider getGetAttributeTests */ public function testGetAttributeStrict($defined, $value, $object, $item, $arguments, $type, $exceptionMessage = null) { $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); $template = new TemplateForTest($twig); if ($defined) { $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, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type)); } } /** * @dataProvider getGetAttributeTests */ public function testGetAttributeDefined($defined, $value, $object, $item, $arguments, $type) { $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); $this->assertEquals($defined, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); } /** * @dataProvider getGetAttributeTests */ public function testGetAttributeDefinedStrict($defined, $value, $object, $item, $arguments, $type) { $twig = new Environment(new ArrayLoader(), ['strict_variables' => true]); $template = new TemplateForTest($twig); $this->assertEquals($defined, CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, $item, $arguments, $type, true)); } public function testGetAttributeCallExceptions() { $twig = new Environment(new ArrayLoader()); $template = new TemplateForTest($twig); $object = new TemplateMagicMethodExceptionObject(); $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $object, 'foo')); } public static function getGetAttributeTests() { $array = [ 'defined' => 'defined', 'zero' => 0, 'null' => null, '1' => 1, 'bar' => true, 'foo' => true, 'baz' => 'baz', 'baf' => 'baf', '09' => '09', '+4' => '+4', ]; $objectArray = new TemplateArrayAccessObject(); $arrayObject = new \ArrayObject($array); $stdObject = (object) $array; $magicPropertyObject = new TemplateMagicPropertyObject(); $propertyObject = new TemplatePropertyObject(); $propertyObject1 = new TemplatePropertyObjectAndIterator(); $propertyObject2 = new TemplatePropertyObjectAndArrayAccess(); $propertyObject3 = new TemplatePropertyObjectDefinedWithUndefinedValue(); $methodObject = new TemplateMethodObject(); $magicMethodObject = new TemplateMagicMethodObject(); $anyType = Template::ANY_CALL; $methodType = Template::METHOD_CALL; $arrayType = Template::ARRAY_CALL; $basicTests = [ // array(defined, value, property to fetch) [true, 'defined', 'defined'], [false, null, 'undefined'], [false, null, 'protected'], [true, 0, 'zero'], [true, 1, 1], [true, 1, 1.0], [true, null, 'null'], [true, true, 'bar'], [true, true, 'foo'], [true, 'baz', 'baz'], [true, 'baf', 'baf'], [true, '09', '09'], [true, '+4', '+4'], ]; $testObjects = [ // array(object, type of fetch) [$array, $arrayType], [$objectArray, $arrayType], [$arrayObject, $anyType], [$stdObject, $anyType], [$magicPropertyObject, $anyType], [$methodObject, $methodType], [$methodObject, $anyType], [$propertyObject, $anyType], [$propertyObject1, $anyType], [$propertyObject2, $anyType], ]; $tests = []; foreach ($testObjects as $testObject) { foreach ($basicTests as $test) { // properties cannot be numbers if (($testObject[0] instanceof \stdClass || $testObject[0] instanceof TemplatePropertyObject) && is_numeric($test[2])) { continue; } if ('+4' === $test[2] && $methodObject === $testObject[0]) { continue; } $tests[] = [$test[0], $test[1], $testObject[0], $test[2], [], $testObject[1]]; } } // additional properties tests $tests = array_merge($tests, [ [true, null, $propertyObject3, 'foo', [], $anyType], ]); // additional method tests $tests = array_merge($tests, [ [true, 'defined', $methodObject, 'defined', [], $methodType], [true, 'defined', $methodObject, 'DEFINED', [], $methodType], [true, 'defined', $methodObject, 'getDefined', [], $methodType], [true, 'defined', $methodObject, 'GETDEFINED', [], $methodType], [true, 'static', $methodObject, 'static', [], $methodType], [true, 'static', $methodObject, 'getStatic', [], $methodType], [true, '__call_undefined', $magicMethodObject, 'undefined', [], $methodType], [true, '__call_UNDEFINED', $magicMethodObject, 'UNDEFINED', [], $methodType], ]); // add the same tests for the any type foreach ($tests as $test) { if ($anyType !== $test[5]) { $test[5] = $anyType; $tests[] = $test; } } $methodAndPropObject = new TemplateMethodAndPropObject(); // additional method tests $tests = array_merge($tests, [ [true, 'a', $methodAndPropObject, 'a', [], $anyType], [true, 'a', $methodAndPropObject, 'a', [], $methodType], [false, null, $methodAndPropObject, 'a', [], $arrayType], [true, 'b_prop', $methodAndPropObject, 'b', [], $anyType], [true, 'b', $methodAndPropObject, 'B', [], $anyType], [true, 'b', $methodAndPropObject, 'b', [], $methodType], [true, 'b', $methodAndPropObject, 'B', [], $methodType], [false, null, $methodAndPropObject, 'b', [], $arrayType], [false, null, $methodAndPropObject, 'c', [], $anyType], [false, null, $methodAndPropObject, 'c', [], $methodType], [false, null, $methodAndPropObject, 'c', [], $arrayType], ]); $arrayAccess = new TemplateArrayAccess(); $tests = array_merge($tests, [ [true, ['foo' => 'bar'], $arrayAccess, 'vars', [], $anyType], ]); // test for Closure::__invoke() $tests[] = [true, 'closure called', static fn (): string => 'closure called', '__invoke', [], $anyType]; $tests[] = [true, 'closure called', static fn (): string => 'closure called', '__invoke', [], $methodType]; // tests when input is not an array or object $tests = array_merge($tests, [ [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 sequence/mapping is empty in "index.twig".'], ]); return $tests; } public function testGetIsMethods() { $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(CoreExtension::getAttribute($twig, $template->getSourceContext(), $getIsObject, 'get')); // 0 should be in the method cache now, so this should fail $this->assertNull(CoreExtension::getAttribute($twig, $template->getSourceContext(), $getIsObject, 0)); } } class TemplateForTest extends Template { private $name; public function __construct(Environment $env, $name = 'index.twig') { parent::__construct($env); $this->name = $name; } public function getZero() { return 0; } public function getEmpty() { return ''; } public function getString() { return 'some_string'; } public function getTrue() { return true; } public function getTemplateName(): string { return $this->name; } public function getDebugInfo(): array { return []; } public function getSourceContext(): Source { return new Source('', $this->getTemplateName()); } protected function doGetParent(array $context): bool|string|Template|TemplateWrapper { return false; } protected function doDisplay(array $context, array $blocks = []): iterable { } public function block_name($context, array $blocks = []) { } } class TemplateArrayAccessObject implements \ArrayAccess { protected $protected = 'protected'; public $attributes = [ 'defined' => 'defined', 'zero' => 0, 'null' => null, '1' => 1, 'bar' => true, 'foo' => true, 'baz' => 'baz', 'baf' => 'baf', '09' => '09', '+4' => '+4', ]; public function offsetExists($name): bool { return \array_key_exists($name, $this->attributes); } #[\ReturnTypeWillChange] public function offsetGet($name) { return \array_key_exists($name, $this->attributes) ? $this->attributes[$name] : null; } public function offsetSet($name, $value): void { } public function offsetUnset($name): void { } } class TemplateMagicPropertyObject { public $defined = 'defined'; public $attributes = [ 'zero' => 0, 'null' => null, '1' => 1, 'bar' => true, 'foo' => true, 'baz' => 'baz', 'baf' => 'baf', '09' => '09', '+4' => '+4', ]; protected $protected = 'protected'; public function __isset($name): bool { return \array_key_exists($name, $this->attributes); } public function __get($name) { return \array_key_exists($name, $this->attributes) ? $this->attributes[$name] : null; } } class TemplateMagicPropertyObjectWithException { public function __isset($key): bool { throw new \Exception('Hey! Don\'t try to isset me!'); } } class TemplatePropertyObject { public $defined = 'defined'; public $zero = 0; public $null; public $bar = true; public $foo = true; public $baz = 'baz'; public $baf = 'baf'; protected $protected = 'protected'; } class TemplatePropertyObjectAndIterator extends TemplatePropertyObject implements \IteratorAggregate { public function getIterator(): \Traversable { return new \ArrayIterator(['foo', 'bar']); } } class TemplatePropertyObjectAndArrayAccess extends TemplatePropertyObject implements \ArrayAccess { private $data = [ 'defined' => 'defined', 'zero' => 0, 'null' => null, 'bar' => true, 'foo' => true, 'baz' => 'baz', 'baf' => 'baf', ]; public function offsetExists($offset): bool { return \array_key_exists($offset, $this->data); } #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->offsetExists($offset) ? $this->data[$offset] : 'n/a'; } public function offsetSet($offset, $value): void { } public function offsetUnset($offset): void { } } class TemplatePropertyObjectDefinedWithUndefinedValue { public $foo; public function __construct() { $this->foo = @$notExist; } } class TemplateMethodObject { public function getDefined() { return 'defined'; } public function get1() { return 1; } public function get09() { return '09'; } public function getZero() { return 0; } public function getNull() { } public function isBar() { return true; } public function hasFoo() { return true; } public function hasBaz() { return 'should never be returned (has)'; } public function isBaz() { return 'should never be returned (is)'; } public function getBaz() { return 'Baz'; } public function baz() { return 'baz'; } public function hasBaf() { return 'should never be returned (has)'; } public function isBaf() { return 'baf'; } protected function getProtected() { return 'protected'; } public static function getStatic() { return 'static'; } } class TemplateGetIsMethods { public function get() { } public function is() { } } class TemplateMethodAndPropObject { private $a = 'a_prop'; public function getA() { return 'a'; } public $b = 'b_prop'; public function getB() { return 'b'; } private $c = 'c_prop'; private function getC() { return 'c'; } } class TemplateArrayAccess implements \ArrayAccess { public $vars = [ 'foo' => 'bar', ]; private $children = []; public function offsetExists($offset): bool { return \array_key_exists($offset, $this->children); } #[\ReturnTypeWillChange] public function offsetGet($offset) { return $this->children[$offset]; } public function offsetSet($offset, $value): void { $this->children[$offset] = $value; } public function offsetUnset($offset): void { unset($this->children[$offset]); } } class TemplateMagicMethodObject { public function __call($method, $arguments) { return '__call_'.$method; } } class TemplateMagicMethodExceptionObject { public function __call($method, $arguments) { throw new \BadMethodCallException(\sprintf('Unknown method "%s".', $method)); } } ================================================ FILE: tests/TemplateWrapperTest.php ================================================ '{% block foo %}{% endblock %}', 'index_with_use' => '{% use "imported" %}{% block foo %}{% endblock %}', 'index_with_extends' => '{% extends "extended" %}{% block foo %}{% endblock %}', 'imported' => '{% block imported %}{% endblock %}', 'extended' => '{% block extended %}{% endblock %}', ])); $wrapper = $twig->load('index'); $this->assertTrue($wrapper->hasBlock('foo')); $this->assertFalse($wrapper->hasBlock('bar')); $this->assertEquals(['foo'], $wrapper->getBlockNames()); $wrapper = $twig->load('index_with_use'); $this->assertTrue($wrapper->hasBlock('foo')); $this->assertTrue($wrapper->hasBlock('imported')); $this->assertEquals(['imported', 'foo'], $wrapper->getBlockNames()); $wrapper = $twig->load('index_with_extends'); $this->assertTrue($wrapper->hasBlock('foo')); $this->assertTrue($wrapper->hasBlock('extended')); $this->assertEquals(['foo', 'extended'], $wrapper->getBlockNames()); } public function testRenderBlock() { $twig = new Environment(new ArrayLoader([ 'index' => '{% block foo %}{{ foo }}{{ bar }}{% endblock %}', ])); $twig->addGlobal('bar', 'BAR'); $wrapper = $twig->load('index'); $this->assertEquals('FOOBAR', $wrapper->renderBlock('foo', ['foo' => 'FOO'])); } public function testDisplayBlock() { $twig = new Environment(new ArrayLoader([ 'index' => '{% block foo %}{{ foo }}{{ bar }}{% endblock %}', ])); $twig->addGlobal('bar', 'BAR'); $wrapper = $twig->load('index'); ob_start(); $wrapper->displayBlock('foo', ['foo' => 'FOO']); $this->assertEquals('FOOBAR', ob_get_clean()); } } ================================================ FILE: tests/TokenParser/GuardTokenParserTest.php ================================================ expectNotToPerformAssertions(); $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $env->registerUndefinedFunctionCallback(static fn ($name) => throw new SyntaxError('boom.')); (new Parser($env))->parse($env->tokenize(new Source('{% guard function boom %}{% endguard %}', ''))); } } ================================================ FILE: tests/TokenParser/TypesTokenParserTest.php ================================================ 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], ], ], ]; } } ================================================ FILE: tests/TokenStreamTest.php ================================================ isEOF()) { $token = $stream->next(); $repr[] = $token->getValue(); } $this->assertEquals('1, 2, 3, 4, 5, 6, 7', implode(', ', $repr), '->next() advances the pointer and returns the current token'); } 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'); while (!$stream->isEOF()) { $stream->next(); } } 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'); while (!$stream->isEOF()) { $stream->look(); $stream->next(); } } } ================================================ FILE: tests/Util/CallableArgumentsExtractorTest.php ================================================ 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) { } ================================================ FILE: tests/Util/DeprecationCollectorTest.php ================================================ addFunction(new TwigFunction('deprec', [$this, 'deprec'], ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')])); $collector = new DeprecationCollector($twig); $deprecations = $collector->collect(new Iterator()); $this->assertEquals(['Since foo/bar 1.1: Twig Function "deprec" is deprecated in deprec.twig at line 1.'], $deprecations); } public function deprec() { } } class Iterator implements \IteratorAggregate { public function getIterator(): \Traversable { return new \ArrayIterator([ 'ok.twig' => '{{ foo }}', 'deprec.twig' => '{{ deprec("foo") }}', ]); } } ================================================ FILE: tests/drupal_test.sh ================================================ #!/bin/bash set -x set -e REPO=`pwd` cd /tmp rm -rf 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/.symfony5/bin:$PATH" symfony server:start -d --no-tls curl -LsS -o blackfire-player.phar https://get.blackfire.io/blackfire-player-v1.31.0.phar chmod +x blackfire-player.phar cat > drupal-tests.bkf <