Files
Twig/src/Parser.php
T
Fabien Potencier 6032fd320c Merge branch '3.x' into 4.x
* 3.x:
  Fix version
  Fix CS
  Remove obsolete code
  Use generics in ExpressionParsers
  Fix precedence rules
  Add deprecation notices in CHANGELOG and docs
  Move Operators to ExpressionParsers, deprecate ExpressionParser
  Add a script to update operator precedence documentation
  Extract operators logic from ExpressionParser to their own classes
  Introduce operator classes to describe operators provided by extensions instead of arrays
  Bump version to 3.21
2025-02-14 09:08:33 +01:00

562 lines
19 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\AssignTemplateVariable;
use Twig\Node\MacroNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\NodeCaptureInterface;
use Twig\Node\NodeOutputInterface;
use Twig\Node\Nodes;
use Twig\Node\PrintNode;
use Twig\Node\TextNode;
use Twig\NodeVisitor\NodeVisitorInterface;
use Twig\TokenParser\TokenParserInterface;
use Twig\Util\ReflectionCallable;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
class Parser
{
private array $stack = [];
private TokenStream $stream;
private ?Node $parent = null;
/**
* @var NodeVisitorInterface[]|null
*/
private ?array $visitors = null;
private ?ExpressionParser $expressionParser = null;
/**
* @var Node[]
*/
private array $blocks;
/**
* @var string[]
*/
private array $blockStack;
/**
* @var MacroNode[]
*/
private array $macros;
private array $importedSymbols;
/**
* @var Node[]
*/
private array $traits;
/**
* @var ModuleNode[]
*/
private array $embeddedTemplates = [];
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;
}
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 = [];
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;
}
$node = new ModuleNode(new BodyNode([$body]), $this->parent, new Nodes($this->blocks), new Nodes($this->macros), new Nodes($this->traits), $this->embeddedTemplates, $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
*/
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);
$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);
}
/**
* @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 setMacro(string $name, MacroNode $node): void
{
$this->macros[$name] = $node;
}
public function addTrait($trait): void
{
$this->traits[] = $trait;
}
/**
* @return void
*/
public function embedTemplate(ModuleNode $template)
{
$template->setIndex(mt_rand());
$this->embeddedTemplates[] = $template;
}
public function addImportedSymbol(string $type, string $alias, ?string $name = null, ?AssignTemplateVariable $internalRef = null): void
{
$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 !== $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, fn () => '');
}
$e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->stream->getSourceContext());
$e->addSuggestions($name, array_keys($this->env->getFunctions()));
throw $e;
}
if ($function->isDeprecated()) {
$src = $this->stream->getSourceContext();
$function->triggerDeprecation($src->getPath() ?: $src->getName(), $line);
}
return $function;
}
public function getFilter(string $name, int $line): TwigFilter
{
try {
$filter = $this->env->getFilter($name);
} catch (SyntaxError $e) {
if (!$this->shouldIgnoreUnknownTwigCallables()) {
throw $e;
}
$filter = null;
}
if (!$filter) {
if ($this->shouldIgnoreUnknownTwigCallables()) {
return new TwigFilter($name, fn () => '');
}
$e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->stream->getSourceContext());
$e->addSuggestions($name, array_keys($this->env->getFilters()));
throw $e;
}
if ($filter->isDeprecated()) {
$src = $this->stream->getSourceContext();
$filter->triggerDeprecation($src->getPath() ?: $src->getName(), $line);
}
return $filter;
}
public function getTest(int $line): TwigTest
{
$name = $this->stream->expect(Token::NAME_TYPE)->getValue();
if ($this->stream->test(Token::NAME_TYPE)) {
// try 2-words tests
$name = $name.' '.$this->getCurrentToken()->getValue();
if ($test = $this->env->getTest($name)) {
$this->stream->next();
}
} else {
$test = $this->env->getTest($name);
}
if (!$test) {
if ($this->shouldIgnoreUnknownTwigCallables()) {
return new TwigTest($name, fn () => '');
}
$e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $this->stream->getSourceContext());
$e->addSuggestions($name, array_keys($this->env->getTests()));
throw $e;
}
if ($test->isDeprecated()) {
$src = $this->stream->getSourceContext();
$test->triggerDeprecation($src->getPath() ?: $src->getName(), $this->stream->getCurrent()->getLine());
}
return $test;
}
private function filterBodyNodes(Node $node, bool $nested = false): ?Node
{
// check that the body does not contain non-empty output nodes
if (
($node instanceof TextNode && !ctype_space($node->getAttribute('data')))
|| (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface)
) {
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
$nested = $nested || !$node instanceof Nodes;
foreach ($node as $k => $n) {
if (null === $this->filterBodyNodes($n, $nested)) {
$node->removeNode($k);
}
}
return $node;
}
private function checkPrecedenceDeprecations(ExpressionParserInterface $expressionParser, AbstractExpression $expr)
{
$expr->setAttribute('expression_parser', $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 ($node->hasAttribute('expression_parser') && $ep === $node->getAttribute('expression_parser')) {
$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 ($node->hasAttribute('expression_parser') && $ep === $node->getAttribute('expression_parser') && !$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()));
}
}
}
}
}