mirror of
https://github.com/twigphp/Twig.git
synced 2026-11-10 12:06:46 +00:00
f640320202
* 3.x: (26 commits) Remove the documentation comments compilation overhead Clarify source function trust requirements Throw on PCRE errors in the matches operator Document that reusing a non-rewindable iterator after destructuring is unsupported Release destructuring temporaries after assignment Deprecate prefixed macro definedness checks Fix duplicate macro deprecation wording Throw when list formatting fails Document that sequence destructuring consumes one value per pattern slot Fix the html_attr documentation about iterables in data attributes Warn about untrusted input with the default Tempest markdown converter Document that overriding MacroNode::compile() is not supported anymore Merge overlapping CHANGELOG entries for the destructuring fatal error fix Document that include_only keeps global variables available Remove lazy macro import resolution Honor date formatter prototype calendars Fix Stringable keys for ArrayAccess implementations Fix repeated object destructuring evaluation Restore void return type compatibility for extension points Reject destructuring patterns containing no variables ... # Conflicts: # CHANGELOG # doc/deprecated.rst # doc/filters/format_datetime.rst # extra/twig-extra-bundle/DependencyInjection/Compiler/MissingExtensionSuggestorPass.php # extra/twig-extra-bundle/DependencyInjection/TwigExtraExtension.php # extra/twig-extra-bundle/TwigExtraBundle.php # src/MacroNamespace.php # src/Node/MacrosNode.php # src/Parser.php # src/Test/IntegrationTestCase.php # src/Test/NodeTestCase.php # tests/CallMacroTest.php # tests/ExpressionParserTest.php # tests/Fixtures/macros/duplicate_definition.legacy.test # tests/Node/MacrosTest.php # tests/ParserTest.php
585 lines
20 KiB
PHP
585 lines
20 KiB
PHP
<?php
|
|
|
|
/*
|
|
* This file is part of Twig.
|
|
*
|
|
* (c) Fabien Potencier
|
|
* (c) Armin Ronacher
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
namespace Twig;
|
|
|
|
use Twig\Error\SyntaxError;
|
|
use Twig\ExpressionParser\ExpressionParserInterface;
|
|
use Twig\ExpressionParser\ExpressionParsers;
|
|
use Twig\ExpressionParser\ExpressionParserType;
|
|
use Twig\ExpressionParser\InfixExpressionParserInterface;
|
|
use Twig\ExpressionParser\Prefix\LiteralExpressionParser;
|
|
use Twig\ExpressionParser\PrefixExpressionParserInterface;
|
|
use Twig\Node\BlockNode;
|
|
use Twig\Node\BlockReferenceNode;
|
|
use Twig\Node\BodyNode;
|
|
use Twig\Node\EmptyNode;
|
|
use Twig\Node\Expression\AbstractExpression;
|
|
use Twig\Node\Expression\Variable\AssignMacroVariable;
|
|
use Twig\Node\MacroNode;
|
|
use Twig\Node\MacrosNode;
|
|
use Twig\Node\ModuleNode;
|
|
use Twig\Node\Node;
|
|
use Twig\Node\NodeDocumentation;
|
|
use Twig\Node\Nodes;
|
|
use Twig\Node\PrintNode;
|
|
use Twig\Node\TextNode;
|
|
use Twig\NodeVisitor\NodeVisitorInterface;
|
|
use Twig\TokenParser\TokenParserInterface;
|
|
use Twig\Util\ReflectionCallable;
|
|
|
|
/**
|
|
* @author Fabien Potencier <fabien@symfony.com>
|
|
*/
|
|
class Parser
|
|
{
|
|
private array $stack = [];
|
|
private ?\WeakMap $expressionRefs = null;
|
|
private TokenStream $stream;
|
|
private ?AbstractExpression $parent = null;
|
|
/**
|
|
* @var NodeVisitorInterface[]|null
|
|
*/
|
|
private ?array $visitors = null;
|
|
/**
|
|
* @var Node[]
|
|
*/
|
|
private array $blocks;
|
|
/**
|
|
* @var string[]
|
|
*/
|
|
private array $blockStack;
|
|
/** @var list<Node|null> */
|
|
private array $documentationTargets = [];
|
|
/**
|
|
* @var MacroNode[]
|
|
*/
|
|
private array $macros;
|
|
private array $importedSymbols;
|
|
/**
|
|
* @var Node[]
|
|
*/
|
|
private array $traits;
|
|
/**
|
|
* @var ModuleNode[]
|
|
*/
|
|
private array $embeddedTemplates = [];
|
|
private int $lastEmbedIndex = 0;
|
|
private int $varNameSalt = 0;
|
|
private bool $ignoreUnknownTwigCallables = false;
|
|
private ExpressionParsers $parsers;
|
|
|
|
public function __construct(
|
|
private Environment $env,
|
|
) {
|
|
$this->parsers = $env->getExpressionParsers();
|
|
}
|
|
|
|
public function getEnvironment(): Environment
|
|
{
|
|
return $this->env;
|
|
}
|
|
|
|
/**
|
|
* @throws SyntaxError
|
|
*/
|
|
public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode
|
|
{
|
|
// reset on root parse() calls only, so the counter spans nested/reentrant parses
|
|
if (!$this->stack) {
|
|
$this->lastEmbedIndex = 0;
|
|
}
|
|
|
|
$vars = get_object_vars($this);
|
|
unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['reservedMacroNames'], $vars['lastEmbedIndex'], $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->documentationTargets = [];
|
|
$this->macros = [];
|
|
$this->traits = [];
|
|
$this->blockStack = [];
|
|
$this->importedSymbols = [[]];
|
|
$this->embeddedTemplates = [];
|
|
$this->expressionRefs = new \WeakMap();
|
|
|
|
try {
|
|
$body = $this->subparse($test, $dropNeedle);
|
|
} 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;
|
|
}
|
|
|
|
if ($this->parent) {
|
|
$body = $this->cleanupBodyForChildTemplates($body);
|
|
}
|
|
|
|
$node = new ModuleNode(
|
|
new BodyNode([$body]),
|
|
$this->parent,
|
|
$this->blocks ? new Nodes($this->blocks) : new EmptyNode(),
|
|
new MacrosNode($this->macros),
|
|
$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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @phpstan-impure
|
|
*
|
|
* @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);
|
|
$node = new PrintNode($expr, $token->getLine());
|
|
NodeDocumentation::add($node, $token);
|
|
$rv[] = $node;
|
|
break;
|
|
|
|
case $this->stream->getCurrent()->test(Token::BLOCK_START_TYPE):
|
|
$startToken = $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);
|
|
$documentationTargetIndex = \count($this->documentationTargets);
|
|
$this->documentationTargets[] = null;
|
|
try {
|
|
$node = $subparser->parse($token);
|
|
$documentationTarget = $this->documentationTargets[$documentationTargetIndex];
|
|
} finally {
|
|
array_pop($this->documentationTargets);
|
|
}
|
|
$node->setNodeTag($subparser->getTag());
|
|
NodeDocumentation::add($node, $startToken);
|
|
if (null !== $documentationTarget && $node !== $documentationTarget) {
|
|
NodeDocumentation::move($node, $documentationTarget);
|
|
}
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* @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 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 setDocumentationTarget(Node $node): void
|
|
{
|
|
if (null === $index = array_key_last($this->documentationTargets)) {
|
|
throw new \LogicException('A documentation target can only be set while parsing a tag.');
|
|
}
|
|
if (null !== $this->documentationTargets[$index]) {
|
|
throw new \LogicException('The documentation target for a tag can only be set once.');
|
|
}
|
|
|
|
$this->documentationTargets[$index] = $node;
|
|
}
|
|
|
|
public function setMacro(string $name, MacroNode $node): void
|
|
{
|
|
if (isset($this->macros[$name])) {
|
|
throw new SyntaxError(\sprintf('Macro "%s" is defined twice (first definition at line %d).', $name, $this->macros[$name]->getTemplateLine()), $node->getTemplateLine(), $node->getSourceContext());
|
|
}
|
|
|
|
$this->macros[$name] = $node;
|
|
}
|
|
|
|
public function addTrait($trait): void
|
|
{
|
|
$this->traits[] = $trait;
|
|
}
|
|
|
|
public function embedTemplate(ModuleNode $template): void
|
|
{
|
|
$template->setIndex(++$this->lastEmbedIndex);
|
|
|
|
$this->embeddedTemplates[] = $template;
|
|
}
|
|
|
|
public function addImportedSymbol(string $type, string $alias, ?string $name = null, ?AssignMacroVariable $internalRef = null): void
|
|
{
|
|
$this->importedSymbols[0][$type][$alias] = ['name' => $name, 'node' => $internalRef];
|
|
}
|
|
|
|
/**
|
|
* @return array{name: string, node: AssignMacroVariable|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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @return bool
|
|
*/
|
|
public function hasInheritance()
|
|
{
|
|
return $this->parent || 0 < \count($this->traits);
|
|
}
|
|
|
|
public function setParent(AbstractExpression $parent, bool $throwOnMultiple = true): void
|
|
{
|
|
if (null !== $this->parent) {
|
|
if (!$throwOnMultiple) {
|
|
return;
|
|
}
|
|
|
|
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 cleanupBodyForChildTemplates(Node $body): Node
|
|
{
|
|
if ($body instanceof BlockReferenceNode) {
|
|
return new EmptyNode();
|
|
}
|
|
if ($body instanceof TextNode && $body->isBlank()) {
|
|
return new EmptyNode();
|
|
}
|
|
|
|
foreach ($body as $k => $node) {
|
|
if ($node instanceof BlockReferenceNode) {
|
|
// as it has a parent, the block reference won't be used
|
|
$body->removeNode($k);
|
|
} elseif ($node instanceof TextNode && $node->isBlank()) {
|
|
// remove nodes considered as "empty"
|
|
$body->removeNode($k);
|
|
}
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
private function checkPrecedenceDeprecations(ExpressionParserInterface $expressionParser, AbstractExpression $expr): void
|
|
{
|
|
$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()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|